feat: chart-led Security overview with sortable Images and History tables (#1364)

* feat: chart-led Security overview with sortable Images and History tables

Refine the Security page around the existing design system and add the
data the dashboard needs.

- Overview leads with four charts (30-day risk trend, severity donut, top
  exposed images, findings by type); the signal-rail counts become a
  secondary summary, and the scanner and deploy-enforcement posture follow.
- Images becomes a recessed table with search, a severity filter, sortable
  columns, a last-scan column, and inline scan actions; the findings cell is
  clickable into the scan sheet, and the per-row cursor tooltip is dropped
  where the columns already carry that information.
- Policies puts deploy-enforcement first, collapses the policy packs into an
  accordion, and uses the standard primary button for Add policy.
- Suppressions and acknowledgements move their titles and Add buttons outside
  the cards, matching the Fleet tab layout.
- History switches from the detail sheet to an inline table (search, sortable
  columns, two-scan compare, pagination); the now-unreachable scan-history
  overlay is removed.
- Add GET /api/security/overview/trend, a node-scoped daily critical/high
  rollup backing the risk-trend chart.
- Extract the shared image-scan hook and the severity classifier, and harden
  the overview data fetch so a malformed non-critical response can never read
  as a clean security state.

* fix: treat malformed Security responses as errors, not empty or clean states

Address an independent review of the data-fetch paths so a 200 with an
unexpected shape can never read as a benign "no findings" view.

- SecurityView: validate that the image-summaries body is a scan-summary map; an
  unexpected shape now sets the error state instead of an empty map. Isolate the
  trend fetch in its own self-catching promise so a transport failure on the
  non-critical chart can no longer poison the overview or summaries error state.
- useImageScan: only a "completed" poll counts as success (a malformed or unknown
  status now throws), and a failed post-scan summaries refresh is logged instead
  of silently dropped.
- HistoryTab: a 200 whose body lacks an items array is treated as an error, not
  an empty "no completed scans" list.
This commit is contained in:
Anso
2026-06-12 14:35:03 -04:00
committed by GitHub
parent 1b96f3b980
commit 3d39d856a3
31 changed files with 1570 additions and 931 deletions
@@ -0,0 +1,233 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { ChevronLeft, ChevronRight, GitCompare, RefreshCw, Search, ArrowUp, ArrowDown } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import { useNodes } from '@/context/NodeContext';
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
import { SeverityChip } from '../VulnerabilityScanSheet';
import { ScanComparisonSheet } from '../ScanComparisonSheet';
import type { VulnerabilityScan, ScanDetailTab, VulnSeverity } from '@/types/security';
const PAGE_SIZE = 100;
const SEVERITY_RANK: Record<VulnSeverity, number> = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 };
type SortKey = 'scanned_at' | 'image_ref' | 'severity' | 'total';
/** Sortable column header. Module-scoped so it is a stable component. */
function SortHead({ label, k, sortKey, sortDir, onSort, align }: {
label: string;
k: SortKey;
sortKey: SortKey;
sortDir: 'asc' | 'desc';
onSort: (k: SortKey) => void;
align?: 'right';
}) {
return (
<TableHead className={cn('text-[11px] cursor-pointer select-none', align === 'right' && 'text-right')}>
<button type="button" onClick={() => onSort(k)} className={cn('inline-flex items-center gap-1 hover:text-stat-value', align === 'right' && 'flex-row-reverse')}>
{label}
{sortKey === k && (sortDir === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />)}
</button>
</TableHead>
);
}
interface HistoryTabProps {
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
}
/** Inline scan-history table: search, sortable columns, two-scan compare, and
* server-paginated completed scans. Replaces the former history sheet. */
export function HistoryTab({ onInspect }: HistoryTabProps) {
const { activeNode } = useNodes();
const nodeId = activeNode?.id;
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [page, setPage] = useState(0);
const [searchDraft, setSearchDraft] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<number[]>([]);
const [compareIds, setCompareIds] = useState<[number, number] | null>(null);
const [sortKey, setSortKey] = useState<SortKey>('scanned_at');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const load = useCallback(async (pageToLoad: number, term: string) => {
setLoading(true);
setError(false);
try {
const params = new URLSearchParams({
status: 'completed',
limit: String(PAGE_SIZE),
offset: String(pageToLoad * PAGE_SIZE),
});
if (term.trim()) params.set('imageRefLike', term.trim());
const res = await apiFetch(`/security/scans?${params.toString()}`);
if (!res.ok) {
setError(true);
return;
}
const data = await res.json();
if (!data || !Array.isArray(data.items)) {
// A 200 with an unexpected shape must surface as an error, not as an
// empty "no completed scans yet" state.
setError(true);
return;
}
setScans(data.items);
setTotal(typeof data.total === 'number' ? data.total : data.items.length);
} catch (err) {
console.error('[Security] Failed to load scan history:', err);
toast.error('Failed to load scan history');
setError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(safePage, search); }, [load, safePage, search, nodeId]);
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 toggleSort = (key: SortKey) => {
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else { setSortKey(key); setSortDir(key === 'image_ref' ? 'asc' : 'desc'); }
};
const sorted = useMemo(() => {
const dir = sortDir === 'asc' ? 1 : -1;
return [...scans].sort((a, b) => {
switch (sortKey) {
case 'image_ref': return a.image_ref.localeCompare(b.image_ref) * dir;
case 'severity': return (SEVERITY_RANK[a.highest_severity ?? 'UNKNOWN'] - SEVERITY_RANK[b.highest_severity ?? 'UNKNOWN']) * dir;
case 'total': return (a.total_vulnerabilities - b.total_vulnerabilities) * dir;
default: return (a.scanned_at - b.scanned_at) * dir;
}
});
}, [scans, sortKey, sortDir]);
return (
<div className="space-y-4">
<FleetTabHeading
title="Scan history"
subtitle="Completed scans across this node. Select two to compare."
action={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={compareSelected} disabled={selected.length !== 2}>
<GitCompare className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Compare ({selected.length}/2)
</Button>
<Button variant="outline" size="sm" onClick={() => load(safePage, search)} disabled={loading}>
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} strokeWidth={1.5} />
</Button>
</div>
}
/>
<div className="relative max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<Input
placeholder="Search by image..."
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { setPage(0); setSearch(searchDraft); } }}
className="pl-8"
/>
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<ScrollArea className="max-h-[60vh] bg-background">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[40px]" />
<SortHead label="Image" k="image_ref" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<SortHead label="Last scanned" k="scanned_at" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<TableHead className="text-[11px]">Trigger</TableHead>
<SortHead label="Severity" k="severity" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<SortHead label="Findings" k="total" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} align="right" />
<TableHead className="text-right text-[11px]">Fixable</TableHead>
<TableHead className="text-right text-[11px]">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!loading && !error && sorted.map((scan) => {
const isSelected = selected.includes(scan.id);
return (
<TableRow key={scan.id} className={cn('hover:bg-muted/30 transition-colors', isSelected && 'bg-accent/30')}>
<TableCell>
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelect(scan.id)} aria-label="Select scan to compare" />
</TableCell>
<TableCell className="font-mono text-xs truncate max-w-[280px]">{scan.image_ref}</TableCell>
<TableCell className="font-mono text-xs text-stat-subtitle whitespace-nowrap">{new Date(scan.scanned_at).toLocaleString()}</TableCell>
<TableCell className="font-mono text-xs capitalize text-stat-subtitle">{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={() => onInspect(scan.id, 'vulns')}>Open</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{loading && <div className="py-12 text-center text-sm text-muted-foreground">Loading scan history...</div>}
{!loading && error && <div className="py-12 text-center text-sm text-muted-foreground">Couldn't load scan history. Try again.</div>}
{!loading && !error && sorted.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
{search ? 'No scans match your search.' : 'No completed scans yet. Scan an image from the Images tab.'}
</div>
)}
</ScrollArea>
</div>
{total > PAGE_SIZE && (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setPage(Math.max(0, safePage - 1))} disabled={safePage === 0}>
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
</Button>
<span className="text-xs text-stat-subtitle tabular-nums px-1">{safePage + 1} / {totalPages}</span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))} disabled={safePage >= totalPages - 1}>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
)}
<ScanComparisonSheet
baselineScanId={compareIds?.[0] ?? null}
currentScanId={compareIds?.[1] ?? null}
onClose={() => setCompareIds(null)}
/>
</div>
);
}
+204 -44
View File
@@ -1,8 +1,56 @@
import { useMemo } from 'react';
import { Boxes, AlertTriangle } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Boxes, AlertTriangle, Search, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ShieldCheck, Loader2 } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Combobox } from '@/components/ui/combobox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { SeverityBadge } from '@/components/ui/SeverityBadge';
import type { ScanSummary, ScanDetailTab } from '@/types/security';
import { getSeverityKey, type SeverityKey } from '@/lib/severityStyles';
import { formatTimeAgo } from '@/lib/relativeTime';
import { cn } from '@/lib/utils';
import type { ScanSummary, ScanDetailTab, ScannerKind } from '@/types/security';
const PAGE_SIZE = 12;
type SortKey = 'image_ref' | 'scanned_at' | 'severity' | 'findings';
const SEVERITY_RANK: Record<SeverityKey, number> = {
CRITICAL: 6, HIGH: 5, MEDIUM: 4, LOW: 3, UNKNOWN: 2, FINDINGS: 1, CLEAN: 0,
};
/** Sortable column header. Module-scoped so it is a stable component. */
function SortHead({ label, k, sortKey, sortDir, onSort, className }: {
label: string;
k: SortKey;
sortKey: SortKey;
sortDir: 'asc' | 'desc';
onSort: (k: SortKey) => void;
className?: string;
}) {
return (
<TableHead className={cn('text-[10px] uppercase tracking-[0.18em] cursor-pointer select-none', className)}>
<button type="button" onClick={() => onSort(k)} className="inline-flex items-center gap-1 hover:text-stat-value">
{label}
{sortKey === k && (sortDir === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />)}
</button>
</TableHead>
);
}
const FILTER_OPTIONS: Array<{ value: 'all' | SeverityKey; label: string }> = [
{ value: 'all', label: 'All severities' },
{ value: 'CRITICAL', label: 'Critical' },
{ value: 'HIGH', label: 'High' },
{ value: 'MEDIUM', label: 'Medium' },
{ value: 'LOW', label: 'Low' },
{ value: 'FINDINGS', label: 'Secrets / misconfigs' },
{ value: 'CLEAN', label: 'Clean' },
];
const findingsCount = (s: ScanSummary) => s.total + (s.secret_count ?? 0) + (s.misconfig_count ?? 0);
interface ImagesTabProps {
summaries: Record<string, ScanSummary>;
@@ -10,17 +58,50 @@ interface ImagesTabProps {
/** True when the summaries fetch failed; render an error state, never a false "clean". */
error?: boolean;
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
/** Admin on a node with a ready scanner; gates the scan Actions column. */
canScan: boolean;
/** image_ref of the scan currently in flight, for the per-row spinner. */
scanningRef: string | null;
onScan: (imageRef: string, scanners: ScannerKind[]) => void;
}
/** Latest-scan index for real images (stack/config scans live in Compose risks). */
export function ImagesTab({ summaries, loading, error, onInspect }: ImagesTabProps) {
const images = useMemo(
() =>
Object.values(summaries)
.filter((s) => !s.image_ref.startsWith('stack:'))
.sort((a, b) => b.scanned_at - a.scanned_at),
[summaries],
);
export function ImagesTab({ summaries, loading, error, onInspect, canScan, scanningRef, onScan }: ImagesTabProps) {
const [search, setSearch] = useState('');
const [severity, setSeverity] = useState('all');
const [sortKey, setSortKey] = useState<SortKey>('scanned_at');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(0);
const filtered = useMemo(() => {
const term = search.trim().toLowerCase();
return Object.values(summaries)
.filter((s) => !s.image_ref.startsWith('stack:'))
.filter((s) => (term ? s.image_ref.toLowerCase().includes(term) : true))
.filter((s) => (severity === 'all' ? true : getSeverityKey(s) === severity));
}, [summaries, search, severity]);
const sorted = useMemo(() => {
const dir = sortDir === 'asc' ? 1 : -1;
return [...filtered].sort((a, b) => {
switch (sortKey) {
case 'image_ref': return a.image_ref.localeCompare(b.image_ref) * dir;
case 'severity': return (SEVERITY_RANK[getSeverityKey(a)] - SEVERITY_RANK[getSeverityKey(b)]) * dir;
case 'findings': return (findingsCount(a) - findingsCount(b)) * dir;
default: return (a.scanned_at - b.scanned_at) * dir;
}
});
}, [filtered, sortKey, sortDir]);
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pageItems = sorted.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
const toggleSort = (key: SortKey) => {
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else { setSortKey(key); setSortDir(key === 'image_ref' ? 'asc' : 'desc'); }
setPage(0);
};
if (error) {
return (
@@ -42,7 +123,8 @@ export function ImagesTab({ summaries, loading, error, onInspect }: ImagesTabPro
);
}
if (images.length === 0) {
const noImagesAtAll = Object.values(summaries).every((s) => s.image_ref.startsWith('stack:'));
if (noImagesAtAll) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Boxes className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
@@ -53,38 +135,116 @@ export function ImagesTab({ summaries, loading, error, onInspect }: ImagesTabPro
}
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-card-border">
<th className="text-left font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2">Image</th>
<th className="text-left font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2 max-md:hidden">Findings</th>
<th className="text-right font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2">Severity</th>
</tr>
</thead>
<tbody>
{images.map((s) => (
<tr key={s.image_ref} className="border-b border-card-border/40 last:border-0 hover:bg-glass-highlight">
<td className="px-4 py-2.5 font-mono text-xs truncate max-w-0 w-full">
<button type="button" className="hover:text-brand truncate block w-full text-left" onClick={() => onInspect(s.scan_id, 'vulns')}>
{s.image_ref}
</button>
</td>
<td className="px-4 py-2.5 font-mono tabular-nums text-xs text-stat-subtitle max-md:hidden">
{s.critical > 0 && <span className="text-destructive mr-2">{s.critical}C</span>}
{s.high > 0 && <span className="text-warning mr-2">{s.high}H</span>}
{s.secret_count > 0 && <span className="text-warning mr-2">{s.secret_count} secret</span>}
{s.misconfig_count > 0 && <span className="text-warning mr-2">{s.misconfig_count} misconfig</span>}
{s.fixable > 0 && <span className="text-stat-subtitle">{s.fixable} fixable</span>}
{s.total === 0 && s.secret_count === 0 && s.misconfig_count === 0 && <span className="text-success">clean</span>}
</td>
<td className="px-4 py-2.5 text-right">
<SeverityBadge summary={s} onClick={() => onInspect(s.scan_id, 'vulns')} />
</td>
</tr>
))}
</tbody>
</table>
<div className="space-y-4">
<div className="flex items-center gap-2 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<Input
placeholder="Search images..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="pl-8"
/>
</div>
<Combobox
options={FILTER_OPTIONS}
value={severity}
onValueChange={(v) => { setSeverity(v || 'all'); setPage(0); }}
className="w-[180px]"
/>
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<ScrollArea className="max-h-[62vh] bg-background">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<SortHead label="Image" k="image_ref" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<SortHead label="Findings" k="findings" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} className="max-md:hidden" />
<SortHead label="Last scan" k="scanned_at" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} className="max-md:hidden" />
<SortHead label="Severity" k="severity" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
{canScan && <TableHead className="text-right text-[10px] uppercase tracking-[0.18em]">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((s) => (
<TableRow key={s.image_ref} className="hover:bg-muted/30 transition-colors">
<TableCell className="font-mono text-xs truncate max-w-[280px]">
<button type="button" className="hover:text-brand truncate block w-full text-left" onClick={() => onInspect(s.scan_id, 'vulns')}>
{s.image_ref}
</button>
</TableCell>
<TableCell className="max-md:hidden">
<button
type="button"
onClick={() => onInspect(s.scan_id, 'vulns')}
className="font-mono tabular-nums text-xs text-stat-subtitle text-left hover:text-stat-value transition-colors"
>
{s.critical > 0 && <span className="text-destructive mr-2">{s.critical}C</span>}
{s.high > 0 && <span className="text-warning mr-2">{s.high}H</span>}
{s.secret_count > 0 && <span className="text-warning mr-2">{s.secret_count} secret</span>}
{s.misconfig_count > 0 && <span className="text-warning mr-2">{s.misconfig_count} misconfig</span>}
{s.fixable > 0 && <span className="text-stat-subtitle">{s.fixable} fixable</span>}
{findingsCount(s) === 0 && <span className="text-success">clean</span>}
</button>
</TableCell>
<TableCell className="font-mono text-xs text-stat-subtitle whitespace-nowrap max-md:hidden">
{formatTimeAgo(s.scanned_at)}
</TableCell>
<TableCell>
<SeverityBadge summary={s} tooltip={false} onClick={() => onInspect(s.scan_id, 'vulns')} />
</TableCell>
{canScan && (
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
disabled={scanningRef === s.image_ref}
title="Scan image"
aria-label={`Scan ${s.image_ref}`}
>
{scanningRef === s.image_ref
? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
: <ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onScan(s.image_ref, ['vuln'])}>
Scan (vulnerabilities)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onScan(s.image_ref, ['vuln', 'secret'])}>
Full scan (vulnerabilities + secrets)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
{pageItems.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
No images match your search or filter.
</div>
)}
</ScrollArea>
</div>
{sorted.length > PAGE_SIZE && (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" 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 text-stat-subtitle tabular-nums px-1">{safePage + 1} / {totalPages}</span>
<Button variant="ghost" size="icon" className="h-7 w-7" 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>
);
}
@@ -1,15 +1,25 @@
import { ShieldOff } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { SignalRail, type SignalTile } from '@/components/ui/SignalRail';
import { cn } from '@/lib/utils';
import { formatTimeAgo } from '@/lib/relativeTime';
import type { SecurityOverview } from '@/types/security';
import type { SecurityOverview, ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
import type { SecurityTab } from '@/lib/events';
import {
SeverityDonutChart,
RiskTrendChart,
TopExposedImagesChart,
FindingsByTypeChart,
} from './SecurityCharts';
interface OverviewTabProps {
overview: SecurityOverview | null;
/** 'unsupported' = node has no overview endpoint (benign); 'failed' = a real error. */
loadError: 'unsupported' | 'failed' | null;
summaries: Record<string, ScanSummary>;
trend: SecurityRiskTrendPoint[];
onNavigate: (tab: SecurityTab) => void;
onInspect: (scanId: number) => void;
}
const STATUS_ROW_TONE: Record<'value' | 'warn' | 'subtitle', string> = {
@@ -28,7 +38,16 @@ function StatusRow({ label, value, tone }: { label: string; value: string; tone?
);
}
export function OverviewTab({ overview, loadError, onNavigate }: OverviewTabProps) {
function ChartCard({ title, className, children }: { title: string; className?: string; children: React.ReactNode }) {
return (
<div className={cn('rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4', className)}>
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle mb-3">{title}</h3>
{children}
</div>
);
}
export function OverviewTab({ overview, loadError, summaries, trend, onNavigate, onInspect }: OverviewTabProps) {
if (loadError === 'unsupported') {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
@@ -56,12 +75,14 @@ export function OverviewTab({ overview, loadError, onNavigate }: OverviewTabProp
if (!overview) {
return (
<div className="space-y-4" aria-busy="true">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-56 w-full rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
</div>
);
}
const summaryList = Object.values(summaries);
const tiles: SignalTile[] = [
{ kicker: 'Scanned images', value: String(overview.scannedImages) },
{ kicker: 'Fixable', value: String(overview.fixable), tone: overview.fixable > 0 ? 'warn' : 'value' },
@@ -77,8 +98,26 @@ export function OverviewTab({ overview, loadError, onNavigate }: OverviewTabProp
return (
<div className="space-y-6">
{/* Signal rail of supporting counts. Wrapped so a phone scrolls the rail
instead of crushing the fixed columns. */}
{/* Charts lead the dashboard. */}
<div className="grid gap-4 lg:grid-cols-3">
<ChartCard title="Risk trend · 30 days · critical + high" className="lg:col-span-2">
<RiskTrendChart trend={trend} />
</ChartCard>
<ChartCard title="Severity distribution">
<SeverityDonutChart summaries={summaryList} />
</ChartCard>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<ChartCard title="Top exposed images">
<TopExposedImagesChart summaries={summaryList} onInspect={onInspect} />
</ChartCard>
<ChartCard title="Findings by type">
<FindingsByTypeChart summaries={summaryList} />
</ChartCard>
</div>
{/* Supporting counts + posture, secondary to the charts above. */}
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden max-md:overflow-x-auto">
<div className="min-w-[640px]">
<SignalRail tiles={tiles} className="border-b-0" />
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import { apiFetch } from '@/lib/api';
@@ -30,6 +31,15 @@ function EnforcementBadge({ enforcement }: { enforcement: PolicyPackRule['enforc
export function PolicyPacksTab() {
const [packs, setPacks] = useState<PolicyPack[] | null>(null);
const [error, setError] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
useEffect(() => {
let cancelled = false;
@@ -62,8 +72,8 @@ export function PolicyPacksTab() {
if (!packs) {
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-40 w-full rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
<Skeleton className="h-16 w-full rounded-lg" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
);
}
@@ -75,38 +85,61 @@ export function PolicyPacksTab() {
Community: they explain what good looks like. Block-on-deploy enforcement is an Admiral capability.
</p>
{packs.map((pack) => (
<div key={pack.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<div className="border-b border-card-border px-4 py-3">
<h3 className="font-display italic text-[18px] leading-6 text-stat-value">{pack.name}</h3>
<p className="text-sm text-muted-foreground">{pack.tagline}</p>
<p className="text-xs text-stat-subtitle mt-1">{pack.tierCopy}</p>
</div>
<ul className="divide-y divide-card-border/40">
{pack.rules.map((rule) => (
<li key={rule.id} className="px-4 py-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium text-sm">{rule.name}</span>
<span className={cn('font-mono text-[10px] uppercase tracking-[0.18em]', SEVERITY_TEXT[rule.severity])}>
{rule.severity}
</span>
</div>
<EnforcementBadge enforcement={rule.enforcement} />
<div className="space-y-3">
{packs.map((pack) => {
const isOpen = expanded.has(pack.id);
return (
<div key={pack.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<button
type="button"
onClick={() => toggle(pack.id)}
aria-expanded={isOpen}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-glass-highlight transition-colors"
>
{isOpen
? <ChevronDown className="w-4 h-4 text-stat-subtitle shrink-0" strokeWidth={1.5} />
: <ChevronRight className="w-4 h-4 text-stat-subtitle shrink-0" strokeWidth={1.5} />}
<div className="min-w-0 flex-1">
<h3 className="font-display italic text-[18px] leading-6 text-stat-value">{pack.name}</h3>
<p className="text-sm text-muted-foreground">{pack.tagline}</p>
</div>
<dl className="mt-2 grid gap-1.5 text-xs sm:grid-cols-[7rem_1fr]">
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Checks</dt>
<dd className="text-stat-subtitle">{rule.whatItChecks}</dd>
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Why</dt>
<dd className="text-stat-subtitle">{rule.why}</dd>
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Fix</dt>
<dd className="text-stat-subtitle">{rule.howToFix}</dd>
</dl>
</li>
))}
</ul>
</div>
))}
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle shrink-0 tabular-nums">
{pack.rules.length} rule{pack.rules.length === 1 ? '' : 's'}
</span>
</button>
{isOpen && (
<div className="border-t border-card-border">
<p className="px-4 py-2 text-xs text-stat-subtitle">{pack.tierCopy}</p>
<ul className="divide-y divide-card-border/40 border-t border-card-border/40">
{pack.rules.map((rule) => (
<li key={rule.id} className="px-4 py-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium text-sm">{rule.name}</span>
<span className={cn('font-mono text-[10px] uppercase tracking-[0.18em]', SEVERITY_TEXT[rule.severity])}>
{rule.severity}
</span>
</div>
<EnforcementBadge enforcement={rule.enforcement} />
</div>
<dl className="mt-2 grid gap-1.5 text-xs sm:grid-cols-[7rem_1fr]">
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Checks</dt>
<dd className="text-stat-subtitle">{rule.whatItChecks}</dd>
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Why</dt>
<dd className="text-stat-subtitle">{rule.why}</dd>
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Fix</dt>
<dd className="text-stat-subtitle">{rule.howToFix}</dd>
</dl>
</li>
))}
</ul>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -252,10 +252,10 @@ export function ScanPolicyManager() {
<div className="flex items-center justify-between gap-3">
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">Deploy enforcement policies</h3>
{isAdmin && !isRemote && !isReplica && (
<SettingsPrimaryButton size="sm" onClick={openCreate}>
<Plus className="w-4 h-4" />
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add policy
</SettingsPrimaryButton>
</Button>
)}
</div>
@@ -0,0 +1,171 @@
import { useMemo } from 'react';
import { PieChart, Pie, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, LabelList } from 'recharts';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
// Severity palette stays within the design's semantic tokens: --destructive
// (critical), --warning (high), a muted --warning (medium), --muted-foreground
// (low). No new chart hue.
const SEVERITY_CONFIG = {
critical: { label: 'Critical', color: 'var(--destructive)' },
high: { label: 'High', color: 'var(--warning)' },
medium: { label: 'Medium', color: 'color-mix(in oklch, var(--warning) 55%, var(--muted))' },
low: { label: 'Low', color: 'var(--muted-foreground)' },
} satisfies ChartConfig;
// The trend and top-exposed charts both plot only the Critical + High slots.
const CRITICAL_HIGH_CONFIG = {
critical: SEVERITY_CONFIG.critical,
high: SEVERITY_CONFIG.high,
} satisfies ChartConfig;
function EmptyChart({ label, height }: { label: string; height: number }) {
return (
<div className="flex items-center justify-center text-xs text-stat-subtitle" style={{ height }}>
{label}
</div>
);
}
/** Donut of total findings by severity across the node's scanned images. */
export function SeverityDonutChart({ summaries }: { summaries: ScanSummary[] }) {
const data = useMemo(() => {
const totals = { critical: 0, high: 0, medium: 0, low: 0 };
for (const s of summaries) {
totals.critical += s.critical;
totals.high += s.high;
totals.medium += s.medium;
totals.low += s.low;
}
return (['critical', 'high', 'medium', 'low'] as const)
.map((k) => ({ key: k, label: SEVERITY_CONFIG[k].label, value: totals[k], fill: `var(--color-${k})` }))
.filter((d) => d.value > 0);
}, [summaries]);
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) return <EmptyChart label="No findings to chart" height={220} />;
return (
<ChartContainer config={SEVERITY_CONFIG} className="h-[220px] w-full">
<PieChart>
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
<Pie data={data} dataKey="value" nameKey="label" innerRadius={55} outerRadius={85} strokeWidth={2} paddingAngle={2} />
</PieChart>
</ChartContainer>
);
}
/** Stacked area of Critical + High findings by scan-day (days with no scans are omitted). */
export function RiskTrendChart({ trend }: { trend: SecurityRiskTrendPoint[] }) {
if (trend.length === 0) return <EmptyChart label="No scan history yet" height={220} />;
const fmtDate = (d: string) => d.slice(5); // MM-DD
return (
<ChartContainer config={CRITICAL_HIGH_CONFIG} className="h-[220px] w-full">
<AreaChart data={trend} margin={{ left: 4, right: 8, top: 8 }}>
<defs>
<linearGradient id="riskHigh" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-high)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-high)" stopOpacity={0.02} />
</linearGradient>
<linearGradient id="riskCritical" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-critical)" stopOpacity={0.4} />
<stop offset="95%" stopColor="var(--color-critical)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="date" tickFormatter={fmtDate} tickLine={false} axisLine={false} fontSize={10} minTickGap={24} />
<YAxis tickLine={false} axisLine={false} fontSize={10} width={28} allowDecimals={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area dataKey="high" stackId="risk" stroke="var(--color-high)" fill="url(#riskHigh)" strokeWidth={1.5} />
<Area dataKey="critical" stackId="risk" stroke="var(--color-critical)" fill="url(#riskCritical)" strokeWidth={1.5} />
</AreaChart>
</ChartContainer>
);
}
interface TopImageDatum { name: string; critical: number; high: number; scanId: number }
/** Horizontal stacked bars of the top images by Critical+High; click opens the scan. */
export function TopExposedImagesChart({
summaries,
onInspect,
}: {
summaries: ScanSummary[];
onInspect: (scanId: number) => void;
}) {
const data: TopImageDatum[] = useMemo(
() =>
summaries
.filter((s) => !s.image_ref.startsWith('stack:') && s.critical + s.high > 0)
.sort((a, b) => b.critical + b.high - (a.critical + a.high))
.slice(0, 6)
.map((s) => ({
name: s.image_ref.length > 28 ? `${s.image_ref.slice(-27)}` : s.image_ref,
critical: s.critical,
high: s.high,
scanId: s.scan_id,
})),
[summaries],
);
if (data.length === 0) return <EmptyChart label="No exposed images" height={220} />;
const handleBarClick = (d: unknown) => {
const dd = d as TopImageDatum;
if (dd?.scanId != null) onInspect(dd.scanId);
};
return (
<ChartContainer config={CRITICAL_HIGH_CONFIG} className="h-[220px] w-full">
<BarChart data={data} layout="vertical" margin={{ left: 8, right: 16 }}>
<XAxis type="number" hide allowDecimals={false} />
<YAxis type="category" dataKey="name" width={150} tickLine={false} axisLine={false} fontSize={10} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="critical" stackId="r" fill="var(--color-critical)" radius={[2, 0, 0, 2]} className="cursor-pointer" onClick={handleBarClick} />
<Bar dataKey="high" stackId="r" fill="var(--color-high)" radius={[0, 2, 2, 0]} className="cursor-pointer" onClick={handleBarClick} />
</BarChart>
</ChartContainer>
);
}
/** Vertical bars comparing the three finding types. */
export function FindingsByTypeChart({ summaries }: { summaries: ScanSummary[] }) {
const data = useMemo(() => {
let vulnerabilities = 0;
let secrets = 0;
let misconfigs = 0;
for (const s of summaries) {
vulnerabilities += s.total;
secrets += s.secret_count;
misconfigs += s.misconfig_count;
}
return [
{ type: 'Vulnerabilities', value: vulnerabilities, fill: 'var(--brand)' },
{ type: 'Secrets', value: secrets, fill: 'var(--destructive)' },
{ type: 'Misconfigs', value: misconfigs, fill: 'var(--warning)' },
];
}, [summaries]);
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) return <EmptyChart label="No findings to chart" height={220} />;
const config = {
value: { label: 'Findings' },
} satisfies ChartConfig;
return (
<ChartContainer config={config} className="h-[220px] w-full">
<BarChart data={data} margin={{ left: 4, right: 8, top: 16 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="type" tickLine={false} axisLine={false} fontSize={10} />
<YAxis tickLine={false} axisLine={false} fontSize={10} width={28} allowDecimals={false} />
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} maxBarSize={64}>
<LabelList dataKey="value" position="top" className="fill-stat-subtitle" fontSize={10} />
</Bar>
</BarChart>
</ChartContainer>
);
}
@@ -0,0 +1,151 @@
/**
* HistoryTab is the inline scan-history table that replaced the history sheet.
* Locks: completed-scan fetch on mount with pagination params, Open -> inspect
* on the vulns tab, two-scan compare capped at two with oldest-first baseline
* ordering, search-by-image, and the load-failure error state.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { VulnerabilityScan } from '@/types/security';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } };
vi.mock('@/context/NodeContext', () => ({ useNodes: () => nodesState }));
const compareProps: { baselineScanId: number | null; currentScanId: number | null }[] = [];
vi.mock('../../ScanComparisonSheet', () => ({
ScanComparisonSheet: (props: { baselineScanId: number | null; currentScanId: number | null }) => {
compareProps.push({ baselineScanId: props.baselineScanId, currentScanId: props.currentScanId });
return null;
},
}));
vi.mock('../../VulnerabilityScanSheet', () => ({
SeverityChip: ({ severity }: { severity: string }) => <span>{severity}</span>,
}));
import { apiFetch } from '@/lib/api';
import { HistoryTab } from '../HistoryTab';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
function scan(overrides: Partial<VulnerabilityScan> = {}): VulnerabilityScan {
return {
id: 1,
node_id: 1,
image_ref: 'alpine:3.19',
image_digest: null,
scanned_at: 1_700_000_000_000,
total_vulnerabilities: 0,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'vuln',
highest_severity: null,
os_info: null,
trivy_version: null,
scan_duration_ms: null,
triggered_by: 'manual',
status: 'completed',
error: null,
stack_context: null,
...overrides,
};
}
function listResponse(items: VulnerabilityScan[], total?: number): Response {
return { ok: true, status: 200, json: async () => ({ items, total: total ?? items.length }) } as unknown as Response;
}
beforeEach(() => {
mockedFetch.mockReset();
compareProps.length = 0;
nodesState.activeNode = { id: 1 };
});
afterEach(() => vi.clearAllMocks());
describe('HistoryTab', () => {
it('fetches completed scans on mount with pagination params', async () => {
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
const url = mockedFetch.mock.calls[0][0] as string;
expect(url).toContain('/security/scans?');
expect(url).toContain('status=completed');
expect(url).toContain('limit=100');
expect(url).toContain('offset=0');
});
it('opens the scan sheet on the vulns tab from Open', async () => {
const onInspect = vi.fn();
mockedFetch.mockResolvedValue(listResponse([scan({ id: 42, image_ref: 'nginx:1' })]));
render(<HistoryTab onInspect={onInspect} />);
await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: 'Open' }));
expect(onInspect).toHaveBeenCalledWith(42, 'vulns');
});
it('compares two scans with the older as baseline and newer as current', async () => {
const older = scan({ id: 10, image_ref: 'a:1', scanned_at: 1000 });
const newer = scan({ id: 20, image_ref: 'b:1', scanned_at: 2000 });
mockedFetch.mockResolvedValue(listResponse([newer, older]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument());
const checks = screen.getAllByLabelText('Select scan to compare');
await userEvent.click(checks[0]);
await userEvent.click(checks[1]);
await userEvent.click(screen.getByRole('button', { name: /Compare/ }));
const last = compareProps[compareProps.length - 1];
expect(last.baselineScanId).toBe(10);
expect(last.currentScanId).toBe(20);
});
it('caps the compare selection at two', async () => {
mockedFetch.mockResolvedValue(listResponse([
scan({ id: 1, image_ref: 'a:1', scanned_at: 3000 }),
scan({ id: 2, image_ref: 'b:1', scanned_at: 2000 }),
scan({ id: 3, image_ref: 'c:1', scanned_at: 1000 }),
]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument());
const checks = screen.getAllByLabelText('Select scan to compare');
await userEvent.click(checks[0]);
await userEvent.click(checks[1]);
await userEvent.click(checks[2]);
expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeInTheDocument();
});
it('searches by image on Enter, adding imageRefLike to the request', async () => {
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
await userEvent.type(screen.getByPlaceholderText('Search by image...'), 'redis{Enter}');
await waitFor(() => {
const calls = mockedFetch.mock.calls.map((c) => c[0] as string);
expect(calls.some((u) => u.includes('imageRefLike=redis'))).toBe(true);
});
});
it('renders the error state when the load fails', async () => {
mockedFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as unknown as Response);
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText(/Couldn't load scan history/)).toBeInTheDocument());
});
it('treats a malformed 200 response (no items array) as an error, not an empty list', async () => {
mockedFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ oops: true }) } as unknown as Response);
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText(/Couldn't load scan history/)).toBeInTheDocument());
expect(screen.queryByText(/No completed scans yet/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,122 @@
/**
* ImagesTab is a prop-driven index over the node's image-scan summaries. It
* filters out stack/config scans, supports search + a severity filter, opens
* the scan sheet from the image name and the Findings cell, and exposes inline
* scan actions only when the caller can scan.
*/
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ImagesTab } from '../ImagesTab';
import type { ScanSummary } from '@/types/security';
function summary(o: Partial<ScanSummary> & { image_ref: string; scan_id: number }): ScanSummary {
return {
highest_severity: null,
scanned_at: Date.now(),
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
unknown: 0,
fixable: 0,
secret_count: 0,
misconfig_count: 0,
...o,
};
}
function asMap(...list: ScanSummary[]): Record<string, ScanSummary> {
return Object.fromEntries(list.map((s) => [s.image_ref, s]));
}
const base = {
loading: false,
error: false,
onInspect: vi.fn(),
canScan: false,
scanningRef: null as string | null,
onScan: vi.fn(),
};
beforeEach(() => vi.clearAllMocks());
it('renders real images and excludes stack/config scans', () => {
render(
<ImagesTab
{...base}
summaries={asMap(
summary({ image_ref: 'nginx:1', scan_id: 1, highest_severity: 'CRITICAL', total: 5, critical: 5 }),
summary({ image_ref: 'stack:web', scan_id: 2, misconfig_count: 3 }),
)}
/>,
);
expect(screen.getByText('nginx:1')).toBeInTheDocument();
expect(screen.queryByText('stack:web')).not.toBeInTheDocument();
});
it('opens the scan sheet on the vulns tab from the image name', async () => {
const onInspect = vi.fn();
render(
<ImagesTab
{...base}
onInspect={onInspect}
summaries={asMap(summary({ image_ref: 'nginx:1', scan_id: 7, highest_severity: 'HIGH', total: 2, high: 2 }))}
/>,
);
await userEvent.click(screen.getByText('nginx:1'));
expect(onInspect).toHaveBeenCalledWith(7, 'vulns');
});
it('opens the scan sheet on the vulns tab from the Findings cell', async () => {
const onInspect = vi.fn();
render(
<ImagesTab
{...base}
onInspect={onInspect}
summaries={asMap(summary({ image_ref: 'nginx:1', scan_id: 9 }))}
/>,
);
await userEvent.click(screen.getByText('clean'));
expect(onInspect).toHaveBeenCalledWith(9, 'vulns');
});
it('narrows the list with the search box', async () => {
render(
<ImagesTab
{...base}
summaries={asMap(
summary({ image_ref: 'nginx:1', scan_id: 1 }),
summary({ image_ref: 'redis:7', scan_id: 2 }),
)}
/>,
);
await userEvent.type(screen.getByPlaceholderText('Search images...'), 'redis');
expect(screen.getByText('redis:7')).toBeInTheDocument();
expect(screen.queryByText('nginx:1')).not.toBeInTheDocument();
});
it('narrows the list with the severity filter', async () => {
render(
<ImagesTab
{...base}
summaries={asMap(
summary({ image_ref: 'crit:1', scan_id: 1, highest_severity: 'CRITICAL', total: 1, critical: 1 }),
summary({ image_ref: 'low:1', scan_id: 2, highest_severity: 'LOW', total: 1, low: 1 }),
)}
/>,
);
await userEvent.click(screen.getByText('All severities'));
await userEvent.click(screen.getByText('Critical'));
expect(screen.getByText('crit:1')).toBeInTheDocument();
expect(screen.queryByText('low:1')).not.toBeInTheDocument();
});
it('shows the scan action only when scanning is allowed', () => {
const data = asMap(summary({ image_ref: 'nginx:1', scan_id: 1 }));
const { rerender } = render(<ImagesTab {...base} canScan={false} summaries={data} />);
expect(screen.queryByLabelText('Scan nginx:1')).not.toBeInTheDocument();
rerender(<ImagesTab {...base} canScan={true} summaries={data} />);
expect(screen.getByLabelText('Scan nginx:1')).toBeInTheDocument();
});
@@ -5,6 +5,7 @@
*/
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
@@ -44,18 +45,27 @@ beforeEach(() => {
mockedFetch.mockResolvedValue(jsonResponse(200, PACKS));
});
it('fetches the catalog with localOnly and renders packs and rules', async () => {
it('fetches the catalog with localOnly and reveals rules when a pack is expanded', async () => {
const user = userEvent.setup();
render(<PolicyPacksTab />);
await waitFor(() => expect(screen.getByText('Homelab baseline')).toBeInTheDocument());
expect(screen.getByText('Strict production')).toBeInTheDocument();
expect(mockedFetch).toHaveBeenCalledWith('/security/policy-packs', { localOnly: true });
// Rules are collapsed behind the accordion until the pack header is clicked.
expect(screen.queryByText('Pin image tags')).not.toBeInTheDocument();
await user.click(screen.getByText('Homelab baseline'));
await user.click(screen.getByText('Strict production'));
expect(screen.getByText('Pin image tags')).toBeInTheDocument();
expect(screen.getByText('No privileged containers')).toBeInTheDocument();
expect(mockedFetch).toHaveBeenCalledWith('/security/policy-packs', { localOnly: true });
});
it('labels rules as warning or enforceable', async () => {
it('labels expanded rules as warning or enforceable', async () => {
const user = userEvent.setup();
render(<PolicyPacksTab />);
await waitFor(() => expect(screen.getByText('Warning')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Homelab baseline')).toBeInTheDocument());
await user.click(screen.getByText('Homelab baseline'));
await user.click(screen.getByText('Strict production'));
expect(screen.getByText('Warning')).toBeInTheDocument();
expect(screen.getByText('Enforceable')).toBeInTheDocument();
});