From ccfda938d56bea87af260683f498afda65667eff Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 21 Aug 2026 15:13:34 -0700 Subject: [PATCH] Add proxies to create/edit monitors page --- client/src/Components/inputs/Select.tsx | 19 +++- .../inputs/forms/FormSelectField.tsx | 1 + client/src/Components/sidebar/Menu.tsx | 7 +- client/src/Hooks/useMonitorForm.ts | 5 + client/src/Pages/CreateMonitor/index.tsx | 93 ++++++++++++++++++- client/src/Pages/Proxies/index.tsx | 2 +- client/src/Routes/index.tsx | 21 ++++- client/src/Types/Monitor.ts | 6 ++ client/src/locales/en.json | 75 ++++++++++++++- 9 files changed, 213 insertions(+), 16 deletions(-) diff --git a/client/src/Components/inputs/Select.tsx b/client/src/Components/inputs/Select.tsx index 92a57860a..0077d2be6 100644 --- a/client/src/Components/inputs/Select.tsx +++ b/client/src/Components/inputs/Select.tsx @@ -11,10 +11,11 @@ interface SelectInputProps extends Omit, "label"> { fieldLabel?: string; required?: boolean; placeholder?: string; + helperText?: string; } const SelectInputInner = ( - { fieldLabel, required, placeholder, ...props }: SelectInputProps, + { fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps, ref: React.ForwardedRef ) => { const theme = useTheme(); @@ -27,7 +28,7 @@ const SelectInputInner = ( : selected === undefined || selected === null || selected === ""; if (isEmpty && placeholder) { - return {placeholder}; + return {placeholder}; } if (isMultiple) { @@ -92,11 +93,21 @@ const SelectInputInner = ( /> ); - if (fieldLabel) { + const helper = helperText ? ( + + {helperText} + + ) : null; + + if (fieldLabel || helper) { return ( - {fieldLabel} + {fieldLabel && {fieldLabel}} {select} + {helper} ); } diff --git a/client/src/Components/inputs/forms/FormSelectField.tsx b/client/src/Components/inputs/forms/FormSelectField.tsx index 78e42300a..faead399f 100644 --- a/client/src/Components/inputs/forms/FormSelectField.tsx +++ b/client/src/Components/inputs/forms/FormSelectField.tsx @@ -44,6 +44,7 @@ export const FormSelectField = ({ }} error={!!fieldState.error} {...rest} + helperText={fieldState.error?.message ?? rest.helperText} > {options.map((option) => ( { return [ { @@ -47,6 +47,11 @@ export const getMenu = (t: Function) => { path: "tags", icon: , }, + { + name: t("components.sidebar.menu.proxies"), + path: "proxies", + icon: , + }, { name: t("components.sidebar.menu.checks"), path: "checks", diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 16314e8ca..5458a75d8 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -4,6 +4,7 @@ import { DefaultHttpMethod, DefaultMonitorMatchMethod, DefaultPageSpeedStrategy, + DefaultProxyMode, } from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor"; @@ -46,6 +47,8 @@ export const getMonitorDefaults = ( url: data?.url || "", method: data?.method ?? DefaultHttpMethod, ignoreTlsErrors: data?.ignoreTlsErrors || false, + proxyMode: data?.proxyMode ?? DefaultProxyMode, + proxyId: data?.proxyId || "", useAdvancedMatching: data?.useAdvancedMatching || false, matchMethod: data?.matchMethod || DefaultMonitorMatchMethod, expectedValue: data?.expectedValue || "", @@ -141,6 +144,8 @@ export const getMonitorDefaults = ( url: "", method: DefaultHttpMethod, ignoreTlsErrors: false, + proxyMode: DefaultProxyMode, + proxyId: "", useAdvancedMatching: false, matchMethod: DefaultMonitorMatchMethod, expectedValue: "", diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index dd9cdc48f..2ff2a985c 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -41,6 +41,8 @@ import { DefaultMonitorMatchMethod, MonitorMatchMethods, GeoCheckIntervalOptions, + ProxyModes, + type ProxyMode, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; @@ -56,6 +58,7 @@ import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectF import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; +import type { ProxyResponse } from "@/Types/Proxy"; const httpMethodOptions = HttpMethods.map((method) => ({ value: method, @@ -67,6 +70,7 @@ interface GeneralSettingsConfig { urlPlaceholder: string; namePlaceholder: string; showUrl: boolean; + showProxy: boolean; showPort: boolean; showGameSelect: boolean; showSecret: boolean; @@ -87,6 +91,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: true, showPort: false, showGameSelect: false, showSecret: false, @@ -101,6 +106,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -115,6 +121,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -129,6 +136,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: true, showGameSelect: false, showSecret: false, @@ -143,6 +151,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: true, showGameSelect: true, showSecret: false, @@ -157,6 +166,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: true, showGameSelect: false, showSecret: false, @@ -171,6 +181,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -185,6 +196,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: true, @@ -199,6 +211,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -213,6 +226,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -258,6 +272,11 @@ const CreateMonitorPage = () => { const { data: notifications } = useGet("/notifications/team"); const { data: games } = useGet("/monitors/games"); const { data: tags } = useGet("/tags/team"); + const { data: proxies } = useGet("/proxies/team"); + const { data: appSettings } = useGet<{ + globalProxyEnabled?: boolean; + globalProxyId?: string | null; + }>("/settings"); const { schema, defaults } = useMonitorForm({ data: existingMonitor ?? null, @@ -281,6 +300,7 @@ const CreateMonitorPage = () => { const watchedType = watch("type") as MonitorType; const watchedMethod = watch("method") as HttpMethod | undefined; + const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean; @@ -466,6 +486,28 @@ const CreateMonitorPage = () => { })), [t] ); + const proxyModeOptions = useMemo( + () => + ProxyModes.map((mode) => ({ + value: mode, + label: t(`pages.createMonitor.form.general.option.proxyMode.value.${mode}`), + })), + [t] + ); + + const proxyOptions = useMemo( + () => + (proxies ?? []).map((proxy) => ({ + value: proxy.id, + label: `${proxy.name} (${proxy.host}:${proxy.port})`, + })), + [proxies] + ); + + const globalProxyName = + appSettings?.globalProxyEnabled === true + ? proxies?.find((proxy) => proxy.id === appSettings?.globalProxyId)?.name + : undefined; return ( @@ -518,6 +560,11 @@ const CreateMonitorPage = () => { } rightContent={ + {/* URL/Host/Container field - not shown for hardware */} {generalSettingsConfig.showUrl && ( { disabled={isEditMode} /> )} + {/* Proxy fields - only shown for HTTP */} + {generalSettingsConfig.showProxy && ( + <> + + {watchedProxyMode === "inherit" && ( + + {globalProxyName + ? t( + "pages.createMonitor.form.general.option.proxyMode.effect.global", + { name: globalProxyName } + ) + : t( + "pages.createMonitor.form.general.option.proxyMode.effect.direct" + )} + + )} + {watchedProxyMode === "custom" && ( + + )} + + )} {/* Strategy field - only for pagespeed type */} {generalSettingsConfig.showStrategy && ( @@ -603,11 +691,6 @@ const CreateMonitorPage = () => { options={dnsRecordTypeOptions} /> )} - } /> diff --git a/client/src/Pages/Proxies/index.tsx b/client/src/Pages/Proxies/index.tsx index 5b40242b8..5370543dc 100644 --- a/client/src/Pages/Proxies/index.tsx +++ b/client/src/Pages/Proxies/index.tsx @@ -32,7 +32,7 @@ const ProxiesPage = () => { setSelectedProxy(null); refetch(); }; - ``; + const handleCancel = () => { setSelectedProxy(null); }; diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index e73b38255..968b468c2 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -39,11 +39,14 @@ import CreateNotifications from "@/Pages/Notifications/create"; import Tags from "@/Pages/Tags"; import CreateTags from "@/Pages/Tags/create"; +// Proxies +import Proxies from "@/Pages/Proxies"; +import CreateProxies from "@/Pages/Proxies/create"; + // Settings import Account from "@/Pages/Account"; import EditUser from "@/Pages/Account/EditUser"; import Settings from "@/Pages/Settings"; - import Maintenance from "@/Pages/Maintenance"; import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create"; @@ -52,10 +55,8 @@ import Logs from "@/Pages/Logs"; // Routing import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected"; - import CreateMonitor from "@/Pages/CreateMonitor"; import { isCustomDomainHost } from "@/Utils/statusPageUrl"; - const Routes = () => { if (isCustomDomainHost()) { return ( @@ -188,12 +189,24 @@ const Routes = () => { path="tags/create" element={} /> - } /> + } + /> + } + /> + } + /> + } diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 6eaa280d7..a5ed14c01 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -109,6 +109,10 @@ export const HttpMethods = ["GET", "HEAD"] as const; export type HttpMethod = (typeof HttpMethods)[number]; export const DefaultHttpMethod: HttpMethod = "GET"; +export const ProxyModes = ["inherit", "none", "custom"] as const; +export type ProxyMode = (typeof ProxyModes)[number]; +export const DefaultProxyMode: ProxyMode = "inherit"; + export interface Monitor { id: string; userId: string; @@ -121,6 +125,8 @@ export interface Monitor { statusWindowThreshold: number; type: MonitorType; ignoreTlsErrors: boolean; + proxyMode: ProxyMode; + proxyId?: string; useAdvancedMatching: boolean; jsonPath?: string; expectedValue?: string; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index fe8f0319e..b78e625ee 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -205,6 +205,7 @@ "infrastructure": "Infrastructure", "notifications": "Notifications", "tags": "Tags", + "proxies": "Proxies", "checks": "Checks", "incidents": "Incidents", "statusPages": "Status pages", @@ -244,6 +245,63 @@ } }, "pages": { + "proxies": { + "header": { + "title": "Proxies", + "description": "Manage your proxy servers for routing HTTP monitoring traffic." + }, + "fallback": { + "title": "No proxies yet", + "description": "Route HTTP monitor checks through a proxy server. Once you create a proxy, you can select it on individual monitors or set it as the global default in Settings.", + "actionButton": "Create a proxy" + }, + "form": { + "name": { + "title": "Proxy name", + "description": "A friendly name to identify this proxy", + "optionName": "Proxy name", + "placeholder": "My proxy" + }, + "server": { + "title": "Proxy server", + "description": "Where the proxy server is reachable. Set the protocol Checkmate uses to connect to it, its hostname or IP address, and the port it listens on.", + "option": { + "protocol": { + "label": "Protocol" + }, + "host": { + "label": "Hostname / IP Address", + "placeholder": "proxy.example.com" + }, + "port": { + "label": "Port" + } + } + }, + "auth": { + "title": "Authentication", + "description": "Optional. Only needed if the proxy server requires basic authentication.", + "option": { + "username": { + "label": "Username", + "placeholder": "Proxy username", + "labelSet": "Username is set. Click Reset to change it." + }, + "password": { + "label": "Password", + "labelSet": "Password is set. Click Reset to change it." + } + } + } + }, + "table": { + "headers": { + "protocol": "Protocol", + "host": "Host", + "port": "Port" + } + } + }, "notFound": { "title": "Oh no! You dropped your sushi!", "subtitle": "Either the URL doesn’t exist, or you don’t have access to it." @@ -581,6 +639,21 @@ "label": "URL", "placeholder": "https://www.google.com" }, + "proxyMode": { + "label": "Proxy mode", + "value": { + "inherit": "Use global proxy setting", + "none": "No proxy (direct connection)", + "custom": "Custom" + }, + "effect": { + "global": "Checks currently go through the global proxy: {{name}}", + "direct": "Global proxy not enabled, will use direct connection" + } + }, + "proxy": { + "label": "Proxy server" + }, "game": { "label": "Choose game", "placeholder": "Select a game" @@ -624,7 +697,7 @@ }, "title": "General settings", "description": { - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.", "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",