Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
021f99e0be | ||
|
|
54ffd74048 | ||
|
|
4bc71a3d18 | ||
|
|
d03765c5fd | ||
|
|
f30085bae3 | ||
|
|
adc6af83e3 | ||
|
|
c24440f8c9 | ||
|
|
d8ce2dfdc8 | ||
|
|
28f8542bce | ||
|
|
2e258fa39a | ||
|
|
7dedcca41d | ||
|
|
97d6222297 | ||
|
|
14ddfc97b0 | ||
|
|
b23dbfeae0 | ||
|
|
68679bc0bf | ||
|
|
959a434d21 | ||
|
|
4298397e3e | ||
|
|
fc3fab4905 | ||
|
|
0b54b5c6cd | ||
|
|
358b1deec7 | ||
|
|
9839d48ca0 | ||
|
|
83c2df4390 | ||
|
|
31f6727519 | ||
|
|
96f8bf4a66 | ||
|
|
06bf5e45f5 | ||
|
|
d0fc402683 | ||
|
|
b922654cfc | ||
|
|
d7dce5d7a3 | ||
|
|
5d2aa28570 | ||
|
|
af3c72b055 | ||
|
|
b3e98ac95d | ||
|
|
3f62c8ec6a | ||
|
|
b2dca7b62f | ||
|
|
f731648007 | ||
|
|
a1199f957d | ||
|
|
ea8ad88ae9 | ||
|
|
6e271f834c | ||
|
|
0c21dcdd2a | ||
|
|
9bb95e1f0c | ||
|
|
ccfda938d5 | ||
|
|
cc137e1d06 | ||
|
|
41859f241a | ||
|
|
252067142e | ||
|
|
1152a70afb | ||
|
|
ffd7ea7517 | ||
|
|
a56bff2305 | ||
|
|
db05db0c0c | ||
|
|
872e5f32cb | ||
|
|
d368ec5bd7 | ||
|
|
825bcc5fdb | ||
|
|
69cdf948da | ||
|
|
d337bb235c | ||
|
|
0c0f43174d | ||
|
|
51149d1dd4 | ||
|
|
448fcc4628 | ||
|
|
ee5462d039 | ||
|
|
7ee6d342e9 | ||
|
|
49d3737a93 | ||
|
|
4402e4fbfa | ||
|
|
721f77ed26 | ||
|
|
34f29e5ae6 | ||
|
|
c2170267e8 | ||
|
|
321ca59380 | ||
|
|
e3ecfe6ad8 | ||
|
|
f4664eec16 | ||
|
|
56f601e34b | ||
|
|
1244ff6d93 | ||
|
|
c0ea804c98 | ||
|
|
c21cf29db6 | ||
|
|
3d0da70ce6 | ||
|
|
feb061240b | ||
|
|
a924227805 | ||
|
|
2144d3f8c0 | ||
|
|
fcd6786149 | ||
|
|
ea968ebf3c | ||
|
|
8a7b852974 | ||
|
|
c8d3e3b70c | ||
|
|
7a90a6f9f4 |
No files matched your search
@@ -0,0 +1,22 @@
|
||||
name: Lockfile Check (Client & Server)
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-lockfiles:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Validate with the same node image the production build uses, so a lock
|
||||
# that passes here cannot fail the image build's npm ci.
|
||||
- name: Resolve node image from Dockerfile
|
||||
id: node-image
|
||||
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check server lockfile
|
||||
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
|
||||
|
||||
- name: Check client lockfile
|
||||
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
|
||||
@@ -1,138 +0,0 @@
|
||||
name: POEditor Translation Synchronization (disabled)
|
||||
|
||||
# This workflow is intentionally disabled — kept for reference only.
|
||||
# The full definition is preserved commented-out below. To re-enable,
|
||||
# delete the stub job and uncomment the section starting at `on:`.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
disabled:
|
||||
if: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "This workflow is disabled. See comments below for the original definition."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# on:
|
||||
# # For manual triggering
|
||||
# workflow_dispatch:
|
||||
# inputs:
|
||||
# languages:
|
||||
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
|
||||
# required: false
|
||||
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
|
||||
# format:
|
||||
# description: "Export format (key_value_json or json)"
|
||||
# required: false
|
||||
# default: "key_value_json"
|
||||
|
||||
# permissions:
|
||||
# contents: write
|
||||
# pull-requests: write
|
||||
|
||||
# jobs:
|
||||
# sync-translations:
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: write
|
||||
# issues: write
|
||||
# pull-requests: write
|
||||
|
||||
# steps:
|
||||
# - name: Checkout code
|
||||
# uses: actions/checkout@v3
|
||||
# with:
|
||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# fetch-depth: 0
|
||||
|
||||
# - name: Setup Node.js
|
||||
# uses: actions/setup-node@v3
|
||||
# with:
|
||||
# node-version: "22"
|
||||
|
||||
# - name: Create package.json for scripts
|
||||
# run: |
|
||||
# mkdir -p .github/scripts
|
||||
# cat > .github/scripts/package.json << EOF
|
||||
# {
|
||||
# "name": "poeditor-scripts",
|
||||
# "version": "1.0.0",
|
||||
# "private": true,
|
||||
# "type": "module",
|
||||
# "dependencies": {
|
||||
# "axios": "^1.6.0",
|
||||
# "fs-extra": "^11.1.1"
|
||||
# }
|
||||
# }
|
||||
# EOF
|
||||
|
||||
# - name: Install dependencies
|
||||
# run: |
|
||||
# cd .github/scripts
|
||||
# npm install
|
||||
|
||||
# - name: Download translations from POEditor
|
||||
# env:
|
||||
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
|
||||
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
|
||||
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
|
||||
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
|
||||
# run: |
|
||||
# mkdir -p temp
|
||||
# node .github/scripts/download-translations.js
|
||||
|
||||
# - name: Verify translation files
|
||||
# run: |
|
||||
# echo "Verifying translation files..."
|
||||
# for file in temp/*.json; do
|
||||
# echo "Checking $file"
|
||||
# if [ ! -s "$file" ]; then
|
||||
# echo "Error: $file is empty or does not exist"
|
||||
# exit 1
|
||||
# fi
|
||||
# # Validate JSON format
|
||||
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
|
||||
# done
|
||||
# echo "All translation files are valid"
|
||||
|
||||
# - name: Copy translations to project
|
||||
# run: |
|
||||
# mkdir -p client/src/locales
|
||||
# cp -r temp/* client/src/locales/
|
||||
# echo "Translation files copied to client/src/locales/"
|
||||
|
||||
# - name: Get current date
|
||||
# id: date
|
||||
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
|
||||
|
||||
# - name: Format client code
|
||||
# run: |
|
||||
# cd client
|
||||
# npm ci
|
||||
# npm run format
|
||||
|
||||
# - name: Create Pull Request
|
||||
# uses: peter-evans/create-pull-request@v7
|
||||
# with:
|
||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# commit-message: "feat: translations updated from POEditor"
|
||||
# title: "🌐 Updated Translations from POEditor"
|
||||
# body: |
|
||||
# This PR contains the latest translations from POEditor.
|
||||
|
||||
# 📅 Update Date: ${{ steps.date.outputs.date }}
|
||||
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
|
||||
|
||||
# ⚠️ Please review the translations and approve the PR if everything looks correct.
|
||||
# branch: translation-update-${{ github.run_number }}
|
||||
# delete-branch: true
|
||||
# base: develop
|
||||
# add-paths: |
|
||||
# client/src/locales/*.json
|
||||
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
||||
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
||||
@@ -1,113 +0,0 @@
|
||||
name: Upload Translations to POEditor on PR Merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- "client/src/locales/**"
|
||||
|
||||
jobs:
|
||||
upload-translations:
|
||||
# Only run if the PR was merged (not just closed) or manually triggered
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history to get changed files
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Create package.json for scripts
|
||||
run: |
|
||||
mkdir -p .github/scripts
|
||||
cat > .github/scripts/package.json << EOF
|
||||
{
|
||||
"name": "poeditor-scripts",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd .github/scripts
|
||||
npm install
|
||||
|
||||
- name: Get changed locale files
|
||||
id: changed-files
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
# Get base and head commits
|
||||
BASE_SHA=${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA=${{ github.event.pull_request.head.sha }}
|
||||
|
||||
echo "Base SHA: $BASE_SHA"
|
||||
echo "Head SHA: $HEAD_SHA"
|
||||
|
||||
# Get list of changed files in client/src/locales directory
|
||||
CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json')
|
||||
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "No changes detected in locale files"
|
||||
echo "CHANGED_FILES=" >> $GITHUB_ENV
|
||||
else
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
echo "CHANGED_FILES<<EOF" >> $GITHUB_ENV
|
||||
echo "$CHANGED_FILES" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Upload changed translations to POEditor
|
||||
if: env.CHANGED_FILES != ''
|
||||
env:
|
||||
POEDITOR_API: ${{ secrets.POEDITOR_API }}
|
||||
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
|
||||
run: |
|
||||
# Process each changed file
|
||||
for FILE in $CHANGED_FILES; do
|
||||
if [[ -f "$FILE" ]]; then
|
||||
# Extract language code from filename (e.g., client/src/locales/en.json -> en)
|
||||
FILENAME=$(basename "$FILE")
|
||||
|
||||
# Special case: map gb.json to en language code
|
||||
if [ "$FILENAME" == "gb.json" ]; then
|
||||
LANG="en"
|
||||
echo "Found gb.json, mapping to language code 'en'"
|
||||
else
|
||||
LANG=$(basename "$FILE" .json)
|
||||
fi
|
||||
|
||||
echo "Processing $FILE for language $LANG"
|
||||
|
||||
# Upload to POEditor
|
||||
LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Notify on success
|
||||
if: success() && env.CHANGED_FILES != ''
|
||||
run: |
|
||||
echo "Successfully uploaded translation files to POEditor."
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
echo "Manual trigger comment: ${{ github.event.inputs.comment }}"
|
||||
fi
|
||||
|
||||
- name: Notify on no changes
|
||||
if: env.CHANGED_FILES == ''
|
||||
run: |
|
||||
echo "No translation files were found to upload."
|
||||
@@ -32,12 +32,13 @@
|
||||
|
||||
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
||||
|
||||
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
|
||||
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong>
|
||||
|
||||
[](https://www.pikapods.com/pods?run=checkmate)
|
||||
|
||||
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
||||
|
||||
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
||||
</p>
|
||||
<br>
|
||||
|
||||
|
||||
@@ -91,6 +92,10 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
|
||||
|
||||
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
|
||||
|
||||
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
|
||||
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
|
||||
|
||||
|
||||
### Configuration
|
||||
|
||||
The image is configured entirely through environment variables on the server container:
|
||||
@@ -207,9 +212,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||
<a id="contributing"></a>
|
||||
## Contributing
|
||||
|
||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
|
||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
|
||||
|
||||
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
|
||||
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
|
||||
|
||||
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "3.10.0",
|
||||
"version": "3.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "client",
|
||||
"version": "3.10.0",
|
||||
"version": "3.11.0",
|
||||
"dependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "3.10.0",
|
||||
"version": "3.11.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build-dev": "tsc -b && vite build --mode development",
|
||||
|
||||
@@ -11,10 +11,11 @@ interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
const SelectInputInner = <T,>(
|
||||
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
|
||||
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
@@ -27,7 +28,7 @@ const SelectInputInner = <T,>(
|
||||
: selected === undefined || selected === null || selected === "";
|
||||
|
||||
if (isEmpty && placeholder) {
|
||||
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
|
||||
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>;
|
||||
}
|
||||
|
||||
if (isMultiple) {
|
||||
@@ -92,11 +93,21 @@ const SelectInputInner = <T,>(
|
||||
/>
|
||||
);
|
||||
|
||||
if (fieldLabel) {
|
||||
const helper = helperText ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
|
||||
>
|
||||
{helperText}
|
||||
</Typography>
|
||||
) : null;
|
||||
|
||||
if (fieldLabel || helper) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
|
||||
{select}
|
||||
{helper}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react-hook-form";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { RadioWithDescription } from "@/Components/inputs/Radio";
|
||||
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
||||
export interface FormRadioOption {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -17,10 +18,12 @@ export interface FormRadioOption {
|
||||
interface FormRadioGroupProps<T extends FieldValues> {
|
||||
name: FieldPath<T>;
|
||||
options: FormRadioOption[];
|
||||
fieldLabel?: string;
|
||||
}
|
||||
export const FormRadioGroup = <T extends FieldValues>({
|
||||
name,
|
||||
options,
|
||||
fieldLabel,
|
||||
}: FormRadioGroupProps<T>) => {
|
||||
const { control } = useFormContext<T>();
|
||||
const theme = useTheme();
|
||||
@@ -30,6 +33,7 @@ export const FormRadioGroup = <T extends FieldValues>({
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FormControl error={!!fieldState.error}>
|
||||
{fieldLabel && <FieldLabel>{fieldLabel}</FieldLabel>}
|
||||
<RadioGroup
|
||||
{...field}
|
||||
sx={{ gap: theme.spacing(LAYOUT.MD) }}
|
||||
|
||||
@@ -44,6 +44,7 @@ export const FormSelectField = <T extends FieldValues>({
|
||||
}}
|
||||
error={!!fieldState.error}
|
||||
{...rest}
|
||||
helperText={fieldState.error?.message ?? rest.helperText}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
Lock,
|
||||
Users,
|
||||
Tag,
|
||||
Waypoints,
|
||||
} from "lucide-react";
|
||||
|
||||
export const getMenu = (t: Function) => {
|
||||
return [
|
||||
{
|
||||
@@ -47,6 +47,11 @@ export const getMenu = (t: Function) => {
|
||||
path: "tags",
|
||||
icon: <Icon icon={Tag} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.proxies"),
|
||||
path: "proxies",
|
||||
icon: <Icon icon={Waypoints} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.checks"),
|
||||
path: "checks",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DefaultHttpMethod,
|
||||
DefaultMonitorMatchMethod,
|
||||
DefaultPageSpeedStrategy,
|
||||
DefaultProxyMode,
|
||||
} from "@/Types/Monitor";
|
||||
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
||||
|
||||
@@ -46,6 +47,8 @@ export const getMonitorDefaults = (
|
||||
url: data?.url || "",
|
||||
method: data?.method ?? DefaultHttpMethod,
|
||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||
proxyMode: data?.proxyMode ?? DefaultProxyMode,
|
||||
proxyId: data?.proxyId || "",
|
||||
useAdvancedMatching: data?.useAdvancedMatching || false,
|
||||
matchMethod: data?.matchMethod || DefaultMonitorMatchMethod,
|
||||
expectedValue: data?.expectedValue || "",
|
||||
@@ -141,6 +144,8 @@ export const getMonitorDefaults = (
|
||||
url: "",
|
||||
method: DefaultHttpMethod,
|
||||
ignoreTlsErrors: false,
|
||||
proxyMode: DefaultProxyMode,
|
||||
proxyId: "",
|
||||
useAdvancedMatching: false,
|
||||
matchMethod: DefaultMonitorMatchMethod,
|
||||
expectedValue: "",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMemo } from "react";
|
||||
import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
|
||||
import type { ProxyResponse } from "@/Types/Proxy";
|
||||
|
||||
interface UseProxiesFormOptions {
|
||||
data?: ProxyResponse | null;
|
||||
}
|
||||
|
||||
export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
|
||||
return useMemo(() => {
|
||||
const defaults: ProxyFormData = {
|
||||
name: data?.name ?? "",
|
||||
protocol: data?.protocol ?? "http",
|
||||
host: data?.host ?? "",
|
||||
port: data?.port ?? 8080,
|
||||
username: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
return { schema: proxySchema, defaults };
|
||||
}, [data]);
|
||||
};
|
||||
@@ -43,6 +43,8 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
|
||||
checkTTL: data?.checkTTL ?? 30,
|
||||
pagespeedApiKey: "",
|
||||
systemEmailPassword: "",
|
||||
globalProxyEnabled: data?.globalProxyEnabled ?? false,
|
||||
globalProxyId: data?.globalProxyId ?? null,
|
||||
};
|
||||
|
||||
return { schema: settingsSchema, defaults };
|
||||
|
||||
@@ -41,9 +41,12 @@ import {
|
||||
DefaultMonitorMatchMethod,
|
||||
MonitorMatchMethods,
|
||||
GeoCheckIntervalOptions,
|
||||
ProxyModes,
|
||||
type ProxyMode,
|
||||
} from "@/Types/Monitor";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import type { AppSettingsResponse } from "@/Types/Settings";
|
||||
import {
|
||||
stepFieldsFor,
|
||||
monitorStepCount,
|
||||
@@ -56,6 +59,7 @@ import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectF
|
||||
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
||||
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
|
||||
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
||||
import type { ProxyResponse } from "@/Types/Proxy";
|
||||
|
||||
const httpMethodOptions = HttpMethods.map((method) => ({
|
||||
value: method,
|
||||
@@ -67,6 +71,7 @@ interface GeneralSettingsConfig {
|
||||
urlPlaceholder: string;
|
||||
namePlaceholder: string;
|
||||
showUrl: boolean;
|
||||
showProxy: boolean;
|
||||
showPort: boolean;
|
||||
showGameSelect: boolean;
|
||||
showSecret: boolean;
|
||||
@@ -87,6 +92,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: true,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -101,6 +107,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -115,6 +122,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -129,6 +137,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: true,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -143,6 +152,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: true,
|
||||
showGameSelect: true,
|
||||
showSecret: false,
|
||||
@@ -157,6 +167,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: true,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -171,6 +182,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -185,6 +197,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: true,
|
||||
@@ -199,6 +212,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -213,6 +227,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -258,6 +273,8 @@ const CreateMonitorPage = () => {
|
||||
const { data: notifications } = useGet<Notification[]>("/notifications/team");
|
||||
const { data: games } = useGet<GamesMap>("/monitors/games");
|
||||
const { data: tags } = useGet<Tag[]>("/tags/team");
|
||||
const { data: proxies } = useGet<ProxyResponse[]>("/proxies/team");
|
||||
const { data: appSettings } = useGet<AppSettingsResponse>("/settings");
|
||||
|
||||
const { schema, defaults } = useMonitorForm({
|
||||
data: existingMonitor ?? null,
|
||||
@@ -281,6 +298,7 @@ const CreateMonitorPage = () => {
|
||||
|
||||
const watchedType = watch("type") as MonitorType;
|
||||
const watchedMethod = watch("method") as HttpMethod | undefined;
|
||||
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
|
||||
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
||||
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
|
||||
|
||||
@@ -466,6 +484,38 @@ const CreateMonitorPage = () => {
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const globalProxyName = appSettings?.globalProxy?.name;
|
||||
|
||||
const proxyModeOptions = useMemo(
|
||||
() =>
|
||||
ProxyModes.map((mode) => {
|
||||
const key =
|
||||
mode === "inherit"
|
||||
? globalProxyName
|
||||
? "inheritGlobal"
|
||||
: "inheritDirect"
|
||||
: mode;
|
||||
return {
|
||||
value: mode,
|
||||
label: t(`pages.createMonitor.form.general.option.proxyMode.value.${key}`, {
|
||||
name: globalProxyName,
|
||||
}),
|
||||
description: t(
|
||||
`pages.createMonitor.form.general.option.proxyMode.description.${key}`
|
||||
),
|
||||
};
|
||||
}),
|
||||
[t, globalProxyName]
|
||||
);
|
||||
|
||||
const proxyOptions = useMemo(
|
||||
() =>
|
||||
(proxies ?? []).map((proxy) => ({
|
||||
value: proxy.id,
|
||||
label: `${proxy.name} (${proxy.host}:${proxy.port})`,
|
||||
})),
|
||||
[proxies]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
@@ -518,6 +568,11 @@ const CreateMonitorPage = () => {
|
||||
}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<FormTextField
|
||||
name="name"
|
||||
fieldLabel={t("pages.createMonitor.form.general.option.name.label")}
|
||||
placeholder={generalSettingsConfig.namePlaceholder}
|
||||
/>
|
||||
{/* URL/Host/Container field - not shown for hardware */}
|
||||
{generalSettingsConfig.showUrl && (
|
||||
<FormTextField
|
||||
@@ -527,6 +582,27 @@ const CreateMonitorPage = () => {
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
)}
|
||||
{/* Proxy fields - only shown for HTTP */}
|
||||
{generalSettingsConfig.showProxy && (
|
||||
<>
|
||||
<FormRadioGroup
|
||||
name="proxyMode"
|
||||
options={proxyModeOptions}
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.general.option.proxyMode.label"
|
||||
)}
|
||||
/>
|
||||
{watchedProxyMode === "custom" && (
|
||||
<FormSelectField
|
||||
name="proxyId"
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.general.option.proxy.label"
|
||||
)}
|
||||
options={proxyOptions}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Strategy field - only for pagespeed type */}
|
||||
{generalSettingsConfig.showStrategy && (
|
||||
@@ -603,11 +679,6 @@ const CreateMonitorPage = () => {
|
||||
options={dnsRecordTypeOptions}
|
||||
/>
|
||||
)}
|
||||
<FormTextField
|
||||
name="name"
|
||||
fieldLabel={t("pages.createMonitor.form.general.option.name.label")}
|
||||
placeholder={generalSettingsConfig.namePlaceholder}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import { Table } from "@/Components/design-elements";
|
||||
import { Pagination } from "@/Components/design-elements/Table";
|
||||
import { useClientPagination } from "@/Hooks/useClientPagination";
|
||||
|
||||
import type { ProxyResponse } from "@/Types/Proxy";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material";
|
||||
|
||||
interface ProxiesTableProps {
|
||||
proxies: ProxyResponse[];
|
||||
setSelectedProxy: Function;
|
||||
}
|
||||
|
||||
export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { pagedRows, paginationProps } = useClientPagination(proxies);
|
||||
|
||||
const getActions = (proxy: ProxyResponse): ActionMenuItem[] => {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
label: t("pages.common.monitors.actions.configure"),
|
||||
action: () => {
|
||||
navigate(`/proxies/configure/${proxy.id}`);
|
||||
},
|
||||
closeMenu: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 2,
|
||||
label: (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("pages.common.monitors.actions.delete")}
|
||||
</Typography>
|
||||
),
|
||||
action: async () => {
|
||||
setSelectedProxy(proxy);
|
||||
},
|
||||
closeMenu: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getHeaders = () => {
|
||||
const headers: Header<ProxyResponse>[] = [
|
||||
{
|
||||
id: "name",
|
||||
content: t("common.table.headers.name"),
|
||||
render: (row) => {
|
||||
return <Typography>{row?.name}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
content: t("pages.proxies.table.headers.protocol"),
|
||||
render: (row) => {
|
||||
return <Typography>{row?.protocol}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "host",
|
||||
content: t("pages.proxies.table.headers.host"),
|
||||
render: (row) => {
|
||||
return <Typography>{row?.host}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "port",
|
||||
content: t("pages.proxies.table.headers.port"),
|
||||
render: (row) => {
|
||||
return <Typography>{row?.port}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
content: t("common.table.headers.actions"),
|
||||
render: (row) => {
|
||||
return <ActionsMenu items={getActions(row)} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
headers={getHeaders()}
|
||||
data={pagedRows}
|
||||
onRowClick={(row) => {
|
||||
navigate(`/proxies/configure/${row.id}`);
|
||||
}}
|
||||
/>
|
||||
{proxies.length > 0 && <Pagination {...paginationProps} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { Button, FieldLabel } from "@/Components/inputs";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { mutate } from "swr";
|
||||
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
||||
import type { ProxyFormData } from "@/Validation/proxy";
|
||||
import { ProxyProtocols, type ProxyResponse } from "@/Types/Proxy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useProxiesForm } from "@/Hooks/useProxiesForm";
|
||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
||||
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
||||
import { useMemo } from "react";
|
||||
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
|
||||
const ProxiesCreatePage = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { proxyId } = useParams<{ proxyId: string }>();
|
||||
const isEditMode = Boolean(proxyId);
|
||||
|
||||
const { data: existingProxy } = useGet<ProxyResponse>(
|
||||
isEditMode ? `/proxies/${proxyId}` : null
|
||||
);
|
||||
|
||||
const { post, loading: isSubmitting } = usePost<ProxyFormData, ProxyResponse>();
|
||||
const { patch, loading: isPatching } = usePatch<ProxyFormData, ProxyResponse>();
|
||||
|
||||
const { schema, defaults } = useProxiesForm({ data: existingProxy });
|
||||
|
||||
const form = useForm<ProxyFormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: defaults,
|
||||
});
|
||||
|
||||
const { reset, handleSubmit } = form;
|
||||
|
||||
const isPasswordSet = existingProxy?.hasPassword ?? false;
|
||||
const [passwordHasBeenReset, setPasswordHasBeenReset] = useState(false);
|
||||
|
||||
const handleResetPassword = () => {
|
||||
form.setValue("password", "");
|
||||
setPasswordHasBeenReset(true);
|
||||
};
|
||||
|
||||
const isUsernameSet = Boolean(existingProxy?.username);
|
||||
const [usernameHasBeenReset, setUsernameHasBeenReset] = useState(false);
|
||||
|
||||
const handleResetUsername = () => {
|
||||
form.setValue("username", "");
|
||||
setUsernameHasBeenReset(true);
|
||||
};
|
||||
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
ProxyProtocols.map((protocol) => ({
|
||||
value: protocol,
|
||||
label: protocol,
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaults);
|
||||
}, [defaults, reset]);
|
||||
|
||||
const onSubmit = async (data: ProxyFormData) => {
|
||||
const payload: ProxyFormData & { clearPassword?: boolean; clearUsername?: boolean } =
|
||||
{
|
||||
...data,
|
||||
};
|
||||
// Don't send password/username if one is set and the user hasn't clicked reset
|
||||
if (isPasswordSet && !passwordHasBeenReset) {
|
||||
delete payload.password;
|
||||
} else if (passwordHasBeenReset && !payload.password) {
|
||||
payload.clearPassword = true;
|
||||
}
|
||||
if (isUsernameSet && !usernameHasBeenReset) {
|
||||
delete payload.username;
|
||||
} else if (usernameHasBeenReset && !payload.username) {
|
||||
payload.clearUsername = true;
|
||||
}
|
||||
const result = isEditMode
|
||||
? await patch(`/proxies/${proxyId}`, payload)
|
||||
: await post("/proxies", payload);
|
||||
if (result?.success) {
|
||||
await mutate((key) => typeof key === "string" && key.startsWith("/proxies"));
|
||||
navigate("/proxies");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<BasePage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<ConfigBox
|
||||
title={t("pages.proxies.form.name.title")}
|
||||
subtitle={t("pages.proxies.form.name.description")}
|
||||
rightContent={
|
||||
<FormTextField
|
||||
name="name"
|
||||
fieldLabel={t("pages.proxies.form.name.optionName")}
|
||||
placeholder={t("pages.proxies.form.name.placeholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.proxies.form.server.title")}
|
||||
subtitle={t("pages.proxies.form.server.description")}
|
||||
rightContent={
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<FormSelectField
|
||||
name="protocol"
|
||||
fieldLabel={t("pages.proxies.form.server.option.protocol.label")}
|
||||
options={protocolOptions}
|
||||
/>
|
||||
<FormTextField
|
||||
name="host"
|
||||
fieldLabel={t("pages.proxies.form.server.option.host.label")}
|
||||
placeholder={t("pages.proxies.form.server.option.host.placeholder")}
|
||||
/>
|
||||
<FormNumberField
|
||||
name="port"
|
||||
fieldLabel={t("pages.proxies.form.server.option.port.label")}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfigBox
|
||||
title={t("pages.proxies.form.auth.title")}
|
||||
subtitle={t("pages.proxies.form.auth.description")}
|
||||
rightContent={
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
{(isUsernameSet === false || usernameHasBeenReset === true) && (
|
||||
<FormTextField
|
||||
name="username"
|
||||
fieldLabel={t("pages.proxies.form.auth.option.username.label")}
|
||||
placeholder={t("pages.proxies.form.auth.option.username.placeholder")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isUsernameSet === true && usernameHasBeenReset === false && (
|
||||
<Box>
|
||||
<FieldLabel>
|
||||
{t("pages.proxies.form.auth.option.username.labelSet")}
|
||||
</FieldLabel>
|
||||
<Button
|
||||
onClick={handleResetUsername}
|
||||
variant="contained"
|
||||
color="error"
|
||||
>
|
||||
{t("common.buttons.reset")}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(isPasswordSet === false || passwordHasBeenReset === true) && (
|
||||
<FormTextField
|
||||
name="password"
|
||||
type="password"
|
||||
fieldLabel={t("pages.proxies.form.auth.option.password.label")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPasswordSet === true && passwordHasBeenReset === false && (
|
||||
<Box>
|
||||
<FieldLabel>
|
||||
{t("pages.proxies.form.auth.option.password.labelSet")}
|
||||
</FieldLabel>
|
||||
<Button
|
||||
onClick={handleResetPassword}
|
||||
variant="contained"
|
||||
color="error"
|
||||
>
|
||||
{t("common.buttons.reset")}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
spacing={theme.spacing(2)}
|
||||
>
|
||||
<Button
|
||||
loading={isSubmitting || isPatching}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</BasePage>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxiesCreatePage;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { BasePageWithStates } from "@/Components/design-elements";
|
||||
import { ProxiesTable } from "@/Pages/Proxies/components/ProxiesTable";
|
||||
import { Dialog } from "@/Components/inputs";
|
||||
import { HeaderCreate } from "@/Components/common";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useGet, useDelete } from "@/Hooks/UseApi";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ProxyResponse } from "@/Types/Proxy";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
|
||||
const ProxiesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const [selectedProxy, setSelectedProxy] = useState<ProxyResponse | null>(null);
|
||||
const isDialogOpen = Boolean(selectedProxy);
|
||||
|
||||
const {
|
||||
data: proxies,
|
||||
isLoading,
|
||||
isValidating,
|
||||
error,
|
||||
refetch,
|
||||
} = useGet<ProxyResponse[]>("/proxies/team", {}, { keepPreviousData: true });
|
||||
|
||||
const { deleteFn, loading: isDeleting } = useDelete();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedProxy) return;
|
||||
await deleteFn(`/proxies/${selectedProxy.id}`);
|
||||
setSelectedProxy(null);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedProxy(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePageWithStates
|
||||
headerKey="proxies"
|
||||
page={t("pages.proxies.fallback.title")}
|
||||
description={t("pages.proxies.fallback.description")}
|
||||
loading={isLoading || isValidating}
|
||||
error={!!error}
|
||||
totalCount={proxies?.length ?? 0}
|
||||
actionButtonText={t("pages.proxies.fallback.actionButton")}
|
||||
actionLink="/proxies/create"
|
||||
>
|
||||
<HeaderCreate
|
||||
path="/proxies/create"
|
||||
isLoading={isLoading || isValidating}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<ProxiesTable
|
||||
proxies={proxies ?? []}
|
||||
setSelectedProxy={setSelectedProxy}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
title={t("common.dialogs.delete.title")}
|
||||
content={t("common.dialogs.delete.description")}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
</BasePageWithStates>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxiesPage;
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Alert,
|
||||
type SelectChangeEvent,
|
||||
} from "@mui/material";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { FormProvider, useForm, type FieldPath } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -43,12 +43,9 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
||||
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
|
||||
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
||||
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
|
||||
|
||||
interface SettingsResponse {
|
||||
settings: any;
|
||||
pagespeedKeySet: boolean;
|
||||
emailPasswordSet: boolean;
|
||||
}
|
||||
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
||||
import type { ProxyResponse } from "@/Types/Proxy";
|
||||
import type { AppSettingsResponse } from "@/Types/Settings";
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const theme = useTheme();
|
||||
@@ -64,9 +61,20 @@ export const SettingsPage = () => {
|
||||
const { post: importMonitors, loading: isImportingMonitors } = usePost();
|
||||
|
||||
// Fetch settings data from API
|
||||
const { data: fetchedSettings } = useGet<SettingsResponse>("/settings");
|
||||
const { data: fetchedSettings } = useGet<AppSettingsResponse>("/settings");
|
||||
// Get proxies
|
||||
const { data: proxies } = useGet<ProxyResponse[]>("/proxies");
|
||||
const proxyOptions = useMemo(
|
||||
() =>
|
||||
(proxies ?? []).map((proxy) => ({
|
||||
value: proxy.id,
|
||||
label: `${proxy.name} (${proxy.host}:${proxy.port})`,
|
||||
})),
|
||||
[proxies]
|
||||
);
|
||||
|
||||
// Form submission
|
||||
const { patch, loading: isSaving } = usePatch<SettingsFormData, SettingsResponse>();
|
||||
const { patch, loading: isSaving } = usePatch<SettingsFormData, AppSettingsResponse>();
|
||||
|
||||
// Local state for API key reset
|
||||
const [isApiKeySet, setIsApiKeySet] = useState(
|
||||
@@ -115,6 +123,8 @@ export const SettingsPage = () => {
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const { watch } = form;
|
||||
|
||||
// Reset form when defaults change
|
||||
useEffect(() => {
|
||||
form.reset(defaults);
|
||||
@@ -292,6 +302,21 @@ export const SettingsPage = () => {
|
||||
|
||||
const languages = Object.keys(i18n.options.resources || {});
|
||||
|
||||
const globalProxyEnabled = watch("globalProxyEnabled");
|
||||
|
||||
// Enabling the proxy with nothing selected preselects the first proxy
|
||||
useEffect(() => {
|
||||
if (
|
||||
globalProxyEnabled &&
|
||||
!form.getValues("globalProxyId") &&
|
||||
proxyOptions.length > 0
|
||||
) {
|
||||
form.setValue("globalProxyId", proxyOptions[0].value, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}, [globalProxyEnabled, proxyOptions, form]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<BasePage
|
||||
@@ -365,6 +390,38 @@ export const SettingsPage = () => {
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<ConfigBox
|
||||
title={t("pages.settings.form.globalProxy.title")}
|
||||
subtitle={t("pages.settings.form.globalProxy.description")}
|
||||
rightContent={
|
||||
proxyOptions.length > 0 ? (
|
||||
<Stack>
|
||||
<FormSwitchField
|
||||
name="globalProxyEnabled"
|
||||
labelPlacement="start"
|
||||
label={t("pages.settings.form.globalProxy.option.enabled.label")}
|
||||
/>
|
||||
{globalProxyEnabled && (
|
||||
<FormSelectField
|
||||
fieldLabel={t(
|
||||
"pages.settings.form.globalProxy.option.proxy.label"
|
||||
)}
|
||||
name="globalProxyId"
|
||||
options={proxyOptions}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<TextLink
|
||||
text={t("pages.settings.form.globalProxy.option.empty.text")}
|
||||
linkText={t("pages.settings.form.globalProxy.option.empty.link")}
|
||||
href="/proxies"
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<ConfigBox
|
||||
title={t("pages.settings.form.pagespeed.title")}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements";
|
||||
import {
|
||||
BasePage,
|
||||
ColoredLabel,
|
||||
ConfigBox,
|
||||
StepProgress,
|
||||
} from "@/Components/design-elements";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
@@ -16,12 +21,12 @@ import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statu
|
||||
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { SelectableMonitorTypes } from "@/Types/Monitor";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
|
||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import { timezoneOptions } from "@/Utils/timezoneOptions";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { mutate } from "swr";
|
||||
import axios from "axios";
|
||||
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
|
||||
import { ThemePicker } from "./Components/ThemePicker";
|
||||
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
||||
@@ -53,7 +58,9 @@ const LogoUploadField = () => {
|
||||
<ImageUpload
|
||||
src={field.value?.data}
|
||||
onChange={(file) => {
|
||||
field.onChange(file ? { data: file.src, contentType: file.file.type } : null);
|
||||
field.onChange(
|
||||
file ? { data: file.src, contentType: file.file.type, file: file.file } : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -86,6 +93,8 @@ const CreateStatusPage = () => {
|
||||
|
||||
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
|
||||
const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]);
|
||||
const { data: tagsResponse } = useGet<Tag[]>("/tags/team");
|
||||
const tags = useMemo(() => tagsResponse ?? [], [tagsResponse]);
|
||||
|
||||
const { post, loading: isSubmittingPost } = usePost();
|
||||
const { put, loading: isSubmittingPut } = usePut();
|
||||
@@ -196,18 +205,8 @@ const CreateStatusPage = () => {
|
||||
if (data.logo === null) {
|
||||
// Signal to remove the logo
|
||||
fd.append("removeLogo", "true");
|
||||
} else if (data.logo?.data && data.logo.data !== "") {
|
||||
if (data.logo.data.startsWith("blob:")) {
|
||||
try {
|
||||
const imageResult = await axios.get(data.logo.data, {
|
||||
responseType: "blob",
|
||||
});
|
||||
fd.append("logo", imageResult.data);
|
||||
URL.revokeObjectURL(data.logo.data);
|
||||
} catch (e) {
|
||||
logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined);
|
||||
}
|
||||
}
|
||||
} else if (data.logo?.file) {
|
||||
fd.append("logo", data.logo.file);
|
||||
}
|
||||
|
||||
let result;
|
||||
@@ -308,6 +307,28 @@ const CreateStatusPage = () => {
|
||||
"pages.statusPages.form.monitors.option.monitors.placeholder"
|
||||
)}
|
||||
options={monitors}
|
||||
renderOptionContent={(monitor) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Typography>
|
||||
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
|
||||
</Typography>
|
||||
{monitor.tags.map((tagId) => {
|
||||
const tag = tags.find(({ id }) => id === tagId);
|
||||
return tag ? (
|
||||
<ColoredLabel
|
||||
key={tag.id}
|
||||
text={tag.name}
|
||||
color={tag.color}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
renderRow={(monitor) => (
|
||||
<Typography flexGrow={1}>
|
||||
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
|
||||
|
||||
@@ -113,6 +113,7 @@ const StatusPageView = () => {
|
||||
apiUrl,
|
||||
{},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
refreshInterval: range === "latest" ? 10000 : 60000,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
@@ -34,9 +34,9 @@ export interface BoldStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
|
||||
heatmapCell: (kind: BoldHeatCell, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -327,9 +327,12 @@ export const boldStyles = (
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
height: 48,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
heatmapCell: (kind, severity = 1) => ({
|
||||
borderRadius: "3px",
|
||||
background: heatCellBg[kind],
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: heatCellBg[kind],
|
||||
boxShadow: heatCellShadow[kind],
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
transition: "transform 0.15s",
|
||||
@@ -344,8 +347,11 @@ export const boldStyles = (
|
||||
alignItems: "flex-end",
|
||||
height: 48,
|
||||
},
|
||||
bar: (kind, heightPct) => ({
|
||||
background: barBg[kind],
|
||||
bar: (kind, heightPct, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: barBg[kind],
|
||||
borderRadius: "3px",
|
||||
minHeight: 4,
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import {
|
||||
EDITORIAL_SECONDARY_SANS_STACK,
|
||||
@@ -38,9 +38,9 @@ export interface EditorialStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
|
||||
heatmapCell: (kind: EditorialHeatCell, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -272,8 +272,11 @@ export const editorialStyles = (
|
||||
gap: { xs: "1px", md: "2px" },
|
||||
height: 40,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
background: heatCellBg[kind],
|
||||
heatmapCell: (kind, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.5 : 1,
|
||||
}),
|
||||
|
||||
@@ -285,8 +288,11 @@ export const editorialStyles = (
|
||||
alignItems: "flex-end",
|
||||
height: 40,
|
||||
},
|
||||
bar: (kind, heightPct) => ({
|
||||
background: barBg[kind],
|
||||
bar: (kind, heightPct, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: barBg[kind],
|
||||
minHeight: 3,
|
||||
opacity: kind === "empty" ? 0.5 : 1,
|
||||
height: `${heightPct}%`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { keyframes } from "@mui/system";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
@@ -37,9 +37,9 @@ export interface ModernStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
|
||||
heatmapCell: (kind: ModernHeatCell, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -83,13 +83,14 @@ export const modernStyles = (
|
||||
): ModernStyles => {
|
||||
const cardShadow = isDark ? darkShadow : lightShadow;
|
||||
const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
|
||||
const darken = (color: string) => `color-mix(in srgb, ${color} 70%, #000000 30%)`;
|
||||
|
||||
const heatCellBg: Record<ModernHeatCell, string> = {
|
||||
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
|
||||
med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
|
||||
slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
|
||||
degraded: `linear-gradient(180deg, ${tokens.degraded}, color-mix(in srgb, ${tokens.degraded} 70%, #000000 30%))`,
|
||||
down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
|
||||
degraded: `linear-gradient(180deg, ${tokens.degraded}, ${darken(tokens.degraded)})`,
|
||||
down: `linear-gradient(180deg, ${tokens.down}, ${darken(tokens.down)})`,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
@@ -103,7 +104,7 @@ export const modernStyles = (
|
||||
const gaugeFillBg: Record<ModernGaugeFill, string> = {
|
||||
ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
|
||||
warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
|
||||
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
|
||||
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down}, ${darken(tokens.down)})`,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -354,13 +355,19 @@ export const modernStyles = (
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
height: 46,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
borderRadius: "3px",
|
||||
background: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
transition: "transform 0.15s",
|
||||
"&:hover": { transform: "scaleY(1.2)" },
|
||||
}),
|
||||
heatmapCell: (kind, severity = 1) => {
|
||||
const mixed = mixSeverityColor(tokens.up, tokens.degraded, severity);
|
||||
return {
|
||||
borderRadius: "3px",
|
||||
background:
|
||||
kind === "degraded"
|
||||
? `linear-gradient(180deg, ${mixed}, ${darken(mixed)})`
|
||||
: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
transition: "transform 0.15s",
|
||||
"&:hover": { transform: "scaleY(1.2)" },
|
||||
};
|
||||
},
|
||||
|
||||
histogram: {
|
||||
padding: { xs: "0 12px", md: "0 24px" },
|
||||
@@ -370,8 +377,11 @@ export const modernStyles = (
|
||||
alignItems: "flex-end",
|
||||
height: 46,
|
||||
},
|
||||
bar: (kind, heightPct) => ({
|
||||
background: barBg[kind],
|
||||
bar: (kind, heightPct, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: barBg[kind],
|
||||
borderRadius: "3px",
|
||||
minHeight: 4,
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
||||
|
||||
@@ -37,9 +37,9 @@ export interface RefinedStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
|
||||
heatmapCell: (kind: RefinedHeatCell, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -296,9 +296,12 @@ export const refinedStyles = (
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
height: 42,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
heatmapCell: (kind, severity = 1) => ({
|
||||
borderRadius: "2px",
|
||||
background: heatCellBg[kind],
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
transition: "transform 0.15s",
|
||||
"&:hover": { transform: "scaleY(1.15)" },
|
||||
@@ -312,8 +315,11 @@ export const refinedStyles = (
|
||||
alignItems: "flex-end",
|
||||
height: 42,
|
||||
},
|
||||
bar: (kind, heightPct) => ({
|
||||
background: barBg[kind],
|
||||
bar: (kind, heightPct, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: barBg[kind],
|
||||
borderRadius: "2px",
|
||||
minHeight: 3,
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
|
||||
@@ -59,9 +59,9 @@ export interface BaseStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
|
||||
heatmapCell: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
|
||||
@@ -18,6 +18,7 @@ export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empt
|
||||
|
||||
export interface ChartCell {
|
||||
key: string;
|
||||
severity: number;
|
||||
barKind: BarKind;
|
||||
heatKind: HeatCellKind;
|
||||
heightPct: number; // histogram bar height
|
||||
@@ -28,9 +29,22 @@ export interface ChartCell {
|
||||
|
||||
const DAY_DOWN_THRESHOLD = 0.5;
|
||||
|
||||
const classifyDay = (bucket: DailyCheckBucket): BarKind => {
|
||||
// Color interpolation
|
||||
// 0 = perfect
|
||||
// 1 = at or below the down threshold
|
||||
const SEVERITY_FLOOR = 0.15; // A degraded server should never be green
|
||||
const SEVERITY_GAMMA = 2; // >1 biases toward green so high-uptime days stay visibly distinct from low ones
|
||||
|
||||
export const severityFromUpRatio = (upRatio: number): number => {
|
||||
const failed = 1 - upRatio;
|
||||
if (failed <= 0) return 0;
|
||||
const scaled = (Math.log10(failed) + 3) / (Math.log10(1 - DAY_DOWN_THRESHOLD) + 3);
|
||||
const clamped = Math.min(1, Math.max(0, scaled));
|
||||
return Math.max(SEVERITY_FLOOR, clamped ** SEVERITY_GAMMA);
|
||||
};
|
||||
|
||||
const classifyDay = (bucket: DailyCheckBucket, upRatio: number): BarKind => {
|
||||
if (bucket.totalChecks === 0) return "empty";
|
||||
const upRatio = bucket.upChecks / bucket.totalChecks;
|
||||
if (upRatio >= 1) return "up";
|
||||
if (upRatio < DAY_DOWN_THRESHOLD) return "down";
|
||||
return "degraded";
|
||||
@@ -55,6 +69,7 @@ const emptyCell = (key: string): ChartCell => ({
|
||||
responseTime: 0,
|
||||
tooltip: null,
|
||||
ariaLabel: "",
|
||||
severity: 0,
|
||||
});
|
||||
|
||||
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
|
||||
@@ -76,6 +91,7 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
|
||||
responseTime: check.responseTime ?? 0,
|
||||
tooltip: <ThemedChartTooltip check={check} />,
|
||||
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
|
||||
severity: 0,
|
||||
})
|
||||
);
|
||||
return padCells(cells, MAX_RECENT_CHECKS);
|
||||
@@ -93,9 +109,17 @@ export const dailyBucketsToCells = (
|
||||
return enumerated.map((date) => {
|
||||
const bucket = byDate.get(date);
|
||||
if (!bucket) return emptyCell(`day-${date}`);
|
||||
const barKind = classifyDay(bucket);
|
||||
const upRatio = bucket.upChecks / bucket.totalChecks;
|
||||
|
||||
const barKind = classifyDay(bucket, upRatio);
|
||||
return {
|
||||
key: `day-${date}`,
|
||||
severity:
|
||||
barKind === "degraded"
|
||||
? severityFromUpRatio(upRatio)
|
||||
: barKind === "down"
|
||||
? 1
|
||||
: 0,
|
||||
barKind,
|
||||
heatKind:
|
||||
barKind === "up"
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
interface Props {
|
||||
cells: ChartCell[];
|
||||
containerSx: SxProps<Theme>;
|
||||
cellSx: (kind: HeatCellKind) => SxProps<Theme>;
|
||||
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
||||
}
|
||||
|
||||
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
||||
@@ -42,7 +42,7 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={cellSx(cell.heatKind)}
|
||||
sx={cellSx(cell.heatKind, cell.severity)}
|
||||
aria-label={cell.ariaLabel}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
interface Props {
|
||||
cells: ChartCell[];
|
||||
containerSx: SxProps<Theme>;
|
||||
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
statsSx: SxProps<Theme>;
|
||||
statsGap?: number;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export const ThemedHistogram = ({
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={barSx(cell.barKind, cell.heightPct)}
|
||||
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
|
||||
aria-label={cell.ariaLabel}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -243,3 +243,10 @@ export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
|
||||
editorial,
|
||||
minimal,
|
||||
};
|
||||
|
||||
// Interpolate between a theme's up and degraded colors by severity (0..1).
|
||||
export const mixSeverityColor = (
|
||||
up: string,
|
||||
degraded: string,
|
||||
severity: number
|
||||
): string => `color-mix(in oklch, ${degraded} ${Math.round(severity * 100)}%, ${up})`;
|
||||
@@ -39,11 +39,14 @@ import CreateNotifications from "@/Pages/Notifications/create";
|
||||
import Tags from "@/Pages/Tags";
|
||||
import CreateTags from "@/Pages/Tags/create";
|
||||
|
||||
// Proxies
|
||||
import Proxies from "@/Pages/Proxies";
|
||||
import CreateProxies from "@/Pages/Proxies/create";
|
||||
|
||||
// Settings
|
||||
import Account from "@/Pages/Account";
|
||||
import EditUser from "@/Pages/Account/EditUser";
|
||||
import Settings from "@/Pages/Settings";
|
||||
|
||||
import Maintenance from "@/Pages/Maintenance";
|
||||
import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create";
|
||||
|
||||
@@ -52,10 +55,8 @@ import Logs from "@/Pages/Logs";
|
||||
|
||||
// Routing
|
||||
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
|
||||
|
||||
import CreateMonitor from "@/Pages/CreateMonitor";
|
||||
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
|
||||
|
||||
const Routes = () => {
|
||||
if (isCustomDomainHost()) {
|
||||
return (
|
||||
@@ -188,12 +189,24 @@ const Routes = () => {
|
||||
path="tags/create"
|
||||
element={<CreateTags />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="tags/configure/:tagId"
|
||||
element={<CreateTags />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="proxies"
|
||||
element={<Proxies />}
|
||||
/>
|
||||
<Route
|
||||
path="proxies/create"
|
||||
element={<CreateProxies />}
|
||||
/>
|
||||
<Route
|
||||
path="proxies/configure/:proxyId"
|
||||
element={<CreateProxies />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="maintenance"
|
||||
element={<Maintenance />}
|
||||
|
||||
@@ -109,6 +109,10 @@ export const HttpMethods = ["GET", "HEAD"] as const;
|
||||
export type HttpMethod = (typeof HttpMethods)[number];
|
||||
export const DefaultHttpMethod: HttpMethod = "GET";
|
||||
|
||||
export const ProxyModes = ["inherit", "none", "custom"] as const;
|
||||
export type ProxyMode = (typeof ProxyModes)[number];
|
||||
export const DefaultProxyMode: ProxyMode = "inherit";
|
||||
|
||||
export interface Monitor {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -121,6 +125,8 @@ export interface Monitor {
|
||||
statusWindowThreshold: number;
|
||||
type: MonitorType;
|
||||
ignoreTlsErrors: boolean;
|
||||
proxyMode: ProxyMode;
|
||||
proxyId?: string;
|
||||
useAdvancedMatching: boolean;
|
||||
jsonPath?: string;
|
||||
expectedValue?: string;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export const ProxyProtocols = ["http", "https"] as const;
|
||||
export type ProxyProtocol = (typeof ProxyProtocols)[number];
|
||||
|
||||
export interface Proxy {
|
||||
id: string;
|
||||
teamId: string;
|
||||
name: string;
|
||||
protocol: ProxyProtocol;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type ProxyResponse = Omit<Proxy, "password"> & { hasPassword: boolean };
|
||||
@@ -24,6 +24,8 @@ export interface Settings {
|
||||
showURL: boolean;
|
||||
singleton: boolean;
|
||||
globalThresholds?: SettingsThresholds;
|
||||
globalProxyEnabled: boolean;
|
||||
globalProxyId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -31,5 +33,11 @@ export interface Settings {
|
||||
export interface AppSettingsResponse {
|
||||
pagespeedKeySet: boolean;
|
||||
emailPasswordSet: boolean;
|
||||
globalProxy: {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
} | null;
|
||||
settings: Settings;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DnsRecordTypes,
|
||||
HttpMethods,
|
||||
PageSpeedStrategies,
|
||||
ProxyModes,
|
||||
type MonitorType,
|
||||
} from "@/Types/Monitor";
|
||||
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
|
||||
@@ -67,6 +68,13 @@ const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code),
|
||||
const httpSchema = baseSchema.extend({
|
||||
type: z.literal("http"),
|
||||
url: urlSchema,
|
||||
proxyMode: z.enum(ProxyModes),
|
||||
// The form uses "" for "no proxy selected". drop it from the payload so the
|
||||
// server never receives an empty ObjectId
|
||||
proxyId: z
|
||||
.string()
|
||||
.transform((value) => (value === "" ? undefined : value))
|
||||
.optional(),
|
||||
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
|
||||
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
|
||||
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
|
||||
@@ -195,7 +203,7 @@ const dnsSchema = baseSchema.extend({
|
||||
});
|
||||
|
||||
// Discriminated union of all monitor types
|
||||
export const monitorSchema = z.discriminatedUnion("type", [
|
||||
const monitorSchemaUnion = z.discriminatedUnion("type", [
|
||||
httpSchema,
|
||||
pingSchema,
|
||||
portSchema,
|
||||
@@ -208,10 +216,21 @@ export const monitorSchema = z.discriminatedUnion("type", [
|
||||
dnsSchema,
|
||||
]);
|
||||
|
||||
// Mirrors the server's refineProxySelection (monitorValidation.ts)
|
||||
export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
|
||||
if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["proxyId"],
|
||||
message: "A proxy must be selected when proxy mode is custom",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type MonitorFormData = z.infer<typeof monitorSchema>;
|
||||
|
||||
const optionForType = (type: MonitorType) =>
|
||||
monitorSchema.options.find((o) => o.shape.type.value === type);
|
||||
monitorSchemaUnion.options.find((o) => o.shape.type.value === type);
|
||||
|
||||
// The step each field of a type is validated on, from the per-field
|
||||
// `monitorStepRegistry` metadata (unannotated fields default to step 0).
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
import { ProxyProtocols } from "@/Types/Proxy";
|
||||
|
||||
export const proxySchema = z.object({
|
||||
name: z.string().min(1, "Proxy name is required"),
|
||||
protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"),
|
||||
host: z.string().min(1, "Proxy host is required"),
|
||||
port: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Proxy port is required")
|
||||
.max(65535, "Proxy port must be between 1 and 65535"),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ProxyFormData = z.infer<typeof proxySchema>;
|
||||
@@ -1,61 +1,73 @@
|
||||
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
|
||||
import { z } from "zod";
|
||||
|
||||
export const settingsSchema = z.object({
|
||||
systemEmailIgnoreTLS: z.boolean(),
|
||||
systemEmailRequireTLS: z.boolean(),
|
||||
systemEmailRejectUnauthorized: z.boolean(),
|
||||
systemEmailConnectionHost: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailSecure: z.boolean().optional(),
|
||||
systemEmailPool: z.boolean().optional(),
|
||||
showURL: z.boolean().optional(),
|
||||
checkTTL: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Please enter a value")
|
||||
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
|
||||
pagespeedApiKey: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailHost: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address")
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailPort: z.number().int().min(1).max(65535).optional(),
|
||||
systemEmailAddress: z
|
||||
.email("Please enter a valid email address")
|
||||
.or(z.literal(""))
|
||||
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
|
||||
.optional(),
|
||||
systemEmailDisplayName: z
|
||||
.string()
|
||||
.max(100, "Display name must be 100 characters or fewer")
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailUser: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailPassword: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailTLSServername: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
globalThresholds: z.object({
|
||||
cpu: z.number().int().min(1).max(100),
|
||||
memory: z.number().int().min(1).max(100),
|
||||
disk: z.number().int().min(1).max(100),
|
||||
temperature: z.number().int().min(1).max(150),
|
||||
}),
|
||||
});
|
||||
export const settingsSchema = z
|
||||
.object({
|
||||
systemEmailIgnoreTLS: z.boolean(),
|
||||
systemEmailRequireTLS: z.boolean(),
|
||||
systemEmailRejectUnauthorized: z.boolean(),
|
||||
systemEmailConnectionHost: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailSecure: z.boolean().optional(),
|
||||
systemEmailPool: z.boolean().optional(),
|
||||
showURL: z.boolean().optional(),
|
||||
checkTTL: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Please enter a value")
|
||||
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
|
||||
pagespeedApiKey: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailHost: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address")
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailPort: z.number().int().min(1).max(65535).optional(),
|
||||
systemEmailAddress: z
|
||||
.email("Please enter a valid email address")
|
||||
.or(z.literal(""))
|
||||
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
|
||||
.optional(),
|
||||
systemEmailDisplayName: z
|
||||
.string()
|
||||
.max(100, "Display name must be 100 characters or fewer")
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailUser: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailPassword: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailTLSServername: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
globalThresholds: z.object({
|
||||
cpu: z.number().int().min(1).max(100),
|
||||
memory: z.number().int().min(1).max(100),
|
||||
disk: z.number().int().min(1).max(100),
|
||||
temperature: z.number().int().min(1).max(150),
|
||||
}),
|
||||
globalProxyEnabled: z.boolean(),
|
||||
globalProxyId: z.string().nullable().optional(),
|
||||
})
|
||||
.superRefine((body, ctx) => {
|
||||
if (body.globalProxyEnabled === true && !body.globalProxyId) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["globalProxyId"],
|
||||
message: "A proxy must be selected to enable the global proxy",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type SettingsFormInput = z.input<typeof settingsSchema>;
|
||||
export type SettingsFormData = z.infer<typeof settingsSchema>;
|
||||
@@ -79,6 +79,7 @@ export const statusPageSchema = z.object({
|
||||
.object({
|
||||
data: z.string(),
|
||||
contentType: z.string(),
|
||||
file: z.instanceof(File).optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"infrastructure": "Infrastructure",
|
||||
"notifications": "Notifications",
|
||||
"tags": "Tags",
|
||||
"proxies": "Proxies",
|
||||
"checks": "Checks",
|
||||
"incidents": "Incidents",
|
||||
"statusPages": "Status pages",
|
||||
@@ -244,6 +245,63 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"proxies": {
|
||||
"header": {
|
||||
"title": "Proxies",
|
||||
"description": "Manage your proxy servers for routing HTTP monitoring traffic."
|
||||
},
|
||||
"fallback": {
|
||||
"title": "No proxies yet",
|
||||
"description": "Route HTTP monitor checks through a proxy server. Once you create a proxy, you can select it on individual monitors or set it as the global default in Settings.",
|
||||
"actionButton": "Create a proxy"
|
||||
},
|
||||
"form": {
|
||||
"name": {
|
||||
"title": "Proxy name",
|
||||
"description": "A friendly name to identify this proxy",
|
||||
"optionName": "Proxy name",
|
||||
"placeholder": "My proxy"
|
||||
},
|
||||
"server": {
|
||||
"title": "Proxy server",
|
||||
"description": "Where the proxy server is reachable. Set the protocol Checkmate uses to connect to it, its hostname or IP address, and the port it listens on.",
|
||||
"option": {
|
||||
"protocol": {
|
||||
"label": "Protocol"
|
||||
},
|
||||
"host": {
|
||||
"label": "Hostname / IP Address",
|
||||
"placeholder": "proxy.example.com"
|
||||
},
|
||||
"port": {
|
||||
"label": "Port"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentication",
|
||||
"description": "Optional. Only needed if the proxy server requires basic authentication.",
|
||||
"option": {
|
||||
"username": {
|
||||
"label": "Username",
|
||||
"placeholder": "Proxy username",
|
||||
"labelSet": "Username is set. Click Reset to change it."
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"labelSet": "Password is set. Click Reset to change it."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"protocol": "Protocol",
|
||||
"host": "Host",
|
||||
"port": "Port"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Oh no! You dropped your sushi!",
|
||||
"subtitle": "Either the URL doesn’t exist, or you don’t have access to it."
|
||||
@@ -581,6 +639,24 @@
|
||||
"label": "URL",
|
||||
"placeholder": "https://www.google.com"
|
||||
},
|
||||
"proxyMode": {
|
||||
"label": "Proxy",
|
||||
"value": {
|
||||
"inheritDirect": "Default (direct connection)",
|
||||
"inheritGlobal": "Default ({{name}})",
|
||||
"none": "Never use a proxy",
|
||||
"custom": "Custom proxy"
|
||||
},
|
||||
"description": {
|
||||
"inheritDirect": "No global proxy is configured, connect directly. If a global proxy is later configured, this monitor will use it.",
|
||||
"inheritGlobal": "Use the global proxy setting",
|
||||
"none": "Always connects directly even if a global proxy is enabled later.",
|
||||
"custom": "Route this monitor's checks through a specific proxy. Overrides global proxy"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
"label": "Proxy server"
|
||||
},
|
||||
"game": {
|
||||
"label": "Choose game",
|
||||
"placeholder": "Select a game"
|
||||
@@ -624,7 +700,7 @@
|
||||
},
|
||||
"title": "General settings",
|
||||
"description": {
|
||||
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.",
|
||||
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.",
|
||||
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
|
||||
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
|
||||
"docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
|
||||
@@ -1286,6 +1362,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"globalProxy": {
|
||||
"title": "Global proxy",
|
||||
"description": "Route monitoring checks through a proxy by default. Applies to all HTTP monitors whose proxy setting is \"Default\"; monitors set to \"Never use a proxy\" or a custom proxy are unaffected.",
|
||||
"option": {
|
||||
"enabled": {
|
||||
"label": "Use global proxy"
|
||||
},
|
||||
"proxy": {
|
||||
"label": "Choose a global proxy"
|
||||
},
|
||||
"empty": {
|
||||
"text": "No proxies configured yet.",
|
||||
"link": "Create one first"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagespeed": {
|
||||
"title": "Google PageSpeed API key",
|
||||
"description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.",
|
||||
|
||||
+1034
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ import "./routes/log.js";
|
||||
import "./routes/maintenanceWindow.js";
|
||||
import "./routes/monitor.js";
|
||||
import "./routes/notification.js";
|
||||
import "./routes/proxies.js";
|
||||
import "./routes/queue.js";
|
||||
import "./routes/settings.js";
|
||||
import "./routes/statusPage.js";
|
||||
@@ -45,6 +46,7 @@ export function getOpenApiSpec(): JsonObject {
|
||||
{ name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." },
|
||||
{ name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." },
|
||||
{ name: "settings", description: "Global application settings (admin/superadmin)." },
|
||||
{ name: "proxies", description: "Outbound proxy servers for routing HTTP monitor checks, team-scoped with an instance-wide admin listing." },
|
||||
{ name: "invite", description: "Team invitations and accepting them." },
|
||||
{ name: "queue", description: "Background job queue introspection and admin actions (admin/superadmin)." },
|
||||
{ name: "diagnostic", description: "System diagnostics for the running server (admin/superadmin)." },
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { z } from "zod";
|
||||
import { registry } from "../registry.js";
|
||||
import { bearer, json, okJson, errorEnvelope, standardErrors } from "../helpers.js";
|
||||
import {
|
||||
createProxyBodyValidation,
|
||||
editProxyBodyValidation,
|
||||
getProxyByIdParamValidation,
|
||||
editProxyParamValidation,
|
||||
deleteProxyParamValidation,
|
||||
proxyResponseSchema,
|
||||
} from "@/api/validation/proxyValidation.js";
|
||||
|
||||
const tags = ["proxies"];
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/proxies",
|
||||
tags,
|
||||
summary: "Create a proxy for the caller's team",
|
||||
security: bearer,
|
||||
request: { body: { content: json(createProxyBodyValidation) } },
|
||||
responses: { "200": okJson(proxyResponseSchema), ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/proxies",
|
||||
tags,
|
||||
summary: "List all proxies on the instance (admin/superadmin)",
|
||||
security: bearer,
|
||||
responses: { "200": okJson(z.array(proxyResponseSchema)), ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/proxies/team",
|
||||
tags,
|
||||
summary: "List proxies for the caller's team",
|
||||
security: bearer,
|
||||
responses: { "200": okJson(z.array(proxyResponseSchema)), ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/proxies/{id}",
|
||||
tags,
|
||||
summary: "Get a proxy by id",
|
||||
security: bearer,
|
||||
request: { params: getProxyByIdParamValidation },
|
||||
responses: { "200": okJson(proxyResponseSchema), ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "patch",
|
||||
path: "/proxies/{id}",
|
||||
tags,
|
||||
summary: "Edit a proxy",
|
||||
security: bearer,
|
||||
request: { params: editProxyParamValidation, body: { content: json(editProxyBodyValidation) } },
|
||||
responses: { "200": okJson(proxyResponseSchema), ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/proxies/{id}",
|
||||
tags,
|
||||
summary: "Delete a proxy",
|
||||
security: bearer,
|
||||
request: { params: deleteProxyParamValidation },
|
||||
responses: {
|
||||
"200": okJson(proxyResponseSchema),
|
||||
"409": { description: "Proxy is in use by monitors or set as the global proxy", content: json(errorEnvelope) },
|
||||
...standardErrors,
|
||||
},
|
||||
});
|
||||
Generated
+1739
-1399
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
|
||||
"test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/**/*.ts' test/unit",
|
||||
"test:unit:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications",
|
||||
@@ -49,6 +50,7 @@
|
||||
"got": "14.4.7",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^8.0.0",
|
||||
"hpagent": "1.2.0",
|
||||
"isomorphic-dompurify": "^2.26.0",
|
||||
"jmespath": "^0.16.0",
|
||||
"jsdom": "^26.1.0",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
export interface IProxiesController {
|
||||
createProxy: RequestHandler;
|
||||
getProxyById: RequestHandler;
|
||||
getAllProxies: RequestHandler;
|
||||
getProxiesByTeamId: RequestHandler;
|
||||
editProxy: RequestHandler;
|
||||
deleteProxy: RequestHandler;
|
||||
@@ -43,6 +44,15 @@ class ProxyController implements IProxiesController {
|
||||
});
|
||||
});
|
||||
|
||||
getAllProxies = catchAsync(async (req: Request, res: Response) => {
|
||||
const proxies = await this.proxiesService.getProxies();
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Proxies retrieved successfully",
|
||||
data: proxies,
|
||||
});
|
||||
});
|
||||
|
||||
getProxiesByTeamId = catchAsync(async (req: Request, res: Response) => {
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
const proxies = await this.proxiesService.getProxiesByTeamId(teamId);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { sendTestEmailBodyValidation } from "@/api/validation/notificationValida
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { IEmailService } from "@/service/emailService.js";
|
||||
import { IProxiesService } from "@/domain/proxies/proxy.service.js";
|
||||
import { Settings } from "@/domain/app-settings/app-settings.type.js";
|
||||
|
||||
export interface ISettingsController {
|
||||
@@ -16,18 +17,23 @@ export interface ISettingsController {
|
||||
class SettingsController implements ISettingsController {
|
||||
private settingsService: ISettingsService;
|
||||
private emailService: IEmailService;
|
||||
constructor(settingsService: ISettingsService, emailService: IEmailService) {
|
||||
private proxiesService: IProxiesService;
|
||||
constructor(settingsService: ISettingsService, emailService: IEmailService, proxiesService: IProxiesService) {
|
||||
this.settingsService = settingsService;
|
||||
this.emailService = emailService;
|
||||
this.proxiesService = proxiesService;
|
||||
}
|
||||
|
||||
buildAppSettings = (dbSettings: Settings) => {
|
||||
buildAppSettings = async (dbSettings: Settings) => {
|
||||
const sanitizedSettings: Record<string, unknown> = { ...dbSettings };
|
||||
delete sanitizedSettings.version;
|
||||
delete sanitizedSettings.jwtSecret;
|
||||
const globalProxy =
|
||||
dbSettings.globalProxyEnabled && dbSettings.globalProxyId ? await this.proxiesService.getProxySummary(dbSettings.globalProxyId) : null;
|
||||
const returnSettings: Record<string, unknown | null> = {
|
||||
pagespeedKeySet: false,
|
||||
emailPasswordSet: false,
|
||||
globalProxy,
|
||||
settings: null,
|
||||
};
|
||||
|
||||
@@ -46,7 +52,7 @@ class SettingsController implements ISettingsController {
|
||||
getAppSettings = catchAsync(async (req: Request, res: Response) => {
|
||||
const dbSettings = await this.settingsService.getDBSettings();
|
||||
|
||||
const returnSettings = this.buildAppSettings(dbSettings);
|
||||
const returnSettings = await this.buildAppSettings(dbSettings);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "App settings fetched successfully",
|
||||
@@ -57,8 +63,15 @@ class SettingsController implements ISettingsController {
|
||||
updateAppSettings = catchAsync(async (req: Request, res: Response) => {
|
||||
const validatedBody = updateAppSettingsBodyValidation.parse(req.body);
|
||||
|
||||
if (validatedBody.globalProxyId) {
|
||||
const proxy = await this.proxiesService.getProxySummary(validatedBody.globalProxyId);
|
||||
if (!proxy) {
|
||||
throw new AppError({ message: "Referenced proxy does not exist", status: 422 });
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSettings = await this.settingsService.updateDbSettings(validatedBody);
|
||||
const returnSettings = this.buildAppSettings(updatedSettings);
|
||||
const returnSettings = await this.buildAppSettings(updatedSettings);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "App settings updated successfully",
|
||||
|
||||
@@ -7,7 +7,7 @@ const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public";
|
||||
// the status page on top of the global helmet policy, blocking external images,
|
||||
// fonts, and stylesheets from custom CSS while keeping the app's Google Fonts.
|
||||
const STATUS_PAGE_CSP = [
|
||||
"img-src 'self' data:",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data: https://fonts.gstatic.com",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
].join("; ");
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { IProxiesController } from "@/api/controllers/proxyController.js";
|
||||
import { isAllowed } from "@/api/middleware/isAllowed.js";
|
||||
import { Router } from "express";
|
||||
|
||||
export const createProxyRoutes = (proxyController: IProxiesController): Router => {
|
||||
const router = Router();
|
||||
router.post("/", proxyController.createProxy);
|
||||
router.get("/", isAllowed(["admin", "superadmin"]), proxyController.getAllProxies);
|
||||
router.get("/team", proxyController.getProxiesByTeamId);
|
||||
router.get("/:id", proxyController.getProxyById);
|
||||
router.delete("/:id", proxyController.deleteProxy);
|
||||
|
||||
@@ -16,6 +16,13 @@ import { DateRanges, SortOrders } from "@/types/query.js";
|
||||
|
||||
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
|
||||
|
||||
// The client form submits proxyId: "" when no proxy is selected, set it to undefined
|
||||
const proxyIdValidation = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value === "" ? undefined : value))
|
||||
.refine((value) => value === undefined || /^[0-9a-f]{24}$/i.test(value), { message: "Invalid proxy ID" });
|
||||
|
||||
export const getMonitorByIdParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
});
|
||||
@@ -122,7 +129,7 @@ export const createMonitorBodyValidation = z
|
||||
url: z.string().min(1, "URL is required"),
|
||||
ignoreTlsErrors: z.boolean().default(false),
|
||||
proxyMode: z.enum(ProxyModes).default("inherit"),
|
||||
proxyId: z.string().optional(),
|
||||
proxyId: proxyIdValidation,
|
||||
useAdvancedMatching: z.boolean().default(false),
|
||||
port: z.number().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
@@ -171,7 +178,7 @@ export const editMonitorBodyValidation = z
|
||||
secret: z.string().optional(),
|
||||
ignoreTlsErrors: z.boolean().optional(),
|
||||
proxyMode: z.enum(ProxyModes).optional(),
|
||||
proxyId: z.string().optional(),
|
||||
proxyId: proxyIdValidation,
|
||||
useAdvancedMatching: z.boolean().optional(),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
@@ -233,7 +240,7 @@ const importedMonitorSchema = z
|
||||
type: z.enum(MonitorTypes, "Invalid monitor type"),
|
||||
ignoreTlsErrors: z.boolean().default(false),
|
||||
proxyMode: z.enum(ProxyModes).default("inherit"),
|
||||
proxyId: z.string().optional(),
|
||||
proxyId: proxyIdValidation,
|
||||
useAdvancedMatching: z.boolean().default(false),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
@@ -306,7 +313,7 @@ export const monitorResponseSchema = z
|
||||
statusWindowThreshold: z.number(),
|
||||
ignoreTlsErrors: z.boolean(),
|
||||
proxyMode: z.enum(ProxyModes),
|
||||
proxyId: z.string().optional(),
|
||||
proxyId: proxyIdValidation,
|
||||
useAdvancedMatching: z.boolean(),
|
||||
jsonPath: z.string().optional(),
|
||||
expectedValue: z.string().optional(),
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export const createApp = ({
|
||||
directives: {
|
||||
upgradeInsecureRequests: null,
|
||||
"script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
|
||||
"img-src": ["'self'", "data:", "https://img.shields.io"],
|
||||
"img-src": ["'self'", "data:", "blob:", "https://img.shields.io"],
|
||||
"object-src": ["'none'"],
|
||||
"base-uri": ["'self'"],
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont
|
||||
return {
|
||||
authController: new AuthController(apiServices.userService),
|
||||
monitorController: new MonitorController(apiServices.monitorService, apiServices.notificationsService),
|
||||
settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService),
|
||||
settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService, apiServices.proxiesService),
|
||||
checkController: new CheckController(apiServices.checkService),
|
||||
geoCheckController: new GeoCheckController(apiServices.geoChecksService),
|
||||
inviteController: new InviteController(apiServices.inviteService),
|
||||
|
||||
@@ -26,7 +26,7 @@ import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
|
||||
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
|
||||
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
|
||||
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
|
||||
|
||||
import { ProxyResolver } from "@/service/network/ProxyResolver.js";
|
||||
// Network providers
|
||||
import { PingProvider } from "@/service/network/PingProvider.js";
|
||||
import { HttpProvider } from "@/service/network/HttpProvider.js";
|
||||
@@ -64,6 +64,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
incidentsRepository,
|
||||
teamsRepository,
|
||||
maintenanceWindowsRepository,
|
||||
proxiesRepository,
|
||||
} = shared;
|
||||
|
||||
// ***********************
|
||||
@@ -93,6 +94,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
dnsProvider,
|
||||
]);
|
||||
|
||||
const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger);
|
||||
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository);
|
||||
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
|
||||
const monitorStatusPolicy = new MonitorStatusPolicy();
|
||||
@@ -110,7 +112,15 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
// Check producer/evaluator
|
||||
// Handles creating and evaluatiog checks
|
||||
// ***********************
|
||||
const checkProducer = new CheckProducer(monitorsRepository, maintenanceWindowsRepository, checkService, networkService, bufferService, logger);
|
||||
const checkProducer = new CheckProducer(
|
||||
monitorsRepository,
|
||||
maintenanceWindowsRepository,
|
||||
checkService,
|
||||
networkService,
|
||||
proxyResolver,
|
||||
bufferService,
|
||||
logger
|
||||
);
|
||||
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
|
||||
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger);
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { CheckFilter, DateRange } from "@/types/query.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { NETWORK_ERROR } from "@/types/network.js";
|
||||
|
||||
const SERVICE_NAME = "StatusService";
|
||||
const SERVICE_NAME = "ChecksRepository";
|
||||
|
||||
class MongoChecksRepository implements IChecksRepository {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
@@ -213,6 +213,9 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
};
|
||||
|
||||
private filterToMatch = (filter: CheckFilter | undefined): Record<string, unknown> => {
|
||||
if (filter === undefined) {
|
||||
return {};
|
||||
}
|
||||
switch (filter) {
|
||||
case "up":
|
||||
return { status: true };
|
||||
@@ -543,35 +546,33 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
totalChecks: upChecksDoc?.totalChecks ?? 0,
|
||||
};
|
||||
|
||||
const checks = (hardwareMetrics ?? []).map(
|
||||
(metric): HardwareCheckStats => ({
|
||||
bucketDate: metric._id,
|
||||
avgCpuUsage: metric.avgCpuUsage ?? 0,
|
||||
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
|
||||
avgTemperature: metric.avgTemperature ?? [],
|
||||
disks: (metric.disks ?? []).map((disk) => ({
|
||||
name: disk?.name ?? "",
|
||||
readSpeed: disk?.readSpeed ?? 0,
|
||||
writeSpeed: disk?.writeSpeed ?? 0,
|
||||
totalBytes: disk?.totalBytes ?? 0,
|
||||
freeBytes: disk?.freeBytes ?? 0,
|
||||
usagePercent: disk?.usagePercent ?? 0,
|
||||
})),
|
||||
net: (metric.net ?? []).map((iface) => ({
|
||||
name: iface?.name ?? "",
|
||||
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
|
||||
deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
|
||||
deltaPacketsSent: iface?.deltaPacketsSent ?? 0,
|
||||
deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0,
|
||||
deltaErrIn: iface?.deltaErrIn ?? 0,
|
||||
deltaErrOut: iface?.deltaErrOut ?? 0,
|
||||
deltaDropIn: iface?.deltaDropIn ?? 0,
|
||||
deltaDropOut: iface?.deltaDropOut ?? 0,
|
||||
deltaFifoIn: iface?.deltaFifoIn ?? 0,
|
||||
deltaFifoOut: iface?.deltaFifoOut ?? 0,
|
||||
})),
|
||||
})
|
||||
);
|
||||
const checks = (hardwareMetrics ?? []).map((metric): HardwareCheckStats => ({
|
||||
bucketDate: metric._id,
|
||||
avgCpuUsage: metric.avgCpuUsage ?? 0,
|
||||
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
|
||||
avgTemperature: metric.avgTemperature ?? [],
|
||||
disks: (metric.disks ?? []).map((disk) => ({
|
||||
name: disk?.name ?? "",
|
||||
readSpeed: disk?.readSpeed ?? 0,
|
||||
writeSpeed: disk?.writeSpeed ?? 0,
|
||||
totalBytes: disk?.totalBytes ?? 0,
|
||||
freeBytes: disk?.freeBytes ?? 0,
|
||||
usagePercent: disk?.usagePercent ?? 0,
|
||||
})),
|
||||
net: (metric.net ?? []).map((iface) => ({
|
||||
name: iface?.name ?? "",
|
||||
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
|
||||
deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
|
||||
deltaPacketsSent: iface?.deltaPacketsSent ?? 0,
|
||||
deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0,
|
||||
deltaErrIn: iface?.deltaErrIn ?? 0,
|
||||
deltaErrOut: iface?.deltaErrOut ?? 0,
|
||||
deltaDropIn: iface?.deltaDropIn ?? 0,
|
||||
deltaDropOut: iface?.deltaDropOut ?? 0,
|
||||
deltaFifoIn: iface?.deltaFifoIn ?? 0,
|
||||
deltaFifoOut: iface?.deltaFifoOut ?? 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
monitorType: "hardware" as const,
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface IMonitorsRepository {
|
||||
findByIds(monitorIds: string[], options?: { recentChecks?: RecentChecksMode }): Promise<Monitor[]>;
|
||||
|
||||
// update
|
||||
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor>;
|
||||
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>, options?: { unsetProxyId?: boolean }): Promise<Monitor>;
|
||||
updateByIds(monitorIds: string[], teamId: string, updates: Partial<Monitor>, excludeStatuses?: MonitorStatus[]): Promise<number>;
|
||||
updateStatusWindowAndChecks(
|
||||
monitorId: string,
|
||||
|
||||
@@ -177,16 +177,14 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
return count;
|
||||
};
|
||||
|
||||
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>) => {
|
||||
const updatedMonitor = await MonitorModel.findOneAndUpdate(
|
||||
{ _id: monitorId, teamId },
|
||||
{
|
||||
$set: {
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>, options?: { unsetProxyId?: boolean }) => {
|
||||
const fields: Partial<Monitor> = { ...patch };
|
||||
const update: Record<string, unknown> = { $set: fields };
|
||||
if (options?.unsetProxyId) {
|
||||
delete fields.proxyId;
|
||||
update.$unset = { proxyId: "" };
|
||||
}
|
||||
const updatedMonitor = await MonitorModel.findOneAndUpdate({ _id: monitorId, teamId }, update, { new: true, runValidators: true });
|
||||
if (!updatedMonitor) {
|
||||
throw new AppError({ message: `Failed to update monitor with id ${monitorId}`, status: 500 });
|
||||
}
|
||||
|
||||
@@ -133,6 +133,10 @@ export class MonitorService implements IMonitorService {
|
||||
}
|
||||
|
||||
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
|
||||
// proxyId is only needed in custom mode
|
||||
if (body.proxyMode !== "custom") {
|
||||
delete body.proxyId;
|
||||
}
|
||||
const monitor = await this.monitorsRepository.create(body, teamId, userId);
|
||||
if (!monitor) {
|
||||
throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" });
|
||||
@@ -375,7 +379,12 @@ export class MonitorService implements IMonitorService {
|
||||
};
|
||||
|
||||
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
|
||||
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body);
|
||||
// Moving off custom mode orphans the stored proxyId. Unset it so the stale reference doesn't block deleting that proxy
|
||||
const unsetProxyId = body.proxyMode !== undefined && body.proxyMode !== "custom";
|
||||
if (unsetProxyId) {
|
||||
delete body.proxyId;
|
||||
}
|
||||
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId });
|
||||
await this.scheduler.updateJob(editedMonitor);
|
||||
return editedMonitor;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,9 @@ export interface IProxiesRepository {
|
||||
// create
|
||||
create(proxyData: Partial<Proxy>): Promise<Proxy>;
|
||||
// read
|
||||
findAll(): Promise<Proxy[]>;
|
||||
findById(proxyId: string, teamId: string): Promise<Proxy>;
|
||||
findByIdOrNull(proxyId: string, teamId?: string): Promise<Proxy | null>;
|
||||
findByTeamId(teamId: string): Promise<Proxy[]>;
|
||||
// update
|
||||
updateById(proxyId: string, teamId: string, patch: Partial<Proxy>, options?: { unsetPassword?: boolean; unsetUsername?: boolean }): Promise<Proxy>;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Proxy } from "@/domain/proxies/proxy.type.js";
|
||||
import { ProxyDocument, ProxyModel } from "@/domain/proxies/proxy.model.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
|
||||
import { UpdateQuery } from "mongoose";
|
||||
import { Error as MongooseError, UpdateQuery } from "mongoose";
|
||||
|
||||
const SERVICE_NAME = "ProxiesRepository";
|
||||
|
||||
@@ -37,6 +37,11 @@ class MongoProxiesRepository implements IProxiesRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(): Promise<Proxy[]> {
|
||||
const proxies = await ProxyModel.find();
|
||||
return proxies.map(this.toEntity);
|
||||
}
|
||||
|
||||
async findById(proxyId: string, teamId: string): Promise<Proxy> {
|
||||
const proxy = await ProxyModel.findOne({ _id: proxyId, teamId });
|
||||
if (!proxy) {
|
||||
@@ -45,6 +50,18 @@ class MongoProxiesRepository implements IProxiesRepository {
|
||||
return this.toEntity(proxy);
|
||||
}
|
||||
|
||||
async findByIdOrNull(proxyId: string, teamId?: string): Promise<Proxy | null> {
|
||||
try {
|
||||
const proxy = await ProxyModel.findOne(teamId ? { _id: proxyId, teamId } : { _id: proxyId });
|
||||
return proxy ? this.toEntity(proxy) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof MongooseError.CastError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<Proxy[]> {
|
||||
const proxies = await ProxyModel.find({ teamId });
|
||||
return proxies.map(this.toEntity);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Proxy, ProxyResponse } from "@/domain/proxies/proxy.type.js";
|
||||
import { Proxy, ProxyResponse, ProxySummary } from "@/domain/proxies/proxy.type.js";
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
@@ -7,6 +7,8 @@ import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"
|
||||
export interface IProxiesService {
|
||||
createProxy(proxy: Partial<Proxy>, teamId: string): Promise<ProxyResponse>;
|
||||
getProxy(proxyId: string, teamId: string): Promise<ProxyResponse>;
|
||||
getProxies(): Promise<ProxyResponse[]>;
|
||||
getProxySummary(proxyId: string): Promise<ProxySummary | null>;
|
||||
getProxiesByTeamId(teamId: string): Promise<ProxyResponse[]>;
|
||||
updateProxy(proxyId: string, teamId: string, patch: Partial<Proxy> & { clearPassword?: boolean; clearUsername?: boolean }): Promise<ProxyResponse>;
|
||||
deleteProxy(proxyId: string, teamId: string): Promise<ProxyResponse>;
|
||||
@@ -39,6 +41,23 @@ export class ProxiesService implements IProxiesService {
|
||||
return this.toResponse(raw);
|
||||
};
|
||||
|
||||
getProxies = async (): Promise<ProxyResponse[]> => {
|
||||
const raw = await this.proxiesRepository.findAll();
|
||||
const clean = raw.map((r) => {
|
||||
return this.toResponse(r);
|
||||
});
|
||||
return clean;
|
||||
};
|
||||
|
||||
getProxySummary = async (proxyId: string): Promise<ProxySummary | null> => {
|
||||
const proxy = await this.proxiesRepository.findByIdOrNull(proxyId);
|
||||
if (!proxy) {
|
||||
return null;
|
||||
}
|
||||
const { id, name, host, port } = proxy;
|
||||
return { id, name, host, port };
|
||||
};
|
||||
|
||||
getProxiesByTeamId = async (teamId: string): Promise<ProxyResponse[]> => {
|
||||
const raw = await this.proxiesRepository.findByTeamId(teamId);
|
||||
const clean = raw.map((r) => {
|
||||
|
||||
@@ -15,3 +15,5 @@ export interface Proxy {
|
||||
}
|
||||
|
||||
export type ProxyResponse = Omit<Proxy, "password"> & { hasPassword: boolean };
|
||||
|
||||
export type ProxySummary = Pick<Proxy, "id" | "name" | "host" | "port">;
|
||||
@@ -9,11 +9,17 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { isStatusUp } from "@/service/network/utils.js";
|
||||
import { NETWORK_ERROR } from "@/types/network.js";
|
||||
import CacheableLookup from "cacheable-lookup";
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
|
||||
import { CheckContext } from "@/types/network.js";
|
||||
|
||||
export const PROXY_CONNECT_ERROR_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "EAI_AGAIN", "EPIPE"]);
|
||||
|
||||
export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
readonly type = "http";
|
||||
|
||||
// Shared, pooled agents reused across every check
|
||||
private static readonly PROXY_AGENT_CACHE_MAX = 50;
|
||||
private readonly proxyAgents = new Map<string, { http: HttpProxyAgent; https: HttpsProxyAgent }>();
|
||||
private readonly httpAgent: HttpAgent;
|
||||
private readonly httpsAgent: HttpsAgent;
|
||||
private readonly httpsAgentInsecure: HttpsAgent;
|
||||
@@ -41,6 +47,44 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
return type === "http";
|
||||
}
|
||||
|
||||
private proxyFailureMessage = (error: RequestError, proxyUrl: string): string | undefined => {
|
||||
if (error.response || !error.code || !PROXY_CONNECT_ERROR_CODES.has(error.code)) {
|
||||
return undefined;
|
||||
}
|
||||
const { hostname, port } = new URL(proxyUrl);
|
||||
return `Proxy connection failed (${hostname}:${port}): ${error.message}`;
|
||||
};
|
||||
|
||||
private getProxyAgents(proxyUrl: string, ignoreTlsErrors: boolean) {
|
||||
const key = `${proxyUrl}|${ignoreTlsErrors}`;
|
||||
const hit = this.proxyAgents.get(key);
|
||||
// Agent already exists, refresh
|
||||
if (hit) {
|
||||
this.proxyAgents.delete(key); // Refresh
|
||||
this.proxyAgents.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
|
||||
// Create new agents
|
||||
const agentOptions = { keepAlive: true, maxSockets: 256, maxFreeSockets: 256, proxy: proxyUrl };
|
||||
const pair = {
|
||||
http: new HttpProxyAgent(agentOptions),
|
||||
https: new HttpsProxyAgent({ ...agentOptions, rejectUnauthorized: !ignoreTlsErrors }),
|
||||
};
|
||||
|
||||
// Cache agents
|
||||
this.proxyAgents.set(key, pair);
|
||||
if (this.proxyAgents.size > HttpProvider.PROXY_AGENT_CACHE_MAX) {
|
||||
const [oldestKey] = this.proxyAgents.keys(); // Destructuring gets the first inserted, ie oldest, item
|
||||
if (oldestKey === undefined) return pair;
|
||||
const oldest = this.proxyAgents.get(oldestKey);
|
||||
oldest?.http.destroy();
|
||||
oldest?.https.destroy();
|
||||
this.proxyAgents.delete(oldestKey);
|
||||
}
|
||||
return pair;
|
||||
}
|
||||
|
||||
private buildResponse<T>(
|
||||
monitor: Monitor,
|
||||
opts: {
|
||||
@@ -113,20 +157,22 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
private handleHttpError<T>(error: unknown, monitor: Monitor): MonitorStatusResponse<T> {
|
||||
private handleHttpError<T>(error: unknown, monitor: Monitor, ctx?: CheckContext): MonitorStatusResponse<T> {
|
||||
if (error instanceof HTTPError || error instanceof RequestError) {
|
||||
const statusCode = error.response?.statusCode;
|
||||
const statusUp = isStatusUp(statusCode, monitor.customUpCodes);
|
||||
const responseTime = error.timings?.phases?.total ?? 0;
|
||||
|
||||
if (!statusUp) {
|
||||
const message = (ctx?.proxyUrl && error instanceof RequestError && this.proxyFailureMessage(error, ctx.proxyUrl)) || error.message;
|
||||
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code: statusCode ?? NETWORK_ERROR,
|
||||
message: error.message,
|
||||
message: message,
|
||||
responseTime,
|
||||
timings: error.timings,
|
||||
payload: null as T,
|
||||
@@ -156,7 +202,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
async handle<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> {
|
||||
async handle<T>(monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<T>> {
|
||||
const { url, secret, ignoreTlsErrors } = monitor;
|
||||
|
||||
if (!url) {
|
||||
@@ -167,10 +213,12 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined,
|
||||
};
|
||||
|
||||
options.agent = {
|
||||
http: this.httpAgent,
|
||||
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
|
||||
};
|
||||
options.agent = ctx?.proxyUrl
|
||||
? this.getProxyAgents(ctx.proxyUrl, Boolean(ignoreTlsErrors))
|
||||
: {
|
||||
http: this.httpAgent,
|
||||
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
|
||||
};
|
||||
|
||||
options.method = monitor.method;
|
||||
|
||||
@@ -188,7 +236,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
timings: response.timings,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return this.handleHttpError(error, monitor);
|
||||
return this.handleHttpError(error, monitor, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { MonitorStatusResponse } from "@/types/network.js";
|
||||
|
||||
import { CheckContext } from "@/types/network.js";
|
||||
export interface IStatusProvider<T> {
|
||||
type: string;
|
||||
supports: (type: MonitorType) => boolean;
|
||||
handle(monitor: Monitor): Promise<MonitorStatusResponse<T>>;
|
||||
handle(monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<T>>;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { Proxy } from "@/domain/proxies/proxy.type.js";
|
||||
|
||||
export interface IProxyResolver {
|
||||
resolve(monitor: Monitor): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
const SERVICE_NAME = "ProxyResolver";
|
||||
|
||||
export const buildProxyUrl = (proxy: Proxy): string => {
|
||||
const { protocol, host, port, username, password } = proxy;
|
||||
let credentials = "";
|
||||
if (username) {
|
||||
credentials = password ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` : `${encodeURIComponent(username)}@`;
|
||||
}
|
||||
return `${protocol}://${credentials}${host}:${port}`;
|
||||
};
|
||||
|
||||
export class ProxyResolver implements IProxyResolver {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
constructor(
|
||||
private proxiesRepository: IProxiesRepository,
|
||||
private settingsService: ISettingsService,
|
||||
private logger: ILogger,
|
||||
private ttlMs = 60_000
|
||||
) {}
|
||||
|
||||
private cache = new Map<string, { value: Promise<unknown>; expiresAt: number }>();
|
||||
|
||||
private getCached<T>(key: string, fetch: () => Promise<T>): Promise<T> {
|
||||
const hit = this.cache.get(key);
|
||||
if (hit && hit.expiresAt > Date.now()) {
|
||||
return hit.value as Promise<T>;
|
||||
}
|
||||
// Cache the in flight promise so concurrent misses share one fetch
|
||||
const value = fetch();
|
||||
this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
||||
// Don't negative cache rejections, evict so the next resolve retries
|
||||
value.catch(() => this.cache.delete(key));
|
||||
return value;
|
||||
}
|
||||
|
||||
private warn(message: string, monitor: Monitor, error?: unknown) {
|
||||
this.logger.warn({
|
||||
message,
|
||||
service: SERVICE_NAME,
|
||||
method: "resolve",
|
||||
details: {
|
||||
monitorId: monitor.id,
|
||||
proxyMode: monitor.proxyMode,
|
||||
proxyId: monitor.proxyId,
|
||||
...(error instanceof Error ? { error: error.message } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveCustom = async (monitor: Monitor): Promise<string | undefined> => {
|
||||
const { proxyId, teamId } = monitor;
|
||||
if (!proxyId) {
|
||||
this.warn("Proxy mode is custom, but proxyId is missing, connecting direct", monitor);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const proxy = await this.getCached(`proxy:${proxyId}:${teamId}`, () => this.proxiesRepository.findByIdOrNull(proxyId, teamId));
|
||||
if (!proxy) {
|
||||
this.warn("Proxy not found, connecting direct", monitor);
|
||||
return undefined;
|
||||
}
|
||||
return buildProxyUrl(proxy);
|
||||
};
|
||||
|
||||
private resolveInherit = async (monitor: Monitor): Promise<string | undefined> => {
|
||||
const settings = await this.getCached("settings", () => this.settingsService.getDBSettings());
|
||||
if (!settings.globalProxyEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
const globalProxyId = settings.globalProxyId;
|
||||
if (!globalProxyId) {
|
||||
return undefined;
|
||||
}
|
||||
const proxy = await this.getCached(`proxy:${globalProxyId}`, () => this.proxiesRepository.findByIdOrNull(globalProxyId));
|
||||
if (!proxy) {
|
||||
this.warn("Global proxy references a proxy that no longer exists, connecting direct", monitor);
|
||||
return undefined;
|
||||
}
|
||||
return buildProxyUrl(proxy);
|
||||
};
|
||||
|
||||
async resolve(monitor: Monitor): Promise<string | undefined> {
|
||||
if (monitor.type !== "http") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
switch (monitor.proxyMode) {
|
||||
case "none":
|
||||
return undefined;
|
||||
case "custom":
|
||||
return await this.resolveCustom(monitor);
|
||||
default:
|
||||
// "inherit" is the default mode
|
||||
return await this.resolveInherit(monitor);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.warn("Proxy resolution failed, connecting direct", monitor, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
|
||||
import type { CheckContext, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
|
||||
import type { AxiosStatic } from "axios";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { NETWORK_ERROR } from "@/types/network.js";
|
||||
@@ -8,7 +8,7 @@ import { IStatusProvider } from "./network/IStatusProvider.js";
|
||||
const SERVICE_NAME = "NetworkService";
|
||||
|
||||
export interface INetworkService {
|
||||
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
requestWebhook(
|
||||
type: string,
|
||||
url: string,
|
||||
@@ -39,12 +39,15 @@ export class NetworkService implements INetworkService {
|
||||
}
|
||||
|
||||
// Main entry point
|
||||
async requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
|
||||
async requestStatus<T extends MonitorType>(
|
||||
monitor: Monitor & { type: T },
|
||||
ctx?: CheckContext
|
||||
): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
|
||||
const provider = this.providers.find((p) => p.supports(monitor.type));
|
||||
if (!provider) {
|
||||
return this.handleUnsupportedType(monitor.type) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
}
|
||||
return provider.handle(monitor) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
return provider.handle(monitor, ctx) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
}
|
||||
|
||||
private async handleUnsupportedType(type: string): Promise<MonitorStatusResponse> {
|
||||
|
||||
@@ -152,3 +152,7 @@ export type StatusChangeResult = {
|
||||
};
|
||||
|
||||
export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
|
||||
|
||||
export type CheckContext = {
|
||||
proxyUrl?: string;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { ICheckService } from "@/domain/checks/check.service.js";
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
|
||||
import { isWindowActive } from "@/utils/maintenanceWindow.js";
|
||||
import { IProxyResolver } from "@/service/network/ProxyResolver.js";
|
||||
|
||||
export interface ICheckProducer {
|
||||
produce(monitor: Monitor): Promise<{ status: MonitorStatusResponse; check: Check } | null>;
|
||||
@@ -22,6 +23,7 @@ export class CheckProducer implements ICheckProducer {
|
||||
private maintenanceWindowsRepository: IMaintenanceWindowsRepository,
|
||||
private checkService: ICheckService,
|
||||
private networkService: INetworkService,
|
||||
private proxyResolver: IProxyResolver,
|
||||
private bufferService: IBufferService,
|
||||
private logger: ILogger
|
||||
) {}
|
||||
@@ -56,7 +58,8 @@ export class CheckProducer implements ICheckProducer {
|
||||
}
|
||||
|
||||
// Step 1b: Acquire status
|
||||
const status = await this.networkService.requestStatus(monitor);
|
||||
const proxyUrl = await this.proxyResolver.resolve(monitor);
|
||||
const status = await this.networkService.requestStatus(monitor, { proxyUrl });
|
||||
if (!status) {
|
||||
throw new Error("No network response");
|
||||
}
|
||||
|
||||
@@ -127,12 +127,14 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||
};
|
||||
|
||||
const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) };
|
||||
const proxyResolver = { resolve: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const checkProducer = new CheckProducer(
|
||||
monitorsRepo as any,
|
||||
maintenanceWindowsRepo as any,
|
||||
checkService as any,
|
||||
networkService as any,
|
||||
proxyResolver as any,
|
||||
bufferStub as any,
|
||||
logger
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
|
||||
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
||||
@@ -494,4 +495,152 @@ describe("HttpProvider", () => {
|
||||
expect(advancedMatcher.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Proxy support ────────────────────────────────────────────────────
|
||||
|
||||
describe("proxy support", () => {
|
||||
const PROXY_URL = "http://proxy.example.com:8080";
|
||||
|
||||
const gotOptions = (callIndex = 0) => mockGot.mock.calls[callIndex]?.[1] as any;
|
||||
|
||||
const makeConnectionError = (code: string, message = `connect ${code} 10.0.0.1:8080`) => {
|
||||
const err = new RequestError(message);
|
||||
(err as any).code = code;
|
||||
return err;
|
||||
};
|
||||
|
||||
it("uses hpagent proxy agents when ctx.proxyUrl is set", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
|
||||
expect(gotOptions().agent.http).toBeInstanceOf(HttpProxyAgent);
|
||||
expect(gotOptions().agent.https).toBeInstanceOf(HttpsProxyAgent);
|
||||
});
|
||||
|
||||
it("uses the shared agents when ctx has no proxyUrl", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
await provider.handle(makeMonitor(), {});
|
||||
|
||||
expect(gotOptions(0).agent.http).not.toBeInstanceOf(HttpProxyAgent);
|
||||
expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http);
|
||||
expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https);
|
||||
});
|
||||
|
||||
it("reuses the cached agent pair across checks with the same proxyUrl", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
|
||||
expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http);
|
||||
expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https);
|
||||
});
|
||||
|
||||
it("builds distinct agent pairs for distinct proxyUrls", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
await provider.handle(makeMonitor(), { proxyUrl: "http://other.example.com:3128" });
|
||||
|
||||
expect(gotOptions(1).agent.http).not.toBe(gotOptions(0).agent.http);
|
||||
});
|
||||
|
||||
it("builds distinct pairs per ignoreTlsErrors and sets rejectUnauthorized accordingly", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ ignoreTlsErrors: false }), { proxyUrl: PROXY_URL });
|
||||
await provider.handle(makeMonitor({ ignoreTlsErrors: true }), { proxyUrl: PROXY_URL });
|
||||
|
||||
expect(gotOptions(1).agent.https).not.toBe(gotOptions(0).agent.https);
|
||||
expect(gotOptions(0).agent.https.options.rejectUnauthorized).toBe(true);
|
||||
expect(gotOptions(1).agent.https.options.rejectUnauthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("evicts and destroys the least-recently-used pair past the cache cap", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" });
|
||||
const first = gotOptions(0).agent;
|
||||
const httpDestroy = jest.spyOn(first.http, "destroy");
|
||||
const httpsDestroy = jest.spyOn(first.https, "destroy");
|
||||
|
||||
for (let i = 1; i <= 49; i++) {
|
||||
await provider.handle(makeMonitor(), { proxyUrl: `http://proxy${i}.example.com:8080` });
|
||||
}
|
||||
expect(httpDestroy).not.toHaveBeenCalled();
|
||||
|
||||
await provider.handle(makeMonitor(), { proxyUrl: "http://proxy50.example.com:8080" });
|
||||
|
||||
expect(httpDestroy).toHaveBeenCalled();
|
||||
expect(httpsDestroy).toHaveBeenCalled();
|
||||
// The evicted pair is gone — the same proxyUrl now gets a fresh pair
|
||||
await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" });
|
||||
expect(gotOptions(51).agent.http).not.toBe(first.http);
|
||||
});
|
||||
|
||||
it("names the proxy in the message when the proxy connection fails", async () => {
|
||||
mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED"));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
expect(result.message).toBe("Proxy connection failed (proxy.example.com:8080): connect ECONNREFUSED 10.0.0.1:8080");
|
||||
});
|
||||
|
||||
it("does not attribute timeouts to the proxy", async () => {
|
||||
mockGot.mockRejectedValue(makeConnectionError("ETIMEDOUT", "Timeout awaiting 'request' for 30000ms"));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("Timeout awaiting 'request' for 30000ms");
|
||||
});
|
||||
|
||||
it("leaves connection-error messages unprefixed for direct checks", async () => {
|
||||
mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED"));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("connect ECONNREFUSED 10.0.0.1:8080");
|
||||
});
|
||||
|
||||
it("never leaks proxy credentials into the message", async () => {
|
||||
mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED"));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor(), { proxyUrl: "http://bob:sekret@proxy.example.com:8080" });
|
||||
|
||||
expect(result.message).toContain("proxy.example.com:8080");
|
||||
expect(result.message).not.toContain("bob");
|
||||
expect(result.message).not.toContain("sekret");
|
||||
});
|
||||
|
||||
it("does not prefix errors that carry a response (target failure through a healthy proxy)", async () => {
|
||||
const err = new RequestError("Response code 500 (Internal Server Error)");
|
||||
(err as any).code = "ECONNRESET";
|
||||
(err as any).response = { statusCode: 500, body: "", headers: {} };
|
||||
(err as any).timings = { phases: { total: 25 } };
|
||||
mockGot.mockRejectedValue(err);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(500);
|
||||
expect(result.message).toBe("Response code 500 (Internal Server Error)");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ const createProducer = (overrides?: Record<string, any>) => {
|
||||
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) },
|
||||
checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) },
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
|
||||
proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) },
|
||||
buffer: { addToBuffer: jest.fn() },
|
||||
...overrides,
|
||||
};
|
||||
@@ -34,6 +35,7 @@ const createProducer = (overrides?: Record<string, any>) => {
|
||||
defaults.maintenanceWindowsRepository as any,
|
||||
defaults.checkService as any,
|
||||
defaults.networkService as any,
|
||||
defaults.proxyResolver as any,
|
||||
defaults.buffer as any,
|
||||
defaults.logger as any
|
||||
);
|
||||
@@ -82,6 +84,29 @@ describe("CheckProducer", () => {
|
||||
expect(defaults.networkService.requestStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── proxy resolution ──────────────────────────────────────────────────────
|
||||
|
||||
it("passes the resolved proxy url into requestStatus", async () => {
|
||||
const { producer, defaults } = createProducer({
|
||||
proxyResolver: { resolve: jest.fn().mockResolvedValue("http://proxy.example.com:8080") },
|
||||
});
|
||||
const monitor = makeMonitor();
|
||||
|
||||
await producer.produce(monitor);
|
||||
|
||||
expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: "http://proxy.example.com:8080" });
|
||||
});
|
||||
|
||||
it("still produces a check when the resolver returns undefined", async () => {
|
||||
const { producer, defaults } = createProducer();
|
||||
const monitor = makeMonitor();
|
||||
|
||||
const result = await producer.produce(monitor);
|
||||
|
||||
expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined });
|
||||
expect(result).toEqual({ status: expect.objectContaining({ monitorId: "m1" }), check: { id: "check-1" } });
|
||||
});
|
||||
|
||||
// ── acquire / record ──────────────────────────────────────────────────────
|
||||
|
||||
it("throws when the network response is null", async () => {
|
||||
|
||||
@@ -124,6 +124,38 @@ describe("MonitorService", () => {
|
||||
expect(jobQueue.addJob).toHaveBeenCalledWith(MONITOR_ID, monitor);
|
||||
});
|
||||
|
||||
it("strips a stray proxyId when proxyMode is not custom", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const monitor = makeMonitor();
|
||||
(monitorsRepository.create as jest.Mock).mockResolvedValue(monitor);
|
||||
const { service } = createService({ monitorsRepository });
|
||||
|
||||
await service.createMonitor(TEAM_ID, USER_ID, {
|
||||
...monitor,
|
||||
proxyMode: "inherit",
|
||||
proxyId: "64b7a1f2c9e77a001a2b3c4d",
|
||||
} as any);
|
||||
|
||||
const createdBody = (monitorsRepository.create as jest.Mock).mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(createdBody.proxyId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps proxyId when proxyMode is custom", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const monitor = makeMonitor();
|
||||
(monitorsRepository.create as jest.Mock).mockResolvedValue(monitor);
|
||||
const { service } = createService({ monitorsRepository });
|
||||
|
||||
await service.createMonitor(TEAM_ID, USER_ID, {
|
||||
...monitor,
|
||||
proxyMode: "custom",
|
||||
proxyId: "64b7a1f2c9e77a001a2b3c4d",
|
||||
} as any);
|
||||
|
||||
const createdBody = (monitorsRepository.create as jest.Mock).mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(createdBody.proxyId).toBe("64b7a1f2c9e77a001a2b3c4d");
|
||||
});
|
||||
|
||||
it("throws when repository returns null", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.create as jest.Mock).mockResolvedValue(null);
|
||||
@@ -750,9 +782,42 @@ describe("MonitorService", () => {
|
||||
const result = await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { name: "Updated" } });
|
||||
|
||||
expect(result).toMatchObject({ name: "Updated" });
|
||||
expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { name: "Updated" });
|
||||
expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { name: "Updated" }, { unsetProxyId: false });
|
||||
expect(jobQueue.updateJob).toHaveBeenCalledWith(updatedMonitor);
|
||||
});
|
||||
|
||||
it("keeps proxyId and does not unset it when proxyMode is custom", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor());
|
||||
const { service } = createService({ monitorsRepository });
|
||||
|
||||
await service.editMonitor({
|
||||
teamId: TEAM_ID,
|
||||
monitorId: MONITOR_ID,
|
||||
body: { proxyMode: "custom", proxyId: "64b7a1f2c9e77a001a2b3c4d" } as any,
|
||||
});
|
||||
|
||||
expect(monitorsRepository.updateById).toHaveBeenCalledWith(
|
||||
MONITOR_ID,
|
||||
TEAM_ID,
|
||||
{ proxyMode: "custom", proxyId: "64b7a1f2c9e77a001a2b3c4d" },
|
||||
{ unsetProxyId: false }
|
||||
);
|
||||
});
|
||||
|
||||
it("strips proxyId and unsets it when proxyMode moves off custom", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor());
|
||||
const { service } = createService({ monitorsRepository });
|
||||
|
||||
await service.editMonitor({
|
||||
teamId: TEAM_ID,
|
||||
monitorId: MONITOR_ID,
|
||||
body: { proxyMode: "inherit", proxyId: "64b7a1f2c9e77a001a2b3c4d" } as any,
|
||||
});
|
||||
|
||||
expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { proxyMode: "inherit" }, { unsetProxyId: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateNotifications", () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("NetworkService", () => {
|
||||
const result = await service.requestStatus(monitor);
|
||||
|
||||
expect(provider.supports).toHaveBeenCalledWith("http");
|
||||
expect(provider.handle).toHaveBeenCalledWith(monitor);
|
||||
expect(provider.handle).toHaveBeenCalledWith(monitor, undefined);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
@@ -73,7 +73,18 @@ describe("NetworkService", () => {
|
||||
await service.requestStatus(monitor);
|
||||
|
||||
expect(httpProvider.handle).not.toHaveBeenCalled();
|
||||
expect(pingProvider.handle).toHaveBeenCalledWith(monitor);
|
||||
expect(pingProvider.handle).toHaveBeenCalledWith(monitor, undefined);
|
||||
});
|
||||
|
||||
it("forwards the check context to the provider", async () => {
|
||||
const provider = createMockProvider("http");
|
||||
const { service } = createService([provider]);
|
||||
const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" };
|
||||
const ctx = { proxyUrl: "http://proxy.example.com:8080" };
|
||||
|
||||
await service.requestStatus(monitor, ctx);
|
||||
|
||||
expect(provider.handle).toHaveBeenCalledWith(monitor, ctx);
|
||||
});
|
||||
|
||||
it("returns unsupported-type response when no provider matches", async () => {
|
||||
|
||||
@@ -6,7 +6,9 @@ import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts";
|
||||
|
||||
const createProxiesRepo = () => ({
|
||||
create: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByIdOrNull: jest.fn(),
|
||||
findByTeamId: jest.fn(),
|
||||
updateById: jest.fn(),
|
||||
deleteById: jest.fn(),
|
||||
@@ -105,6 +107,44 @@ describe("ProxiesService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProxies", () => {
|
||||
it("lists every proxy on the instance and masks passwords", async () => {
|
||||
const { service, proxiesRepository } = createService();
|
||||
(proxiesRepository.findAll as jest.Mock).mockResolvedValue([makeProxy(), makeProxy({ id: "proxy-2", teamId: "team-2", password: undefined })]);
|
||||
|
||||
const result = await service.getProxies();
|
||||
|
||||
expect(proxiesRepository.findAll).toHaveBeenCalledWith();
|
||||
expect(result).toHaveLength(2);
|
||||
for (const proxy of result) {
|
||||
expect(proxy).not.toHaveProperty("password");
|
||||
}
|
||||
expect(result[0].hasPassword).toBe(true);
|
||||
expect(result[1].hasPassword).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProxySummary", () => {
|
||||
it("looks up without a team filter and returns only display fields", async () => {
|
||||
const { service, proxiesRepository } = createService();
|
||||
(proxiesRepository.findByIdOrNull as jest.Mock).mockResolvedValue(makeProxy());
|
||||
|
||||
const result = await service.getProxySummary("proxy-1");
|
||||
|
||||
expect(proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("proxy-1");
|
||||
expect(result).toEqual({ id: "proxy-1", name: "Egress proxy", host: "proxy.internal", port: 3128 });
|
||||
});
|
||||
|
||||
it("returns null when the proxy does not exist", async () => {
|
||||
const { service, proxiesRepository } = createService();
|
||||
(proxiesRepository.findByIdOrNull as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const result = await service.getProxySummary("missing");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProxiesByTeamId", () => {
|
||||
it("masks every proxy in the list", async () => {
|
||||
const { service, proxiesRepository } = createService();
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { ProxyResolver, buildProxyUrl } from "../../../src/service/network/ProxyResolver.ts";
|
||||
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
|
||||
import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "m1",
|
||||
teamId: "team",
|
||||
type: "http",
|
||||
proxyMode: "inherit",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeProxy = (overrides?: Partial<Proxy>): Proxy =>
|
||||
({
|
||||
id: "p1",
|
||||
teamId: "team",
|
||||
name: "corp",
|
||||
protocol: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 8080,
|
||||
...overrides,
|
||||
}) as Proxy;
|
||||
|
||||
const createResolver = (overrides?: Record<string, any>) => {
|
||||
const defaults = {
|
||||
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(makeProxy()) },
|
||||
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: "p1" }) },
|
||||
logger: createMockLogger(),
|
||||
ttlMs: 60_000,
|
||||
...overrides,
|
||||
};
|
||||
const resolver = new ProxyResolver(defaults.proxiesRepository as any, defaults.settingsService as any, defaults.logger as any, defaults.ttlMs);
|
||||
return { resolver, defaults };
|
||||
};
|
||||
|
||||
describe("ProxyResolver", () => {
|
||||
// ── gates ─────────────────────────────────────────────────────────────────
|
||||
|
||||
it("returns undefined for non-http monitors without touching repo or settings", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ type: "port", proxyMode: "custom", proxyId: "p1" } as Partial<Monitor>));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
|
||||
expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined for proxyMode 'none' without calling settings", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "none" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── custom mode ───────────────────────────────────────────────────────────
|
||||
|
||||
it("resolves a custom proxy with a team-scoped lookup", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
|
||||
|
||||
expect(result).toBe("http://proxy.example.com:8080");
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1", "team");
|
||||
});
|
||||
|
||||
it("returns undefined and warns when the custom proxy no longer exists", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(null) },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined and warns when proxyMode is custom but proxyId is missing, without a repo call", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: undefined }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.logger.warn).toHaveBeenCalled();
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── inherit mode ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns undefined when the global proxy is disabled, without a repo call", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: false, globalProxyId: "p1" }) },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves the global proxy with an unscoped lookup when enabled", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(result).toBe("http://proxy.example.com:8080");
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1");
|
||||
});
|
||||
|
||||
it("returns undefined when the global proxy is enabled but globalProxyId is null, without a repo call", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: null }) },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined and warns when the global proxy references a proxy that no longer exists", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(null) },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats an unknown proxyMode as inherit", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "garbage" as Monitor["proxyMode"] }));
|
||||
|
||||
expect(result).toBe("http://proxy.example.com:8080");
|
||||
expect(defaults.settingsService.getDBSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── never throws ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns undefined and warns when the repository throws", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockRejectedValue(new Error("db down")) },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined and warns when the settings service throws", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockRejectedValue(new Error("db down")) },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── caching ───────────────────────────────────────────────────────────────
|
||||
|
||||
it("serves a second resolve within the TTL from cache", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1);
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refetches after the TTL expires", async () => {
|
||||
const { resolver, defaults } = createResolver({ ttlMs: 0 });
|
||||
|
||||
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(2);
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("negative-caches a dangling ref, costing one repo call per TTL window", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(null) },
|
||||
});
|
||||
|
||||
await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" }));
|
||||
await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" }));
|
||||
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shares one in-flight fetch between concurrent resolves", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
await Promise.all([
|
||||
resolver.resolve(makeMonitor({ proxyMode: "inherit" })),
|
||||
resolver.resolve(makeMonitor({ proxyMode: "inherit" })),
|
||||
resolver.resolve(makeMonitor({ proxyMode: "inherit" })),
|
||||
]);
|
||||
|
||||
expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1);
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache a rejected fetch — the next resolve retries", async () => {
|
||||
const findByIdOrNull = jest.fn<() => Promise<Proxy | null>>().mockRejectedValueOnce(new Error("db down")).mockResolvedValueOnce(makeProxy());
|
||||
const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } });
|
||||
|
||||
const first = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
|
||||
const second = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
|
||||
|
||||
expect(first).toBeUndefined();
|
||||
expect(second).toBe("http://proxy.example.com:8080");
|
||||
expect(findByIdOrNull).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not let a scoped miss poison the unscoped global lookup for the same proxy id", async () => {
|
||||
const findByIdOrNull = jest.fn<(proxyId: string, teamId?: string) => Promise<Proxy | null>>(async (_proxyId, teamId) =>
|
||||
teamId ? null : makeProxy()
|
||||
);
|
||||
const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } });
|
||||
|
||||
const customResult = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
|
||||
const inheritResult = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(customResult).toBeUndefined();
|
||||
expect(inheritResult).toBe("http://proxy.example.com:8080");
|
||||
expect(findByIdOrNull).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProxyUrl", () => {
|
||||
it("builds a URL without credentials when there is no username", () => {
|
||||
expect(buildProxyUrl(makeProxy())).toBe("http://proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("builds a URL with username and password", () => {
|
||||
expect(buildProxyUrl(makeProxy({ username: "user", password: "pass" }))).toBe("http://user:pass@proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("percent-encodes credentials", () => {
|
||||
expect(buildProxyUrl(makeProxy({ username: "user@x", password: "p@ss" }))).toBe("http://user%40x:p%40ss@proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("builds a URL with a username only, without a colon", () => {
|
||||
expect(buildProxyUrl(makeProxy({ username: "user" }))).toBe("http://user@proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("emits no credentials for a password without a username", () => {
|
||||
expect(buildProxyUrl(makeProxy({ password: "pass" }))).toBe("http://proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("uses the https scheme without a trailing slash", () => {
|
||||
expect(buildProxyUrl(makeProxy({ protocol: "https" }))).toBe("https://proxy.example.com:8080");
|
||||
});
|
||||
});
|
||||
@@ -477,6 +477,7 @@ describe("monitorValidation — proxy fields", () => {
|
||||
type: "http" as const,
|
||||
url: "https://example.com",
|
||||
};
|
||||
const PROXY_ID = "64b7a1f2c9e77a001a2b3c4d";
|
||||
|
||||
describe("createMonitorBodyValidation", () => {
|
||||
it("defaults proxyMode to inherit", () => {
|
||||
@@ -487,23 +488,38 @@ describe("monitorValidation — proxy fields", () => {
|
||||
});
|
||||
|
||||
it("accepts custom mode with a proxyId", () => {
|
||||
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" });
|
||||
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: PROXY_ID });
|
||||
|
||||
expect(parsed.proxyMode).toBe("custom");
|
||||
expect(parsed.proxyId).toBe("proxy-1");
|
||||
expect(parsed.proxyId).toBe(PROXY_ID);
|
||||
});
|
||||
|
||||
it("rejects custom mode without a proxyId", () => {
|
||||
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects custom mode with an empty-string proxyId", () => {
|
||||
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("normalizes an empty-string proxyId to undefined on non-custom modes", () => {
|
||||
for (const proxyMode of ["inherit", "none"] as const) {
|
||||
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "" });
|
||||
expect(parsed.proxyId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a proxyId that is not a valid ObjectId", () => {
|
||||
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an unknown proxyMode", () => {
|
||||
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "global" })).toThrow();
|
||||
});
|
||||
|
||||
it("allows a stray proxyId on non-custom modes", () => {
|
||||
for (const proxyMode of ["inherit", "none"] as const) {
|
||||
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "proxy-1" });
|
||||
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: PROXY_ID });
|
||||
expect(parsed.proxyMode).toBe(proxyMode);
|
||||
}
|
||||
});
|
||||
@@ -521,9 +537,19 @@ describe("monitorValidation — proxy fields", () => {
|
||||
});
|
||||
|
||||
it("accepts custom mode with a proxyId", () => {
|
||||
const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "proxy-1" });
|
||||
const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: PROXY_ID });
|
||||
|
||||
expect(parsed.proxyId).toBe("proxy-1");
|
||||
expect(parsed.proxyId).toBe(PROXY_ID);
|
||||
});
|
||||
|
||||
it("normalizes an empty-string proxyId to undefined", () => {
|
||||
const parsed = editMonitorBodyValidation.parse({ proxyMode: "inherit", proxyId: "" });
|
||||
|
||||
expect(parsed.proxyId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a proxyId that is not a valid ObjectId", () => {
|
||||
expect(() => editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "not-an-object-id" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user