diff --git a/src/frontend/src/locales/en.json b/src/frontend/src/locales/en.json index d601fec97d..b7293000d3 100644 --- a/src/frontend/src/locales/en.json +++ b/src/frontend/src/locales/en.json @@ -459,8 +459,26 @@ "triggers.field.flowPlaceholder": "Select a flow", "triggers.field.name": "Name", "triggers.field.namePlaceholder": "e.g. daily-summary", - "triggers.field.cron": "Cron expression", + "triggers.field.cron": "Schedule", "triggers.field.cronHint": "Five-field POSIX cron, e.g. */5 * * * * (every 5 minutes).", + "triggers.cron.minutes": "Every N minutes", + "triggers.cron.hours": "Every N hours", + "triggers.cron.daily": "Daily at…", + "triggers.cron.weekly": "Weekly on…", + "triggers.cron.custom": "Custom (cron expression)", + "triggers.cron.everyLabel": "every", + "triggers.cron.atLabel": "at", + "triggers.cron.minutesUnit": "minutes", + "triggers.cron.hoursUnit": "hours", + "triggers.cron.customHint": "Advanced: 5-field POSIX cron. Validated by the backend.", + "triggers.cron.previewLabel": "Cron:", + "triggers.cron.day.sun": "Sunday", + "triggers.cron.day.mon": "Monday", + "triggers.cron.day.tue": "Tuesday", + "triggers.cron.day.wed": "Wednesday", + "triggers.cron.day.thu": "Thursday", + "triggers.cron.day.fri": "Friday", + "triggers.cron.day.sat": "Saturday", "triggers.field.timezone": "Timezone", "triggers.field.timezoneOther": "Other (IANA name)…", "triggers.field.maxAttempts": "Max attempts", diff --git a/src/frontend/src/pages/MainPage/pages/triggersPage/components/CronScheduleField.tsx b/src/frontend/src/pages/MainPage/pages/triggersPage/components/CronScheduleField.tsx new file mode 100644 index 0000000000..e760418e54 --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/triggersPage/components/CronScheduleField.tsx @@ -0,0 +1,326 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +// Friendly schedule modes. Each one composes into a 5-field POSIX cron +// expression that the backend's croniter accepts as-is. Users who need +// expressions outside these patterns switch to ``custom`` mode and +// type the cron string themselves. +type Mode = "minutes" | "hours" | "daily" | "weekly" | "custom"; + +const MINUTE_INTERVALS = [1, 2, 5, 10, 15, 20, 30] as const; +const HOUR_INTERVALS = [1, 2, 3, 4, 6, 8, 12] as const; +const DAYS = [ + { value: "0", labelKey: "triggers.cron.day.sun" }, + { value: "1", labelKey: "triggers.cron.day.mon" }, + { value: "2", labelKey: "triggers.cron.day.tue" }, + { value: "3", labelKey: "triggers.cron.day.wed" }, + { value: "4", labelKey: "triggers.cron.day.thu" }, + { value: "5", labelKey: "triggers.cron.day.fri" }, + { value: "6", labelKey: "triggers.cron.day.sat" }, +] as const; + +interface ParsedCron { + mode: Mode; + minutes: number; + hours: number; + daily: { hour: number; minute: number }; + weekly: { day: string; hour: number; minute: number }; + custom: string; +} + +const DEFAULTS: ParsedCron = { + mode: "minutes", + minutes: 5, + hours: 1, + daily: { hour: 9, minute: 0 }, + weekly: { day: "1", hour: 9, minute: 0 }, + custom: "*/5 * * * *", +}; + +// Parse a cron string back into one of the friendly modes when +// possible. Anything that does not fit one of the recognized patterns +// falls back to ``custom`` so the original expression is preserved. +function parseCron(value: string): ParsedCron { + const next: ParsedCron = { ...DEFAULTS, custom: value || DEFAULTS.custom }; + const parts = (value ?? "").trim().split(/\s+/); + if (parts.length !== 5) return next; + const [m, h, dom, mon, dow] = parts; + const everyN = (field: string) => { + const match = field.match(/^\*\/(\d+)$/); + return match ? Number.parseInt(match[1], 10) : null; + }; + + if ( + h === "*" && + dom === "*" && + mon === "*" && + dow === "*" && + everyN(m) !== null + ) { + return { ...next, mode: "minutes", minutes: everyN(m)! }; + } + if ( + m === "0" && + dom === "*" && + mon === "*" && + dow === "*" && + everyN(h) !== null + ) { + return { ...next, mode: "hours", hours: everyN(h)! }; + } + const numericMinute = m.match(/^\d{1,2}$/); + const numericHour = h.match(/^\d{1,2}$/); + if (numericMinute && numericHour && dom === "*" && mon === "*") { + if (dow === "*") { + return { + ...next, + mode: "daily", + daily: { + hour: Number.parseInt(h, 10), + minute: Number.parseInt(m, 10), + }, + }; + } + if (dow.match(/^[0-6]$/)) { + return { + ...next, + mode: "weekly", + weekly: { + day: dow, + hour: Number.parseInt(h, 10), + minute: Number.parseInt(m, 10), + }, + }; + } + } + return { ...next, mode: "custom" }; +} + +function composeCron(state: ParsedCron): string { + switch (state.mode) { + case "minutes": + return `*/${state.minutes} * * * *`; + case "hours": + return `0 */${state.hours} * * *`; + case "daily": + return `${state.daily.minute} ${state.daily.hour} * * *`; + case "weekly": + return `${state.weekly.minute} ${state.weekly.hour} * * ${state.weekly.day}`; + case "custom": + return state.custom; + } +} + +interface CronScheduleFieldProps { + value: string; + onChange: (cron: string) => void; + error?: string; +} + +export default function CronScheduleField({ + value, + onChange, + error, +}: CronScheduleFieldProps) { + const { t } = useTranslation(); + + // ``value`` is the canonical cron string in the parent form. We + // mirror it into local state on mount and whenever the parent + // resets (e.g. switching between create and edit). Internal edits + // recompose the cron and bubble it up via ``onChange``. + const [state, setState] = useState(() => parseCron(value)); + + useEffect(() => { + setState(parseCron(value)); + // We only want to re-sync when ``value`` changes from the parent's + // perspective, not on every local re-render that pushed a new + // string up — eslint will warn about onChange, ignore intentionally. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + const composed = useMemo(() => composeCron(state), [state]); + + // Push composed cron up whenever state changes. + useEffect(() => { + if (composed !== value) onChange(composed); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [composed]); + + const update = (patch: Partial) => + setState((prev) => ({ ...prev, ...patch })); + + return ( +
+
+ + + {state.mode === "minutes" && ( +
+ + {t("triggers.cron.everyLabel")} + + + + {t("triggers.cron.minutesUnit")} + +
+ )} + + {state.mode === "hours" && ( +
+ + {t("triggers.cron.everyLabel")} + + + + {t("triggers.cron.hoursUnit")} + +
+ )} + + {state.mode === "daily" && ( +
+ + { + const [h, m] = e.target.value.split(":"); + update({ + daily: { + hour: Number.parseInt(h, 10), + minute: Number.parseInt(m, 10), + }, + }); + }} + /> +
+ )} + + {state.mode === "weekly" && ( +
+ + + { + const [h, m] = e.target.value.split(":"); + update({ + weekly: { + ...state.weekly, + hour: Number.parseInt(h, 10), + minute: Number.parseInt(m, 10), + }, + }); + }} + /> +
+ )} + + {state.mode === "custom" && ( +
+ update({ custom: e.target.value })} + placeholder="*/5 * * * *" + /> + + {t("triggers.cron.customHint")} + +
+ )} +
+ +
+ + {t("triggers.cron.previewLabel")} + + {composed} +
+ + {error && {error}} +
+ ); +} diff --git a/src/frontend/src/pages/MainPage/pages/triggersPage/components/TriggerFormModal.tsx b/src/frontend/src/pages/MainPage/pages/triggersPage/components/TriggerFormModal.tsx index 2f6588e38b..a9c7c14294 100644 --- a/src/frontend/src/pages/MainPage/pages/triggersPage/components/TriggerFormModal.tsx +++ b/src/frontend/src/pages/MainPage/pages/triggersPage/components/TriggerFormModal.tsx @@ -22,6 +22,7 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; +import { useGetRefreshFlowsQuery } from "@/controllers/API/queries/flows/use-get-refresh-flows-query"; import { usePatchTrigger, usePostTrigger, @@ -29,6 +30,7 @@ import { import useAlertStore from "@/stores/alertStore"; import useFlowsManagerStore from "@/stores/flowsManagerStore"; import type { Trigger } from "../types"; +import CronScheduleField from "./CronScheduleField"; // Curated list of common IANA timezones. The user can still type any // IANA name via the "Other" option which switches to a free-text @@ -110,6 +112,15 @@ export default function TriggerFormModal({ const isEditing = existingTrigger !== null; + // Make sure the flow selector has data even if the user landed on + // /triggers without visiting the home page first. The query also + // hydrates the flowsManagerStore that the table uses to render + // flow names. + useGetRefreshFlowsQuery( + { get_all: true, remove_example_flows: true }, + { enabled: open && (flows === undefined || flows.length === 0) }, + ); + const flowOptions = useMemo( () => (flows ?? []) @@ -268,24 +279,18 @@ export default function TriggerFormModal({
- - {t("triggers.field.cron")} + ( + + )} /> - - {t("triggers.field.cronHint")} - - {errors.cron_expression && ( - - {errors.cron_expression.message} - - )}
diff --git a/src/frontend/src/pages/MainPage/pages/triggersPage/index.tsx b/src/frontend/src/pages/MainPage/pages/triggersPage/index.tsx index 229a20e89a..0fa2eaefd2 100644 --- a/src/frontend/src/pages/MainPage/pages/triggersPage/index.tsx +++ b/src/frontend/src/pages/MainPage/pages/triggersPage/index.tsx @@ -3,7 +3,9 @@ import { useTranslation } from "react-i18next"; import ForwardedIconComponent from "@/components/common/genericIconComponent"; import { Button } from "@/components/ui/button"; import { SidebarTrigger } from "@/components/ui/sidebar"; +import { useGetRefreshFlowsQuery } from "@/controllers/API/queries/flows/use-get-refresh-flows-query"; import { useGetTriggers } from "@/controllers/API/queries/triggers"; +import useFlowsManagerStore from "@/stores/flowsManagerStore"; import TriggerFormModal from "./components/TriggerFormModal"; import TriggerJobsDrawer from "./components/TriggerJobsDrawer"; import TriggersTable from "./components/TriggersTable"; @@ -15,6 +17,15 @@ export default function TriggersPage() { const [editing, setEditing] = useState(null); const [jobsTrigger, setJobsTrigger] = useState(null); + // Hydrate the flowsManagerStore when the user lands here directly, + // before visiting the home page. The hook is a no-op for repeat + // mounts because TanStack Query dedupes by key. + const flows = useFlowsManagerStore((s) => s.flows); + useGetRefreshFlowsQuery( + { get_all: true, remove_example_flows: true }, + { enabled: flows === undefined }, + ); + const { data: triggers, isLoading } = useGetTriggers({}); const handleCreate = () => {