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>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.url}>
<td>
<a href={r.url} target="_blank" rel="noreferrer">
{r.path}
</a>
</td>
<td>{r.status_code}</td>
<td>{r.signal}</td>
</tr>
))}
{rows.map((r) => {
const live = r.status_code >= 200 && r.status_code < 400;
return (
<tr key={r.url} className={live ? 'dr-admin-live' : 'dr-admin-muted'}>
<td>
<a href={r.url} target="_blank" rel="noreferrer">
{r.path}
</a>
</td>
<td>{r.status_code || '—'}</td>
<td>{r.signal}</td>
</tr>
);
})}
</tbody>
</table>
</section>
@@ -282,9 +285,11 @@ export const TechStackBanner = memo(function TechStackBanner({ stack }: { stack:
export const ActionMatrix = memo(function ActionMatrix({
rows,
onOpenPlaybook,
onAction,
}: {
rows: ReconActionRow[];
onOpenPlaybook?: (lane: string) => void;
onAction?: (lane: string, action: string) => void;
}) {
if (!rows.length) return null;
return (
@@ -312,6 +317,7 @@ export const ActionMatrix = memo(function ActionMatrix({
className="btn btn-outline btn-sm"
onClick={() => {
if (a === 'playbook' && onOpenPlaybook) onOpenPlaybook(r.lane);
onAction?.(r.lane, 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 { HelpTip } from '../HelpTip';
import { loadReconHistory, openPortsSignature } from '../../help/reconHistory';
import { diffReconReports, loadReconHistory, openPortsSignature } from '../../help/reconHistory';
import type { ReconHistoryEntry } from '../../types/recon';
export default memo(function HistoryPanel({
@@ -34,6 +34,16 @@ export default memo(function HistoryPanel({
void 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 (
<section className="neon-card dr-history" data-testid="dr-history-panel">
<div className="dr-fleet-head">
@@ -49,20 +59,32 @@ export default memo(function HistoryPanel({
<p className="dr-empty">No prior scans{host.trim() ? ` for ${host.trim()}` : ''}.</p>
) : null}
<ul className="dr-history-list">
{entries.map((e) => (
<li key={e.scan_id}>
<button
type="button"
className="dr-history-item"
onClick={() => onSelect?.(e)}
>
<span className="font-tech">{e.host}</span>
<span className="dr-history-meta">
{e.profile || 'scan'} · {new Date(e.scanned_at).toLocaleString()} · ports {openPortsSignature(e.report)}
</span>
</button>
</li>
))}
{entries.map((e) => {
const diffs = diffsByScanId.get(e.scan_id) ?? [];
return (
<li key={e.scan_id}>
<button
type="button"
className="dr-history-item"
onClick={() => onSelect?.(e)}
>
<span className="font-tech">{e.host}</span>
<span className="dr-history-meta">
{e.profile || 'scan'} · {new Date(e.scanned_at).toLocaleString()} · ports {openPortsSignature(e.report)}
</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>
</li>
);
})}
</ul>
</section>
);

View File

@@ -148,7 +148,22 @@ export function buildFormFingerprintCards(report: ReconScanReport, deckOrigin: s
export type AdminSurfaceMapRow = ReconAdminSurfaceFinding;
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) {

View File

@@ -420,6 +420,48 @@
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 {
display: grid;
gap: 0.55rem;

View File

@@ -105,6 +105,8 @@ describe('DeployReconPage', () => {
expect(await screen.findByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeInTheDocument();
expect(screen.getByTestId('dr-host-input')).toBeInTheDocument();
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 () => {
@@ -117,8 +119,10 @@ describe('DeployReconPage', () => {
await waitFor(() => expect(api.reconScan).toHaveBeenCalled());
expect(screen.getByTestId('dr-port-matrix')).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 });
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 () => {

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 { api } from '../api/client';
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 SacredPageHeader from '../components/Visual/sacredGeometry/SacredPageHeader';
import {
FLEET_SPREAD_PORTS,
RECON_PORT_HINTS,
ActionMatrix,
BannerGrabPanel,
FindingCard,
FormFingerprintCards,
PortBundleSelector,
ScanProfileSelector,
SsrfCanaryPanel,
StreamingPortMatrix,
TechStackBanner,
UploadAdminMap,
useDeployReconDerived,
} from '../components/Recon/DeployReconPanels';
import {
buildActionMatrix,
buildPlaybookTree,
buildReconFindingCards,
buildReconMermaid,
crucibleSpreadLink,
curlInstallLine,
FLEET_SPREAD_PORTS,
isLocalScanUnreachableError,
reconScanBody,
streamRevealPorts,
suggestDeployKitFinding,
type PortBundleId,
type ScanProfileId,
} from '../help/deployRecon';
import FleetDiscoveriesPanel from '../components/Recon/FleetDiscoveriesPanel';
import { exportReconJson, saveReconHistoryEntry } from '../help/reconHistory';
import { useWebSocket } from '../hooks/useWebSocket';
import './DeployReconPage.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 [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>
);
}
const SCAN_DEBOUNCE_MS = 350;
function ScanSkeleton() {
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({
report,
cards,
mermaid,
serverBase,
pinnedBuildId,
deployKitLane,
streaming,
visiblePorts,
onOpenPlaybook,
onMatrixAction,
canaryWsHit,
}: {
report: ReconScanReport;
cards: ReconWebFindingCard[];
mermaid: string;
serverBase: 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 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(
() => curlInstallLine(serverBase, pinnedBuildId),
[serverBase, pinnedBuildId],
@@ -141,9 +95,27 @@ const ResultsPanel = memo(function ResultsPanel({
return (
<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}
<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>
<summary className="dr-section-title">Web findings</summary>
{cards.length === 0 ? (
@@ -157,6 +129,7 @@ const ResultsPanel = memo(function ResultsPanel({
curlLine={curlLine}
host={report.host}
fleetSpreadOpen={fleetSpreadOpen}
deployKitLane={deployKitLane}
/>
))}
</div>
@@ -170,35 +143,50 @@ const ResultsPanel = memo(function ResultsPanel({
</details>
) : null}
{(report.recommendations?.length ?? 0) > 0 ? (
<details>
<summary className="dr-section-title">Spread recommendations</summary>
<ul className="dr-empty" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{report.recommendations!.map((r, i) => (
<li key={`${r.lane}-${r.template}-${i}`}>{r.reason}</li>
))}
</ul>
</details>
) : null}
<div className="dr-scan-actions">
<button
type="button"
className="btn btn-outline btn-sm"
data-testid="dr-export-json"
onClick={() => exportReconJson(report)}
>
Export JSON
</button>
<Link to={crucibleSpreadLink(report.host, deployKitLane)} className="btn btn-outline btn-sm">
Open Crucible spread
</Link>
</div>
</div>
);
});
export default function DeployReconPage() {
const { latestMessage } = useWebSocket();
const [host, setHost] = useState('');
const [port, setPort] = useState('80');
const [https, setHttps] = useState(false);
const [pathPrefix, setPathPrefix] = useState('');
const [scanProfile, setScanProfile] = useState<ScanProfileId>('quick');
const [portBundle, setPortBundle] = useState<PortBundleId>('fleet');
const [scanning, setScanning] = useState(false);
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState('');
const [relayOffer, setRelayOffer] = useState(false);
const [relayBusy, setRelayBusy] = useState(false);
const [report, setReport] = useState<ReconScanReport | null>(null);
const [visiblePorts, setVisiblePorts] = useState<ReconPortResult[]>([]);
const [manualScans, setManualScans] = useState<ReconScanReport[]>([]);
const [deepScanIp, setDeepScanIp] = useState('');
const [serverBase, setServerBase] = 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 debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const streamAbortRef = useRef(false);
useEffect(() => {
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 trimmed = (targetHost ?? host).trim();
if (!trimmed) {
@@ -228,33 +254,54 @@ export default function DeployReconPage() {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setRelayOffer(false);
const portNum = parseInt(port, 10) || 80;
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);
if (!controller.signal.aborted) {
setReport(res);
setManualScans((prev) => {
const rest = prev.filter((s) => s.host.trim() !== res.host.trim());
return [res, ...rest].slice(0, 48);
});
}
if (!controller.signal.aborted) await applyReport(res);
} catch (e) {
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 {
if (!controller.signal.aborted) {
setScanning(false);
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) => {
setHost(ip);
setDeepScanIp(ip);
setScanning(true);
setError('');
setReport(null);
setVisiblePorts([]);
streamAbortRef.current = true;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void runScan(ip);
@@ -268,9 +315,12 @@ export default function DeployReconPage() {
return;
}
abortRef.current?.abort();
streamAbortRef.current = true;
setScanning(true);
setError('');
setRelayOffer(false);
setReport(null);
setVisiblePorts([]);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void runScan();
@@ -279,18 +329,37 @@ export default function DeployReconPage() {
useEffect(() => () => {
abortRef.current?.abort();
streamAbortRef.current = true;
if (debounceRef.current) clearTimeout(debounceRef.current);
}, []);
const cards = useMemo(
() => (report ? buildReconFindingCards(report, serverBase) : []),
[report, serverBase],
const playbookTree = useMemo(
() => (report ? buildPlaybookTree(report) : []),
[report],
);
const mermaid = useMemo(
() => (report ? buildReconMermaid(report.ports, cards) : ''),
[report, cards],
const curlLine = useMemo(
() => 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 (
<div className="page deploy-recon-page operator-deck-page">
<SacredPageHeader
@@ -301,6 +370,8 @@ export default function DeployReconPage() {
/>
<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">
<label htmlFor="dr-host">Host / IP</label>
<input
@@ -348,36 +419,82 @@ export default function DeployReconPage() {
data-testid="dr-path-input"
/>
</div>
<button
type="button"
className="btn btn-primary"
disabled={scanning || !host.trim()}
onClick={scheduleScan}
data-testid="dr-scan-btn"
>
{scanning ? 'Scanning…' : 'Scan'}
</button>
<div className="dr-scan-actions">
<button
type="button"
className="btn btn-primary"
disabled={scanning || !host.trim()}
onClick={scheduleScan}
data-testid="dr-scan-btn"
>
{scanning ? 'Scanning…' : 'Scan'}
</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>
{error ? <p className="dr-error" role="alert">{error}</p> : null}
{scanning ? <ScanSkeleton /> : null}
{scanning && !report ? <ScanSkeleton /> : null}
{report && !scanning ? (
<ResultsPanel
report={report}
cards={cards}
mermaid={mermaid}
serverBase={serverBase}
pinnedBuildId={pinnedBuildId}
deployKitLane={deployKitLane}
streaming={streaming}
visiblePorts={visiblePorts.length ? visiblePorts : report.ports}
onOpenPlaybook={(lane) => {
setPlaybookLane(lane);
setPlaybookOpen(true);
}}
onMatrixAction={handleMatrixAction}
canaryWsHit={canaryWsHit}
/>
) : null}
<FleetDiscoveriesPanel
manualScans={manualScans}
onScanDeeper={handleScanDeeper}
scanningIp={deepScanIp}
serverBase={serverBase}
pinnedBuildId={pinnedBuildId}
/>
<Suspense fallback={<p className="dr-empty">Loading history</p>}>
<HistoryPanel host={host} onSelect={handleHistorySelect} />
</Suspense>
<Suspense fallback={null}>
<FleetDiscoveriesPanel
manualScans={manualScans}
onScanDeeper={handleScanDeeper}
scanningIp={deepScanIp}
serverBase={serverBase}
pinnedBuildId={pinnedBuildId}
/>
</Suspense>
<Suspense fallback={null}>
<PlaybookWizardModal
open={playbookOpen}
onClose={() => setPlaybookOpen(false)}
tree={playbookTree}
focusLane={playbookLane}
/>
</Suspense>
</div>
);
}