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
@@ -25,7 +25,10 @@ import {
Download,
Loader2,
Check,
GitCompare,
} from 'lucide-react';
import { Combobox } from '@/components/ui/combobox';
import { ScanComparisonSheet } from './ScanComparisonSheet';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
@@ -40,6 +43,7 @@ interface VulnerabilityScanSheetProps {
onClose: () => void;
onRescan?: (imageRef: string) => void;
canGenerateSbom?: boolean;
canCompare?: boolean;
}
type SeverityFilter = 'ALL' | VulnSeverity;
@@ -72,6 +76,7 @@ export function VulnerabilityScanSheet({
onClose,
onRescan,
canGenerateSbom = false,
canCompare = false,
}: VulnerabilityScanSheetProps) {
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
@@ -80,6 +85,10 @@ export function VulnerabilityScanSheet({
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('ALL');
const [page, setPage] = useState(0);
const [downloadingSbom, setDownloadingSbom] = useState(false);
const [compareOpen, setCompareOpen] = useState(false);
const [compareOptions, setCompareOptions] = useState<VulnerabilityScan[]>([]);
const [compareLoading, setCompareLoading] = useState(false);
const [compareBaselineId, setCompareBaselineId] = useState<number | null>(null);
const DETAIL_FETCH_LIMIT = 500;
@@ -107,8 +116,12 @@ export function VulnerabilityScanSheet({
}, [scanId]);
useEffect(() => {
if (scanId != null) load();
else {
setCompareOpen(false);
setCompareOptions([]);
setCompareBaselineId(null);
if (scanId != null) {
load();
} else {
setScan(null);
setDetails([]);
setTotalDetails(0);
@@ -159,6 +172,28 @@ export function VulnerabilityScanSheet({
[scan],
);
const openCompareMenu = useCallback(async () => {
if (!scan) return;
setCompareOpen((o) => !o);
if (compareOptions.length > 0 || compareLoading) return;
setCompareLoading(true);
try {
const res = await apiFetch(
`/security/scans?imageRef=${encodeURIComponent(scan.image_ref)}&limit=25`,
);
if (!res.ok) throw new Error('Failed to load scan history');
const body = await res.json();
const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : [];
setCompareOptions(
items.filter((s) => s.id !== scan.id && s.status === 'completed'),
);
} catch (err) {
toast.error((err as Error)?.message || 'Could not load scan history');
} finally {
setCompareLoading(false);
}
}, [scan, compareOptions.length, compareLoading]);
const exportCsv = useCallback(() => {
if (!scan || details.length === 0) return;
const header = 'CVE,Package,Severity,Installed,Fixed,URL\n';
@@ -300,7 +335,49 @@ export function VulnerabilityScanSheet({
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
CSV
</Button>
{canCompare && (
<Button
variant="outline"
size="sm"
onClick={openCompareMenu}
disabled={compareLoading}
title="Compare this scan to a previous one for the same image"
>
{compareLoading ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<GitCompare className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
Compare
</Button>
)}
</div>
{compareOpen && canCompare && (
<div className="pt-2 space-y-2">
{compareOptions.length === 0 && !compareLoading ? (
<div className="rounded border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
No other completed scans for this image yet. Run a second scan to enable comparison.
</div>
) : (
<>
<div className="text-xs text-stat-subtitle uppercase tracking-wide">
Compare against
</div>
<Combobox
options={compareOptions.map((s) => ({
value: String(s.id),
label: `${new Date(s.scanned_at).toLocaleString()} - ${s.total_vulnerabilities} findings (${s.triggered_by})`,
}))}
value={compareBaselineId != null ? String(compareBaselineId) : ''}
onValueChange={(v) => setCompareBaselineId(v ? Number(v) : null)}
placeholder="Choose a baseline scan..."
searchPlaceholder="Search by date..."
/>
</>
)}
</div>
)}
</div>
{/* Severity filter tabs */}
@@ -416,6 +493,11 @@ export function VulnerabilityScanSheet({
</div>
)}
</SheetContent>
<ScanComparisonSheet
baselineScanId={compareBaselineId}
currentScanId={compareBaselineId != null ? scanId : null}
onClose={() => setCompareBaselineId(null)}
/>
</Sheet>
);
}