Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e760db1192 | ||
|
|
3b03850a3b | ||
|
|
705c19f7c8 |
No files matched your search
@@ -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>
|
||||
@@ -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."
|
||||
Generated
+2
-2
@@ -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
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "3.11.0",
|
||||
"version": "3.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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")}
|
||||
|
||||
@@ -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)})`}
|
||||
|
||||
@@ -34,17 +34,6 @@ import { Typography } from "@mui/material";
|
||||
|
||||
const certificateDateFormat = "MMM D, YYYY h A";
|
||||
|
||||
const isHttpsUrl = (url?: string): boolean => {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new URL(url).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
interface CertificateResponse {
|
||||
certificateDate: string;
|
||||
}
|
||||
@@ -91,15 +80,13 @@ const UptimeDetailsPage = () => {
|
||||
const monitor = monitorData?.monitor;
|
||||
const monitorStats = monitorDetailsData?.monitorStats ?? null;
|
||||
|
||||
// Certificate fetch - only for monitors served over HTTPS. A "http" type
|
||||
// monitor may still have an http:// URL, which serves no certificate, so the
|
||||
// scheme is gated here to match the server rather than requesting a 400.
|
||||
// Certificate fetch - only for HTTP monitors
|
||||
const certificateUrl = useMemo(() => {
|
||||
if (!monitorId || monitor?.type !== "http" || !isHttpsUrl(monitor?.url)) {
|
||||
if (!monitorId || monitor?.type !== "http") {
|
||||
return null;
|
||||
}
|
||||
return `/monitors/certificate/${monitorId}`;
|
||||
}, [monitorId, monitor?.type, monitor?.url]);
|
||||
}, [monitorId, monitor?.type]);
|
||||
|
||||
const { data: certificateData } = useGet<CertificateResponse>(
|
||||
certificateUrl,
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
DnsRecordTypes,
|
||||
HttpMethods,
|
||||
PageSpeedStrategies,
|
||||
ProxyModes,
|
||||
type MonitorType,
|
||||
} from "@/Types/Monitor";
|
||||
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
|
||||
@@ -68,8 +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),
|
||||
proxyId: z.string().optional(),
|
||||
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
|
||||
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
|
||||
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
|
||||
@@ -198,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,
|
||||
@@ -211,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).
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -79,7 +79,6 @@ export const statusPageSchema = z.object({
|
||||
.object({
|
||||
data: z.string(),
|
||||
contentType: z.string(),
|
||||
file: z.instanceof(File).optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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)." },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+1476
-1699
File diff suppressed because it is too large.
Load diff
+3
-1
@@ -49,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",
|
||||
@@ -59,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",
|
||||
|
||||
@@ -7,23 +7,10 @@ import { parse as parseDomain } from "tldts";
|
||||
type SSLCheckerType = typeof sslChecker;
|
||||
type WhoisModule = typeof whoiser;
|
||||
|
||||
const DEFAULT_HTTPS_PORT = 443;
|
||||
// Ports are user-defined, so a probe can dial something that accepts the
|
||||
// connection and never completes the handshake. Bound the wait explicitly.
|
||||
const CERTIFICATE_TIMEOUT_MS = 10_000;
|
||||
|
||||
export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise<SSLDetails> => {
|
||||
const monitorUrl = new URL(monitor.url);
|
||||
// A plain-HTTP monitor serves no certificate. Reject before dialing, so the
|
||||
// caller gets this instead of a raw OpenSSL handshake error from the socket.
|
||||
if (monitorUrl.protocol !== "https:") {
|
||||
throw new AppError({ message: "Certificate is only available for HTTPS monitors", status: 400 });
|
||||
}
|
||||
const hostname = monitorUrl.hostname;
|
||||
// URL.port is "" for the scheme's default port, so fall back to 443; without
|
||||
// this the certificate of a monitor on a non-standard port was probed on 443.
|
||||
const port = monitorUrl.port === "" ? DEFAULT_HTTPS_PORT : Number(monitorUrl.port);
|
||||
const cert = await checker(hostname, { port, timeout: CERTIFICATE_TIMEOUT_MS });
|
||||
const cert = await checker(hostname);
|
||||
if (cert?.validTo === null || cert?.validTo === undefined) {
|
||||
throw new Error("Certificate not found");
|
||||
}
|
||||
|
||||
@@ -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("; ");
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -22,4 +22,3 @@ export * from "./notificationValidation.js";
|
||||
export * from "./userValidation.js";
|
||||
export * from "./incidentValidation.js";
|
||||
export * from "./tagValidation.js";
|
||||
export * from "./proxyValidation.js";
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
MonitorStatuses,
|
||||
MonitorTypes,
|
||||
PageSpeedStrategies,
|
||||
ProxyModes,
|
||||
} from "@/domain/monitors/monitor.type.js";
|
||||
import { DateRanges, SortOrders } from "@/types/query.js";
|
||||
|
||||
@@ -101,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(),
|
||||
@@ -121,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: z.string().optional(),
|
||||
useAdvancedMatching: z.boolean().default(false),
|
||||
port: z.number().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
@@ -153,8 +140,7 @@ export const createMonitorBodyValidation = z
|
||||
.superRefine(refineDnsHostname)
|
||||
.superRefine(refineStrategyType)
|
||||
.superRefine(refineHeadMatching)
|
||||
.superRefine(refineRegexPattern)
|
||||
.superRefine(refineProxySelection);
|
||||
.superRefine(refineRegexPattern);
|
||||
|
||||
export const editMonitorBodyValidation = z
|
||||
.object({
|
||||
@@ -170,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: z.string().optional(),
|
||||
useAdvancedMatching: z.boolean().optional(),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
@@ -196,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"),
|
||||
@@ -232,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: z.string().optional(),
|
||||
useAdvancedMatching: z.boolean().default(false),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
@@ -272,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"),
|
||||
@@ -305,8 +285,6 @@ export const monitorResponseSchema = z
|
||||
statusWindowSize: z.number(),
|
||||
statusWindowThreshold: z.number(),
|
||||
ignoreTlsErrors: z.boolean(),
|
||||
proxyMode: z.enum(ProxyModes),
|
||||
proxyId: z.string().optional(),
|
||||
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
@@ -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'"],
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,11 +172,6 @@ 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>) => {
|
||||
const updatedMonitor = await MonitorModel.findOneAndUpdate(
|
||||
{ _id: monitorId, teamId },
|
||||
@@ -423,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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">;
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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:";
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -152,7 +152,3 @@ export type StatusChangeResult = {
|
||||
};
|
||||
|
||||
export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
|
||||
|
||||
export type CheckContext = {
|
||||
proxyUrl?: string;
|
||||
};
|
||||
Vendored
+8
@@ -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;
|
||||
}
|
||||
Vendored
-3
@@ -12,9 +12,6 @@ declare module "ssl-checker" {
|
||||
export interface SSLOptions {
|
||||
method?: "GET" | "HEAD";
|
||||
port?: number;
|
||||
// Inherited from https.RequestOptions; ssl-checker rejects with "Timed Out"
|
||||
// when the socket exceeds it.
|
||||
timeout?: number;
|
||||
agent?: Agent;
|
||||
rejectUnauthorized?: boolean;
|
||||
validateSubjectAltName?: boolean;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { fetchMonitorCertificate } from "../../../src/api/controllers/controllerUtils.ts";
|
||||
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
|
||||
import type { SSLDetails, SSLOptions } from "ssl-checker";
|
||||
|
||||
const VALID_CERT: SSLDetails = {
|
||||
daysRemaining: 30,
|
||||
valid: true,
|
||||
validFrom: "2026-01-01T00:00:00.000Z",
|
||||
validTo: "2026-12-31T00:00:00.000Z",
|
||||
validFor: ["example.com"],
|
||||
};
|
||||
|
||||
/** Records the arguments the checker was called with. */
|
||||
const spyChecker = (cert: SSLDetails = VALID_CERT) => {
|
||||
const calls: Array<{ hostname: string; options?: SSLOptions }> = [];
|
||||
const checker = (async (hostname: string, options?: SSLOptions) => {
|
||||
calls.push({ hostname, options });
|
||||
return cert;
|
||||
}) as unknown as typeof import("ssl-checker").default;
|
||||
return { checker, calls };
|
||||
};
|
||||
|
||||
const monitorWithUrl = (url: string) => ({ url }) as Monitor;
|
||||
|
||||
describe("fetchMonitorCertificate", () => {
|
||||
it("probes the port named in the monitor URL", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:54321"));
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].hostname).toBe("example.com");
|
||||
expect(calls[0].options?.port).toBe(54321);
|
||||
});
|
||||
|
||||
it("falls back to 443 when the URL names no port", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com"));
|
||||
|
||||
expect(calls[0].options?.port).toBe(443);
|
||||
});
|
||||
|
||||
it("passes an explicit 443 through unchanged", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:443"));
|
||||
|
||||
expect(calls[0].options?.port).toBe(443);
|
||||
});
|
||||
|
||||
it("bounds the probe with a timeout so a user-defined port cannot hang it", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:54321"));
|
||||
|
||||
expect(calls[0].options?.timeout).toBe(10_000);
|
||||
});
|
||||
|
||||
it("never passes NaN as the port", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com"));
|
||||
|
||||
expect(Number.isNaN(calls[0].options?.port)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a plain-HTTP monitor without opening a TLS connection", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
// Probing a non-TLS port would surface a raw OpenSSL handshake error.
|
||||
await expect(fetchMonitorCertificate(checker, monitorWithUrl("http://example.com:8080"))).rejects.toThrow(
|
||||
"Certificate is only available for HTTPS monitors"
|
||||
);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a plain-HTTP monitor on the default port", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await expect(fetchMonitorCertificate(checker, monitorWithUrl("http://example.com"))).rejects.toThrow(
|
||||
"Certificate is only available for HTTPS monitors"
|
||||
);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns the certificate details it received", async () => {
|
||||
const { checker } = spyChecker();
|
||||
|
||||
const cert = await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:8443"));
|
||||
|
||||
expect(cert.validTo).toBe(VALID_CERT.validTo);
|
||||
});
|
||||
|
||||
it("throws when the certificate has no expiry", async () => {
|
||||
const { checker } = spyChecker({ ...VALID_CERT, validTo: undefined as unknown as string });
|
||||
|
||||
await expect(fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:8443"))).rejects.toThrow("Certificate not found");
|
||||
});
|
||||
});
|
||||
@@ -470,72 +470,3 @@ describe("monitorValidation — customUpCodes", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("monitorValidation — proxy fields", () => {
|
||||
const baseHttpBody = {
|
||||
name: "HTTP check",
|
||||
type: "http" as const,
|
||||
url: "https://example.com",
|
||||
};
|
||||
|
||||
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-1" });
|
||||
|
||||
expect(parsed.proxyMode).toBe("custom");
|
||||
expect(parsed.proxyId).toBe("proxy-1");
|
||||
});
|
||||
|
||||
it("rejects custom mode without a proxyId", () => {
|
||||
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an unknown proxyMode", () => {
|
||||
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "global" })).toThrow();
|
||||
});
|
||||
|
||||
it("allows a stray proxyId on non-custom modes", () => {
|
||||
for (const proxyMode of ["inherit", "none"] as const) {
|
||||
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "proxy-1" });
|
||||
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-1" });
|
||||
|
||||
expect(parsed.proxyId).toBe("proxy-1");
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user