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

@@ -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 = [