From 1591565b1b3a04c9c44fd471fbcd890e755ef4ef Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Wed, 22 Oct 2025 20:40:24 +0200 Subject: [PATCH] Update storage widget for enhanced disk collector metrics Restructure storage display to handle new individual metrics architecture: - Parse disk_{pool}_* metrics instead of indexed disk_{index}_* format - Support individual drive metrics disk_{pool}_{drive}_health/temperature/wear - Display tree structure: "Storage {pool} ({type}): drive details" - Show pool usage summary with individual drive health/temp/wear status - Auto-discover storage pools and drives from metric patterns - Maintain proper status aggregation from individual metrics The dashboard now correctly displays the new enhanced disk collector output with storage pools containing multiple drives and their individual metrics. --- dashboard/src/ui/mod.rs | 268 +++++++++++++++++++++------------------- 1 file changed, 143 insertions(+), 125 deletions(-) diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index fa7aa7f..9298948 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -432,79 +432,97 @@ impl TuiApp { } if let Some(ref hostname) = self.current_host { - // Get disk count to determine how many disks to display - let disk_count = + // Get storage pool count (renamed from disk_count in new architecture) + let pool_count = if let Some(count_metric) = metric_store.get_metric(hostname, "disk_count") { count_metric.value.as_i64().unwrap_or(0) as usize } else { 0 }; - if disk_count == 0 { - // No disks found - show error/waiting message + if pool_count == 0 { + // No storage pools found - show error/waiting message let content_chunks = ratatui::layout::Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1), Constraint::Min(0)]) .split(area); - let disk_title = Paragraph::new("Storage:").style(Typography::widget_title()); - frame.render_widget(disk_title, content_chunks[0]); + let storage_title = Paragraph::new("Storage:").style(Typography::widget_title()); + frame.render_widget(storage_title, content_chunks[0]); - let no_disks_spans = - StatusIcons::create_status_spans(Status::Unknown, "No mounted disks detected"); - let no_disks_para = Paragraph::new(ratatui::text::Line::from(no_disks_spans)); - frame.render_widget(no_disks_para, content_chunks[1]); + let no_storage_spans = + StatusIcons::create_status_spans(Status::Unknown, "No storage pools detected"); + let no_storage_para = Paragraph::new(ratatui::text::Line::from(no_storage_spans)); + frame.render_widget(no_storage_para, content_chunks[1]); return; } - // Group disks by physical device - let mut physical_devices: std::collections::HashMap> = + // Discover storage pools from metrics (look for disk_{pool}_usage_percent patterns) + let mut storage_pools: std::collections::HashMap> = std::collections::HashMap::new(); - - for disk_index in 0..disk_count { - if let Some(physical_device_metric) = metric_store - .get_metric(hostname, &format!("disk_{}_physical_device", disk_index)) - { - let physical_device = physical_device_metric.value.as_string(); - physical_devices - .entry(physical_device) - .or_insert_with(Vec::new) - .push(disk_index); + + let all_metrics = metric_store.get_metrics_for_host(hostname); + + // Find storage pools by looking for usage metrics + for metric in &all_metrics { + if metric.name.starts_with("disk_") && metric.name.ends_with("_usage_percent") { + let pool_name = metric.name + .strip_prefix("disk_") + .and_then(|s| s.strip_suffix("_usage_percent")) + .unwrap_or_default() + .to_string(); + + if !pool_name.is_empty() && pool_name != "tmp" { + storage_pools.entry(pool_name.clone()).or_insert_with(Vec::new); + } + } + } + + // Find individual drives for each pool + for metric in &all_metrics { + if metric.name.starts_with("disk_") && metric.name.contains("_") && metric.name.ends_with("_health") { + // Parse disk_{pool}_{drive}_health format + let parts: Vec<&str> = metric.name.split('_').collect(); + if parts.len() >= 4 && parts[0] == "disk" && parts[parts.len()-1] == "health" { + // Extract pool name (everything between "disk_" and "_{drive}_health") + let drive_name = parts[parts.len()-2].to_string(); + let pool_part_end = parts.len() - 2; + let pool_name = parts[1..pool_part_end].join("_"); + + if let Some(drives) = storage_pools.get_mut(&pool_name) { + if !drives.contains(&drive_name) { + drives.push(drive_name); + } + } + } } } - // Calculate how many lines we need - let mut _total_lines_needed = 0; - for partitions in physical_devices.values() { - _total_lines_needed += 2 + partitions.len(); // title + health + usage_per_partition - } - let available_lines = area.height as usize; - - // Create constraints dynamically based on physical devices let mut constraints = Vec::new(); - let mut devices_to_show = Vec::new(); + let mut pools_to_show = Vec::new(); let mut current_line = 0; - // Sort physical devices by name for consistent ordering - let mut sorted_devices: Vec<_> = physical_devices.iter().collect(); - sorted_devices.sort_by_key(|(device_name, _)| device_name.as_str()); + // Sort storage pools by name for consistent ordering + let mut sorted_pools: Vec<_> = storage_pools.iter().collect(); + sorted_pools.sort_by_key(|(pool_name, _)| pool_name.as_str()); - for (physical_device, partitions) in sorted_devices { - let lines_for_this_device = 2 + partitions.len(); - if current_line + lines_for_this_device <= available_lines { - devices_to_show.push((physical_device.clone(), partitions.clone())); + for (pool_name, drives) in sorted_pools { + // Calculate lines needed: title + usage + 1 line per drive + let lines_for_this_pool = 2 + drives.len(); + if current_line + lines_for_this_pool <= available_lines { + pools_to_show.push((pool_name.clone(), drives.clone())); - // Add constraints for this device - constraints.push(Constraint::Length(1)); // Device title - constraints.push(Constraint::Length(1)); // Health line - for _ in 0..partitions.len() { - constraints.push(Constraint::Length(1)); // Usage line per partition + // Add constraints for this pool + constraints.push(Constraint::Length(1)); // Pool title + constraints.push(Constraint::Length(1)); // Pool usage line + for _ in 0..drives.len() { + constraints.push(Constraint::Length(1)); // Drive line (health/temp/wear) } - current_line += lines_for_this_device; + current_line += lines_for_this_pool; } else { - break; // Can't fit more devices + break; // Can't fit more pools } } @@ -520,102 +538,102 @@ impl TuiApp { let mut chunk_index = 0; - // Display each physical device - for (physical_device, partitions) in &devices_to_show { - // Device title - let disk_title_text = format!("Disk {}:", physical_device); - let disk_title_para = - Paragraph::new(disk_title_text).style(Typography::widget_title()); - frame.render_widget(disk_title_para, content_chunks[chunk_index]); - chunk_index += 1; - - // Health status (one per physical device) - let smart_health = metric_store - .get_metric(hostname, &format!("disk_smart_{}_health", physical_device)) - .map(|m| (m.value.as_string(), m.status)) - .unwrap_or_else(|| ("Unknown".to_string(), Status::Unknown)); - - let smart_temp = metric_store - .get_metric( - hostname, - &format!("disk_smart_{}_temperature", physical_device), - ) - .and_then(|m| m.value.as_f32()); - - let temp_text = if let Some(temp) = smart_temp { - format!(" {}°C", temp as i32) + // Display each storage pool + for (pool_name, drives) in &pools_to_show { + // Pool title with type indicator + let pool_display_name = if pool_name == "root" { + "root".to_string() } else { - String::new() + pool_name.clone() }; - - let health_spans = StatusIcons::create_status_spans( - smart_health.1, - &format!("Health: {}{}", smart_health.0, temp_text), - ); - let health_para = Paragraph::new(ratatui::text::Line::from(health_spans)); - frame.render_widget(health_para, content_chunks[chunk_index]); + + let pool_type = if drives.len() > 1 { "multi-drive" } else { "single" }; + let pool_title_text = format!("Storage {} ({}):", pool_display_name, pool_type); + let pool_title_para = Paragraph::new(pool_title_text).style(Typography::widget_title()); + frame.render_widget(pool_title_para, content_chunks[chunk_index]); chunk_index += 1; - // Usage lines (one per partition/mount point) - // Sort partitions by disk index for consistent ordering - let mut sorted_partitions = partitions.clone(); - sorted_partitions.sort(); - for &disk_index in &sorted_partitions { - let mount_point = metric_store - .get_metric(hostname, &format!("disk_{}_mount_point", disk_index)) - .map(|m| m.value.as_string()) - .unwrap_or("?".to_string()); + // Pool usage information + let usage_percent = metric_store + .get_metric(hostname, &format!("disk_{}_usage_percent", pool_name)) + .and_then(|m| m.value.as_f32()) + .unwrap_or(0.0); - let usage_percent = metric_store - .get_metric(hostname, &format!("disk_{}_usage_percent", disk_index)) - .and_then(|m| m.value.as_f32()) - .unwrap_or(0.0); + let used_gb = metric_store + .get_metric(hostname, &format!("disk_{}_used_gb", pool_name)) + .and_then(|m| m.value.as_f32()) + .unwrap_or(0.0); - let used_gb = metric_store - .get_metric(hostname, &format!("disk_{}_used_gb", disk_index)) - .and_then(|m| m.value.as_f32()) - .unwrap_or(0.0); + let total_gb = metric_store + .get_metric(hostname, &format!("disk_{}_total_gb", pool_name)) + .and_then(|m| m.value.as_f32()) + .unwrap_or(0.0); - let total_gb = metric_store - .get_metric(hostname, &format!("disk_{}_total_gb", disk_index)) - .and_then(|m| m.value.as_f32()) - .unwrap_or(0.0); + let usage_status = metric_store + .get_metric(hostname, &format!("disk_{}_usage_percent", pool_name)) + .map(|m| m.status) + .unwrap_or(Status::Unknown); - let usage_status = metric_store - .get_metric(hostname, &format!("disk_{}_usage_percent", disk_index)) - .map(|m| m.status) - .unwrap_or(Status::Unknown); + let usage_spans = StatusIcons::create_status_spans( + usage_status, + &format!( + "Usage: {:.1}% • {:.1}/{:.1} GB", + usage_percent, used_gb, total_gb + ), + ); + let usage_para = Paragraph::new(ratatui::text::Line::from(usage_spans)); + frame.render_widget(usage_para, content_chunks[chunk_index]); + chunk_index += 1; - // Format mount point for usage line - let mount_display = if mount_point == "/" { - "root".to_string() - } else if mount_point == "/boot" { - "boot".to_string() - } else if mount_point.starts_with("/") { - mount_point[1..].to_string() // Remove leading slash + // Individual drive information + let mut sorted_drives = drives.clone(); + sorted_drives.sort(); + for drive_name in &sorted_drives { + // Get drive health + let health_metric = metric_store + .get_metric(hostname, &format!("disk_{}_{}_health", pool_name, drive_name)); + let (health_status, health_color) = if let Some(metric) = health_metric { + (metric.value.as_string(), metric.status) } else { - mount_point.clone() + ("Unknown".to_string(), Status::Unknown) }; - let usage_spans = StatusIcons::create_status_spans( - usage_status, - &format!( - "Usage @{}: {:.1}% • {:.1}/{:.1} GB", - mount_display, usage_percent, used_gb, total_gb - ), - ); - let usage_para = Paragraph::new(ratatui::text::Line::from(usage_spans)); - frame.render_widget(usage_para, content_chunks[chunk_index]); + // Get drive temperature + let temp_text = metric_store + .get_metric(hostname, &format!("disk_{}_{}_temperature", pool_name, drive_name)) + .and_then(|m| m.value.as_f32()) + .map(|temp| format!(" {}°C", temp as i32)) + .unwrap_or_default(); + + // Get drive wear level (SSDs) + let wear_text = metric_store + .get_metric(hostname, &format!("disk_{}_{}_wear_percent", pool_name, drive_name)) + .and_then(|m| m.value.as_f32()) + .map(|wear| format!(" {}%", wear as i32)) + .unwrap_or_default(); + + // Build drive status line + let mut drive_info = format!("{}: {}", drive_name, health_status); + if !temp_text.is_empty() { + drive_info.push_str(&temp_text); + } + if !wear_text.is_empty() { + drive_info.push_str(&format!(" wear{}", wear_text)); + } + + let drive_spans = StatusIcons::create_status_spans(health_color, &drive_info); + let drive_para = Paragraph::new(ratatui::text::Line::from(drive_spans)); + frame.render_widget(drive_para, content_chunks[chunk_index]); chunk_index += 1; } } - // Show truncation indicator if we couldn't display all devices - if devices_to_show.len() < physical_devices.len() { + // Show truncation indicator if we couldn't display all pools + if pools_to_show.len() < storage_pools.len() { if let Some(last_chunk) = content_chunks.last() { - let truncated_count = physical_devices.len() - devices_to_show.len(); + let truncated_count = storage_pools.len() - pools_to_show.len(); let truncated_text = format!( - "... and {} more disk{}", + "... and {} more pool{}", truncated_count, if truncated_count == 1 { "" } else { "s" } ); @@ -630,8 +648,8 @@ impl TuiApp { .constraints([Constraint::Length(1), Constraint::Min(0)]) .split(area); - let disk_title = Paragraph::new("Storage:").style(Typography::widget_title()); - frame.render_widget(disk_title, content_chunks[0]); + let storage_title = Paragraph::new("Storage:").style(Typography::widget_title()); + frame.render_widget(storage_title, content_chunks[0]); let no_host_spans = StatusIcons::create_status_spans(Status::Unknown, "No host connected");