diff --git a/CHANGELOG.md b/CHANGELOG.md index e272fe51..baeb27d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added +### Fixed -* **scheduled-operations:** Team Pro feature for scheduling recurring Docker operations (stack restarts, fleet snapshots, system prunes) via cron expressions with full execution history logging. Includes new SchedulerService, CRUD API endpoints, and a dedicated UI section. +* **scheduled-ops:** fix "Run Now" audit log entry incorrectly showing "Created scheduled task" instead of "Triggered scheduled task" +* **scheduled-ops:** add `triggered_by` attribution to run records distinguishing scheduler vs manual executions +* **scheduled-ops:** add pagination to execution history view +* **scheduled-ops:** make system prune targets configurable (containers, images, networks, volumes) + +### Docs + +* **scheduled-ops:** add screenshots and document Run Now behavior for disabled tasks ## [0.14.2](https://github.com/AnsoCode/Sencho/compare/v0.14.1...v0.14.2) (2026-03-29) diff --git a/backend/src/index.ts b/backend/src/index.ts index c719a299..0ca8d8e7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -686,6 +686,7 @@ const AUDIT_ROUTE_SUMMARIES: Record = { 'DELETE /sso/config': 'Deleted SSO configuration', 'POST /api-tokens': 'Created API token', 'DELETE /api-tokens': 'Revoked API token', + 'POST /scheduled-tasks/*/run': 'Triggered scheduled task', 'POST /scheduled-tasks': 'Created scheduled task', 'PUT /scheduled-tasks': 'Updated scheduled task', 'DELETE /scheduled-tasks': 'Deleted scheduled task', @@ -693,15 +694,45 @@ const AUDIT_ROUTE_SUMMARIES: Record = { }; function getAuditSummary(method: string, apiPath: string): string { - // Try exact prefix matches from most specific to least const normalized = apiPath.replace(/^\//, ''); - for (const [pattern, summary] of Object.entries(AUDIT_ROUTE_SUMMARIES)) { - const [pMethod, pPath] = pattern.split(' '); - if (method === pMethod && normalized.startsWith(pPath.replace(/^\//, ''))) { - // Extract resource name from path if available (e.g., /stacks/myapp → "myapp") - const rest = normalized.slice(pPath.replace(/^\//, '').length).replace(/^\//, ''); - const resourceName = rest.split('/')[0]; - return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary; + const normalizedSegments = normalized.split('/'); + + // Sort patterns by segment count descending (most specific first) + const sortedEntries = Object.entries(AUDIT_ROUTE_SUMMARIES) + .sort((a, b) => b[0].split('/').length - a[0].split('/').length); + + for (const [pattern, summary] of sortedEntries) { + const spaceIdx = pattern.indexOf(' '); + const pMethod = pattern.slice(0, spaceIdx); + const pPath = pattern.slice(spaceIdx + 1).replace(/^\//, ''); + if (method !== pMethod) continue; + + const patternSegments = pPath.split('/'); + const hasWildcard = patternSegments.includes('*'); + + if (hasWildcard) { + // Wildcard matching: exact segment count, '*' matches any single segment + if (patternSegments.length > normalizedSegments.length) continue; + let match = true; + let resourceName = ''; + for (let i = 0; i < patternSegments.length; i++) { + if (patternSegments[i] === '*') { + resourceName = resourceName || normalizedSegments[i]; + } else if (patternSegments[i] !== normalizedSegments[i]) { + match = false; + break; + } + } + if (match) { + return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary; + } + } else { + // Prefix matching (original behavior) + if (normalized.startsWith(pPath)) { + const rest = normalized.slice(pPath.length).replace(/^\//, ''); + const resourceName = rest.split('/')[0]; + return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary; + } } } return `${method} /api/${normalized}`; @@ -3388,7 +3419,7 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requireTeamPro(req, res)) return; try { - const { name, target_type, target_id, node_id, action, cron_expression, enabled } = req.body; + const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets } = req.body; if (!name || typeof name !== 'string') { res.status(400).json({ error: 'Name is required' }); return; @@ -3412,6 +3443,13 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { if (target_type === 'stack' && (!target_id || !node_id)) { res.status(400).json({ error: 'Stack operations require target_id and node_id.' }); return; } + // Validate prune targets + const validPruneTargets = ['containers', 'images', 'networks', 'volumes']; + if (prune_targets !== undefined && prune_targets !== null) { + if (!Array.isArray(prune_targets) || prune_targets.length === 0 || !prune_targets.every((t: string) => validPruneTargets.includes(t))) { + res.status(400).json({ error: 'prune_targets must be a non-empty array of: containers, images, networks, volumes' }); return; + } + } // Validate cron expression try { CronExpressionParser.parse(cron_expression); } catch { res.status(400).json({ error: 'Invalid cron expression.' }); return; @@ -3436,6 +3474,7 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { next_run_at: nextRun, last_status: null, last_error: null, + prune_targets: prune_targets ? JSON.stringify(prune_targets) : null, }); const task = DatabaseService.getInstance().getScheduledTask(id); @@ -3472,7 +3511,7 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - const { name, target_type, target_id, node_id, action, cron_expression, enabled } = req.body; + const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets } = req.body; if (target_type && !['stack', 'fleet', 'system'].includes(target_type)) { res.status(400).json({ error: 'Invalid target_type' }); return; @@ -3493,6 +3532,14 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { res.status(400).json({ error: 'Prune action requires target_type "system".' }); return; } + // Validate prune targets + const validPruneTargets = ['containers', 'images', 'networks', 'volumes']; + if (prune_targets !== undefined && prune_targets !== null) { + if (!Array.isArray(prune_targets) || prune_targets.length === 0 || !prune_targets.every((t: string) => validPruneTargets.includes(t))) { + res.status(400).json({ error: 'prune_targets must be a non-empty array of: containers, images, networks, volumes' }); return; + } + } + if (cron_expression) { try { CronExpressionParser.parse(cron_expression); } catch { res.status(400).json({ error: 'Invalid cron expression.' }); return; @@ -3507,6 +3554,7 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { if (action !== undefined) updates.action = action; if (cron_expression !== undefined) updates.cron_expression = cron_expression; if (enabled !== undefined) updates.enabled = enabled ? 1 : 0; + if (prune_targets !== undefined) updates.prune_targets = prune_targets ? JSON.stringify(prune_targets) : null; // Recalculate next_run if cron changed or if enabling const finalCron = cron_expression || existing.cron_expression; @@ -3606,9 +3654,10 @@ app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void => const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100); - const runs = db.getScheduledTaskRuns(id, limit); - res.json(runs); + const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); + const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0); + const result = db.getScheduledTaskRuns(id, limit, offset); + res.json(result); } catch (error) { console.error('[ScheduledTasks] Runs error:', error); res.status(500).json({ error: 'Failed to fetch task runs' }); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 6f361767..0d858681 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -153,6 +153,7 @@ export interface ScheduledTask { next_run_at: number | null; last_status: string | null; last_error: string | null; + prune_targets: string | null; } export interface ScheduledTaskRun { @@ -163,6 +164,7 @@ export interface ScheduledTaskRun { status: 'running' | 'success' | 'failure'; output: string | null; error: string | null; + triggered_by: 'scheduler' | 'manual'; } export class DatabaseService { @@ -395,6 +397,10 @@ export class DatabaseService { maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''"); maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''"); + // Scheduled operations migrations + maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'"); + maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL'); + // Drop legacy SSH/TLS columns from pre-0.7 databases (no longer read or written) const legacyCols = ['host', 'port', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', 'tls_ca', 'tls_cert', 'tls_key']; for (const col of legacyCols) { @@ -1133,16 +1139,20 @@ export class DatabaseService { ).all(now) as ScheduledTask[]; } - public getScheduledTaskRuns(taskId: number, limit = 50): ScheduledTaskRun[] { - return this.db.prepare( - 'SELECT * FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC LIMIT ?' - ).all(taskId, limit) as ScheduledTaskRun[]; + public getScheduledTaskRuns(taskId: number, limit = 20, offset = 0): { runs: ScheduledTaskRun[]; total: number } { + const runs = this.db.prepare( + 'SELECT * FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC LIMIT ? OFFSET ?' + ).all(taskId, limit, offset) as ScheduledTaskRun[]; + const { total } = this.db.prepare( + 'SELECT COUNT(*) as total FROM scheduled_task_runs WHERE task_id = ?' + ).get(taskId) as { total: number }; + return { runs, total }; } public createScheduledTaskRun(run: Omit): number { const result = this.db.prepare( - 'INSERT INTO scheduled_task_runs (task_id, started_at, completed_at, status, output, error) VALUES (?, ?, ?, ?, ?, ?)' - ).run(run.task_id, run.started_at, run.completed_at, run.status, run.output, run.error); + 'INSERT INTO scheduled_task_runs (task_id, started_at, completed_at, status, output, error, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(run.task_id, run.started_at, run.completed_at, run.status, run.output, run.error, run.triggered_by); return result.lastInsertRowid as number; } diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 1c8e71fe..2c9e04d7 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -67,6 +67,8 @@ export class SchedulerService { } } + // Intentionally allows triggering disabled tasks — useful for testing before enabling a schedule. + // Manual triggers are attributed as 'manual' in the run record (see triggered_by column). public async triggerTask(taskId: number): Promise { const db = DatabaseService.getInstance(); const task = db.getScheduledTask(taskId); @@ -74,13 +76,13 @@ export class SchedulerService { if (this.runningTasks.has(task.id)) throw new Error('Task is already running'); this.runningTasks.add(task.id); try { - await this.executeTask(task); + await this.executeTask(task, 'manual'); } finally { this.runningTasks.delete(task.id); } } - private async executeTask(task: ScheduledTask): Promise { + private async executeTask(task: ScheduledTask, triggeredBy: 'scheduler' | 'manual' = 'scheduler'): Promise { const db = DatabaseService.getInstance(); const runId = db.createScheduledTaskRun({ task_id: task.id, @@ -89,6 +91,7 @@ export class SchedulerService { status: 'running', output: null, error: null, + triggered_by: triggeredBy, }); try { @@ -297,7 +300,11 @@ export class SchedulerService { private async executePrune(task: ScheduledTask): Promise { const nodeId = task.node_id ?? NodeRegistry.getInstance().getDefaultNodeId(); const docker = DockerController.getInstance(nodeId); - const targets = ['containers', 'images', 'networks', 'volumes'] as const; + const allTargets = ['containers', 'images', 'networks', 'volumes'] as const; + type PruneTarget = typeof allTargets[number]; + const targets: PruneTarget[] = task.prune_targets + ? (JSON.parse(task.prune_targets) as string[]).filter((t): t is PruneTarget => allTargets.includes(t as PruneTarget)) + : [...allTargets]; const results: string[] = []; for (const target of targets) { diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 133e0b6d..24073f0d 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -12,13 +12,17 @@ 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. + + Scheduled operations list view showing tasks with status, schedule, and actions + + ## Supported Actions | Action | Target | Description | |--------|--------|-------------| | **Restart Stack** | A specific stack on a specific node | Restarts all containers in the stack via the Docker Engine API | | **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files | -| **System Prune** | A specific node (or the default node) | Prunes unused containers, images, networks, and volumes | +| **System Prune** | A specific node (or the default node) | Prunes selected resources (containers, images, networks, volumes — all by default) | ## Creating a Scheduled Task @@ -28,10 +32,15 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i - **Name** - a descriptive label (e.g. "Nightly staging restart"). - **Action** - choose Restart Stack, Fleet Snapshot, or System Prune. - **Stack / Node** - if you chose Restart Stack, select the target stack and the node it runs on. + - **Prune Targets** - if you chose System Prune, select which resources to prune (containers, images, networks, volumes). All are selected by default. - **Cron Expression** - standard 5-field cron format. A human-readable preview appears below the input. - **Enabled** - toggle the task on or off. 4. Click **Create**. + + Create scheduled task dialog with action, cron expression, and prune target options + + ## Cron Expression Reference Sencho uses standard 5-field cron expressions: @@ -59,6 +68,7 @@ Sencho uses standard 5-field cron expressions: ## Managing Tasks - **Enable/Disable** - Use the toggle switch in the task list to pause or resume a schedule without deleting it. +- **Run Now** - Click the play icon to immediately execute a task. This works even on disabled tasks, allowing you to test a schedule before enabling it. Manual runs are labeled "Manual" in the execution history. - **Edit** - Click the pencil icon to update the task name, schedule, or target. - **Delete** - Click the trash icon to permanently remove the task and all its execution history. @@ -67,12 +77,17 @@ Sencho uses standard 5-field cron expressions: Click the history icon on any task to view its execution log. Each entry shows: - **Timestamp** - when the task ran. +- **Source** - whether the run was triggered by the scheduler or manually via Run Now. - **Status** - success or failure. - **Duration** - how long the execution took. - **Details** - output message or error description. Execution history is retained for 30 days. + + Execution history showing run source, status, duration, and details with pagination + + ## How It Works The Scheduler Service runs in the background and checks for due tasks every 60 seconds. When a task's next run time has passed: diff --git a/docs/images/scheduled-operations/create-dialog.png b/docs/images/scheduled-operations/create-dialog.png new file mode 100644 index 00000000..d2ebd0a4 Binary files /dev/null and b/docs/images/scheduled-operations/create-dialog.png differ diff --git a/docs/images/scheduled-operations/overview.png b/docs/images/scheduled-operations/overview.png new file mode 100644 index 00000000..1e873291 Binary files /dev/null and b/docs/images/scheduled-operations/overview.png differ diff --git a/docs/images/scheduled-operations/run-history.png b/docs/images/scheduled-operations/run-history.png new file mode 100644 index 00000000..307e94a5 Binary files /dev/null and b/docs/images/scheduled-operations/run-history.png differ diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index d7a98229..1bdf5307 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -10,7 +10,8 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play } from 'lucide-react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight } from 'lucide-react'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import cronstrue from 'cronstrue'; @@ -31,6 +32,7 @@ interface ScheduledTask { next_run_at: number | null; last_status: string | null; last_error: string | null; + prune_targets: string | null; } interface TaskRun { @@ -41,6 +43,7 @@ interface TaskRun { status: 'running' | 'success' | 'failure'; output: string | null; error: string | null; + triggered_by: 'scheduler' | 'manual'; } interface NodeOption { @@ -84,8 +87,12 @@ export default function ScheduledOperationsView() { const [formNodeId, setFormNodeId] = useState(''); const [formCron, setFormCron] = useState('0 3 * * *'); const [formEnabled, setFormEnabled] = useState(true); + const [formPruneTargets, setFormPruneTargets] = useState(['containers', 'images', 'networks', 'volumes']); const [saving, setSaving] = useState(false); const [runningTaskId, setRunningTaskId] = useState(null); + const [runsPage, setRunsPage] = useState(1); + const [runsTotal, setRunsTotal] = useState(0); + const runsLimit = 20; // Available stacks and nodes for selection const [stacks, setStacks] = useState([]); @@ -142,6 +149,7 @@ export default function ScheduledOperationsView() { setFormNodeId(''); setFormCron('0 3 * * *'); setFormEnabled(true); + setFormPruneTargets(['containers', 'images', 'networks', 'volumes']); setDialogOpen(true); }; @@ -153,6 +161,9 @@ export default function ScheduledOperationsView() { setFormNodeId(task.node_id != null ? String(task.node_id) : ''); setFormCron(task.cron_expression); setFormEnabled(task.enabled === 1); + setFormPruneTargets( + task.prune_targets ? JSON.parse(task.prune_targets) : ['containers', 'images', 'networks', 'volumes'] + ); setDialogOpen(true); }; @@ -172,6 +183,9 @@ export default function ScheduledOperationsView() { body.target_id = formTargetId; body.node_id = formNodeId ? parseInt(formNodeId, 10) : null; } + if (formAction === 'prune' && formPruneTargets.length > 0) { + body.prune_targets = formPruneTargets; + } setSaving(true); try { @@ -226,13 +240,17 @@ export default function ScheduledOperationsView() { } }; - const openRuns = async (task: ScheduledTask) => { + const openRuns = async (task: ScheduledTask, page = 1) => { setRunsTask(task); + setRunsPage(page); setRunsLoading(true); + const offset = (page - 1) * runsLimit; try { - const res = await apiFetch(`/scheduled-tasks/${task.id}/runs?limit=50`, { localOnly: true }); + const res = await apiFetch(`/scheduled-tasks/${task.id}/runs?limit=${runsLimit}&offset=${offset}`, { localOnly: true }); if (res.ok) { - setRuns(await res.json()); + const data = await res.json(); + setRuns(data.runs); + setRunsTotal(data.total); } } catch { // Non-critical @@ -419,6 +437,27 @@ export default function ScheduledOperationsView() { )} + {formAction === 'prune' && ( +
+ +
+ {['containers', 'images', 'networks', 'volumes'].map(target => ( + + ))} +
+
+ )} +
- @@ -474,10 +513,12 @@ export default function ScheduledOperationsView() { ) : runs.length === 0 ? (
No executions yet.
) : ( + <> Time + Source Status Duration Details @@ -493,6 +534,11 @@ export default function ScheduledOperationsView() { {new Date(run.started_at).toLocaleString()} + + + {run.triggered_by === 'manual' ? 'Manual' : 'Scheduled'} + + {run.status === 'success' ? ( Success @@ -511,6 +557,22 @@ export default function ScheduledOperationsView() { })}
+ {Math.ceil(runsTotal / runsLimit) > 1 && runsTask && ( +
+

+ Page {runsPage} of {Math.ceil(runsTotal / runsLimit)} +

+
+ + +
+
+ )} + )}