diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts index 643e9caca..95947928c 100644 --- a/client/src/Hooks/useSettingsForm.ts +++ b/client/src/Hooks/useSettingsForm.ts @@ -43,6 +43,8 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => checkTTL: data?.checkTTL ?? 30, pagespeedApiKey: "", systemEmailPassword: "", + globalProxyEnabled: data?.globalProxyEnabled ?? false, + globalProxyId: data?.globalProxyId ?? null, }; return { schema: settingsSchema, defaults }; diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index cc1472de2..3377182af 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -10,7 +10,7 @@ import { Alert, type SelectChangeEvent, } from "@mui/material"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { FormProvider, useForm, type FieldPath } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslation } from "react-i18next"; @@ -43,6 +43,8 @@ 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"; interface SettingsResponse { settings: any; @@ -65,6 +67,17 @@ export const SettingsPage = () => { // Fetch settings data from API const { data: fetchedSettings } = useGet("/settings"); + // Get proxies + const { data: proxies } = useGet("/proxies/team"); + const proxyOptions = useMemo( + () => + (proxies ?? []).map((proxy) => ({ + value: proxy.id, + label: `${proxy.name} (${proxy.host}:${proxy.port})`, + })), + [proxies] + ); + // Form submission const { patch, loading: isSaving } = usePatch(); @@ -115,6 +128,8 @@ export const SettingsPage = () => { mode: "onChange", }); + const { watch } = form; + // Reset form when defaults change useEffect(() => { form.reset(defaults); @@ -292,6 +307,21 @@ export const SettingsPage = () => { const languages = Object.keys(i18n.options.resources || {}); + const globalProxyEnabled = watch("globalProxyEnabled"); + + // Enabling the proxy with nothing selected preselects the first proxy + useEffect(() => { + if ( + globalProxyEnabled && + !form.getValues("globalProxyId") && + proxyOptions.length > 0 + ) { + form.setValue("globalProxyId", proxyOptions[0].value, { + shouldValidate: true, + }); + } + }, [globalProxyEnabled, proxyOptions, form]); + return ( { } /> + {isAdmin && ( + 0 ? ( + + + {globalProxyEnabled && ( + + )} + + ) : ( + + ) + } + /> + )} {isAdmin && ( (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailSecure: z.boolean().optional(), - systemEmailPool: z.boolean().optional(), - showURL: z.boolean().optional(), - checkTTL: z - .number() - .int() - .min(1, "Please enter a value") - .max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`), - pagespeedApiKey: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailHost: z - .string() - .regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address") - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailPort: z.number().int().min(1).max(65535).optional(), - systemEmailAddress: z - .email("Please enter a valid email address") - .or(z.literal("")) - .transform((val) => (val === "" ? null : val.toLowerCase().trim())) - .optional(), - systemEmailDisplayName: z - .string() - .max(100, "Display name must be 100 characters or fewer") - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailUser: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailPassword: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailTLSServername: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - globalThresholds: z.object({ - cpu: z.number().int().min(1).max(100), - memory: z.number().int().min(1).max(100), - disk: z.number().int().min(1).max(100), - temperature: z.number().int().min(1).max(150), - }), -}); +export const settingsSchema = z + .object({ + systemEmailIgnoreTLS: z.boolean(), + systemEmailRequireTLS: z.boolean(), + systemEmailRejectUnauthorized: z.boolean(), + systemEmailConnectionHost: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailSecure: z.boolean().optional(), + systemEmailPool: z.boolean().optional(), + showURL: z.boolean().optional(), + checkTTL: z + .number() + .int() + .min(1, "Please enter a value") + .max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`), + pagespeedApiKey: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailHost: z + .string() + .regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address") + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailPort: z.number().int().min(1).max(65535).optional(), + systemEmailAddress: z + .email("Please enter a valid email address") + .or(z.literal("")) + .transform((val) => (val === "" ? null : val.toLowerCase().trim())) + .optional(), + systemEmailDisplayName: z + .string() + .max(100, "Display name must be 100 characters or fewer") + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailUser: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailPassword: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailTLSServername: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + globalThresholds: z.object({ + cpu: z.number().int().min(1).max(100), + memory: z.number().int().min(1).max(100), + disk: z.number().int().min(1).max(100), + temperature: z.number().int().min(1).max(150), + }), + globalProxyEnabled: z.boolean(), + globalProxyId: z.string().nullable().optional(), + }) + .superRefine((body, ctx) => { + if (body.globalProxyEnabled === true && !body.globalProxyId) { + ctx.addIssue({ + code: "custom", + path: ["globalProxyId"], + message: "A proxy must be selected to enable the global proxy", + }); + } + }); export type SettingsFormInput = z.input; export type SettingsFormData = z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b233deba9..e82f84949 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -643,13 +643,13 @@ "label": "Proxy", "value": { "inheritDirect": "Default (direct connection)", - "inheritGlobal": "Default (global proxy: {{name}})", + "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": "Follows the global proxy setting — changing it in Settings also changes this monitor's connection.", + "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" } @@ -1362,6 +1362,22 @@ } } }, + "globalProxy": { + "title": "Global proxy", + "description": "Route monitoring checks through a proxy by default. Applies to all HTTP monitors whose proxy setting is \"Default\"; monitors set to \"Never use a proxy\" or a custom proxy are unaffected.", + "option": { + "enabled": { + "label": "Use global proxy" + }, + "proxy": { + "label": "Choose a global proxy" + }, + "empty": { + "text": "No proxies configured yet.", + "link": "Create one first" + } + } + }, "pagespeed": { "title": "Google PageSpeed API key", "description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.",