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
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) => (
))}
{/* 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}
/>
)}
);
}