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