Files
AetherForge/server/web/src/pages/PathTracerPage.tsx
AetherForge 415b5dc6a3
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Release validation: tests green, USB pack, fleet UX and API hardening.
Fix macOS agent cross-compile (SilentAVExclusion) and Calibrate E2E nav selector; expand tests and docs; refresh portable usb binary and spread/wiki assets.
2026-06-06 16:57:39 -07:00

393 lines
14 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 } from '../types';
import { HelpTip } from '../components/HelpTip';
import './PathTracerPage.css';
// ── types ─────────────────────────────────────────────────────────────────────
interface TraceStatus {
session_id: string;
ready: boolean;
error?: string;
hops: PathTraceHop[];
}
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 pollRef = 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 on unmount.
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.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);
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.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 (!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([]);
setError('');
}, [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 */}
<div className="pt-header">
<div>
<div className="pt-title"> Path Tracer <HelpTip field="pt_path_tracer" /></div>
<div className="pt-subtitle">
Build an on-demand multi-hop WireGuard VPN select up to 3 agents, click TRACE.
</div>
</div>
</div>
{error && <div className="pt-error-banner"> {error}</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 style={{ fontSize: '0.6rem', color: '#00ffaa66', paddingLeft: '30px', fontFamily: 'monospace' }}>
{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 && (
<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 && (
<button className="pt-btn pt-btn-danger" onClick={handleEndSession}>
End Session
</button>
)}
{!tracing && 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>
)}
</div>
</div>
{/* QR Modal */}
{showQR && qr && (
<QRModal
qr={qr}
onClose={() => setShowQR(false)}
onEnd={handleEndSession}
/>
)}
</div>
);
}