mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user