Add custom monitor interval options

This commit is contained in:
fabiangigler committed 2026-09-13 20:28:27 +02:00
1 parent f41e0f0619
commit 4bb366f5ab
5 files changed
+80 -13

No files matched your search

@@ -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<T extends FieldValues, O extends AutocompleteOpt
options: O[];
fieldLabel?: string;
placeholder?: string;
filterOptions?: AutocompleteProps<O, false, false, false>["filterOptions"];
getOptionDisabled?: AutocompleteProps<O, false, false, false>["getOptionDisabled"];
}
export const FormAutocompleteField = <
@@ -26,6 +29,8 @@ export const FormAutocompleteField = <
options,
fieldLabel,
placeholder,
filterOptions,
getOptionDisabled,
}: FormAutocompleteProps<T, O>) => {
const { control } = useFormContext<T>();
return (
@@ -43,6 +48,8 @@ export const FormAutocompleteField = <
}}
fieldLabel={fieldLabel}
placeholder={placeholder}
filterOptions={filterOptions}
getOptionDisabled={getOptionDisabled}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
+52 -11
View File
@@ -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={
<FormSelectField
<FormAutocompleteField
name="interval"
fieldLabel={t(
"pages.createMonitor.form.frequency.option.frequency.label"
)}
options={intervalOptions}
filterOptions={(options, { inputValue }) =>
filterIntervalOptions(options, inputValue)
}
getOptionDisabled={(option) => option.id < MIN_MONITOR_INTERVAL_MS}
/>
}
/>
+2
View File
@@ -60,6 +60,8 @@ export const monitorTypeLabelKey: Record<SelectableMonitorType, string> &
pagespeed: "optionPagespeed",
};
export const MIN_MONITOR_INTERVAL_MS = 10000;
export const MonitorIntervalOptions = [
{ value: 15000, labelKey: "fifteenSeconds" },
{ value: 30000, labelKey: "thirtySeconds" },
+3 -1
View File
@@ -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 }),
@@ -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.