Files
sencho/frontend/src/components/SecurityHistoryView.tsx
T
Anso 6354cb3387 fix(security-ui): expose Community-tier scan surfaces per PR #930 (#1070)
PR #930 opened the backend security routes to Community admin but left
several frontend isPaid gates in place, so the matching UI surfaces
stayed hidden from Community even though the API would accept the
request. This brings the UI in line with the backend matrix.

Flipped:
- ResourcesView Scan history button (was trivy.available && isPaid)
- ResourcesView Full scan (vulnerabilities + secrets) menu item
- ResourcesView VulnerabilityScanSheet canCompare
- ResourcesView VulnerabilityScanSheet canManageSuppressions (now isAdmin)
- EditorView stack-level Scan config button (canScan)
- SecurityHistoryView primaryAction (Compare)
- SecurityHistoryView VulnerabilityScanSheet canManageSuppressions

Unchanged on purpose (still paid, matching backend gates):
- canGenerateSbom (SBOM endpoint requirePaid)
- SARIF download in the drawer overflow
- Scan Policies block in Settings
- Trivy auto-update toggle (requireAdmiral)

Test: inverted the SecurityHistoryView.test.tsx assertion that locked
in the now-incorrect "hides Compare on Community" behavior.
2026-05-16 20:01:38 -04:00

324 lines
12 KiB
TypeScript

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<string, VulnerabilityScan[]>();
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<VulnerabilityScan[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [searchDraft, setSearchDraft] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<number[]>([]);
const [compareIds, setCompareIds] = useState<[number, number] | null>(null);
const [inspectScanId, setInspectScanId] = useState<number | null>(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<number | null>(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 (
<SystemSheet
open={open}
onOpenChange={(next) => { 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"
>
<SheetSection title="Scans" hideHeader>
<div className="flex items-center gap-3 mb-3 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
<Input
placeholder="Search by image..."
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
className="pl-8"
/>
</div>
{needsPagination && (
<div className="flex items-center gap-1 ml-auto" aria-live="polite">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
aria-label="Previous page"
>
<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"
aria-label={`Page ${safePage + 1} of ${totalPages}`}
>
{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}
aria-label="Next page"
>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
)}
</div>
{groups.length === 0 && !loading ? (
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
<ShieldCheck className="w-8 h-8 text-muted-foreground" strokeWidth={1.5} />
<div className="text-sm text-muted-foreground">
{search
? 'No completed scans match your search.'
: 'No scans have completed on this node yet.'}
</div>
</div>
) : (
<ScrollArea block className="max-h-[60vh]">
<div className="space-y-5">
{groups.map((group) => (
<div key={group.image_ref}>
<div className="flex items-center gap-2 mb-1.5">
<span className="font-mono text-sm truncate" title={group.image_ref}>
{group.image_ref}
</span>
<span className="text-xs text-stat-subtitle">
{group.scans.length} scan{group.scans.length === 1 ? '' : 's'}
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]" />
<TableHead className="w-[180px]">Scanned</TableHead>
<TableHead className="w-[120px]">Trigger</TableHead>
<TableHead className="w-[120px]">Highest</TableHead>
<TableHead className="w-[90px] text-right">Total</TableHead>
<TableHead className="w-[90px] text-right">Fixable</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{group.scans.map((scan) => {
const isSelected = selected.includes(scan.id);
return (
<TableRow
key={scan.id}
className={cn(isSelected && 'bg-accent/30')}
>
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(scan.id)}
aria-label={`Select scan ${scan.id}`}
/>
</TableCell>
<TableCell className="font-mono text-xs">
{new Date(scan.scanned_at).toLocaleString()}
</TableCell>
<TableCell className="font-mono text-xs capitalize">
{scan.triggered_by}
</TableCell>
<TableCell>
{scan.highest_severity ? (
<SeverityChip severity={scan.highest_severity} />
) : (
<span className="text-xs text-success font-mono">none</span>
)}
</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{scan.total_vulnerabilities}
</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums text-success">
{scan.fixable_count}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setInspectScanId(scan.id)}
>
Open
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
))}
</div>
</ScrollArea>
)}
</SheetSection>
<ScanComparisonSheet
baselineScanId={compareIds?.[0] ?? null}
currentScanId={compareIds?.[1] ?? null}
onClose={() => setCompareIds(null)}
/>
<VulnerabilityScanSheet
scanId={inspectScanId}
onClose={() => setInspectScanId(null)}
canGenerateSbom={isPaid}
canCompare={false}
canManageSuppressions={isAdmin}
/>
</SystemSheet>
);
}