diff --git a/server/web/src/components/Recon/DeployReconPanels.tsx b/server/web/src/components/Recon/DeployReconPanels.tsx
new file mode 100644
index 0000000..0da973a
--- /dev/null
+++ b/server/web/src/components/Recon/DeployReconPanels.tsx
@@ -0,0 +1,383 @@
+import { memo, useEffect, useMemo, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { api } from '../../api/client';
+import { HelpTip } from '../HelpTip';
+import {
+ FLEET_SPREAD_PORTS,
+ PORT_BUNDLES,
+ RECON_PORT_HINTS,
+ SCAN_PROFILES,
+ crucibleSpreadLink,
+ curlInstallLine,
+ type PortBundleId,
+ type ScanProfileId,
+} from '../../help/deployRecon';
+import type {
+ ReconActionRow,
+ ReconFormFingerprintCard,
+} from '../../help/deployRecon';
+import {
+ buildAdminSurfaceMap,
+ buildFormFingerprintCards,
+ buildTechStackHints,
+ type AdminSurfaceMapRow,
+} from '../../help/deployRecon';
+import type {
+ ReconPortBanner,
+ ReconPortResult,
+ ReconScanReport,
+ ReconSsrfCanaryInfo,
+ ReconStackEntry,
+ ReconWebFindingCard,
+} from '../../types/recon';
+import { ssrfCanaryToken } from '../../help/deployRecon';
+
+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 (
+
+ );
+}
+
+export const ScanProfileSelector = memo(function ScanProfileSelector({
+ value,
+ onChange,
+}: {
+ value: ScanProfileId;
+ onChange: (v: ScanProfileId) => void;
+}) {
+ return (
+
+
Scan profile
+
+ {(Object.keys(SCAN_PROFILES) as ScanProfileId[]).map((id) => (
+
+ ))}
+
+
+ );
+});
+
+export const PortBundleSelector = memo(function PortBundleSelector({
+ value,
+ onChange,
+}: {
+ value: PortBundleId;
+ onChange: (v: PortBundleId) => void;
+}) {
+ return (
+
+ Port bundle
+
+
+ );
+});
+
+export const StreamingPortMatrix = memo(function StreamingPortMatrix({
+ ports,
+ streaming,
+ onHint,
+}: {
+ ports: ReconPortResult[];
+ streaming?: boolean;
+ onHint: (hint: string) => void;
+}) {
+ return (
+
+
+ Port matrix
+
+
+ {ports.map((p) => (
+
+ ))}
+
+
+ );
+});
+
+export const BannerGrabPanel = memo(function BannerGrabPanel({ banners }: { banners: ReconPortBanner[] }) {
+ if (!banners?.length) return null;
+ return (
+
+ Banner grab
+
+ {banners.map((b) => (
+ -
+ :{b.port}
+ {b.service ? {b.service} : null}
+ {b.title ? {b.title} : null}
+ {b.banner ?
{b.banner} : null}
+ {b.hint ? {b.hint} : null}
+
+ ))}
+
+
+ );
+});
+
+export const FormFingerprintCards = memo(function FormFingerprintCards({
+ cards,
+}: {
+ cards: ReconFormFingerprintCard[];
+}) {
+ if (cards.length === 0) return null;
+ return (
+
+ Form fingerprints
+
+ {cards.map((c) => (
+
+
+ {c.name}
+ score {c.score}
+
+ {c.page_url}
+ {c.matches.length ? {c.matches.join(' · ')}
: null}
+
+
+ ))}
+
+
+ );
+});
+
+export const SsrfCanaryPanel = memo(function SsrfCanaryPanel({
+ canary,
+ scanId,
+ wsHitScanId,
+}: {
+ canary?: ReconSsrfCanaryInfo;
+ scanId?: string;
+ wsHitScanId?: string | null;
+}) {
+ const token = ssrfCanaryToken(canary?.scan_id || scanId || '');
+ const [status, setStatus] = useState(canary?.status ?? 'pending');
+ const [hitAt, setHitAt] = useState(canary?.hit_at);
+
+ useEffect(() => {
+ if (canary?.status) setStatus(canary.status);
+ if (canary?.hit_at) setHitAt(canary.hit_at);
+ }, [canary]);
+
+ useEffect(() => {
+ if (wsHitScanId && token && wsHitScanId === token) {
+ setStatus('confirmed');
+ setHitAt(new Date().toISOString());
+ }
+ }, [wsHitScanId, token]);
+
+ useEffect(() => {
+ if (!token || status === 'confirmed') return undefined;
+ const timer = setInterval(() => {
+ void api.getSsrfCanaryStatus(token).then((info) => {
+ if (!info) return;
+ setStatus(info.status);
+ if (info.hit_at) setHitAt(info.hit_at);
+ });
+ }, 4000);
+ return () => clearInterval(timer);
+ }, [token, status]);
+
+ const url = canary?.url;
+ if (!url && !token) return null;
+
+ return (
+
+
+ SSRF canary
+ {status}
+
+ {url ? : null}
+ {hitAt ? Hit at {hitAt}
: null}
+ {canary?.paste_field_name ? (
+ Paste into field: {canary.paste_field_name}
+ ) : null}
+
+ );
+});
+
+export const UploadAdminMap = memo(function UploadAdminMap({ rows }: { rows: AdminSurfaceMapRow[] }) {
+ if (!rows.length) return null;
+ return (
+
+ Upload / admin map
+
+
+
+ | Path |
+ Status |
+ Signal |
+
+
+
+ {rows.map((r) => (
+
+ |
+
+ {r.path}
+
+ |
+ {r.status_code} |
+ {r.signal} |
+
+ ))}
+
+
+
+ );
+});
+
+export const TechStackBanner = memo(function TechStackBanner({ stack }: { stack: ReconStackEntry[] }) {
+ if (!stack.length) return null;
+ return (
+
+ Tech stack
+
+ {stack.map((e) => (
+ -
+ {e.name}
+ {e.source}
+ {e.detail ? {e.detail} : null}
+
+ ))}
+
+
+ );
+});
+
+export const ActionMatrix = memo(function ActionMatrix({
+ rows,
+ onOpenPlaybook,
+}: {
+ rows: ReconActionRow[];
+ onOpenPlaybook?: (lane: string) => void;
+}) {
+ if (!rows.length) return null;
+ return (
+
+ Action matrix
+
+
+
+ | Lane |
+ Reason |
+ Actions |
+
+
+
+ {rows.map((r) => (
+
+ | {r.title} |
+ {r.reason} |
+
+
+ {r.actions.map((a) => (
+
+ ))}
+
+ |
+
+ ))}
+
+
+
+ );
+});
+
+export const FindingCard = memo(function FindingCard({
+ card,
+ curlLine,
+ host,
+ fleetSpreadOpen,
+ deployKitLane,
+}: {
+ card: ReconWebFindingCard;
+ curlLine: string;
+ host: string;
+ fleetSpreadOpen: boolean;
+ deployKitLane?: string;
+}) {
+ const finding = deployKitLane || card.spread_lane;
+ return (
+
+
+ {card.title}
+ {card.confidence}
+ {card.spread_lane ? {card.spread_lane} : null}
+
+ {card.detail}
+ {card.mermaid ? {card.mermaid} : null}
+
+
+ {card.probe_url ? : null}
+ {fleetSpreadOpen ? (
+
+ Fleet spread to {host}
+
+ ) : null}
+
+
+ );
+});
+
+export function useDeployReconDerived(report: ReconScanReport | null, deckOrigin: string) {
+ return useMemo(() => {
+ if (!report) {
+ return {
+ fingerprintCards: [] as ReconFormFingerprintCard[],
+ adminRows: [] as AdminSurfaceMapRow[],
+ stack: [] as ReconStackEntry[],
+ };
+ }
+ return {
+ fingerprintCards: buildFormFingerprintCards(report, deckOrigin),
+ adminRows: buildAdminSurfaceMap(report),
+ stack: buildTechStackHints(report),
+ };
+ }, [report, deckOrigin]);
+}
+
diff --git a/server/web/src/components/Recon/HistoryPanel.tsx b/server/web/src/components/Recon/HistoryPanel.tsx
new file mode 100644
index 0000000..8584e66
--- /dev/null
+++ b/server/web/src/components/Recon/HistoryPanel.tsx
@@ -0,0 +1,69 @@
+import { memo, useCallback, useEffect, useState } from 'react';
+import { api } from '../../api/client';
+import { HelpTip } from '../HelpTip';
+import { loadReconHistory, openPortsSignature } from '../../help/reconHistory';
+import type { ReconHistoryEntry } from '../../types/recon';
+
+export default memo(function HistoryPanel({
+ host,
+ onSelect,
+}: {
+ host: string;
+ onSelect?: (entry: ReconHistoryEntry) => void;
+}) {
+ const [entries, setEntries] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ const server = await api.getReconHistory();
+ const local = await loadReconHistory(host.trim() || undefined);
+ const map = new Map();
+ for (const e of [...(server?.entries ?? []), ...local]) {
+ if (host.trim() && e.host.trim() !== host.trim()) continue;
+ map.set(e.scan_id, e);
+ }
+ setEntries([...map.values()].sort((a, b) => b.scanned_at.localeCompare(a.scanned_at)));
+ } finally {
+ setLoading(false);
+ }
+ }, [host]);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ return (
+
+
+
+ Scan history
+
+
+
+ {loading ? Loading history…
: null}
+ {!loading && entries.length === 0 ? (
+ No prior scans{host.trim() ? ` for ${host.trim()}` : ''}.
+ ) : null}
+
+ {entries.map((e) => (
+ -
+
+
+ ))}
+
+
+ );
+});
diff --git a/server/web/src/components/Recon/PlaybookWizardModal.tsx b/server/web/src/components/Recon/PlaybookWizardModal.tsx
new file mode 100644
index 0000000..1bfbb27
--- /dev/null
+++ b/server/web/src/components/Recon/PlaybookWizardModal.tsx
@@ -0,0 +1,48 @@
+import { memo } from 'react';
+import { HelpTip } from '../HelpTip';
+import type { PlaybookTreeNode } from '../../help/deployRecon';
+
+export default memo(function PlaybookWizardModal({
+ open,
+ onClose,
+ tree,
+ focusLane,
+}: {
+ open: boolean;
+ onClose: () => void;
+ tree: PlaybookTreeNode[];
+ focusLane?: string;
+}) {
+ if (!open) return null;
+ const root = tree[0];
+ return (
+
+
+
+
Playbook wizard
+
+
+ {root ? (
+
+
+ Target {root.label}
+
+
+
+ ) : (
+
Run a scan to populate spread lanes.
+ )}
+
+
+ );
+});
diff --git a/server/web/src/help/reconHistory.test.ts b/server/web/src/help/reconHistory.test.ts
new file mode 100644
index 0000000..37ba9de
--- /dev/null
+++ b/server/web/src/help/reconHistory.test.ts
@@ -0,0 +1,43 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ diffReconReports,
+ exportReconJson,
+ openPortsSignature,
+ saveReconHistoryEntry,
+} from './reconHistory';
+import type { ReconScanReport } from '../types/recon';
+
+const base: ReconScanReport = {
+ host: '10.0.0.5',
+ scan_id: 's1',
+ scanned_at: '2026-06-07T10:00:00Z',
+ ports: [{ port: 80, open: true }],
+};
+
+describe('reconHistory', () => {
+ afterEach(() => localStorage.clear());
+
+ it('saves and signs open ports', () => {
+ const entry = saveReconHistoryEntry(base);
+ expect(entry.scan_id).toBe('s1');
+ expect(openPortsSignature(base)).toBe('80');
+ });
+
+ it('diffs port changes', () => {
+ const next = { ...base, ports: [{ port: 80, open: true }, { port: 443, open: true }] };
+ const diffs = diffReconReports(base, next);
+ expect(diffs.some((d) => d.kind === 'port_opened' && d.label.includes('443'))).toBe(true);
+ });
+
+ it('exports JSON without throwing', () => {
+ const click = vi.fn();
+ const create = vi.spyOn(document, 'createElement').mockReturnValue({ click } as unknown as HTMLAnchorElement);
+ exportReconJson(base, 'out.json');
+ expect(click).toHaveBeenCalled();
+ create.mockRestore();
+ });
+});
+
diff --git a/server/web/src/help/reconHistory.ts b/server/web/src/help/reconHistory.ts
new file mode 100644
index 0000000..6716eef
--- /dev/null
+++ b/server/web/src/help/reconHistory.ts
@@ -0,0 +1,131 @@
+import type { ReconHistoryEntry, ReconScanReport } from '../types/recon';
+
+const LOCAL_KEY = 'aetherforge.recon.history.v1';
+const LOCAL_LIMIT = 64;
+
+export type ReconReportDiffKind =
+ | 'port_opened'
+ | 'port_closed'
+ | 'ssrf_up'
+ | 'ssrf_down'
+ | 'cms_added';
+
+export interface ReconReportDiff {
+ kind: ReconReportDiffKind;
+ label: string;
+ detail?: string;
+}
+
+function normalizeEntry(raw: ReconHistoryEntry & { id?: string }): ReconHistoryEntry {
+ const scan_id = raw.scan_id || raw.id || `${raw.host}-${raw.scanned_at}`;
+ return { ...raw, scan_id, report: raw.report };
+}
+
+function readLocalEntries(): ReconHistoryEntry[] {
+ try {
+ const raw = localStorage.getItem(LOCAL_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw) as { entries?: (ReconHistoryEntry & { id?: string })[] };
+ if (!Array.isArray(parsed.entries)) return [];
+ return parsed.entries.map(normalizeEntry);
+ } catch {
+ return [];
+ }
+}
+
+function writeLocalEntries(entries: ReconHistoryEntry[]): void {
+ try {
+ localStorage.setItem(LOCAL_KEY, JSON.stringify({ entries: entries.slice(0, LOCAL_LIMIT) }));
+ } catch {
+ /* quota / private mode */
+ }
+}
+
+export function openPortsSignature(report: ReconScanReport): string {
+ return report.ports
+ .filter((p) => p.open)
+ .map((p) => p.port)
+ .sort((a, b) => a - b)
+ .join(',');
+}
+
+export async function loadReconHistory(host?: string): Promise {
+ const local = readLocalEntries();
+ if (!host?.trim()) {
+ return local;
+ }
+ const trimmed = host.trim();
+ try {
+ const res = await fetch(`/api/v1/recon/history?host=${encodeURIComponent(trimmed)}`, {
+ headers: { Accept: 'application/json' },
+ credentials: 'include',
+ });
+ if (res.ok) {
+ const body = (await res.json()) as { entries?: ReconHistoryEntry[] } | ReconHistoryEntry[];
+ const server = Array.isArray(body) ? body : body.entries ?? [];
+ if (server.length > 0) {
+ const byId = new Map();
+ for (const e of [...local, ...server.map(normalizeEntry)]) {
+ if (e.host.trim() === trimmed) byId.set(e.scan_id, e);
+ }
+ return [...byId.values()].sort((a, b) => b.scanned_at.localeCompare(a.scanned_at));
+ }
+ }
+ } catch {
+ /* offline */
+ }
+ return local.filter((e) => e.host.trim() === trimmed);
+}
+
+export function saveReconHistoryEntry(report: ReconScanReport): ReconHistoryEntry {
+ const scan_id = report.scan_id ?? `${report.host}-${report.scanned_at}`;
+ const entry: ReconHistoryEntry = {
+ scan_id,
+ host: report.host,
+ scanned_at: report.scanned_at,
+ profile: report.profile,
+ status: report.status,
+ report,
+ };
+ const prev = readLocalEntries().filter((e) => e.scan_id !== entry.scan_id);
+ writeLocalEntries([entry, ...prev]);
+ return entry;
+}
+
+export function exportReconJson(report: ReconScanReport, filename?: string): void {
+ const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename ?? `recon-${report.host.replace(/[^a-zA-Z0-9._-]+/g, '-')}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+export function diffReconReports(prev: ReconScanReport, next: ReconScanReport): ReconReportDiff[] {
+ const diffs: ReconReportDiff[] = [];
+ const prevPorts = new Map(prev.ports.map((p) => [p.port, p.open]));
+ for (const p of next.ports) {
+ const was = prevPorts.get(p.port);
+ if (was !== true && p.open) {
+ diffs.push({ kind: 'port_opened', label: `TCP ${p.port} opened`, detail: 'Was closed on prior scan.' });
+ } else if (was === true && !p.open) {
+ diffs.push({ kind: 'port_closed', label: `TCP ${p.port} closed`, detail: 'Was open on prior scan.' });
+ }
+ }
+ const prevScore = prev.crawl?.ssrf_score ?? 0;
+ const nextScore = next.crawl?.ssrf_score ?? 0;
+ if (nextScore > prevScore) {
+ diffs.push({ kind: 'ssrf_up', label: 'SSRF score increased', detail: `${prevScore} → ${nextScore}` });
+ } else if (nextScore < prevScore) {
+ diffs.push({ kind: 'ssrf_down', label: 'SSRF score decreased', detail: `${prevScore} → ${nextScore}` });
+ }
+ const prevCms = new Set(prev.crawl?.cms_fingerprints ?? []);
+ for (const cms of next.crawl?.cms_fingerprints ?? []) {
+ if (!prevCms.has(cms)) {
+ diffs.push({ kind: 'cms_added', label: `CMS hint: ${cms}`, detail: 'New fingerprint vs prior scan.' });
+ }
+ }
+ return diffs;
+}
+