mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 14:45:41 +00:00
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:
@@ -81,7 +81,7 @@ Common fields:
|
|||||||
|
|
||||||
- **Name.** A descriptive label.
|
- **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).
|
- **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.
|
- **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).
|
- **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:
|
|||||||
<img src="/images/scheduled-operations/create-restart.png" alt="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." />
|
<img src="/images/scheduled-operations/create-restart.png" alt="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." />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
|
### 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
|
## Granular targeting
|
||||||
|
|
||||||
### Per-service restart
|
### Per-service restart
|
||||||
|
|||||||
@@ -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 { toast } from '@/components/ui/toast-store';
|
||||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
|
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||||
import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling';
|
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 { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
SCHEDULED_ACTIONS,
|
SCHEDULED_ACTIONS,
|
||||||
@@ -29,6 +39,9 @@ import {
|
|||||||
} from '@/lib/scheduledActions';
|
} from '@/lib/scheduledActions';
|
||||||
|
|
||||||
const DEFAULT_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'];
|
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_HOURS = 24;
|
||||||
const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000;
|
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 [formTargetId, setFormTargetId] = useState('');
|
||||||
const [formNodeId, setFormNodeId] = useState('');
|
const [formNodeId, setFormNodeId] = useState('');
|
||||||
const [formCron, setFormCron] = useState('0 3 * * *');
|
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 [formEnabled, setFormEnabled] = useState(true);
|
||||||
const [formDeleteAfterRun, setFormDeleteAfterRun] = useState(false);
|
const [formDeleteAfterRun, setFormDeleteAfterRun] = useState(false);
|
||||||
const [formPruneTargets, setFormPruneTargets] = useState<string[]>(DEFAULT_PRUNE_TARGETS);
|
const [formPruneTargets, setFormPruneTargets] = useState<string[]>(DEFAULT_PRUNE_TARGETS);
|
||||||
@@ -204,6 +220,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
setFormTargetId(prefillData?.stackName ?? '');
|
setFormTargetId(prefillData?.stackName ?? '');
|
||||||
setFormNodeId(nodeId);
|
setFormNodeId(nodeId);
|
||||||
setFormCron('0 3 * * *');
|
setFormCron('0 3 * * *');
|
||||||
|
setScheduleMode('simple');
|
||||||
|
setSimpleSchedule(DEFAULT_SIMPLE_SCHEDULE);
|
||||||
|
setSimpleReplacedCron(false);
|
||||||
setFormEnabled(true);
|
setFormEnabled(true);
|
||||||
setFormDeleteAfterRun(false);
|
setFormDeleteAfterRun(false);
|
||||||
setFormPruneTargets(DEFAULT_PRUNE_TARGETS);
|
setFormPruneTargets(DEFAULT_PRUNE_TARGETS);
|
||||||
@@ -220,6 +239,10 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
setFormTargetId(task.target_id || '');
|
setFormTargetId(task.target_id || '');
|
||||||
setFormNodeId(task.node_id != null ? String(task.node_id) : '');
|
setFormNodeId(task.node_id != null ? String(task.node_id) : '');
|
||||||
setFormCron(task.cron_expression);
|
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);
|
setFormEnabled(task.enabled === 1);
|
||||||
setFormDeleteAfterRun((task.delete_after_run ?? 0) === 1);
|
setFormDeleteAfterRun((task.delete_after_run ?? 0) === 1);
|
||||||
setFormPruneTargets(
|
setFormPruneTargets(
|
||||||
@@ -239,11 +262,25 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
return;
|
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> = {
|
const body: Record<string, unknown> = {
|
||||||
name: formName,
|
name: formName,
|
||||||
target_type: actionDef.targetType,
|
target_type: actionDef.targetType,
|
||||||
action: actionDef.backendAction,
|
action: actionDef.backendAction,
|
||||||
cron_expression: formCron,
|
cron_expression: cronExpression,
|
||||||
enabled: formEnabled,
|
enabled: formEnabled,
|
||||||
delete_after_run: formDeleteAfterRun,
|
delete_after_run: formDeleteAfterRun,
|
||||||
target_id: actionDef.requiresStack ? formTargetId : null,
|
target_id: actionDef.requiresStack ? formTargetId : null,
|
||||||
@@ -345,8 +382,44 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentAction = getActionById(formAction);
|
const currentAction = getActionById(formAction);
|
||||||
const cronDescription = getCronDescription(formCron);
|
|
||||||
const cronFieldError = getCronFieldError(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 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 nodeNameById = useMemo(() => new Map(nodes.map(n => [n.id, n.name])), [nodes]);
|
||||||
const actionOptions = useMemo(
|
const actionOptions = useMemo(
|
||||||
@@ -364,8 +437,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
[nodes],
|
[nodes],
|
||||||
);
|
);
|
||||||
const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions;
|
const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions;
|
||||||
|
const scheduleInvalid = scheduleMode === 'simple'
|
||||||
|
? !!simpleCronError
|
||||||
|
: (!formCron || !!cronFieldError);
|
||||||
const isSaveDisabled =
|
const isSaveDisabled =
|
||||||
saving || !currentAction || !formName || !formCron || !!cronFieldError
|
saving || !currentAction || !formName || scheduleInvalid
|
||||||
|| (!!currentAction?.requiresStack && (!formTargetId || !formNodeId))
|
|| (!!currentAction?.requiresStack && (!formTargetId || !formNodeId))
|
||||||
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId)
|
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId)
|
||||||
|| (formAction === 'prune' && formPruneTargets.length === 0);
|
|| (formAction === 'prune' && formPruneTargets.length === 0);
|
||||||
@@ -807,16 +883,40 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Cron Expression</Label>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Input
|
<Label>Schedule</Label>
|
||||||
placeholder="0 3 * * *"
|
<SegmentedControl<'simple' | 'advanced'>
|
||||||
value={formCron}
|
value={scheduleMode}
|
||||||
onChange={e => setFormCron(e.target.value)}
|
options={[{ value: 'simple', label: 'Simple' }, { value: 'advanced', label: 'Advanced' }]}
|
||||||
className="font-mono"
|
onChange={handleScheduleModeChange}
|
||||||
/>
|
ariaLabel="Schedule mode"
|
||||||
{cronFieldError
|
/>
|
||||||
? <p className="text-xs text-destructive">{cronFieldError}</p>
|
</div>
|
||||||
: <p className="text-xs text-muted-foreground">{cronDescription}</p>}
|
{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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -829,11 +929,15 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
id="task-delete-after-run"
|
id="task-delete-after-run"
|
||||||
checked={formDeleteAfterRun}
|
checked={formDeleteAfterRun}
|
||||||
onCheckedChange={(checked) => setFormDeleteAfterRun(checked === true)}
|
onCheckedChange={(checked) => setFormDeleteAfterRun(checked === true)}
|
||||||
|
disabled={scheduleMode === 'simple' && simpleSchedule.frequency === 'once'}
|
||||||
className="mt-0.5"
|
className="mt-0.5"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium">Delete after successful run</span>
|
<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>
|
<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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ describe('ScheduledOperationsView', () => {
|
|||||||
const createButton = screen.getByRole('button', { name: 'Create' });
|
const createButton = screen.getByRole('button', { name: 'Create' });
|
||||||
expect(createButton).toBeEnabled();
|
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 * * *');
|
const cronInput = screen.getByPlaceholderText('0 3 * * *');
|
||||||
await userEvent.clear(cronInput);
|
await userEvent.clear(cronInput);
|
||||||
await userEvent.type(cronInput, '30 0 3 * * *');
|
await userEvent.type(cronInput, '30 0 3 * * *');
|
||||||
@@ -531,4 +533,133 @@ describe('ScheduledOperationsView', () => {
|
|||||||
)).toBeInTheDocument();
|
)).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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { getCronFieldError } from './scheduling';
|
import {
|
||||||
|
getCronFieldError,
|
||||||
|
buildCron,
|
||||||
|
parseCron,
|
||||||
|
getSimpleScheduleError,
|
||||||
|
type SimpleSchedule,
|
||||||
|
} from './scheduling';
|
||||||
|
|
||||||
|
function schedule(overrides: Partial<SimpleSchedule> = {}): SimpleSchedule {
|
||||||
|
return {
|
||||||
|
frequency: 'daily',
|
||||||
|
minute: 0,
|
||||||
|
hour: 3,
|
||||||
|
weekdays: [],
|
||||||
|
dayOfMonth: 1,
|
||||||
|
date: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('getCronFieldError', () => {
|
describe('getCronFieldError', () => {
|
||||||
it('accepts a standard 5-field expression', () => {
|
it('accepts a standard 5-field expression', () => {
|
||||||
@@ -27,3 +45,135 @@ describe('getCronFieldError', () => {
|
|||||||
expect(getCronFieldError(' 0 3 * * * ')).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,3 +26,150 @@ export function formatTimestamp(ts: number | null): string {
|
|||||||
if (ts == null) return '-';
|
if (ts == null) return '-';
|
||||||
return new Date(ts).toLocaleString();
|
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 * * <weekday list>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user