From 29ed0524c1c28c614c9c624fd40d6c796b16e366 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 17 Apr 2026 11:08:46 -0400 Subject: [PATCH] feat(security): severity-aware scheduled scan notifications (#654) Enrich scheduled vulnerability scan completion notifications with per-severity CVE counts so recipients can triage from the message body alone. Expose the scan action in the schedule creation UI, require an explicit node_id, and harden fire-and-forget alert dispatches so a failing webhook cannot crash the scheduler. Notification body now reports scanned/skipped/failed counts plus critical/high/medium totals aggregated across fresh and cached scans, reflecting the current node posture rather than only what was newly scanned on this run. --- .../src/__tests__/scheduler-service.test.ts | 114 ++++++++++++++++-- backend/src/index.ts | 9 ++ backend/src/services/SchedulerService.ts | 83 +++++++++++-- backend/src/services/TrivyService.ts | 38 +++++- docs/features/alerts-notifications.mdx | 15 ++- docs/features/scheduled-operations.mdx | 27 ++++- .../scan-create-dialog.png | Bin 0 -> 22633 bytes .../components/ScheduledOperationsView.tsx | 19 ++- frontend/src/types/scheduling.ts | 2 +- 9 files changed, 280 insertions(+), 27 deletions(-) create mode 100644 docs/images/scheduled-operations/scan-create-dialog.png 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 0000000000000000000000000000000000000000..f80dfb7bcf0869f2aab2fafc591c60cc18f5becd GIT binary patch literal 22633 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 literal 0 HcmV?d00001 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;