feat: add device traffic metrics with gateway telemetry reporting and admin UI display

Add rx_bytes and tx_bytes fields to Device type and API responses. Add formatDataSize helper for human-readable byte formatting with units from B to TB. Add Received and Sent columns to devices table in admin UI with formatted traffic totals. Add traffic metrics display to device action panel.

Add TelemetrySnapshot and PeerTelemetry types for gateway runtime stats. Add gateway telemetry endpoint at POST /gateway
This commit is contained in:
2026-03-18 07:43:22 +01:00
parent 21b7a140dd
commit 610c5459e5
14 changed files with 472 additions and 34 deletions

View File

@@ -11,6 +11,12 @@ type EnrollmentState = {
tunnelStrategy: string;
};
type TunnelMetrics = {
active: boolean;
rxBytes: number;
txBytes: number;
};
function formatInvokeError(err: unknown, fallback: string) {
if (typeof err === "string" && err.trim().length > 0) {
return err;
@@ -39,6 +45,23 @@ function currentProfileLabel(state: EnrollmentState | null) {
return `Split tunnel (${state.resources.length} resources)`;
}
function formatDataSize(bytes: number) {
if (!bytes) {
return "0 MB";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value >= 100 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
}
export function App() {
const [serverUrl, setServerUrl] = useState("http://localhost");
const [username, setUsername] = useState("");
@@ -48,34 +71,61 @@ export function App() {
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [state, setState] = useState<EnrollmentState | null>(null);
const [metrics, setMetrics] = useState<TunnelMetrics>({
active: false,
rxBytes: 0,
txBytes: 0
});
async function refreshTunnelMetrics() {
try {
const value = await invoke<TunnelMetrics>("tunnel_metrics");
setMetrics(value);
setConnected(value.active);
} catch {
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
}
}
useEffect(() => {
void invoke<EnrollmentState | null>("load_state")
.then(async (value) => {
if (value) {
setState(value);
try {
const active = await invoke<boolean>("tunnel_status");
setConnected(active);
} catch {
setConnected(false);
}
await refreshTunnelMetrics();
}
})
.catch(() => undefined);
}, []);
useEffect(() => {
if (!state) {
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
return undefined;
}
void refreshTunnelMetrics();
const timer = window.setInterval(() => {
void refreshTunnelMetrics();
}, 5000);
return () => window.clearInterval(timer);
}, [state]);
const profileLabel = useMemo(() => currentProfileLabel(state), [state]);
async function waitForTunnelStatus(expected: boolean) {
for (let attempt = 0; attempt < 8; attempt += 1) {
try {
const active = await invoke<boolean>("tunnel_status");
if (active === expected) {
return active;
const value = await invoke<TunnelMetrics>("tunnel_metrics");
setMetrics(value);
setConnected(value.active);
if (value.active === expected) {
return value.active;
}
} catch {
if (!expected) {
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
return false;
}
}
@@ -83,7 +133,13 @@ export function App() {
await new Promise((resolve) => window.setTimeout(resolve, 500));
}
return invoke<boolean>("tunnel_status").catch(() => false);
return invoke<TunnelMetrics>("tunnel_metrics")
.then((value) => {
setMetrics(value);
setConnected(value.active);
return value.active;
})
.catch(() => false);
}
async function onSubmit(event: FormEvent) {
@@ -96,6 +152,7 @@ export function App() {
payload: { serverUrl, username, password }
});
setState(result);
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
} catch (err) {
setError(formatInvokeError(err, "Enrollment failed"));
} finally {
@@ -110,6 +167,7 @@ export function App() {
try {
const result = await invoke<EnrollmentState>("sync_profile");
setState(result);
await refreshTunnelMetrics();
} catch (err) {
setError(formatInvokeError(err, "Profile sync failed"));
} finally {
@@ -140,6 +198,7 @@ export function App() {
await invoke("clear_session");
setConnected(false);
setState(null);
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
setError(null);
} catch (err) {
setError(formatInvokeError(err, "Unable to clear local profile"));
@@ -242,6 +301,14 @@ export function App() {
<span>Last sync</span>
<strong>{state.lastSyncTime}</strong>
</div>
<div className="detail-card">
<span>Received</span>
<strong>{formatDataSize(metrics.rxBytes)}</strong>
</div>
<div className="detail-card">
<span>Sent</span>
<strong>{formatDataSize(metrics.txBytes)}</strong>
</div>
</div>
</div>