feat(scheduler): add auto_backup, auto_stop, auto_down, auto_start and delete_after_run one-shot mode (#777)

* feat(scheduler): add auto_backup, auto_stop, auto_down, auto_start actions and delete_after_run one-shot mode

Extends the scheduler with four new stack-targeted actions:

- auto_backup: backs up stack compose files and .env using the existing
  FileSystemService.backupStackFiles primitive
- auto_stop: runs compose stop (containers preserved)
- auto_down: runs compose down (containers removed)
- auto_start: runs compose up -d via deployStack (universal start for both
  stopped and down stacks)

Adds delete_after_run boolean column to scheduled_tasks. When enabled,
the task self-deletes after its first successful execution; failures keep
the task so the user can debug and retry.

All four new actions gate at Admiral tier, consistent with restart/snapshot/prune.
Migration is idempotent (maybeAddCol).

* docs(scheduler): update scheduled-operations doc with new lifecycle actions and delete-after-run

Adds the four new actions (Backup Stack Files, Stop Stack, Take Stack Down,
Start Stack) to the action table. Documents the delete-after-run one-shot mode
with its success-only deletion semantics. Adds the Stack Lifecycle Scheduling
section explaining stop-vs-down semantics and the local-execution boundary.
Adds three troubleshooting entries: auto-start on a missing compose folder,
auto-backup single-slot overwrite by design, and one-shot task disappearing
after successful run.

Updates the timeline description from four to five lanes. Refreshes
screenshots to show the new dialog layout with the Lifecycle lane visible.
This commit is contained in:
Anso
2026-04-25 16:11:13 -04:00
committed by GitHub
parent e0034132b4
commit abee078741
10 changed files with 341 additions and 18 deletions
@@ -32,6 +32,10 @@ const ACTION_OPTIONS: Array<{
{ value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' },
{ value: 'prune', label: 'System Prune', targetType: 'system' },
{ value: 'scan', label: 'Vulnerability Scan', targetType: 'system' },
{ value: 'auto_backup', label: 'Backup Stack Files', targetType: 'stack' },
{ value: 'auto_stop', label: 'Stop Stack (keep containers)', targetType: 'stack' },
{ value: 'auto_down', label: 'Take Stack Down (remove containers)', targetType: 'stack' },
{ value: 'auto_start', label: 'Start Stack', targetType: 'stack' },
];
const TIMELINE_LANES: { key: ScheduledTask['action']; label: string; color: string; bg: string; actions: ScheduledTask['action'][] }[] = [
@@ -39,6 +43,7 @@ const TIMELINE_LANES: { key: ScheduledTask['action']; label: string; color: stri
{ key: 'update', label: 'Update', color: 'var(--success)', bg: 'oklch(from var(--success) l c h / 0.18)', actions: ['update'] },
{ key: 'scan', label: 'Scan', color: 'var(--label-purple)', bg: 'var(--label-purple-bg)', actions: ['scan'] },
{ key: 'prune', label: 'Prune', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)', actions: ['prune', 'snapshot'] },
{ key: 'auto_stop', label: 'Lifecycle', color: 'var(--label-blue)', bg: 'var(--label-blue-bg)', actions: ['auto_stop', 'auto_down', 'auto_start', 'auto_backup'] },
];
const TIMELINE_WINDOW_HOURS = 24;
@@ -90,6 +95,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
const [formNodeId, setFormNodeId] = useState('');
const [formCron, setFormCron] = useState('0 3 * * *');
const [formEnabled, setFormEnabled] = useState(true);
const [formDeleteAfterRun, setFormDeleteAfterRun] = useState(false);
const [formPruneTargets, setFormPruneTargets] = useState<string[]>(['containers', 'images', 'networks', 'volumes']);
const [formTargetServices, setFormTargetServices] = useState<string[]>([]);
const [formPruneLabelFilter, setFormPruneLabelFilter] = useState('');
@@ -210,6 +216,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
setFormNodeId(nodeId);
setFormCron('0 3 * * *');
setFormEnabled(true);
setFormDeleteAfterRun(false);
setFormPruneTargets(['containers', 'images', 'networks', 'volumes']);
setFormTargetServices([]);
setFormPruneLabelFilter('');
@@ -225,6 +232,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
setFormNodeId(task.node_id != null ? String(task.node_id) : '');
setFormCron(task.cron_expression);
setFormEnabled(task.enabled === 1);
setFormDeleteAfterRun((task.delete_after_run ?? 0) === 1);
setFormPruneTargets(
task.prune_targets ? JSON.parse(task.prune_targets) : ['containers', 'images', 'networks', 'volumes']
);
@@ -245,6 +253,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
action: actionOption.backendAction ?? formAction,
cron_expression: formCron,
enabled: formEnabled,
delete_after_run: formDeleteAfterRun,
};
if (actionOption.targetType === 'stack') {
@@ -793,6 +802,19 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="task-enabled" />
<Label htmlFor="task-enabled">Enabled</Label>
</div>
<label className="flex items-start gap-2 cursor-pointer">
<Checkbox
id="task-delete-after-run"
checked={formDeleteAfterRun}
onCheckedChange={(checked) => setFormDeleteAfterRun(checked === true)}
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>
</div>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>