Compare commits

...
Author SHA1 Message Date
Alexander Holliday 021f99e0be Merge pull request #3889 from bluewave-labs/develop
develop -> master
2026-08-26 12:08:53 -07:00
Alexander Holliday 54ffd74048 Merge pull request #3887 from bluewave-labs/feat/heatmap-color-interpolation
feat: interpoalte colors for severity of downtime
2026-08-26 12:07:52 -07:00
Alex Holliday 4bc71a3d18 interpoalte colors for severity of downtime 2026-08-26 11:48:33 -07:00
Alexander Holliday d03765c5fd Merge pull request #3886 from bluewave-labs/fix/proxy-id-empty-string
fix: deal with empty strings for proxyId
2026-08-26 11:21:18 -07:00
Alex Holliday f30085bae3 deal with empty strings for proxyId 2026-08-26 11:19:08 -07:00
Alexander Holliday adc6af83e3 Merge pull request #3885 from bluewave-labs/develop
Develop -> Master
2026-08-26 10:45:29 -07:00
Alexander Holliday c24440f8c9 Merge pull request #3883 from bluewave-labs/chore/swr-policy
chore: set keep previous data
2026-08-26 10:42:00 -07:00
Alex Holliday d8ce2dfdc8 set keep previous data 2026-08-26 10:40:02 -07:00
Alexander Holliday 28f8542bce Merge pull request #3882 from bluewave-labs/chore/dry-run-check
chore: add lockfile check
2026-08-26 10:06:53 -07:00
Gorkem Cetin 2e258fa39a Update README with installation options
Added 1-click installation options for Checkmate.
2026-08-26 12:51:40 +03:00
Gorkem Cetin 7dedcca41d Revise README for contributor stats and project description
Updated the README to reflect recent achievements and contributions.
2026-08-26 12:47:27 +03:00
Alex Holliday 97d6222297 add a lockfile check 2026-08-25 15:20:57 -07:00
Alexander Holliday 14ddfc97b0 Merge pull request #3876 from bluewave-labs/fix/node-version
Fix/node version
2026-08-25 15:17:41 -07:00
Alex Holliday b23dbfeae0 regen lockfile 2026-08-25 15:16:54 -07:00
Alex Holliday 68679bc0bf lockfile script 2026-08-25 15:16:12 -07:00
Alexander Holliday 959a434d21 Merge pull request #3875 from bluewave-labs/fix/package-lock-sync
fix package lock sync issue
2026-08-25 15:06:03 -07:00
Alex Holliday 4298397e3e fix package lock sync issue 2026-08-25 15:05:28 -07:00
Alexander Holliday fc3fab4905 Merge pull request #3873 from bluewave-labs/chore/version-bump
minor version bump
2026-08-25 14:52:58 -07:00
Alex Holliday 0b54b5c6cd minor version bump 2026-08-25 14:49:52 -07:00
Alexander Holliday 358b1deec7 Merge pull request #3872 from bluewave-labs/fix/csp
fix: logo image preview CSP
2026-08-25 14:48:00 -07:00
Alex Holliday 9839d48ca0 add img-src blob: to CSP, stop refetching blob 2026-08-25 14:44:01 -07:00
Alexander Holliday 83c2df4390 Merge pull request #3863 from eralpozcan/feat/status-page-monitor-tags
feat(status-page): show monitor tags in status page selector
2026-08-25 14:16:33 -07:00
Alexander Holliday 31f6727519 Merge pull request #3871 from bluewave-labs/fix/remove-dead-workflows
fix: remove dead workflows
2026-08-25 14:12:02 -07:00
Alex Holliday 96f8bf4a66 Merge branch 'develop' into fix/remove-dead-workflows 2026-08-25 14:10:18 -07:00
Alexander Holliday 06bf5e45f5 Merge pull request #3870 from bluewave-labs/fix/package-lock
fix: regen package lock
2026-08-25 14:09:34 -07:00
Alex Holliday d0fc402683 format 2026-08-25 14:08:11 -07:00
Alex Holliday b922654cfc remove poeditor workflows 2026-08-25 14:07:13 -07:00
Alex Holliday d7dce5d7a3 regen package lock 2026-08-25 14:03:35 -07:00
Alexander Holliday 5d2aa28570 Merge pull request #3869 from bluewave-labs/fix/proxy-scoping
fix: proxy scoping
2026-08-25 13:54:05 -07:00
Alex Holliday af3c72b055 add tests 2026-08-25 11:19:33 -07:00
Alex Holliday b3e98ac95d update openapi docs 2026-08-25 11:14:50 -07:00
Alex Holliday 3f62c8ec6a resolve scoping issue by fetching proxy summary 2026-08-25 11:13:12 -07:00
Alex Holliday b2dca7b62f add getAllProxies route 2026-08-25 11:02:37 -07:00
Alexander Holliday f731648007 Merge pull request #3868 from bluewave-labs/feat/proxy-frontend
add global proxy setting config and translation keys
2026-08-25 10:48:04 -07:00
Alexander Holliday a1199f957d Merge pull request #3867 from bluewave-labs/feat/proxy-frontend
feat: proxy frontend
2026-08-25 10:44:46 -07:00
Alex Holliday ea8ad88ae9 add global proxy setting config and translation keys 2026-08-25 10:29:19 -07:00
Alex Holliday 6e271f834c select -> radio, update descriptions 2026-08-25 09:55:34 -07:00
Alex Holliday 0c21dcdd2a add settings response type, destructure 2026-08-25 09:39:56 -07:00
eralpozcan 9bb95e1f0c feat: show monitor tags in status page selector 2026-08-22 14:05:44 +03:00
Alex Holliday ccfda938d5 Add proxies to create/edit monitors page 2026-08-21 15:13:34 -07:00
Alex Holliday cc137e1d06 add proxies CRUD pages 2026-08-21 15:13:17 -07:00
Alexander Holliday 41859f241a Merge pull request #3860 from bluewave-labs/feat/proxy-resolver
feat: proxy resolver
2026-08-21 12:34:22 -07:00
Alex Holliday 252067142e apply proxy agents to http monitors 2026-08-21 12:19:23 -07:00
Alex Holliday 1152a70afb wire up proxy resolver 2026-08-21 11:41:26 -07:00
Alex Holliday ffd7ea7517 add tests 2026-08-21 11:13:22 -07:00
Alex Holliday a56bff2305 add proxy resolver 2026-08-21 11:06:42 -07:00
Alexander Holliday db05db0c0c Merge pull request #3859 from bluewave-labs/feat/outbound-proxy-server-crud
feat: outbound proxy server crud
2026-08-21 10:54:42 -07:00
Alexander Holliday 872e5f32cb Merge pull request #3807 from bluewave-labs/develop
develop -> master
2026-07-30 09:39:06 -07:00
Alexander Holliday d368ec5bd7 Merge pull request #3761 from bluewave-labs/develop
develop -> master
2026-07-07 10:19:52 -07:00
Alexander Holliday 825bcc5fdb Merge pull request #3751 from bluewave-labs/develop
develop -> master
2026-07-02 11:06:47 -07:00
Alexander Holliday 69cdf948da Merge pull request #3750 from bluewave-labs/develop
develop -> master
2026-07-02 10:57:17 -07:00
Alexander Holliday d337bb235c Merge pull request #3749 from bluewave-labs/develop
migration fix
2026-07-02 10:43:59 -07:00
Alexander Holliday 0c0f43174d Merge pull request #3748 from bluewave-labs/develop
fix/migration
2026-07-02 10:30:55 -07:00
Alexander Holliday 51149d1dd4 Merge pull request #3747 from bluewave-labs/develop
develop -> master
2026-07-02 10:18:55 -07:00
Alexander Holliday 448fcc4628 Merge pull request #3707 from bluewave-labs/develop
develop -> master
2026-06-05 12:04:15 -07:00
Alexander Holliday ee5462d039 Merge pull request #3699 from bluewave-labs/develop
develop -> master
2026-06-05 10:39:06 -07:00
Alexander Holliday 7ee6d342e9 Merge pull request #3606 from bluewave-labs/develop
develop -> master
2026-04-29 12:11:29 -07:00
Alexander Holliday 49d3737a93 Merge pull request #3604 from bluewave-labs/develop
develop -> master
2026-04-29 11:51:43 -07:00
Alexander Holliday 4402e4fbfa Merge pull request #3567 from bluewave-labs/develop
develop -> master
2026-04-23 11:26:21 -07:00
Alexander Holliday 721f77ed26 Merge pull request #3565 from bluewave-labs/develop
develop -> master
2026-04-23 10:15:31 -07:00
Alexander Holliday 34f29e5ae6 Merge pull request #3563 from bluewave-labs/develop
develop -> master
2026-04-23 09:52:58 -07:00
Alexander Holliday c2170267e8 Merge pull request #3394 from bluewave-labs/develop
develop -> master 3.5.1 release
2026-03-06 12:56:20 -08:00
Alexander Holliday 321ca59380 Merge pull request #3368 from bluewave-labs/develop
Develop -> master (3.5.0)
2026-03-04 10:41:02 -08:00
Alexander Holliday e3ecfe6ad8 Merge pull request #3323 from bluewave-labs/develop
develop -> master
2026-02-20 12:38:33 -08:00
Alexander Holliday f4664eec16 Merge pull request #3321 from bluewave-labs/develop
develop->master
2026-02-20 12:20:49 -08:00
Alexander Holliday 56f601e34b Merge pull request #3319 from bluewave-labs/develop
develop -> master
2026-02-20 12:07:29 -08:00
Alexander Holliday 1244ff6d93 Merge pull request #3212 from bluewave-labs/develop
develop -> master
2026-01-27 13:58:48 -08:00
Alexander Holliday c0ea804c98 Merge pull request #3209 from bluewave-labs/develop
develop -> master
2026-01-27 13:40:25 -08:00
Alexander Holliday c21cf29db6 Merge pull request #3207 from bluewave-labs/develop
develop -> master
2026-01-27 13:20:18 -08:00
Alexander Holliday 3d0da70ce6 Merge pull request #3204 from bluewave-labs/develop
develop -> master
2026-01-27 13:09:47 -08:00
Alexander Holliday feb061240b Merge pull request #3042 from bluewave-labs/develop
develp->master
2025-10-27 12:09:00 -07:00
Alexander Holliday a924227805 Merge pull request #2964 from bluewave-labs/develop
develop -> master
2025-09-18 13:40:45 -07:00
Alexander Holliday 2144d3f8c0 Merge pull request #2962 from bluewave-labs/develop
develop -> master
2025-09-18 12:04:51 -07:00
Alexander Holliday fcd6786149 Merge pull request #2936 from bluewave-labs/develop
develop -> master
2025-09-08 20:43:21 -07:00
Alexander Holliday ea968ebf3c Merge pull request #2933 from bluewave-labs/develop
develop -> master
2025-09-08 20:33:35 -07:00
Alexander Holliday 8a7b852974 Merge pull request #2929 from bluewave-labs/develop
develop -> master
2025-09-08 07:24:16 -07:00
Alexander Holliday c8d3e3b70c Merge pull request #2921 from bluewave-labs/develop
develop -> master
2025-09-04 15:46:30 -07:00
Alexander Holliday 7a90a6f9f4 Merge pull request #2915 from bluewave-labs/develop
develop->master
2025-09-01 10:33:09 -07:00
73 changed files with 4784 additions and 1878 deletions

No files matched your search

+22
View File
@@ -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
-138
View File
@@ -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>
-113
View File
@@ -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."
+9 -4
View File
@@ -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>
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](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!
+2 -2
View File
@@ -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
View File
@@ -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",
+15 -4
View File
@@ -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
+6 -1
View File
@@ -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",
+5
View File
@@ -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: "",
+22
View File
@@ -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]);
};
+2
View File
@@ -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 };
+76 -5
View File
@@ -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} />}
</>
);
};
+213
View File
@@ -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;
+72
View File
@@ -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;
+66 -9
View File
@@ -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")}
+36 -15
View File
@@ -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})`;
+17 -4
View File
@@ -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 />}
+6
View File
@@ -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;
+17
View File
@@ -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 };
+8
View File
@@ -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;
}
+21 -2
View File
@@ -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).
+17
View File
@@ -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>;
+67 -55
View File
@@ -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>;
+1
View File
@@ -79,6 +79,7 @@ export const statusPageSchema = z.object({
.object({
data: z.string(),
contentType: z.string(),
file: z.instanceof(File).optional(),
})
.nullable()
.optional()
+93 -1
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -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)." },
+75
View File
@@ -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,
},
});
+1739 -1399
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -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("; ");
+2
View File
@@ -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);
+11 -4
View File
@@ -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
View File
@@ -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'"],
},
+1 -1
View File
@@ -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),
+12 -2
View File
@@ -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 });
}
+10 -1
View File
@@ -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);
+20 -1
View File
@@ -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) => {
+2
View File
@@ -15,3 +15,5 @@ export interface Proxy {
}
export type ProxyResponse = Omit<Proxy, "password"> & { hasPassword: boolean };
export type ProxySummary = Pick<Proxy, "id" | "name" | "host" | "port">;
+56 -8
View File
@@ -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>>;
}
+111
View File
@@ -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;
}
}
}
+7 -4
View File
@@ -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> {
+4
View File
@@ -152,3 +152,7 @@ export type StatusChangeResult = {
};
export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
export type CheckContext = {
proxyUrl?: string;
};
+4 -1
View File
@@ -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();
});
});