diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index da353281..d5d17baa 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -5,7 +5,6 @@ import { PolicyBlockDialog } from '../stack/PolicyBlockDialog'; import { DeleteStackDialog } from './DeleteStackDialog'; import { UnsavedChangesDialog } from './UnsavedChangesDialog'; import { StackAlertSheet } from '../StackAlertSheet'; -import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; import { GitSourcePanel } from '../stack/GitSourcePanel'; import { LogViewer } from '../LogViewer'; import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet'; @@ -54,9 +53,8 @@ export function ShellOverlays({ pendingUnsavedLoad, bashModalOpen, selectedContainer, logViewerOpen, logContainer, - alertSheetOpen, closeAlertSheet, alertSheetStack, + stackMonitor, closeStackMonitor, policyBlock, setPolicyBlock, policyBypassing, - autoHealStackName, setAutoHealStackName, stackMisconfigScanId, setStackMisconfigScanId, diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming, } = overlayState; @@ -96,11 +94,12 @@ export function ShellOverlays({ /> )} - {/* Stack Alert Sheet */} + {/* Stack monitor (alerts + auto-heal as tabs) */} { if (!open) closeStackMonitor(); }} + stackName={stackMonitor?.stackName ?? ''} + initialTab={stackMonitor?.tab ?? 'alerts'} /> {/* Pre-deploy policy block */} @@ -114,13 +113,6 @@ export function ShellOverlays({ onBypass={stackActions.bypassPolicyAndDeploy} /> - {/* Stack Auto-Heal Sheet */} - { if (!open) setAutoHealStackName(null); }} - /> - {/* Git Source Panel */} {stackName && ( { expect(result.current.selectedContainer).toBeNull(); expect(result.current.logViewerOpen).toBe(false); expect(result.current.logContainer).toBeNull(); - expect(result.current.alertSheetOpen).toBe(false); - expect(result.current.alertSheetStack).toBe(''); - expect(result.current.autoHealStackName).toBeNull(); + expect(result.current.stackMonitor).toBeNull(); expect(result.current.policyBlock).toBeNull(); expect(result.current.policyBypassing).toBe(false); expect(result.current.stackMisconfigScanId).toBeNull(); @@ -69,32 +67,29 @@ describe('useOverlayState', () => { expect(result.current.logContainer).toBeNull(); }); - it('openAlertSheet sets sheet state', () => { + it('openAlertSheet opens stack monitor on the alerts tab', () => { const { result } = renderHook(() => useOverlayState()); act(() => result.current.openAlertSheet('web-stack')); - expect(result.current.alertSheetOpen).toBe(true); - expect(result.current.alertSheetStack).toBe('web-stack'); + expect(result.current.stackMonitor).toEqual({ stackName: 'web-stack', tab: 'alerts' }); }); - it('openAlertSheet with autoHeal sets autoHealStackName', () => { + it('openAutoHeal opens stack monitor on the auto-heal tab', () => { const { result } = renderHook(() => useOverlayState()); - act(() => result.current.openAlertSheet('web-stack', 'web-stack')); - expect(result.current.alertSheetOpen).toBe(true); - expect(result.current.alertSheetStack).toBe('web-stack'); - expect(result.current.autoHealStackName).toBe('web-stack'); + act(() => result.current.openAutoHeal('web-stack')); + expect(result.current.stackMonitor).toEqual({ stackName: 'web-stack', tab: 'auto-heal' }); }); - it('openAlertSheet without autoHeal leaves autoHealStackName null', () => { + it('openAutoHeal after openAlertSheet switches to the auto-heal tab', () => { const { result } = renderHook(() => useOverlayState()); act(() => result.current.openAlertSheet('web-stack')); - expect(result.current.alertSheetOpen).toBe(true); - expect(result.current.autoHealStackName).toBeNull(); + act(() => result.current.openAutoHeal('web-stack')); + expect(result.current.stackMonitor).toEqual({ stackName: 'web-stack', tab: 'auto-heal' }); }); - it('closeAlertSheet sets alertSheetOpen to false', () => { + it('closeStackMonitor clears the stack monitor state', () => { const { result } = renderHook(() => useOverlayState()); act(() => result.current.openAlertSheet('web-stack')); - act(() => result.current.closeAlertSheet()); - expect(result.current.alertSheetOpen).toBe(false); + act(() => result.current.closeStackMonitor()); + expect(result.current.stackMonitor).toBeNull(); }); }); diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts index ee3578b1..63e443b9 100644 --- a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts @@ -66,15 +66,14 @@ export function useOverlayState() { return () => window.removeEventListener(SENCHO_OPEN_LOGS_EVENT, handler); }, [openLogViewer]); // openLogViewer is stable (useCallback with empty deps) - const [alertSheetOpen, setAlertSheetOpen] = useState(false); - const [alertSheetStack, setAlertSheetStack] = useState(''); - const [autoHealStackName, setAutoHealStackName] = useState(null); - const openAlertSheet = useCallback((stackName: string, autoHeal?: string | null) => { - setAlertSheetStack(stackName); - setAutoHealStackName(autoHeal ?? null); - setAlertSheetOpen(true); + const [stackMonitor, setStackMonitor] = useState<{ stackName: string; tab: 'alerts' | 'auto-heal' } | null>(null); + const openAlertSheet = useCallback((stackName: string) => { + setStackMonitor({ stackName, tab: 'alerts' }); }, []); - const closeAlertSheet = useCallback(() => setAlertSheetOpen(false), []); + const openAutoHeal = useCallback((stackName: string) => { + setStackMonitor({ stackName, tab: 'auto-heal' }); + }, []); + const closeStackMonitor = useCallback(() => setStackMonitor(null), []); const [policyBlock, setPolicyBlock] = useState(null); const [policyBypassing, setPolicyBypassing] = useState(false); @@ -91,8 +90,7 @@ export function useOverlayState() { pendingUnsavedNode, setPendingUnsavedNode, bashModalOpen, selectedContainer, openBashModal, closeBashModal, logViewerOpen, logContainer, openLogViewer, closeLogViewer, - alertSheetOpen, alertSheetStack, autoHealStackName, openAlertSheet, closeAlertSheet, - setAutoHealStackName, + stackMonitor, openAlertSheet, openAutoHeal, closeStackMonitor, policyBlock, setPolicyBlock, policyBypassing, setPolicyBypassing, stackMisconfigScanId, setStackMisconfigScanId, diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming, diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index 6d9b26ca..783905ff 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -49,7 +49,7 @@ export function useSidebarContextMenu({ menuVisibility: stackActions.getStackMenuVisibility(file), autoUpdateEnabled: stackListState.autoUpdateSettings[sName] ?? true, openAlertSheet: () => overlayState.openAlertSheet(file), - openAutoHeal: () => overlayState.setAutoHealStackName(file), + openAutoHeal: () => overlayState.openAutoHeal(file), checkUpdates: () => stackActions.checkUpdatesForStack(), openStackApp: () => stackActions.openStackApp(file), deploy: () => stackActions.executeStackActionByFile(file, 'deploy', 'deploy'), diff --git a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx index 88bcedf9..37abe2ba 100644 --- a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx +++ b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx @@ -3,8 +3,7 @@ import { Search, Loader2, Check, CircleCheck, CircleAlert, AlertTriangle, Download, RefreshCw, Monitor, Globe, } from 'lucide-react'; -import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { ScrollArea } from '@/components/ui/scroll-area'; +import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -64,174 +63,160 @@ export function NodeUpdatesSheet({ const localEntry = updateStatuses.find(s => s.type === 'local') ?? updateStatuses[0]; const gatewayLabel = formatVersion(localEntry?.latestVersion); + const meta = updateStatuses.length === 0 + ? 'No nodes' + : `${updateStatuses.length} nodes · ${available} update${available === 1 ? '' : 's'} available`; + + const footerContext = updateStatuses.length === 0 + ? undefined + : (gatewayLabel ? `Latest version ${gatewayLabel}` : `${available} update${available === 1 ? '' : 's'} available`); + + const secondaryActions = canBulkUpdate && updatableRemoteCount > 0 + ? [{ + label: `Update all (${updatableRemoteCount})`, + icon: Download, + onClick: () => { void triggerUpdateAll(); }, + }] + : undefined; + return ( - - - - Node Updates - Check and apply updates across your fleet nodes. - - - {checkingUpdates ? ( -
- - Checking for updates... -
- ) : updateStatuses.length === 0 ? ( -
- No nodes found. -
- ) : ( -
- {/* Summary stats */} -
-
-
-
{upToDate}
-
- Up to date -
+ { void handleRecheck(); }, + disabled: recheckingUpdates || checkingUpdates, + }} + secondaryActions={secondaryActions} + footerContext={footerContext} + size="lg" + > + {checkingUpdates ? ( +
+ + Checking for updates... +
+ ) : updateStatuses.length === 0 ? ( +
+ No nodes found. +
+ ) : ( + <> + +
+
+
{upToDate}
+
+ Up to date
-
-
{available}
-
- Available -
+
+
+
{available}
+
+ Available
-
-
{updating}
-
- Updating -
+
+
+
{updating}
+
+ Updating
-
-
{failed}
-
- Failed -
+
+
+
{failed}
+
+ Failed
+ - {/* Search + gateway version */} -
-
- - setSearch(e.target.value)} - className="h-8 pl-8 text-xs" - /> -
- {gatewayLabel && ( -
- Latest: {gatewayLabel} + +
+ + setSearch(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+ +
+ Node + Type + Current + Latest + Status +
+ +
+ {filtered.map(s => ( +
+
+
+ {s.type === 'local' + ? + : + } +
+ {s.name} +
+ + {s.type} + + + {formatVersion(s.version) ?? unknown} + + + {formatVersion(s.latestVersion) ?? unknown} + +
+ {s.updateStatus && ( + retryNodeUpdate(s.nodeId)} + onDismiss={() => dismissNodeUpdate(s.nodeId)} + /> + )} + {!s.updateStatus && !s.updateAvailable && ( + + Up to date + + )} + {s.updateAvailable && !s.updateStatus && ( + + )} +
+
+ ))} + {filtered.length === 0 && ( +
+ No nodes match “{search}”
)}
- - {/* Table column header */} -
-
- Node - Type - Current - Latest - Status -
-
- - {/* Node list — fills remaining height, no cap */} - -
- {filtered.map(s => ( -
-
-
- {s.type === 'local' - ? - : - } -
- {s.name} -
- - {s.type} - - - {formatVersion(s.version) ?? unknown} - - - {formatVersion(s.latestVersion) ?? unknown} - -
- {s.updateStatus && ( - retryNodeUpdate(s.nodeId)} - onDismiss={() => dismissNodeUpdate(s.nodeId)} - /> - )} - {!s.updateStatus && !s.updateAvailable && ( - - Up to date - - )} - {s.updateAvailable && !s.updateStatus && ( - - )} -
-
- ))} - {filtered.length === 0 && ( -
- No nodes match “{search}” -
- )} -
-
- - {/* Footer */} -
- - {canBulkUpdate && updatableRemoteCount > 0 && ( - - )} -
-
- )} - - + + + )} + ); } diff --git a/frontend/src/components/ScanComparisonSheet.tsx b/frontend/src/components/ScanComparisonSheet.tsx index 008149c7..342a3caa 100644 --- a/frontend/src/components/ScanComparisonSheet.tsx +++ b/frontend/src/components/ScanComparisonSheet.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Button } from '@/components/ui/button'; import { @@ -14,7 +14,6 @@ import { ArrowRight, ChevronLeft, ChevronRight, - GitCompare, Loader2, MinusCircle, PlusCircle, @@ -145,93 +144,93 @@ export function ScanComparisonSheet({ const pageItems = rows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); const needsPagination = rows.length > PAGE_SIZE; + const meta = data + ? `#${data.scanA.id} → #${data.scanB.id} · +${data.added.length} −${data.removed.length}` + : (loading ? 'Loading…' : ''); + + const footerContext = data + ? `${data.scanA.image_ref} → ${data.scanB.image_ref}` + : undefined; + return ( - !o && onClose()}> - - -
- Scan comparison -
- - - Diff - - - Side-by-side comparison of two vulnerability scans showing added, removed, and unchanged findings. - -
+ !o && onClose()} + crumb={['Security', 'Scans', 'Compare']} + name="Diff" + meta={meta} + footerContext={footerContext} + size="xl" + > + {loading && ( +
+ +
+ )} - {loading && ( -
- -
- )} - - {data && !loading && ( -
- {/* Scan identification */} -
-
-
-
Baseline
-
{data.scanA.image_ref}
-
{new Date(data.scanA.scanned_at).toLocaleString()}
-
- -
-
Current
-
{data.scanB.image_ref}
-
{new Date(data.scanB.scanned_at).toLocaleString()}
-
+ {data && !loading && ( + <> + +
+
+
Baseline
+
{data.scanA.image_ref}
+
{new Date(data.scanA.scanned_at).toLocaleString()}
+
+ +
+
Current
+
{data.scanB.image_ref}
+
{new Date(data.scanB.scanned_at).toLocaleString()}
- - {crossImage && ( -
- - - You are comparing scans from two different image references. Package-level changes may reflect image differences rather than CVE drift. - -
- )} - - {data.truncated && ( -
- - - Showing the first {data.row_limit ?? 1000} findings per scan. One or both scans exceed this limit, so the comparison may be incomplete. - -
- )} - - {/* Delta ribbon */} - {addedCounts && removedCounts && ( -
- {(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as VulnSeverity[]).map((sev) => { - const delta = formatDelta(sev, addedCounts[sev], removedCounts[sev]); - return ( - - {sev} - {delta.text} - - ); - })} -
- )}
- {/* Filter pills */} -
+ {crossImage && ( +
+ + + You are comparing scans from two different image references. Package-level changes may reflect image differences rather than CVE drift. + +
+ )} + + {data.truncated && ( +
+ + + Showing the first {data.row_limit ?? 1000} findings per scan. One or both scans exceed this limit, so the comparison may be incomplete. + +
+ )} + + {addedCounts && removedCounts && ( +
+ {(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as VulnSeverity[]).map((sev) => { + const delta = formatDelta(sev, addedCounts[sev], removedCounts[sev]); + return ( + + {sev} + {delta.text} + + ); + })} +
+ )} + + + +
- )} -
- - -
- {runsLoading ? ( -
Loading...
- ) : runs.length === 0 ? ( -
No executions yet.
- ) : ( - <> + { if (!open) setRunsTask(null); }} + crumb={['Schedules', runsTask?.name ?? '—', 'Runs']} + name={runsTask?.name ?? 'Run history'} + meta={`${runsTotal} run${runsTotal === 1 ? '' : 's'}`} + secondaryActions={runsTask && runs.length > 0 ? [{ + label: 'Download CSV', + icon: Download, + onClick: () => window.open(`/api/scheduled-tasks/${runsTask.id}/runs/export`, '_blank'), + }] : undefined} + footerContext={runsTask?.next_run_at ? `Next run ${formatTimestamp(runsTask.next_run_at)}` : undefined} + size="lg" + > + + {runsLoading ? ( +
Loading...
+ ) : runs.length === 0 ? ( +
No executions yet.
+ ) : ( + <> @@ -927,12 +922,10 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p )} - - )} - - - - + + )} + + ); } diff --git a/frontend/src/components/SecurityHistoryView.tsx b/frontend/src/components/SecurityHistoryView.tsx index 27ae24a0..96cea72d 100644 --- a/frontend/src/components/SecurityHistoryView.tsx +++ b/frontend/src/components/SecurityHistoryView.tsx @@ -3,13 +3,7 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from '@/components/ui/sheet'; +import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; import { Table, TableBody, @@ -153,198 +147,177 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) }; const compareDisabled = selected.length !== 2; + const meta = `${total} scan${total === 1 ? '' : 's'} · ${groups.length} image${groups.length === 1 ? '' : 's'}`; + const footerContext = `Node ${activeNode?.name ?? '—'}`; return ( - { if (!next) onClose(); }}> - - -
- Security · Node {activeNode?.name ?? '-'} + { if (!next) onClose(); }} + crumb={['Security', 'Scan history']} + name="Scan history" + meta={meta} + primaryAction={isPaid ? { + label: `Compare (${selected.length}/2)`, + icon: GitCompare, + onClick: compareSelected, + disabled: compareDisabled, + } : undefined} + secondaryActions={[{ + label: 'Refresh', + icon: RefreshCw, + onClick: () => load(safePage, search), + disabled: loading, + }]} + footerContext={footerContext} + size="xl" + > + +
+
+ + setSearchDraft(e.target.value)} + className="pl-8" + />
-
- Scan history -
- {isPaid && ( - - )} + {needsPagination && ( +
+ + {safePage + 1} / {totalPages} + +
-
- - Completed vulnerability scans on this node, grouped by image. Select two to compare. - - -
-
-
- - setSearchDraft(e.target.value)} - className="pl-8" - /> -
- {needsPagination && ( -
- - - {safePage + 1} / {totalPages} - - -
- )} -
- - {groups.length === 0 && !loading ? ( -
- -
- {search - ? 'No completed scans match your search.' - : 'No scans have completed on this node yet.'} -
-
- ) : ( - -
- {groups.map((group) => ( -
-
- - {group.image_ref} - - - {group.scans.length} scan{group.scans.length === 1 ? '' : 's'} - -
-
- - - - Scanned - Trigger - Highest - Total - Fixable - - - - - {group.scans.map((scan) => { - const isSelected = selected.includes(scan.id); - return ( - - - toggleSelect(scan.id)} - aria-label={`Select scan ${scan.id}`} - /> - - - {new Date(scan.scanned_at).toLocaleString()} - - - {scan.triggered_by} - - - {scan.highest_severity ? ( - - ) : ( - none - )} - - - {scan.total_vulnerabilities} - - - {scan.fixable_count} - - - - - - ); - })} - -
-
- ))} -
- )}
- setCompareIds(null)} - /> + {groups.length === 0 && !loading ? ( +
+ +
+ {search + ? 'No completed scans match your search.' + : 'No scans have completed on this node yet.'} +
+
+ ) : ( + +
+ {groups.map((group) => ( +
+
+ + {group.image_ref} + + + {group.scans.length} scan{group.scans.length === 1 ? '' : 's'} + +
+ + + + + Scanned + Trigger + Highest + Total + Fixable + + + + + {group.scans.map((scan) => { + const isSelected = selected.includes(scan.id); + return ( + + + toggleSelect(scan.id)} + aria-label={`Select scan ${scan.id}`} + /> + + + {new Date(scan.scanned_at).toLocaleString()} + + + {scan.triggered_by} + + + {scan.highest_severity ? ( + + ) : ( + none + )} + + + {scan.total_vulnerabilities} + + + {scan.fixable_count} + + + + + + ); + })} + +
+
+ ))} +
+
+ )} + - setInspectScanId(null)} - canGenerateSbom={isPaid} - canCompare={false} - canManageSuppressions={isPaid && isAdmin} - /> - - + setCompareIds(null)} + /> + + setInspectScanId(null)} + canGenerateSbom={isPaid} + canCompare={false} + canManageSuppressions={isPaid && isAdmin} + /> + ); } diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx index 918f8ff9..c7fa5e8a 100644 --- a/frontend/src/components/StackAlertSheet.tsx +++ b/frontend/src/components/StackAlertSheet.tsx @@ -1,11 +1,5 @@ import { useState, useEffect } from 'react'; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from '@/components/ui/sheet'; +import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; import { AlertDialog, AlertDialogAction, @@ -20,13 +14,14 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Combobox } from '@/components/ui/combobox'; -import { ScrollArea } from '@/components/ui/scroll-area'; +import { TogglePill } from '@/components/ui/toggle-pill'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2 } from 'lucide-react'; +import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2, ChevronDown, ChevronUp } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; +import { useLicense } from '@/context/LicenseContext'; interface StackAlert { id?: number; @@ -38,10 +33,42 @@ interface StackAlert { cooldown_mins: number; } +interface AutoHealPolicy { + id?: number; + stack_name: string; + service_name: string | null; + unhealthy_duration_mins: number; + cooldown_mins: number; + max_restarts_per_hour: number; + auto_disable_after_failures: number; + enabled: number; + consecutive_failures: number; + last_fired_at: number; + created_at: number; + updated_at: number; +} + +interface AutoHealHistoryEntry { + id?: number; + policy_id: number; + stack_name: string; + service_name: string | null; + container_name: string; + container_id: string; + action: 'restarted' | 'skipped_user_action' | 'skipped_cooldown' | 'skipped_rate_limit' | 'failed' | 'policy_auto_disabled'; + reason: string; + success: number; + error: string | null; + timestamp: number; +} + +type MonitorTab = 'alerts' | 'auto-heal'; + interface StackAlertSheetProps { - isOpen: boolean; - onClose: () => void; + open: boolean; + onOpenChange: (open: boolean) => void; stackName: string; + initialTab?: MonitorTab; } interface AgentStatus { @@ -81,7 +108,60 @@ const clampNonNegative = (setter: (v: string) => void) => (e: React.ChangeEvent< setter(val); }; -export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetProps) { +function actionColorClass(action: AutoHealHistoryEntry['action']): string { + if (action === 'restarted') return 'text-success'; + if (action === 'failed' || action === 'policy_auto_disabled') return 'text-destructive'; + return 'text-muted-foreground'; +} + +function actionLabel(action: AutoHealHistoryEntry['action']): string { + switch (action) { + case 'restarted': return 'Restarted'; + case 'skipped_user_action': return 'Skipped (user action)'; + case 'skipped_cooldown': return 'Skipped (cooldown)'; + case 'skipped_rate_limit': return 'Skipped (rate limit)'; + case 'failed': return 'Failed'; + case 'policy_auto_disabled': return 'Auto-disabled'; + } +} + +export function StackAlertSheet({ open, onOpenChange, stackName, initialTab = 'alerts' }: StackAlertSheetProps) { + const { isPaid } = useLicense(); + // Per Sencho convention: paid features hide their trigger entirely. Community users + // never see the Auto-heal tab, and a stray initialTab='auto-heal' falls back to alerts. + const effectiveInitialTab: MonitorTab = !isPaid && initialTab === 'auto-heal' ? 'alerts' : initialTab; + const [activeTab, setActiveTab] = useState(effectiveInitialTab); + + useEffect(() => { + if (open) setActiveTab(effectiveInitialTab); + }, [open, effectiveInitialTab, stackName]); + + const tabs = isPaid + ? [ + { id: 'alerts', label: 'Alerts' }, + { id: 'auto-heal', label: 'Auto-heal' }, + ] + : [{ id: 'alerts', label: 'Alerts' }]; + + return ( + setActiveTab(id as MonitorTab)} + size="md" + > + {activeTab === 'alerts' && } + {activeTab === 'auto-heal' && isPaid && } + + ); +} + +function AlertsTab({ stackName }: { stackName: string }) { const { isAdmin } = useAuth(); const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; @@ -95,7 +175,6 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP enabledTypes: [], }); - // New Alert Form State const [metric, setMetric] = useState('cpu_percent'); const [operator, setOperator] = useState('>'); const [threshold, setThreshold] = useState(''); @@ -103,11 +182,10 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP const [cooldown, setCooldown] = useState('60'); useEffect(() => { - if (isOpen && stackName) { - fetchAlerts(); - fetchAgentStatus(); - } - }, [isOpen, stackName]); // eslint-disable-line react-hooks/exhaustive-deps + if (!stackName) return; + fetchAlerts(); + fetchAgentStatus(); + }, [stackName]); // eslint-disable-line react-hooks/exhaustive-deps const fetchAlerts = async () => { try { @@ -124,7 +202,6 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP const fetchAgentStatus = async () => { setAgentStatus(prev => ({ ...prev, loading: true })); try { - // Always fetch agents from the active node (proxied via x-node-id for remote) const res = await apiFetch('/agents'); if (res.ok) { const agents: Array<{ type: string; enabled: boolean }> = await res.json(); @@ -148,7 +225,6 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP toast.error('Please enter a threshold.'); return; } - setIsLoading(true); const newAlert = { stack_name: stackName, @@ -158,7 +234,6 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP duration_mins: parseInt(duration, 10), cooldown_mins: parseInt(cooldown, 10), }; - try { const res = await apiFetch('/alerts', { method: 'POST', @@ -263,179 +338,122 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP }; return ( - <> - !open && onClose()}> - - - Stack Alerts: {stackName} - - Configure metric thresholds to trigger notifications for this stack. - - + + + {renderAgentStatusBanner()} + - - -
- {/* Notification agent status banner */} - {renderAgentStatusBanner()} - - {/* List Existing Alerts */} -
-

Existing Rules

- {alerts.length === 0 ? ( -
- No active alert rules for this stack. -
- ) : ( - alerts.map(alert => ( -
-
-
- - {metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold} - -
- Trigger after {alert.duration_mins}m • Cooldown: {alert.cooldown_mins}m -
-
- {isAdmin && } -
-
- )) - )} + + {alerts.length === 0 ? ( +
+ No active alert rules for this stack. +
+ ) : ( +
+ {alerts.map(alert => ( +
+
+ + {metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold} + +
+ Trigger after {alert.duration_mins}m • Cooldown {alert.cooldown_mins}m +
- -
- - {/* Add New Alert Form */} - {isAdmin &&
-

Add New Rule

- -
-
- - - - - - -

The system resource or metric to monitor. Select from CPU, Memory, Network I/O, or Restarts.

-
-
-
- -
- -
-
-
- - - - - - -

The comparison condition to trigger the alert against the threshold.

-
-
-
- -
-
-
- - - - - - -

The numerical value the metric needs to breach to trigger the conditions.

-
-
-
- -
-
- -
-
-
- - - - - - -

How long the metric must stay in breach of the threshold before sending an alert.

-
-
-
- -
-
-
- - - - - - -

How long to wait before sending another alert if the stack continues to breach.

-
-
-
- -
-
- - -
} + )}
- - - - + ))} +
+ )} +
+ + {isAdmin && ( + +
+
+
+ + + + + + +

The system resource or metric to monitor. Select from CPU, Memory, Network I/O, or Restarts.

+
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ )} !open && setConfirmDeleteId(null)}> @@ -456,6 +474,349 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP + + ); +} + +function AutoHealTab({ stackName, open }: { stackName: string; open: boolean }) { + const [policies, setPolicies] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [serviceOptions, setServiceOptions] = useState<{ value: string; label: string }[]>([]); + + const [service, setService] = useState(''); + const [unhealthyFor, setUnhealthyFor] = useState('5'); + const [cooldown, setCooldown] = useState('5'); + const [maxRestarts, setMaxRestarts] = useState('3'); + const [autoDisableAfter, setAutoDisableAfter] = useState('5'); + + useEffect(() => { + if (!open || !stackName) return; + setLoading(true); + apiFetch(`/auto-heal/policies?stackName=${encodeURIComponent(stackName)}`) + .then(res => res.json() as Promise) + .then(data => setPolicies(data)) + .catch(() => toast.error('Failed to load auto-heal policies.')) + .finally(() => setLoading(false)); + + apiFetch(`/stacks/${encodeURIComponent(stackName)}/services`) + .then(res => res.json() as Promise) + .then(names => setServiceOptions(names.map(n => ({ value: n, label: n })))) + .catch(() => { /* services list is optional, silently skip */ }); + }, [open, stackName]); + + const handleToggle = async (id: number, enabled: boolean) => { + setSaving(true); + try { + const res = await apiFetch(`/auto-heal/policies/${id}`, { + method: 'PATCH', + body: JSON.stringify({ enabled: enabled ? 1 : 0 }), + }); + if (res.ok) { + setPolicies(prev => + prev.map(p => p.id === id ? { ...p, enabled: enabled ? 1 : 0 } : p) + ); + } else { + const err = await res.json().catch(() => ({})) as Record; + toast.error((err?.message as string) || (err?.error as string) || 'Failed to update policy.'); + } + } catch (e) { + console.error('[StackAlertSheet] Failed to toggle policy:', e); + toast.error('Network error. Could not reach the node.'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: number) => { + setDeleting(true); + try { + const res = await apiFetch(`/auto-heal/policies/${id}`, { method: 'DELETE' }); + if (res.ok) { + toast.success('Policy deleted.'); + setPolicies(prev => prev.filter(p => p.id !== id)); + } else { + const err = await res.json().catch(() => ({})) as Record; + toast.error((err?.message as string) || (err?.error as string) || 'Failed to delete policy.'); + } + } catch (e) { + console.error('[StackAlertSheet] Failed to delete policy:', e); + toast.error('Network error. Could not reach the node.'); + } finally { + setDeleting(false); + } + }; + + const handleAddPolicy = async () => { + setSaving(true); + const body = { + stack_name: stackName, + service_name: service === '' ? null : service, + unhealthy_duration_mins: parseInt(unhealthyFor, 10) || 5, + cooldown_mins: parseInt(cooldown, 10) || 5, + max_restarts_per_hour: parseInt(maxRestarts, 10) || 3, + auto_disable_after_failures: parseInt(autoDisableAfter, 10) || 5, + }; + try { + const res = await apiFetch('/auto-heal/policies', { + method: 'POST', + body: JSON.stringify(body), + }); + if (res.ok) { + toast.success('Policy added.'); + setService(''); + setUnhealthyFor('5'); + setCooldown('5'); + setMaxRestarts('3'); + setAutoDisableAfter('5'); + apiFetch(`/auto-heal/policies?stackName=${encodeURIComponent(stackName)}`) + .then(res => res.json() as Promise) + .then(data => setPolicies(data)) + .catch(() => toast.error('Failed to reload policies.')); + } else { + const err = await res.json().catch(() => ({})) as Record; + toast.error((err?.message as string) || (err?.error as string) || 'Failed to add policy.'); + console.error('[StackAlertSheet] addPolicy failed:', err); + } + } catch (e) { + console.error('[StackAlertSheet] addPolicy threw:', e); + toast.error('Network error. Could not reach the node.'); + } finally { + setSaving(false); + } + }; + + const serviceComboOptions = [ + { value: '', label: 'All services' }, + ...serviceOptions, + ]; + + return ( + <> + + {loading ? ( +
+ + Loading policies... +
+ ) : policies.length === 0 ? ( +
+ No auto-heal policies configured for this stack. +
+ ) : ( +
+ {policies.map(policy => ( + + ))} +
+ )} +
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
); } + +interface PolicyRowProps { + policy: AutoHealPolicy; + onDelete: (id: number) => void; + onToggle: (id: number, enabled: boolean) => void; + deleting: boolean; + saving: boolean; +} + +function PolicyRow({ policy, onDelete, onToggle, deleting, saving }: PolicyRowProps) { + const [historyOpen, setHistoryOpen] = useState(false); + const [history, setHistory] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(false); + + const toggleHistory = async () => { + if (!historyOpen && history.length === 0 && policy.id != null) { + setLoadingHistory(true); + try { + const res = await apiFetch(`/auto-heal/policies/${policy.id}/history`); + if (res.ok) { + const data: AutoHealHistoryEntry[] = await res.json(); + setHistory(data); + } else { + const err = await res.json().catch(() => ({})) as Record; + toast.error((err?.message as string) || (err?.error as string) || 'Failed to load history.'); + } + } catch (e) { + console.error('[StackAlertSheet] Failed to fetch history:', e); + toast.error('Network error. Could not reach the node.'); + } finally { + setLoadingHistory(false); + } + } + setHistoryOpen(prev => !prev); + }; + + return ( +
+
+
+ + {policy.service_name ?? All services} + + + Unhealthy for {policy.unhealthy_duration_mins} min + • Cooldown: {policy.cooldown_mins} min + • Max {policy.max_restarts_per_hour}/hr + + {policy.consecutive_failures > 0 && ( + + + {policy.consecutive_failures} failure{policy.consecutive_failures !== 1 ? 's' : ''} + + + )} +
+
+ policy.id != null && onToggle(policy.id, checked)} + disabled={saving} + aria-label={`Toggle policy for ${policy.service_name ?? 'all services'}`} + /> + + +
+
+ + {historyOpen && ( +
+

Recent activity

+ {history.length === 0 ? ( +

No history yet.

+ ) : ( + history.map((entry) => ( +
+ + {new Date(entry.timestamp).toLocaleString()} + + + {entry.container_name} + + + {actionLabel(entry.action)} + + + {entry.reason} + +
+ )) + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/StackAutoHealSheet.tsx b/frontend/src/components/StackAutoHealSheet.tsx deleted file mode 100644 index 01135a57..00000000 --- a/frontend/src/components/StackAutoHealSheet.tsx +++ /dev/null @@ -1,439 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from '@/components/ui/sheet'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { TogglePill } from '@/components/ui/toggle-pill'; -import { Combobox } from '@/components/ui/combobox'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Trash2, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; -import { toast } from '@/components/ui/toast-store'; -import { apiFetch } from '@/lib/api'; -import { PaidGate } from '@/components/PaidGate'; - -interface AutoHealPolicy { - id?: number; - stack_name: string; - service_name: string | null; - unhealthy_duration_mins: number; - cooldown_mins: number; - max_restarts_per_hour: number; - auto_disable_after_failures: number; - enabled: number; - consecutive_failures: number; - last_fired_at: number; - created_at: number; - updated_at: number; -} - -interface AutoHealHistoryEntry { - id?: number; - policy_id: number; - stack_name: string; - service_name: string | null; - container_name: string; - container_id: string; - action: 'restarted' | 'skipped_user_action' | 'skipped_cooldown' | 'skipped_rate_limit' | 'failed' | 'policy_auto_disabled'; - reason: string; - success: number; - error: string | null; - timestamp: number; -} - -interface StackAutoHealSheetProps { - stackName: string; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -const clampNonNegative = (setter: (v: string) => void) => (e: React.ChangeEvent) => { - let val = e.target.value; - if (val !== '' && Number(val) < 0) val = '0'; - setter(val); -}; - -function actionColorClass(action: AutoHealHistoryEntry['action']): string { - if (action === 'restarted') return 'text-success'; - if (action === 'failed' || action === 'policy_auto_disabled') return 'text-destructive'; - return 'text-muted-foreground'; -} - -function actionLabel(action: AutoHealHistoryEntry['action']): string { - switch (action) { - case 'restarted': return 'Restarted'; - case 'skipped_user_action': return 'Skipped (user action)'; - case 'skipped_cooldown': return 'Skipped (cooldown)'; - case 'skipped_rate_limit': return 'Skipped (rate limit)'; - case 'failed': return 'Failed'; - case 'policy_auto_disabled': return 'Auto-disabled'; - } -} - -interface PolicyRowProps { - policy: AutoHealPolicy; - onDelete: (id: number) => void; - onToggle: (id: number, enabled: boolean) => void; - deleting: boolean; - saving: boolean; -} - -function PolicyRow({ policy, onDelete, onToggle, deleting, saving }: PolicyRowProps) { - const [historyOpen, setHistoryOpen] = useState(false); - const [history, setHistory] = useState([]); - const [loadingHistory, setLoadingHistory] = useState(false); - - const toggleHistory = async () => { - if (!historyOpen && history.length === 0 && policy.id != null) { - setLoadingHistory(true); - try { - const res = await apiFetch(`/auto-heal/policies/${policy.id}/history`); - if (res.ok) { - const data: AutoHealHistoryEntry[] = await res.json(); - setHistory(data); - } else { - const err = await res.json().catch(() => ({})) as Record; - toast.error((err?.message as string) || (err?.error as string) || 'Failed to load history.'); - } - } catch (e) { - console.error('[StackAutoHealSheet] Failed to fetch history:', e); - toast.error('Network error. Could not reach the node.'); - } finally { - setLoadingHistory(false); - } - } - setHistoryOpen(prev => !prev); - }; - - return ( -
-
-
- - {policy.service_name ?? All services} - - - Unhealthy for {policy.unhealthy_duration_mins} min - • Cooldown: {policy.cooldown_mins} min - • Max {policy.max_restarts_per_hour}/hr - - {policy.consecutive_failures > 0 && ( - - - {policy.consecutive_failures} failure{policy.consecutive_failures !== 1 ? 's' : ''} - - - )} -
-
- policy.id != null && onToggle(policy.id, checked)} - disabled={saving} - aria-label={`Toggle policy for ${policy.service_name ?? 'all services'}`} - /> - - -
-
- - {historyOpen && ( -
-

Recent Activity

- {history.length === 0 ? ( -

No history yet.

- ) : ( - history.map((entry) => ( -
- - {new Date(entry.timestamp).toLocaleString()} - - - {entry.container_name} - - - {actionLabel(entry.action)} - - - {entry.reason} - -
- )) - )} -
- )} -
- ); -} - -export function StackAutoHealSheet({ stackName, open, onOpenChange }: StackAutoHealSheetProps) { - const [policies, setPolicies] = useState([]); - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); - const [deleting, setDeleting] = useState(false); - const [serviceOptions, setServiceOptions] = useState<{ value: string; label: string }[]>([]); - - // Form state - const [service, setService] = useState(''); - const [unhealthyFor, setUnhealthyFor] = useState('5'); - const [cooldown, setCooldown] = useState('5'); - const [maxRestarts, setMaxRestarts] = useState('3'); - const [autoDisableAfter, setAutoDisableAfter] = useState('5'); - - useEffect(() => { - if (!open || !stackName) return; - - setLoading(true); - apiFetch(`/auto-heal/policies?stackName=${encodeURIComponent(stackName)}`) - .then(res => res.json() as Promise) - .then(data => setPolicies(data)) - .catch(() => toast.error('Failed to load auto-heal policies.')) - .finally(() => setLoading(false)); - - apiFetch(`/stacks/${encodeURIComponent(stackName)}/services`) - .then(res => res.json() as Promise) - .then(names => setServiceOptions(names.map(n => ({ value: n, label: n })))) - .catch(() => { /* services list is optional, silently skip */ }); - }, [open, stackName]); - - const handleToggle = async (id: number, enabled: boolean) => { - setSaving(true); - try { - const res = await apiFetch(`/auto-heal/policies/${id}`, { - method: 'PATCH', - body: JSON.stringify({ enabled: enabled ? 1 : 0 }), - }); - if (res.ok) { - setPolicies(prev => - prev.map(p => p.id === id ? { ...p, enabled: enabled ? 1 : 0 } : p) - ); - } else { - const err = await res.json().catch(() => ({})) as Record; - toast.error((err?.message as string) || (err?.error as string) || 'Failed to update policy.'); - } - } catch (e) { - console.error('[StackAutoHealSheet] Failed to toggle policy:', e); - toast.error('Network error. Could not reach the node.'); - } finally { - setSaving(false); - } - }; - - const handleDelete = async (id: number) => { - setDeleting(true); - try { - const res = await apiFetch(`/auto-heal/policies/${id}`, { method: 'DELETE' }); - if (res.ok) { - toast.success('Policy deleted.'); - setPolicies(prev => prev.filter(p => p.id !== id)); - } else { - const err = await res.json().catch(() => ({})) as Record; - toast.error((err?.message as string) || (err?.error as string) || 'Failed to delete policy.'); - } - } catch (e) { - console.error('[StackAutoHealSheet] Failed to delete policy:', e); - toast.error('Network error. Could not reach the node.'); - } finally { - setDeleting(false); - } - }; - - const handleAddPolicy = async () => { - setSaving(true); - const body = { - stack_name: stackName, - service_name: service === '' ? null : service, - unhealthy_duration_mins: parseInt(unhealthyFor, 10) || 5, - cooldown_mins: parseInt(cooldown, 10) || 5, - max_restarts_per_hour: parseInt(maxRestarts, 10) || 3, - auto_disable_after_failures: parseInt(autoDisableAfter, 10) || 5, - }; - try { - const res = await apiFetch('/auto-heal/policies', { - method: 'POST', - body: JSON.stringify(body), - }); - if (res.ok) { - toast.success('Policy added.'); - setService(''); - setUnhealthyFor('5'); - setCooldown('5'); - setMaxRestarts('3'); - setAutoDisableAfter('5'); - apiFetch(`/auto-heal/policies?stackName=${encodeURIComponent(stackName)}`) - .then(res => res.json() as Promise) - .then(data => setPolicies(data)) - .catch(() => toast.error('Failed to reload policies.')); - } else { - const err = await res.json().catch(() => ({})) as Record; - toast.error((err?.message as string) || (err?.error as string) || 'Failed to add policy.'); - console.error('[StackAutoHealSheet] addPolicy failed:', err); - } - } catch (e) { - console.error('[StackAutoHealSheet] addPolicy threw:', e); - toast.error('Network error. Could not reach the node.'); - } finally { - setSaving(false); - } - }; - - const serviceComboOptions = [ - { value: '', label: 'All services' }, - ...serviceOptions, - ]; - - return ( - - - - - Auto-Heal Policies: {stackName} - - Configure auto-heal policies to automatically restart unhealthy containers in this stack. - - - - -
- {/* Existing policies */} -
-

Active Policies

- {loading ? ( -
- - Loading policies... -
- ) : policies.length === 0 ? ( -
- No auto-heal policies configured for this stack. -
- ) : ( - policies.map(policy => ( - - )) - )} -
- -
- - {/* Add new policy form */} -
-

Add New Policy

- -
- - -
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
-
-
-
-
- ); -} diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index a2c2818c..233c612f 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Button } from '@/components/ui/button'; import { @@ -17,7 +17,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { - ShieldCheck, ShieldOff, ExternalLink, ChevronLeft, @@ -27,11 +26,8 @@ import { Loader2, Check, GitCompare, - KeyRound, - FileWarning, } from 'lucide-react'; import { Combobox } from '@/components/ui/combobox'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Dialog, DialogContent, @@ -48,6 +44,7 @@ import { toast } from '@/components/ui/toast-store'; import { cn } from '@/lib/utils'; import { cveUrl } from '@/lib/cveUrl'; import { SEVERITY_ROW_TINT } from '@/lib/severityStyles'; +import { formatTimeAgo } from '@/lib/relativeTime'; import type { VulnerabilityScan, VulnerabilityDetail, @@ -371,40 +368,71 @@ export function VulnerabilityScanSheet({ } }, [scan]); - return ( - !open && onClose()}> - - -
- Vulnerability scan · {scan?.triggered_by ?? '-'} -
- - - - {scan?.image_ref ?? 'Loading...'} - - - - {scan - ? `Vulnerability scan results for ${scan.image_ref}: ${scan.total_vulnerabilities} total findings.` - : 'Vulnerability scan details.'} - -
+ const meta = scan + ? `${scan.total_vulnerabilities} vulns · ${scan.fixable_count} fixable · ${scan.triggered_by}` + : (loading ? 'Loading…' : 'No scan'); + const footerContext = scan + ? `Scanned ${formatTimeAgo(new Date(scan.scanned_at).getTime())}` + : undefined; + + const secondaryActions = scan ? [ + ...(canCompare ? [{ + label: 'Compare', + icon: compareLoading ? Loader2 : GitCompare, + onClick: openCompareMenu, + disabled: compareLoading, + }] : []), + ...(details.length > 0 ? [{ + label: 'CSV', + icon: Download, + onClick: exportCsv, + }] : []), + ...(canGenerateSbom && scan.status === 'completed' ? [{ + label: 'SARIF', + icon: Download, + onClick: () => { void exportSarif(); }, + }] : []), + ] : undefined; + + return ( + <> + !open && onClose()} + crumb={['Security', 'Scans', scan?.image_ref ?? '…']} + name={scan?.image_ref ?? 'Loading…'} + meta={meta} + primaryAction={onRescan && scan ? { + label: 'Re-scan', + icon: RefreshCw, + onClick: () => onRescan(scan.image_ref), + disabled: scan.status === 'in_progress', + } : undefined} + secondaryActions={secondaryActions} + tabs={scan ? [ + { id: 'vulns', label: 'Vulnerabilities', count: totalDetails }, + { id: 'secrets', label: 'Secrets', count: scan.secret_count ?? secrets.length }, + { id: 'misconfigs', label: 'Misconfigs', count: scan.misconfig_count ?? misconfigs.length }, + ] : undefined} + activeTab={tab} + onTabChange={(id) => setTab(id as FindingTab)} + footerContext={footerContext} + size="lg" + > {loading && !scan && ( -
+
)} {scan && ( -
- {/* Summary stats */} -
+ <> + {scan.policy_evaluation?.violated && (
)} -
+
{scan.critical_count > 0 && ( {scan.critical_count} CRITICAL @@ -476,19 +504,8 @@ export function VulnerabilityScanSheet({
-
- {onRescan && ( - - )} - {canGenerateSbom && ( + {canGenerateSbom && ( +
- {canGenerateSbom && ( - - )} - {canCompare && ( - - )} -
+
+ )} {compareOpen && canCompare && ( -
+
{compareOptions.length === 0 && !compareLoading ? (
No other completed scans for this image yet. Run a second scan to enable comparison. @@ -569,44 +554,11 @@ export function VulnerabilityScanSheet({ )}
)} -
+ - setTab(v as FindingTab)} - className="flex flex-col flex-1 min-h-0" - > -
- - - - Vulnerabilities - - ({totalDetails}) - - - - - Secrets - - ({scan.secret_count ?? secrets.length}) - - - - - Misconfigs - - ({scan.misconfig_count ?? misconfigs.length}) - - - -
- - -
+ {tab === 'vulns' && ( + +
{(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => (
)} - -
- {secrets.length === 0 ? ( -
- No secrets detected. -
- ) : ( - - - - Severity - Rule - Title - Target - - - - {secretsPageItems.map((s) => ( - - - - - - {s.rule_id} - - -
- {s.title || -} + + {secrets.length === 0 ? ( +
+ No secrets detected. +
+ ) : ( +
+ + + Severity + Rule + Title + Target + + + + {secretsPageItems.map((s) => ( + + + + + + {s.rule_id} + + +
+ {s.title || -} +
+ {s.match_excerpt && ( +
+ {s.match_excerpt}
- {s.match_excerpt && ( -
- {s.match_excerpt} -
- )} -
- - {s.target} - {s.start_line != null && ( - - :{s.start_line} - {s.end_line != null && s.end_line !== s.start_line - ? `-${s.end_line}` - : ''} - - )} - -
- ))} -
-
- )} -
+ )} + + + {s.target} + {s.start_line != null && ( + + :{s.start_line} + {s.end_line != null && s.end_line !== s.start_line + ? `-${s.end_line}` + : ''} + + )} + + + ))} + + + )}
- +
+ )} - + {tab === 'misconfigs' && ( + {misconfigsNeedsPagination && ( -
+
)} - -
- {misconfigs.length === 0 ? ( -
- No misconfigurations detected. -
- ) : ( - - - - Severity - Check - Title - Target - Fix - - - - {misconfigsPageItems.map((m) => ( - - - - - - {m.check_id || m.rule_id} - - -
- {m.primary_url ? ( - - {m.title || m.rule_id} - - - ) : ( - m.title || m.rule_id - )} -
- {m.message && ( -
+ {misconfigs.length === 0 ? ( +
+ No misconfigurations detected. +
+ ) : ( +
+ + + Severity + Check + Title + Target + Fix + + + + {misconfigsPageItems.map((m) => ( + + + + + + {m.check_id || m.rule_id} + + +
+ {m.primary_url ? ( + - {m.message} -
+ {m.title || m.rule_id} + + + ) : ( + m.title || m.rule_id )} -
- - {m.target} - - - {m.resolution || -} - -
- ))} -
-
- )} -
+
+ {m.message && ( +
+ {m.message} +
+ )} + + + {m.target} + + + {m.resolution || -} + + + ))} + + + )} - - -
+
+ )} + )} - + + - + ); }