Compare commits

...

5 Commits

Author SHA1 Message Date
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
5bb7d6cf57 Fix CPU model extraction for newer Intel generations
All checks were successful
Build and Release / build-and-release (push) Successful in 1m24s
- Handle 12th/13th Gen Intel format (e.g., "12th Gen Intel(R) Core(TM) i7-12700K")
- Extract full model including suffix (i7-12700K instead of truncated name)
- Simplify pattern matching logic
- Reduce fallback truncation to 15 chars
2025-12-01 19:35:03 +01:00
7a0dc27846 Extract CPU model number only to save display space
All checks were successful
Build and Release / build-and-release (push) Successful in 1m35s
- Parse Intel models (i3/i5/i7/i9-XXXX) from full name
- Parse AMD Ryzen models (Ryzen X XXXX) from full name
- Display format: "i7-9700 (8 cores)" instead of full CPU name
- Reduces CPU section width significantly
2025-12-01 19:23:26 +01:00
9 changed files with 137 additions and 53 deletions

6
Cargo.lock generated
View File

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

View File

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

View File

@@ -129,9 +129,11 @@ impl CpuCollector {
for line in content.lines() {
if line.starts_with("model name") {
if let Some(colon_pos) = line.find(':') {
let name = line[colon_pos + 1..].trim().to_string();
let full_name = line[colon_pos + 1..].trim();
// Extract just the model number (e.g., "i7-9700" from "Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz")
let model = Self::extract_cpu_model(full_name);
if model_name.is_none() {
model_name = Some(name);
model_name = Some(model);
}
}
} else if line.starts_with("processor") {
@@ -147,6 +149,41 @@ impl CpuCollector {
Ok(())
}
/// Extract CPU model number from full model name
/// Examples:
/// - "Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz" -> "i7-9700"
/// - "12th Gen Intel(R) Core(TM) i7-12700K" -> "i7-12700K"
/// - "AMD Ryzen 9 5950X 16-Core Processor" -> "Ryzen 9 5950X"
fn extract_cpu_model(full_name: &str) -> String {
// Look for Intel Core patterns (both old and new gen): i3, i5, i7, i9
// Match pattern like "i7-12700K" or "i7-9700"
for prefix in &["i3-", "i5-", "i7-", "i9-"] {
if let Some(pos) = full_name.find(prefix) {
// Find end of model number (until space or end of string)
let after_prefix = &full_name[pos..];
let end = after_prefix.find(' ').unwrap_or(after_prefix.len());
return after_prefix[..end].to_string();
}
}
// Look for AMD Ryzen pattern
if let Some(pos) = full_name.find("Ryzen") {
// Extract "Ryzen X XXXX" pattern
let after_ryzen = &full_name[pos..];
let parts: Vec<&str> = after_ryzen.split_whitespace().collect();
if parts.len() >= 3 {
return format!("{} {} {}", parts[0], parts[1], parts[2]);
}
}
// Fallback: return first 15 characters or full name if shorter
if full_name.len() > 15 {
full_name[..15].to_string()
} else {
full_name.to_string()
}
}
/// Collect CPU C-state (idle depth) and populate AgentData with top 3 C-states by usage
async fn collect_cstate(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> {
// Read C-state usage from first CPU (representative of overall system)

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 == "transmission-vpn" && status_info.active_state == "active" {
if let Some((active_count, download_mbps, upload_mbps)) = self.get_transmission_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,74 @@ impl SystemdCollector {
None
}
/// Get aggregate transmission torrent statistics
/// Returns: (active_count, download_mbps, upload_mbps)
fn get_transmission_stats(&self) -> Option<(u32, f32, f32)> {
let rpc_url = "http://localhost:9091/transmission/rpc";
// Create HTTP client with timeout
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.ok()?;
// First request to get session ID (transmission requires this for CSRF protection)
let session_id = match client.post(rpc_url).send() {
Ok(resp) => {
resp.headers()
.get("X-Transmission-Session-Id")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
Err(_) => return None,
}?;
// Request torrent list with session ID
let request_body = serde_json::json!({
"method": "torrent-get",
"arguments": {
"fields": ["status", "rateDownload", "rateUpload"]
}
});
let response = client
.post(rpc_url)
.header("X-Transmission-Session-Id", session_id)
.json(&request_body)
.send()
.ok()?;
let json: serde_json::Value = response.json().ok()?;
// Parse torrent data and calculate aggregates
let torrent_list = json["arguments"]["torrents"].as_array()?;
let mut active_count = 0u32;
let mut total_download_bps = 0.0f64;
let mut total_upload_bps = 0.0f64;
for torrent in torrent_list {
let status_code = torrent["status"].as_i64().unwrap_or(0);
let rate_download = torrent["rateDownload"].as_f64().unwrap_or(0.0);
let rate_upload = torrent["rateUpload"].as_f64().unwrap_or(0.0);
// Status codes: 0=stopped, 4=downloading, 6=seeding
// Count as active if downloading or seeding
if status_code == 4 || status_code == 6 {
active_count += 1;
}
total_download_bps += rate_download;
total_upload_bps += rate_upload;
}
// Convert bytes/s 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.240"
version = "0.1.245"
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.240"
version = "0.1.245"
edition = "2021"
[dependencies]