import { useEffect, useMemo, useRef, useState, type ReactNode } 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 { NetworkExposedControl, ViewNetworkingAction } from './ExposureNetworking'; import type { ImagesTargetingState } from './imagesTargeting'; import { intentionalBannerKind, standingExposureContexts, standingIntentEvidence, targetingExposureContexts, allTargetingExposureContexts, primaryExposureIntentEvidence, driverIdsForImage, } from './imagesTargeting'; import type { ImageExposureContext, 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); /** Intent evidence for standing summary, or targeting when active for this image. */ function intentEvidenceFor( summary: ScanSummary, targeting: ImagesTargetingState | null | undefined, ): string | null { if (targeting?.imageRefs.includes(summary.image_ref)) { const fromTargets = primaryExposureIntentEvidence(targeting.targets, summary.image_ref); if (fromTargets) return fromTargets; } return standingIntentEvidence(summary); } function contextsForImage( summary: ScanSummary, targeting: ImagesTargetingState | null | undefined, ): ImageExposureContext[] { if (targeting?.imageRefs.includes(summary.image_ref)) { const fromTargets = targetingExposureContexts(targeting.targets, summary.image_ref); if (fromTargets.length > 0) return fromTargets; } return standingExposureContexts(summary); } function IntentEvidenceLine({ line }: { line: string | null }) { if (!line) return null; return (
{line}
); } const CLEAR_BTN_CLASS = 'text-xs font-medium text-brand hover:underline whitespace-nowrap shrink-0'; function TargetingClearButton({ onClear }: { onClear?: () => void }) { if (!onClear) return null; return ( ); } function TargetingBannerFrame({ children }: { children: ReactNode }) { return (
{children}
); } function formatTargetingTitle(label: string, matched: number, total: number): string { if (matched < total) { return `${label} · ${matched} of ${total} affected images`; } return `${label} · ${matched} affected image${matched === 1 ? '' : 's'}`; } function IntentionalExposureBanner({ kind, unavailableCount, contexts, nodeId, onClear, }: { kind: 'absolute' | 'partial'; unavailableCount: number; contexts: ImageExposureContext[]; nodeId?: number; onClear?: () => void; }) { const title = kind === 'absolute' ? 'Exposure is intentional' : 'Known exposure is intentional'; const body = kind === 'absolute' ? 'This workload is classified in Networking. Exposure still increases the security relevance of these findings. Open an affected image below to remediate or triage its findings.' : `Known exposure contexts are intentionally classified. Intent could not be verified for ${unavailableCount} service${unavailableCount === 1 ? '' : 's'}. Open an affected image below to remediate or triage its findings.`; return (

{title}

{body}

); } 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, driverVulnerabilityIds?: string[]) => 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. */ initialFilter?: ImageFilterValue; /** Bumped by SecurityView on each filter/targeting navigation so re-apply works. */ filterToken?: number; /** Parent-owned posture targeting (R1). */ targeting?: ImagesTargetingState | null; onClearTargeting?: () => void; /** When true, targeting banner discloses the overview pass may be incomplete. */ posturePartial?: boolean; /** Active node id for SENCHO_OPEN_STACK Networking navigation. */ nodeId?: number; } /** Latest-scan index for real images (stack/config scans live in Compose risks). */ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scanningRef, onScan, initialFilter, filterToken = 0, targeting = null, onClearTargeting, posturePartial = false, nodeId, }: 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); const [searchExpanded, setSearchExpanded] = useState(false); const searchInputRef = useRef(null); useEffect(() => { if (searchExpanded) searchInputRef.current?.focus(); }, [searchExpanded]); // Apply externally-driven filter / targeting. Keyed on tokens so repeating the // same navigation re-applies after Clear (R1) and resets severity (R2). useEffect(() => { if (targeting) { setSeverity(initialFilter ?? 'all'); setPage(0); return; } if (initialFilter) { setSeverity(initialFilter); setPage(0); } }, [targeting?.token, filterToken, targeting, initialFilter]); const imageSummaries = useMemo( () => Object.values(summaries).filter((s) => !s.image_ref.startsWith('stack:')), [summaries], ); const matchedTargetMeta = useMemo(() => { if (!targeting || targeting.imageRefs.length === 0) { return { active: false as const, matched: 0, total: 0, refs: null as Set | null }; } const wanted = new Set(targeting.imageRefs); const refs = new Set( imageSummaries.filter((s) => wanted.has(s.image_ref)).map((s) => s.image_ref), ); return { active: true as const, matched: refs.size, total: targeting.imageRefs.length, refs, label: targeting.label, }; }, [targeting, imageSummaries]); // R3: never filter the list at zero matches; fall back to the full list. const targetingActive = matchedTargetMeta.active && matchedTargetMeta.matched > 0; const filtered = useMemo(() => { const term = search.trim().toLowerCase(); const targetRefs = targetingActive ? matchedTargetMeta.refs : null; return imageSummaries .filter((s) => (targetRefs ? targetRefs.has(s.image_ref) : true)) .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; }); }, [imageSummaries, search, severity, targetingActive, matchedTargetMeta.refs]); 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 = imageSummaries.length === 0; if (noImagesAtAll) { return (
{targeting && onClearTargeting ? (

None of the images for this Security action have a scan summary on this node.

) : null}

No scanned images

Scan an image from Resources to see its findings here.

); } let targetingBanner: ReactNode = null; if (matchedTargetMeta.active && matchedTargetMeta.matched === 0) { targetingBanner = (

None of the images for this Security action have a scan summary on this node. Showing all images.

); } else if (matchedTargetMeta.active && matchedTargetMeta.matched > 0 && targeting) { const isExposure = targeting.kind === 'public_exposure'; const hasConflict = isExposure && targeting.targets.some((t) => t.intentConflict); const driverCount = targeting.drivers?.length ?? 0; const intentional = isExposure && !hasConflict ? intentionalBannerKind(targeting.targets, { truncated: posturePartial }) : { kind: 'none' as const, unavailableCount: 0 }; if (hasConflict) { targetingBanner = (

Exposure conflicts with declared intent

Compose publishes beyond loopback while Networking intent is internal or same-node. Review networking to align configuration with intent.

); } else if (intentional.kind === 'absolute' || intentional.kind === 'partial') { targetingBanner = ( ); } else { const partialMatch = matchedTargetMeta.matched < matchedTargetMeta.total; const fullDriverCount = targeting.driverCount ?? driverCount; const drivingTitle = driverCount > 0; const monitoringKinds = new Set(['waiting_upstream', 'update_check_uncertain']); const monitoringMode = monitoringKinds.has(targeting.kind); const truncated = targeting.driversTruncated === true && fullDriverCount > driverCount && driverCount > 0; const driverTitle = monitoringMode ? (truncated ? `Findings under Monitoring · showing ${driverCount} of ${fullDriverCount}` : `Findings under Monitoring · ${fullDriverCount} finding${fullDriverCount === 1 ? '' : 's'}`) : (truncated ? `Driving current Security action · showing ${driverCount} of ${fullDriverCount}` : `Driving current Security action · ${fullDriverCount} finding${fullDriverCount === 1 ? '' : 's'}`); targetingBanner = (

{drivingTitle ? driverTitle : formatTargetingTitle( matchedTargetMeta.label, matchedTargetMeta.matched, matchedTargetMeta.total, )}

{drivingTitle ? (monitoringMode ? 'Open an image to review findings under Monitoring for this reason.' : 'Open an image to review the exact findings driving this Security action.') : 'Showing images responsible for the current Security action.'} {partialMatch ? ' An affected image has no scan summary on this node.' : ''} {posturePartial ? ' The overview pass may be incomplete.' : ''}

); } } const inspectDriversFor = (imageRef: string) => driverIdsForImage(targeting?.drivers, imageRef); return (
{targetingBanner} {isMobile ? ( <> {search !== '' || searchExpanded ? (
{ setSearch(e.target.value); setPage(0); }} onBlur={() => { if (search === '') setSearchExpanded(false); }} className="pl-8" />
) : ( Search images )} { setSeverity(v); setPage(0); }} />
{pageItems.map((s) => ( ))}
{pageItems.length === 0 && (
No images match your search or filter.
)}
) : ( <>
{search !== '' || searchExpanded ? (
{ setSearch(e.target.value); setPage(0); }} onBlur={() => { if (search === '') setSearchExpanded(false); }} className="pl-8" />
) : ( Search images )} { setSeverity((v || 'all') as ImageFilterValue); setPage(0); }} className="w-[200px] [&>button]:!bg-background" />
{canScan && Actions} {pageItems.map((s) => (
{s.publicly_exposed === true ? ( ) : null}
{formatTimeAgo(s.scanned_at)} onInspect(s.scan_id, 'vulns', inspectDriversFor(s.image_ref))} /> {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}
)}
); }