diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 8ceed6c0..f28494fd 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -47,7 +47,12 @@ const { mockDispatchAlert: vi.fn().mockResolvedValue(undefined), mockGetProxyTarget: vi.fn().mockReturnValue(null), mockIsTrivyAvailable: vi.fn().mockReturnValue(true), - mockScanAllNodeImages: vi.fn().mockResolvedValue({ scanned: 0, skipped: 0, failed: 0 }), + mockScanAllNodeImages: vi.fn().mockResolvedValue({ + scanned: 0, + skipped: 0, + failed: 0, + severity: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 }, + }), })); vi.mock('../services/DatabaseService', () => ({ @@ -777,9 +782,33 @@ describe('SchedulerService - scheduled scan notifications', () => { }; } + function scanResult(opts: { + scanned?: number; + skipped?: number; + failed?: number; + critical?: number; + high?: number; + medium?: number; + low?: number; + unknown?: number; + } = {}) { + return { + scanned: opts.scanned ?? 0, + skipped: opts.skipped ?? 0, + failed: opts.failed ?? 0, + severity: { + critical: opts.critical ?? 0, + high: opts.high ?? 0, + medium: opts.medium ?? 0, + low: opts.low ?? 0, + unknown: opts.unknown ?? 0, + }, + }; + } + it('dispatches info-level notification when scan completes cleanly', async () => { mockGetScheduledTask.mockReturnValue(makeScanTask()); - mockScanAllNodeImages.mockResolvedValue({ scanned: 3, skipped: 1, failed: 0 }); + mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 3, skipped: 1 })); const svc = SchedulerService.getInstance(); await svc.triggerTask(200); @@ -798,7 +827,7 @@ describe('SchedulerService - scheduled scan notifications', () => { it('dispatches warning-level notification when scan has failures', async () => { mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 201, name: 'flaky-scan' })); - mockScanAllNodeImages.mockResolvedValue({ scanned: 5, skipped: 0, failed: 2 }); + mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 5, failed: 2 })); const svc = SchedulerService.getInstance(); await svc.triggerTask(201); @@ -812,7 +841,7 @@ describe('SchedulerService - scheduled scan notifications', () => { it('passes target_id to dispatchAlert when set', async () => { mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 202, target_id: 'web-stack' })); - mockScanAllNodeImages.mockResolvedValue({ scanned: 1, skipped: 0, failed: 0 }); + mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 1 })); const svc = SchedulerService.getInstance(); await svc.triggerTask(202); @@ -828,9 +857,9 @@ describe('SchedulerService - scheduled scan notifications', () => { mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 204, name: 'recovered-scan', - last_status: 'failure', // Previous run failed + last_status: 'failure', })); - mockScanAllNodeImages.mockResolvedValue({ scanned: 2, skipped: 0, failed: 0 }); + mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 2 })); const svc = SchedulerService.getInstance(); await svc.triggerTask(204); @@ -853,7 +882,7 @@ describe('SchedulerService - scheduled scan notifications', () => { target_id: 'my-stack', node_id: 1, created_by: 'admin', - last_status: null, // No previous failure → no recovery notification + last_status: null, }); mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]); @@ -862,6 +891,77 @@ describe('SchedulerService - scheduled scan notifications', () => { expect(mockDispatchAlert).not.toHaveBeenCalled(); }); + + it('dispatches error-level notification when Trivy is unavailable', async () => { + mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 205, target_id: 'payment-stack' })); + mockIsTrivyAvailable.mockReturnValueOnce(false); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(205); + + expect(mockDispatchAlert).toHaveBeenCalledWith( + 'error', + expect.stringMatching(/failed.*Trivy/i), + 'payment-stack', + ); + }); + + it('includes severity counts in the notification message', async () => { + mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 206 })); + mockScanAllNodeImages.mockResolvedValue( + scanResult({ scanned: 3, skipped: 1, critical: 2, high: 5, medium: 10 }), + ); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(206); + + const message = mockDispatchAlert.mock.calls[0][1] as string; + expect(message).toContain('2 critical'); + expect(message).toContain('5 high'); + expect(message).toContain('10 medium'); + }); + + it('reports "No images to scan" when the node has nothing to scan', async () => { + mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 207 })); + mockScanAllNodeImages.mockResolvedValue(scanResult()); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(207); + + expect(mockDispatchAlert).toHaveBeenCalledWith( + 'info', + expect.stringContaining('No images to scan'), + undefined, + ); + }); + + it('reports "All N image(s) already scanned recently" when every image was cached', async () => { + mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 208 })); + mockScanAllNodeImages.mockResolvedValue(scanResult({ skipped: 12 })); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(208); + + expect(mockDispatchAlert).toHaveBeenCalledWith( + 'info', + expect.stringContaining('All 12 image(s) already scanned recently'), + undefined, + ); + }); + + it('persists the run as success even when notification dispatch throws', async () => { + mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 209 })); + mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 1 })); + mockDispatchAlert.mockRejectedValueOnce(new Error('webhook down')); + + const svc = SchedulerService.getInstance(); + await expect(svc.triggerTask(209)).resolves.not.toThrow(); + + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ status: 'success' }), + ); + }); }); // ── Cleanup ──────────────────────────────────────────────────────────── diff --git a/backend/src/index.ts b/backend/src/index.ts index eeb63c1f..9d506104 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6249,6 +6249,9 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { if (action === 'scan' && target_type !== 'system') { res.status(400).json({ error: 'Scan action requires target_type "system".' }); return; } + if (action === 'scan' && !node_id) { + res.status(400).json({ error: 'Scan action requires node_id.' }); return; + } if (target_type === 'stack' && (!target_id || !node_id)) { res.status(400).json({ error: 'Stack operations require target_id and node_id.' }); return; } @@ -6370,6 +6373,12 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { if (finalAction === 'scan' && finalTargetType !== 'system') { res.status(400).json({ error: 'Scan action requires target_type "system".' }); return; } + if (finalAction === 'scan') { + const finalNodeId = node_id !== undefined ? node_id : existing.node_id; + if (!finalNodeId) { + res.status(400).json({ error: 'Scan action requires node_id.' }); return; + } + } // Validate prune targets const validPruneTargets = ['containers', 'images', 'networks', 'volumes']; diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 29a038ee..c980566e 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -13,6 +13,7 @@ import { captureLocalNodeFiles, captureRemoteNodeFiles } from '../utils/snapshot import { NodeRegistry } from './NodeRegistry'; import { NotificationService } from './NotificationService'; import TrivyService from './TrivyService'; +import type { ScanAllNodeImagesResult } from './TrivyService'; import TrivyInstaller from './TrivyInstaller'; const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -86,7 +87,7 @@ export class SchedulerService { try { await installer.update(); await trivy.detectTrivy(); - NotificationService.getInstance().dispatchAlert( + this.safeDispatch( 'info', `Trivy updated from v${previous} to v${check.latest}`, ); @@ -97,7 +98,7 @@ export class SchedulerService { } else { const lastNotified = settings.trivy_last_notified_version || ''; if (lastNotified === check.latest) return; - NotificationService.getInstance().dispatchAlert( + this.safeDispatch( 'info', `Trivy update available: v${check.latest} (currently v${check.current ?? 'unknown'})`, ); @@ -139,6 +140,16 @@ export class SchedulerService { return expr.next().toDate().getTime(); } + /** + * Fire a notification without awaiting completion, catching any promise + * rejection so the scheduler never crashes on a failed dispatch. + */ + private safeDispatch(level: 'info' | 'warning' | 'error', message: string, stackName?: string): void { + NotificationService.getInstance() + .dispatchAlert(level, message, stackName) + .catch(err => console.error('[SchedulerService] Notification dispatch failed:', getErrorMessage(err, 'unknown error'))); + } + private async tick(): Promise { if (this.isProcessing) { console.warn('[SchedulerService] Tick skipped: previous tick still processing'); @@ -280,13 +291,19 @@ export class SchedulerService { }); console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) completed successfully`); if (task.action === 'scan') { - NotificationService.getInstance().dispatchAlert( - scanFailedCount > 0 ? 'warning' : 'info', + const scanLevel: 'info' | 'warning' = scanFailedCount > 0 ? 'warning' : 'info'; + if (isDebugEnabled()) { + console.log( + `[SchedulerService:debug] Dispatching scan completion notification (level=${scanLevel}, stackContext=${task.target_id ?? 'none'})`, + ); + } + this.safeDispatch( + scanLevel, `Scheduled scan "${task.name}" completed: ${output}`, task.target_id ?? undefined ); } else if (task.last_status === 'failure') { - NotificationService.getInstance().dispatchAlert( + this.safeDispatch( 'info', `Scheduled task "${task.name}" (${task.action}) recovered successfully`, task.target_id ?? undefined @@ -321,7 +338,7 @@ export class SchedulerService { error: errMsg, }); console.error(`[SchedulerService] Task "${task.name}" (id=${task.id}) failed:`, errMsg); - NotificationService.getInstance().dispatchAlert( + this.safeDispatch( 'error', `Scheduled task "${task.name}" (${task.action}) failed: ${errMsg}`, task.target_id ?? undefined @@ -598,7 +615,7 @@ export class SchedulerService { await compose.updateStack(stackName, undefined, true); db.clearStackUpdateStatus(nodeId, stackName); - NotificationService.getInstance().dispatchAlert( + this.safeDispatch( 'info', `Auto-update: stack "${stackName}" updated with new images`, stackName @@ -618,11 +635,55 @@ export class SchedulerService { console.log(`[SchedulerService:debug] Scan task ${task.id}: no node_id specified, using default node ${nodeId}`); } + const scanStart = Date.now(); + if (isDebugEnabled()) console.log(`[SchedulerService:debug] executeScan start: task=${task.id} node=${nodeId}`); + const summary = await trivy.scanAllNodeImages(nodeId, 'scheduled'); - const parts: string[] = [`Scanned ${summary.scanned} image(s)`]; - if (summary.skipped > 0) parts.push(`${summary.skipped} skipped (cached)`); - if (summary.failed > 0) parts.push(`${summary.failed} failed`); - return { output: parts.join('; '), failed: summary.failed }; + if (isDebugEnabled()) { + console.log( + `[SchedulerService:debug] executeScan summary: scanned=${summary.scanned} skipped=${summary.skipped} failed=${summary.failed} ` + + `critical=${summary.severity.critical} high=${summary.severity.high} medium=${summary.severity.medium} ` + + `low=${summary.severity.low} unknown=${summary.severity.unknown} durationMs=${Date.now() - scanStart}`, + ); + } + + const output = formatScanOutput(summary); + return { output, failed: summary.failed }; } } + +/** + * Build the human-readable completion message from a bulk scan summary. + * Exported for unit tests. + */ +export function formatScanOutput(summary: ScanAllNodeImagesResult): string { + const { scanned, skipped, failed, severity } = summary; + + let header: string; + if (scanned === 0 && skipped === 0 && failed === 0) { + header = 'No images to scan'; + } else if (scanned === 0 && skipped > 0 && failed === 0) { + header = `All ${skipped} image(s) already scanned recently (cache hit)`; + } else { + const parts: string[] = [`Scanned ${scanned} image(s)`]; + if (skipped > 0) parts.push(`${skipped} skipped (cached)`); + if (failed > 0) parts.push(`${failed} failed`); + header = parts.join('; '); + } + + const severityTiers: Array<[string, number]> = [ + ['critical', severity.critical], + ['high', severity.high], + ['medium', severity.medium], + ]; + const nonZero = severityTiers.filter(([, n]) => n > 0); + if (nonZero.length === 0) { + if (scanned === 0 && skipped === 0 && failed === 0) { + return header + '.'; + } + return `${header}. No critical, high, or medium findings.`; + } + const findings = nonZero.map(([label, n]) => `${n} ${label}`).join(', '); + return `${header}. Found ${findings}.`; +} diff --git a/backend/src/services/TrivyService.ts b/backend/src/services/TrivyService.ts index bba9c50f..486adc6e 100644 --- a/backend/src/services/TrivyService.ts +++ b/backend/src/services/TrivyService.ts @@ -77,6 +77,21 @@ interface TrivyRawOutput { Results?: TrivyRawResult[]; } +export interface ScanAllNodeImagesSeverityTotals { + critical: number; + high: number; + medium: number; + low: number; + unknown: number; +} + +export interface ScanAllNodeImagesResult { + scanned: number; + skipped: number; + failed: number; + severity: ScanAllNodeImagesSeverityTotals; +} + export interface TrivyVulnerability { vulnerabilityId: string; pkgName: string; @@ -874,7 +889,7 @@ class TrivyService { async scanAllNodeImages( nodeId: number, triggeredBy: VulnScanTrigger = 'scheduled', - ): Promise<{ scanned: number; skipped: number; failed: number }> { + ): Promise { if (this.source === 'none') { throw new Error('Trivy is not available on this host'); } @@ -889,26 +904,43 @@ class TrivyService { let scanned = 0; let skipped = 0; let failed = 0; + const severity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 }; + const countedDigests = new Set(); + + const addSeverity = (row: VulnerabilityScan | null): void => { + if (!row) return; + severity.critical += row.critical_count; + severity.high += row.high_count; + severity.medium += row.medium_count; + severity.low += row.low_count; + severity.unknown += row.unknown_count; + }; + for (const ref of imageRefs) { try { const digest = await this.getImageDigest(ref, nodeId); if (digest) { + if (countedDigests.has(digest)) continue; const cached = DatabaseService.getInstance().getLatestScanByDigest(digest, 'vuln'); if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) { skipped++; + addSeverity(cached); + countedDigests.add(digest); continue; } } - await this.runScanAndPersist(ref, nodeId, triggeredBy, null); + const fresh = await this.runScanAndPersist(ref, nodeId, triggeredBy, null); + addSeverity(fresh); scanned++; + if (digest) countedDigests.add(digest); } catch (err) { failed++; console.warn(`[Trivy] Failed to scan ${ref}:`, getErrorMessage(err, 'unknown error')); } await new Promise((r) => setTimeout(r, 300)); } - return { scanned, skipped, failed }; + return { scanned, skipped, failed, severity }; } async generateSBOM(imageRef: string, format: SbomFormat): Promise { diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index 9c35a4a4..c7ce0779 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -166,8 +166,21 @@ Recurring [vulnerability scans](/features/vulnerability-scanning) dispatch a not - **Info** when every image scanned successfully. - **Warning** when one or more images failed to scan during the run. +- **Error** when the run itself could not start (for example, Trivy is not installed on the target node). -The message includes the scheduled task name and a summary of how many images were scanned, cached, and failed. Failures are typically transient (registry timeouts, missing credentials) and do not stop the rest of the run from completing. +The message includes the scheduled task name, a summary of how many images were scanned, cached, and failed, and a breakdown of findings by severity. A typical clean-run message looks like: + +``` +Scheduled scan "nightly-scan" completed: Scanned 12 image(s); 3 skipped (cached). Found 2 critical, 5 high, 10 medium. +``` + +If no critical, high, or medium findings are present, the message ends with `No critical, high, or medium findings.` so the outcome is still explicit. When the target node has nothing to scan, the message reads `No images to scan.`, and when every image was already covered by a recent cached scan it reads `All N image(s) already scanned recently (cache hit).` In both cases the notification still fires so you know the run executed. + + + Severity counts reflect the current security posture of the node, aggregated across both freshly scanned images and cached scan results. They are not a delta of what changed on this run. + + +Failures are typically transient (registry timeouts, missing credentials) and do not stop the rest of the run from completing. ## Container crash detection diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index f9df7b7b..2a126bb4 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -31,8 +31,8 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i 2. Click **New Schedule**. 3. Fill in the form: - **Name**: A descriptive label (e.g. "Nightly staging restart"). - - **Action**: Choose Restart Stack, Fleet Snapshot, or System Prune. The form fields below change based on your selection. - - **Node**: (Restart Stack only) Select the node where the target stack runs. + - **Action**: Choose Restart Stack, Fleet Snapshot, System Prune, or Vulnerability Scan. The form fields below change based on your selection. + - **Node**: (Restart Stack and Vulnerability Scan) Select the node to run against. For Restart Stack it determines where the target stack lives; for Vulnerability Scan it determines which node's images are scanned. - **Stack**: (Restart Stack only) Select the stack to restart. 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. - **Prune Targets**: (System Prune only) Select which resources to prune: containers, images, networks, volumes. All are selected by default. @@ -52,7 +52,7 @@ The task list is displayed as a table with the following columns: | Column | Description | |--------|-------------| | **Name** | The task name | -| **Action** | Task type badge: Restart Stack, Fleet Snapshot, or System Prune | +| **Action** | Task type badge: Restart Stack, Fleet Snapshot, System Prune, or Vulnerability Scan | | **Target** | Stack name (with selected services, if any), or the target type for non-stack actions | | **Schedule** | Human-readable description with the raw cron expression below | | **Status** | Last run result: **Success** (green), **Failed** (red), or "Never run" | @@ -70,6 +70,12 @@ When creating a Restart Stack schedule, you can target individual services inste Service checkboxes displayed when creating a per-service restart schedule +### Scheduled Vulnerability Scans + +A Vulnerability Scan task runs Trivy against every image on the selected node and persists the results. The scan uses the same digest-based 24-hour cache as manual scans, so unchanged images are not rescanned on every run. See [Vulnerability Scanning](/features/vulnerability-scanning) for how results are surfaced in the UI and [Installing Trivy](/operations/trivy-setup) for setup on each node. + +When a scheduled scan finishes, Sencho dispatches a completion notification with a summary of what was scanned and a breakdown of findings by severity. The full message format is documented in [Alerts & Notifications → Scheduled scan completion](/features/alerts-notifications#scheduled-scan-completion). + ### 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. @@ -130,6 +136,8 @@ When a scheduled task fails, Sencho automatically dispatches an **error-level al 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. +Vulnerability Scan tasks always send a completion notification, even on a clean run, because the message carries severity counts you may want to react to. See [Alerts & Notifications → Scheduled scan completion](/features/alerts-notifications#scheduled-scan-completion) for the full message format. + To configure notification channels, go to **Settings > Notifications**. @@ -183,3 +191,16 @@ If a task's cron expression becomes invalid after creation (for example, due to ### Run shows "Server restarted during execution" This means Sencho was restarted (or crashed) while this task was mid-execution. The run was marked as failed automatically on startup. The task itself is still enabled and will run at its next scheduled time. If you want to re-run it immediately, use the **Run Now** button. + +### Scheduled scan completed but no notification arrived + +The scan notification shares the same delivery path as every other Sencho alert. Check, in order: + +1. At least one channel is enabled in **Settings > Notifications** and the **Test** button succeeds for it. +2. If the stack the scan is associated with has notification routes defined, make sure at least one matching route is enabled; the routing layer takes priority over the global channels. +3. Open the notification bell. The in-app bell receives every notification regardless of channel configuration, and a failed external delivery is logged there as an error entry you can inspect. +4. On a remote node, notification channels must be configured on the remote instance itself; channel settings are per-node. + +### 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. diff --git a/docs/images/scheduled-operations/scan-create-dialog.png b/docs/images/scheduled-operations/scan-create-dialog.png new file mode 100644 index 00000000..f80dfb7b Binary files /dev/null and b/docs/images/scheduled-operations/scan-create-dialog.png differ diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index 98080235..1e5d443b 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -22,6 +22,7 @@ const ACTION_OPTIONS = [ { value: 'restart', label: 'Restart Stack', targetType: 'stack' as const }, { value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' as const }, { value: 'prune', label: 'System Prune', targetType: 'system' as const }, + { value: 'scan', label: 'Vulnerability Scan', targetType: 'system' as const }, ]; interface ScheduledOperationsViewProps { @@ -194,6 +195,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: body.target_id = formTargetId; body.node_id = formNodeId ? parseInt(formNodeId, 10) : null; } + if (formAction === 'scan') { + body.node_id = formNodeId ? parseInt(formNodeId, 10) : null; + } if (formAction === 'prune' && formPruneTargets.length > 0) { body.prune_targets = formPruneTargets; } @@ -480,6 +484,19 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: )} + {formAction === 'scan' && ( +
+ + ({ value: String(n.id), label: n.name }))} + value={formNodeId} + onValueChange={setFormNodeId} + placeholder="Select node..." + /> +

Every image on the selected node will be scanned.

+
+ )} + {formAction === 'prune' && ( <>
@@ -531,7 +548,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
- diff --git a/frontend/src/types/scheduling.ts b/frontend/src/types/scheduling.ts index db6e3875..214d3942 100644 --- a/frontend/src/types/scheduling.ts +++ b/frontend/src/types/scheduling.ts @@ -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'; + action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan'; cron_expression: string; enabled: number; created_by: string;