Files
AetherForge/server/web/src/pages/PathTracerPage.tsx
AetherForge 7831e70dd4 Add AV/Defender tests and clear PROBLEMS.md antivirus rows.
Cover mining diagnostics JSON blockers, AV-Safe preset fields, defender_off error paths, and Calibrate exclusion .ps1 generation with Go + Vitest; honest AV limits already documented in settingHelp.
2026-06-07 06:34:56 -07:00

456 lines
16 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../api/client';
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, PathTraceHop, SpreadRouteRecommendation } from '../types';
import { HelpTip } from '../components/HelpTip';
import SacredPageHeader from '../components/Visual/sacredGeometry/SacredPageHeader';
import './PathTracerPage.css';
// ── types ─────────────────────────────────────────────────────────────────────
interface TraceStatus {
session_id: string;
ready: boolean;
error?: string;
hops: PathTraceHop[];
spread_routes?: SpreadRouteRecommendation[];
}
interface QRData {
config: string;
qr_png_b64: string;
}
// ── helpers ───────────────────────────────────────────────────────────────────
function HopStatusBadge({ status }: { status: PathTraceHop['status'] }) {
return <span className={`pt-hop-status ${status}`}>{status}</span>;
}
// ── QR Modal ──────────────────────────────────────────────────────────────────
function QRModal({
qr,
onClose,
onEnd,
}: {
qr: QRData;
onClose: () => void;
onEnd: () => void;
}) {
useModalAmbientDuck(true);
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(qr.config).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
const handleDownload = () => {
const blob = new Blob([qr.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'pathtrace.conf';
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="pt-modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="pt-modal">
<div className="pt-modal-title"> PATH TRACE ACTIVE</div>
<div className="pt-qr-wrap">
<img
className="pt-qr-img"
src={`data:image/png;base64,${qr.qr_png_b64}`}
alt="WireGuard QR"
/>
</div>
<p className="pt-hint">
Scan with the <strong>WireGuard</strong> app on your phone,<br />
or download the .conf file and import it.
</p>
<pre className="pt-config-box">{qr.config}</pre>
<div className="pt-modal-actions">
<button className="pt-btn pt-btn-primary" onClick={handleCopy}>
{copied ? '✓ Copied' : 'Copy Config'}
</button>
<button className="pt-btn pt-btn-ghost" onClick={handleDownload}>
Download .conf
</button>
<button
className="pt-btn pt-btn-danger"
onClick={() => { onEnd(); onClose(); }}
style={{ marginLeft: 'auto' }}
>
End Session
</button>
</div>
</div>
</div>
);
}
// ── main component ────────────────────────────────────────────────────────────
export default function PathTracerPage() {
const { agents: wsAgents } = useWebSocket();
const [restAgents, setRestAgents] = useState<Agent[]>([]);
const [selected, setSelected] = useState<string[]>([]); // ordered chain
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [sessionID, setSessionID] = useState('');
const [hops, setHops] = useState<PathTraceHop[]>([]);
const [tracing, setTracing] = useState(false);
const [qr, setQR] = useState<QRData | null>(null);
const [showQR, setShowQR] = useState(false);
const [spreadRoutes, setSpreadRoutes] = useState<SpreadRouteRecommendation[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [autoEndCountdown, setAutoEndCountdown] = useState<number | null>(null);
const autoEndRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Use WebSocket agents; fall back to REST on mount if WebSocket hasn't populated yet.
const agents = wsAgents.length > 0 ? wsAgents : restAgents;
useEffect(() => {
api.listAgents().then(setRestAgents).catch(() => {});
}, []);
// Stop polling and auto-end timer on unmount.
useEffect(() => () => {
if (pollRef.current) clearInterval(pollRef.current);
if (autoEndRef.current) clearInterval(autoEndRef.current);
}, []);
const toggleAgent = (id: string, offline: boolean) => {
if (offline) return;
if (tracing) return; // don't let selection change while tracing
setSelected((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= 3) return prev; // max 3 hops
return [...prev, id];
});
};
const handleTrace = useCallback(async () => {
if (selected.length === 0) return;
setError('');
setLoading(true);
setTracing(true);
setHops([]);
setQR(null);
setSpreadRoutes([]);
try {
const res = await api.startTrace(selected);
setSessionID(res.session_id);
setHops(res.hops);
startPolling(res.session_id);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Trace failed');
setTracing(false);
} finally {
setLoading(false);
}
}, [selected]);
const startPolling = (sid: string) => {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const status: TraceStatus = await api.getTraceStatus(sid);
setHops(status.hops);
if (status.spread_routes?.length) {
setSpreadRoutes(status.spread_routes);
}
if (status.error) {
setError(status.error);
clearInterval(pollRef.current!);
pollRef.current = null;
setTracing(false);
return;
}
if (status.ready) {
try {
const qrData = await api.getTraceQR(sid);
clearInterval(pollRef.current!);
pollRef.current = null;
setQR(qrData);
setShowQR(true);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load QR config');
}
return;
}
} catch {
// Ignore transient status poll errors
}
}, 2000);
};
const handleEndSession = useCallback(async () => {
if (autoEndRef.current) { clearInterval(autoEndRef.current); autoEndRef.current = null; }
setAutoEndCountdown(null);
if (!sessionID) return;
try {
await api.deleteTrace(sessionID);
} catch {
// best-effort
}
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setSessionID('');
setHops([]);
setTracing(false);
setQR(null);
setSelected([]);
setSpreadRoutes([]);
setError('');
}, [sessionID]);
// Auto-delete the session 10 seconds after an error, with a visible countdown.
useEffect(() => {
if (!error || !sessionID) return;
if (autoEndRef.current) clearInterval(autoEndRef.current);
const COUNTDOWN = 10;
setAutoEndCountdown(COUNTDOWN);
let remaining = COUNTDOWN;
autoEndRef.current = setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
clearInterval(autoEndRef.current!);
autoEndRef.current = null;
setAutoEndCountdown(null);
handleEndSession();
} else {
setAutoEndCountdown(remaining);
}
}, 1000);
return () => {
if (autoEndRef.current) { clearInterval(autoEndRef.current); autoEndRef.current = null; }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error, sessionID]);
const isWindows = (a: Agent) =>
!!(a.platform?.toLowerCase().includes('win') || a.platform?.toLowerCase().includes('windows'));
const onlineAgents = agents.filter((a) => a.status === 'online');
const offlineAgents = agents.filter((a) => a.status !== 'online');
const allHopsReady = hops.length > 0 && hops.every((h) => h.status === 'ready');
return (
<div className="pathtrace-page operator-deck-page">
{/* Header */}
<SacredPageHeader
eyebrow="NETWORK OPS · WIREGUARD"
title="⬡ Path Tracer"
helpField="pt_path_tracer"
subtitle="Build an on-demand multi-hop WireGuard VPN — select up to 3 agents, click TRACE."
/>
{error && (
<div className="pt-error-banner" role="alert">
<strong> Session Error</strong>
<div style={{ marginTop: '0.3rem' }}>{error}</div>
{sessionID && autoEndCountdown !== null && (
<div style={{ marginTop: '0.3rem', opacity: 0.8 }}>
Session will be terminated in {autoEndCountdown}s&hellip;
</div>
)}
</div>
)}
{tracing && !allHopsReady && !error && (
<div className="pt-info-banner">
<span className="pt-spinner" />
Orchestrating tunnel waiting for agents to configure WireGuard&hellip;
</div>
)}
<div className="pt-body">
{/* Left: agent selection */}
<div>
<div className="pt-section-label">
Online agents &mdash; click to add to chain (max 3) <HelpTip field="pt_agent_chain" />
</div>
<div className="pt-section-panel">
{onlineAgents.length === 0 && (
<div className="pt-chain-empty">No online agents found.</div>
)}
<div className="pt-agent-grid">
{onlineAgents.map((a) => {
const idx = selected.indexOf(a.id);
const isSelected = idx >= 0;
const winOnly = isWindows(a);
return (
<div
key={a.id}
className={`pt-agent-card${isSelected ? ' selected' : ''}`}
onClick={() => winOnly ? toggleAgent(a.id, false) : undefined}
title={!winOnly ? 'WireGuard Path Tracer requires a Windows agent' : undefined}
style={!winOnly ? { opacity: 0.5, cursor: 'not-allowed' } : undefined}
>
{isSelected && (
<span className="pt-agent-card-order">{idx + 1}</span>
)}
<div className="pt-agent-name">
<span className="pt-agent-status-dot online" />
{a.name}
</div>
<div className="pt-agent-ip">{a.ip || '—'}</div>
{!winOnly && (
<div style={{ fontSize: '0.6rem', color: '#ff8800', fontFamily: 'monospace', marginTop: '0.15rem' }}>
non-Windows
</div>
)}
</div>
);
})}
{offlineAgents.map((a) => (
<div key={a.id} className="pt-agent-card offline">
<div className="pt-agent-name">
<span className="pt-agent-status-dot offline" />
{a.name}
</div>
<div className="pt-agent-ip">offline</div>
</div>
))}
</div>
</div>
</div>
{/* Right: chain + controls */}
<div className="pt-sidebar">
{/* Chain visualizer */}
<div className="pt-chain-panel">
<div className="pt-chain-title">VPN Chain</div>
{selected.length === 0 ? (
<div className="pt-chain-empty">No hops selected yet.</div>
) : (
<div className="pt-chain-row">
{/* Phone icon */}
<div className="pt-chain-hop" style={{ marginBottom: '0.15rem' }}>
<span style={{ fontSize: '0.7rem', color: '#888', fontFamily: 'monospace' }}>
📱 Your Phone
</span>
</div>
{selected.map((id, i) => {
const agent = agents.find((a) => a.id === id);
const hop = hops.find((h) => h.agent_id === id);
return (
<div key={id}>
<div className="pt-chain-arrow"></div>
<div className="pt-chain-hop">
<div className="pt-chain-hop-badge">{i + 1}</div>
<span className="pt-chain-hop-name">
{agent?.name ?? id.slice(0, 8)}
</span>
{hop && <HopStatusBadge status={hop.status} />}
</div>
{hop?.external_ip && (
<div className="pt-hop-meta">
{hop.external_ip}:{hop.port}
</div>
)}
{hop?.error && (
<div style={{ fontSize: '0.6rem', color: '#ff5050', paddingLeft: '30px', fontFamily: 'monospace' }}>
{hop.error}
</div>
)}
</div>
);
})}
<div className="pt-chain-arrow"></div>
<div className="pt-chain-hop">
<span style={{ fontSize: '0.7rem', color: '#888', fontFamily: 'monospace' }}>
🌐 Internet
</span>
</div>
</div>
)}
</div>
{/* Controls */}
<div className="pt-actions">
{!tracing && !sessionID && (
<button
className="pt-btn pt-btn-primary"
disabled={selected.length === 0 || loading}
onClick={handleTrace}
>
{loading ? <><span className="pt-spinner" />Building</> : '⬡ TRACE'}
</button>
)}
{tracing && allHopsReady && qr && (
<button className="pt-btn pt-btn-primary" onClick={() => setShowQR(true)}>
Show QR Code
</button>
)}
{(tracing || (!!error && !!sessionID)) && (
<button className="pt-btn pt-btn-danger" onClick={handleEndSession}>
End Session{autoEndCountdown !== null && ` (${autoEndCountdown}s)`}
</button>
)}
{!tracing && !sessionID && selected.length > 0 && (
<button className="pt-btn pt-btn-ghost" onClick={() => setSelected([])}>
Clear
</button>
)}
</div>
{/* Max hop hint */}
{selected.length >= 3 && !tracing && (
<div className="pt-hint">Max 3 hops reached.</div>
)}
{allHopsReady && (
<div className="pt-info-banner">
All hops ready tunnel is active.
</div>
)}
{spreadRoutes.length > 0 && (
<div className="pt-route-hints" style={{ marginTop: '0.75rem' }}>
<div className="pt-chain-title">Spread Routes</div>
<ul className="pt-route-list">
{spreadRoutes.map((route) => (
<li key={`${route.target_subnet}-${route.seed_agent_id}`}>
{route.target_subnet} {route.seed_agent_name ?? route.seed_agent_id.slice(0, 8)}
{route.join_lane ? ` (${route.join_lane})` : ''}
{route.erasure_lanes_enabled ? ' · RS lanes' : ''}
{route.score ? ` · ${route.score.toFixed(2)}` : ''}
</li>
))}
</ul>
</div>
)}
</div>
</div>
{/* QR Modal */}
{showQR && qr && (
<QRModal
qr={qr}
onClose={() => setShowQR(false)}
onEnd={handleEndSession}
/>
)}
</div>
);
}