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/FormRadioGroupField.tsx b/client/src/Components/inputs/forms/FormRadioGroupField.tsx index a751ff0ed..ed97ae2ee 100644 --- a/client/src/Components/inputs/forms/FormRadioGroupField.tsx +++ b/client/src/Components/inputs/forms/FormRadioGroupField.tsx @@ -8,6 +8,7 @@ import { } from "react-hook-form"; import { LAYOUT } from "@/Utils/Theme/constants"; import { RadioWithDescription } from "@/Components/inputs/Radio"; +import { FieldLabel } from "@/Components/inputs/FieldLabel"; export interface FormRadioOption { value: string; label: string; @@ -17,10 +18,12 @@ export interface FormRadioOption { interface FormRadioGroupProps { name: FieldPath; options: FormRadioOption[]; + fieldLabel?: string; } export const FormRadioGroup = ({ name, options, + fieldLabel, }: FormRadioGroupProps) => { const { control } = useFormContext(); const theme = useTheme(); @@ -30,6 +33,7 @@ export const FormRadioGroup = ({ control={control} render={({ field, fieldState }) => ( + {fieldLabel && {fieldLabel}} ({ }} 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/Hooks/useProxiesForm.ts b/client/src/Hooks/useProxiesForm.ts new file mode 100644 index 000000000..1b8de2045 --- /dev/null +++ b/client/src/Hooks/useProxiesForm.ts @@ -0,0 +1,22 @@ +import { useMemo } from "react"; +import { proxySchema, type ProxyFormData } from "@/Validation/proxy"; +import type { ProxyResponse } from "@/Types/Proxy"; + +interface UseProxiesFormOptions { + data?: ProxyResponse | null; +} + +export const useProxiesForm = ({ data }: UseProxiesFormOptions) => { + return useMemo(() => { + const defaults: ProxyFormData = { + name: data?.name ?? "", + protocol: data?.protocol ?? "http", + host: data?.host ?? "", + port: data?.port ?? 8080, + username: "", + password: "", + }; + + return { schema: proxySchema, defaults }; + }, [data]); +}; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index dd9cdc48f..6d19ae25b 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -41,9 +41,12 @@ import { DefaultMonitorMatchMethod, MonitorMatchMethods, GeoCheckIntervalOptions, + ProxyModes, + type ProxyMode, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; +import type { AppSettingsResponse } from "@/Types/Settings"; import { stepFieldsFor, monitorStepCount, @@ -56,6 +59,7 @@ import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectF import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; +import type { ProxyResponse } from "@/Types/Proxy"; const httpMethodOptions = HttpMethods.map((method) => ({ value: method, @@ -67,6 +71,7 @@ interface GeneralSettingsConfig { urlPlaceholder: string; namePlaceholder: string; showUrl: boolean; + showProxy: boolean; showPort: boolean; showGameSelect: boolean; showSecret: boolean; @@ -87,6 +92,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: true, showPort: false, showGameSelect: false, showSecret: false, @@ -101,6 +107,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -115,6 +122,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -129,6 +137,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: true, showGameSelect: false, showSecret: false, @@ -143,6 +152,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: true, showGameSelect: true, showSecret: false, @@ -157,6 +167,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: true, showGameSelect: false, showSecret: false, @@ -171,6 +182,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -185,6 +197,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: true, @@ -199,6 +212,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -213,6 +227,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -258,6 +273,8 @@ const CreateMonitorPage = () => { const { data: notifications } = useGet("/notifications/team"); const { data: games } = useGet("/monitors/games"); const { data: tags } = useGet("/tags/team"); + const { data: proxies } = useGet("/proxies/team"); + const { data: appSettings } = useGet("/settings"); const { schema, defaults } = useMonitorForm({ data: existingMonitor ?? null, @@ -281,6 +298,7 @@ const CreateMonitorPage = () => { const watchedType = watch("type") as MonitorType; const watchedMethod = watch("method") as HttpMethod | undefined; + const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean; @@ -466,6 +484,41 @@ const CreateMonitorPage = () => { })), [t] ); + const globalProxyName = + appSettings?.settings?.globalProxyEnabled === true + ? proxies?.find((proxy) => proxy.id === appSettings?.settings?.globalProxyId)?.name + : undefined; + + 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 ( @@ -518,6 +571,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 === "custom" && ( + + )} + + )} {/* Strategy field - only for pagespeed type */} {generalSettingsConfig.showStrategy && ( @@ -603,11 +682,6 @@ const CreateMonitorPage = () => { options={dnsRecordTypeOptions} /> )} - } /> diff --git a/client/src/Pages/Proxies/components/ProxiesTable.tsx b/client/src/Pages/Proxies/components/ProxiesTable.tsx new file mode 100644 index 000000000..f2ae148e3 --- /dev/null +++ b/client/src/Pages/Proxies/components/ProxiesTable.tsx @@ -0,0 +1,103 @@ +import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; +import Typography from "@mui/material/Typography"; +import type { Header } from "@/Components/design-elements/Table"; +import { Table } from "@/Components/design-elements"; +import { Pagination } from "@/Components/design-elements/Table"; +import { useClientPagination } from "@/Hooks/useClientPagination"; + +import type { ProxyResponse } from "@/Types/Proxy"; +import { useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; + +interface ProxiesTableProps { + proxies: ProxyResponse[]; + setSelectedProxy: Function; +} + +export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const theme = useTheme(); + const { pagedRows, paginationProps } = useClientPagination(proxies); + + const getActions = (proxy: ProxyResponse): ActionMenuItem[] => { + return [ + { + id: 1, + label: t("pages.common.monitors.actions.configure"), + action: () => { + navigate(`/proxies/configure/${proxy.id}`); + }, + closeMenu: true, + }, + + { + id: 2, + label: ( + + {t("pages.common.monitors.actions.delete")} + + ), + action: async () => { + setSelectedProxy(proxy); + }, + closeMenu: true, + }, + ]; + }; + + const getHeaders = () => { + const headers: Header[] = [ + { + id: "name", + content: t("common.table.headers.name"), + render: (row) => { + return {row?.name}; + }, + }, + { + id: "protocol", + content: t("pages.proxies.table.headers.protocol"), + render: (row) => { + return {row?.protocol}; + }, + }, + { + id: "host", + content: t("pages.proxies.table.headers.host"), + render: (row) => { + return {row?.host}; + }, + }, + { + id: "port", + content: t("pages.proxies.table.headers.port"), + render: (row) => { + return {row?.port}; + }, + }, + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, + ]; + return headers; + }; + + return ( + <> + { + navigate(`/proxies/configure/${row.id}`); + }} + /> + {proxies.length > 0 && } + + ); +}; diff --git a/client/src/Pages/Proxies/create/index.tsx b/client/src/Pages/Proxies/create/index.tsx new file mode 100644 index 000000000..2783c93a9 --- /dev/null +++ b/client/src/Pages/Proxies/create/index.tsx @@ -0,0 +1,213 @@ +import { BasePage, ConfigBox } from "@/Components/design-elements"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { Button, FieldLabel } from "@/Components/inputs"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; + +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; +import { useForm, FormProvider } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { mutate } from "swr"; +import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; +import type { ProxyFormData } from "@/Validation/proxy"; +import { ProxyProtocols, type ProxyResponse } from "@/Types/Proxy"; +import { useTranslation } from "react-i18next"; +import { useProxiesForm } from "@/Hooks/useProxiesForm"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import { useMemo } from "react"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; +const ProxiesCreatePage = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const navigate = useNavigate(); + const { proxyId } = useParams<{ proxyId: string }>(); + const isEditMode = Boolean(proxyId); + + const { data: existingProxy } = useGet( + isEditMode ? `/proxies/${proxyId}` : null + ); + + const { post, loading: isSubmitting } = usePost(); + const { patch, loading: isPatching } = usePatch(); + + const { schema, defaults } = useProxiesForm({ data: existingProxy }); + + const form = useForm({ + 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 ( + + + + } + /> + + + + + + } + /> + + + {(isUsernameSet === false || usernameHasBeenReset === true) && ( + + )} + + {isUsernameSet === true && usernameHasBeenReset === false && ( + + + {t("pages.proxies.form.auth.option.username.labelSet")} + + + + )} + + {(isPasswordSet === false || passwordHasBeenReset === true) && ( + + )} + + {isPasswordSet === true && passwordHasBeenReset === false && ( + + + {t("pages.proxies.form.auth.option.password.labelSet")} + + + + )} + + } + /> + + + + + + + ); +}; + +export default ProxiesCreatePage; diff --git a/client/src/Pages/Proxies/index.tsx b/client/src/Pages/Proxies/index.tsx new file mode 100644 index 000000000..5370543dc --- /dev/null +++ b/client/src/Pages/Proxies/index.tsx @@ -0,0 +1,72 @@ +import { BasePageWithStates } from "@/Components/design-elements"; +import { ProxiesTable } from "@/Pages/Proxies/components/ProxiesTable"; +import { Dialog } from "@/Components/inputs"; +import { HeaderCreate } from "@/Components/common"; + +import { useState } from "react"; +import { useGet, useDelete } from "@/Hooks/UseApi"; +import { useTranslation } from "react-i18next"; +import type { ProxyResponse } from "@/Types/Proxy"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; + +const ProxiesPage = () => { + const { t } = useTranslation(); + const isAdmin = useIsAdmin(); + + const [selectedProxy, setSelectedProxy] = useState(null); + const isDialogOpen = Boolean(selectedProxy); + + const { + data: proxies, + isLoading, + isValidating, + error, + refetch, + } = useGet("/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 ( + + + + + + ); +}; + +export default ProxiesPage; 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/Types/Proxy.ts b/client/src/Types/Proxy.ts new file mode 100644 index 000000000..dd098dee3 --- /dev/null +++ b/client/src/Types/Proxy.ts @@ -0,0 +1,17 @@ +export const ProxyProtocols = ["http", "https"] as const; +export type ProxyProtocol = (typeof ProxyProtocols)[number]; + +export interface Proxy { + id: string; + teamId: string; + name: string; + protocol: ProxyProtocol; + host: string; + port: number; + username?: string; + password?: string; + createdAt: string; + updatedAt: string; +} + +export type ProxyResponse = Omit & { hasPassword: boolean }; diff --git a/client/src/Types/Settings.ts b/client/src/Types/Settings.ts index 1338d49cb..22cbb6a8d 100644 --- a/client/src/Types/Settings.ts +++ b/client/src/Types/Settings.ts @@ -24,6 +24,8 @@ export interface Settings { showURL: boolean; singleton: boolean; globalThresholds?: SettingsThresholds; + globalProxyEnabled: boolean; + globalProxyId?: string | null; createdAt: string; updatedAt: string; } diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index a31cdb919..5b53ff3a5 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -5,6 +5,7 @@ import { DnsRecordTypes, HttpMethods, PageSpeedStrategies, + ProxyModes, type MonitorType, } from "@/Types/Monitor"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; @@ -67,6 +68,8 @@ 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 }), @@ -195,7 +198,7 @@ const dnsSchema = baseSchema.extend({ }); // Discriminated union of all monitor types -export const monitorSchema = z.discriminatedUnion("type", [ +const monitorSchemaUnion = z.discriminatedUnion("type", [ httpSchema, pingSchema, portSchema, @@ -208,10 +211,21 @@ export const monitorSchema = z.discriminatedUnion("type", [ dnsSchema, ]); +// Mirrors the server's refineProxySelection (monitorValidation.ts) +export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => { + if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) { + ctx.addIssue({ + code: "custom", + path: ["proxyId"], + message: "A proxy must be selected when proxy mode is custom", + }); + } +}); + export type MonitorFormData = z.infer; const optionForType = (type: MonitorType) => - monitorSchema.options.find((o) => o.shape.type.value === type); + monitorSchemaUnion.options.find((o) => o.shape.type.value === type); // The step each field of a type is validated on, from the per-field // `monitorStepRegistry` metadata (unannotated fields default to step 0). diff --git a/client/src/Validation/proxy.ts b/client/src/Validation/proxy.ts new file mode 100644 index 000000000..22582e92a --- /dev/null +++ b/client/src/Validation/proxy.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { ProxyProtocols } from "@/Types/Proxy"; + +export const proxySchema = z.object({ + name: z.string().min(1, "Proxy name is required"), + protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"), + host: z.string().min(1, "Proxy host is required"), + port: z + .number() + .int() + .min(1, "Proxy port is required") + .max(65535, "Proxy port must be between 1 and 65535"), + username: z.string().optional(), + password: z.string().optional(), +}); + +export type ProxyFormData = z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index fe8f0319e..b233deba9 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,24 @@ "label": "URL", "placeholder": "https://www.google.com" }, + "proxyMode": { + "label": "Proxy", + "value": { + "inheritDirect": "Default (direct connection)", + "inheritGlobal": "Default (global proxy: {{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": "Follows the global proxy setting — changing it in Settings also changes this monitor's connection.", + "none": "Always connects directly even if a global proxy is enabled later.", + "custom": "Route this monitor's checks through a specific proxy. Overrides global proxy" + } + }, + "proxy": { + "label": "Proxy server" + }, "game": { "label": "Choose game", "placeholder": "Select a game" @@ -624,7 +700,7 @@ }, "title": "General settings", "description": { - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.", "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",