Files
sencho/frontend/src/components/security/ImagesTab.tsx
T
Anso 083442d5ea fix: differentiate security action links and add suppression editing (#1500)
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.
2026-06-28 06:17:47 -04:00

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>
);
}