Implement storage widget tree structure with themed status icons

Add proper hierarchical tree display for storage pools and drives:
- Pool headers with status icons and type indication (Single/multi-drive)
- Individual drive lines with ├─ tree symbols and health status
- Usage summary with └─ end symbol and capacity status
- T: and W: prefixes for temperature and wear level metrics
- Themed status icons using StatusIcons::get_icon() with proper colors
- 2-space indentation for clean tree structure appearance

Replace flat storage display with beautiful tree format:
● Storage steampool (multi-drive):
  ├─ ● sdb T:35°C W:12%
  ├─ ● sdc T:38°C W:8%
  └─ ● 78.1% 1250.3GB/1600.0GB

Uses agent-calculated status from NixOS-configured thresholds.
Update CLAUDE.md with complete implementation specification.
This commit is contained in:
Christoffer Martinsson 2025-10-22 21:17:33 +02:00
parent 1591565b1b
commit b1f294cf2f
2 changed files with 143 additions and 55 deletions

View File

@ -397,6 +397,43 @@ Agent → ["cpu_load_1min", "memory_usage_percent", ...] → Dashboard → Widge
- [x] Eliminated configuration drift between defaults and deployed settings
- [x] All cm-dashboard configuration now managed declaratively through NixOS
**In Progress:**
- [ ] **Storage Widget Tree Structure Implementation (2025-10-22)**
- [ ] Replace flat storage display with proper tree structure format
- [ ] Implement themed status icons for pool/drive/usage status
- [ ] Add tree symbols (├─, └─) with proper indentation for hierarchical display
- [ ] Support T: and W: prefixes for temperature and wear metrics
- [ ] Use agent-calculated status from NixOS-configured thresholds (no dashboard calculations)
### Storage Widget Tree Structure Specification
**Target Display Format:**
```
● Storage steampool (Raid0):
├─ ● sdb T:35°C W:12%
├─ ● sdc T:38°C W:8%
└─ ● 78.1% 1250.3GB/1600.0GB
```
**Status Icon Sources:**
- **Pool Status**: Aggregated status from pool health + usage (`disk_{pool}_usage_percent` metric status)
- **Drive Status**: Individual SMART health status (`disk_{pool}_{drive}_health` metric status)
- **Usage Status**: Disk usage level status (`disk_{pool}_usage_percent` metric status)
**Implementation Details:**
- **Tree Symbols**: `├─` for intermediate lines, `└─` for final line
- **Indentation**: 2 spaces before tree symbols
- **Status Icons**: Use `StatusIcons::get_icon(status)` with themed colors
- **Temperature Format**: `T:{temp}°C` from `disk_{pool}_{drive}_temperature` metrics
- **Wear Format**: `W:{wear}%` from `disk_{pool}_{drive}_wear_percent` metrics
- **Pool Type**: Determine from drive count (Single/multi-drive) or RAID type detection
- **Status Calculation**: Dashboard displays agent-calculated status, no threshold evaluation
**Layout Constraints:**
- Dynamic: 1 header + N drives + 1 usage line per storage pool
- Supports multiple storage pools with proper spacing
- Truncation indicator if pools exceed available display space
**Production Configuration:**
- CPU load thresholds: Warning ≥ 9.0, Critical ≥ 10.0
- CPU temperature thresholds: Warning ≥ 100°C, Critical ≥ 100°C (effectively disabled)

View File

@ -538,22 +538,94 @@ impl TuiApp {
let mut chunk_index = 0;
// Display each storage pool
// Display each storage pool with tree structure
for (pool_name, drives) in &pools_to_show {
// Pool title with type indicator
// Pool header with status icon and type
let pool_display_name = if pool_name == "root" {
"root".to_string()
} else {
pool_name.clone()
};
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]);
let pool_type = if drives.len() > 1 { "multi-drive" } else { "Single" };
// Get pool status from usage metric
let pool_status = metric_store
.get_metric(hostname, &format!("disk_{}_usage_percent", pool_name))
.map(|m| m.status)
.unwrap_or(Status::Unknown);
// Create pool header with status icon
let pool_status_icon = StatusIcons::get_icon(pool_status);
let pool_status_color = Theme::status_color(pool_status);
let pool_header_text = format!("Storage {} ({}):", pool_display_name, pool_type);
let pool_header_spans = vec![
ratatui::text::Span::styled(
format!("{} ", pool_status_icon),
Style::default().fg(pool_status_color),
),
ratatui::text::Span::styled(
pool_header_text,
Typography::widget_title(),
),
];
let pool_header_para = Paragraph::new(ratatui::text::Line::from(pool_header_spans));
frame.render_widget(pool_header_para, content_chunks[chunk_index]);
chunk_index += 1;
// Pool usage information
// Individual drive lines with tree symbols
let mut sorted_drives = drives.clone();
sorted_drives.sort();
for (_drive_idx, drive_name) in sorted_drives.iter().enumerate() {
// Get drive health status
let drive_health_metric = metric_store
.get_metric(hostname, &format!("disk_{}_{}_health", pool_name, drive_name));
let drive_status = drive_health_metric
.map(|m| m.status)
.unwrap_or(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!(" T:{:.0}°C", temp))
.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!(" W:{:.0}%", wear))
.unwrap_or_default();
// Build drive line with tree symbol
let tree_symbol = "├─";
let drive_status_icon = StatusIcons::get_icon(drive_status);
let drive_status_color = Theme::status_color(drive_status);
let drive_text = format!("{}{}{}", drive_name, temp_text, wear_text);
let drive_spans = vec![
ratatui::text::Span::styled(" ", Style::default()), // 2-space indentation
ratatui::text::Span::styled(
format!("{} ", tree_symbol),
Style::default().fg(Theme::muted_text()),
),
ratatui::text::Span::styled(
format!("{} ", drive_status_icon),
Style::default().fg(drive_status_color),
),
ratatui::text::Span::styled(
drive_text,
Style::default().fg(Theme::primary_text()),
),
];
let drive_para = Paragraph::new(ratatui::text::Line::from(drive_spans));
frame.render_widget(drive_para, content_chunks[chunk_index]);
chunk_index += 1;
}
// Usage line with end tree symbol and status icon
let usage_percent = metric_store
.get_metric(hostname, &format!("disk_{}_usage_percent", pool_name))
.and_then(|m| m.value.as_f32())
@ -574,58 +646,37 @@ impl TuiApp {
.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
// Format usage with proper units
let (used_display, total_display, unit) = if total_gb < 1.0 {
(used_gb * 1024.0, total_gb * 1024.0, "MB")
} else {
(used_gb, total_gb, "GB")
};
let end_tree_symbol = "└─";
let usage_status_icon = StatusIcons::get_icon(usage_status);
let usage_status_color = Theme::status_color(usage_status);
let usage_text = format!("{:.1}% {:.1}{}/{:.1}{}",
usage_percent, used_display, unit, total_display, unit);
let usage_spans = vec![
ratatui::text::Span::styled(" ", Style::default()), // 2-space indentation
ratatui::text::Span::styled(
format!("{} ", end_tree_symbol),
Style::default().fg(Theme::muted_text()),
),
);
ratatui::text::Span::styled(
format!("{} ", usage_status_icon),
Style::default().fg(usage_status_color),
),
ratatui::text::Span::styled(
usage_text,
Style::default().fg(Theme::primary_text()),
),
];
let usage_para = Paragraph::new(ratatui::text::Line::from(usage_spans));
frame.render_widget(usage_para, content_chunks[chunk_index]);
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 pools