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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; 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 = { 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 ( ); } 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; 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(initialFilter ?? 'all'); const [sortKey, setSortKey] = useState('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 (

Couldn't load scan results

Scan results failed to load for this node. Try again shortly.

); } if (loading) { return (
); } const noImagesAtAll = Object.values(summaries).every((s) => s.image_ref.startsWith('stack:')); if (noImagesAtAll) { return (

No scanned images

Scan an image from Resources to see its findings here.

); } return (
{isMobile ? ( <>
{ setSearch(e.target.value); setPage(0); }} className="pl-8" />
{ setSeverity(v); setPage(0); }} />
{pageItems.map((s) => ( ))}
{pageItems.length === 0 && (
No images match your search or filter.
)}
) : ( <>
{ setSearch(e.target.value); setPage(0); }} className="pl-8" />
{ setSeverity((v || 'all') as ImageFilterValue); setPage(0); }} className="w-[200px]" />
{canScan && Actions} {pageItems.map((s) => ( {formatTimeAgo(s.scanned_at)} onInspect(s.scan_id, 'vulns')} /> {canScan && ( Scan image onScan(s.image_ref, ['vuln'])}> Scan (vulnerabilities) onScan(s.image_ref, ['vuln', 'secret'])}> Full scan (vulnerabilities + secrets) )} ))}
{pageItems.length === 0 && (
No images match your search or filter.
)}
)} {sorted.length > PAGE_SIZE && (
{safePage + 1} / {totalPages}
)}
); }