Compare commits

..
Author SHA1 Message Date
gorkem-bwl e760db1192 fix(monitors): correct proxy transport and error attribution
Second review round found two blocking defects, both verified against a stub
proxy rather than inferred.

HttpsProxyAgent was used for every target, and it always issues CONNECT:

    PROXY RECEIVED: "CONNECT example.com:80 HTTP/1.1"

Squid's stock ACL permits CONNECT only to 443, so every http:// monitor would
have broken the moment HTTP_PROXY was set — in exactly the hardened networks
this feature targets. Plain-http targets now use http-proxy-agent, which
sends an absolute-URI request. The agent cache keys on target scheme.

A refused CONNECT is replayed onto the socket, so the proxy's own status was
parsed as the target's: a proxy ACL denial reported "the site returned 403",
and a monitor whose customUpCodes included 403 or 407 was marked UP while the
target was never reached. Proxy-tunnel rejections are now detected via the
agent's proxyConnect event and reported as a network error naming the proxy.
The listener is per request and removed in a finally block, so a failure
cannot be attributed to a concurrent check.

Also from review:

- The fallback for an invalid PROXY_MONITOR_TYPES no longer fires when every
  entry was a deliberate but unproxyable type ("ping,port"). Falling back
  there would have proxied http and pagespeed, which the operator never
  nominated. Typos still fall back; unproxyable lists stay empty and warn.
- Dropped the now-redundant strict/insecure agent split: TLS is applied per
  request, and Node already separates socket pools by TLS options, so the
  split only doubled tunnels to the proxy.
- Renamed to ProxyPolicy.ts to match the PascalCase peers in the directory.
- The warning no longer echoes the raw config value.

Adds 9 tests covering scheme selection, tunnel rejection, and the narrowed
fallback. Verified by reverting: the tunnel-failure test fails without the fix.
2026-08-20 10:58:55 -04:00
gorkem-bwl 3b03850a3b fix(monitors): keep ignoreTlsErrors effective for proxied checks
Review found that the proxy path silently dropped ignoreTlsErrors.
rejectUnauthorized was passed to the HttpsProxyAgent constructor, where it
governs the socket to the proxy rather than the tunnelled handshake with the
target. Verified directly: the value lands in connectOpts, while the target
handshake reads got's per-request https options, which were unset and so
defaulted to strict.

The consequence was worse than a no-op. A monitor with ignoreTlsErrors
against a self-signed endpoint works today; configuring a proxy would have
made it fail verification, flip to down and fire a false incident with
notifications. Stating the option per request fixes it, and the agent cache
keeps its TLS split so a relaxed agent is never shared with a strict monitor.

Also from review:

- A fully-invalid PROXY_MONITOR_TYPES disabled proxying entirely, which in a
  restricted-egress deployment would take every HTTP monitor down at once —
  the exact failure the parser was meant to prevent. It now falls back to the
  defaults and warns loudly. "none" remains the way to disable deliberately.
- The "none" sentinel only matched when it was the sole entry, so "none,http"
  half-enabled proxying. It is now honoured wherever it appears.
- "unknown" is a real MonitorType but never a deliberate choice, so it is
  reported as a typo rather than as an unproxyable type.
- Dropped the unused warning field from ProxyResolution.
- Documented that lowercase proxy variables are honoured and take precedence,
  which matters on base images that already set them.

Adds 10 tests, including the request-level TLS assertions the previous suite
lacked. Verified by reverting the fix: both TLS tests fail without it.
2026-08-20 10:46:25 -04:00
gorkem-bwl 705c19f7c8 feat(monitors): route checks through an outbound HTTP proxy
Deployments with restricted egress could not run checks at all: got does not
read HTTP_PROXY/HTTPS_PROXY (unlike curl or axios), and NODE_USE_ENV_PROXY
only affects Node's built-in fetch, which got does not use. Even a correctly
built proxy agent was discarded, because HttpProvider overwrote
options.agent on every request.

Proxy selection is resolved per check and applied where the agent is chosen.
NO_PROXY handling is delegated to proxy-from-env rather than reimplemented,
so wildcards, host lists and port-qualified entries behave exactly as
operators expect from curl.

Infrastructure monitors are excluded by default. Hardware and pagespeed
checks both delegate to HttpProvider, so a naive global proxy would also
capture Capture agents — which normally sit on the internal network, where
proxying them fails or hairpins. PROXY_MONITOR_TYPES is the escape hatch for
deployments whose agents genuinely are behind the proxy, and accepts "none"
to disable proxying without unsetting the environment variables. Values that
are not real monitor types, or that open raw sockets and cannot be proxied
(ping, port, docker, grpc, websocket, dns, game), are dropped and logged so
a typo cannot silently disable proxying.

Agents are cached by proxy URL and TLS mode. Keying on TLS matters: sharing
one agent between a strict monitor and an ignoreTlsErrors monitor would leak
the relaxed setting across monitors. Caching also preserves pooling, so 50
monitors share tunnels instead of dialling the proxy per check.

Both new dependencies were already present transitively (76K total).

Notification providers still call out directly and are unaffected; that is a
known follow-up for the same class of deployment.
2026-08-20 10:39:36 -04:00
95 changed files with 2504 additions and 5570 deletions

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
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
@@ -0,0 +1,138 @@
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
@@ -0,0 +1,113 @@
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."
+4 -9
View File
@@ -32,13 +32,12 @@
<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 align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
[![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" />
</p>
<br>
@@ -92,10 +91,6 @@ 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:
@@ -212,9 +207,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) and [Mert](https://github.com/mertssmnoglu) 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), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) 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 almost 11K+ stars and attracted 150+ 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 7000+ stars and attracted 90+ 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.11.0",
"version": "3.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.11.0",
"version": "3.10.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
+1 -2
View File
@@ -1,10 +1,9 @@
{
"name": "client",
"private": true,
"version": "3.11.0",
"version": "3.10.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",
+4 -15
View File
@@ -11,11 +11,10 @@ interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string;
required?: boolean;
placeholder?: string;
helperText?: string;
}
const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>,
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
const theme = useTheme();
@@ -28,7 +27,7 @@ const SelectInputInner = <T,>(
: selected === undefined || selected === null || selected === "";
if (isEmpty && placeholder) {
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>;
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
}
if (isMultiple) {
@@ -93,21 +92,11 @@ const SelectInputInner = <T,>(
/>
);
const helper = helperText ? (
<Typography
variant="caption"
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
>
{helperText}
</Typography>
) : null;
if (fieldLabel || helper) {
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{select}
{helper}
</Stack>
);
}
@@ -8,7 +8,6 @@ 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;
@@ -18,12 +17,10 @@ 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();
@@ -33,7 +30,6 @@ 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,7 +44,6 @@ export const FormSelectField = <T extends FieldValues>({
}}
error={!!fieldState.error}
{...rest}
helperText={fieldState.error?.message ?? rest.helperText}
>
{options.map((option) => (
<MenuItem
+1 -6
View File
@@ -18,8 +18,8 @@ import {
Lock,
Users,
Tag,
Waypoints,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
@@ -47,11 +47,6 @@ 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,7 +4,6 @@ import {
DefaultHttpMethod,
DefaultMonitorMatchMethod,
DefaultPageSpeedStrategy,
DefaultProxyMode,
} from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
@@ -47,8 +46,6 @@ 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 || "",
@@ -144,8 +141,6 @@ export const getMonitorDefaults = (
url: "",
method: DefaultHttpMethod,
ignoreTlsErrors: false,
proxyMode: DefaultProxyMode,
proxyId: "",
useAdvancedMatching: false,
matchMethod: DefaultMonitorMatchMethod,
expectedValue: "",
-22
View File
@@ -1,22 +0,0 @@
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,8 +43,6 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "",
systemEmailPassword: "",
globalProxyEnabled: data?.globalProxyEnabled ?? false,
globalProxyId: data?.globalProxyId ?? null,
};
return { schema: settingsSchema, defaults };
+5 -76
View File
@@ -41,12 +41,9 @@ 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,
@@ -59,7 +56,6 @@ 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,
@@ -71,7 +67,6 @@ interface GeneralSettingsConfig {
urlPlaceholder: string;
namePlaceholder: string;
showUrl: boolean;
showProxy: boolean;
showPort: boolean;
showGameSelect: boolean;
showSecret: boolean;
@@ -92,7 +87,6 @@ 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,
@@ -107,7 +101,6 @@ 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,
@@ -122,7 +115,6 @@ 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,
@@ -137,7 +129,6 @@ 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,
@@ -152,7 +143,6 @@ 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,
@@ -167,7 +157,6 @@ 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,
@@ -182,7 +171,6 @@ 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,
@@ -197,7 +185,6 @@ 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,
@@ -212,7 +199,6 @@ 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,
@@ -227,7 +213,6 @@ 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,
@@ -273,8 +258,6 @@ 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,
@@ -298,7 +281,6 @@ 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;
@@ -484,38 +466,6 @@ 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}>
@@ -568,11 +518,6 @@ 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
@@ -582,27 +527,6 @@ 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 && (
@@ -679,6 +603,11 @@ const CreateMonitorPage = () => {
options={dnsRecordTypeOptions}
/>
)}
<FormTextField
name="name"
fieldLabel={t("pages.createMonitor.form.general.option.name.label")}
placeholder={generalSettingsConfig.namePlaceholder}
/>
</Stack>
}
/>
@@ -1,103 +0,0 @@
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
@@ -1,213 +0,0 @@
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
@@ -1,72 +0,0 @@
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;
+9 -66
View File
@@ -10,7 +10,7 @@ import {
Alert,
type SelectChangeEvent,
} from "@mui/material";
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import { FormProvider, useForm, type FieldPath } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
@@ -43,9 +43,12 @@ 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";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import type { ProxyResponse } from "@/Types/Proxy";
import type { AppSettingsResponse } from "@/Types/Settings";
interface SettingsResponse {
settings: any;
pagespeedKeySet: boolean;
emailPasswordSet: boolean;
}
export const SettingsPage = () => {
const theme = useTheme();
@@ -61,20 +64,9 @@ export const SettingsPage = () => {
const { post: importMonitors, loading: isImportingMonitors } = usePost();
// Fetch settings data from API
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]
);
const { data: fetchedSettings } = useGet<SettingsResponse>("/settings");
// Form submission
const { patch, loading: isSaving } = usePatch<SettingsFormData, AppSettingsResponse>();
const { patch, loading: isSaving } = usePatch<SettingsFormData, SettingsResponse>();
// Local state for API key reset
const [isApiKeySet, setIsApiKeySet] = useState(
@@ -123,8 +115,6 @@ export const SettingsPage = () => {
mode: "onChange",
});
const { watch } = form;
// Reset form when defaults change
useEffect(() => {
form.reset(defaults);
@@ -302,21 +292,6 @@ 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
@@ -390,38 +365,6 @@ 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")}
+15 -36
View File
@@ -1,9 +1,4 @@
import {
BasePage,
ColoredLabel,
ConfigBox,
StepProgress,
} from "@/Components/design-elements";
import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import { logger } from "@/Utils/logger";
import { LAYOUT } from "@/Utils/Theme/constants";
@@ -21,12 +16,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";
@@ -58,9 +53,7 @@ const LogoUploadField = () => {
<ImageUpload
src={field.value?.data}
onChange={(file) => {
field.onChange(
file ? { data: file.src, contentType: file.file.type, file: file.file } : null
);
field.onChange(file ? { data: file.src, contentType: file.file.type } : null);
}}
/>
);
@@ -93,8 +86,6 @@ 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();
@@ -205,8 +196,18 @@ const CreateStatusPage = () => {
if (data.logo === null) {
// Signal to remove the logo
fd.append("removeLogo", "true");
} else if (data.logo?.file) {
fd.append("logo", data.logo.file);
} 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);
}
}
}
let result;
@@ -307,28 +308,6 @@ 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,7 +113,6 @@ const StatusPageView = () => {
apiUrl,
{},
{
keepPreviousData: true,
refreshInterval: range === "latest" ? 10000 : 60000,
}
);
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import 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, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -327,12 +327,9 @@ export const boldStyles = (
gap: { xs: "1px", md: "3px" },
height: 48,
},
heatmapCell: (kind, severity = 1) => ({
heatmapCell: (kind) => ({
borderRadius: "3px",
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
background: heatCellBg[kind],
boxShadow: heatCellShadow[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
@@ -347,11 +344,8 @@ export const boldStyles = (
alignItems: "flex-end",
height: 48,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: 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 { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import 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, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -272,11 +272,8 @@ export const editorialStyles = (
gap: { xs: "1px", md: "2px" },
height: 40,
},
heatmapCell: (kind, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
heatmapCell: (kind) => ({
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.5 : 1,
}),
@@ -288,11 +285,8 @@ export const editorialStyles = (
alignItems: "flex-end",
height: 40,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: 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 { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import 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, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -83,14 +83,13 @@ 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}, ${darken(tokens.degraded)})`,
down: `linear-gradient(180deg, ${tokens.down}, ${darken(tokens.down)})`,
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%))`,
empty: tokens.border,
};
@@ -104,7 +103,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}, ${darken(tokens.down)})`,
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
};
return {
@@ -355,19 +354,13 @@ export const modernStyles = (
gap: { xs: "1px", md: "3px" },
height: 46,
},
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)" },
};
},
heatmapCell: (kind) => ({
borderRadius: "3px",
background: 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" },
@@ -377,11 +370,8 @@ export const modernStyles = (
alignItems: "flex-end",
height: 46,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: 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 { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import 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, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -296,12 +296,9 @@ export const refinedStyles = (
gap: { xs: "1px", md: "3px" },
height: 42,
},
heatmapCell: (kind, severity = 1) => ({
heatmapCell: (kind) => ({
borderRadius: "2px",
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.15)" },
@@ -315,11 +312,8 @@ export const refinedStyles = (
alignItems: "flex-end",
height: 42,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: 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, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -18,7 +18,6 @@ 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
@@ -29,22 +28,9 @@ export interface ChartCell {
const DAY_DOWN_THRESHOLD = 0.5;
// 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 => {
const classifyDay = (bucket: DailyCheckBucket): 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";
@@ -69,7 +55,6 @@ const emptyCell = (key: string): ChartCell => ({
responseTime: 0,
tooltip: null,
ariaLabel: "",
severity: 0,
});
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
@@ -91,7 +76,6 @@ 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);
@@ -109,17 +93,9 @@ export const dailyBucketsToCells = (
return enumerated.map((date) => {
const bucket = byDate.get(date);
if (!bucket) return emptyCell(`day-${date}`);
const upRatio = bucket.upChecks / bucket.totalChecks;
const barKind = classifyDay(bucket, upRatio);
const barKind = classifyDay(bucket);
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, severity?: number) => SxProps<Theme>;
cellSx: (kind: HeatCellKind) => 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, cell.severity)}
sx={cellSx(cell.heatKind)}
aria-label={cell.ariaLabel}
/>
</Tooltip>
@@ -13,7 +13,7 @@ import type {
interface Props {
cells: ChartCell[];
containerSx: SxProps<Theme>;
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
statsSx: SxProps<Theme>;
statsGap?: number;
}
@@ -61,7 +61,7 @@ export const ThemedHistogram = ({
placement="top"
>
<Box
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
sx={barSx(cell.barKind, cell.heightPct)}
aria-label={cell.ariaLabel}
/>
</Tooltip>
@@ -243,10 +243,3 @@ 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})`;
+4 -17
View File
@@ -39,14 +39,11 @@ 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";
@@ -55,8 +52,10 @@ 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 (
@@ -189,24 +188,12 @@ 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,10 +109,6 @@ 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;
@@ -125,8 +121,6 @@ export interface Monitor {
statusWindowThreshold: number;
type: MonitorType;
ignoreTlsErrors: boolean;
proxyMode: ProxyMode;
proxyId?: string;
useAdvancedMatching: boolean;
jsonPath?: string;
expectedValue?: string;
-17
View File
@@ -1,17 +0,0 @@
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,8 +24,6 @@ export interface Settings {
showURL: boolean;
singleton: boolean;
globalThresholds?: SettingsThresholds;
globalProxyEnabled: boolean;
globalProxyId?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -33,11 +31,5 @@ export interface Settings {
export interface AppSettingsResponse {
pagespeedKeySet: boolean;
emailPasswordSet: boolean;
globalProxy: {
id: string;
name: string;
host: string;
port: number;
} | null;
settings: Settings;
}
+11 -17
View File
@@ -1,31 +1,25 @@
/**
* Intl.NumberFormat instance for percentage formatting.
* Reused across all formatting calls for performance.
*
* Two decimals rather than one: at a single decimal every value above 99.95%
* renders as "100.0%", so a period containing real downtime is reported as
* flawless. It also collapses 99.9 / 99.95 / 99.99 — three materially
* different SLA commitments — into the same figure.
*/
const percentageFormatter = new Intl.NumberFormat("en-US", {
style: "percent",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
/**
* Formats a decimal value as a percentage string.
* @param value - Decimal value (e.g., 0.75 for 75%)
* @returns Formatted percentage string (e.g., "75.00%")
* @returns Formatted percentage string (e.g., "75.0%")
* @example
* formatPercentage(0.75) // "75.00%"
* formatPercentage(1) // "100.00%"
* formatPercentage(0.5432) // "54.32%"
* formatPercentage(0.9999) // "99.99%"
* formatPercentage(0.75) // "75.0%"
* formatPercentage(1) // "100.0%"
* formatPercentage(0.5432) // "54.3%"
*/
export const formatPercentage = (value: number): string => {
if (typeof value !== "number" || Number.isNaN(value)) {
return "0.00%";
return "0.0%";
}
return percentageFormatter.format(value);
};
@@ -33,11 +27,11 @@ export const formatPercentage = (value: number): string => {
/**
* Formats a whole number percentage value as a percentage string.
* @param value - Whole number percentage (e.g., 75 for 75%)
* @returns Formatted percentage string (e.g., "75.00%")
* @returns Formatted percentage string (e.g., "75.0%")
* @example
* formatPercentageFromWhole(75) // "75.00%"
* formatPercentageFromWhole(100) // "100.00%"
* formatPercentageFromWhole(54.32) // "54.32%"
* formatPercentageFromWhole(75) // "75.0%"
* formatPercentageFromWhole(100) // "100.0%"
* formatPercentageFromWhole(54.32) // "54.3%"
*/
export const formatPercentageFromWhole = (value: number): string => {
return formatPercentage(value / 100);
+2 -21
View File
@@ -5,7 +5,6 @@ import {
DnsRecordTypes,
HttpMethods,
PageSpeedStrategies,
ProxyModes,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
@@ -68,13 +67,6 @@ 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 }),
@@ -203,7 +195,7 @@ const dnsSchema = baseSchema.extend({
});
// Discriminated union of all monitor types
const monitorSchemaUnion = z.discriminatedUnion("type", [
export const monitorSchema = z.discriminatedUnion("type", [
httpSchema,
pingSchema,
portSchema,
@@ -216,21 +208,10 @@ const monitorSchemaUnion = 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) =>
monitorSchemaUnion.options.find((o) => o.shape.type.value === type);
monitorSchema.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
@@ -1,17 +0,0 @@
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>;
+55 -67
View File
@@ -1,73 +1,61 @@
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),
}),
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 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 type SettingsFormInput = z.input<typeof settingsSchema>;
export type SettingsFormData = z.infer<typeof settingsSchema>;
-1
View File
@@ -79,7 +79,6 @@ export const statusPageSchema = z.object({
.object({
data: z.string(),
contentType: z.string(),
file: z.instanceof(File).optional(),
})
.nullable()
.optional()
+1 -93
View File
@@ -205,7 +205,6 @@
"infrastructure": "Infrastructure",
"notifications": "Notifications",
"tags": "Tags",
"proxies": "Proxies",
"checks": "Checks",
"incidents": "Incidents",
"statusPages": "Status pages",
@@ -245,63 +244,6 @@
}
},
"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."
@@ -639,24 +581,6 @@
"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"
@@ -700,7 +624,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. Set an optional proxy server to connect through.",
"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.",
"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).",
@@ -1362,22 +1286,6 @@
}
}
},
"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.",
+21
View File
@@ -32,3 +32,24 @@ ORIGIN=localhost
# Set to "false" to disable the selectable theme feature on status pages.
# When disabled, all status pages render with the default "refined" theme.
STATUS_PAGE_THEMES_ENABLED=true
# Outbound Proxy
# --------------
# Route monitor checks through an HTTP proxy, for deployments whose egress is
# restricted. Credentials may be embedded: http://user:pass@proxy:3128
# The lowercase forms (http_proxy, https_proxy, no_proxy) are also honoured and
# take precedence over these, which matters on base images that already set them.
# HTTP_PROXY=http://proxy.corp.example:3128
# HTTPS_PROXY=http://proxy.corp.example:3128
#
# Hosts that must be reached directly. Supports wildcards and host lists, with
# the same semantics curl uses.
# NO_PROXY=localhost,127.0.0.1,*.internal,10.0.0.5
#
# Monitor types to route through the proxy, comma-separated. Defaults to
# "http,pagespeed". Infrastructure ("hardware") monitors are excluded by
# default because Capture agents normally sit on the internal network — add it
# here if yours are reachable only through the proxy. Use "none" to disable
# proxying without unsetting the variables above. Types that open raw sockets
# (ping, port, docker, grpc, websocket, dns, game) cannot be proxied.
# PROXY_MONITOR_TYPES=http,pagespeed
-1034
View File
File diff suppressed because it is too large. Load diff
-2
View File
@@ -9,7 +9,6 @@ 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";
@@ -46,7 +45,6 @@ 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
@@ -1,75 +0,0 @@
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,
},
});
+1452 -1680
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -5,7 +5,6 @@
"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",
@@ -50,7 +49,8 @@
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"hpagent": "1.2.0",
"http-proxy-agent": "^9.1.0",
"https-proxy-agent": "^9.1.0",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"jsdom": "^26.1.0",
@@ -60,6 +60,7 @@
"multer": "^1.4.5-lts.2",
"nodemailer": "9.0.5",
"ping": "0.4.4",
"proxy-from-env": "^2.1.0",
"re2": "^1.24.1",
"sharp": "0.35.3",
"ssl-checker": "2.0.10",
@@ -1,89 +0,0 @@
import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js";
import { IProxiesService } from "@/domain/proxies/proxy.service.js";
import { requireTeamId } from "./controllerUtils.js";
import {
createProxyBodyValidation,
editProxyBodyValidation,
getProxyByIdParamValidation,
editProxyParamValidation,
deleteProxyParamValidation,
} from "@/api/validation/index.js";
export interface IProxiesController {
createProxy: RequestHandler;
getProxyById: RequestHandler;
getAllProxies: RequestHandler;
getProxiesByTeamId: RequestHandler;
editProxy: RequestHandler;
deleteProxy: RequestHandler;
}
class ProxyController implements IProxiesController {
constructor(private proxiesService: IProxiesService) {}
createProxy = catchAsync(async (req: Request, res: Response) => {
const validatedBody = createProxyBodyValidation.parse(req.body);
const teamId = requireTeamId(req.user?.teamId);
const proxy = await this.proxiesService.createProxy(validatedBody, teamId);
return res.status(200).json({
success: true,
msg: "Proxy created successfully",
data: proxy,
});
});
getProxyById = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const { id: proxyId } = getProxyByIdParamValidation.parse(req.params);
const proxy = await this.proxiesService.getProxy(proxyId, teamId);
return res.status(200).json({
success: true,
msg: "Proxy retrieved successfully",
data: proxy,
});
});
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);
return res.status(200).json({
success: true,
msg: "Proxies retrieved successfully",
data: proxies,
});
});
editProxy = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const { id: proxyId } = editProxyParamValidation.parse(req.params);
const validatedBody = editProxyBodyValidation.parse(req.body);
const updatedProxy = await this.proxiesService.updateProxy(proxyId, teamId, validatedBody);
return res.status(200).json({
success: true,
msg: "Proxy updated successfully",
data: updatedProxy,
});
});
deleteProxy = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const { id: proxyId } = deleteProxyParamValidation.parse(req.params);
await this.proxiesService.deleteProxy(proxyId, teamId);
return res.status(200).json({
success: true,
msg: "Proxy deleted successfully",
});
});
}
export default ProxyController;
@@ -5,7 +5,6 @@ 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 {
@@ -17,23 +16,18 @@ export interface ISettingsController {
class SettingsController implements ISettingsController {
private settingsService: ISettingsService;
private emailService: IEmailService;
private proxiesService: IProxiesService;
constructor(settingsService: ISettingsService, emailService: IEmailService, proxiesService: IProxiesService) {
constructor(settingsService: ISettingsService, emailService: IEmailService) {
this.settingsService = settingsService;
this.emailService = emailService;
this.proxiesService = proxiesService;
}
buildAppSettings = async (dbSettings: Settings) => {
buildAppSettings = (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,
};
@@ -52,7 +46,7 @@ class SettingsController implements ISettingsController {
getAppSettings = catchAsync(async (req: Request, res: Response) => {
const dbSettings = await this.settingsService.getDBSettings();
const returnSettings = await this.buildAppSettings(dbSettings);
const returnSettings = this.buildAppSettings(dbSettings);
return res.status(200).json({
success: true,
msg: "App settings fetched successfully",
@@ -63,15 +57,8 @@ 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 = await this.buildAppSettings(updatedSettings);
const returnSettings = 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: blob:",
"img-src 'self' data:",
"font-src 'self' data: https://fonts.gstatic.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
].join("; ");
-14
View File
@@ -1,14 +0,0 @@
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);
router.patch("/:id", proxyController.editProxy);
return router;
};
-1
View File
@@ -22,4 +22,3 @@ export * from "./notificationValidation.js";
export * from "./userValidation.js";
export * from "./incidentValidation.js";
export * from "./tagValidation.js";
export * from "./proxyValidation.js";
+3 -32
View File
@@ -10,19 +10,11 @@ import {
MonitorStatuses,
MonitorTypes,
PageSpeedStrategies,
ProxyModes,
} from "@/domain/monitors/monitor.type.js";
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"),
});
@@ -108,16 +100,6 @@ const refineHeadMatching = (body: { method?: string; useAdvancedMatching?: boole
}
};
const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ctx: z.RefinementCtx) => {
if (body.proxyMode === "custom" && !body.proxyId) {
ctx.addIssue({
code: "custom",
path: ["proxyId"],
message: "A proxy must be selected when proxy mode is custom",
});
}
};
export const createMonitorBodyValidation = z
.object({
_id: z.string().optional(),
@@ -128,8 +110,6 @@ export const createMonitorBodyValidation = z
statusWindowThreshold: z.number().min(1).max(100).default(60),
url: z.string().min(1, "URL is required"),
ignoreTlsErrors: z.boolean().default(false),
proxyMode: z.enum(ProxyModes).default("inherit"),
proxyId: proxyIdValidation,
useAdvancedMatching: z.boolean().default(false),
port: z.number().optional(),
isActive: z.boolean().optional(),
@@ -160,8 +140,7 @@ export const createMonitorBodyValidation = z
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection);
.superRefine(refineRegexPattern);
export const editMonitorBodyValidation = z
.object({
@@ -177,8 +156,6 @@ export const editMonitorBodyValidation = z
customUpCodes: z.array(httpStatusCode).optional(),
secret: z.string().optional(),
ignoreTlsErrors: z.boolean().optional(),
proxyMode: z.enum(ProxyModes).optional(),
proxyId: proxyIdValidation,
useAdvancedMatching: z.boolean().optional(),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
@@ -203,8 +180,7 @@ export const editMonitorBodyValidation = z
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection);
.superRefine(refineRegexPattern);
export const pauseMonitorParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -239,8 +215,6 @@ const importedMonitorSchema = z
statusWindowThreshold: z.number().min(1).max(100).default(60),
type: z.enum(MonitorTypes, "Invalid monitor type"),
ignoreTlsErrors: z.boolean().default(false),
proxyMode: z.enum(ProxyModes).default("inherit"),
proxyId: proxyIdValidation,
useAdvancedMatching: z.boolean().default(false),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
@@ -279,8 +253,7 @@ const importedMonitorSchema = z
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection);
.superRefine(refineRegexPattern);
export const importMonitorsBodyValidation = z.object({
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
@@ -312,8 +285,6 @@ export const monitorResponseSchema = z
statusWindowSize: z.number(),
statusWindowThreshold: z.number(),
ignoreTlsErrors: z.boolean(),
proxyMode: z.enum(ProxyModes),
proxyId: proxyIdValidation,
useAdvancedMatching: z.boolean(),
jsonPath: z.string().optional(),
expectedValue: z.string().optional(),
@@ -1,45 +0,0 @@
import { ProxyProtocols } from "@/domain/proxies/proxy.type.js";
import { z } from "zod";
//****************************************
// Proxy Validations
//****************************************
export const createProxyBodyValidation = 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 const editProxyBodyValidation = createProxyBodyValidation.extend({
clearPassword: z.boolean().optional(),
clearUsername: z.boolean().optional(),
});
export const getProxyByIdParamValidation = z.object({
id: z.string().min(1, "Proxy ID is required"),
});
export const editProxyParamValidation = z.object({
id: z.string().min(1, "Proxy ID is required"),
});
export const deleteProxyParamValidation = z.object({
id: z.string().min(1, "Proxy ID is required"),
});
export const proxyResponseSchema = z
.object({
id: z.string(),
teamId: z.string(),
name: z.string(),
protocol: z.enum(ProxyProtocols),
host: z.string(),
port: z.number(),
username: z.string().optional(),
hasPassword: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
})
.passthrough();
@@ -40,16 +40,5 @@ export const updateAppSettingsBodyValidation = z
temperature: z.number().min(1).max(150).optional(),
})
.optional(),
globalProxyEnabled: z.boolean().optional(),
globalProxyId: z.string().nullable().optional(),
})
.strip()
.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",
});
}
});
.strip();
+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:", "blob:", "https://img.shields.io"],
"img-src": ["'self'", "data:", "https://img.shields.io"],
"object-src": ["'none'"],
"base-uri": ["'self'"],
},
+1 -4
View File
@@ -12,7 +12,6 @@ import NotificationController from "../api/controllers/notificationController.js
import TagsController from "../api/controllers/tagController.js";
import DiagnosticController from "../api/controllers/diagnosticController.js";
import IncidentController from "../api/controllers/incidentController.js";
import ProxyController from "@/api/controllers/proxyController.js";
import { ApiServices } from "@/config/services.api.js";
export interface InitializedControllers {
@@ -30,13 +29,12 @@ export interface InitializedControllers {
tagController: TagsController;
diagnosticController: DiagnosticController;
incidentController: IncidentController;
proxyController: ProxyController;
}
export const initializeControllers = (apiServices: ApiServices): InitializedControllers => {
return {
authController: new AuthController(apiServices.userService),
monitorController: new MonitorController(apiServices.monitorService, apiServices.notificationsService),
settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService, apiServices.proxiesService),
settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService),
checkController: new CheckController(apiServices.checkService),
geoCheckController: new GeoCheckController(apiServices.geoChecksService),
inviteController: new InviteController(apiServices.inviteService),
@@ -48,6 +46,5 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont
tagController: new TagsController(apiServices.tagsService),
diagnosticController: new DiagnosticController(apiServices.diagnosticService),
incidentController: new IncidentController(apiServices.incidentService),
proxyController: new ProxyController(apiServices.proxiesService),
};
};
+11
View File
@@ -34,6 +34,17 @@ const envSchema = z.object({
// Optional
ORIGIN: z.string().optional(),
// Outbound proxy for monitor checks. HTTP_PROXY/HTTPS_PROXY/NO_PROXY follow
// the conventional semantics (NO_PROXY supports wildcards and host lists) and
// are read by proxy-from-env directly from the environment.
HTTP_PROXY: z.string().optional(),
HTTPS_PROXY: z.string().optional(),
NO_PROXY: z.string().optional(),
// Comma-separated monitor types to route through the proxy. Defaults to
// "http,pagespeed"; "hardware" is excluded because Capture agents normally
// live on the internal network. Set explicitly to override.
PROXY_MONITOR_TYPES: z.string().optional(),
// Feature flags
STATUS_PAGE_THEMES_ENABLED: booleanCoercion.default(true),
});
-3
View File
@@ -19,7 +19,6 @@ import { createDiagnosticRoutes } from "@/api/routes/diagnosticRoutes.js";
import { createNotificationRoutes } from "@/api/routes/notificationRoutes.js";
import { createTagRoutes } from "@/api/routes/tagRoutes.js";
import { createIncidentRoutes } from "@/api/routes/incidentRoutes.js";
import { createProxyRoutes } from "@/api/routes/proxyRoutes.js";
export const setupRoutes = (app: Application, controllers: InitializedControllers, apiServices: ApiServices) => {
const verifyJWT = createVerifyJWT(apiServices.settingsService);
@@ -38,7 +37,6 @@ export const setupRoutes = (app: Application, controllers: InitializedController
const tagRoutes = createTagRoutes(controllers.tagController);
const diagnosticRoutes = createDiagnosticRoutes(controllers.diagnosticController, verifyJWT);
const incidentRoutes = createIncidentRoutes(controllers.incidentController);
const proxyRoutes = createProxyRoutes(controllers.proxyController);
app.use("/api/v1/auth", authApiLimiter, authRoutes);
app.use("/api/v1/monitors", verifyJWT, monitorRoutes);
@@ -54,5 +52,4 @@ export const setupRoutes = (app: Application, controllers: InitializedController
app.use("/api/v1/tags", verifyJWT, tagRoutes);
app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes);
app.use("/api/v1/incidents", verifyJWT, incidentRoutes);
app.use("/api/v1/proxies", verifyJWT, proxyRoutes);
};
+3 -6
View File
@@ -6,14 +6,14 @@ import { IStatusPageService, StatusPageService } from "@/domain/status-pages/sta
import { ITagsService, TagsService } from "@/domain/tags/tag.service.js";
import { IUserService, UserService } from "@/domain/users/user.service.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { ProxiesService, IProxiesService } from "@/domain/proxies/proxy.service.js";
import { SharedServices } from "@/config/services.shared.js";
// Third-party
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { games } from "gamedig";
import { SharedServices } from "@/config/services.shared.js";
export interface ApiServices extends SharedServices {
worker: IJobScheduler; // control-plane handle only (DBQueueWorker in all-in-one, bare JobScheduler in API-only)
userService: IUserService;
@@ -23,7 +23,6 @@ export interface ApiServices extends SharedServices {
statusPageService: IStatusPageService;
tagsService: ITagsService;
diagnosticService: IDiagnosticService;
proxiesService: IProxiesService;
}
export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => {
@@ -46,7 +45,6 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
teamsRepository,
maintenanceWindowsRepository,
jobsRepository,
proxiesRepository,
} = shared;
const userService = new UserService({
@@ -96,7 +94,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository);
const tagsService = new TagsService(tagsRepository, monitorsRepository);
const diagnosticService = new DiagnosticService(db);
const proxiesService = new ProxiesService(proxiesRepository, monitorsRepository, settingsService);
return {
...shared,
worker: jobScheduler,
@@ -107,6 +105,5 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
statusPageService,
tagsService,
diagnosticService,
proxiesService,
};
};
-5
View File
@@ -52,7 +52,6 @@ import { IStatusPagesRepository } from "@/domain/status-pages/status-page-reposi
import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js";
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
// Mongo repository implementations
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
@@ -71,7 +70,6 @@ import MongoStatusPagesRepository from "@/domain/status-pages/status-page-reposi
import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js";
// Shared infrastructure + business services that both the API and the worker process construct.
export interface SharedServices {
@@ -105,7 +103,6 @@ export interface SharedServices {
incidentsRepository: IIncidentsRepository;
teamsRepository: ITeamsRepository;
maintenanceWindowsRepository: IMaintenanceWindowsRepository;
proxiesRepository: IProxiesRepository;
}
export const buildShared = async ({
@@ -142,7 +139,6 @@ export const buildShared = async ({
const tagsRepository = new MongoTagsRepository();
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
const proxiesRepository = new MongoProxiesRepository();
// Inject settings repository into settings service (now that DB is connected)
(settingsService as SettingsService).setRepository(settingsRepository);
@@ -225,7 +221,6 @@ export const buildShared = async ({
incidentsRepository,
teamsRepository,
maintenanceWindowsRepository,
proxiesRepository,
};
return sharedServices;
};
+3 -13
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,14 +64,13 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
incidentsRepository,
teamsRepository,
maintenanceWindowsRepository,
proxiesRepository,
} = shared;
// ***********************
// Network providers
// ***********************
const pingProvider = new PingProvider(ping);
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath), envSettings.proxyMonitorTypes, logger);
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
const dockerProvider = new DockerProvider(logger, Docker);
@@ -94,7 +93,6 @@ 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();
@@ -112,15 +110,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
// Check producer/evaluator
// Handles creating and evaluatiog checks
// ***********************
const checkProducer = new CheckProducer(
monitorsRepository,
maintenanceWindowsRepository,
checkService,
networkService,
proxyResolver,
bufferService,
logger
);
const checkProducer = new CheckProducer(monitorsRepository, maintenanceWindowsRepository, checkService, networkService, bufferService, logger);
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger);
@@ -1,27 +0,0 @@
import { MonitorModel } from "../../domain/monitors/monitor.model.js";
import type { ILogger } from "@/utils/logger.js";
/**
* Backfill proxyMode on monitors created before the field existed.
*
* Mongoose defaults only apply to new documents, and monitorResponseSchema
* requires proxyMode on every monitor read.
*/
export async function backfillMonitorProxyMode(logger: ILogger): Promise<void> {
const SERVICE_NAME = "Migration:BackfillMonitorProxyMode";
try {
logger.info({ service: SERVICE_NAME, message: "Starting proxyMode backfill" });
const result = await MonitorModel.updateMany({ proxyMode: { $exists: false } }, { $set: { proxyMode: "inherit" } });
logger.info({
service: SERVICE_NAME,
message: `Backfilled proxyMode on ${result.modifiedCount} monitors`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error backfilling proxyMode: ${errorMessage}` });
throw error;
}
}
-2
View File
@@ -9,7 +9,6 @@ import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintena
import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js";
import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js";
import { slimRecentChecks } from "./0010_slimRecentChecks.js";
import { backfillMonitorProxyMode } from "./0011_backfillMonitorProxyMode.js";
import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = {
@@ -28,7 +27,6 @@ const migrations: MigrationEntry[] = [
{ name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt },
{ name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings },
{ name: "0010_slimRecentChecks", execute: slimRecentChecks },
{ name: "0011_backfillMonitorProxyMode", execute: backfillMonitorProxyMode },
];
const runMigrations = async (logger: ILogger) => {
@@ -40,8 +40,6 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
singleton: { type: Boolean, required: true, unique: true, default: true },
version: { type: Number, default: 1 },
globalThresholds: { type: thresholdsSchema },
globalProxyEnabled: { type: Boolean, default: false },
globalProxyId: { type: String, default: null },
},
{ timestamps: true }
);
@@ -28,8 +28,6 @@ class MongoSettingsRepository implements ISettingsRepository {
singleton: doc.singleton,
version: doc.version ?? 1,
globalThresholds: doc.globalThresholds ?? undefined,
globalProxyEnabled: doc.globalProxyEnabled ?? false,
globalProxyId: doc.globalProxyId ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -16,6 +16,8 @@ export type EnvConfig = {
queueMode: QueueMode;
queuePrimaryProcesses: boolean;
statusPageThemesEnabled: boolean;
/** Comma-separated monitor types routed through the outbound proxy, if set. */
proxyMonitorTypes?: string;
clientConfig: ClientRuntimeConfig;
};
@@ -43,6 +45,7 @@ export class SettingsService implements ISettingsService {
queueMode: env.QUEUE_MODE,
queuePrimaryProcesses: env.QUEUE_PRIMARY_PROCESSES,
statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED,
proxyMonitorTypes: env.PROXY_MONITOR_TYPES,
clientConfig: {
...(env.CLIENT_CONFIG_API_BASE_URL && { apiBaseUrl: env.CLIENT_CONFIG_API_BASE_URL }),
...(env.CLIENT_CONFIG_CLIENT_HOST && { clientHost: env.CLIENT_CONFIG_CLIENT_HOST }),
@@ -49,8 +49,6 @@ export interface Settings {
singleton: boolean;
version: number;
globalThresholds?: SettingsThresholds;
globalProxyEnabled: boolean;
globalProxyId?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -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 = "ChecksRepository";
const SERVICE_NAME = "StatusService";
class MongoChecksRepository implements IChecksRepository {
static SERVICE_NAME = SERVICE_NAME;
@@ -213,9 +213,6 @@ class MongoChecksRepository implements IChecksRepository {
};
private filterToMatch = (filter: CheckFilter | undefined): Record<string, unknown> => {
if (filter === undefined) {
return {};
}
switch (filter) {
case "up":
return { status: true };
@@ -546,33 +543,35 @@ 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,
+2 -13
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.type.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods, ProxyModes } from "@/domain/monitors/monitor.type.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.type.js";
import type {
CheckAudits,
ILighthouseAudit,
@@ -14,7 +14,7 @@ type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { createdAt: Dat
type MonitorDocumentBase = Omit<
Monitor,
"id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "proxyId" | "createdAt" | "updatedAt"
"id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt"
> & {
statusWindow: boolean[];
recentChecks: CheckSnapshotDocument[];
@@ -22,7 +22,6 @@ type MonitorDocumentBase = Omit<
tags: Types.ObjectId[];
selectedDisks: string[];
matchMethod?: MonitorMatchMethod;
proxyId?: Types.ObjectId;
};
interface MonitorDocument extends MonitorDocumentBase {
@@ -171,16 +170,6 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: Boolean,
default: false,
},
proxyMode: {
type: String,
enum: ProxyModes,
default: "inherit",
},
proxyId: {
type: Schema.Types.ObjectId,
ref: "Proxy",
},
useAdvancedMatching: {
type: Boolean,
default: false,
@@ -34,7 +34,7 @@ export interface IMonitorsRepository {
findByIds(monitorIds: string[], options?: { recentChecks?: RecentChecksMode }): Promise<Monitor[]>;
// update
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>, options?: { unsetProxyId?: boolean }): Promise<Monitor>;
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor>;
updateByIds(monitorIds: string[], teamId: string, updates: Partial<Monitor>, excludeStatuses?: MonitorStatus[]): Promise<number>;
updateStatusWindowAndChecks(
monitorId: string,
@@ -53,7 +53,6 @@ export interface IMonitorsRepository {
// counts
findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise<number>;
findMonitorCountByProxyId(proxyId: string): Promise<number>;
// other
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
@@ -172,19 +172,16 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return count;
};
findMonitorCountByProxyId = async (proxyId: string): Promise<number> => {
const count = await MonitorModel.countDocuments({ proxyId });
return count;
};
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 });
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>) => {
const updatedMonitor = await MonitorModel.findOneAndUpdate(
{ _id: monitorId, teamId },
{
$set: {
...patch,
},
},
{ new: true, runValidators: true }
);
if (!updatedMonitor) {
throw new AppError({ message: `Failed to update monitor with id ${monitorId}`, status: 500 });
}
@@ -421,8 +418,6 @@ class MongoMonitorsRepository implements IMonitorsRepository {
statusWindowThreshold: doc.statusWindowThreshold,
type: doc.type,
ignoreTlsErrors: doc.ignoreTlsErrors,
proxyMode: doc.proxyMode ?? "inherit",
proxyId: doc.proxyId ? toStringId(doc.proxyId) : undefined,
useAdvancedMatching: doc.useAdvancedMatching ?? false,
jsonPath: doc.jsonPath ?? undefined,
expectedValue: doc.expectedValue ?? undefined,
+1 -10
View File
@@ -133,10 +133,6 @@ 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" });
@@ -379,12 +375,7 @@ export class MonitorService implements IMonitorService {
};
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
// 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 });
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body);
await this.scheduler.updateJob(editedMonitor);
return editedMonitor;
};
@@ -41,9 +41,6 @@ export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type);
export const ProxyModes = ["inherit", "none", "custom"] as const;
export type ProxyMode = (typeof ProxyModes)[number];
export const UptimeDetailsSupportedTypes = [
"http",
"ping",
@@ -84,8 +81,6 @@ export interface Monitor {
statusWindowThreshold: number;
type: MonitorType;
ignoreTlsErrors: boolean;
proxyMode: ProxyMode;
proxyId?: string;
useAdvancedMatching: boolean;
jsonPath?: string;
expectedValue?: string;
-38
View File
@@ -1,38 +0,0 @@
import { Schema, model, type Types } from "mongoose";
import type { Proxy as ProxyEntity } from "@/domain/proxies/proxy.type.js";
import { ProxyProtocols } from "@/domain/proxies/proxy.type.js";
type ProxyDocumentBase = Omit<ProxyEntity, "id" | "teamId" | "createdAt" | "updatedAt">;
interface ProxyDocument extends ProxyDocumentBase {
_id: Types.ObjectId;
teamId: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
const ProxySchema = new Schema<ProxyDocument>(
{
teamId: {
type: Schema.Types.ObjectId,
ref: "Team",
immutable: true,
required: true,
},
name: { type: String, required: true },
protocol: { type: String, enum: ProxyProtocols, required: true },
host: { type: String, required: true },
port: { type: Number, required: true },
username: { type: String, required: false },
password: { type: String, required: false },
},
{ timestamps: true }
);
ProxySchema.index({ teamId: 1, name: 1 }, { unique: true });
const ProxyModel = model<ProxyDocument>("Proxy", ProxySchema);
export type { ProxyDocument };
export { ProxyModel };
export default ProxyModel;
@@ -1,15 +0,0 @@
import { Proxy } from "@/domain/proxies/proxy.type.js";
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>;
// delete
deleteById(proxyId: string, teamId: string): Promise<Proxy>;
}
@@ -1,112 +0,0 @@
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
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 { Error as MongooseError, UpdateQuery } from "mongoose";
const SERVICE_NAME = "ProxiesRepository";
class MongoProxiesRepository implements IProxiesRepository {
static SERVICE_NAME = SERVICE_NAME;
private toEntity = (doc: ProxyDocument): Proxy => {
return {
id: toStringId(doc._id),
teamId: toStringId(doc.teamId),
name: doc.name,
protocol: doc.protocol,
host: doc.host,
port: doc.port,
username: doc.username,
password: doc.password,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
};
async create(proxyData: Partial<Proxy>): Promise<Proxy> {
try {
const proxy = await ProxyModel.create({ ...proxyData });
return this.toEntity(proxy);
} catch (error) {
if (error && typeof error === "object" && (error as { code?: number }).code === 11000) {
throw new AppError({ message: `A proxy named "${proxyData.name}" already exists`, service: SERVICE_NAME, status: 409 });
}
throw error;
}
}
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) {
throw new AppError({ message: "Proxy not found", service: SERVICE_NAME, status: 404 });
}
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);
}
async updateById(
proxyId: string,
teamId: string,
patch: Partial<Proxy>,
options?: { unsetPassword?: boolean; unsetUsername?: boolean }
): Promise<Proxy> {
try {
const update: UpdateQuery<ProxyDocument> = {
$set: {
...patch,
},
};
if (options?.unsetPassword) {
delete update.$set?.password;
update.$unset = { ...update.$unset, password: 1 };
}
if (options?.unsetUsername) {
delete update.$set?.username;
update.$unset = { ...update.$unset, username: 1 };
}
const updatedProxy = await ProxyModel.findOneAndUpdate({ _id: proxyId, teamId }, update, { new: true, runValidators: true });
if (!updatedProxy) {
throw new AppError({ message: "Proxy not found or could not be updated", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(updatedProxy);
} catch (error) {
if (error && typeof error === "object" && (error as { code?: number }).code === 11000) {
throw new AppError({ message: `A proxy named "${patch.name}" already exists`, service: SERVICE_NAME, status: 409 });
}
throw error;
}
}
async deleteById(proxyId: string, teamId: string): Promise<Proxy> {
const deletedProxy = await ProxyModel.findOneAndDelete({ _id: proxyId, teamId });
if (!deletedProxy) {
throw new AppError({ message: "Proxy not found or could not be deleted", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(deletedProxy);
}
}
export default MongoProxiesRepository;
-111
View File
@@ -1,111 +0,0 @@
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";
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>;
}
const SERVICE_NAME = "ProxiesService";
export class ProxiesService implements IProxiesService {
constructor(
private proxiesRepository: IProxiesRepository,
private monitorsRepository: IMonitorsRepository,
private settingsService: ISettingsService
) {}
private toResponse = ({ password, ...rest }: Proxy): ProxyResponse => ({ ...rest, hasPassword: Boolean(password) });
createProxy = async (proxyData: Partial<Proxy>, teamId: string): Promise<ProxyResponse> => {
proxyData.teamId = teamId;
if (!proxyData.password) {
delete proxyData.password;
}
if (!proxyData.username) {
delete proxyData.username;
}
const raw = await this.proxiesRepository.create(proxyData);
return this.toResponse(raw);
};
getProxy = async (proxyId: string, teamId: string): Promise<ProxyResponse> => {
const raw = await this.proxiesRepository.findById(proxyId, teamId);
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) => {
return this.toResponse(r);
});
return clean;
};
updateProxy = async (
proxyId: string,
teamId: string,
patch: Partial<Proxy> & { clearPassword?: boolean; clearUsername?: boolean }
): Promise<ProxyResponse> => {
// Empty/absent password and username don't delete, clearPassword / clearUsername does
const { clearPassword, clearUsername, ...proxyPatch } = patch;
if (!proxyPatch.password) {
delete proxyPatch.password;
}
if (!proxyPatch.username) {
delete proxyPatch.username;
}
const raw = await this.proxiesRepository.updateById(proxyId, teamId, proxyPatch, {
unsetPassword: clearPassword === true,
unsetUsername: clearUsername === true,
});
return this.toResponse(raw);
};
deleteProxy = async (proxyId: string, teamId: string): Promise<ProxyResponse> => {
// Make sure proxy is not in use
const monitorsUsingProxy = await this.monitorsRepository.findMonitorCountByProxyId(proxyId);
if (monitorsUsingProxy > 0) {
throw new AppError({
message: `Proxy still in use by ${monitorsUsingProxy} monitor${monitorsUsingProxy > 1 ? "s" : ""}`,
service: SERVICE_NAME,
status: 409,
});
}
// Make sure this is not set as a global proxy
const settings = await this.settingsService.getDBSettings();
if (settings.globalProxyId === proxyId) {
throw new AppError({
message: "Proxy is set as the global proxy in settings",
service: SERVICE_NAME,
status: 409,
});
}
const raw = await this.proxiesRepository.deleteById(proxyId, teamId);
return this.toResponse(raw);
};
}
-19
View File
@@ -1,19 +0,0 @@
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 };
export type ProxySummary = Pick<Proxy, "id" | "name" | "host" | "port">;
+65 -57
View File
@@ -9,24 +9,25 @@ 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"]);
import { ProxyAgentCache, parseProxyMonitorTypes, resolveProxyForCheck } from "@/service/network/ProxyPolicy.js";
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;
// Outbound proxy, resolved once at startup from the environment
private readonly proxyableTypes: Set<string>;
private readonly proxyAgents = new ProxyAgentCache();
constructor(
private got: Got,
private advancedMatcher: IAdvancedMatcher
private advancedMatcher: IAdvancedMatcher,
proxyMonitorTypes?: string,
logger?: { warn: (payload: { message: string; service: string; method: string }) => void }
) {
const cacheable = new CacheableLookup({ maxTtl: 300, errorTtl: 30 });
this.got = got.extend({
@@ -41,50 +42,18 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
this.httpAgent = new HttpAgent(agentOptions);
this.httpsAgent = new HttpsAgent({ ...agentOptions, rejectUnauthorized: true });
this.httpsAgentInsecure = new HttpsAgent({ ...agentOptions, rejectUnauthorized: false });
const { types, warning } = parseProxyMonitorTypes(proxyMonitorTypes);
this.proxyableTypes = types;
if (warning) {
logger?.warn({ message: warning, service: "HttpProvider", method: "constructor" });
}
}
supports(type: MonitorType) {
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: {
@@ -157,22 +126,20 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
};
}
private handleHttpError<T>(error: unknown, monitor: Monitor, ctx?: CheckContext): MonitorStatusResponse<T> {
private handleHttpError<T>(error: unknown, monitor: Monitor): 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: message,
message: error.message,
responseTime,
timings: error.timings,
payload: null as T,
@@ -202,7 +169,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
};
}
async handle<T>(monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<T>> {
async handle<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> {
const { url, secret, ignoreTlsErrors } = monitor;
if (!url) {
@@ -212,13 +179,37 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
const options: Record<string, unknown> = {
headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined,
};
let cleanupProxyListener: (() => void) | undefined;
options.agent = ctx?.proxyUrl
? this.getProxyAgents(ctx.proxyUrl, Boolean(ignoreTlsErrors))
: {
http: this.httpAgent,
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
};
// Route through an outbound proxy when the environment configures one and
// this monitor type is eligible; otherwise use the shared direct agents.
const { proxyUrl } = resolveProxyForCheck(monitor.type, url, this.proxyableTypes);
// Set when the proxy itself refuses the tunnel, so a proxy ACL denial is not
// reported as the target's own status code.
let tunnelFailure: { statusCode: number } | null = null;
if (proxyUrl) {
const proxyAgent = this.proxyAgents.get(proxyUrl, url);
options.agent = { http: proxyAgent, https: proxyAgent };
// The agent's rejectUnauthorized governs the socket to the proxy, not the
// tunnelled handshake with the target, so ignoreTlsErrors has to be stated
// per request or it would be silently dropped for proxied monitors.
options.https = { rejectUnauthorized: !ignoreTlsErrors };
const emitter = proxyAgent as unknown as {
once?: (event: string, cb: (connect: { statusCode: number }) => void) => void;
off?: (event: string, cb: (connect: { statusCode: number }) => void) => void;
};
const onProxyConnect = (connect: { statusCode: number }) => {
if (connect.statusCode >= 400) tunnelFailure = { statusCode: connect.statusCode };
};
emitter.once?.("proxyConnect", onProxyConnect);
cleanupProxyListener = () => emitter.off?.("proxyConnect", onProxyConnect);
} else {
options.agent = {
http: this.httpAgent,
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
};
}
options.method = monitor.method;
@@ -236,7 +227,24 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
timings: response.timings,
});
} catch (error: unknown) {
return this.handleHttpError(error, monitor, ctx);
if (tunnelFailure) {
// The proxy rejected the tunnel, so the target was never reached and
// its status is unknown. Reporting NETWORK_ERROR keeps customUpCodes
// from marking the monitor up on the proxy's 403/407.
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: `Proxy refused the connection (HTTP ${(tunnelFailure as { statusCode: number }).statusCode})`,
responseTime: 0,
payload: null as T,
};
}
return this.handleHttpError(error, monitor);
} finally {
cleanupProxyListener?.();
}
}
}
@@ -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, ctx?: CheckContext): Promise<MonitorStatusResponse<T>>;
handle(monitor: Monitor): Promise<MonitorStatusResponse<T>>;
}
+162
View File
@@ -0,0 +1,162 @@
import { HttpProxyAgent } from "http-proxy-agent";
import { HttpsProxyAgent } from "https-proxy-agent";
import { getProxyForUrl } from "proxy-from-env";
import type { Agent as HttpAgent } from "http";
import type { Agent as HttpsAgent } from "https";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
/**
* Monitor types whose checks may be routed through an outbound HTTP proxy.
*
* Only types served by HttpProvider can be proxied at all — ping, port, docker,
* grpc, websocket, dns and game open raw sockets, which an HTTP proxy cannot
* carry.
*
* "hardware" is deliberately excluded by default: infrastructure monitors talk
* to Capture agents, which almost always sit on the internal network. Sending
* those through a corporate proxy fails or hairpins, so operators must opt in
* explicitly via PROXY_MONITOR_TYPES.
*/
export const PROXYABLE_MONITOR_TYPES = ["http", "pagespeed"] as const;
export type ProxyableMonitorType = (typeof PROXYABLE_MONITOR_TYPES)[number];
/** Types that reach the network through HttpProvider, and so could be proxied. */
const HTTP_BACKED_MONITOR_TYPES = ["http", "pagespeed", "hardware"] as const;
export interface ProxyResolution {
/** Proxy URL to dial through, or null to connect directly. */
proxyUrl: string | null;
}
/**
* Parses the PROXY_MONITOR_TYPES escape hatch.
*
* Unset falls back to PROXYABLE_MONITOR_TYPES, and the literal "none" disables
* proxying entirely. Entries that are not real monitor types, or that never
* reach the network through HttpProvider, are dropped and reported so a typo
* cannot silently disable proxying.
*/
export const parseProxyMonitorTypes = (raw: string | undefined): { types: Set<string>; warning?: string } => {
if (raw === undefined || raw.trim() === "") {
return { types: new Set<string>(PROXYABLE_MONITOR_TYPES) };
}
const requested = raw
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter((entry) => entry !== "");
// Explicit opt-out, so proxying can be disabled without unsetting the proxy
// environment variables the rest of the container may rely on. Honoured
// wherever it appears, so "none,http" disables rather than half-enabling.
if (requested.includes("none")) {
return { types: new Set<string>() };
}
const types = new Set<string>();
const unknown: string[] = [];
const unproxyable: string[] = [];
for (const entry of requested) {
// "unknown" is a real MonitorType but never a deliberate choice here.
if (entry === "unknown" || !(MonitorTypes as readonly string[]).includes(entry)) {
unknown.push(entry);
} else if (!(HTTP_BACKED_MONITOR_TYPES as readonly string[]).includes(entry)) {
unproxyable.push(entry);
} else {
types.add(entry);
}
}
const problems: string[] = [];
if (unknown.length > 0) {
problems.push(`unknown monitor type(s): ${unknown.join(", ")}`);
}
if (unproxyable.length > 0) {
problems.push(`monitor type(s) that do not use HTTP and cannot be proxied: ${unproxyable.join(", ")}`);
}
// Every entry was a typo. Disabling all proxying here would take every HTTP
// monitor down in a restricted-egress deployment, so fall back to the defaults
// and make the misconfiguration loud instead.
//
// An all-unproxyable list (e.g. "ping,port") is left empty on purpose: those
// are deliberate choices, and silently proxying http instead would route
// traffic the operator never nominated.
if (types.size === 0 && unproxyable.length === 0) {
return {
types: new Set<string>(PROXYABLE_MONITOR_TYPES),
warning: `PROXY_MONITOR_TYPES matched no proxyable monitor type — ${problems.join("; ")}. Falling back to: ${PROXYABLE_MONITOR_TYPES.join(", ")}. Use "none" to disable proxying.`,
};
}
return {
types,
warning: problems.length > 0 ? `PROXY_MONITOR_TYPES ignored ${problems.join("; ")}. Proxying: ${[...types].join(", ")}` : undefined,
};
};
/**
* Decides whether a single check should be proxied.
*
* Honours NO_PROXY (including wildcards and port-qualified hosts) by delegating
* to proxy-from-env, so the same rules operators already use for curl apply
* here rather than a hand-rolled reimplementation.
*
* proxy-from-env resolves against process.env, which is read once at startup
* (see createProxySettings). Checks run concurrently, so the environment is
* never mutated per-check.
*/
export const resolveProxyForCheck = (monitorType: MonitorType, url: string, proxyableTypes: Set<string>): ProxyResolution => {
if (!proxyableTypes.has(monitorType)) {
return { proxyUrl: null };
}
const proxyUrl = getProxyForUrl(url);
return { proxyUrl: proxyUrl === "" ? null : proxyUrl };
};
/**
* Builds proxy agents on demand and reuses them.
*
* The agent must match the *target* scheme, not the proxy's. HttpsProxyAgent
* always issues CONNECT, which hardened proxies (Squid's stock ACL among them)
* permit only to 443 — so a plain-http target needs HttpProxyAgent, which sends
* an absolute-URI request instead.
*
* Caching preserves connection pooling: without it every check would open a
* fresh connection to the proxy. Node keys its own socket pools on the TLS
* options it receives per request, so agents are shared across monitors
* regardless of ignoreTlsErrors — that setting is applied per request in
* HttpProvider, not baked into the agent.
*/
export class ProxyAgentCache {
private readonly agents = new Map<string, HttpAgent | HttpsAgent>();
get(proxyUrl: string, targetUrl: string): HttpAgent | HttpsAgent {
const targetIsSecure = safeProtocol(targetUrl) !== "http:";
const key = `${proxyUrl}::${targetIsSecure ? "https" : "http"}`;
const existing = this.agents.get(key);
if (existing) return existing;
const options = { keepAlive: true, maxSockets: 256, maxFreeSockets: 256 };
const agent = (targetIsSecure ? new HttpsProxyAgent(proxyUrl, options) : new HttpProxyAgent(proxyUrl, options)) as unknown as HttpsAgent;
this.agents.set(key, agent);
return agent;
}
/** Number of distinct agents held; used in tests to prove pooling. */
get size(): number {
return this.agents.size;
}
}
/** Target URLs are validated upstream; fall back to https on anything odd. */
const safeProtocol = (url: string): string => {
try {
return new URL(url).protocol;
} catch {
return "https:";
}
};
-111
View File
@@ -1,111 +0,0 @@
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;
}
}
}
+4 -7
View File
@@ -1,5 +1,5 @@
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import type { CheckContext, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
import type { 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 }, ctx?: CheckContext): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
requestWebhook(
type: string,
url: string,
@@ -39,15 +39,12 @@ export class NetworkService implements INetworkService {
}
// Main entry point
async requestStatus<T extends MonitorType>(
monitor: Monitor & { type: T },
ctx?: CheckContext
): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
async requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): 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, ctx) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
return provider.handle(monitor) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
}
private async handleUnsupportedType(type: string): Promise<MonitorStatusResponse> {
-4
View File
@@ -152,7 +152,3 @@ export type StatusChangeResult = {
};
export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
export type CheckContext = {
proxyUrl?: string;
};
+8
View File
@@ -0,0 +1,8 @@
declare module "proxy-from-env" {
/**
* Resolves the proxy that should handle a request to the given URL, reading
* HTTP_PROXY / HTTPS_PROXY / NO_PROXY (and their lowercase forms) from
* process.env. Returns an empty string when the request should go direct.
*/
export function getProxyForUrl(url: string): string;
}
+1 -4
View File
@@ -9,7 +9,6 @@ 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>;
@@ -23,7 +22,6 @@ export class CheckProducer implements ICheckProducer {
private maintenanceWindowsRepository: IMaintenanceWindowsRepository,
private checkService: ICheckService,
private networkService: INetworkService,
private proxyResolver: IProxyResolver,
private bufferService: IBufferService,
private logger: ILogger
) {}
@@ -58,8 +56,7 @@ export class CheckProducer implements ICheckProducer {
}
// Step 1b: Acquire status
const proxyUrl = await this.proxyResolver.resolve(monitor);
const status = await this.networkService.requestStatus(monitor, { proxyUrl });
const status = await this.networkService.requestStatus(monitor);
if (!status) {
throw new Error("No network response");
}
@@ -127,14 +127,12 @@ 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,5 +1,4 @@
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";
@@ -75,9 +74,9 @@ const makeGotResponse = (overrides?: Record<string, any>) => ({
...overrides,
});
const createProvider = (matcher?: any) => {
const createProvider = (matcher?: any, proxyMonitorTypes?: string) => {
const advancedMatcher = matcher ?? createMockMatcher();
const provider = new HttpProvider(mockGot as any, advancedMatcher);
const provider = new HttpProvider(mockGot as any, advancedMatcher, proxyMonitorTypes);
return { provider, advancedMatcher };
};
@@ -496,151 +495,127 @@ describe("HttpProvider", () => {
});
});
// ── Proxy support ────────────────────────────────────────────────────
// ── Outbound proxy ───────────────────────────────────────────────────
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;
describe("outbound proxy routing", () => {
const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"];
const clearProxyEnv = () => {
for (const key of PROXY_KEYS) delete process.env[key];
};
it("uses hpagent proxy agents when ctx.proxyUrl is set", async () => {
const agentOf = () => (mockGot.mock.calls[0]?.[1] as any)?.agent;
const httpsOptionOf = () => (mockGot.mock.calls[0]?.[1] as any)?.https;
const isProxyAgent = (agent: any) => Boolean(agent?.https?.constructor?.name?.includes("Proxy"));
it("routes an http monitor through the configured proxy", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
await provider.handle(makeMonitor({ url: "https://example.com" }));
expect(gotOptions().agent.http).toBeInstanceOf(HttpProxyAgent);
expect(gotOptions().agent.https).toBeInstanceOf(HttpsProxyAgent);
expect(isProxyAgent(agentOf())).toBe(true);
clearProxyEnv();
});
it("uses the shared agents when ctx has no proxyUrl", async () => {
it("leaves infrastructure monitors direct by default", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor());
await provider.handle(makeMonitor(), {});
// Capture agents live on the internal network; proxying them breaks them.
await provider.handle(makeMonitor({ type: "hardware", url: "https://10.0.0.5:59232" }));
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);
expect(isProxyAgent(agentOf())).toBe(false);
clearProxyEnv();
});
it("reuses the cached agent pair across checks with the same proxyUrl", async () => {
it("routes infrastructure monitors once opted in", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider(undefined, "http,hardware");
await provider.handle(makeMonitor({ type: "hardware", url: "https://10.0.0.5:59232" }));
expect(isProxyAgent(agentOf())).toBe(true);
clearProxyEnv();
});
it("keeps ignoreTlsErrors effective for proxied monitors", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
// The proxy agent's rejectUnauthorized applies to the socket to the proxy,
// not the tunnelled handshake, so this must be stated per request or a
// self-signed target would start failing the moment a proxy is configured.
await provider.handle(makeMonitor({ url: "https://self-signed.example.com", ignoreTlsErrors: true }));
expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http);
expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https);
expect(httpsOptionOf()).toEqual({ rejectUnauthorized: false });
clearProxyEnv();
});
it("builds distinct agent pairs for distinct proxyUrls", async () => {
it("keeps TLS strict for proxied monitors that did not opt out", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
await provider.handle(makeMonitor(), { proxyUrl: "http://other.example.com:3128" });
await provider.handle(makeMonitor({ url: "https://example.com", ignoreTlsErrors: false }));
expect(gotOptions(1).agent.http).not.toBe(gotOptions(0).agent.http);
expect(httpsOptionOf()).toEqual({ rejectUnauthorized: true });
clearProxyEnv();
});
it("builds distinct pairs per ignoreTlsErrors and sets rejectUnauthorized accordingly", async () => {
it("does not set a proxy agent when no proxy is configured", async () => {
clearProxyEnv();
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ ignoreTlsErrors: false }), { proxyUrl: PROXY_URL });
await provider.handle(makeMonitor({ ignoreTlsErrors: true }), { proxyUrl: PROXY_URL });
await provider.handle(makeMonitor({ url: "https://example.com" }));
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);
expect(isProxyAgent(agentOf())).toBe(false);
});
it("evicts and destroys the least-recently-used pair past the cache cap", async () => {
mockGot.mockResolvedValue(makeGotResponse());
it("reports a refused proxy tunnel as a network error, not the target's status", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
const { provider } = createProvider();
// The agent replays a refused CONNECT onto the socket, so the proxy's own
// status would otherwise be parsed as the target's.
const agent: any = (provider as any).proxyAgents.get("http://corp:3128", "https://example.com");
const err = new HTTPError("Response code 403 (Forbidden)");
(err as any).response = { statusCode: 403, body: "", headers: {} };
(err as any).timings = { phases: { total: 5 } };
mockGot.mockImplementation(() => {
agent.emit("proxyConnect", { statusCode: 403 });
return Promise.reject(err);
});
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 });
const result = await provider.handle(makeMonitor({ url: "https://example.com", customUpCodes: [403] }));
// customUpCodes must not mark the monitor up on the proxy's status.
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");
expect(result.message).toContain("Proxy refused");
clearProxyEnv();
});
it("does not attribute timeouts to the proxy", async () => {
mockGot.mockRejectedValue(makeConnectionError("ETIMEDOUT", "Timeout awaiting 'request' for 30000ms"));
it("honours NO_PROXY", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
process.env.NO_PROXY = "example.com";
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
await provider.handle(makeMonitor({ url: "https://example.com" }));
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)");
expect(isProxyAgent(agentOf())).toBe(false);
clearProxyEnv();
});
});
});
@@ -0,0 +1,218 @@
import { describe, expect, it, afterEach } from "@jest/globals";
import {
PROXYABLE_MONITOR_TYPES,
ProxyAgentCache,
parseProxyMonitorTypes,
resolveProxyForCheck,
} from "../../../../src/service/network/ProxyPolicy.ts";
// proxy-from-env reads process.env directly, so these tests set and restore it.
const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"];
const withEnv = (vars: Record<string, string>) => {
for (const key of PROXY_KEYS) delete process.env[key];
for (const [key, value] of Object.entries(vars)) process.env[key] = value;
};
afterEach(() => {
for (const key of PROXY_KEYS) delete process.env[key];
});
const defaults = () => parseProxyMonitorTypes(undefined).types;
describe("parseProxyMonitorTypes", () => {
it("defaults to http and pagespeed, excluding infrastructure", () => {
const { types, warning } = parseProxyMonitorTypes(undefined);
expect([...types].sort()).toEqual([...PROXYABLE_MONITOR_TYPES].sort());
expect(types.has("hardware")).toBe(false);
expect(warning).toBeUndefined();
});
it("treats an empty value as unset", () => {
expect([...parseProxyMonitorTypes(" ").types].sort()).toEqual([...PROXYABLE_MONITOR_TYPES].sort());
});
it("lets operators opt infrastructure in", () => {
const { types, warning } = parseProxyMonitorTypes("http,hardware");
expect(types.has("hardware")).toBe(true);
expect(types.has("pagespeed")).toBe(false);
expect(warning).toBeUndefined();
});
it("tolerates whitespace and mixed case", () => {
const { types } = parseProxyMonitorTypes(" HTTP , Hardware ");
expect([...types].sort()).toEqual(["hardware", "http"]);
});
it("allows proxying to be disabled entirely with an explicit sentinel", () => {
const { types, warning } = parseProxyMonitorTypes("none");
expect(types.size).toBe(0);
// "none" is a supported value, not a typo, so it must not warn.
expect(warning).toBeUndefined();
});
it("honours the none sentinel wherever it appears", () => {
expect(parseProxyMonitorTypes("none,http").types.size).toBe(0);
});
it("leaves an all-unproxyable list empty rather than proxying types nobody named", () => {
// "ping,port" is a misunderstanding, but a deliberate one — silently
// proxying http instead would route traffic the operator never nominated.
const { types, warning } = parseProxyMonitorTypes("ping,port");
expect(types.size).toBe(0);
expect(warning).toContain("cannot be proxied");
});
it("falls back to defaults when every entry is invalid, rather than disabling proxying", () => {
// Silently proxying nothing would take every HTTP monitor down in a
// restricted-egress deployment, which is worse than ignoring the typo.
const { types, warning } = parseProxyMonitorTypes("htpp");
expect([...types].sort()).toEqual([...PROXYABLE_MONITOR_TYPES].sort());
expect(warning).toContain("Falling back to");
});
it("treats the internal unknown type as a typo", () => {
const { types, warning } = parseProxyMonitorTypes("unknown");
expect([...types].sort()).toEqual([...PROXYABLE_MONITOR_TYPES].sort());
expect(warning).toContain("unknown monitor type");
});
it("drops socket-based types that a proxy cannot carry, and says so", () => {
const { types, warning } = parseProxyMonitorTypes("http,ping,port,dns");
expect([...types]).toEqual(["http"]);
expect(warning).toContain("cannot be proxied");
expect(warning).toContain("ping");
});
it("reports unknown types rather than silently ignoring them", () => {
const { types, warning } = parseProxyMonitorTypes("http,htpp");
expect([...types]).toEqual(["http"]);
expect(warning).toContain("unknown monitor type");
expect(warning).toContain("htpp");
});
});
describe("resolveProxyForCheck", () => {
it("proxies a public HTTP monitor", () => {
withEnv({ HTTPS_PROXY: "http://corp:3128" });
expect(resolveProxyForCheck("http", "https://example.com", defaults()).proxyUrl).toBe("http://corp:3128");
});
it("leaves infrastructure checks direct by default", () => {
withEnv({ HTTPS_PROXY: "http://corp:3128" });
// Capture agents sit on the internal network; proxying them would break them.
expect(resolveProxyForCheck("hardware", "https://10.0.0.5:59232", defaults()).proxyUrl).toBeNull();
});
it("proxies infrastructure once opted in", () => {
withEnv({ HTTPS_PROXY: "http://corp:3128" });
const types = parseProxyMonitorTypes("http,hardware").types;
expect(resolveProxyForCheck("hardware", "https://10.0.0.5:59232", types).proxyUrl).toBe("http://corp:3128");
});
it("returns null when no proxy is configured", () => {
withEnv({});
expect(resolveProxyForCheck("http", "https://example.com", defaults()).proxyUrl).toBeNull();
});
it("honours NO_PROXY host entries", () => {
withEnv({ HTTPS_PROXY: "http://corp:3128", NO_PROXY: "internal.example.com" });
expect(resolveProxyForCheck("http", "https://internal.example.com", defaults()).proxyUrl).toBeNull();
expect(resolveProxyForCheck("http", "https://public.example.com", defaults()).proxyUrl).toBe("http://corp:3128");
});
it("honours NO_PROXY wildcards", () => {
withEnv({ HTTPS_PROXY: "http://corp:3128", NO_PROXY: "*.internal" });
expect(resolveProxyForCheck("http", "https://box.internal", defaults()).proxyUrl).toBeNull();
});
it("keeps credentials in an authenticated proxy URL", () => {
withEnv({ HTTPS_PROXY: "http://enki:s3cr3t@corp:3128" });
expect(resolveProxyForCheck("http", "https://example.com", defaults()).proxyUrl).toBe("http://enki:s3cr3t@corp:3128");
});
it("selects the proxy matching the target scheme", () => {
withEnv({ HTTP_PROXY: "http://plain:3128", HTTPS_PROXY: "http://secure:3129" });
expect(resolveProxyForCheck("http", "http://example.com", defaults()).proxyUrl).toBe("http://plain:3128");
expect(resolveProxyForCheck("http", "https://example.com", defaults()).proxyUrl).toBe("http://secure:3129");
});
it("never proxies socket-based monitor types", () => {
withEnv({ HTTPS_PROXY: "http://corp:3128" });
for (const type of ["ping", "port", "docker", "grpc", "websocket", "dns", "game"] as const) {
expect(resolveProxyForCheck(type, "https://example.com", defaults()).proxyUrl).toBeNull();
}
});
});
describe("ProxyAgentCache", () => {
it("reuses one agent per proxy so connections stay pooled", () => {
const cache = new ProxyAgentCache();
const first = cache.get("http://corp:3128", "https://example.com");
const second = cache.get("http://corp:3128", "https://other.example.com");
expect(second).toBe(first);
expect(cache.size).toBe(1);
});
it("uses a CONNECT-based agent for https targets", () => {
const cache = new ProxyAgentCache();
const agent = cache.get("http://corp:3128", "https://example.com");
expect(agent.constructor.name).toBe("HttpsProxyAgent");
});
it("uses an absolute-URI agent for plain http targets", () => {
const cache = new ProxyAgentCache();
// HttpsProxyAgent would issue CONNECT, which hardened proxies allow only
// to 443, breaking every http:// monitor.
const agent = cache.get("http://corp:3128", "http://example.com");
expect(agent.constructor.name).toBe("HttpProxyAgent");
});
it("keeps http and https target agents separate", () => {
const cache = new ProxyAgentCache();
cache.get("http://corp:3128", "https://example.com");
cache.get("http://corp:3128", "http://example.com");
expect(cache.size).toBe(2);
});
it("keeps a separate agent per proxy URL", () => {
const cache = new ProxyAgentCache();
cache.get("http://a:3128", "https://example.com");
cache.get("http://b:3128", "https://example.com");
expect(cache.size).toBe(2);
});
it("falls back to a CONNECT agent for an unparseable target", () => {
const cache = new ProxyAgentCache();
expect(cache.get("http://corp:3128", "not-a-url").constructor.name).toBe("HttpsProxyAgent");
});
});
@@ -26,7 +26,6 @@ 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,
};
@@ -35,7 +34,6 @@ 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
);
@@ -84,29 +82,6 @@ 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,38 +124,6 @@ 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);
@@ -782,42 +750,9 @@ 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" }, { unsetProxyId: false });
expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { name: "Updated" });
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, undefined);
expect(provider.handle).toHaveBeenCalledWith(monitor);
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
@@ -73,18 +73,7 @@ describe("NetworkService", () => {
await service.requestStatus(monitor);
expect(httpProvider.handle).not.toHaveBeenCalled();
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);
expect(pingProvider.handle).toHaveBeenCalledWith(monitor);
});
it("returns unsupported-type response when no provider matches", async () => {
@@ -1,248 +0,0 @@
import { describe, expect, it, jest } from "@jest/globals";
import { ProxiesService } from "../../../src/domain/proxies/proxy.service.ts";
import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createProxiesRepo = () => ({
create: jest.fn(),
findAll: jest.fn(),
findById: jest.fn(),
findByIdOrNull: jest.fn(),
findByTeamId: jest.fn(),
updateById: jest.fn(),
deleteById: jest.fn(),
});
const createMonitorsRepo = () => ({
findMonitorCountByProxyId: jest.fn(),
});
const createSettingsService = () => ({
getDBSettings: jest.fn(),
});
const createService = () => {
const proxiesRepository = createProxiesRepo();
const monitorsRepository = createMonitorsRepo();
const settingsService = createSettingsService();
(monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(0);
(settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: null });
const service = new ProxiesService(proxiesRepository as any, monitorsRepository as any, settingsService as any);
return { service, proxiesRepository, monitorsRepository, settingsService };
};
const makeProxy = (overrides?: Partial<Proxy>): Proxy =>
({
id: "proxy-1",
teamId: "team-1",
name: "Egress proxy",
protocol: "http",
host: "proxy.internal",
port: 3128,
username: "user",
password: "secret",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Proxy;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("ProxiesService", () => {
describe("createProxy", () => {
it("sets teamId and delegates to repository", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy());
await service.createProxy({ name: "Egress proxy", protocol: "http", host: "proxy.internal", port: 3128 }, "team-1");
expect(proxiesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1", name: "Egress proxy" }));
});
it("omits password from the response and reports hasPassword", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy());
const result = await service.createProxy({ name: "Egress proxy" }, "team-1");
expect(result).not.toHaveProperty("password");
expect(result.hasPassword).toBe(true);
});
it("strips blank credentials before creating", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined }));
await service.createProxy({ name: "Egress proxy", username: "", password: "" }, "team-1");
const created = (proxiesRepository.create as jest.Mock).mock.calls[0][0] as Partial<Proxy>;
expect(created).not.toHaveProperty("username");
expect(created).not.toHaveProperty("password");
});
});
describe("getProxy", () => {
it("delegates to repository and masks the password", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy());
const result = await service.getProxy("proxy-1", "team-1");
expect(proxiesRepository.findById).toHaveBeenCalledWith("proxy-1", "team-1");
expect(result).not.toHaveProperty("password");
expect(result.hasPassword).toBe(true);
});
it("reports hasPassword false when none is stored", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy({ password: undefined }));
const result = await service.getProxy("proxy-1", "team-1");
expect(result.hasPassword).toBe(false);
});
});
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();
(proxiesRepository.findByTeamId as jest.Mock).mockResolvedValue([makeProxy(), makeProxy({ id: "proxy-2", password: undefined })]);
const result = await service.getProxiesByTeamId("team-1");
expect(proxiesRepository.findByTeamId).toHaveBeenCalledWith("team-1");
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("updateProxy", () => {
it("keeps the stored password when the patch has a blank one", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy());
await service.updateProxy("proxy-1", "team-1", { name: "Renamed", password: "", username: "" });
const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial<Proxy>;
expect(patch).not.toHaveProperty("password");
expect(patch).not.toHaveProperty("username");
expect(patch.name).toBe("Renamed");
});
it("passes a new password through", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ password: "next" }));
await service.updateProxy("proxy-1", "team-1", { password: "next" });
const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial<Proxy>;
expect(patch.password).toBe("next");
});
it("maps clear flags to unset options and keeps them out of the patch", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined }));
await service.updateProxy("proxy-1", "team-1", { name: "Renamed", clearPassword: true, clearUsername: true });
const [, , patch, options] = (proxiesRepository.updateById as jest.Mock).mock.calls[0];
expect(patch).not.toHaveProperty("clearPassword");
expect(patch).not.toHaveProperty("clearUsername");
expect(options).toEqual({ unsetPassword: true, unsetUsername: true });
});
it("does not unset when clear flags are absent", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy());
await service.updateProxy("proxy-1", "team-1", { name: "Renamed" });
const options = (proxiesRepository.updateById as jest.Mock).mock.calls[0][3];
expect(options).toEqual({ unsetPassword: false, unsetUsername: false });
});
it("masks the updated proxy in the response", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy());
const result = await service.updateProxy("proxy-1", "team-1", { name: "Renamed" });
expect(result).not.toHaveProperty("password");
expect(result.hasPassword).toBe(true);
});
});
describe("deleteProxy", () => {
it("deletes when unreferenced and masks the response", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.deleteById as jest.Mock).mockResolvedValue(makeProxy());
const result = await service.deleteProxy("proxy-1", "team-1");
expect(proxiesRepository.deleteById).toHaveBeenCalledWith("proxy-1", "team-1");
expect(result).not.toHaveProperty("password");
});
it("rejects with 409 when monitors reference the proxy", async () => {
const { service, proxiesRepository, monitorsRepository } = createService();
(monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(3);
await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 });
expect(proxiesRepository.deleteById).not.toHaveBeenCalled();
});
it("rejects with 409 when the proxy is the global proxy", async () => {
const { service, proxiesRepository, settingsService } = createService();
(settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: "proxy-1" });
await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 });
expect(proxiesRepository.deleteById).not.toHaveBeenCalled();
});
});
});
@@ -1,267 +0,0 @@
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");
});
});
@@ -470,98 +470,3 @@ describe("monitorValidation — customUpCodes", () => {
});
});
});
describe("monitorValidation — proxy fields", () => {
const baseHttpBody = {
name: "HTTP check",
type: "http" as const,
url: "https://example.com",
};
const PROXY_ID = "64b7a1f2c9e77a001a2b3c4d";
describe("createMonitorBodyValidation", () => {
it("defaults proxyMode to inherit", () => {
const parsed = createMonitorBodyValidation.parse(baseHttpBody);
expect(parsed.proxyMode).toBe("inherit");
expect(parsed.proxyId).toBeUndefined();
});
it("accepts custom mode with a proxyId", () => {
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: PROXY_ID });
expect(parsed.proxyMode).toBe("custom");
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_ID });
expect(parsed.proxyMode).toBe(proxyMode);
}
});
});
describe("editMonitorBodyValidation", () => {
it("leaves proxyMode unset when omitted (no default injected on PATCH)", () => {
const parsed = editMonitorBodyValidation.parse({ name: "Renamed" });
expect(parsed.proxyMode).toBeUndefined();
});
it("rejects custom mode without a proxyId", () => {
expect(() => editMonitorBodyValidation.parse({ proxyMode: "custom" })).toThrow();
});
it("accepts custom mode with a proxyId", () => {
const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: PROXY_ID });
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();
});
});
describe("importMonitorsBodyValidation", () => {
it("defaults proxyMode to inherit on import", () => {
const parsed = importMonitorsBodyValidation.parse({ monitors: [baseHttpBody] });
expect(parsed.monitors[0].proxyMode).toBe("inherit");
});
it("rejects an imported monitor with custom mode and no proxyId", () => {
expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseHttpBody, proxyMode: "custom" }] })).toThrow();
});
});
});
@@ -1,97 +0,0 @@
import { describe, expect, it } from "@jest/globals";
import { createProxyBodyValidation, editProxyBodyValidation, proxyResponseSchema } from "../../../src/api/validation/proxyValidation.ts";
const validBody = {
name: "Egress proxy",
protocol: "http" as const,
host: "proxy.internal",
port: 3128,
};
describe("proxyValidation", () => {
describe("createProxyBodyValidation", () => {
it("accepts a minimal valid body", () => {
const parsed = createProxyBodyValidation.parse(validBody);
expect(parsed).toEqual(validBody);
});
it("accepts optional credentials", () => {
const parsed = createProxyBodyValidation.parse({ ...validBody, username: "user", password: "secret" });
expect(parsed.username).toBe("user");
expect(parsed.password).toBe("secret");
});
it("rejects an empty name", () => {
expect(() => createProxyBodyValidation.parse({ ...validBody, name: "" })).toThrow();
});
it("rejects an empty host", () => {
expect(() => createProxyBodyValidation.parse({ ...validBody, host: "" })).toThrow();
});
it("rejects a protocol outside the tuple", () => {
expect(() => createProxyBodyValidation.parse({ ...validBody, protocol: "socks5" })).toThrow();
});
it("accepts the port boundaries", () => {
expect(createProxyBodyValidation.parse({ ...validBody, port: 1 }).port).toBe(1);
expect(createProxyBodyValidation.parse({ ...validBody, port: 65535 }).port).toBe(65535);
});
it("rejects out-of-range and non-integer ports", () => {
for (const port of [0, -1, 65536, 3128.5, "3128"]) {
expect(() => createProxyBodyValidation.parse({ ...validBody, port })).toThrow();
}
});
});
describe("editProxyBodyValidation", () => {
it("accepts the create shape without clear flags", () => {
const parsed = editProxyBodyValidation.parse(validBody);
expect(parsed.clearPassword).toBeUndefined();
expect(parsed.clearUsername).toBeUndefined();
});
it("accepts clearPassword and clearUsername", () => {
const parsed = editProxyBodyValidation.parse({ ...validBody, clearPassword: true, clearUsername: true });
expect(parsed.clearPassword).toBe(true);
expect(parsed.clearUsername).toBe(true);
});
it("rejects non-boolean clear flags", () => {
expect(() => editProxyBodyValidation.parse({ ...validBody, clearPassword: "yes" })).toThrow();
});
});
describe("proxyResponseSchema", () => {
const validResponse = {
id: "proxy-1",
teamId: "team-1",
name: "Egress proxy",
protocol: "http",
host: "proxy.internal",
port: 3128,
hasPassword: false,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
it("accepts a masked proxy", () => {
expect(() => proxyResponseSchema.parse(validResponse)).not.toThrow();
});
it("requires hasPassword", () => {
const { hasPassword: _hasPassword, ...withoutFlag } = validResponse;
expect(() => proxyResponseSchema.parse(withoutFlag)).toThrow();
});
it("does not declare a password field", () => {
expect(Object.keys(proxyResponseSchema.shape)).not.toContain("password");
});
});
});