import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { ChevronLeft, ChevronRight, GitCompare, RefreshCw, Search, ShieldCheck, } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { cn } from '@/lib/utils'; import { ScanComparisonSheet } from './ScanComparisonSheet'; import { SeverityChip } from './VulnerabilityScanSheet'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; import { useLicense } from '@/context/LicenseContext'; import { useAuth } from '@/context/AuthContext'; import { useNodes } from '@/context/NodeContext'; import type { VulnerabilityScan } from '@/types/security'; const PAGE_SIZE = 100; interface GroupedScans { image_ref: string; scans: VulnerabilityScan[]; } function groupByImage(scans: VulnerabilityScan[]): GroupedScans[] { const map = new Map(); for (const s of scans) { const list = map.get(s.image_ref) ?? []; list.push(s); map.set(s.image_ref, list); } const groups: GroupedScans[] = []; for (const [image_ref, list] of map.entries()) { list.sort((a, b) => b.scanned_at - a.scanned_at); groups.push({ image_ref, scans: list }); } groups.sort((a, b) => (b.scans[0]?.scanned_at ?? 0) - (a.scans[0]?.scanned_at ?? 0)); return groups; } interface SecurityHistoryViewProps { open: boolean; onClose: () => void; } export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) { const { isPaid } = useLicense(); const { isAdmin } = useAuth(); const { activeNode } = useNodes(); const [scans, setScans] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [searchDraft, setSearchDraft] = useState(''); const [search, setSearch] = useState(''); const [selected, setSelected] = useState([]); const [compareIds, setCompareIds] = useState<[number, number] | null>(null); const [inspectScanId, setInspectScanId] = useState(null); const [page, setPage] = useState(0); const load = useCallback(async (pageToLoad: number, searchTerm: string) => { setLoading(true); try { const params = new URLSearchParams({ status: 'completed', limit: String(PAGE_SIZE), offset: String(pageToLoad * PAGE_SIZE), }); if (searchTerm.trim()) params.set('imageRefLike', searchTerm.trim()); const res = await apiFetch(`/security/scans?${params.toString()}`); if (!res.ok) throw new Error('Failed to load scans'); const body = await res.json(); const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : []; setScans(items); setTotal(typeof body?.total === 'number' ? body.total : items.length); } catch (err) { toast.error((err as Error)?.message || 'Could not load scan history'); } finally { setLoading(false); } }, []); const lastNodeIdRef = useRef(activeNode?.id ?? null); const [reloadToken, setReloadToken] = useState(0); useEffect(() => { const id = activeNode?.id ?? null; if (lastNodeIdRef.current === id) return; lastNodeIdRef.current = id; setSelected([]); setPage(0); setReloadToken((t) => t + 1); }, [activeNode?.id]); useEffect(() => { if (!open) return; load(page, search); // reloadToken bumps when the active node changes even if page/search // happen to match the previous values, so the fetch re-runs exactly once. }, [open, load, page, search, reloadToken]); useEffect(() => { const t = setTimeout(() => { setSearch(searchDraft); setPage(0); }, 300); return () => clearTimeout(t); }, [searchDraft]); const groups = useMemo(() => groupByImage(scans), [scans]); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const safePage = Math.min(page, totalPages - 1); const needsPagination = total > PAGE_SIZE; const toggleSelect = (scanId: number) => { setSelected((prev) => { if (prev.includes(scanId)) return prev.filter((x) => x !== scanId); if (prev.length >= 2) return [prev[1], scanId]; return [...prev, scanId]; }); }; const compareSelected = () => { if (selected.length !== 2) return; const [aId, bId] = selected; const a = scans.find((s) => s.id === aId); const b = scans.find((s) => s.id === bId); if (!a || !b) return; const [older, newer] = a.scanned_at <= b.scanned_at ? [a, b] : [b, a]; setCompareIds([older.id, newer.id]); }; 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(); }} crumb={['Security', 'Scan history']} name="Scan history" meta={meta} primaryAction={{ label: `Compare (${selected.length}/2)`, icon: GitCompare, onClick: compareSelected, disabled: compareDisabled, }} secondaryActions={[{ label: 'Refresh', icon: RefreshCw, onClick: () => load(safePage, search), disabled: loading, }]} footerContext={footerContext} size="xl" >
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)} /> setInspectScanId(null)} canGenerateSbom={isPaid} canCompare={false} canManageSuppressions={isAdmin} />
); }