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
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user