mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
083442d5ea
Security page UX fixes: - Stop the CVSS x EPSS scatter chart from painting a full-plot "white rectangle" cursor on click (cursor disabled), and prevent click-drag selection on charts. - Differentiate the overview action links: "fixable" links (masthead primary action, review-queue blocker, and the Fixable signal tile) now open the Images tab pre-filtered to fixable findings; the Stale and Failed signal tiles link to the History tab where those scans are listed; Secrets and Misconfigs tiles link to their tabs. The Images tab accepts an initialFilter and exposes a Fixable option in the severity dropdown. - Fix the "Secrets / misconfigs" option wrapping and misaligning in the severity dropdown (single-line option labels, wider trigger). - Add Edit for CVE suppressions and misconfig acknowledgements (reason, scope pattern, expiry), reusing the existing dialog and the existing PUT endpoints; the CVE/rule identity stays fixed.
311 lines
14 KiB
TypeScript
311 lines
14 KiB
TypeScript
import { useEffect, 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 { getSeverityKey, type SeverityKey, type ImageFilterValue } from '@/lib/severityStyles';
|
|
import { formatTimeAgo } from '@/lib/relativeTime';
|
|
import { cn } from '@/lib/utils';
|
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
|
import { ImageScanRow, ImageFilterChips, type ImageFilterChip } from './SecurityMobile';
|
|
import type { ScanSummary, ScanDetailTab, ScannerKind } from '@/types/security';
|
|
|
|
// Mobile severity chips. 'FIXABLE' is a phone-only pseudo-filter (the desktop
|
|
// Combobox never emits it), so the shared filter logic treats it specially.
|
|
const MOBILE_FILTER_CHIPS: ImageFilterChip[] = [
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'CRITICAL', label: 'Critical' },
|
|
{ value: 'HIGH', label: 'High' },
|
|
{ value: 'FIXABLE', label: 'Fixable' },
|
|
{ value: 'CLEAN', label: 'Clean' },
|
|
];
|
|
|
|
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: ImageFilterValue; label: string }> = [
|
|
{ value: 'all', label: 'All severities' },
|
|
{ value: 'FIXABLE', label: 'Fixable' },
|
|
{ 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>;
|
|
loading: boolean;
|
|
/** 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;
|
|
/** Preselects the severity/fixable filter, e.g. when arriving from an
|
|
* overview "fixable findings" link. Applied whenever the value changes. */
|
|
initialFilter?: ImageFilterValue;
|
|
}
|
|
|
|
/** Latest-scan index for real images (stack/config scans live in Compose risks). */
|
|
export function ImagesTab({ summaries, loading, error, onInspect, canScan, scanningRef, onScan, initialFilter }: ImagesTabProps) {
|
|
const isMobile = useIsMobile();
|
|
const [search, setSearch] = useState('');
|
|
const [severity, setSeverity] = useState<ImageFilterValue>(initialFilter ?? 'all');
|
|
const [sortKey, setSortKey] = useState<SortKey>('scanned_at');
|
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
|
const [page, setPage] = useState(0);
|
|
|
|
// Apply an externally-driven filter (e.g. an overview "fixable" deep link).
|
|
// Keyed on the incoming value so re-navigating to the same filter re-applies.
|
|
useEffect(() => {
|
|
if (initialFilter) { setSeverity(initialFilter); setPage(0); }
|
|
}, [initialFilter]);
|
|
|
|
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) => {
|
|
if (severity === 'all') return true;
|
|
if (severity === 'FIXABLE') return s.fixable > 0;
|
|
return 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 (
|
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
<AlertTriangle className="w-12 h-12 text-warning/60 mb-4" strokeWidth={1.5} />
|
|
<h3 className="text-lg font-medium mb-1">Couldn't load scan results</h3>
|
|
<p className="text-sm text-muted-foreground">Scan results failed to load for this node. Try again shortly.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-2" aria-busy="true">
|
|
<Skeleton className="h-12 w-full rounded-lg" />
|
|
<Skeleton className="h-12 w-full rounded-lg" />
|
|
<Skeleton className="h-12 w-full rounded-lg" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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} />
|
|
<h3 className="text-lg font-medium mb-1">No scanned images</h3>
|
|
<p className="text-sm text-muted-foreground">Scan an image from Resources to see its findings here.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{isMobile ? (
|
|
<>
|
|
<div className="relative">
|
|
<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="Filter images…"
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
<ImageFilterChips
|
|
chips={MOBILE_FILTER_CHIPS}
|
|
active={severity}
|
|
onSelect={(v) => { setSeverity(v); setPage(0); }}
|
|
/>
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
|
<div className="px-4">
|
|
{pageItems.map((s) => (
|
|
<ImageScanRow key={s.image_ref} summary={s} onInspect={onInspect} />
|
|
))}
|
|
</div>
|
|
{pageItems.length === 0 && (
|
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
|
No images match your search or filter.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<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') as ImageFilterValue); setPage(0); }}
|
|
className="w-[200px]"
|
|
/>
|
|
</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>
|
|
);
|
|
}
|