From 41ded0170c6a6eb160257f103bf5fde324d8176a Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sun, 23 Nov 2025 20:29:24 +0100 Subject: [PATCH] Add wear percentage display and NVMe temperature collection - Display wear percentage in storage headers for single physical drives - Remove redundant drive type indicators, show wear data instead - Fix wear metric parsing for physical drives (underscore count issue) - Add NVMe temperature parsing support (Temperature: format) - Add raw metrics debugging functionality for troubleshooting - Clean up physical drive display to remove redundant information --- Cargo.lock | 6 +- agent/Cargo.toml | 2 +- agent/src/collectors/disk.rs | 11 +++ dashboard/Cargo.toml | 2 +- dashboard/src/app.rs | 16 +++- dashboard/src/main.rs | 6 +- dashboard/src/ui/widgets/system.rs | 118 ++++++++++++++--------------- shared/Cargo.toml | 2 +- 8 files changed, 95 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee3fea7..b7ab04e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.128" +version = "0.1.129" dependencies = [ "anyhow", "chrono", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.128" +version = "0.1.129" dependencies = [ "anyhow", "async-trait", @@ -324,7 +324,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.128" +version = "0.1.129" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index f200f18..c7df308 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.129" +version = "0.1.130" edition = "2021" [dependencies] diff --git a/agent/src/collectors/disk.rs b/agent/src/collectors/disk.rs index 7846794..24b4f5b 100644 --- a/agent/src/collectors/disk.rs +++ b/agent/src/collectors/disk.rs @@ -499,6 +499,17 @@ impl DiskCollector { } } } + // NVMe format: "Temperature:" (capital T) + if line.contains("Temperature:") { + if let Some(temp_part) = line.split("Temperature:").nth(1) { + if let Some(temp_str) = temp_part.split_whitespace().next() { + if let Ok(temp) = temp_str.parse::() { + return Some(temp); + } + } + } + } + // Legacy format: "temperature:" (lowercase) if line.contains("temperature:") { if let Some(temp_part) = line.split("temperature:").nth(1) { if let Some(temp_str) = temp_part.split_whitespace().next() { diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 3f570c1..6c34e56 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.129" +version = "0.1.130" edition = "2021" [dependencies] diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs index cbc42fd..ef33b48 100644 --- a/dashboard/src/app.rs +++ b/dashboard/src/app.rs @@ -20,12 +20,13 @@ pub struct Dashboard { tui_app: Option, terminal: Option>>, headless: bool, + raw_data: bool, initial_commands_sent: std::collections::HashSet, config: DashboardConfig, } impl Dashboard { - pub async fn new(config_path: Option, headless: bool) -> Result { + pub async fn new(config_path: Option, headless: bool, raw_data: bool) -> Result { info!("Initializing dashboard"); // Load configuration - try default path if not specified @@ -119,6 +120,7 @@ impl Dashboard { tui_app, terminal, headless, + raw_data, initial_commands_sent: std::collections::HashSet::new(), config, }) @@ -204,6 +206,18 @@ impl Dashboard { .insert(metric_message.hostname.clone()); } + // Show raw data if requested (before processing) + if self.raw_data { + println!("RAW METRICS FROM {}: {} metrics", metric_message.hostname, metric_message.metrics.len()); + for metric in &metric_message.metrics { + println!(" {}: {:?} ({:?})", metric.name, metric.value, metric.status); + if let Some(desc) = &metric.description { + println!(" └─ {}", desc); + } + } + println!("{}", "─".repeat(80)); + } + // Update metric store self.metric_store .update_metrics(&metric_message.hostname, metric_message.metrics); diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index 0429706..cf0bdcb 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -51,6 +51,10 @@ struct Cli { /// Run in headless mode (no TUI, just logging) #[arg(long)] headless: bool, + + /// Show raw agent data in headless mode + #[arg(long)] + raw_data: bool, } #[tokio::main] @@ -86,7 +90,7 @@ async fn main() -> Result<()> { } // Create and run dashboard - let mut dashboard = Dashboard::new(cli.config, cli.headless).await?; + let mut dashboard = Dashboard::new(cli.config, cli.headless, cli.raw_data).await?; // Setup graceful shutdown let ctrl_c = async { diff --git a/dashboard/src/ui/widgets/system.rs b/dashboard/src/ui/widgets/system.rs index 6c74912..9e24010 100644 --- a/dashboard/src/ui/widgets/system.rs +++ b/dashboard/src/ui/widgets/system.rs @@ -232,12 +232,16 @@ impl SystemWidget { if let MetricValue::Float(temp) = metric.value { drive.temperature = Some(temp); drive.status = metric.status.clone(); + // For physical drives, if this is the main drive, also update pool health + if drive.name == pool.name && pool.health_status == Status::Unknown { + pool.health_status = metric.status.clone(); + } } } } } else if metric.name.contains("_wear_percent") { if let Some(drive_name) = self.extract_drive_name(&metric.name) { - // Find existing drive or create new one + // For physical drives, ensure we create the drive object let drive_exists = pool.drives.iter().any(|d| d.name == drive_name); if !drive_exists { pool.drives.push(StorageDrive { @@ -252,6 +256,10 @@ impl SystemWidget { if let MetricValue::Float(wear) = metric.value { drive.wear_percent = Some(wear); drive.status = metric.status.clone(); + // For physical drives, if this is the main drive, also update pool health + if drive.name == pool.name && pool.health_status == Status::Unknown { + pool.health_status = metric.status.clone(); + } } } } @@ -372,20 +380,23 @@ impl SystemWidget { } } - // Handle physical drive health metrics: disk_{drive}_health - if metric_name.ends_with("_health") && !metric_name.contains("_pool_health") { - // Count underscores to distinguish physical drive health (disk_{drive}_health) - // from pool drive health (disk_{pool}_{drive}_health) + // Handle physical drive metrics: disk_{drive}_health and disk_{drive}_wear_percent + if (metric_name.ends_with("_health") && !metric_name.contains("_pool_health")) + || metric_name.ends_with("_wear_percent") { + // Count underscores to distinguish physical drive metrics (disk_{drive}_metric) + // from pool drive metrics (disk_{pool}_{drive}_metric) let underscore_count = metric_name.matches('_').count(); - if underscore_count == 2 { // disk_{drive}_health - if let Some(suffix_pos) = metric_name.rfind("_health") { + // disk_nvme0n1_wear_percent has 3 underscores: disk_nvme0n1_wear_percent + if underscore_count == 3 { // disk_{drive}_wear_percent (where drive has underscores) + if let Some(suffix_pos) = metric_name.rfind("_health") + .or_else(|| metric_name.rfind("_wear_percent")) { return Some(metric_name[5..suffix_pos].to_string()); // Skip "disk_" } } } // Handle drive-specific metrics: disk_{pool}_{drive}_{metric} - let drive_suffixes = ["_temperature", "_wear_percent", "_health"]; + let drive_suffixes = ["_temperature", "_health"]; for suffix in drive_suffixes { if let Some(suffix_pos) = metric_name.rfind(suffix) { // Extract pool name by finding the second-to-last underscore @@ -428,9 +439,9 @@ impl SystemWidget { /// Extract drive name from disk metric name fn extract_drive_name(&self, metric_name: &str) -> Option { - // Pattern: disk_{pool_name}_{drive_name}_{metric_type} - // Now using actual device names like: disk_srv_media_sdb_temperature - // Since pool_name can contain underscores, work backwards from known metric suffixes + // Pattern: disk_{pool_name}_{drive_name}_{metric_type} OR disk_{drive_name}_{metric_type} + // Pool drives: disk_srv_media_sdb_temperature + // Physical drives: disk_nvme0n1_temperature if metric_name.starts_with("disk_") { if let Some(suffix_pos) = metric_name.rfind("_temperature") .or_else(|| metric_name.rfind("_wear_percent")) @@ -440,6 +451,10 @@ impl SystemWidget { // Extract the last component as drive name (e.g., "sdb", "sdc", "nvme0n1") if let Some(drive_start) = before_suffix.rfind('_') { return Some(before_suffix[drive_start + 1..].to_string()); + } else { + // Handle physical drive metrics: disk_{drive}_metric (no pool) + // Extract everything after "disk_" as the drive name + return Some(before_suffix[5..].to_string()); // Skip "disk_" } } } @@ -453,8 +468,16 @@ impl SystemWidget { for pool in &self.storage_pools { // Pool header line with type and health let pool_label = if pool.pool_type.starts_with("drive (") { - // For physical drives, show the drive name instead of mount point - format!("{} ({}):", pool.name, pool.pool_type) + // For physical drives, show the drive name with wear percentage if available + // Look for any drive with wear data (physical drives may have drives named after the pool) + let wear_opt = pool.drives.iter() + .find_map(|d| d.wear_percent); + + if let Some(wear) = wear_opt { + format!("{} W: {:.0}%:", pool.name, wear) + } else { + format!("{}:", pool.name) + } } else if pool.pool_type == "single" { format!("{}:", pool.mount_point) } else { @@ -468,25 +491,27 @@ impl SystemWidget { // Skip pool health line as discussed - removed - // Total usage line (always show for pools) - let usage_text = match (pool.usage_percent, pool.used_gb, pool.total_gb) { - (Some(pct), Some(used), Some(total)) => { - format!("Total: {:.0}% {:.1}GB/{:.1}GB", pct, used, total) - } - _ => "Total: —% —GB/—GB".to_string(), - }; - - let has_drives = !pool.drives.is_empty(); - let has_filesystems = !pool.filesystems.is_empty(); - let has_children = has_drives || has_filesystems; - let tree_symbol = if has_children { "├─" } else { "└─" }; - let mut usage_spans = vec![ - Span::raw(" "), - Span::styled(tree_symbol, Typography::tree()), - Span::raw(" "), - ]; - usage_spans.extend(StatusIcons::create_status_spans(pool.status.clone(), &usage_text)); - lines.push(Line::from(usage_spans)); + // Total usage line (only show for multi-drive pools, skip for single physical drives) + if !pool.pool_type.starts_with("drive (") { + let usage_text = match (pool.usage_percent, pool.used_gb, pool.total_gb) { + (Some(pct), Some(used), Some(total)) => { + format!("Total: {:.0}% {:.1}GB/{:.1}GB", pct, used, total) + } + _ => "Total: —% —GB/—GB".to_string(), + }; + + let has_drives = !pool.drives.is_empty(); + let has_filesystems = !pool.filesystems.is_empty(); + let has_children = has_drives || has_filesystems; + let tree_symbol = if has_children { "├─" } else { "└─" }; + let mut usage_spans = vec![ + Span::raw(" "), + Span::styled(tree_symbol, Typography::tree()), + Span::raw(" "), + ]; + usage_spans.extend(StatusIcons::create_status_spans(pool.status.clone(), &usage_text)); + lines.push(Line::from(usage_spans)); + } // Drive lines with enhanced grouping if pool.pool_type.contains("mergerfs") && pool.drives.len() > 1 { @@ -540,34 +565,7 @@ impl SystemWidget { self.render_drive_line(&mut lines, drive, tree_symbol); } } else if pool.pool_type.starts_with("drive (") { - // Physical drive pools: show drive info + filesystem children - // First show drive information - for drive in &pool.drives { - let mut drive_info = Vec::new(); - if let Some(temp) = drive.temperature { - drive_info.push(format!("T: {:.0}°C", temp)); - } - if let Some(wear) = drive.wear_percent { - drive_info.push(format!("W: {:.0}%", wear)); - } - let drive_text = if drive_info.is_empty() { - format!("Drive: {}", drive.name) - } else { - format!("Drive: {}", drive_info.join(" ")) - }; - - let has_filesystems = !pool.filesystems.is_empty(); - let tree_symbol = if has_filesystems { "├─" } else { "└─" }; - let mut drive_spans = vec![ - Span::raw(" "), - Span::styled(tree_symbol, Typography::tree()), - Span::raw(" "), - ]; - drive_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text)); - lines.push(Line::from(drive_spans)); - } - - // Then show filesystem children + // Physical drive pools: wear data shown in header, skip drive lines, show filesystems directly for (i, filesystem) in pool.filesystems.iter().enumerate() { let is_last = i == pool.filesystems.len() - 1; let tree_symbol = if is_last { "└─" } else { "├─" }; diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 2523fe6..6dcc2b3 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.129" +version = "0.1.130" edition = "2021" [dependencies]