From 4bb366f5abbbcb1e9b553770e9d12de06b1508a6 Mon Sep 17 00:00:00 2001 From: fabiangigler Date: Wed, 26 Aug 2026 15:26:52 +0200 Subject: [PATCH] Add custom monitor interval options --- .../inputs/forms/FormAutoCompleteField.tsx | 9 ++- client/src/Pages/CreateMonitor/index.tsx | 63 +++++++++++++++---- client/src/Types/Monitor.ts | 2 + client/src/Validation/monitor.ts | 4 +- .../test/unit/services/jobScheduler.test.ts | 15 +++++ 5 files changed, 80 insertions(+), 13 deletions(-) diff --git a/client/src/Components/inputs/forms/FormAutoCompleteField.tsx b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx index 1dbc65118..6c1a4fd94 100644 --- a/client/src/Components/inputs/forms/FormAutoCompleteField.tsx +++ b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx @@ -5,9 +5,10 @@ import { type FieldValues, } from "react-hook-form"; import { Autocomplete } from "@/Components/inputs"; +import type { AutocompleteProps } from "@mui/material/Autocomplete"; export interface AutocompleteOption { - id: string; + id: string | number; name: string; } @@ -16,6 +17,8 @@ interface FormAutocompleteProps["filterOptions"]; + getOptionDisabled?: AutocompleteProps["getOptionDisabled"]; } export const FormAutocompleteField = < @@ -26,6 +29,8 @@ export const FormAutocompleteField = < options, fieldLabel, placeholder, + filterOptions, + getOptionDisabled, }: FormAutocompleteProps) => { const { control } = useFormContext(); return ( @@ -43,6 +48,8 @@ export const FormAutocompleteField = < }} fieldLabel={fieldLabel} placeholder={placeholder} + filterOptions={filterOptions} + getOptionDisabled={getOptionDisabled} error={!!fieldState.error} helperText={fieldState.error?.message ?? ""} /> diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 3291ab090..380329c4b 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -38,6 +38,7 @@ import { PageSpeedStrategies, DnsRecordTypes, MonitorIntervalOptions, + MIN_MONITOR_INTERVAL_MS, DefaultMonitorMatchMethod, MonitorMatchMethods, GeoCheckIntervalOptions, @@ -58,9 +59,14 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField"; import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField"; import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import { + FormAutocompleteField, + type AutocompleteOption, +} from "@/Components/inputs/forms/FormAutoCompleteField"; import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; import type { ProxyResponse } from "@/Types/Proxy"; +import { formatDuration } from "@/Utils/TimeUtils"; const httpMethodOptions = HttpMethods.map((method) => ({ value: method, @@ -75,6 +81,36 @@ const maskedInputSx = { "&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" }, }; +interface IntervalOption extends AutocompleteOption { + id: number; +} + +const INTERVAL_UNIT_MULTIPLIERS = [1000, 60000, 3600000, 86400000] as const; + +const toIntervalOption = (intervalMs: number): IntervalOption => ({ + id: intervalMs, + name: formatDuration(intervalMs, true), +}); + +const filterIntervalOptions = ( + options: IntervalOption[], + inputValue: string +): IntervalOption[] => { + const input = inputValue.trim(); + if (input === "" || options.some((option) => option.name === input)) { + return options; + } + + const amount = Number(input); + if (!Number.isFinite(amount) || amount <= 0) { + return []; + } + + return INTERVAL_UNIT_MULTIPLIERS.map((multiplier) => amount * multiplier) + .filter(Number.isSafeInteger) + .map(toIntervalOption); +}; + interface GeneralSettingsConfig { urlLabel: string; urlPlaceholder: string; @@ -314,6 +350,7 @@ const CreateMonitorPage = () => { const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean; + const watchedInterval = watch("interval"); // Steps without an advanced section drop it, so the last step is the form's. const totalSteps = monitorStepCount(watchedType); @@ -471,16 +508,16 @@ const CreateMonitorPage = () => { [t] ); - const intervalOptions = useMemo( - () => - MonitorIntervalOptions.map((option) => ({ - value: option.value, - label: t( - `pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}` - ), - })), - [t] - ); + const intervalOptions = useMemo(() => { + const options = MonitorIntervalOptions.map(({ value }) => toIntervalOption(value)); + if ( + typeof watchedInterval === "number" && + !options.some((option) => option.id === watchedInterval) + ) { + options.push(toIntervalOption(watchedInterval)); + } + return options; + }, [watchedInterval]); const matchMethodOptions = useMemo( () => @@ -764,12 +801,16 @@ const CreateMonitorPage = () => { title={t("pages.createMonitor.form.frequency.title")} subtitle={t("pages.createMonitor.form.frequency.description")} rightContent={ - + filterIntervalOptions(options, inputValue) + } + getOptionDisabled={(option) => option.id < MIN_MONITOR_INTERVAL_MS} /> } /> diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 858c827d5..892e2b549 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -60,6 +60,8 @@ export const monitorTypeLabelKey: Record & pagespeed: "optionPagespeed", }; +export const MIN_MONITOR_INTERVAL_MS = 10000; + export const MonitorIntervalOptions = [ { value: 15000, labelKey: "fifteenSeconds" }, { value: 30000, labelKey: "thirtySeconds" }, diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 8a2b6e726..1637d5297 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -6,6 +6,7 @@ import { HttpMethods, PageSpeedStrategies, ProxyModes, + MIN_MONITOR_INTERVAL_MS, type MonitorType, } from "@/Types/Monitor"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; @@ -27,7 +28,8 @@ const baseSchema = z.object({ description: z.string().optional(), interval: z .number() - .min(15000, "Interval must be at least 15 seconds") + .int("Interval must be a whole number of milliseconds") + .min(MIN_MONITOR_INTERVAL_MS, "Interval must be at least 10 seconds") .register(monitorStepRegistry, { step: 1 }), notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }), tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }), diff --git a/server/test/unit/services/jobScheduler.test.ts b/server/test/unit/services/jobScheduler.test.ts index 90c8c2850..c4a162dd6 100644 --- a/server/test/unit/services/jobScheduler.test.ts +++ b/server/test/unit/services/jobScheduler.test.ts @@ -143,6 +143,21 @@ describe("JobScheduler", () => { }); }); + describe("addJob", () => { + it("preserves an arbitrary monitor interval in the scheduled job", async () => { + const { scheduler, mocks } = createScheduler(); + + await scheduler.addJob("m1", makeMonitor({ interval: 75000 })); + + expect(mocks.jobsRepository.upsertJob).toHaveBeenCalledWith( + expect.objectContaining({ + id: "check:m1", + intervalMs: 75000, + }) + ); + }); + }); + // Regression: a heartbeat upsert racing shutdown's deleteById could resurrect the // registry row, leaving a ghost worker that inflates the alive count until TTL. // shutdown() must await the in-flight beat and issue the delete as the LAST write.