From d68ecfbc64ec5a306a769eab0bccc573610979fc Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sun, 23 Nov 2025 12:48:24 +0100 Subject: [PATCH] Complete unified pool visualization with filesystem children - Implement filesystem children display under physical drive pools - Agent generates individual filesystem metrics for each mount point - Dashboard parses filesystem metrics and displays as tree children - Add filesystem usage, total, and available space metrics - Support target format: drive info + filesystem children hierarchy - Fix compilation warnings by properly using available_bytes calculation --- Cargo.lock | 6 +- agent/Cargo.toml | 2 +- agent/src/collectors/disk.rs | 82 +++++++++++++++- dashboard/Cargo.toml | 2 +- dashboard/src/ui/widgets/system.rs | 151 ++++++++++++++++++++++++++++- shared/Cargo.toml | 2 +- 6 files changed, 237 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7e8ead..2bb1378 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.100" +version = "0.1.102" dependencies = [ "anyhow", "chrono", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.100" +version = "0.1.102" dependencies = [ "anyhow", "async-trait", @@ -324,7 +324,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.100" +version = "0.1.103" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 8167a5b..289470f 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.101" +version = "0.1.103" edition = "2021" [dependencies] diff --git a/agent/src/collectors/disk.rs b/agent/src/collectors/disk.rs index 79f6d21..897011c 100644 --- a/agent/src/collectors/disk.rs +++ b/agent/src/collectors/disk.rs @@ -343,6 +343,9 @@ impl DiskCollector { storage_pools.push(pool); } + // IMPORTANT: Do not create individual filesystem pools when using auto-discovery + // All single disk filesystems should be grouped into physical drive pools above + // Process mergerfs pools (these remain as logical pools) for pool_info in &topology.mergerfs_pools { if let Ok((total_bytes, used_bytes)) = self.get_filesystem_info(&pool_info.mount_point) { @@ -887,6 +890,11 @@ impl DiskCollector { } } + /// Convert bytes to gigabytes + fn bytes_to_gb(&self, bytes: u64) -> f32 { + bytes as f32 / (1024.0 * 1024.0 * 1024.0) + } + /// Detect device backing a mount point using lsblk (static version for startup) fn detect_device_for_mount_point_static(mount_point: &str) -> Result> { let output = Command::new("lsblk") @@ -1212,6 +1220,79 @@ impl Collector for DiskCollector { }); } } + + // Individual filesystem metrics for PhysicalDrive pools + if let StoragePoolType::PhysicalDrive { filesystems } = &storage_pool.pool_type { + for filesystem_mount in filesystems { + if let Ok((total_bytes, used_bytes)) = self.get_filesystem_info(filesystem_mount) { + let available_bytes = total_bytes - used_bytes; + let usage_percent = if total_bytes > 0 { + (used_bytes as f64 / total_bytes as f64) * 100.0 + } else { 0.0 }; + + let filesystem_name = if filesystem_mount == "/" { + "root".to_string() + } else { + filesystem_mount.trim_start_matches('/').replace('/', "_") + }; + + // Calculate filesystem status based on usage + let fs_status = if usage_percent >= self.config.usage_critical_percent as f64 { + Status::Critical + } else if usage_percent >= self.config.usage_warning_percent as f64 { + Status::Warning + } else { + Status::Ok + }; + + // Filesystem usage metrics + metrics.push(Metric { + name: format!("disk_{}_fs_{}_usage_percent", pool_name, filesystem_name), + value: MetricValue::Float(usage_percent as f32), + unit: Some("%".to_string()), + description: Some(format!("{}: {:.0}%", filesystem_mount, usage_percent)), + status: fs_status.clone(), + timestamp, + }); + + metrics.push(Metric { + name: format!("disk_{}_fs_{}_used_gb", pool_name, filesystem_name), + value: MetricValue::Float(self.bytes_to_gb(used_bytes)), + unit: Some("GB".to_string()), + description: Some(format!("{}: {}GB used", filesystem_mount, self.bytes_to_human_readable(used_bytes))), + status: Status::Ok, + timestamp, + }); + + metrics.push(Metric { + name: format!("disk_{}_fs_{}_total_gb", pool_name, filesystem_name), + value: MetricValue::Float(self.bytes_to_gb(total_bytes)), + unit: Some("GB".to_string()), + description: Some(format!("{}: {}GB total", filesystem_mount, self.bytes_to_human_readable(total_bytes))), + status: Status::Ok, + timestamp, + }); + + metrics.push(Metric { + name: format!("disk_{}_fs_{}_available_gb", pool_name, filesystem_name), + value: MetricValue::Float(self.bytes_to_gb(available_bytes)), + unit: Some("GB".to_string()), + description: Some(format!("{}: {}GB available", filesystem_mount, self.bytes_to_human_readable(available_bytes))), + status: Status::Ok, + timestamp, + }); + + metrics.push(Metric { + name: format!("disk_{}_fs_{}_mount_point", pool_name, filesystem_name), + value: MetricValue::String(filesystem_mount.clone()), + unit: None, + description: Some(format!("Mount: {}", filesystem_mount)), + status: Status::Ok, + timestamp, + }); + } + } + } } // Add storage pool count metric @@ -1234,5 +1315,4 @@ impl Collector for DiskCollector { Ok(metrics) } - } diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 9073d80..c8fd45b 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.101" +version = "0.1.103" edition = "2021" [dependencies] diff --git a/dashboard/src/ui/widgets/system.rs b/dashboard/src/ui/widgets/system.rs index 4339b90..921ef18 100644 --- a/dashboard/src/ui/widgets/system.rs +++ b/dashboard/src/ui/widgets/system.rs @@ -48,6 +48,7 @@ struct StoragePool { pool_type: String, // "single", "mergerfs (2+1)", "RAID5 (3+1)", etc. pool_health: Option, // "healthy", "degraded", "critical", "rebuilding" drives: Vec, + filesystems: Vec, // For physical drive pools: individual filesystem children usage_percent: Option, used_gb: Option, total_gb: Option, @@ -63,6 +64,15 @@ struct StorageDrive { status: Status, } +#[derive(Clone)] +struct FileSystem { + mount_point: String, + usage_percent: Option, + used_gb: Option, + total_gb: Option, + status: Status, +} + impl SystemWidget { pub fn new() -> Self { Self { @@ -160,6 +170,7 @@ impl SystemWidget { pool_type: "single".to_string(), // Default, will be updated pool_health: None, drives: Vec::new(), + filesystems: Vec::new(), usage_percent: None, used_gb: None, total_gb: None, @@ -230,6 +241,75 @@ impl SystemWidget { } } } + } else if metric.name.contains("_fs_") { + // Handle filesystem metrics for physical drive pools (disk_{pool}_fs_{fs_name}_{metric}) + if let (Some(fs_name), Some(metric_type)) = self.extract_filesystem_metric(&metric.name) { + // Find or create filesystem entry + let fs_exists = pool.filesystems.iter().any(|fs| { + let fs_id = if fs.mount_point == "/" { + "root".to_string() + } else { + fs.mount_point.trim_start_matches('/').replace('/', "_") + }; + fs_id == fs_name + }); + + if !fs_exists { + // Extract actual mount point from mount_point metric if available + let mount_point = if metric_type == "mount_point" { + if let MetricValue::String(mount) = &metric.value { + mount.clone() + } else { + format!("/{}", fs_name.replace('_', "/")) + } + } else { + format!("/{}", fs_name.replace('_', "/")) + }; + + pool.filesystems.push(FileSystem { + mount_point, + usage_percent: None, + used_gb: None, + total_gb: None, + status: Status::Unknown, + }); + } + + // Update the filesystem with the metric value + if let Some(filesystem) = pool.filesystems.iter_mut().find(|fs| { + let fs_id = if fs.mount_point == "/" { + "root".to_string() + } else { + fs.mount_point.trim_start_matches('/').replace('/', "_") + }; + fs_id == fs_name + }) { + match metric_type.as_str() { + "usage_percent" => { + if let MetricValue::Float(usage) = metric.value { + filesystem.usage_percent = Some(usage); + filesystem.status = metric.status.clone(); + } + } + "used_gb" => { + if let MetricValue::Float(used) = metric.value { + filesystem.used_gb = Some(used); + } + } + "total_gb" => { + if let MetricValue::Float(total) = metric.value { + filesystem.total_gb = Some(total); + } + } + "mount_point" => { + if let MetricValue::String(mount) = &metric.value { + filesystem.mount_point = mount.clone(); + } + } + _ => {} + } + } + } } } } @@ -272,6 +352,25 @@ impl SystemWidget { None } + /// Extract filesystem name and metric type from filesystem metric names + /// Pattern: disk_{pool}_fs_{filesystem_name}_{metric_type} + fn extract_filesystem_metric(&self, metric_name: &str) -> (Option, Option) { + if metric_name.starts_with("disk_") && metric_name.contains("_fs_") { + // Find the _fs_ part + if let Some(fs_start) = metric_name.find("_fs_") { + let after_fs = &metric_name[fs_start + 4..]; // Skip "_fs_" + + // Find the last underscore to separate filesystem name from metric type + if let Some(last_underscore) = after_fs.rfind('_') { + let fs_name = after_fs[..last_underscore].to_string(); + let metric_type = after_fs[last_underscore + 1..].to_string(); + return (Some(fs_name), Some(metric_type)); + } + } + } + (None, None) + } + /// Extract drive name from disk metric name fn extract_drive_name(&self, metric_name: &str) -> Option { // Pattern: disk_{pool_name}_{drive_name}_{metric_type} @@ -337,7 +436,9 @@ impl SystemWidget { }; let has_drives = !pool.drives.is_empty(); - let tree_symbol = if has_drives { "├─" } else { "└─" }; + 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()), @@ -397,6 +498,54 @@ 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 + 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 = match (filesystem.usage_percent, filesystem.used_gb, filesystem.total_gb) { + (Some(pct), Some(used), Some(total)) => { + format!("{}: {:.0}% {:.1}GB/{:.1}GB", filesystem.mount_point, pct, used, total) + } + _ => format!("{}: —% —GB/—GB", filesystem.mount_point), + }; + + let mut fs_spans = vec![ + Span::raw(" "), + Span::styled(tree_symbol, Typography::tree()), + Span::raw(" "), + ]; + fs_spans.extend(StatusIcons::create_status_spans(filesystem.status.clone(), &fs_text)); + lines.push(Line::from(fs_spans)); + } } else { // Single drive or simple pools for (i, drive) in pool.drives.iter().enumerate() { diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 3c8258b..980ead0 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.101" +version = "0.1.103" edition = "2021" [dependencies]