diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a22085d..4187b518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* **scheduled-ops:** failure notifications — dispatches error alerts through configured notification channels (Discord, Slack, webhook) when a scheduled task fails, with info-level recovery notifications when a previously-failing task succeeds again +* **scheduled-ops:** per-service restart targeting — scheduled stack restarts can now target specific services rather than restarting the entire stack +* **scheduled-ops:** prune label filter — scheduled system prune operations can be scoped to resources matching a specific Docker label (e.g. `com.docker.compose.project=mystack`) +* **scheduled-ops:** CSV export for execution history — one-click download of the full run history from the execution history panel + +### Fixed + +* **scheduled-ops:** `prune_targets` field was silently dropped when creating scheduled tasks due to missing column in the INSERT statement + ### Security * **docker:** upgrade Docker Compose from v2.40.3 to v5.1.1 to remediate 5 Go stdlib/x/crypto CVEs (CVE-2025-68121, CVE-2025-61726, CVE-2025-61729, CVE-2026-25679, CVE-2025-47913). Compose v2.40.3 was compiled with Go 1.24.9 and x/crypto 0.38.0; v5.1.1 ships Go 1.25.8 and x/crypto 0.46.0. Docker CLI remains at v29.3.1 (already patched). CVE-2026-33186 (grpc ≥1.79.3) cannot be resolved until upstream Docker/Compose releases upgrade past grpc 1.78.0. diff --git a/backend/src/index.ts b/backend/src/index.ts index e7a7fbed..1da03b98 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2682,6 +2682,22 @@ app.get('/api/stacks/:stackName/containers', async (req: Request, res: Response) } }); +app.get('/api/stacks/:stackName/services', async (req: Request, res: Response) => { + try { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } + const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName); + const parsed = YAML.parse(content); + const services = parsed?.services ? Object.keys(parsed.services) : []; + res.json(services); + } catch (error) { + console.error('[Stacks] Failed to fetch services:', error); + res.status(500).json({ error: 'Failed to fetch services' }); + } +}); + app.get('/api/containers/:id/logs', async (req: Request, res: Response) => { try { const id = req.params.id as string; @@ -3691,7 +3707,7 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { - const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets } = req.body; + const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body; if (!name || typeof name !== 'string') { res.status(400).json({ error: 'Name is required' }); return; @@ -3722,6 +3738,24 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { res.status(400).json({ error: 'prune_targets must be a non-empty array of: containers, images, networks, volumes' }); return; } } + // Validate target_services + if (target_services !== undefined && target_services !== null) { + if (!Array.isArray(target_services) || target_services.length === 0 || !target_services.every((s: unknown) => typeof s === 'string' && s.length > 0)) { + res.status(400).json({ error: 'target_services must be a non-empty array of service name strings' }); return; + } + if (action !== 'restart' || target_type !== 'stack') { + res.status(400).json({ error: 'target_services can only be used with restart action on stack target' }); return; + } + } + // Validate prune_label_filter + if (prune_label_filter !== undefined && prune_label_filter !== null) { + if (typeof prune_label_filter !== 'string' || prune_label_filter.trim().length === 0) { + res.status(400).json({ error: 'prune_label_filter must be a non-empty string' }); return; + } + if (action !== 'prune') { + res.status(400).json({ error: 'prune_label_filter can only be used with prune action' }); return; + } + } // Validate cron expression try { CronExpressionParser.parse(cron_expression); } catch { res.status(400).json({ error: 'Invalid cron expression.' }); return; @@ -3747,6 +3781,8 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { last_status: null, last_error: null, 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, }); const task = DatabaseService.getInstance().getScheduledTask(id); @@ -3783,7 +3819,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, prune_targets } = req.body; + const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body; if (target_type && !['stack', 'fleet', 'system'].includes(target_type)) { res.status(400).json({ error: 'Invalid target_type' }); return; @@ -3811,6 +3847,24 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { res.status(400).json({ error: 'prune_targets must be a non-empty array of: containers, images, networks, volumes' }); return; } } + // Validate target_services + if (target_services !== undefined && target_services !== null) { + if (!Array.isArray(target_services) || target_services.length === 0 || !target_services.every((s: unknown) => typeof s === 'string' && s.length > 0)) { + res.status(400).json({ error: 'target_services must be a non-empty array of service name strings' }); return; + } + if (finalAction !== 'restart' || finalTargetType !== 'stack') { + res.status(400).json({ error: 'target_services can only be used with restart action on stack target' }); return; + } + } + // Validate prune_label_filter + if (prune_label_filter !== undefined && prune_label_filter !== null) { + if (typeof prune_label_filter !== 'string' || prune_label_filter.trim().length === 0) { + res.status(400).json({ error: 'prune_label_filter must be a non-empty string' }); return; + } + if (finalAction !== 'prune') { + res.status(400).json({ error: 'prune_label_filter can only be used with prune action' }); return; + } + } if (cron_expression) { try { CronExpressionParser.parse(cron_expression); } catch { @@ -3827,6 +3881,8 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { 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; + 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; // Recalculate next_run if cron changed or if enabling const finalCron = cron_expression || existing.cron_expression; @@ -3915,6 +3971,48 @@ app.post('/api/scheduled-tasks/:id/run', async (req: Request, res: Response): Pr } }); +app.get('/api/scheduled-tasks/:id/runs/export', (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requireAdmiral(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } + + const db = DatabaseService.getInstance(); + const task = db.getScheduledTask(id); + if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + + const runs = db.getAllScheduledTaskRuns(id); + + const escapeCsv = (val: string): string => { + if (val.includes(',') || val.includes('"') || val.includes('\n')) { + return `"${val.replace(/"/g, '""')}"`; + } + return val; + }; + + const lines = ['Timestamp,Source,Status,Duration (s),Details']; + for (const run of runs) { + const timestamp = new Date(run.started_at).toISOString(); + const source = run.triggered_by === 'manual' ? 'Manual' : 'Scheduled'; + const status = run.status.charAt(0).toUpperCase() + run.status.slice(1); + const duration = run.completed_at && run.started_at + ? ((run.completed_at - run.started_at) / 1000).toFixed(1) + : ''; + const details = run.error || run.output || ''; + lines.push(`${escapeCsv(timestamp)},${escapeCsv(source)},${escapeCsv(status)},${escapeCsv(duration)},${escapeCsv(details)}`); + } + + const safeName = task.name.replace(/[^a-zA-Z0-9_-]/g, '_'); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="task-${safeName}-history.csv"`); + res.send(lines.join('\n')); + } catch (error) { + console.error('[ScheduledTasks] Export error:', error); + res.status(500).json({ error: 'Failed to export task runs' }); + } +}); + app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 4860ceda..b7116827 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -166,6 +166,8 @@ export interface ScheduledTask { last_status: string | null; last_error: string | null; prune_targets: string | null; + target_services: string | null; + prune_label_filter: string | null; } export interface ScheduledTaskRun { @@ -428,6 +430,8 @@ export class DatabaseService { // Scheduled operations migrations maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'"); 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'); // 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']; @@ -1241,12 +1245,13 @@ export class DatabaseService { public createScheduledTask(task: Omit): 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) 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) 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.last_status, task.last_error, task.prune_targets, task.target_services, + task.prune_label_filter ); return result.lastInsertRowid as number; } @@ -1261,6 +1266,8 @@ export class DatabaseService { enabled: updates.enabled, created_by: updates.created_by, updated_at: updates.updated_at, last_run_at: updates.last_run_at, next_run_at: updates.next_run_at, 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, }; for (const [col, val] of Object.entries(map)) { @@ -1316,6 +1323,12 @@ export class DatabaseService { this.db.prepare(`UPDATE scheduled_task_runs SET ${fields.join(', ')} WHERE id = ?`).run(...values); } + public getAllScheduledTaskRuns(taskId: number): ScheduledTaskRun[] { + return this.db.prepare( + 'SELECT * FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC' + ).all(taskId) as ScheduledTaskRun[]; + } + public cleanupOldTaskRuns(retentionDays = 30): void { const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000); this.db.prepare('DELETE FROM scheduled_task_runs WHERE started_at < ?').run(cutoff); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 71a0ba94..a37bde89 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -103,22 +103,34 @@ class DockerController { }; } - public async pruneSystem(target: 'containers' | 'images' | 'networks' | 'volumes') { - let result: any = {}; + public async pruneSystem(target: 'containers' | 'images' | 'networks' | 'volumes', labelFilter?: string) { + let spaceReclaimed = 0; if (target === 'containers') { - result = await this.docker.pruneContainers(); + const filters: Record = {}; + if (labelFilter) filters.label = [labelFilter]; + const r = await this.docker.pruneContainers({ filters }); + spaceReclaimed = r.SpaceReclaimed || 0; } else if (target === 'images') { // Remove all unused images, not just dangling ones - result = await this.docker.pruneImages({ filters: { dangling: { 'false': true } } }); + const filters: Record> = { dangling: { 'false': true } }; + if (labelFilter) filters.label = [labelFilter]; + const r = await this.docker.pruneImages({ filters }); + spaceReclaimed = r.SpaceReclaimed || 0; } else if (target === 'networks') { - result = await this.docker.pruneNetworks(); + const filters: Record = {}; + if (labelFilter) filters.label = [labelFilter]; + const r = await this.docker.pruneNetworks({ filters }); + spaceReclaimed = (r as { SpaceReclaimed?: number }).SpaceReclaimed || 0; } else if (target === 'volumes') { - result = await this.docker.pruneVolumes({ filters: { all: ['true'] } }); + const filters: Record = { all: ['true'] }; + if (labelFilter) filters.label = [labelFilter]; + const r = await this.docker.pruneVolumes({ filters }); + spaceReclaimed = r.SpaceReclaimed || 0; } return { success: true, - reclaimedBytes: result?.SpaceReclaimed || 0 + reclaimedBytes: spaceReclaimed }; } @@ -346,6 +358,7 @@ class DockerController { interface ComposeContainer { ID?: string; Name?: string; + Service?: string; State?: string; Status?: string; Publishers?: { URL?: string, TargetPort?: number, PublishedPort?: number }[]; @@ -387,6 +400,7 @@ class DockerController { return { Id: c.ID || '', Names: ['/' + (c.Name || '')], // Add leading slash to match Dockerode format + Service: c.Service || '', State: c.State || 'unknown', Status: c.Status || '', Ports diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 2c9e04d7..52383ffe 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -5,6 +5,7 @@ import { LicenseService } from './LicenseService'; import DockerController from './DockerController'; import { FileSystemService } from './FileSystemService'; import { NodeRegistry } from './NodeRegistry'; +import { NotificationService } from './NotificationService'; export class SchedulerService { private static instance: SchedulerService; @@ -122,6 +123,12 @@ export class SchedulerService { output, }); console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) completed successfully`); + if (task.last_status === 'failure') { + NotificationService.getInstance().dispatchAlert( + 'info', + `Scheduled task "${task.name}" (${task.action}) recovered successfully` + ); + } } catch (error: unknown) { const errMsg = error instanceof Error ? error.message : String(error); let nextRun: number | null = null; @@ -143,6 +150,10 @@ export class SchedulerService { error: errMsg, }); console.error(`[SchedulerService] Task "${task.name}" (id=${task.id}) failed:`, errMsg); + NotificationService.getInstance().dispatchAlert( + 'error', + `Scheduled task "${task.name}" (${task.action}) failed: ${errMsg}` + ); } } @@ -155,8 +166,21 @@ export class SchedulerService { if (!containers || containers.length === 0) { throw new Error(`No containers found for stack "${task.target_id}"`); } - await Promise.all(containers.map(c => docker.restartContainer(c.Id))); - return `Restarted ${containers.length} container(s) in stack "${task.target_id}"`; + + let filtered = containers; + if (task.target_services) { + const serviceNames: string[] = JSON.parse(task.target_services); + filtered = containers.filter(c => c.Service && serviceNames.includes(c.Service)); + if (filtered.length === 0) { + throw new Error(`No containers found matching services [${serviceNames.join(', ')}] in stack "${task.target_id}"`); + } + } + + await Promise.all(filtered.map(c => docker.restartContainer(c.Id))); + const servicesSuffix = task.target_services + ? ` (services: ${(JSON.parse(task.target_services) as string[]).join(', ')})` + : ''; + return `Restarted ${filtered.length} container(s) in stack "${task.target_id}"${servicesSuffix}`; } private async executeSnapshot(task: ScheduledTask): Promise { @@ -305,11 +329,12 @@ export class SchedulerService { 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 labelFilter = task.prune_label_filter || undefined; const results: string[] = []; for (const target of targets) { try { - const result = await docker.pruneSystem(target); + const result = await docker.pruneSystem(target, labelFilter); results.push(`${target}: ${result.reclaimedBytes ?? 0} bytes reclaimed`); } catch (error: unknown) { const msg = error instanceof Error ? error.message : String(error); @@ -317,6 +342,7 @@ export class SchedulerService { } } - return `System prune completed: ${results.join('; ')}`; + const filterSuffix = labelFilter ? ` (label: ${labelFilter})` : ''; + return `System prune completed${filterSuffix}: ${results.join('; ')}`; } } diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 66950a9b..f72122a0 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -20,9 +20,9 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i | Action | Target | Description | |--------|--------|-------------| -| **Restart Stack** | A specific stack on a specific node | Restarts all containers in the stack via the Docker Engine API | +| **Restart Stack** | A specific stack (or specific services within it) on a specific node | Restarts all or selected 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 selected resources (containers, images, networks, volumes — all by default) | +| **System Prune** | A specific node (or the default node) | Prunes selected resources, optionally filtered by Docker label | ## Creating a Scheduled Task @@ -32,7 +32,9 @@ 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. + - **Services** - optionally select specific services within the stack to restart. Leave empty to restart all services. - **Prune Targets** - if you chose System Prune, select which resources to prune (containers, images, networks, volumes). All are selected by default. + - **Label Filter** - 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. 4. Click **Create**. @@ -41,6 +43,26 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i Create scheduled task dialog with action, cron expression, and prune target options +## Granular Targeting + +### Per-Service Restart + +When creating a Restart Stack schedule, you can target individual services instead of restarting the entire stack. After selecting a stack, Sencho reads the compose file and displays checkboxes for each defined service. Select the services you want to restart, or leave all unchecked to restart every service in the stack. + + + Service checkboxes displayed when creating a per-service restart schedule + + +### Prune Label Filter + +When creating a System Prune schedule, you can scope the prune to resources matching a specific Docker label. This lets you target resources from a particular stack or project without affecting unrelated containers, images, or volumes. + +Enter a label in `key=value` format (e.g. `com.docker.compose.project=mystack`). Leave the field empty to prune all unused resources of the selected types. + + + Label filter input for scoping prune operations to specific Docker labels + + ## Cron Expression Reference Sencho uses standard 5-field cron expressions: @@ -72,6 +94,18 @@ Sencho uses standard 5-field cron expressions: - **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. +## Failure Notifications + +When a scheduled task fails, Sencho automatically dispatches an **error-level alert** through your configured notification channels (Discord, Slack, or custom webhooks). The alert includes the task name, action type, and error message so you can diagnose the issue immediately. + +When a previously failing task succeeds again, Sencho sends an **info-level recovery notification** to confirm the issue is resolved. This recovery-only approach avoids notification noise from tasks that succeed on every run. + +To configure notification channels, go to **Settings > Notifications**. + + + Failure notification alert shown in the notification bell popover + + ## Execution History Click the history icon on any task to view its execution log. Each entry shows: @@ -84,8 +118,12 @@ Click the history icon on any task to view its execution log. Each entry shows: Execution history is retained for 30 days. +### Exporting History + +Click the download icon in the top-right corner of the execution history panel to export the full history as a CSV file. The export includes all runs within the 30-day retention window — not just the current page. + - Execution history showing run source, status, duration, and details with pagination + Execution history showing run source, status, duration, and details with export button ## How It Works @@ -95,6 +133,7 @@ The Scheduler Service runs in the background and checks for due tasks every 60 s 1. The scheduler verifies your Admiral license is active. 2. It executes the configured action using the same internal services that power the UI buttons (restart, snapshot, prune). 3. Results are logged to the execution history. -4. The next run time is recalculated from the cron expression. +4. On failure, an alert is dispatched via your configured notification channels. +5. The next run time is recalculated from the cron expression. If a task is still running from a previous execution, the scheduler skips it to prevent overlap. diff --git a/docs/images/scheduled-operations/failure-notification.png b/docs/images/scheduled-operations/failure-notification.png new file mode 100644 index 00000000..9be73ce5 Binary files /dev/null and b/docs/images/scheduled-operations/failure-notification.png differ diff --git a/docs/images/scheduled-operations/overview.png b/docs/images/scheduled-operations/overview.png index 1e873291..ced9b124 100644 Binary files a/docs/images/scheduled-operations/overview.png and b/docs/images/scheduled-operations/overview.png differ diff --git a/docs/images/scheduled-operations/per-service-restart.png b/docs/images/scheduled-operations/per-service-restart.png new file mode 100644 index 00000000..5df3d9b3 Binary files /dev/null and b/docs/images/scheduled-operations/per-service-restart.png differ diff --git a/docs/images/scheduled-operations/prune-label-filter.png b/docs/images/scheduled-operations/prune-label-filter.png new file mode 100644 index 00000000..46995e54 Binary files /dev/null and b/docs/images/scheduled-operations/prune-label-filter.png differ diff --git a/docs/images/scheduled-operations/run-history.png b/docs/images/scheduled-operations/run-history.png index 307e94a5..d55d3583 100644 Binary files a/docs/images/scheduled-operations/run-history.png 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 1d364d97..035604b8 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -11,7 +11,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; -import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import cronstrue from 'cronstrue'; @@ -33,6 +33,8 @@ interface ScheduledTask { last_status: string | null; last_error: string | null; prune_targets: string | null; + target_services: string | null; + prune_label_filter: string | null; } interface TaskRun { @@ -88,6 +90,9 @@ export default function ScheduledOperationsView() { const [formCron, setFormCron] = useState('0 3 * * *'); const [formEnabled, setFormEnabled] = useState(true); const [formPruneTargets, setFormPruneTargets] = useState(['containers', 'images', 'networks', 'volumes']); + const [formTargetServices, setFormTargetServices] = useState([]); + const [formPruneLabelFilter, setFormPruneLabelFilter] = useState(''); + const [availableServices, setAvailableServices] = useState([]); const [saving, setSaving] = useState(false); const [runningTaskId, setRunningTaskId] = useState(null); const [runsPage, setRunsPage] = useState(1); @@ -141,6 +146,26 @@ export default function ScheduledOperationsView() { fetchNodes(); }, [fetchTasks, fetchStacks, fetchNodes]); + useEffect(() => { + if (formAction !== 'restart' || !formTargetId) { + setAvailableServices([]); + return; + } + let cancelled = false; + const fetchServices = async () => { + try { + const res = await apiFetch(`/stacks/${encodeURIComponent(formTargetId)}/services`); + if (res.ok && !cancelled) { + setAvailableServices(await res.json()); + } + } catch { + // Non-critical + } + }; + fetchServices(); + return () => { cancelled = true; }; + }, [formAction, formTargetId]); + const openCreate = () => { setEditingTask(null); setFormName(''); @@ -150,6 +175,8 @@ export default function ScheduledOperationsView() { setFormCron('0 3 * * *'); setFormEnabled(true); setFormPruneTargets(['containers', 'images', 'networks', 'volumes']); + setFormTargetServices([]); + setFormPruneLabelFilter(''); setDialogOpen(true); }; @@ -164,6 +191,10 @@ export default function ScheduledOperationsView() { setFormPruneTargets( task.prune_targets ? JSON.parse(task.prune_targets) : ['containers', 'images', 'networks', 'volumes'] ); + setFormTargetServices( + task.target_services ? JSON.parse(task.target_services) : [] + ); + setFormPruneLabelFilter(task.prune_label_filter || ''); setDialogOpen(true); }; @@ -186,6 +217,12 @@ export default function ScheduledOperationsView() { if (formAction === 'prune' && formPruneTargets.length > 0) { body.prune_targets = formPruneTargets; } + if (formAction === 'restart' && formTargetServices.length > 0) { + body.target_services = formTargetServices; + } + if (formAction === 'prune' && formPruneLabelFilter.trim()) { + body.prune_label_filter = formPruneLabelFilter.trim(); + } setSaving(true); try { @@ -332,7 +369,11 @@ export default function ScheduledOperationsView() { - {task.target_type === 'stack' ? task.target_id : task.target_type} + {task.target_type === 'stack' + ? task.target_services + ? `${task.target_id} (${(JSON.parse(task.target_services) as string[]).join(', ')})` + : task.target_id + : task.target_type}
{getCronDescription(task.cron_expression)}
@@ -394,7 +435,7 @@ export default function ScheduledOperationsView() {
- { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }}> @@ -434,28 +475,60 @@ export default function ScheduledOperationsView() {
+ {formAction === 'restart' && formTargetId && availableServices.length > 0 && ( +
+ +
+ {availableServices.map(svc => ( + + ))} +
+
+ )} )} {formAction === 'prune' && ( -
- -
- {['containers', 'images', 'networks', 'volumes'].map(target => ( - - ))} + <> +
+ +
+ {['containers', 'images', 'networks', 'volumes'].map(target => ( + + ))} +
-
+
+ + setFormPruneLabelFilter(e.target.value)} + className="font-mono text-xs" + /> +

Only prune resources matching this Docker label.

+
+ )}
@@ -505,7 +578,19 @@ export default function ScheduledOperationsView() { { if (!open) setRunsTask(null); }}> - Execution History - {runsTask?.name} +
+ Execution History - {runsTask?.name} + {runsTask && runs.length > 0 && ( + + )} +
{runsLoading ? (