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 {
// 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<String, Vec<usize>> =
// Discover storage pools from metrics (look for disk_{pool}_usage_percent patterns)
let mut storage_pools: std::collections::HashMap<String, Vec<String>> =
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");