import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { LayoutDashboard, Boxes, FileWarning, KeyRound, BookCheck, EyeOff, History as HistoryIcon, Wrench, Info, } from 'lucide-react'; import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; import { PageMasthead, type MastheadTone } from '@/components/ui/PageMasthead'; import { CapabilityGate } from '@/components/CapabilityGate'; import { deriveMasthead, SCANNER_DETECTIONS_NOTE } from './security/securityMasthead'; import { springs } from '@/lib/motion'; import { apiFetch } from '@/lib/api'; import { formatTimeAgo } from '@/lib/relativeTime'; import { useAuth } from '@/context/AuthContext'; import { useNodes } from '@/context/NodeContext'; import { useImageScan } from '@/hooks/useImageScan'; import { useIsMobile } from '@/hooks/use-is-mobile'; import { Masthead, type Tone } from './mobile/mobile-ui'; import { SecurityMobileTabs, type SecurityMobileTab } from './security/SecurityMobile'; import type { SecurityTab } from '@/lib/events'; import type { ImageFilterValue } from '@/lib/severityStyles'; import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, ExploitIntelFinding, FleetRole } from '@/types/security'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; import { SuppressionsPanel } from './settings/SuppressionsPanel'; import { MisconfigAckPanel } from './settings/MisconfigAckPanel'; import { OverviewTab } from './security/OverviewTab'; import { reasonImageFilter } from './security/postureNavigation'; import { ImagesTab } from './security/ImagesTab'; import { FindingsTab } from './security/FindingsTab'; import { ScanPolicyManager } from './security/ScanPolicyManager'; import { ScannerSetupTab } from './security/ScannerSetupTab'; import { HistoryTab } from './security/HistoryTab'; /** A /security/image-summaries 200 body must be a map of scan summaries. An * unexpected shape is treated as an error, never as a benign "no findings". An * empty object is valid (a node with no scans yet). */ function isScanSummaryMap(v: unknown): v is Record { if (!v || typeof v !== 'object' || Array.isArray(v)) return false; return Object.values(v).every( (s) => !!s && typeof s === 'object' && typeof (s as ScanSummary).image_ref === 'string' && typeof (s as ScanSummary).scan_id === 'number', ); } interface SecurityViewProps { activeTab: SecurityTab; onTabChange: (tab: SecurityTab) => void; /** Notifications + more-menu cluster for the mobile masthead right slot. * Passed only on the bespoke phone surface; absent on desktop. */ headerActions?: ReactNode; } // Maps the masthead tone (shared with the desktop PageMasthead) onto the mobile // masthead's dot tone, state-word color, and whether the dot pulses. Idle reads // as an amber caution (the mobile dot has no neutral grey). type StateWordClass = 'text-destructive' | 'text-warning' | 'text-stat-value' | 'text-stat-title'; const MOBILE_MASTHEAD_TONE: Record = { error: { dot: 'destructive', word: 'text-destructive', pulse: true }, warn: { dot: 'warning', word: 'text-warning', pulse: true }, live: { dot: 'brand', word: 'text-stat-value', pulse: false }, idle: { dot: 'warning', word: 'text-stat-title', pulse: false }, }; export function SecurityView({ activeTab, onTabChange, headerActions }: SecurityViewProps) { const { isAdmin } = useAuth(); const { activeNode } = useNodes(); const isMobile = useIsMobile(); const isRemote = activeNode?.type === 'remote'; const [overview, setOverview] = useState(null); // 'unsupported' = the node has no overview endpoint (e.g. an older remote, 404); // 'failed' = a genuine error (5xx, network, malformed body) that must not read as benign. const [overviewLoadError, setOverviewLoadError] = useState<'unsupported' | 'failed' | null>(null); const [summaries, setSummaries] = useState>({}); const [summariesLoading, setSummariesLoading] = useState(true); const [summariesError, setSummariesError] = useState(false); const [trend, setTrend] = useState([]); const [exploitIntel, setExploitIntel] = useState([]); // True when the exploit-intel query hit its row cap: the list shows the // highest-risk findings but not every one, so the UI discloses it. const [exploitTruncated, setExploitTruncated] = useState(false); const [isReplica, setIsReplica] = useState(false); // Bumped after a node-wide scan completes to refetch the active node's posture. const [reloadToken, setReloadToken] = useState(0); const [inspectScanId, setInspectScanId] = useState(null); const [inspectInitialTab, setInspectInitialTab] = useState(undefined); // Filter to preselect on the Images tab when arriving from an overview link // (e.g. "fixable findings"). Null leaves the Images tab on its own default. const [imagesFilter, setImagesFilter] = useState(null); // Navigate between security tabs, optionally preselecting an Images filter so // an overview action link lands on exactly the affected images. const handleNavigate = useCallback((tab: SecurityTab, filter?: ImageFilterValue) => { if (tab === 'images' && filter) setImagesFilter(filter); onTabChange(tab); }, [onTabChange]); const onInspect = useCallback((scanId: number, initialTab?: ScanDetailTab) => { setInspectInitialTab(initialTab); setInspectScanId(scanId); }, []); // Scanner readiness gates the Images Actions column; an admin on a node whose // scanner is available can trigger scans inline. const canScan = isAdmin && !!overview?.scanner.available; const { scanningRef, scanImage } = useImageScan({ onComplete: (scanId) => onInspect(scanId, 'vulns'), onSummaries: setSummaries, }); // Active-node scoped data: overview rollup + image summaries follow x-node-id. // A failed fetch (5xx, network, malformed body) must surface as an error, never // as a benign "clean / no findings" view, which for a security surface is the // most dangerous misread. A 404 on /overview is the one benign case (an older // remote node that lacks the endpoint). useEffect(() => { let cancelled = false; (async () => { setSummariesLoading(true); setOverviewLoadError(null); setSummariesError(false); // The trend chart is non-critical: isolate its fetch entirely (transport // failure included, not just a non-OK/malformed body) so it can never // poison the overview/summaries error state. It degrades to an empty chart // with its own "no history" message. const trendPromise: Promise = apiFetch('/security/overview/trend') .then((r) => (r.ok ? r.json() : [])) .then((t) => (Array.isArray(t) ? t : [])) .catch(() => []); // Exploit-intel powers two overview charts; isolate it like the trend so a // failure (or an older node without the endpoint) degrades to empty panels. const exploitIntelPromise: Promise<{ items: ExploitIntelFinding[]; truncated: boolean }> = apiFetch('/security/overview/exploit-intel') .then((r) => (r.ok ? r.json() : { items: [], truncated: false })) .then((b) => ({ items: b && Array.isArray(b.items) ? b.items : [], truncated: b?.truncated === true, })) .catch(() => ({ items: [], truncated: false })); try { const [overviewRes, summariesRes] = await Promise.all([ apiFetch('/security/overview'), apiFetch('/security/image-summaries'), ]); if (cancelled) return; if (overviewRes.ok) { setOverview(await overviewRes.json()); } else { setOverview(null); setOverviewLoadError(overviewRes.status === 404 ? 'unsupported' : 'failed'); if (overviewRes.status !== 404) { console.warn('[Security] overview request failed:', overviewRes.status); } } if (summariesRes.ok) { const body = await summariesRes.json(); if (isScanSummaryMap(body)) { setSummaries(body); } else { // A 200 with an unexpected shape must not read as "no findings". setSummaries({}); setSummariesError(true); console.warn('[Security] image-summaries returned an unexpected shape'); } } else { setSummaries({}); setSummariesError(true); console.warn('[Security] image-summaries request failed:', summariesRes.status); } } catch (err) { if (cancelled) return; console.warn('[Security] failed to load security data:', err); setOverview(null); setOverviewLoadError('failed'); setSummaries({}); setSummariesError(true); } finally { if (!cancelled) setSummariesLoading(false); } const [trendData, intelData] = await Promise.all([trendPromise, exploitIntelPromise]); if (!cancelled) { setTrend(trendData); setExploitIntel(intelData.items); setExploitTruncated(intelData.truncated); } })(); return () => { cancelled = true; }; }, [activeNode?.id, reloadToken]); // Governance panels (suppressions/acks) are control-governed; probe the local // fleet role so a replica renders them read-only, mirroring Settings. useEffect(() => { if (isRemote) return; let cancelled = false; (async () => { try { const res = await apiFetch('/fleet/role', { localOnly: true }); if (!res.ok || cancelled) return; const data = await res.json(); if (!cancelled && (data?.role === 'control' || data?.role === 'replica')) { setIsReplica((data.role as FleetRole) === 'replica'); } } catch { // Treat as control on probe failure (read-only gate is best-effort). } })(); return () => { cancelled = true; }; }, [isRemote, activeNode?.id]); const { state, tone } = deriveMasthead(overview, overviewLoadError !== null); const pulsing = tone === 'live' && !!overview?.scanner.available; // The mobile tab strip mirrors the desktop tab IA, so every section stays // reachable by scroll. const mobileTabs: SecurityMobileTab[] = [ { value: 'overview', label: 'Overview' }, { value: 'images', label: 'Images' }, { value: 'compose', label: 'Compose risks' }, { value: 'secrets', label: 'Secrets' }, { value: 'policies', label: 'Policies' }, { value: 'suppressions', label: 'Suppressions' }, { value: 'history', label: 'History' }, { value: 'scanner', label: 'Scanner setup' }, ]; // The scanner-detections disclaimer rides as an info affordance next to the // scanned-images count rather than a standing caption below the masthead. // When posture is Action needed, the subtitle leads with the action count and // top blocker labels so the operator sees "why red" without opening the page. const blockers = overview?.postureReasons?.filter((r) => r.severity === 'blocker') ?? []; const actionSummary = overview?.posture === 'Action needed' && blockers.length > 0 ? `${blockers.length} action${blockers.length === 1 ? '' : 's'}: ${blockers.slice(0, 2).map((r) => r.label.toLowerCase()).join(', ')} · ` : null; const subtitle = overview ? ( {actionSummary ? {actionSummary} : null} {overview.scannedImages} {overview.scannedImages === 1 ? 'image' : 'images'} scanned · scanner {overview.scanner.available ? 'ready' : 'not installed'} ) : undefined; // The tab panels are identical on desktop and mobile; only the masthead and // the tab strip differ, so the panels are shared between both layouts. const tabPanels = ( <> setReloadToken((t) => t + 1)} /> {isRemote ? (
) : (
)}
); const scanSheet = ( setInspectScanId(null)} canGenerateSbom={isAdmin} canExportSarif={isAdmin} canCompare canManageSuppressions={isAdmin} /> ); // Mobile: a bespoke masthead-led screen (no TopBar). The masthead leads with // the notifications + more-menu cluster in its right slot, the tab strip is a // horizontal scroller, and the active panel scrolls below. if (isMobile) { const mobileTone = MOBILE_MASTHEAD_TONE[tone]; return (
onTabChange(v as SecurityTab)}> {tabPanels}
{scanSheet}
); } return (
0 ? 'error' : 'value' }, { label: 'HIGH', value: String(overview.high), tone: overview.high > 0 ? 'warn' : 'value' }, { label: 'LAST SCAN', value: overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never', tone: 'subtitle' }, ] : undefined} > {overview?.posture === 'Action needed' && overview.primaryAction ? ( ) : null} onTabChange(v as SecurityTab)}> {/* Standard full-width tab band (matches Fleet): the list's own pill band is flattened so the tabs sit directly in this single band. */}
Overview Images Compose risks Secrets Policies Suppressions History Scanner setup
{tabPanels}
{scanSheet}
); }