Wire Deploy Recon page to new recon API panels and streaming results.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

This commit is contained in:
AetherForge
2026-06-07 12:28:27 -07:00
parent 5317135a7d
commit 8c343ac984
6 changed files with 375 additions and 169 deletions

View File

@@ -244,17 +244,20 @@ export const UploadAdminMap = memo(function UploadAdminMap({ rows }: { rows: Adm
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((r) => ( {rows.map((r) => {
<tr key={r.url}> const live = r.status_code >= 200 && r.status_code < 400;
return (
<tr key={r.url} className={live ? 'dr-admin-live' : 'dr-admin-muted'}>
<td> <td>
<a href={r.url} target="_blank" rel="noreferrer"> <a href={r.url} target="_blank" rel="noreferrer">
{r.path} {r.path}
</a> </a>
</td> </td>
<td>{r.status_code}</td> <td>{r.status_code || '—'}</td>
<td>{r.signal}</td> <td>{r.signal}</td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</section> </section>
@@ -282,9 +285,11 @@ export const TechStackBanner = memo(function TechStackBanner({ stack }: { stack:
export const ActionMatrix = memo(function ActionMatrix({ export const ActionMatrix = memo(function ActionMatrix({
rows, rows,
onOpenPlaybook, onOpenPlaybook,
onAction,
}: { }: {
rows: ReconActionRow[]; rows: ReconActionRow[];
onOpenPlaybook?: (lane: string) => void; onOpenPlaybook?: (lane: string) => void;
onAction?: (lane: string, action: string) => void;
}) { }) {
if (!rows.length) return null; if (!rows.length) return null;
return ( return (
@@ -312,6 +317,7 @@ export const ActionMatrix = memo(function ActionMatrix({
className="btn btn-outline btn-sm" className="btn btn-outline btn-sm"
onClick={() => { onClick={() => {
if (a === 'playbook' && onOpenPlaybook) onOpenPlaybook(r.lane); if (a === 'playbook' && onOpenPlaybook) onOpenPlaybook(r.lane);
onAction?.(r.lane, a);
}} }}
> >
{a} {a}

View File

@@ -1,7 +1,7 @@
import { memo, useCallback, useEffect, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client'; import { api } from '../../api/client';
import { HelpTip } from '../HelpTip'; import { HelpTip } from '../HelpTip';
import { loadReconHistory, openPortsSignature } from '../../help/reconHistory'; import { diffReconReports, loadReconHistory, openPortsSignature } from '../../help/reconHistory';
import type { ReconHistoryEntry } from '../../types/recon'; import type { ReconHistoryEntry } from '../../types/recon';
export default memo(function HistoryPanel({ export default memo(function HistoryPanel({
@@ -34,6 +34,16 @@ export default memo(function HistoryPanel({
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
const diffsByScanId = useMemo(() => {
const out = new Map<string, ReturnType<typeof diffReconReports>>();
for (let i = 0; i < entries.length; i += 1) {
const prev = entries[i + 1];
if (!prev) continue;
out.set(entries[i].scan_id, diffReconReports(prev.report, entries[i].report));
}
return out;
}, [entries]);
return ( return (
<section className="neon-card dr-history" data-testid="dr-history-panel"> <section className="neon-card dr-history" data-testid="dr-history-panel">
<div className="dr-fleet-head"> <div className="dr-fleet-head">
@@ -49,7 +59,9 @@ export default memo(function HistoryPanel({
<p className="dr-empty">No prior scans{host.trim() ? ` for ${host.trim()}` : ''}.</p> <p className="dr-empty">No prior scans{host.trim() ? ` for ${host.trim()}` : ''}.</p>
) : null} ) : null}
<ul className="dr-history-list"> <ul className="dr-history-list">
{entries.map((e) => ( {entries.map((e) => {
const diffs = diffsByScanId.get(e.scan_id) ?? [];
return (
<li key={e.scan_id}> <li key={e.scan_id}>
<button <button
type="button" type="button"
@@ -60,9 +72,19 @@ export default memo(function HistoryPanel({
<span className="dr-history-meta"> <span className="dr-history-meta">
{e.profile || 'scan'} · {new Date(e.scanned_at).toLocaleString()} · ports {openPortsSignature(e.report)} {e.profile || 'scan'} · {new Date(e.scanned_at).toLocaleString()} · ports {openPortsSignature(e.report)}
</span> </span>
{diffs.length > 0 ? (
<span className="dr-history-diffs" data-testid={`dr-history-diff-${e.scan_id}`}>
{diffs.map((d) => (
<span key={`${d.kind}-${d.label}`} className={`dr-history-diff ${d.kind}`}>
{d.label}
</span>
))}
</span>
) : null}
</button> </button>
</li> </li>
))} );
})}
</ul> </ul>
</section> </section>
); );

View File

@@ -148,7 +148,22 @@ export function buildFormFingerprintCards(report: ReconScanReport, deckOrigin: s
export type AdminSurfaceMapRow = ReconAdminSurfaceFinding; export type AdminSurfaceMapRow = ReconAdminSurfaceFinding;
export function buildAdminSurfaceMap(report: ReconScanReport): AdminSurfaceMapRow[] { export function buildAdminSurfaceMap(report: ReconScanReport): AdminSurfaceMapRow[] {
return report.admin_surface ?? []; const admin = report.admin_surface ?? [];
const uploads = (report.crawl?.upload_hunter ?? []).map((u) => {
let path = u.target?.trim() || u.page_url;
try {
path = u.target?.trim() || new URL(u.page_url).pathname || u.page_url;
} catch {
/* relative URL */
}
return {
path,
url: u.page_url,
status_code: u.status_code ?? 0,
signal: u.tags?.length ? u.tags.join(' · ') : u.source || 'upload',
};
});
return [...admin, ...uploads];
} }
export function buildTechStackHints(report: ReconScanReport) { export function buildTechStackHints(report: ReconScanReport) {

View File

@@ -420,6 +420,48 @@
border-bottom: 1px solid rgba(255, 255, 255, 0.04); border-bottom: 1px solid rgba(255, 255, 255, 0.04);
} }
.dr-admin-live td {
color: #7dffb0;
}
.dr-admin-muted td {
color: var(--text-muted);
}
.dr-history-diffs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.35rem;
}
.dr-history-diff {
font-family: var(--font-tech);
font-size: 0.58rem;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-muted);
}
.dr-history-diff.port_opened {
border-color: rgba(80, 220, 120, 0.35);
color: #7dffb0;
}
.dr-history-diff.port_closed,
.dr-history-diff.ssrf_down {
border-color: rgba(255, 120, 120, 0.3);
color: #ffb0b0;
}
.dr-history-diff.ssrf_up,
.dr-history-diff.cms_added {
border-color: rgba(0, 232, 245, 0.35);
color: #9efcff;
}
.dr-fp-grid { .dr-fp-grid {
display: grid; display: grid;
gap: 0.55rem; gap: 0.55rem;

View File

@@ -105,6 +105,8 @@ describe('DeployReconPage', () => {
expect(await screen.findByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeInTheDocument(); expect(await screen.findByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeInTheDocument();
expect(screen.getByTestId('dr-host-input')).toBeInTheDocument(); expect(screen.getByTestId('dr-host-input')).toBeInTheDocument();
expect(screen.getByTestId('dr-scan-btn')).toBeDisabled(); expect(screen.getByTestId('dr-scan-btn')).toBeDisabled();
expect(screen.getByTestId('dr-scan-profile')).toBeInTheDocument();
expect(screen.getByTestId('dr-port-bundle')).toBeInTheDocument();
}); });
it('runs scan and shows port matrix and findings', async () => { it('runs scan and shows port matrix and findings', async () => {
@@ -117,8 +119,10 @@ describe('DeployReconPage', () => {
await waitFor(() => expect(api.reconScan).toHaveBeenCalled()); await waitFor(() => expect(api.reconScan).toHaveBeenCalled());
expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument(); expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument();
expect(screen.getByTestId('dr-finding-ssrf')).toBeInTheDocument(); expect(screen.getByTestId('dr-finding-ssrf')).toBeInTheDocument();
expect(screen.getByTestId('dr-action-matrix')).toBeInTheDocument();
expect(screen.getByTestId('dr-export-json')).toBeInTheDocument();
const spreadLinks = screen.getAllByRole('link', { name: /Fleet spread to scan\.lab/i }); const spreadLinks = screen.getAllByRole('link', { name: /Fleet spread to scan\.lab/i });
expect(spreadLinks[0]).toHaveAttribute('href', '/crucible?reconHost=scan.lab&tab=spread'); expect(spreadLinks[0]).toHaveAttribute('href', '/crucible?reconHost=scan.lab&tab=spread&finding=winrm');
}); });
it('shows fleet discoveries table with uninfected badge', async () => { it('shows fleet discoveries table with uninfected badge', async () => {

View File

@@ -1,39 +1,48 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '../api/client'; import { api } from '../api/client';
import type { BuildRecord } from '../types'; import type { BuildRecord } from '../types';
import type { ReconPortResult, ReconScanReport, ReconWebFindingCard } from '../types/recon'; import type { ReconHistoryEntry, ReconPortResult, ReconScanReport } from '../types/recon';
import { HelpTip } from '../components/HelpTip'; import { HelpTip } from '../components/HelpTip';
import SacredPageHeader from '../components/Visual/sacredGeometry/SacredPageHeader'; import SacredPageHeader from '../components/Visual/sacredGeometry/SacredPageHeader';
import { import {
FLEET_SPREAD_PORTS, ActionMatrix,
RECON_PORT_HINTS, BannerGrabPanel,
FindingCard,
FormFingerprintCards,
PortBundleSelector,
ScanProfileSelector,
SsrfCanaryPanel,
StreamingPortMatrix,
TechStackBanner,
UploadAdminMap,
useDeployReconDerived,
} from '../components/Recon/DeployReconPanels';
import {
buildActionMatrix,
buildPlaybookTree,
buildReconFindingCards, buildReconFindingCards,
buildReconMermaid, buildReconMermaid,
crucibleSpreadLink, crucibleSpreadLink,
curlInstallLine, curlInstallLine,
FLEET_SPREAD_PORTS,
isLocalScanUnreachableError,
reconScanBody, reconScanBody,
streamRevealPorts,
suggestDeployKitFinding,
type PortBundleId,
type ScanProfileId,
} from '../help/deployRecon'; } from '../help/deployRecon';
import FleetDiscoveriesPanel from '../components/Recon/FleetDiscoveriesPanel'; import { exportReconJson, saveReconHistoryEntry } from '../help/reconHistory';
import { useWebSocket } from '../hooks/useWebSocket';
import './DeployReconPage.css'; import './DeployReconPage.css';
import '../components/Fleet/ReconVisuals.css'; import '../components/Fleet/ReconVisuals.css';
const SCAN_DEBOUNCE_MS = 350; const FleetDiscoveriesPanel = lazy(() => import('../components/Recon/FleetDiscoveriesPanel'));
const HistoryPanel = lazy(() => import('../components/Recon/HistoryPanel'));
const PlaybookWizardModal = lazy(() => import('../components/Recon/PlaybookWizardModal'));
function CopyChip({ text, label }: { text: string; label: string }) { const SCAN_DEBOUNCE_MS = 350;
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 ScanSkeleton() { function ScanSkeleton() {
return ( return (
@@ -46,90 +55,35 @@ function ScanSkeleton() {
); );
} }
const PortMatrix = memo(function PortMatrix({
ports,
onHint,
}: {
ports: ReconPortResult[];
onHint: (hint: string) => void;
}) {
return (
<div data-testid="dr-port-matrix">
<h3 className="dr-section-title">
Port matrix <HelpTip field="dr_port_matrix" />
</h3>
<div className="dr-port-matrix">
{ports.map((p) => (
<button
key={p.port}
type="button"
className={`dr-port-cell${p.open ? ' open' : ''}`}
disabled={!p.open}
title={p.open ? RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open` : `TCP ${p.port} closed`}
onClick={() => {
if (p.open) onHint(RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open`);
}}
>
{p.port}
{p.open ? ' ●' : ' ○'}
</button>
))}
</div>
</div>
);
});
const FindingCard = memo(function FindingCard({
card,
curlLine,
host,
fleetSpreadOpen,
}: {
card: ReconWebFindingCard;
curlLine: string;
host: string;
fleetSpreadOpen: boolean;
}) {
return (
<article className="dr-finding-card" data-testid={`dr-finding-${card.id}`}>
<div className="dr-finding-head">
<span className="dr-finding-title">{card.title}</span>
<span className={`dr-confidence ${card.confidence}`}>{card.confidence}</span>
{card.spread_lane ? (
<span className="join-lane-badge">{card.spread_lane}</span>
) : null}
</div>
<p className="dr-finding-detail">{card.detail}</p>
{card.mermaid ? <pre className="dr-mermaid">{card.mermaid}</pre> : null}
<div className="dr-deploy-panel">
<CopyChip text={curlLine} label="Copy curl install.sh" />
{card.probe_url ? (
<CopyChip text={card.probe_url} label="Copy SSRF probe URL" />
) : null}
{fleetSpreadOpen ? (
<Link to={crucibleSpreadLink(host)} className="btn btn-outline btn-sm">
Fleet spread to {host}
</Link>
) : null}
</div>
</article>
);
});
const ResultsPanel = memo(function ResultsPanel({ const ResultsPanel = memo(function ResultsPanel({
report, report,
cards,
mermaid,
serverBase, serverBase,
pinnedBuildId, pinnedBuildId,
deployKitLane,
streaming,
visiblePorts,
onOpenPlaybook,
onMatrixAction,
canaryWsHit,
}: { }: {
report: ReconScanReport; report: ReconScanReport;
cards: ReconWebFindingCard[];
mermaid: string;
serverBase: string; serverBase: string;
pinnedBuildId: string; pinnedBuildId: string;
deployKitLane: string;
streaming: boolean;
visiblePorts: ReconPortResult[];
onOpenPlaybook: (lane: string) => void;
onMatrixAction: (lane: string, action: string) => void;
canaryWsHit?: string | null;
}) { }) {
const [portHint, setPortHint] = useState(''); const [portHint, setPortHint] = useState('');
const derived = useDeployReconDerived(report, serverBase);
const cards = useMemo(
() => buildReconFindingCards(report, serverBase),
[report, serverBase],
);
const mermaid = useMemo(() => buildReconMermaid(report.ports, cards), [report.ports, cards]);
const actionRows = useMemo(() => buildActionMatrix(report), [report]);
const curlLine = useMemo( const curlLine = useMemo(
() => curlInstallLine(serverBase, pinnedBuildId), () => curlInstallLine(serverBase, pinnedBuildId),
[serverBase, pinnedBuildId], [serverBase, pinnedBuildId],
@@ -141,9 +95,27 @@ const ResultsPanel = memo(function ResultsPanel({
return ( return (
<div className="dr-results" data-testid="dr-results"> <div className="dr-results" data-testid="dr-results">
<PortMatrix ports={report.ports} onHint={setPortHint} /> {deployKitLane ? (
<p className="dr-deploy-kit-hint" data-testid="dr-deploy-kit">
Deploy kit lane: <span className="font-tech">{deployKitLane}</span>
<HelpTip field="dr_deploy_kit" />
</p>
) : null}
{report.relay_via ? (
<p className="dr-relay-via">Relayed via {report.relay_via}</p>
) : null}
<StreamingPortMatrix ports={visiblePorts} streaming={streaming} onHint={setPortHint} />
{portHint ? <p className="dr-port-hint" data-testid="dr-port-hint">{portHint}</p> : null} {portHint ? <p className="dr-port-hint" data-testid="dr-port-hint">{portHint}</p> : null}
<BannerGrabPanel banners={report.banners ?? []} />
<SsrfCanaryPanel canary={report.canary} scanId={report.scan_id} wsHitScanId={canaryWsHit} />
<FormFingerprintCards cards={derived.fingerprintCards} />
<UploadAdminMap rows={derived.adminRows} />
<TechStackBanner stack={derived.stack} />
<ActionMatrix rows={actionRows} onOpenPlaybook={onOpenPlaybook} onAction={onMatrixAction} />
<details open> <details open>
<summary className="dr-section-title">Web findings</summary> <summary className="dr-section-title">Web findings</summary>
{cards.length === 0 ? ( {cards.length === 0 ? (
@@ -157,6 +129,7 @@ const ResultsPanel = memo(function ResultsPanel({
curlLine={curlLine} curlLine={curlLine}
host={report.host} host={report.host}
fleetSpreadOpen={fleetSpreadOpen} fleetSpreadOpen={fleetSpreadOpen}
deployKitLane={deployKitLane}
/> />
))} ))}
</div> </div>
@@ -170,35 +143,50 @@ const ResultsPanel = memo(function ResultsPanel({
</details> </details>
) : null} ) : null}
{(report.recommendations?.length ?? 0) > 0 ? ( <div className="dr-scan-actions">
<details> <button
<summary className="dr-section-title">Spread recommendations</summary> type="button"
<ul className="dr-empty" style={{ margin: 0, paddingLeft: '1.1rem' }}> className="btn btn-outline btn-sm"
{report.recommendations!.map((r, i) => ( data-testid="dr-export-json"
<li key={`${r.lane}-${r.template}-${i}`}>{r.reason}</li> onClick={() => exportReconJson(report)}
))} >
</ul> Export JSON
</details> </button>
) : null} <Link to={crucibleSpreadLink(report.host, deployKitLane)} className="btn btn-outline btn-sm">
Open Crucible spread
</Link>
</div>
</div> </div>
); );
}); });
export default function DeployReconPage() { export default function DeployReconPage() {
const { latestMessage } = useWebSocket();
const [host, setHost] = useState(''); const [host, setHost] = useState('');
const [port, setPort] = useState('80'); const [port, setPort] = useState('80');
const [https, setHttps] = useState(false); const [https, setHttps] = useState(false);
const [pathPrefix, setPathPrefix] = useState(''); const [pathPrefix, setPathPrefix] = useState('');
const [scanProfile, setScanProfile] = useState<ScanProfileId>('quick');
const [portBundle, setPortBundle] = useState<PortBundleId>('fleet');
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [relayOffer, setRelayOffer] = useState(false);
const [relayBusy, setRelayBusy] = useState(false);
const [report, setReport] = useState<ReconScanReport | null>(null); const [report, setReport] = useState<ReconScanReport | null>(null);
const [visiblePorts, setVisiblePorts] = useState<ReconPortResult[]>([]);
const [manualScans, setManualScans] = useState<ReconScanReport[]>([]); const [manualScans, setManualScans] = useState<ReconScanReport[]>([]);
const [deepScanIp, setDeepScanIp] = useState(''); const [deepScanIp, setDeepScanIp] = useState('');
const [serverBase, setServerBase] = useState(''); const [serverBase, setServerBase] = useState('');
const [pinnedBuildId, setPinnedBuildId] = useState(''); const [pinnedBuildId, setPinnedBuildId] = useState('');
const [deployKitLane, setDeployKitLane] = useState('');
const [playbookOpen, setPlaybookOpen] = useState(false);
const [playbookLane, setPlaybookLane] = useState<string | undefined>();
const [canaryWsHit, setCanaryWsHit] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const streamAbortRef = useRef(false);
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
@@ -218,6 +206,44 @@ export default function DeployReconPage() {
})(); })();
}, []); }, []);
useEffect(() => {
if (!latestMessage || latestMessage.type !== 'recon_canary_hit') return;
const payload = latestMessage.payload as { scan_id?: string };
if (payload?.scan_id) setCanaryWsHit(payload.scan_id);
}, [latestMessage]);
const revealPorts = useCallback(async (ports: ReconPortResult[]) => {
streamAbortRef.current = false;
setStreaming(true);
setVisiblePorts([]);
for await (const chunk of streamRevealPorts(ports)) {
if (streamAbortRef.current) return;
setVisiblePorts(chunk);
}
if (!streamAbortRef.current) setStreaming(false);
}, []);
const applyReport = useCallback(async (res: ReconScanReport) => {
setReport(res);
saveReconHistoryEntry(res);
setManualScans((prev) => {
const rest = prev.filter((s) => s.host.trim() !== res.host.trim());
return [res, ...rest].slice(0, 48);
});
setDeployKitLane(suggestDeployKitFinding(res));
void revealPorts(res.ports);
try {
const kit = await api.getReconDeployKit({
host: res.host,
finding: suggestDeployKitFinding(res) || undefined,
});
if (kit?.join_lane) setDeployKitLane(kit.join_lane);
else if (kit?.finding) setDeployKitLane(kit.finding);
} catch {
/* optional API */
}
}, [revealPorts]);
const runScan = useCallback(async (targetHost?: string) => { const runScan = useCallback(async (targetHost?: string) => {
const trimmed = (targetHost ?? host).trim(); const trimmed = (targetHost ?? host).trim();
if (!trimmed) { if (!trimmed) {
@@ -228,33 +254,54 @@ export default function DeployReconPage() {
abortRef.current?.abort(); abortRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
setRelayOffer(false);
const portNum = parseInt(port, 10) || 80; const portNum = parseInt(port, 10) || 80;
try { try {
const body = reconScanBody(trimmed, portNum, https, pathPrefix); const body = reconScanBody(trimmed, portNum, https, pathPrefix, scanProfile, portBundle);
const res = await api.reconScan(body, controller.signal); const res = await api.reconScan(body, controller.signal);
if (!controller.signal.aborted) { if (!controller.signal.aborted) await applyReport(res);
setReport(res);
setManualScans((prev) => {
const rest = prev.filter((s) => s.host.trim() !== res.host.trim());
return [res, ...rest].slice(0, 48);
});
}
} catch (e) { } catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return; if (e instanceof DOMException && e.name === 'AbortError') return;
setError(e instanceof Error ? e.message : 'Scan failed'); const msg = e instanceof Error ? e.message : 'Scan failed';
setError(msg);
if (isLocalScanUnreachableError(msg)) setRelayOffer(true);
} finally { } finally {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
setScanning(false); setScanning(false);
setDeepScanIp(''); setDeepScanIp('');
} }
} }
}, [host, port, https, pathPrefix]); }, [host, port, https, pathPrefix, scanProfile, portBundle, applyReport]);
const runRelayScan = useCallback(async () => {
const trimmed = host.trim();
if (!trimmed || relayBusy) return;
setRelayBusy(true);
setError('');
try {
const res = await api.reconRelayScan({ host: trimmed, udp_guess: scanProfile === 'deep' });
if (res?.ok && res.report) {
await applyReport(res.report);
setRelayOffer(false);
} else {
setError(res?.error || 'Relay scan failed');
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Relay scan failed');
} finally {
setRelayBusy(false);
setScanning(false);
}
}, [host, relayBusy, scanProfile, applyReport]);
const handleScanDeeper = useCallback((ip: string) => { const handleScanDeeper = useCallback((ip: string) => {
setHost(ip); setHost(ip);
setDeepScanIp(ip); setDeepScanIp(ip);
setScanning(true); setScanning(true);
setError(''); setError('');
setReport(null);
setVisiblePorts([]);
streamAbortRef.current = true;
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
void runScan(ip); void runScan(ip);
@@ -268,9 +315,12 @@ export default function DeployReconPage() {
return; return;
} }
abortRef.current?.abort(); abortRef.current?.abort();
streamAbortRef.current = true;
setScanning(true); setScanning(true);
setError(''); setError('');
setRelayOffer(false);
setReport(null); setReport(null);
setVisiblePorts([]);
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
void runScan(); void runScan();
@@ -279,18 +329,37 @@ export default function DeployReconPage() {
useEffect(() => () => { useEffect(() => () => {
abortRef.current?.abort(); abortRef.current?.abort();
streamAbortRef.current = true;
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
}, []); }, []);
const cards = useMemo( const playbookTree = useMemo(
() => (report ? buildReconFindingCards(report, serverBase) : []), () => (report ? buildPlaybookTree(report) : []),
[report, serverBase], [report],
); );
const mermaid = useMemo(
() => (report ? buildReconMermaid(report.ports, cards) : ''), const curlLine = useMemo(
[report, cards], () => curlInstallLine(serverBase, pinnedBuildId),
[serverBase, pinnedBuildId],
); );
const handleMatrixAction = useCallback((lane: string, action: string) => {
const trimmed = report?.host.trim() ?? host.trim();
if (action === 'copy_curl') {
void navigator.clipboard?.writeText(curlLine);
} else if (action === 'copy_probe') {
const cards = report ? buildReconFindingCards(report, serverBase) : [];
const probe = cards.find((c) => c.probe_url)?.probe_url;
if (probe) void navigator.clipboard?.writeText(probe);
} else if (action === 'crucible' && trimmed) {
window.open(crucibleSpreadLink(trimmed, lane), '_blank', 'noopener,noreferrer');
}
}, [curlLine, host, report, serverBase]);
const handleHistorySelect = useCallback((entry: ReconHistoryEntry) => {
void applyReport(entry.report);
}, [applyReport]);
return ( return (
<div className="page deploy-recon-page operator-deck-page"> <div className="page deploy-recon-page operator-deck-page">
<SacredPageHeader <SacredPageHeader
@@ -301,6 +370,8 @@ export default function DeployReconPage() {
/> />
<section className="neon-card dr-scan-form" aria-label="Recon target"> <section className="neon-card dr-scan-form" aria-label="Recon target">
<ScanProfileSelector value={scanProfile} onChange={setScanProfile} />
<PortBundleSelector value={portBundle} onChange={setPortBundle} />
<div className="dr-field"> <div className="dr-field">
<label htmlFor="dr-host">Host / IP</label> <label htmlFor="dr-host">Host / IP</label>
<input <input
@@ -348,6 +419,7 @@ export default function DeployReconPage() {
data-testid="dr-path-input" data-testid="dr-path-input"
/> />
</div> </div>
<div className="dr-scan-actions">
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
@@ -357,20 +429,55 @@ export default function DeployReconPage() {
> >
{scanning ? 'Scanning…' : 'Scan'} {scanning ? 'Scanning…' : 'Scan'}
</button> </button>
{relayOffer ? (
<button
type="button"
className="btn btn-outline"
disabled={relayBusy}
onClick={() => void runRelayScan()}
data-testid="dr-relay-scan"
>
{relayBusy ? 'Relaying…' : 'Relay scan via fleet'}
</button>
) : null}
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => {
setPlaybookLane(undefined);
setPlaybookOpen(true);
}}
data-testid="dr-playbook-open"
>
Playbook wizard
</button>
</div>
</section> </section>
{error ? <p className="dr-error" role="alert">{error}</p> : null} {error ? <p className="dr-error" role="alert">{error}</p> : null}
{scanning ? <ScanSkeleton /> : null} {scanning && !report ? <ScanSkeleton /> : null}
{report && !scanning ? ( {report && !scanning ? (
<ResultsPanel <ResultsPanel
report={report} report={report}
cards={cards}
mermaid={mermaid}
serverBase={serverBase} serverBase={serverBase}
pinnedBuildId={pinnedBuildId} pinnedBuildId={pinnedBuildId}
deployKitLane={deployKitLane}
streaming={streaming}
visiblePorts={visiblePorts.length ? visiblePorts : report.ports}
onOpenPlaybook={(lane) => {
setPlaybookLane(lane);
setPlaybookOpen(true);
}}
onMatrixAction={handleMatrixAction}
canaryWsHit={canaryWsHit}
/> />
) : null} ) : null}
<Suspense fallback={<p className="dr-empty">Loading history</p>}>
<HistoryPanel host={host} onSelect={handleHistorySelect} />
</Suspense>
<Suspense fallback={null}>
<FleetDiscoveriesPanel <FleetDiscoveriesPanel
manualScans={manualScans} manualScans={manualScans}
onScanDeeper={handleScanDeeper} onScanDeeper={handleScanDeeper}
@@ -378,6 +485,16 @@ export default function DeployReconPage() {
serverBase={serverBase} serverBase={serverBase}
pinnedBuildId={pinnedBuildId} pinnedBuildId={pinnedBuildId}
/> />
</Suspense>
<Suspense fallback={null}>
<PlaybookWizardModal
open={playbookOpen}
onClose={() => setPlaybookOpen(false)}
tree={playbookTree}
focusLane={playbookLane}
/>
</Suspense>
</div> </div>
); );
} }