import { useCallback, useEffect, useMemo, useState } from 'react'; import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Button } from '@/components/ui/button'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { ArrowRight, ChevronLeft, ChevronRight, Loader2, MinusCircle, PlusCircle, ShieldCheck, ShieldOff, Equal, AlertTriangle, } from 'lucide-react'; import { apiFetch } from '@/lib/api'; 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 { SeverityChip } from './VulnerabilityScanSheet'; import type { ScanCompareResult, ScanCompareVulnerability, VulnSeverity, } from '@/types/security'; interface ScanComparisonSheetProps { baselineScanId: number | null; currentScanId: number | null; onClose: () => void; } type DiffFilter = 'added' | 'removed' | 'unchanged'; const PAGE_SIZE = 25; const SEVERITY_ORDER: Record = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, UNKNOWN: 4, }; function sortBySeverity(rows: T[]): T[] { return [...rows].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); } function countBySeverity(rows: Array<{ severity: VulnSeverity }>): Record { const counts: Record = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, UNKNOWN: 0, }; for (const r of rows) counts[r.severity] += 1; return counts; } type DeltaTone = 'success' | 'warning' | 'destructive' | 'muted'; const DELTA_TONE_CLASS: Record = { destructive: 'text-destructive border-destructive/40 bg-destructive/10', warning: 'text-warning border-warning/40 bg-warning/10', success: 'text-success border-success/40 bg-success/10', muted: 'text-muted-foreground border-border bg-muted/30', }; function formatDelta( severity: VulnSeverity, added: number, removed: number, ): { text: string; tone: DeltaTone } { const net = added - removed; if (net > 0) { const tone: DeltaTone = severity === 'CRITICAL' ? 'destructive' : 'warning'; return { text: `+${net}`, tone }; } if (net < 0) return { text: `${net}`, tone: 'success' }; return { text: '0', tone: 'muted' }; } export function ScanComparisonSheet({ baselineScanId, currentScanId, onClose, }: ScanComparisonSheetProps) { const [loading, setLoading] = useState(false); const [data, setData] = useState(null); const [filter, setFilter] = useState('added'); const [page, setPage] = useState(0); const load = useCallback(async () => { if (baselineScanId == null || currentScanId == null) return; setLoading(true); setData(null); setPage(0); setFilter('added'); try { const res = await apiFetch( `/security/compare?scanId1=${baselineScanId}&scanId2=${currentScanId}`, ); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body?.error || 'Failed to load comparison'); } const body = (await res.json()) as ScanCompareResult; setData(body); } catch (err) { toast.error((err as Error)?.message || 'Failed to load comparison'); onClose(); } finally { setLoading(false); } }, [baselineScanId, currentScanId, onClose]); useEffect(() => { if (baselineScanId != null && currentScanId != null) load(); }, [baselineScanId, currentScanId, load]); const open = baselineScanId != null && currentScanId != null; const addedCounts = useMemo(() => (data ? countBySeverity(data.added) : null), [data]); const removedCounts = useMemo(() => (data ? countBySeverity(data.removed) : null), [data]); const crossImage = data != null && data.scanA.image_ref !== data.scanB.image_ref; const rows = useMemo(() => { if (!data) return []; if (filter === 'added') return sortBySeverity(data.added); if (filter === 'removed') return sortBySeverity(data.removed); return sortBySeverity(data.unchanged as ScanCompareVulnerability[]); }, [data, filter]); const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages - 1); 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()} crumb={['Security', 'Scans', 'Compare']} name="Diff" meta={meta} footerContext={footerContext} size="xl" > {loading && (
)} {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.
)} {addedCounts && removedCounts && (
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as VulnSeverity[]).map((sev) => { const delta = formatDelta(sev, addedCounts[sev], removedCounts[sev]); return ( {sev} {delta.text} ); })}
)}
{needsPagination && (
{safePage + 1} / {totalPages}
)}
{pageItems.length === 0 ? (
{filter === 'added' && 'No new findings. Nothing regressed between these scans.'} {filter === 'removed' && 'No findings were resolved between these scans.'} {filter === 'unchanged' && 'No findings are shared between the two scans.'}
) : ( CVE Package Severity Status {pageItems.map((v, idx) => { const href = cveUrl(v.vulnerability_id, v.primary_url); const rowClass = cn( SEVERITY_ROW_TINT[v.severity], filter === 'unchanged' && 'opacity-75', v.suppressed && 'opacity-60', ); return ( {v.suppressed && ( )} {href ? ( {v.vulnerability_id} ) : ( v.vulnerability_id )} {v.pkg_name} {filter === 'added' && ( Added )} {filter === 'removed' && ( Removed )} {filter === 'unchanged' && ( {crossImage ? 'Shared' : 'Unchanged'} )} ); })}
)}
)}
); } export type { ScanComparisonSheetProps };