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
@@ -243,3 +243,80 @@ describe('GET /api/scheduled-tasks/:id/runs', () => {
expect(res.body).toHaveProperty('runs');
});
});
describe('POST /api/scheduled-tasks - new lifecycle actions', () => {
const stackPayload = (action: string) => ({
name: `test-${action}`,
target_type: 'stack',
target_id: 'my-stack',
node_id: 1,
action,
cron_expression: '0 3 * * *',
enabled: true,
});
for (const action of ['auto_backup', 'auto_stop', 'auto_down', 'auto_start']) {
it(`creates ${action} task successfully (Admiral)`, async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.set('Cookie', adminCookie)
.send(stackPayload(action));
expect(res.status).toBe(201);
expect(res.body.action).toBe(action);
expect(res.body.target_type).toBe('stack');
});
it(`rejects ${action} with target_type "system"`, async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.set('Cookie', adminCookie)
.send({ ...stackPayload(action), target_type: 'system', target_id: null });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/target_type "stack"/);
});
}
it('persists delete_after_run flag', async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.set('Cookie', adminCookie)
.send({ ...stackPayload('auto_backup'), delete_after_run: true });
expect(res.status).toBe(201);
expect(res.body.delete_after_run).toBe(1);
});
it('defaults delete_after_run to 0 when not provided', async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.set('Cookie', adminCookie)
.send(stackPayload('auto_stop'));
expect(res.status).toBe(201);
expect(res.body.delete_after_run).toBe(0);
});
});
describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
it('can toggle delete_after_run via update', async () => {
const now = Date.now();
const id = DatabaseService.getInstance().createScheduledTask({
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
});
const res = await request(app)
.put(`/api/scheduled-tasks/${id}`)
.set('Cookie', adminCookie)
.send({ delete_after_run: true });
expect(res.status).toBe(200);
expect(res.body.delete_after_run).toBe(1);
const res2 = await request(app)
.put(`/api/scheduled-tasks/${id}`)
.set('Cookie', adminCookie)
.send({ delete_after_run: false });
expect(res2.status).toBe(200);
expect(res2.body.delete_after_run).toBe(0);
});
});
@@ -3,6 +3,7 @@
* license gating, cron parsing, and error handling.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { ScheduledTask } from '../services/DatabaseService';
// ── Hoisted mocks ──────────────────────────────────────────────────────
@@ -21,6 +22,10 @@ const {
mockIsTrivyAvailable,
mockScanAllNodeImages,
mockGetStackAutoUpdateSettingsForNode,
mockDeleteScheduledTask,
mockRunCommand,
mockDeployStack,
mockBackupStackFiles,
} = vi.hoisted(() => ({
mockGetDueScheduledTasks: vi.fn().mockReturnValue([]),
mockCreateScheduledTaskRun: vi.fn().mockReturnValue(1),
@@ -56,6 +61,10 @@ const {
violations: [],
}),
mockGetStackAutoUpdateSettingsForNode: vi.fn().mockReturnValue({}),
mockDeleteScheduledTask: vi.fn(),
mockRunCommand: vi.fn().mockResolvedValue(undefined),
mockDeployStack: vi.fn().mockResolvedValue(undefined),
mockBackupStackFiles: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../services/DatabaseService', () => ({
@@ -75,6 +84,7 @@ vi.mock('../services/DatabaseService', () => ({
markStaleRunsAsFailed: mockMarkStaleRunsAsFailed,
deleteOldScans: mockDeleteOldScans,
getStackAutoUpdateSettingsForNode: mockGetStackAutoUpdateSettingsForNode,
deleteScheduledTask: mockDeleteScheduledTask,
}),
},
}));
@@ -102,6 +112,8 @@ vi.mock('../services/ComposeService', () => ({
ComposeService: {
getInstance: () => ({
updateStack: mockUpdateStack,
runCommand: mockRunCommand,
deployStack: mockDeployStack,
}),
},
}));
@@ -112,6 +124,7 @@ vi.mock('../services/FileSystemService', () => ({
getStacks: mockGetStacks,
getStackContent: mockGetStackContent,
getEnvContent: mockGetEnvContent,
backupStackFiles: mockBackupStackFiles,
}),
},
}));
@@ -1357,3 +1370,112 @@ describe('SchedulerService - executeUpdateRemote', () => {
);
});
});
// ── Lifecycle actions (auto_backup, auto_stop, auto_down, auto_start) ───
function makeLifecycleTask(action: ScheduledTask['action'], overrides: Partial<ScheduledTask> = {}): ScheduledTask {
return {
id: 300,
name: `lifecycle-${action}`,
action,
target_type: 'stack',
target_id: 'my-stack',
node_id: 1,
cron_expression: '0 2 * * *',
enabled: 1,
created_by: 'admin',
created_at: 0,
updated_at: 0,
last_run_at: null,
next_run_at: null,
last_status: null,
last_error: null,
prune_targets: null,
target_services: null,
prune_label_filter: null,
delete_after_run: 0,
...overrides,
};
}
describe('SchedulerService - lifecycle actions', () => {
it('auto_stop calls runCommand with "stop"', async () => {
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_stop'));
await SchedulerService.getInstance().triggerTask(300);
expect(mockRunCommand).toHaveBeenCalledWith('my-stack', 'stop');
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'success' }));
});
it('auto_down calls runCommand with "down"', async () => {
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_down'));
await SchedulerService.getInstance().triggerTask(300);
expect(mockRunCommand).toHaveBeenCalledWith('my-stack', 'down');
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'success' }));
});
it('auto_start calls deployStack', async () => {
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_start'));
await SchedulerService.getInstance().triggerTask(300);
expect(mockDeployStack).toHaveBeenCalledWith('my-stack');
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'success' }));
});
it('auto_backup calls backupStackFiles', async () => {
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_backup'));
await SchedulerService.getInstance().triggerTask(300);
expect(mockBackupStackFiles).toHaveBeenCalledWith('my-stack');
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'success' }));
});
it('auto_stop records failure when target_id is missing', async () => {
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_stop', { target_id: null }));
await SchedulerService.getInstance().triggerTask(300);
expect(mockRunCommand).not.toHaveBeenCalled();
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'failure' }));
});
it('auto_backup records failure when node_id is missing', async () => {
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_backup', { node_id: null }));
await SchedulerService.getInstance().triggerTask(300);
expect(mockBackupStackFiles).not.toHaveBeenCalled();
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'failure' }));
});
it('non-admiral paid tier skips lifecycle actions', async () => {
mockGetTier.mockReturnValue('paid');
mockGetVariant.mockReturnValue('standard');
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_stop'));
mockGetDueScheduledTasks.mockReturnValue([makeLifecycleTask('auto_stop')]);
const svc = SchedulerService.getInstance();
await (svc as any).tick();
expect(mockRunCommand).not.toHaveBeenCalled();
});
});
// ── delete_after_run ────────────────────────────────────────────────────
describe('SchedulerService - delete_after_run', () => {
it('deletes task after successful run when delete_after_run is 1', async () => {
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_backup', { delete_after_run: 1 }));
await SchedulerService.getInstance().triggerTask(300);
expect(mockDeleteScheduledTask).toHaveBeenCalledWith(300);
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'success' }));
});
it('does not delete task when run fails even if delete_after_run is 1', async () => {
mockBackupStackFiles.mockRejectedValueOnce(new Error('disk full'));
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_backup', { delete_after_run: 1 }));
await SchedulerService.getInstance().triggerTask(300);
expect(mockDeleteScheduledTask).not.toHaveBeenCalled();
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'failure' }));
});
it('does not delete task when delete_after_run is 0', async () => {
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_backup', { delete_after_run: 0 }));
await SchedulerService.getInstance().triggerTask(300);
expect(mockDeleteScheduledTask).not.toHaveBeenCalled();
expect(mockUpdateScheduledTask).toHaveBeenCalledWith(300, expect.objectContaining({ last_status: 'success' }));
});
});
+11 -4
View File
@@ -8,13 +8,15 @@ import { escapeCsvField } from '../utils/csv';
import { getErrorMessage } from '../utils/errors';
const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
const VALID_ACTIONS = ['restart', 'snapshot', 'prune', 'update', 'scan'] as const;
const VALID_ACTIONS = ['restart', 'snapshot', 'prune', 'update', 'scan', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start'] as const;
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.';
type TargetType = typeof VALID_TARGET_TYPES[number];
type ScheduledAction = typeof VALID_ACTIONS[number];
const STACK_ONLY_ACTIONS = new Set<ScheduledAction>(['auto_backup', 'auto_stop', 'auto_down', 'auto_start']);
function parseTaskId(req: Request, res: Response): number | null {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) {
@@ -35,6 +37,9 @@ function validateActionTarget(action: ScheduledAction, targetType: TargetType):
if (action === 'snapshot' && targetType !== 'fleet') return 'Snapshot action requires target_type "fleet".';
if (action === 'prune' && targetType !== 'system') return 'Prune action requires target_type "system".';
if (action === 'scan' && targetType !== 'system') return 'Scan action requires target_type "system".';
if (STACK_ONLY_ACTIONS.has(action) && targetType !== 'stack') {
return `${action} action requires target_type "stack".`;
}
return null;
}
@@ -113,7 +118,7 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body;
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run } = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
res.status(400).json({ error: 'Name is required' }); return;
@@ -122,7 +127,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return;
}
if (!(VALID_ACTIONS as readonly string[]).includes(action)) {
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, or scan.' }); return;
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, scan, auto_backup, auto_stop, auto_down, or auto_start.' }); return;
}
if (!requireScheduledTaskTier(action, req, res)) return;
@@ -169,6 +174,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
prune_targets: prune_targets ? JSON.stringify(prune_targets) : null,
target_services: target_services ? JSON.stringify(target_services) : null,
prune_label_filter: prune_label_filter ? prune_label_filter.trim() : null,
delete_after_run: delete_after_run ? 1 : 0,
});
console.log(`[ScheduledTasks] Created task id=${id} action=${action} target=${target_id || 'none'}`);
@@ -208,7 +214,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(existing.action, req, res)) return;
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body;
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run } = req.body;
if (target_type && !(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) {
res.status(400).json({ error: 'Invalid target_type' }); return;
@@ -256,6 +262,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
if (prune_targets !== undefined) updates.prune_targets = prune_targets ? JSON.stringify(prune_targets) : null;
if (target_services !== undefined) updates.target_services = target_services ? JSON.stringify(target_services) : null;
if (prune_label_filter !== undefined) updates.prune_label_filter = prune_label_filter ? prune_label_filter.trim() : null;
if (delete_after_run !== undefined) updates.delete_after_run = delete_after_run ? 1 : 0;
const finalCron = cron_expression || existing.cron_expression;
const finalEnabled = enabled !== undefined ? enabled : existing.enabled;
+6 -3
View File
@@ -264,7 +264,7 @@ export interface ScheduledTask {
target_type: 'stack' | 'fleet' | 'system';
target_id: string | null;
node_id: number | null;
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan';
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan' | 'auto_backup' | 'auto_stop' | 'auto_down' | 'auto_start';
cron_expression: string;
enabled: number;
created_by: string;
@@ -277,6 +277,7 @@ export interface ScheduledTask {
prune_targets: string | null;
target_services: string | null;
prune_label_filter: string | null;
delete_after_run?: number;
}
export interface ScheduledTaskRun {
@@ -949,6 +950,7 @@ export class DatabaseService {
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'target_services', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'prune_label_filter', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'delete_after_run', 'INTEGER DEFAULT 0');
// Recreate stack_update_status with composite PK (node_id, stack_name).
// Original table had stack_name as sole PK which breaks when multiple nodes share stack names.
@@ -2471,13 +2473,13 @@ export class DatabaseService {
public createScheduledTask(task: Omit<ScheduledTask, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, delete_after_run) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
task.name, task.target_type, task.target_id, task.node_id,
task.action, task.cron_expression, task.enabled, task.created_by,
task.created_at, task.updated_at, task.last_run_at, task.next_run_at,
task.last_status, task.last_error, task.prune_targets, task.target_services,
task.prune_label_filter
task.prune_label_filter, task.delete_after_run ?? 0
);
return result.lastInsertRowid as number;
}
@@ -2494,6 +2496,7 @@ export class DatabaseService {
last_status: updates.last_status, last_error: updates.last_error,
prune_targets: updates.prune_targets, target_services: updates.target_services,
prune_label_filter: updates.prune_label_filter,
delete_after_run: updates.delete_after_run,
};
for (const [col, val] of Object.entries(map)) {
+56 -6
View File
@@ -289,10 +289,35 @@ export class SchedulerService {
scanFailedCount = result.failed;
break;
}
case 'auto_backup':
output = await this.executeAutoBackup(task);
break;
case 'auto_stop':
output = await this.executeAutoStop(task);
break;
case 'auto_down':
output = await this.executeAutoDown(task);
break;
case 'auto_start':
output = await this.executeAutoStart(task);
break;
}
if (isDebugEnabled()) console.log(`[SchedulerService:debug] Task ${task.id} action completed in ${Date.now() - actionStart}ms`);
db.updateScheduledTaskRun(runId, {
completed_at: Date.now(),
status: 'success',
output,
});
console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) completed successfully`);
if (task.delete_after_run === 1) {
console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) self-deleting after successful one-shot run`);
db.deleteScheduledTask(task.id);
return;
}
const nextRun = this.calculateNextRun(task.cron_expression);
db.updateScheduledTask(task.id, {
last_run_at: Date.now(),
@@ -301,12 +326,7 @@ export class SchedulerService {
last_error: null,
updated_at: Date.now(),
});
db.updateScheduledTaskRun(runId, {
completed_at: Date.now(),
status: 'success',
output,
});
console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) completed successfully`);
if (task.action === 'scan') {
const scanLevel: 'info' | 'warning' = scanFailedCount > 0 ? 'warning' : 'info';
if (isDebugEnabled()) {
@@ -392,6 +412,36 @@ export class SchedulerService {
return `Restarted ${filtered.length} container(s) in stack "${task.target_id}"${servicesSuffix}`;
}
private assertStackTarget(task: ScheduledTask, label: string): asserts task is ScheduledTask & { target_id: string; node_id: number } {
if (!task.target_id || task.node_id == null) {
throw new Error(`${label} requires target_id and node_id`);
}
}
private async executeAutoBackup(task: ScheduledTask): Promise<string> {
this.assertStackTarget(task, 'Auto-backup');
await FileSystemService.getInstance(task.node_id).backupStackFiles(task.target_id);
return `Backed up stack "${task.target_id}" files`;
}
private async executeAutoStop(task: ScheduledTask): Promise<string> {
this.assertStackTarget(task, 'Auto-stop');
await ComposeService.getInstance(task.node_id).runCommand(task.target_id, 'stop');
return `Stopped stack "${task.target_id}" (containers preserved)`;
}
private async executeAutoDown(task: ScheduledTask): Promise<string> {
this.assertStackTarget(task, 'Auto-down');
await ComposeService.getInstance(task.node_id).runCommand(task.target_id, 'down');
return `Took down stack "${task.target_id}" (containers removed)`;
}
private async executeAutoStart(task: ScheduledTask): Promise<string> {
this.assertStackTarget(task, 'Auto-start');
await ComposeService.getInstance(task.node_id).deployStack(task.target_id);
return `Started stack "${task.target_id}"`;
}
private async executeSnapshot(task: ScheduledTask): Promise<string> {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
+45 -4
View File
@@ -1,6 +1,6 @@
---
title: Scheduled Operations
description: Automate recurring Docker operations like stack restarts, fleet snapshots, and system prunes on a cron schedule.
description: Automate recurring Docker operations like stack restarts, lifecycle management, fleet snapshots, and system prunes on a cron schedule.
---
<Note>
@@ -11,7 +11,7 @@ description: Automate recurring Docker operations like stack restarts, fleet sna
Scheduled Operations lets you automate recurring maintenance tasks across your infrastructure. Define a cron schedule, choose an action, and Sencho handles the rest, including a full execution history log so you always know what ran and when.
The view opens on a **Timeline** that plots the next 24 hours of scheduled work across four lanes (Restart, Update, Scan, Prune) so you can see, at a glance, what is about to fire and when. Toggle to **All tasks** for the full CRUD table.
The view opens on a **Timeline** that plots the next 24 hours of scheduled work across five lanes (Restart, Update, Scan, Prune, Lifecycle) so you can see, at a glance, what is about to fire and when. Toggle to **All tasks** for the full CRUD table.
<Frame>
<img src="/images/scheduled-operations/timeline.png" alt="Schedules timeline showing the next 24 hours across four lanes with a cyan now rail" />
@@ -22,7 +22,7 @@ The view opens on a **Timeline** that plots the next 24 hours of scheduled work
The timeline is the default view. It shows:
- A hero with the current 24-hour window as a date range, and the **next firing** on the right (time, task name, and relative countdown).
- Four color-coded lanes: **Restart** (cyan), **Update** (green), **Scan** (purple), and **Prune** (amber). Snapshot tasks share the Prune lane.
- Five color-coded lanes: **Restart** (cyan), **Update** (green), **Scan** (purple), **Prune** (amber), and **Lifecycle** (blue). Snapshot tasks share the Prune lane; stop, down, start, and backup tasks share the Lifecycle lane.
- One pill per firing within the window, positioned proportionally to the task's next run time. Click any pill to open that task's execution history.
- A vertical cyan **now rail** at the left edge, and six mono time ticks along the bottom axis.
@@ -39,6 +39,10 @@ Toggle to **All tasks** from the header to see every schedule in a table, regard
| **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files |
| **System Prune** | The default node | Prunes selected resources, optionally filtered by Docker label |
| **Vulnerability Scan** | All images on a specific node | Runs Trivy against every image on the target node and records the results. Requires Trivy to be installed, see [Installing Trivy](/operations/trivy-setup). Available on Skipper and Admiral. |
| **Backup Stack Files** | A specific stack on a specific node | Backs up the stack's compose file and `.env` to `<DATA_DIR>/backups/<stackName>/`. The most recent backup per stack is kept; each run overwrites the previous one. |
| **Stop Stack** | A specific stack on a specific node | Runs `docker compose stop` — containers are stopped but preserved. Use this for off-hours power saving when you want a fast start later. |
| **Take Stack Down** | A specific stack on a specific node | Runs `docker compose down` — containers are removed. Use this to fully release resources when the stack is not needed for an extended period. |
| **Start Stack** | A specific stack on a specific node | Runs `docker compose up -d`. Works whether the stack was stopped or taken down: if containers exist they are started; if not, they are created and started from the compose file. |
## Creating a Scheduled Task
@@ -46,7 +50,7 @@ Toggle to **All tasks** from the header to see every schedule in a table, regard
2. Click **New Schedule**.
3. Fill in the form:
- **Name**: A descriptive label (e.g. "Nightly staging restart").
- **Action**: Choose Restart Stack, Auto-update Stack, Fleet Snapshot, System Prune, or Vulnerability Scan. The form fields below change based on your selection.
- **Action**: Choose from the full action list. The form fields below change based on your selection.
- **Node**: (Restart Stack, Auto-update Stack, and Vulnerability Scan) Select the node to run against. For stack actions it determines where the target stack lives; for Vulnerability Scan it determines which node's images are scanned.
- **Stack**: (Restart Stack and Auto-update Stack) Select the stack to target. Becomes available after choosing a node.
- **Services**: (Restart Stack only) Optionally select specific services within the stack to restart. Leave empty to restart all services.
@@ -54,6 +58,7 @@ Toggle to **All tasks** from the header to see every schedule in a table, regard
- **Label Filter**: (System Prune only) Optionally filter prune operations to resources matching a specific Docker label (e.g. `com.docker.compose.project=mystack`).
- **Cron Expression**: Standard 5-field cron format. A human-readable preview appears below the input.
- **Enabled**: Toggle the task on or off.
- **Delete after successful run**: When checked, the task automatically removes itself after its first successful execution. This turns the task into a one-time operation. Failures keep the task so you can inspect the error, adjust the target if needed, and trigger a retry with **Run Now** or by re-enabling the schedule.
4. Click **Create**.
<Frame>
@@ -101,6 +106,30 @@ Enter a label in `key=value` format (e.g. `com.docker.compose.project=mystack`).
<img src="/images/scheduled-operations/prune-label-filter.png" alt="Label filter input for scoping prune operations to specific Docker labels" />
</Frame>
### Stack Lifecycle Scheduling
The Stop Stack, Take Stack Down, Start Stack, and Backup Stack Files actions let you schedule lifecycle events for individual stacks on a per-node basis.
A common pattern for development or staging stacks is:
- A **Stop Stack** or **Take Stack Down** task scheduled for the end of the working day (e.g. `0 19 * * 1-5` — 7 PM on weekdays).
- A **Start Stack** task scheduled for the start of the working day (e.g. `0 8 * * 1-5` — 8 AM on weekdays).
**Stop vs Down:** Use Stop Stack when you want containers ready to resume quickly; Docker keeps the container filesystem in place and `Start Stack` simply restarts the existing containers. Use Take Stack Down when you want to fully release resources; `Start Stack` recreates the containers from the compose file on the next run.
**Backup Stack Files** keeps only the most recent backup per stack under `<DATA_DIR>/backups/<stackName>/`. For a point-in-time archive across all nodes, use a [Fleet Snapshot](/features/fleet-backups) schedule instead.
All four actions run against the local Sencho instance only. To schedule lifecycle operations on a remote node, manage the schedule from within that node's own Sencho UI.
### Delete after Successful Run
When **Delete after successful run** is enabled, the task removes itself from the schedule after its first successful execution. The entire task record, including run history, is removed. Use this for one-time preparatory or clean-up tasks: pre-scaling a stack before a deployment, a one-off backup before a config change, or stopping a stack once a migration is complete.
If the run fails, the task stays in the schedule unchanged so you can inspect the error and retry. Disabling the option at any time before the successful run prevents auto-deletion.
<Note>
Manual runs via **Run Now** also trigger the delete-after-run logic. If you run the task manually and it succeeds, the task removes itself.
</Note>
## Cron Expression Reference
Sencho uses standard 5-field cron expressions:
@@ -219,3 +248,15 @@ The scan notification shares the same delivery path as every other Sencho alert.
### Scan task fails with "Trivy binary is not available"
Trivy must be installed on the node that runs the scan, not just on the primary instance. Follow [Installing Trivy](/operations/trivy-setup) on the target node, then trigger **Run Now** from the schedule to confirm the scan succeeds before waiting for the next cron tick.
### Start Stack fails with a compose file error
The Start Stack action runs `docker compose up -d` against the stack's compose directory. If the stack folder has been removed or its compose file is missing, the run fails. Re-create or restore the stack in the [Editor](/features/editor), then trigger **Run Now** to confirm the start succeeds before the next cron tick.
### Auto-backup always overwrites the previous backup
Backup Stack Files keeps a single backup slot per stack under `<DATA_DIR>/backups/<stackName>/`. Each run overwrites the previous backup. This is by design for simplicity. If you need a timestamped archive of compose files across all nodes, use a [Fleet Snapshot](/features/fleet-backups) schedule, which stores versioned snapshots.
### One-shot task disappeared after a successful run
A task with **Delete after successful run** enabled removes itself automatically after the first successful execution, including its run history. This is expected behavior. If you need a record of the run before deletion, export the execution history to CSV via the clock icon before the next successful run.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 79 KiB

@@ -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>
+2 -1
View File
@@ -4,7 +4,7 @@ export interface ScheduledTask {
target_type: 'stack' | 'fleet' | 'system';
target_id: string | null;
node_id: number | null;
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan';
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan' | 'auto_backup' | 'auto_stop' | 'auto_down' | 'auto_start';
cron_expression: string;
enabled: number;
created_by: string;
@@ -17,6 +17,7 @@ export interface ScheduledTask {
prune_targets: string | null;
target_services: string | null;
prune_label_filter: string | null;
delete_after_run?: number;
next_runs?: number[];
}