diff --git a/CLAUDE.md b/CLAUDE.md index 2a36dcb..3da9d71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index 9298948..e6439c5 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -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