@@ -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;
+}