diff --git a/Cargo.lock b/Cargo.lock index 28128e4..e9b2c2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.136" +version = "0.1.137" dependencies = [ "anyhow", "chrono", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.136" +version = "0.1.137" dependencies = [ "anyhow", "async-trait", @@ -324,7 +324,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.136" +version = "0.1.137" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 06b7f7d..a786165 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.137" +version = "0.1.138" edition = "2021" [dependencies] diff --git a/agent/src/agent.rs b/agent/src/agent.rs index a3d8721..a8dfa05 100644 --- a/agent/src/agent.rs +++ b/agent/src/agent.rs @@ -262,47 +262,11 @@ impl Agent { agent_data.system.memory.swap_used_gb = value; } } - // Tmpfs metrics - else if metric.name.starts_with("memory_tmp_") { - // For now, create a single /tmp tmpfs entry - if metric.name == "memory_tmp_usage_percent" { + // Tmpfs metrics - handle multiple auto-discovered tmpfs mounts + else if metric.name.starts_with("memory_tmpfs_") { + if let Some((mount_point, metric_type)) = self.parse_tmpfs_metric_name(&metric.name) { if let Some(value) = metric.value.as_f32() { - if let Some(tmpfs) = agent_data.system.memory.tmpfs.get_mut(0) { - tmpfs.usage_percent = value; - } else { - agent_data.system.memory.tmpfs.push(TmpfsData { - mount: "/tmp".to_string(), - usage_percent: value, - used_gb: 0.0, - total_gb: 0.0, - }); - } - } - } else if metric.name == "memory_tmp_used_gb" { - if let Some(value) = metric.value.as_f32() { - if let Some(tmpfs) = agent_data.system.memory.tmpfs.get_mut(0) { - tmpfs.used_gb = value; - } else { - agent_data.system.memory.tmpfs.push(TmpfsData { - mount: "/tmp".to_string(), - usage_percent: 0.0, - used_gb: value, - total_gb: 0.0, - }); - } - } - } else if metric.name == "memory_tmp_total_gb" { - if let Some(value) = metric.value.as_f32() { - if let Some(tmpfs) = agent_data.system.memory.tmpfs.get_mut(0) { - tmpfs.total_gb = value; - } else { - agent_data.system.memory.tmpfs.push(TmpfsData { - mount: "/tmp".to_string(), - usage_percent: 0.0, - used_gb: 0.0, - total_gb: value, - }); - } + self.update_tmpfs_data(&mut agent_data.system.memory.tmpfs, &mount_point, &metric_type, value); } } } @@ -394,6 +358,63 @@ impl Agent { Ok(()) } + /// Parse tmpfs metric name to extract mount point and metric type + /// Example: "memory_tmpfs_tmp_usage_percent" -> ("/tmp", "usage_percent") + fn parse_tmpfs_metric_name(&self, metric_name: &str) -> Option<(String, String)> { + if !metric_name.starts_with("memory_tmpfs_") { + return None; + } + + let remainder = &metric_name[13..]; // Remove "memory_tmpfs_" prefix + + // Find the last underscore to separate metric type from mount point + if let Some(last_underscore) = remainder.rfind('_') { + let mount_safe = &remainder[..last_underscore]; + let metric_type = &remainder[last_underscore + 1..]; + + // Convert safe mount name back to actual mount point + let mount_point = if mount_safe.is_empty() { + "/" + } else { + &format!("/{}", mount_safe.replace('_', "/")) + }; + + Some((mount_point.to_string(), metric_type.to_string())) + } else { + None + } + } + + /// Update tmpfs data in the tmpfs vector + fn update_tmpfs_data(&self, tmpfs_vec: &mut Vec, mount_point: &str, metric_type: &str, value: f32) { + // Find existing tmpfs entry + let existing_index = tmpfs_vec.iter() + .position(|tmpfs| tmpfs.mount == mount_point); + + let tmpfs_index = if let Some(index) = existing_index { + index + } else { + // Create new entry + tmpfs_vec.push(TmpfsData { + mount: mount_point.to_string(), + usage_percent: 0.0, + used_gb: 0.0, + total_gb: 0.0, + }); + tmpfs_vec.len() - 1 + }; + + // Update the tmpfs entry + if let Some(tmpfs) = tmpfs_vec.get_mut(tmpfs_index) { + match metric_type { + "usage_percent" => tmpfs.usage_percent = value, + "used_gb" => tmpfs.used_gb = value, + "total_gb" => tmpfs.total_gb = value, + _ => {} // Unknown metric type, ignore + } + } + } + /// Extract drive name from metric like "disk_nvme0n1_temperature" fn extract_drive_name(&self, metric_name: &str) -> Option { if metric_name.starts_with("disk_") { @@ -529,31 +550,6 @@ impl Agent { } /// Create heartbeat metric for host connectivity detection - fn get_heartbeat_metric(&self) -> Metric { - use std::time::{SystemTime, UNIX_EPOCH}; - - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - Metric::new( - "agent_heartbeat".to_string(), - MetricValue::Integer(timestamp as i64), - Status::Ok, - ) - } - - /// Send standalone heartbeat for connectivity detection - async fn send_heartbeat(&mut self) -> Result<()> { - // Create minimal agent data with just heartbeat - let agent_data = AgentData::new(self.hostname.clone(), self.get_agent_version()); - // Heartbeat timestamp is already set in AgentData::new() - - self.zmq_handler.publish_agent_data(&agent_data).await?; - debug!("Sent standalone heartbeat for connectivity detection"); - Ok(()) - } async fn handle_commands(&mut self) -> Result<()> { // Try to receive commands (non-blocking) diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index ec2bf0e..63e263d 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.137" +version = "0.1.138" edition = "2021" [dependencies] diff --git a/dashboard/src/ui/widgets/backup.rs b/dashboard/src/ui/widgets/backup.rs index 973fe4e..b30a5f9 100644 --- a/dashboard/src/ui/widgets/backup.rs +++ b/dashboard/src/ui/widgets/backup.rs @@ -18,8 +18,6 @@ pub struct BackupWidget { duration_seconds: Option, /// Last backup timestamp last_run_timestamp: Option, - /// Total number of backup services - total_services: Option, /// Total repository size in GB total_repo_size_gb: Option, /// Total disk space for backups in GB @@ -32,14 +30,6 @@ pub struct BackupWidget { backup_disk_serial_number: Option, /// Backup disk wear percentage from SMART data backup_disk_wear_percent: Option, - /// Backup disk filesystem label - backup_disk_filesystem_label: Option, - /// Number of completed services - services_completed_count: Option, - /// Number of failed services - services_failed_count: Option, - /// Number of disabled services - services_disabled_count: Option, /// All individual service metrics for detailed display service_metrics: Vec, /// Last update indicator @@ -50,7 +40,6 @@ pub struct BackupWidget { struct ServiceMetricData { name: String, status: Status, - exit_code: Option, archive_count: Option, repo_size_gb: Option, } @@ -61,17 +50,12 @@ impl BackupWidget { overall_status: Status::Unknown, duration_seconds: None, last_run_timestamp: None, - total_services: None, total_repo_size_gb: None, backup_disk_total_gb: None, backup_disk_used_gb: None, backup_disk_product_name: None, backup_disk_serial_number: None, backup_disk_wear_percent: None, - backup_disk_filesystem_label: None, - services_completed_count: None, - services_failed_count: None, - services_disabled_count: None, service_metrics: Vec::new(), has_data: false, } @@ -112,6 +96,7 @@ impl BackupWidget { /// Extract service name from metric name (e.g., "backup_service_gitea_status" -> "gitea") + #[allow(dead_code)] fn extract_service_name(metric_name: &str) -> Option { if metric_name.starts_with("backup_service_") { let name_part = &metric_name[15..]; // Remove "backup_service_" prefix @@ -119,8 +104,6 @@ impl BackupWidget { // Try to extract service name by removing known suffixes if let Some(service_name) = name_part.strip_suffix("_status") { Some(service_name.to_string()) - } else if let Some(service_name) = name_part.strip_suffix("_exit_code") { - Some(service_name.to_string()) } else if let Some(service_name) = name_part.strip_suffix("_archive_count") { Some(service_name.to_string()) } else if let Some(service_name) = name_part.strip_suffix("_repo_size_gb") { @@ -154,6 +137,7 @@ impl Widget for BackupWidget { } impl BackupWidget { + #[allow(dead_code)] fn update_from_metrics(&mut self, metrics: &[&Metric]) { debug!("Backup widget updating with {} metrics", metrics.len()); for metric in metrics { @@ -199,9 +183,6 @@ impl BackupWidget { "backup_last_run_timestamp" => { self.last_run_timestamp = metric.value.as_i64(); } - "backup_total_services" => { - self.total_services = metric.value.as_i64(); - } "backup_total_repo_size_gb" => { self.total_repo_size_gb = metric.value.as_f32(); } @@ -220,18 +201,6 @@ impl BackupWidget { "backup_disk_wear_percent" => { self.backup_disk_wear_percent = metric.value.as_f32(); } - "backup_disk_filesystem_label" => { - self.backup_disk_filesystem_label = Some(metric.value.as_string()); - } - "backup_services_completed_count" => { - self.services_completed_count = metric.value.as_i64(); - } - "backup_services_failed_count" => { - self.services_failed_count = metric.value.as_i64(); - } - "backup_services_disabled_count" => { - self.services_disabled_count = metric.value.as_i64(); - } _ => { // Handle individual service metrics if let Some(service_name) = Self::extract_service_name(&metric.name) { @@ -243,8 +212,7 @@ impl BackupWidget { ServiceMetricData { name: service_name, status: Status::Unknown, - exit_code: None, - archive_count: None, + archive_count: None, repo_size_gb: None, } }); @@ -252,8 +220,6 @@ impl BackupWidget { if metric.name.ends_with("_status") { entry.status = metric.status; debug!("Set status for {}: {:?}", entry.name, entry.status); - } else if metric.name.ends_with("_exit_code") { - entry.exit_code = metric.value.as_i64(); } else if metric.name.ends_with("_archive_count") { entry.archive_count = metric.value.as_i64(); debug!( diff --git a/dashboard/src/ui/widgets/services.rs b/dashboard/src/ui/widgets/services.rs index c072168..95af6ad 100644 --- a/dashboard/src/ui/widgets/services.rs +++ b/dashboard/src/ui/widgets/services.rs @@ -47,6 +47,7 @@ impl ServicesWidget { } /// Extract service name and determine if it's a parent or sub-service + #[allow(dead_code)] fn extract_service_info(metric_name: &str) -> Option<(String, Option)> { if metric_name.starts_with("service_") { if let Some(end_pos) = metric_name @@ -277,6 +278,7 @@ impl Widget for ServicesWidget { } impl ServicesWidget { + #[allow(dead_code)] fn update_from_metrics(&mut self, metrics: &[&Metric]) { debug!("Services widget updating with {} metrics", metrics.len()); diff --git a/dashboard/src/ui/widgets/system.rs b/dashboard/src/ui/widgets/system.rs index f182f18..07bdffa 100644 --- a/dashboard/src/ui/widgets/system.rs +++ b/dashboard/src/ui/widgets/system.rs @@ -31,6 +31,8 @@ pub struct SystemWidget { tmp_total_gb: Option, memory_status: Status, tmp_status: Status, + /// All tmpfs mounts (for auto-discovery support) + tmpfs_mounts: Vec, // Storage metrics (collected from disk metrics) storage_pools: Vec, @@ -50,7 +52,6 @@ struct StoragePool { used_gb: Option, total_gb: Option, status: Status, - health_status: Status, // Separate status for pool health vs usage } #[derive(Clone)] @@ -88,6 +89,7 @@ impl SystemWidget { tmp_total_gb: None, memory_status: Status::Unknown, tmp_status: Status::Unknown, + tmpfs_mounts: Vec::new(), storage_pools: Vec::new(), has_data: false, } @@ -121,20 +123,6 @@ impl SystemWidget { } } - /// Format /tmp usage - fn format_tmp_usage(&self) -> String { - match (self.tmp_usage_percent, self.tmp_used_gb, self.tmp_total_gb) { - (Some(pct), Some(used), Some(total)) => { - let used_str = if used < 0.1 { - format!("{:.0}B", used * 1024.0) // Show as MB if very small - } else { - format!("{:.1}GB", used) - }; - format!("{:.0}% {}/{:.1}GB", pct, used_str, total) - } - _ => "—% —GB/—GB".to_string(), - } - } /// Get the current agent hash for rebuild completion detection pub fn _get_agent_hash(&self) -> Option<&String> { @@ -166,7 +154,10 @@ impl Widget for SystemWidget { self.memory_total_gb = Some(memory.total_gb); self.memory_status = Status::Ok; - // Extract tmpfs data + // Store all tmpfs mounts for display + self.tmpfs_mounts = memory.tmpfs.clone(); + + // Extract tmpfs data (maintain backward compatibility for /tmp) if let Some(tmp_data) = memory.tmpfs.iter().find(|t| t.mount == "/tmp") { self.tmp_usage_percent = Some(tmp_data.usage_percent); self.tmp_used_gb = Some(tmp_data.used_gb); @@ -196,7 +187,6 @@ impl SystemWidget { used_gb: None, total_gb: None, status: Status::Ok, - health_status: Status::Ok, }; // Add drive info @@ -278,40 +268,27 @@ impl SystemWidget { let pool_spans = StatusIcons::create_status_spans(pool.status.clone(), &pool_label); lines.push(Line::from(pool_spans)); - // Pool total usage line - if let (Some(usage), Some(used), Some(total)) = (pool.usage_percent, pool.used_gb, pool.total_gb) { - let usage_spans = vec![ - Span::styled(" ├─ ", Typography::tree()), - Span::raw(" "), - ]; - let mut usage_line_spans = usage_spans; - usage_line_spans.extend(StatusIcons::create_status_spans(pool.status.clone(), &format!("Total: {}% {:.1}GB/{:.1}GB", usage as i32, used, total))); - lines.push(Line::from(usage_line_spans)); - } - - // Drive details for physical drives + // Show individual filesystems for physical drives (matching CLAUDE.md format) if pool.pool_type.starts_with("drive") { - for drive in &pool.drives { - if drive.name == pool.name { - let mut drive_details = Vec::new(); - if let Some(temp) = drive.temperature { - drive_details.push(format!("T: {}°C", temp as i32)); - } - if let Some(wear) = drive.wear_percent { - drive_details.push(format!("W: {}%", wear as i32)); - } - - if !drive_details.is_empty() { - let drive_text = format!("● {} {}", drive.name, drive_details.join(" ")); - let drive_spans = vec![ - Span::styled(" └─ ", Typography::tree()), - Span::raw(" "), - ]; - let mut drive_line_spans = drive_spans; - drive_line_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text)); - lines.push(Line::from(drive_line_spans)); - } - } + // Show filesystem entries like: ├─ ● /: 55% 250.5GB/456.4GB + for (i, filesystem) in pool.filesystems.iter().enumerate() { + let is_last = i == pool.filesystems.len() - 1; + let tree_symbol = if is_last { " └─ " } else { " ├─ " }; + + let fs_text = format!("{}: {:.0}% {:.1}GB/{:.1}GB", + filesystem.mount_point, + filesystem.usage_percent.unwrap_or(0.0), + filesystem.used_gb.unwrap_or(0.0), + filesystem.total_gb.unwrap_or(0.0)); + + let mut fs_spans = vec![ + Span::styled(tree_symbol, Typography::tree()), + ]; + fs_spans.extend(StatusIcons::create_status_spans( + filesystem.status.clone(), + &fs_text + )); + lines.push(Line::from(fs_spans)); } } else { // For mergerfs pools, show data drives and parity drives in tree structure @@ -432,15 +409,29 @@ impl SystemWidget { ); lines.push(Line::from(memory_spans)); - let tmp_text = self.format_tmp_usage(); - let mut tmp_spans = vec![ - Span::styled(" └─ ", Typography::tree()), - ]; - tmp_spans.extend(StatusIcons::create_status_spans( - self.tmp_status.clone(), - &format!("/tmp: {}", tmp_text) - )); - lines.push(Line::from(tmp_spans)); + // Display all tmpfs mounts + for (i, tmpfs) in self.tmpfs_mounts.iter().enumerate() { + let is_last = i == self.tmpfs_mounts.len() - 1; + let tree_symbol = if is_last { " └─ " } else { " ├─ " }; + + let usage_text = if tmpfs.total_gb > 0.0 { + format!("{:.0}% {:.1}GB/{:.1}GB", + tmpfs.usage_percent, + tmpfs.used_gb, + tmpfs.total_gb) + } else { + "— —/—".to_string() + }; + + let mut tmpfs_spans = vec![ + Span::styled(tree_symbol, Typography::tree()), + ]; + tmpfs_spans.extend(StatusIcons::create_status_spans( + Status::Ok, // TODO: Calculate status based on usage_percent + &format!("{}: {}", tmpfs.mount, usage_text) + )); + lines.push(Line::from(tmpfs_spans)); + } // Storage section lines.push(Line::from(vec![ diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 3d58209..7b38feb 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.137" +version = "0.1.138" edition = "2021" [dependencies]