Compare commits

...

4 Commits

Author SHA1 Message Date
1cb6abf58a Replace Transmission with qBittorrent for torrent statistics
All checks were successful
Build and Release / build-and-release (push) Successful in 1m27s
Update collector to use qBittorrent Web API instead of Transmission RPC.
Query qBittorrent through VPN namespace using existing passwordless sudo
permissions for ip netns exec commands.

- Change service name from transmission-vpn to openvpn-vpn-download
- Replace get_transmission_stats() with get_qbittorrent_stats()
- Use curl through VPN namespace to access qBittorrent API at localhost:8080
- Parse qBittorrent JSON response for state, dlspeed, upspeed
- Count active torrents (downloading, uploading, stalledDL, stalledUP)
- Update version to v0.1.246
2025-12-02 23:31:56 +01:00
477724b4f4 Unify sub-service display formatting for Info status
All checks were successful
Build and Release / build-and-release (push) Successful in 1m39s
Change docker images to use name field for all data instead of metrics,
matching the pattern used by torrent stats and VPN routes. Increase display
width for Status::Info sub-services from 18 to 50 characters to accommodate
longer informational text without truncation.

- Docker images now show: "image-name size: 994.0 MB" in name field
- Torrent stats show: "17 active, ↓ 2.5 MB/s, ↑ 1.2 MB/s" in name field
- Remove fixed-width padding for Info status sub-services
- Update version to v0.1.245
2025-12-02 11:36:27 +01:00
7a3ed17952 Add torrent statistics to transmission-vpn service
All checks were successful
Build and Release / build-and-release (push) Successful in 1m47s
Implement aggregate torrent statistics display for transmission-vpn service
via Transmission RPC API. Shows active torrent count and total download/upload
speeds. Change VPN route label from "ip:" to "route:" for clarity.

- Add get_transmission_stats() method to query Transmission RPC
- Display format: "X active, ↓ MB/s, ↑ MB/s"
- Update version to v0.1.244
2025-12-02 11:12:14 +01:00
7e1962a168 Remove ZMQ debug packet counter from display
All checks were successful
Build and Release / build-and-release (push) Successful in 1m23s
- Remove ZMQ stats display from system widget
- Remove update_zmq_stats method
- Remove zmq_packets_received and zmq_last_packet_age fields
- Clean up display to only show essential information
2025-12-01 19:42:05 +01:00
8 changed files with 85 additions and 51 deletions

6
Cargo.lock generated
View File

@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "cm-dashboard"
version = "0.1.241"
version = "0.1.245"
dependencies = [
"anyhow",
"chrono",
@@ -301,7 +301,7 @@ dependencies = [
[[package]]
name = "cm-dashboard-agent"
version = "0.1.241"
version = "0.1.245"
dependencies = [
"anyhow",
"async-trait",
@@ -325,7 +325,7 @@ dependencies = [
[[package]]
name = "cm-dashboard-shared"
version = "0.1.241"
version = "0.1.245"
dependencies = [
"chrono",
"serde",

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-dashboard-agent"
version = "0.1.242"
version = "0.1.246"
edition = "2021"
[dependencies]

View File

@@ -142,16 +142,11 @@ impl SystemdCollector {
// Add Docker images
let docker_images = self.get_docker_images();
for (image_name, image_status, image_size_mb) in docker_images {
let mut metrics = Vec::new();
metrics.push(SubServiceMetric {
label: "size".to_string(),
value: image_size_mb,
unit: Some("MB".to_string()),
});
for (image_name, _image_status, image_size_mb) in docker_images {
let metrics = Vec::new();
sub_services.push(SubServiceData {
name: image_name.to_string(),
name: format!("{} size: {:.1} MB", image_name, image_size_mb),
service_status: Status::Info, // Informational only, no status icon
metrics,
service_type: "image".to_string(),
@@ -164,7 +159,7 @@ impl SystemdCollector {
let metrics = Vec::new();
sub_services.push(SubServiceData {
name: format!("ip: {}", external_ip),
name: format!("route: {}", external_ip),
service_status: Status::Info,
metrics,
service_type: "vpn_route".to_string(),
@@ -172,6 +167,19 @@ impl SystemdCollector {
}
}
if service_name == "openvpn-vpn-download" && status_info.active_state == "active" {
if let Some((active_count, download_mbps, upload_mbps)) = self.get_qbittorrent_stats() {
let metrics = Vec::new();
sub_services.push(SubServiceData {
name: format!("{} active, ↓ {:.1} MB/s, ↑ {:.1} MB/s", active_count, download_mbps, upload_mbps),
service_status: Status::Info,
metrics,
service_type: "torrent_stats".to_string(),
});
}
}
// Create complete service data
let service_data = ServiceData {
name: service_name.clone(),
@@ -878,6 +886,61 @@ impl SystemdCollector {
None
}
/// Get aggregate qBittorrent torrent statistics
/// Returns: (active_count, download_mbps, upload_mbps)
fn get_qbittorrent_stats(&self) -> Option<(u32, f32, f32)> {
// Query qBittorrent API through VPN namespace
let output = Command::new("timeout")
.args(&[
"5",
"sudo",
"ip",
"netns",
"exec",
"vpn",
"curl",
"-s",
"--max-time",
"4",
"http://localhost:8080/api/v2/torrents/info"
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let output_str = String::from_utf8_lossy(&output.stdout);
let torrents: Vec<serde_json::Value> = serde_json::from_str(&output_str).ok()?;
let mut active_count = 0u32;
let mut total_download_bps = 0.0f64;
let mut total_upload_bps = 0.0f64;
for torrent in torrents {
let state = torrent["state"].as_str().unwrap_or("");
let dlspeed = torrent["dlspeed"].as_f64().unwrap_or(0.0);
let upspeed = torrent["upspeed"].as_f64().unwrap_or(0.0);
// States: downloading, uploading, stalledDL, stalledUP, queuedDL, queuedUP, pausedDL, pausedUP
// Count as active if downloading or uploading (seeding)
if state.contains("downloading") || state.contains("uploading") ||
state == "stalledDL" || state == "stalledUP" {
active_count += 1;
}
total_download_bps += dlspeed;
total_upload_bps += upspeed;
}
// qBittorrent returns bytes/s, convert to MB/s
let download_mbps = (total_download_bps / 1024.0 / 1024.0) as f32;
let upload_mbps = (total_upload_bps / 1024.0 / 1024.0) as f32;
Some((active_count, download_mbps, upload_mbps))
}
}
#[async_trait]

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-dashboard"
version = "0.1.242"
version = "0.1.246"
edition = "2021"
[dependencies]

View File

@@ -110,14 +110,6 @@ impl TuiApp {
host_widgets.system_widget.update_from_agent_data(agent_data);
host_widgets.services_widget.update_from_agent_data(agent_data);
// Update ZMQ stats
if let Some(zmq_stats) = metric_store.get_zmq_stats(&hostname) {
host_widgets.system_widget.update_zmq_stats(
zmq_stats.packets_received,
zmq_stats.last_packet_age_secs
);
}
host_widgets.last_update = Some(Instant::now());
}
}

View File

@@ -230,9 +230,12 @@ impl ServicesWidget {
info: &ServiceInfo,
is_last: bool,
) -> Vec<ratatui::text::Span<'static>> {
// Informational sub-services (Status::Info) can use more width since they don't show columns
let max_width = if info.widget_status == Status::Info { 50 } else { 18 };
// Truncate long sub-service names to fit layout (accounting for indentation)
let short_name = if name.len() > 18 {
format!("{}...", &name[..15])
let short_name = if name.len() > max_width {
format!("{}...", &name[..(max_width.saturating_sub(3))])
} else {
name.to_string()
};
@@ -281,9 +284,9 @@ impl ServicesWidget {
format!(" {} ", tree_symbol),
Typography::tree(),
),
// Service name (no icon)
// Service name (no icon) - no fixed width padding for Info status
ratatui::text::Span::styled(
format!("{:<18} ", short_name),
short_name,
Style::default()
.fg(Theme::secondary_text())
.bg(Theme::background()),

View File

@@ -15,10 +15,6 @@ pub struct SystemWidget {
nixos_build: Option<String>,
agent_hash: Option<String>,
// ZMQ communication stats
zmq_packets_received: Option<u64>,
zmq_last_packet_age: Option<f64>,
// Network interfaces
network_interfaces: Vec<cm_dashboard_shared::NetworkInterfaceData>,
@@ -92,8 +88,6 @@ impl SystemWidget {
Self {
nixos_build: None,
agent_hash: None,
zmq_packets_received: None,
zmq_last_packet_age: None,
network_interfaces: Vec::new(),
cpu_load_1min: None,
cpu_load_5min: None,
@@ -159,12 +153,6 @@ impl SystemWidget {
pub fn _get_agent_hash(&self) -> Option<&String> {
self.agent_hash.as_ref()
}
/// Update ZMQ communication statistics
pub fn update_zmq_stats(&mut self, packets_received: u64, last_packet_age_secs: f64) {
self.zmq_packets_received = Some(packets_received);
self.zmq_last_packet_age = Some(last_packet_age_secs);
}
}
use super::Widget;
@@ -811,18 +799,6 @@ impl SystemWidget {
Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary())
]));
// ZMQ communication stats
if let (Some(packets), Some(age)) = (self.zmq_packets_received, self.zmq_last_packet_age) {
let age_text = if age < 1.0 {
format!("{:.0}ms ago", age * 1000.0)
} else {
format!("{:.1}s ago", age)
};
lines.push(Line::from(vec![
Span::styled(format!("ZMQ: {} pkts, last {}", packets, age_text), Typography::secondary())
]));
}
// CPU section
lines.push(Line::from(vec![
Span::styled("CPU:", Typography::widget_title())

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-dashboard-shared"
version = "0.1.242"
version = "0.1.246"
edition = "2021"
[dependencies]