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

@@ -53,6 +53,14 @@ struct EnrollmentResult {
tunnel_strategy: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TunnelMetrics {
active: bool,
rx_bytes: u64,
tx_bytes: u64,
}
#[derive(Debug, Serialize)]
struct LoginRequest<'a> {
username: &'a str,
@@ -318,6 +326,18 @@ fn tunnel_status(app: AppHandle, state: State<'_, AppState>) -> Result<bool, Str
tunnel_manager::is_active(&app, std::path::Path::new(&session.profile_path))
}
#[tauri::command]
fn tunnel_metrics(app: AppHandle, state: State<'_, AppState>) -> Result<TunnelMetrics, String> {
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?;
let metrics = tunnel_manager::metrics(&app, std::path::Path::new(&session.profile_path))?;
Ok(TunnelMetrics {
active: metrics.active,
rx_bytes: metrics.rx_bytes,
tx_bytes: metrics.tx_bytes,
})
}
fn generate_keypair() -> (String, String) {
let private = StaticSecret::random_from_rng(OsRng);
let public = PublicKey::from(&private);
@@ -444,7 +464,7 @@ pub fn run() {
}
_ => {}
})
.invoke_handler(tauri::generate_handler![load_state, clear_session, enroll_device, sync_profile, connect_tunnel, disconnect_tunnel, tunnel_status])
.invoke_handler(tauri::generate_handler![load_state, clear_session, enroll_device, sync_profile, connect_tunnel, disconnect_tunnel, tunnel_status, tunnel_metrics])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -3,6 +3,7 @@ use std::{
process::Command,
};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
pub fn current_tunnel_strategy() -> &'static str {
@@ -48,20 +49,32 @@ pub fn disconnect(app: &AppHandle, profile_path: &Path) -> Result<(), String> {
}
pub fn is_active(app: &AppHandle, profile_path: &Path) -> Result<bool, String> {
Ok(metrics(app, profile_path)?.active)
}
pub fn metrics(app: &AppHandle, profile_path: &Path) -> Result<TunnelMetrics, String> {
let backend = bundled_backend(app)?;
let output = Command::new(backend)
.arg("status")
.arg("metrics")
.arg("--profile")
.arg(profile_path)
.output()
.map_err(|err| format!("Unable to query embedded tunnel backend: {}", err))?;
if !output.status.success() {
return Err(format_helper_error("status", &output));
return Err(format_helper_error("metrics", &output));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.trim().eq_ignore_ascii_case("active"))
serde_json::from_slice::<TunnelMetrics>(&output.stdout)
.map_err(|err| format!("Unable to decode tunnel metrics: {}", err))
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TunnelMetrics {
pub active: bool,
pub rx_bytes: u64,
pub tx_bytes: u64,
}
fn bundled_backend(app: &AppHandle) -> Result<PathBuf, String> {

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>

View File

@@ -55,6 +55,15 @@ struct TunnelResponse {
ok: bool,
error: Option<String>,
active: Option<bool>,
rx_bytes: Option<u64>,
tx_bytes: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
struct TunnelMetrics {
active: bool,
rx_bytes: u64,
tx_bytes: u64,
}
fn main() -> ExitCode {
@@ -84,6 +93,15 @@ fn run() -> Result<(), String> {
println!("{}", if active { "active" } else { "inactive" });
Ok(())
}
"metrics" => {
let profile = parse_profile_arg(args)?;
println!(
"{}",
serde_json::to_string(&windows_client_metrics(&profile)?)
.map_err(|err| format!("Unable to encode tunnel metrics: {err}"))?
);
Ok(())
}
"connect" | "disconnect" => {
let profile = parse_profile_arg(args)?;
windows_client_request(command.as_str(), &profile)
@@ -101,6 +119,14 @@ fn run() -> Result<(), String> {
println!("{}", if active { "active" } else { "inactive" });
Ok(())
}
"metrics" => {
println!(
"{}",
serde_json::to_string(&tunnel_metrics(&profile)?)
.map_err(|err| format!("Unable to encode tunnel metrics: {err}"))?
);
Ok(())
}
"connect" => connect_direct(&profile),
"disconnect" => disconnect_direct(&profile),
_ => Err("unsupported command".into()),
@@ -214,19 +240,23 @@ fn handle_service_client(mut stream: TcpStream) -> Result<(), String> {
.map_err(|err| format!("Unable to decode IPC request: {err}"))?;
let response = match request.action.as_str() {
"connect" => match connect_direct(Path::new(&request.profile)) {
Ok(()) => TunnelResponse { ok: true, error: None, active: None },
Ok(()) => TunnelResponse { ok: true, error: None, active: None, rx_bytes: None, tx_bytes: None },
Err(err) => TunnelResponse {
ok: false,
error: Some(err),
active: None,
rx_bytes: None,
tx_bytes: None,
},
},
"disconnect" => match disconnect_direct(Path::new(&request.profile)) {
Ok(()) => TunnelResponse { ok: true, error: None, active: None },
Ok(()) => TunnelResponse { ok: true, error: None, active: None, rx_bytes: None, tx_bytes: None },
Err(err) => TunnelResponse {
ok: false,
error: Some(err),
active: None,
rx_bytes: None,
tx_bytes: None,
},
},
"status" => match tunnel_is_active(Path::new(&request.profile)) {
@@ -234,17 +264,39 @@ fn handle_service_client(mut stream: TcpStream) -> Result<(), String> {
ok: true,
error: None,
active: Some(active),
rx_bytes: None,
tx_bytes: None,
},
Err(err) => TunnelResponse {
ok: false,
error: Some(err),
active: None,
rx_bytes: None,
tx_bytes: None,
},
},
"metrics" => match tunnel_metrics(Path::new(&request.profile)) {
Ok(metrics) => TunnelResponse {
ok: true,
error: None,
active: Some(metrics.active),
rx_bytes: Some(metrics.rx_bytes),
tx_bytes: Some(metrics.tx_bytes),
},
Err(err) => TunnelResponse {
ok: false,
error: Some(err),
active: None,
rx_bytes: None,
tx_bytes: None,
},
},
_ => TunnelResponse {
ok: false,
error: Some("unsupported tunnel action".into()),
active: None,
rx_bytes: None,
tx_bytes: None,
},
};
@@ -367,34 +419,43 @@ fn windows_client_request(action: &str, profile: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
fn windows_client_status(profile: &Path) -> Result<bool, String> {
Ok(windows_client_metrics(profile)?.active)
}
#[cfg(target_os = "windows")]
fn windows_client_metrics(profile: &Path) -> Result<TunnelMetrics, String> {
let mut stream = connect_to_service()?;
let payload = serde_json::to_string(&TunnelRequest {
action: "status".to_string(),
action: "metrics".to_string(),
profile: profile.display().to_string(),
})
.map_err(|err| format!("Unable to encode tunnel status request: {err}"))?;
.map_err(|err| format!("Unable to encode tunnel metrics request: {err}"))?;
stream
.write_all(payload.as_bytes())
.and_then(|_| stream.write_all(b"\n"))
.map_err(|err| format!("Unable to send tunnel status request: {err}"))?;
.map_err(|err| format!("Unable to send tunnel metrics request: {err}"))?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader
.read_line(&mut line)
.map_err(|err| format!("Unable to read tunnel status response: {err}"))?;
.map_err(|err| format!("Unable to read tunnel metrics response: {err}"))?;
let response = serde_json::from_str::<TunnelResponse>(&line)
.map_err(|err| format!("Unable to decode tunnel status response: {err}"))?;
.map_err(|err| format!("Unable to decode tunnel metrics response: {err}"))?;
if !response.ok {
return Err(response
.error
.unwrap_or_else(|| "NexaVPN tunnel service status check failed".into()));
.unwrap_or_else(|| "NexaVPN tunnel service metrics check failed".into()));
}
Ok(response.active.unwrap_or(false))
Ok(TunnelMetrics {
active: response.active.unwrap_or(false),
rx_bytes: response.rx_bytes.unwrap_or(0),
tx_bytes: response.tx_bytes.unwrap_or(0),
})
}
#[cfg(target_os = "windows")]
@@ -517,12 +578,31 @@ fn disconnect_direct(profile: &Path) -> Result<(), String> {
}
fn tunnel_is_active(profile: &Path) -> Result<bool, String> {
Ok(tunnel_metrics(profile)?.active)
}
fn tunnel_metrics(profile: &Path) -> Result<TunnelMetrics, String> {
let active = tunnel_service_is_active(profile)?;
if !active {
return Ok(TunnelMetrics {
active: false,
rx_bytes: 0,
tx_bytes: 0,
});
}
let (rx_bytes, tx_bytes) = read_transfer_totals(profile).unwrap_or((0, 0));
Ok(TunnelMetrics {
active: true,
rx_bytes,
tx_bytes,
})
}
fn tunnel_service_is_active(profile: &Path) -> Result<bool, String> {
#[cfg(target_os = "windows")]
{
let tunnel_name = profile
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| "invalid profile filename".to_string())?;
let tunnel_name = tunnel_name(profile)?;
let service_name = format!("WireGuardTunnel${}", tunnel_name);
let output = Command::new("sc")
.arg("query")
@@ -541,10 +621,7 @@ fn tunnel_is_active(profile: &Path) -> Result<bool, String> {
#[cfg(target_os = "macos")]
{
let tunnel_name = profile
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| "invalid profile filename".to_string())?;
let tunnel_name = tunnel_name(profile)?;
let status = Command::new("wg")
.arg("show")
.arg(tunnel_name)
@@ -557,6 +634,63 @@ fn tunnel_is_active(profile: &Path) -> Result<bool, String> {
Err("unsupported platform".into())
}
fn tunnel_name(profile: &Path) -> Result<&str, String> {
profile
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| "invalid profile filename".to_string())
}
fn read_transfer_totals(profile: &Path) -> Result<(u64, u64), String> {
let tunnel_name = tunnel_name(profile)?;
let mut command = Command::new(find_wg_cli()?);
command.arg("show").arg(tunnel_name).arg("dump");
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
let output = command
.output()
.map_err(|err| format!("Unable to query tunnel transfer counters: {err}"))?;
if !output.status.success() {
return Err("Unable to read WireGuard transfer counters.".into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut rx_bytes = 0_u64;
let mut tx_bytes = 0_u64;
for line in stdout.lines().skip(1) {
let fields: Vec<&str> = line.split('\t').collect();
if fields.len() < 7 {
continue;
}
rx_bytes = rx_bytes.saturating_add(fields[5].parse::<u64>().unwrap_or(0));
tx_bytes = tx_bytes.saturating_add(fields[6].parse::<u64>().unwrap_or(0));
}
Ok((rx_bytes, tx_bytes))
}
#[cfg(target_os = "windows")]
fn find_wg_cli() -> Result<PathBuf, String> {
let candidates = [
PathBuf::from(r"C:\Program Files\WireGuard\wg.exe"),
PathBuf::from(r"C:\Program Files (x86)\WireGuard\wg.exe"),
];
candidates
.into_iter()
.find(|path| path.exists())
.ok_or_else(|| "required Windows WireGuard CLI is not available".to_string())
}
#[cfg(target_os = "macos")]
fn find_wg_cli() -> Result<PathBuf, String> {
Ok(PathBuf::from("wg"))
}
#[cfg(target_os = "windows")]
fn find_windows_wireguard() -> Result<PathBuf, String> {
let candidates = [