feat(security): scan comparison UI (#648)

Side-by-side vulnerability scan comparison with two entry points:

- Compare button plus inline baseline picker inside the scan drawer.
- New Scan History page reachable from the Resources Hub, grouping
  completed scans by image with a checkbox selection flow.

The comparison sheet shows a severity delta ribbon, Added/Removed/Unchanged
filters, and a paginated CVE table. Cross-image comparisons are allowed
but flagged with a warning. Compare access is gated to Skipper and
Admiral tiers; the underlying /security/compare endpoint is unchanged.
This commit is contained in:
Anso
2026-04-16 23:15:36 -04:00
committed by GitHub
parent e660d2a658
commit 8ee0c0c476
8 changed files with 806 additions and 6 deletions
@@ -0,0 +1,348 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/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,
GitCompare,
Loader2,
MinusCircle,
PlusCircle,
ShieldCheck,
Equal,
AlertTriangle,
} from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
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<VulnSeverity, number> = {
CRITICAL: 0,
HIGH: 1,
MEDIUM: 2,
LOW: 3,
UNKNOWN: 4,
};
function sortBySeverity<T extends { severity: VulnSeverity }>(rows: T[]): T[] {
return [...rows].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
}
function countBySeverity(rows: Array<{ severity: VulnSeverity }>): Record<VulnSeverity, number> {
const counts: Record<VulnSeverity, number> = {
CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, UNKNOWN: 0,
};
for (const r of rows) counts[r.severity] += 1;
return counts;
}
function formatDelta(added: number, removed: number): { text: string; tone: 'success' | 'warning' | 'muted' } {
const net = added - removed;
if (net > 0) return { text: `+${net}`, tone: 'warning' };
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<ScanCompareResult | null>(null);
const [filter, setFilter] = useState<DiffFilter>('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<ScanCompareVulnerability[]>(() => {
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;
return (
<Sheet open={open} onOpenChange={(o) => !o && onClose()}>
<SheetContent className="sm:max-w-4xl flex flex-col p-0">
<SheetHeader className="p-6 pb-4 border-b">
<SheetTitle className="flex items-center gap-2 pr-6">
<GitCompare className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<span className="font-mono text-sm truncate">
Compare scans
</span>
</SheetTitle>
<SheetDescription className="sr-only">
Side-by-side comparison of two vulnerability scans showing added, removed, and unchanged findings.
</SheetDescription>
</SheetHeader>
{loading && (
<div className="flex items-center justify-center flex-1">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
</div>
)}
{data && !loading && (
<div className="flex flex-col flex-1 min-h-0">
{/* Scan identification */}
<div className="px-6 py-4 border-b space-y-3">
<div className="flex items-center gap-3 text-xs font-mono tabular-nums">
<div className="flex-1 min-w-0">
<div className="text-stat-subtitle uppercase tracking-wide text-[10px]">Baseline</div>
<div className="text-stat-value truncate">{data.scanA.image_ref}</div>
<div className="text-stat-subtitle">{new Date(data.scanA.scanned_at).toLocaleString()}</div>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<div className="flex-1 min-w-0">
<div className="text-stat-subtitle uppercase tracking-wide text-[10px]">Current</div>
<div className="text-stat-value truncate">{data.scanB.image_ref}</div>
<div className="text-stat-subtitle">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
</div>
</div>
{crossImage && (
<div className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
<span>
You are comparing scans from two different image references. Package-level changes may reflect image differences rather than CVE drift.
</span>
</div>
)}
{/* Delta ribbon */}
{addedCounts && removedCounts && (
<div className="flex flex-wrap gap-2">
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as VulnSeverity[]).map((sev) => {
const delta = formatDelta(addedCounts[sev], removedCounts[sev]);
const toneClass =
delta.tone === 'warning'
? 'text-warning border-warning/40 bg-warning/10'
: delta.tone === 'success'
? 'text-success border-success/40 bg-success/10'
: 'text-muted-foreground border-border bg-muted/30';
return (
<span
key={sev}
aria-label={`${sev} delta ${delta.text}`}
className={cn(
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-xs font-mono tabular-nums',
toneClass,
)}
>
<span className="uppercase tracking-wide text-[10px]">{sev}</span>
<span>{delta.text}</span>
</span>
);
})}
</div>
)}
</div>
{/* Filter pills */}
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap">
<Button
variant={filter === 'added' ? 'default' : 'ghost'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => { setFilter('added'); setPage(0); }}
>
<PlusCircle className="w-3 h-3 mr-1" strokeWidth={1.5} />
Added ({data.added.length})
</Button>
<Button
variant={filter === 'removed' ? 'default' : 'ghost'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => { setFilter('removed'); setPage(0); }}
>
<MinusCircle className="w-3 h-3 mr-1" strokeWidth={1.5} />
Removed ({data.removed.length})
</Button>
<Button
variant={filter === 'unchanged' ? 'default' : 'ghost'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => { setFilter('unchanged'); setPage(0); }}
>
<Equal className="w-3 h-3 mr-1" strokeWidth={1.5} />
Unchanged ({data.unchanged.length})
</Button>
{needsPagination && (
<div className="flex items-center gap-1 ml-auto">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
>
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
disabled={safePage >= totalPages - 1}
>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
)}
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="px-6 py-3">
{pageItems.length === 0 ? (
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
<ShieldCheck className="w-8 h-8 text-success" strokeWidth={1.5} />
<div className="text-sm text-muted-foreground">
{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.'}
</div>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">CVE</TableHead>
<TableHead>Package</TableHead>
<TableHead className="w-[100px]">Severity</TableHead>
<TableHead className="w-[110px]">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((v, idx) => {
const rowClass =
filter === 'added'
? 'bg-destructive/5'
: filter === 'removed'
? 'bg-success/5'
: 'opacity-70';
return (
<TableRow key={`${v.vulnerability_id}-${v.pkg_name}-${idx}`} className={rowClass}>
<TableCell className="font-mono text-xs">
{v.primary_url ? (
<a
href={v.primary_url}
target="_blank"
rel="noreferrer noopener"
className="hover:underline"
>
{v.vulnerability_id}
</a>
) : (
v.vulnerability_id
)}
</TableCell>
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={v.pkg_name}>
{v.pkg_name}
</TableCell>
<TableCell>
<SeverityChip severity={v.severity} />
</TableCell>
<TableCell className="font-mono text-xs">
{filter === 'added' && (
<span className="inline-flex items-center gap-1 text-destructive">
<PlusCircle className="w-3 h-3" strokeWidth={1.5} />
Added
</span>
)}
{filter === 'removed' && (
<span className="inline-flex items-center gap-1 text-success">
<MinusCircle className="w-3 h-3" strokeWidth={1.5} />
Removed
</span>
)}
{filter === 'unchanged' && (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Equal className="w-3 h-3" strokeWidth={1.5} />
Unchanged
</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</ScrollArea>
</div>
)}
</SheetContent>
</Sheet>
);
}
export type { ScanComparisonSheetProps };