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.
This commit is contained in:
Christoffer Martinsson 2025-10-22 20:40:24 +02:00
parent 08d3454683
commit 1591565b1b

View File

@ -432,79 +432,97 @@ impl TuiApp {
} }
if let Some(ref hostname) = self.current_host { if let Some(ref hostname) = self.current_host {
// Get disk count to determine how many disks to display // Get storage pool count (renamed from disk_count in new architecture)
let disk_count = let pool_count =
if let Some(count_metric) = metric_store.get_metric(hostname, "disk_count") { if let Some(count_metric) = metric_store.get_metric(hostname, "disk_count") {
count_metric.value.as_i64().unwrap_or(0) as usize count_metric.value.as_i64().unwrap_or(0) as usize
} else { } else {
0 0
}; };
if disk_count == 0 { if pool_count == 0 {
// No disks found - show error/waiting message // No storage pools found - show error/waiting message
let content_chunks = ratatui::layout::Layout::default() let content_chunks = ratatui::layout::Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)]) .constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area); .split(area);
let disk_title = Paragraph::new("Storage:").style(Typography::widget_title()); let storage_title = Paragraph::new("Storage:").style(Typography::widget_title());
frame.render_widget(disk_title, content_chunks[0]); frame.render_widget(storage_title, content_chunks[0]);
let no_disks_spans = let no_storage_spans =
StatusIcons::create_status_spans(Status::Unknown, "No mounted disks detected"); StatusIcons::create_status_spans(Status::Unknown, "No storage pools detected");
let no_disks_para = Paragraph::new(ratatui::text::Line::from(no_disks_spans)); let no_storage_para = Paragraph::new(ratatui::text::Line::from(no_storage_spans));
frame.render_widget(no_disks_para, content_chunks[1]); frame.render_widget(no_storage_para, content_chunks[1]);
return; return;
} }
// Group disks by physical device // Discover storage pools from metrics (look for disk_{pool}_usage_percent patterns)
let mut physical_devices: std::collections::HashMap<String, Vec<usize>> = let mut storage_pools: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new(); std::collections::HashMap::new();
for disk_index in 0..disk_count { let all_metrics = metric_store.get_metrics_for_host(hostname);
if let Some(physical_device_metric) = metric_store
.get_metric(hostname, &format!("disk_{}_physical_device", disk_index)) // Find storage pools by looking for usage metrics
{ for metric in &all_metrics {
let physical_device = physical_device_metric.value.as_string(); if metric.name.starts_with("disk_") && metric.name.ends_with("_usage_percent") {
physical_devices let pool_name = metric.name
.entry(physical_device) .strip_prefix("disk_")
.or_insert_with(Vec::new) .and_then(|s| s.strip_suffix("_usage_percent"))
.push(disk_index); .unwrap_or_default()
.to_string();
if !pool_name.is_empty() && pool_name != "tmp" {
storage_pools.entry(pool_name.clone()).or_insert_with(Vec::new);
}
} }
} }
// Calculate how many lines we need // Find individual drives for each pool
let mut _total_lines_needed = 0; for metric in &all_metrics {
for partitions in physical_devices.values() { if metric.name.starts_with("disk_") && metric.name.contains("_") && metric.name.ends_with("_health") {
_total_lines_needed += 2 + partitions.len(); // title + health + usage_per_partition // 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);
}
}
}
}
} }
let available_lines = area.height as usize; let available_lines = area.height as usize;
// Create constraints dynamically based on physical devices
let mut constraints = Vec::new(); let mut constraints = Vec::new();
let mut devices_to_show = Vec::new(); let mut pools_to_show = Vec::new();
let mut current_line = 0; let mut current_line = 0;
// Sort physical devices by name for consistent ordering // Sort storage pools by name for consistent ordering
let mut sorted_devices: Vec<_> = physical_devices.iter().collect(); let mut sorted_pools: Vec<_> = storage_pools.iter().collect();
sorted_devices.sort_by_key(|(device_name, _)| device_name.as_str()); sorted_pools.sort_by_key(|(pool_name, _)| pool_name.as_str());
for (physical_device, partitions) in sorted_devices { for (pool_name, drives) in sorted_pools {
let lines_for_this_device = 2 + partitions.len(); // Calculate lines needed: title + usage + 1 line per drive
if current_line + lines_for_this_device <= available_lines { let lines_for_this_pool = 2 + drives.len();
devices_to_show.push((physical_device.clone(), partitions.clone())); if current_line + lines_for_this_pool <= available_lines {
pools_to_show.push((pool_name.clone(), drives.clone()));
// Add constraints for this device // Add constraints for this pool
constraints.push(Constraint::Length(1)); // Device title constraints.push(Constraint::Length(1)); // Pool title
constraints.push(Constraint::Length(1)); // Health line constraints.push(Constraint::Length(1)); // Pool usage line
for _ in 0..partitions.len() { for _ in 0..drives.len() {
constraints.push(Constraint::Length(1)); // Usage line per partition constraints.push(Constraint::Length(1)); // Drive line (health/temp/wear)
} }
current_line += lines_for_this_device; current_line += lines_for_this_pool;
} else { } else {
break; // Can't fit more devices break; // Can't fit more pools
} }
} }
@ -520,102 +538,102 @@ impl TuiApp {
let mut chunk_index = 0; let mut chunk_index = 0;
// Display each physical device // Display each storage pool
for (physical_device, partitions) in &devices_to_show { for (pool_name, drives) in &pools_to_show {
// Device title // Pool title with type indicator
let disk_title_text = format!("Disk {}:", physical_device); let pool_display_name = if pool_name == "root" {
let disk_title_para = "root".to_string()
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)
} else { } else {
String::new() pool_name.clone()
}; };
let health_spans = StatusIcons::create_status_spans( let pool_type = if drives.len() > 1 { "multi-drive" } else { "single" };
smart_health.1, let pool_title_text = format!("Storage {} ({}):", pool_display_name, pool_type);
&format!("Health: {}{}", smart_health.0, temp_text), let pool_title_para = Paragraph::new(pool_title_text).style(Typography::widget_title());
); frame.render_widget(pool_title_para, content_chunks[chunk_index]);
let health_para = Paragraph::new(ratatui::text::Line::from(health_spans));
frame.render_widget(health_para, content_chunks[chunk_index]);
chunk_index += 1; chunk_index += 1;
// Usage lines (one per partition/mount point) // Pool usage information
// 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());
let usage_percent = metric_store let usage_percent = metric_store
.get_metric(hostname, &format!("disk_{}_usage_percent", disk_index)) .get_metric(hostname, &format!("disk_{}_usage_percent", pool_name))
.and_then(|m| m.value.as_f32()) .and_then(|m| m.value.as_f32())
.unwrap_or(0.0); .unwrap_or(0.0);
let used_gb = metric_store let used_gb = metric_store
.get_metric(hostname, &format!("disk_{}_used_gb", disk_index)) .get_metric(hostname, &format!("disk_{}_used_gb", pool_name))
.and_then(|m| m.value.as_f32()) .and_then(|m| m.value.as_f32())
.unwrap_or(0.0); .unwrap_or(0.0);
let total_gb = metric_store let total_gb = metric_store
.get_metric(hostname, &format!("disk_{}_total_gb", disk_index)) .get_metric(hostname, &format!("disk_{}_total_gb", pool_name))
.and_then(|m| m.value.as_f32()) .and_then(|m| m.value.as_f32())
.unwrap_or(0.0); .unwrap_or(0.0);
let usage_status = metric_store let usage_status = metric_store
.get_metric(hostname, &format!("disk_{}_usage_percent", disk_index)) .get_metric(hostname, &format!("disk_{}_usage_percent", pool_name))
.map(|m| m.status) .map(|m| m.status)
.unwrap_or(Status::Unknown); .unwrap_or(Status::Unknown);
// 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
} else {
mount_point.clone()
};
let usage_spans = StatusIcons::create_status_spans( let usage_spans = StatusIcons::create_status_spans(
usage_status, usage_status,
&format!( &format!(
"Usage @{}: {:.1}% • {:.1}/{:.1} GB", "Usage: {:.1}% • {:.1}/{:.1} GB",
mount_display, usage_percent, used_gb, total_gb usage_percent, used_gb, total_gb
), ),
); );
let usage_para = Paragraph::new(ratatui::text::Line::from(usage_spans)); let usage_para = Paragraph::new(ratatui::text::Line::from(usage_spans));
frame.render_widget(usage_para, content_chunks[chunk_index]); frame.render_widget(usage_para, content_chunks[chunk_index]);
chunk_index += 1; chunk_index += 1;
// 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 {
("Unknown".to_string(), Status::Unknown)
};
// 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 // Show truncation indicator if we couldn't display all pools
if devices_to_show.len() < physical_devices.len() { if pools_to_show.len() < storage_pools.len() {
if let Some(last_chunk) = content_chunks.last() { 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!( let truncated_text = format!(
"... and {} more disk{}", "... and {} more pool{}",
truncated_count, truncated_count,
if truncated_count == 1 { "" } else { "s" } if truncated_count == 1 { "" } else { "s" }
); );
@ -630,8 +648,8 @@ impl TuiApp {
.constraints([Constraint::Length(1), Constraint::Min(0)]) .constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area); .split(area);
let disk_title = Paragraph::new("Storage:").style(Typography::widget_title()); let storage_title = Paragraph::new("Storage:").style(Typography::widget_title());
frame.render_widget(disk_title, content_chunks[0]); frame.render_widget(storage_title, content_chunks[0]);
let no_host_spans = let no_host_spans =
StatusIcons::create_status_spans(Status::Unknown, "No host connected"); StatusIcons::create_status_spans(Status::Unknown, "No host connected");