From 3bece54e7231dd1698ac95ac01066acdf74406ab Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 28 Jun 2026 03:18:11 -0400 Subject: [PATCH] feat: add a Simple mode to the New Schedule flow (#1495) The schedule editor exposed only a raw 5-field cron input, which is unfriendly for the common "daily at 3am" or "one-time next week" cases. Add a Simple mode (now the default) that builds the cron from a frequency and time: Once, Hourly, Daily, Weekly, and Monthly. Advanced mode keeps the raw cron input as an escape hatch. - Simple mode compiles to the existing cron_expression on save; no schema or scheduler changes. - One-time schedules reuse the existing delete-after-run flag: selecting Once turns it on and locks it so the task runs a single time (cron has no year field, so without it the task would repeat yearly). - Editing a task opens in Simple mode when its cron maps to one of the simple shapes, otherwise in Advanced; switching a custom cron to Simple warns that it will be replaced. - Day-of-week uses frosted toggle chips and the time uses hour/minute selects so the controls match the rest of the editor. --- docs/features/scheduled-operations.mdx | 22 ++- .../src/components/ScheduleSimplePanel.tsx | 166 ++++++++++++++++++ .../components/ScheduledOperationsView.tsx | 132 ++++++++++++-- .../ScheduledOperationsView.test.tsx | 131 ++++++++++++++ frontend/src/lib/scheduling.test.ts | 152 +++++++++++++++- frontend/src/lib/scheduling.ts | 147 ++++++++++++++++ 6 files changed, 734 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/ScheduleSimplePanel.tsx diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 4c0314b0..72db0b8c 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -81,7 +81,7 @@ Common fields: - **Name.** A descriptive label. - **Action.** Pick the operation. Once selected, the form shows a risk badge and a one-line helper text below the picker so you can see the blast radius at a glance. The six risk levels are Safe (informational, no risk), Read-only (observation only), Interruptive (temporarily disrupts running state), Runtime change (changes running state, not destructive), Removes containers (tears down containers), and Destructive (irreversibly deletes resources). -- **Cron Expression.** A standard 5-field cron. A human-readable preview appears below the input as you type (`At 03:00 AM`, `At 04:00 AM, only on Sunday`, and so on). See [Cron expression reference](#cron-expression-reference). +- **Schedule.** Choose when the task runs. The section opens in **Simple** mode, where you pick a frequency (Once, Hourly, Daily, Weekly, or Monthly) and a time, and Sencho builds the cron expression for you. Switch to **Advanced** to type a raw 5-field cron by hand. Either way a human-readable preview appears below the controls (`At 03:00 AM`, `At 04:00 AM, only on Sunday`, and so on). See [Simple and Advanced modes](#simple-and-advanced-modes) and the [Cron expression reference](#cron-expression-reference). - **Enabled.** Toggle the task on or off without deleting it. - **Delete after successful run.** When enabled, the task removes itself from the schedule after its first successful execution. Failures keep the task so you can inspect the error and retry. See [Delete after successful run](#delete-after-successful-run). @@ -97,6 +97,26 @@ Conditional fields per action: The New scheduled task modal configured for a Restart Stack task. Name reads 'Nightly staging restart'. Action is Restart Stack. Node is Local. Stack is 'audit-mesh-prod'. A Services group with helper text '(leave empty for all)' shows two unchecked checkboxes labelled 'echo' and 'prober'. Cron Expression is '0 3 * * *' with the preview 'At 03:00 AM'. The Enabled toggle reads ON. The Delete after successful run checkbox is unchecked. Cancel and Create buttons sit at the bottom right. +### Simple and Advanced modes + +The **Schedule** section has two modes, toggled at the top right of the section. + +**Simple** (the default) turns a frequency and a time into a cron expression so you do not have to write one by hand: + +- **Once** runs the task a single time on a date you pick. Selecting Once turns on **Delete after successful run** and locks it on (see [One-time schedules](#one-time-schedules)). +- **Hourly** runs at a chosen minute past every hour. +- **Daily** runs once a day at a chosen time. +- **Weekly** runs at a chosen time on the weekdays you select. +- **Monthly** runs at a chosen time on a chosen day of the month. + +The generated cron and its plain-language description appear beneath the controls, and times run in the node's local timezone. + +**Advanced** swaps the controls for a single cron input where you type a raw 5-field expression. Use it for cadences Simple mode does not cover, such as "every 6 hours" (`0 */6 * * *`). Editing a task whose schedule is not expressible in Simple mode opens directly in Advanced; switching such a task to Simple replaces the custom expression and tells you so before you save. + +#### One-time schedules + +Because cron has no year field, a one-time schedule is stored as a fully pinned `minute hour day month *` expression that would otherwise repeat on that date every year. To keep it genuinely one-time, selecting **Once** turns on **Delete after successful run** and locks it on: the task fires on the chosen date and then removes itself once it succeeds. A failed run is kept so you can retry or debug. To build a recurring schedule instead, choose a different frequency or use Advanced cron. + ## Granular targeting ### Per-service restart diff --git a/frontend/src/components/ScheduleSimplePanel.tsx b/frontend/src/components/ScheduleSimplePanel.tsx new file mode 100644 index 00000000..9056bb09 --- /dev/null +++ b/frontend/src/components/ScheduleSimplePanel.tsx @@ -0,0 +1,166 @@ +import { SegmentedControl } from '@/components/ui/segmented-control'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { getCronDescription, type SimpleFrequency, type SimpleSchedule } from '@/lib/scheduling'; +import { cn } from '@/lib/utils'; + +const FREQUENCY_OPTIONS: { value: SimpleFrequency; label: string }[] = [ + { value: 'once', label: 'Once' }, + { value: 'hourly', label: 'Hourly' }, + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'monthly', label: 'Monthly' }, +]; + +const WEEKDAYS: { value: number; short: string; full: string }[] = [ + { value: 0, short: 'Su', full: 'Sunday' }, + { value: 1, short: 'Mo', full: 'Monday' }, + { value: 2, short: 'Tu', full: 'Tuesday' }, + { value: 3, short: 'We', full: 'Wednesday' }, + { value: 4, short: 'Th', full: 'Thursday' }, + { value: 5, short: 'Fr', full: 'Friday' }, + { value: 6, short: 'Sa', full: 'Saturday' }, +]; + +const pad2 = (n: number) => String(n).padStart(2, '0'); +const numInputValue = (n: number) => (Number.isNaN(n) ? '' : n); + +/** A compact 0..count-1 dropdown rendered with the design-system Select. */ +function UnitSelect({ value, count, ariaLabel, onValueChange }: { + value: number; + count: number; + ariaLabel: string; + onValueChange: (n: number) => void; +}) { + return ( + + ); +} + +interface ScheduleSimplePanelProps { + value: SimpleSchedule; + onChange: (next: SimpleSchedule) => void; + derivedCron: string; + error: string | null; +} + +export function ScheduleSimplePanel({ value, onChange, derivedCron, error }: ScheduleSimplePanelProps) { + const patch = (partial: Partial) => onChange({ ...value, ...partial }); + + const toggleWeekday = (day: number, checked: boolean) => { + patch({ weekdays: checked ? [...value.weekdays, day] : value.weekdays.filter(d => d !== day) }); + }; + + const timeControl = ( +
+ +
+ patch({ hour })} /> + : + patch({ minute })} /> +
+
+ ); + + return ( +
+ + value={value.frequency} + options={FREQUENCY_OPTIONS} + onChange={freq => patch({ frequency: freq })} + ariaLabel="Schedule frequency" + fullWidth + /> + +
+ {value.frequency === 'once' && ( +
+ + patch({ date: date ?? null })} /> +
+ )} + + {value.frequency === 'weekly' && ( +
+ +
+ {WEEKDAYS.map(d => { + const active = value.weekdays.includes(d.value); + return ( + + ); + })} +
+
+ )} + + {value.frequency === 'monthly' && ( +
+ + patch({ dayOfMonth: Number.parseInt(e.target.value, 10) })} + className="w-24" + /> +
+ )} + + {value.frequency === 'hourly' ? ( +
+ +
+ patch({ minute })} /> + past every hour +
+
+ ) : ( + timeControl + )} +
+ + {error ? ( +

{error}

+ ) : ( +

+ {getCronDescription(derivedCron)} · {derivedCron} +

+ )} + +

Runs in the node's local timezone.

+
+ ); +} diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index 971b2f9b..2a766662 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -13,8 +13,18 @@ import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, Che import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; import { Combobox } from '@/components/ui/combobox'; +import { SegmentedControl } from '@/components/ui/segmented-control'; import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling'; -import { getCronDescription, getCronFieldError, formatTimestamp } from '@/lib/scheduling'; +import { + getCronDescription, + getCronFieldError, + formatTimestamp, + buildCron, + parseCron, + getSimpleScheduleError, + type SimpleSchedule, +} from '@/lib/scheduling'; +import { ScheduleSimplePanel } from './ScheduleSimplePanel'; import { cn } from '@/lib/utils'; import { SCHEDULED_ACTIONS, @@ -29,6 +39,9 @@ import { } from '@/lib/scheduledActions'; const DEFAULT_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes']; +const DEFAULT_SIMPLE_SCHEDULE: SimpleSchedule = { + frequency: 'daily', minute: 0, hour: 3, weekdays: [], dayOfMonth: 1, date: null, +}; const TIMELINE_WINDOW_HOURS = 24; const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000; @@ -77,6 +90,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const [formTargetId, setFormTargetId] = useState(''); const [formNodeId, setFormNodeId] = useState(''); const [formCron, setFormCron] = useState('0 3 * * *'); + const [scheduleMode, setScheduleMode] = useState<'simple' | 'advanced'>('simple'); + const [simpleSchedule, setSimpleSchedule] = useState(DEFAULT_SIMPLE_SCHEDULE); + const [simpleReplacedCron, setSimpleReplacedCron] = useState(false); const [formEnabled, setFormEnabled] = useState(true); const [formDeleteAfterRun, setFormDeleteAfterRun] = useState(false); const [formPruneTargets, setFormPruneTargets] = useState(DEFAULT_PRUNE_TARGETS); @@ -204,6 +220,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p setFormTargetId(prefillData?.stackName ?? ''); setFormNodeId(nodeId); setFormCron('0 3 * * *'); + setScheduleMode('simple'); + setSimpleSchedule(DEFAULT_SIMPLE_SCHEDULE); + setSimpleReplacedCron(false); setFormEnabled(true); setFormDeleteAfterRun(false); setFormPruneTargets(DEFAULT_PRUNE_TARGETS); @@ -220,6 +239,10 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p setFormTargetId(task.target_id || ''); setFormNodeId(task.node_id != null ? String(task.node_id) : ''); setFormCron(task.cron_expression); + const parsed = parseCron(task.cron_expression, (task.delete_after_run ?? 0) === 1); + setScheduleMode(parsed ? 'simple' : 'advanced'); + setSimpleSchedule(parsed ?? DEFAULT_SIMPLE_SCHEDULE); + setSimpleReplacedCron(false); setFormEnabled(task.enabled === 1); setFormDeleteAfterRun((task.delete_after_run ?? 0) === 1); setFormPruneTargets( @@ -239,11 +262,25 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p return; } + // Re-assert schedule validity at the action, not just via the disabled + // button, so the cron is never compiled from an invalid simple schedule. + if (scheduleMode === 'simple') { + const scheduleError = getSimpleScheduleError(simpleSchedule); + if (scheduleError) { + toast.error(scheduleError); + return; + } + } + + // Simple mode compiles its structured fields to the same cron string the + // backend stores; Advanced mode sends the raw expression as-is. + const cronExpression = scheduleMode === 'simple' ? buildCron(simpleSchedule) : formCron; + const body: Record = { name: formName, target_type: actionDef.targetType, action: actionDef.backendAction, - cron_expression: formCron, + cron_expression: cronExpression, enabled: formEnabled, delete_after_run: formDeleteAfterRun, target_id: actionDef.requiresStack ? formTargetId : null, @@ -345,8 +382,44 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p }; const currentAction = getActionById(formAction); - const cronDescription = getCronDescription(formCron); const cronFieldError = getCronFieldError(formCron); + const simpleCronError = scheduleMode === 'simple' ? getSimpleScheduleError(simpleSchedule) : null; + // In Advanced mode the saved value is the raw input; in Simple mode it is the + // compiled cron, short-circuited to '' on a validation error so buildCron is + // never reached with an invalid (e.g. dateless one-time) schedule. + const derivedCron = scheduleMode === 'simple' + ? (simpleCronError ? '' : buildCron(simpleSchedule)) + : formCron; + + // Top-level Simple/Advanced toggle. Advanced -> Simple re-parses the typed + // cron and pre-fills when it maps to a simple shape, otherwise flags that the + // custom expression will be replaced. Simple -> Advanced seeds the cron input + // from what Simple produced so the user keeps what they configured. + const handleScheduleModeChange = (mode: 'simple' | 'advanced') => { + if (mode === scheduleMode) return; + if (mode === 'simple') { + const parsed = parseCron(formCron, formDeleteAfterRun); + if (parsed) { + setSimpleSchedule(parsed); + setSimpleReplacedCron(false); + } else { + setSimpleReplacedCron(true); + } + } else { + if (!simpleCronError) setFormCron(buildCron(simpleSchedule)); + setSimpleReplacedCron(false); + } + setScheduleMode(mode); + }; + + // Selecting the one-time frequency defaults delete-after-run on (the only way + // a fully-pinned cron behaves as a single run). Leaving it does not revert. + const handleSimpleScheduleChange = (next: SimpleSchedule) => { + if (next.frequency === 'once' && simpleSchedule.frequency !== 'once') { + setFormDeleteAfterRun(true); + } + setSimpleSchedule(next); + }; const nodeOptions = useMemo(() => nodes.map(n => ({ value: String(n.id), label: n.name })), [nodes]); const nodeNameById = useMemo(() => new Map(nodes.map(n => [n.id, n.name])), [nodes]); const actionOptions = useMemo( @@ -364,8 +437,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p [nodes], ); const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions; + const scheduleInvalid = scheduleMode === 'simple' + ? !!simpleCronError + : (!formCron || !!cronFieldError); const isSaveDisabled = - saving || !currentAction || !formName || !formCron || !!cronFieldError + saving || !currentAction || !formName || scheduleInvalid || (!!currentAction?.requiresStack && (!formTargetId || !formNodeId)) || (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId) || (formAction === 'prune' && formPruneTargets.length === 0); @@ -807,16 +883,40 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p )}
- - setFormCron(e.target.value)} - className="font-mono" - /> - {cronFieldError - ?

{cronFieldError}

- :

{cronDescription}

} +
+ + + value={scheduleMode} + options={[{ value: 'simple', label: 'Simple' }, { value: 'advanced', label: 'Advanced' }]} + onChange={handleScheduleModeChange} + ariaLabel="Schedule mode" + /> +
+ {scheduleMode === 'simple' ? ( + <> + + {simpleReplacedCron && ( +

Switching to Simple mode replaces your custom cron expression.

+ )} + + ) : ( + <> + setFormCron(e.target.value)} + className="font-mono" + /> + {cronFieldError + ?

{cronFieldError}

+ :

{getCronDescription(formCron)}

} + + )}
@@ -829,11 +929,15 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p id="task-delete-after-run" checked={formDeleteAfterRun} onCheckedChange={(checked) => setFormDeleteAfterRun(checked === true)} + disabled={scheduleMode === 'simple' && simpleSchedule.frequency === 'once'} className="mt-0.5" />
Delete after successful run

Task removes itself after its first successful execution. Failures keep the task so you can retry or debug.

+ {scheduleMode === 'simple' && simpleSchedule.frequency === 'once' && ( +

Required for one-time schedules: the task fires on the chosen date, then deletes itself once it succeeds. Cron has no year field, so without this it would repeat on that date every year. A failed run is kept so you can retry.

+ )}
diff --git a/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx b/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx index 87cc7875..56104eb3 100644 --- a/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx +++ b/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx @@ -191,6 +191,8 @@ describe('ScheduledOperationsView', () => { const createButton = screen.getByRole('button', { name: 'Create' }); expect(createButton).toBeEnabled(); + // The raw cron input lives in Advanced mode; Simple mode generates the cron. + await userEvent.click(screen.getByRole('radio', { name: 'Advanced' })); const cronInput = screen.getByPlaceholderText('0 3 * * *'); await userEvent.clear(cronInput); await userEvent.type(cronInput, '30 0 3 * * *'); @@ -531,4 +533,133 @@ describe('ScheduledOperationsView', () => { )).toBeInTheDocument(); }); }); + + describe('Simple schedule mode', () => { + const deleteCheckboxState = () => + document.querySelector('#task-delete-after-run')?.getAttribute('data-state'); + + it('defaults to Simple / Daily / 03:00 generating "0 3 * * *"', async () => { + render(); + await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ })); + + expect(screen.getByRole('radio', { name: 'Simple' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('radio', { name: 'Daily' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByLabelText('Hour')).toHaveTextContent('03'); + expect(screen.getByText(/0 3 \* \* \*/)).toBeInTheDocument(); + }); + + it('updates the cron preview when the frequency changes', async () => { + render(); + await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ })); + + await userEvent.click(screen.getByRole('radio', { name: 'Hourly' })); + expect(screen.getByText(/^· 0 \* \* \* \*$/)).toBeInTheDocument(); + }); + + it('locks delete-after-run on for a one-time schedule and keeps it on (editable) after leaving', async () => { + render(); + await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ })); + const deleteCheckbox = () => document.querySelector('#task-delete-after-run'); + expect(deleteCheckboxState()).toBe('unchecked'); + expect(deleteCheckbox()).toBeEnabled(); + + await userEvent.click(screen.getByRole('radio', { name: 'Once' })); + expect(deleteCheckboxState()).toBe('checked'); + expect(deleteCheckbox()).toBeDisabled(); // cannot be turned off for one-time schedules + expect(screen.getByText(/Required for one-time schedules/i)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('radio', { name: 'Daily' })); + expect(screen.queryByText(/Required for one-time schedules/i)).not.toBeInTheDocument(); + expect(deleteCheckboxState()).toBe('checked'); // not reverted + expect(deleteCheckbox()).toBeEnabled(); // editable again + }); + + it('blocks save for invalid simple schedules', async () => { + render(); + await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ })); + await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'one-off'); + // Fleet Snapshot needs neither node nor stack, isolating the schedule gate. + await userEvent.click(screen.getAllByRole('combobox')[0]); + await userEvent.click(await screen.findByRole('button', { name: 'Create Fleet Snapshot' })); + + const createBtn = () => screen.getByRole('button', { name: 'Create' }); + expect(createBtn()).toBeEnabled(); + + await userEvent.click(screen.getByRole('radio', { name: 'Once' })); // no date chosen + expect(createBtn()).toBeDisabled(); + + await userEvent.click(screen.getByRole('radio', { name: 'Weekly' })); // no weekdays + expect(createBtn()).toBeDisabled(); + + await userEvent.click(screen.getByRole('radio', { name: 'Monthly' })); + expect(createBtn()).toBeEnabled(); // day defaults to 1 + const dom = screen.getByLabelText('Day of month'); + await userEvent.clear(dom); + await userEvent.type(dom, '0'); + expect(createBtn()).toBeDisabled(); + await userEvent.clear(dom); + await userEvent.type(dom, '32'); + expect(createBtn()).toBeDisabled(); + }); + + it('opens an existing simple cron in Simple mode without the one-time caveat', async () => { + tasksFixture = [makeTask({ id: 5, name: 'daily-prune', cron_expression: '0 3 * * *', delete_after_run: 0 })]; + render(); + + await userEvent.click(await screen.findByRole('button', { name: /All tasks/ })); + await userEvent.click(await screen.findByTitle('Edit')); + + expect(screen.getByRole('radio', { name: 'Simple' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('radio', { name: 'Daily' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.queryByText(/one-time schedule fires on the chosen date/i)).not.toBeInTheDocument(); + }); + + it('opens a non-simple cron in Advanced mode', async () => { + tasksFixture = [makeTask({ id: 6, name: 'every-15', cron_expression: '*/15 * * * *' })]; + render(); + + await userEvent.click(await screen.findByRole('button', { name: /All tasks/ })); + await userEvent.click(await screen.findByTitle('Edit')); + + expect(screen.getByRole('radio', { name: 'Advanced' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByDisplayValue('*/15 * * * *')).toBeInTheDocument(); + }); + + it('warns that switching Advanced -> Simple replaces a custom cron expression', async () => { + render(); + await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ })); + + await userEvent.click(screen.getByRole('radio', { name: 'Advanced' })); + const input = screen.getByDisplayValue('0 3 * * *'); + await userEvent.clear(input); + await userEvent.type(input, '*/15 * * * *'); + await userEvent.click(screen.getByRole('radio', { name: 'Simple' })); + + expect(screen.getByText(/Switching to Simple mode replaces your custom cron expression/i)).toBeInTheDocument(); + expect(screen.getByText(/0 3 \* \* \*/)).toBeInTheDocument(); + }); + + it('saves the compiled cron from a non-default Simple schedule (weekly)', async () => { + render(); + await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ })); + await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'weekly-snapshot'); + await userEvent.click(screen.getAllByRole('combobox')[0]); + await userEvent.click(await screen.findByRole('button', { name: 'Create Fleet Snapshot' })); + + await userEvent.click(screen.getByRole('radio', { name: 'Weekly' })); + await userEvent.click(screen.getByRole('checkbox', { name: 'Monday' })); + await userEvent.click(screen.getByRole('checkbox', { name: 'Wednesday' })); + + await userEvent.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + const postCall = mockedFetch.mock.calls.find( + ([url, opts]) => url === '/scheduled-tasks' && opts?.method === 'POST', + ); + expect(postCall).toBeTruthy(); + // Compiled from the weekday selection, not the legacy default literal. + expect(JSON.parse(postCall![1].body).cron_expression).toBe('0 3 * * 1,3'); + }); + }); + }); }); diff --git a/frontend/src/lib/scheduling.test.ts b/frontend/src/lib/scheduling.test.ts index a6b51a2b..bd5182e2 100644 --- a/frontend/src/lib/scheduling.test.ts +++ b/frontend/src/lib/scheduling.test.ts @@ -1,5 +1,23 @@ import { describe, it, expect } from 'vitest'; -import { getCronFieldError } from './scheduling'; +import { + getCronFieldError, + buildCron, + parseCron, + getSimpleScheduleError, + type SimpleSchedule, +} from './scheduling'; + +function schedule(overrides: Partial = {}): SimpleSchedule { + return { + frequency: 'daily', + minute: 0, + hour: 3, + weekdays: [], + dayOfMonth: 1, + date: null, + ...overrides, + }; +} describe('getCronFieldError', () => { it('accepts a standard 5-field expression', () => { @@ -27,3 +45,135 @@ describe('getCronFieldError', () => { expect(getCronFieldError(' 0 3 * * * ')).toBeNull(); }); }); + +describe('buildCron', () => { + it('hourly emits a literal minute against every hour', () => { + expect(buildCron(schedule({ frequency: 'hourly', minute: 15 }))).toBe('15 * * * *'); + }); + + it('daily emits minute and hour', () => { + expect(buildCron(schedule({ frequency: 'daily', minute: 0, hour: 3 }))).toBe('0 3 * * *'); + }); + + it('weekly emits a sorted, de-duplicated weekday list', () => { + expect(buildCron(schedule({ frequency: 'weekly', minute: 0, hour: 3, weekdays: [5, 1, 3] }))).toBe('0 3 * * 1,3,5'); + expect(buildCron(schedule({ frequency: 'weekly', minute: 0, hour: 3, weekdays: [1, 3, 1] }))).toBe('0 3 * * 1,3'); + }); + + it('monthly emits the day of month', () => { + expect(buildCron(schedule({ frequency: 'monthly', minute: 30, hour: 14, dayOfMonth: 15 }))).toBe('30 14 15 * *'); + }); + + it('once pins the day and month from the date', () => { + expect(buildCron(schedule({ frequency: 'once', minute: 0, hour: 9, date: new Date(2026, 5, 30) }))).toBe('0 9 30 6 *'); + }); + + it('once returns an empty string when no date is set (total, never throws)', () => { + expect(buildCron(schedule({ frequency: 'once', date: null }))).toBe(''); + }); + + it('weekly returns an empty string when no weekdays are selected (no malformed cron)', () => { + expect(buildCron(schedule({ frequency: 'weekly', weekdays: [] }))).toBe(''); + }); + + it('every frequency with valid input produces a 5-field expression', () => { + const cases: SimpleSchedule[] = [ + schedule({ frequency: 'hourly', minute: 5 }), + schedule({ frequency: 'daily' }), + schedule({ frequency: 'weekly', weekdays: [2] }), + schedule({ frequency: 'monthly', dayOfMonth: 10 }), + schedule({ frequency: 'once', date: new Date(2026, 0, 1) }), + ]; + for (const c of cases) { + expect(buildCron(c).split(/\s+/)).toHaveLength(5); + } + }); +}); + +describe('parseCron', () => { + it('round-trips every buildCron output at the cron-string level', () => { + const cases: SimpleSchedule[] = [ + schedule({ frequency: 'hourly', minute: 5 }), + schedule({ frequency: 'daily', minute: 0, hour: 3 }), + schedule({ frequency: 'weekly', minute: 0, hour: 0, weekdays: [1, 3, 5] }), + schedule({ frequency: 'monthly', minute: 30, hour: 14, dayOfMonth: 1 }), + schedule({ frequency: 'once', minute: 0, hour: 9, date: new Date(new Date().getFullYear(), 5, 30) }), + ]; + for (const c of cases) { + const cron = buildCron(c); + const parsed = parseCron(cron, c.frequency === 'once'); + expect(parsed).not.toBeNull(); + expect(buildCron(parsed as SimpleSchedule)).toBe(cron); + } + }); + + it('returns null for non-matching shapes', () => { + expect(parseCron('*/15 * * * *', false)).toBeNull(); + expect(parseCron('@daily', false)).toBeNull(); + expect(parseCron('0 3 * * 1-5', false)).toBeNull(); // range in weekday field + expect(parseCron('0 3 1 * 1', false)).toBeNull(); // both day-of-month and weekday pinned + expect(parseCron('30 0 3 * * *', false)).toBeNull(); // 6 fields + }); + + it('reads a daily expression', () => { + expect(parseCron('0 0 * * *', false)).toMatchObject({ frequency: 'daily', minute: 0, hour: 0 }); + }); + + it('reads a weekly expression with a weekday list', () => { + expect(parseCron('0 0 * * 1,3,5', false)).toMatchObject({ frequency: 'weekly', minute: 0, hour: 0, weekdays: [1, 3, 5] }); + }); + + it('reads a monthly expression', () => { + expect(parseCron('30 14 1 * *', false)).toMatchObject({ frequency: 'monthly', minute: 30, hour: 14, dayOfMonth: 1 }); + }); + + it('reads a one-time expression only when delete-after-run is set', () => { + expect(parseCron('0 0 1 1 *', false)).toBeNull(); + const parsed = parseCron('0 0 1 1 *', true); + expect(parsed).toMatchObject({ frequency: 'once', minute: 0, hour: 0, dayOfMonth: 1 }); + expect(parsed?.date?.getMonth()).toBe(0); // January + expect(parsed?.date?.getDate()).toBe(1); + }); + + it('rejects an impossible day/month combination for once', () => { + expect(parseCron('0 0 31 2 *', true)).toBeNull(); // Feb 31 + }); +}); + +describe('getSimpleScheduleError', () => { + const now = new Date(2026, 5, 28); + + it('passes a valid daily schedule', () => { + expect(getSimpleScheduleError(schedule({ frequency: 'daily' }), now)).toBeNull(); + }); + + it('blocks weekly with no weekdays', () => { + expect(getSimpleScheduleError(schedule({ frequency: 'weekly', weekdays: [] }), now)).toBe('Choose at least one weekday.'); + }); + + it('blocks an out-of-range day of month', () => { + expect(getSimpleScheduleError(schedule({ frequency: 'monthly', dayOfMonth: 0 }), now)).toBe('Day of month must be 1-31.'); + expect(getSimpleScheduleError(schedule({ frequency: 'monthly', dayOfMonth: 32 }), now)).toBe('Day of month must be 1-31.'); + }); + + it('blocks once with no date', () => { + expect(getSimpleScheduleError(schedule({ frequency: 'once', date: null }), now)).toBe('Select a date.'); + }); + + it('blocks once with a past date', () => { + expect(getSimpleScheduleError(schedule({ frequency: 'once', date: new Date(2026, 5, 1) }), now)) + .toBe('The selected date is in the past and this schedule would never fire.'); + }); + + it('passes once with a future date', () => { + expect(getSimpleScheduleError(schedule({ frequency: 'once', date: new Date(2026, 11, 25) }), now)).toBeNull(); + }); + + it('blocks an invalid time', () => { + expect(getSimpleScheduleError(schedule({ frequency: 'daily', minute: Number.NaN }), now)).toBe('Enter a valid time.'); + }); + + it('ignores the hour for hourly schedules', () => { + expect(getSimpleScheduleError(schedule({ frequency: 'hourly', hour: 25, minute: 0 }), now)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/scheduling.ts b/frontend/src/lib/scheduling.ts index f3b32529..6429dc34 100644 --- a/frontend/src/lib/scheduling.ts +++ b/frontend/src/lib/scheduling.ts @@ -26,3 +26,150 @@ export function formatTimestamp(ts: number | null): string { if (ts == null) return '-'; return new Date(ts).toLocaleString(); } + +/** + * Simple schedule mode: a friendly, structured way to describe a schedule that + * compiles down to the same 5-field `cron_expression` the backend already + * stores. Anything outside these five shapes is edited as raw cron (Advanced). + */ +export type SimpleFrequency = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly'; + +export interface SimpleSchedule { + frequency: SimpleFrequency; + minute: number; // 0-59 + hour: number; // 0-23, ignored for 'hourly' + weekdays: number[]; // 0-6 (Sun-Sat), used for 'weekly' + dayOfMonth: number; // 1-31, used for 'monthly' + date: Date | null; // used for 'once' (supplies day + month) +} + +/** + * Generate the 5-field cron expression for a simple schedule. Never throws: an + * incomplete schedule (a 'once' with no date, or a 'weekly' with no days) + * returns an empty string, so a render that reaches it before validation cannot + * crash and a malformed expression is never produced. + */ +export function buildCron(s: SimpleSchedule): string { + const m = s.minute; + const h = s.hour; + switch (s.frequency) { + case 'hourly': + return `${m} * * * *`; + case 'daily': + return `${m} ${h} * * *`; + case 'weekly': { + if (s.weekdays.length === 0) return ''; + const days = [...new Set(s.weekdays)].sort((a, b) => a - b).join(','); + return `${m} ${h} * * ${days}`; + } + case 'monthly': + return `${m} ${h} ${s.dayOfMonth} * *`; + case 'once': + if (!s.date) return ''; + return `${m} ${h} ${s.date.getDate()} ${s.date.getMonth() + 1} *`; + } +} + +function parseIntField(field: string, min: number, max: number): number | null { + if (!/^\d+$/.test(field)) return null; + const n = Number(field); + return n >= min && n <= max ? n : null; +} + +function parseWeekdayList(field: string): number[] | null { + const days: number[] = []; + for (const token of field.split(',')) { + const n = parseIntField(token, 0, 6); + if (n === null) return null; + days.push(n); + } + return days; +} + +/** + * Reverse of `buildCron`. Returns the structured schedule when `cron` matches + * one of the five generatable shapes, or null when it doesn't (so the editor + * falls back to Advanced cron). A fully pinned `M H D MO *` is only read as a + * one-time schedule when `deleteAfterRun` is set, since cron has no year field + * and Simple mode has no recurring "yearly" frequency. Strict and total. + */ +export function parseCron(cron: string, deleteAfterRun: boolean): SimpleSchedule | null { + const parts = cron.trim().split(/\s+/); + if (parts.length !== 5) return null; + const [minF, hrF, domF, monF, dowF] = parts; + + const minute = parseIntField(minF, 0, 59); + if (minute === null) return null; + + const base: SimpleSchedule = { + frequency: 'daily', minute, hour: 0, weekdays: [], dayOfMonth: 1, date: null, + }; + + // hourly: M * * * * + if (hrF === '*' && domF === '*' && monF === '*' && dowF === '*') { + return { ...base, frequency: 'hourly' }; + } + + const hour = parseIntField(hrF, 0, 23); + if (hour === null) return null; + + // daily: M H * * * + if (domF === '*' && monF === '*' && dowF === '*') { + return { ...base, frequency: 'daily', hour }; + } + + // weekly: M H * * + if (domF === '*' && monF === '*' && dowF !== '*') { + const weekdays = parseWeekdayList(dowF); + if (!weekdays) return null; + return { ...base, frequency: 'weekly', hour, weekdays }; + } + + // monthly: M H D * * + if (domF !== '*' && monF === '*' && dowF === '*') { + const dayOfMonth = parseIntField(domF, 1, 31); + if (dayOfMonth === null) return null; + return { ...base, frequency: 'monthly', hour, dayOfMonth }; + } + + // once: M H D MO * (only a one-time schedule when delete-after-run is set) + if (domF !== '*' && monF !== '*' && dowF === '*') { + if (!deleteAfterRun) return null; + const dayOfMonth = parseIntField(domF, 1, 31); + const month = parseIntField(monF, 1, 12); + if (dayOfMonth === null || month === null) return null; + const date = new Date(new Date().getFullYear(), month - 1, dayOfMonth); + // Reject impossible day/month combos (JS Date rolls Feb 31 into March). + if (date.getMonth() !== month - 1 || date.getDate() !== dayOfMonth) return null; + return { ...base, frequency: 'once', hour, dayOfMonth, date }; + } + + return null; +} + +/** + * Validate a simple schedule. Returns a human-readable, save-blocking message + * when the fields can't produce a schedule that will fire, or null when valid. + * Returns the same `string | null` shape `getCronFieldError` uses for Advanced. + */ +export function getSimpleScheduleError(s: SimpleSchedule, now: Date = new Date()): string | null { + if (!Number.isInteger(s.minute) || s.minute < 0 || s.minute > 59) { + return 'Enter a valid time.'; + } + if (s.frequency !== 'hourly' && (!Number.isInteger(s.hour) || s.hour < 0 || s.hour > 23)) { + return 'Enter a valid time.'; + } + if (s.frequency === 'weekly' && s.weekdays.length === 0) { + return 'Choose at least one weekday.'; + } + if (s.frequency === 'monthly' && (!Number.isInteger(s.dayOfMonth) || s.dayOfMonth < 1 || s.dayOfMonth > 31)) { + return 'Day of month must be 1-31.'; + } + if (s.frequency === 'once') { + if (!s.date) return 'Select a date.'; + const picked = new Date(s.date.getFullYear(), s.date.getMonth(), s.date.getDate()).getTime(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + if (picked < today) return 'The selected date is in the past and this schedule would never fire.'; + } + return null; +}