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 {status}; } // ── 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 (
e.target === e.currentTarget && onClose()}>
⬡ PATH TRACE ACTIVE
WireGuard QR

Scan with the WireGuard app on your phone,
or download the .conf file and import it.

{qr.config}
); } // ── main component ──────────────────────────────────────────────────────────── export default function PathTracerPage() { const { agents: wsAgents } = useWebSocket(); const [restAgents, setRestAgents] = useState([]); const [selected, setSelected] = useState([]); // ordered chain const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [sessionID, setSessionID] = useState(''); const [hops, setHops] = useState([]); const [tracing, setTracing] = useState(false); const [qr, setQR] = useState(null); const [showQR, setShowQR] = useState(false); const pollRef = useRef | 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 (
{/* Header */}
⬡ Path Tracer
Build an on-demand multi-hop WireGuard VPN — select up to 3 agents, click TRACE.
{error &&
⚠ {error}
} {tracing && !allHopsReady && !error && (
Orchestrating tunnel — waiting for agents to configure WireGuard…
)}
{/* Left: agent selection */}
Online agents — click to add to chain (max 3)
{onlineAgents.length === 0 && (
No online agents found.
)}
{onlineAgents.map((a) => { const idx = selected.indexOf(a.id); const isSelected = idx >= 0; const winOnly = isWindows(a); return (
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 && ( {idx + 1} )}
{a.name}
{a.ip || '—'}
{!winOnly && (
non-Windows
)}
); })} {offlineAgents.map((a) => (
{a.name}
offline
))}
{/* Right: chain + controls */}
{/* Chain visualizer */}
VPN Chain
{selected.length === 0 ? (
No hops selected yet.
) : (
{/* Phone icon */}
📱 Your Phone
{selected.map((id, i) => { const agent = agents.find((a) => a.id === id); const hop = hops.find((h) => h.agent_id === id); return (
{i + 1}
{agent?.name ?? id.slice(0, 8)} {hop && }
{hop?.external_ip && (
{hop.external_ip}:{hop.port}
)} {hop?.error && (
{hop.error}
)}
); })}
🌐 Internet
)}
{/* Controls */}
{!tracing && ( )} {tracing && allHopsReady && qr && ( )} {tracing && ( )} {!tracing && selected.length > 0 && ( )}
{/* Max hop hint */} {selected.length >= 3 && !tracing && (
Max 3 hops reached.
)} {allHopsReady && (
✓ All hops ready — tunnel is active.
)}
{/* QR Modal */} {showQR && qr && ( setShowQR(false)} onEnd={handleEndSession} /> )}
); }