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.
This commit is contained in:
Anso
2026-06-28 03:18:11 -04:00
committed by GitHub
parent ed06900fd4
commit 3bece54e72
6 changed files with 734 additions and 16 deletions
@@ -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 (
<Select value={String(value)} onValueChange={v => onValueChange(Number(v))}>
<SelectTrigger aria-label={ariaLabel} className="w-[72px] font-mono tabular-nums">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[240px]">
{Array.from({ length: count }, (_, i) => i).map(n => (
<SelectItem key={n} value={String(n)} className="font-mono tabular-nums">{pad2(n)}</SelectItem>
))}
</SelectContent>
</Select>
);
}
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<SimpleSchedule>) => onChange({ ...value, ...partial });
const toggleWeekday = (day: number, checked: boolean) => {
patch({ weekdays: checked ? [...value.weekdays, day] : value.weekdays.filter(d => d !== day) });
};
const timeControl = (
<div className="flex flex-col gap-1.5">
<Label>Time</Label>
<div className="flex items-center gap-1.5">
<UnitSelect value={value.hour} count={24} ariaLabel="Hour" onValueChange={hour => patch({ hour })} />
<span className="text-sm text-muted-foreground">:</span>
<UnitSelect value={value.minute} count={60} ariaLabel="Minute" onValueChange={minute => patch({ minute })} />
</div>
</div>
);
return (
<div className="space-y-3">
<SegmentedControl<SimpleFrequency>
value={value.frequency}
options={FREQUENCY_OPTIONS}
onChange={freq => patch({ frequency: freq })}
ariaLabel="Schedule frequency"
fullWidth
/>
<div className="flex flex-wrap items-end gap-4 max-md:gap-3">
{value.frequency === 'once' && (
<div className="flex flex-col gap-1.5">
<Label>Date</Label>
<DatePicker value={value.date ?? undefined} onChange={date => patch({ date: date ?? null })} />
</div>
)}
{value.frequency === 'weekly' && (
<div className="flex flex-col gap-1.5">
<Label>Days</Label>
<div className="inline-flex items-center rounded-md border border-glass-border bg-popover p-0.5 shadow-sm backdrop-blur-[10px] backdrop-saturate-[1.15]" role="group" aria-label="Weekdays">
{WEEKDAYS.map(d => {
const active = value.weekdays.includes(d.value);
return (
<button
key={d.value}
type="button"
role="checkbox"
aria-checked={active}
aria-label={d.full}
onClick={() => toggleWeekday(d.value, !active)}
className={cn(
'rounded px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
active ? 'bg-brand/10 text-brand' : 'text-stat-subtitle hover:text-stat-value',
)}
>
{d.short}
</button>
);
})}
</div>
</div>
)}
{value.frequency === 'monthly' && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="simple-dom">Day of month</Label>
<Input
id="simple-dom"
type="number"
min={1}
max={31}
value={numInputValue(value.dayOfMonth)}
onChange={e => patch({ dayOfMonth: Number.parseInt(e.target.value, 10) })}
className="w-24"
/>
</div>
)}
{value.frequency === 'hourly' ? (
<div className="flex flex-col gap-1.5">
<Label>Minute</Label>
<div className="flex items-center gap-2">
<UnitSelect value={value.minute} count={60} ariaLabel="Minute" onValueChange={minute => patch({ minute })} />
<span className="text-xs text-muted-foreground">past every hour</span>
</div>
</div>
) : (
timeControl
)}
</div>
{error ? (
<p className="text-xs text-destructive">{error}</p>
) : (
<p className="text-xs text-muted-foreground">
{getCronDescription(derivedCron)} <span className="font-mono text-stat-subtitle">· {derivedCron}</span>
</p>
)}
<p className="text-xs text-muted-foreground">Runs in the node's local timezone.</p>
</div>
);
}
@@ -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<SimpleSchedule>(DEFAULT_SIMPLE_SCHEDULE);
const [simpleReplacedCron, setSimpleReplacedCron] = useState(false);
const [formEnabled, setFormEnabled] = useState(true);
const [formDeleteAfterRun, setFormDeleteAfterRun] = useState(false);
const [formPruneTargets, setFormPruneTargets] = useState<string[]>(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<string, unknown> = {
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
)}
<div className="space-y-2">
<Label>Cron Expression</Label>
<Input
placeholder="0 3 * * *"
value={formCron}
onChange={e => setFormCron(e.target.value)}
className="font-mono"
/>
{cronFieldError
? <p className="text-xs text-destructive">{cronFieldError}</p>
: <p className="text-xs text-muted-foreground">{cronDescription}</p>}
<div className="flex items-center justify-between gap-3">
<Label>Schedule</Label>
<SegmentedControl<'simple' | 'advanced'>
value={scheduleMode}
options={[{ value: 'simple', label: 'Simple' }, { value: 'advanced', label: 'Advanced' }]}
onChange={handleScheduleModeChange}
ariaLabel="Schedule mode"
/>
</div>
{scheduleMode === 'simple' ? (
<>
<ScheduleSimplePanel
value={simpleSchedule}
onChange={handleSimpleScheduleChange}
derivedCron={derivedCron}
error={simpleCronError}
/>
{simpleReplacedCron && (
<p className="text-xs text-muted-foreground">Switching to Simple mode replaces your custom cron expression.</p>
)}
</>
) : (
<>
<Input
placeholder="0 3 * * *"
value={formCron}
onChange={e => setFormCron(e.target.value)}
className="font-mono"
/>
{cronFieldError
? <p className="text-xs text-destructive">{cronFieldError}</p>
: <p className="text-xs text-muted-foreground">{getCronDescription(formCron)}</p>}
</>
)}
</div>
<div className="flex items-center gap-2">
@@ -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"
/>
<div>
<span className="text-sm font-medium">Delete after successful run</span>
<p className="text-xs text-muted-foreground">Task removes itself after its first successful execution. Failures keep the task so you can retry or debug.</p>
{scheduleMode === 'simple' && simpleSchedule.frequency === 'once' && (
<p className="mt-1 text-xs text-muted-foreground">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.</p>
)}
</div>
</label>
</ModalBody>
@@ -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(<ScheduledOperationsView />);
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(<ScheduledOperationsView />);
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(<ScheduledOperationsView />);
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(<ScheduledOperationsView />);
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(<ScheduledOperationsView />);
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(<ScheduledOperationsView />);
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(<ScheduledOperationsView />);
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(<ScheduledOperationsView />);
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');
});
});
});
});