use cm_dashboard_shared::{Metric, MetricValue, Status}; use ratatui::{ layout::Rect, text::{Line, Span, Text}, widgets::Paragraph, Frame, }; use super::Widget; use crate::ui::theme::{StatusIcons, Typography}; /// System widget displaying NixOS info, CPU, RAM, and Storage in unified layout #[derive(Clone)] pub struct SystemWidget { // NixOS information nixos_build: Option, config_hash: Option, active_users: Option, agent_hash: Option, // CPU metrics cpu_load_1min: Option, cpu_load_5min: Option, cpu_load_15min: Option, cpu_frequency: Option, cpu_status: Status, // Memory metrics memory_usage_percent: Option, memory_used_gb: Option, memory_total_gb: Option, tmp_usage_percent: Option, tmp_used_gb: Option, tmp_total_gb: Option, memory_status: Status, // Storage metrics (collected from disk metrics) storage_pools: Vec, // Overall status has_data: bool, } #[derive(Clone)] struct StoragePool { name: String, mount_point: String, pool_type: String, // "Single", "Raid0", etc. drives: Vec, usage_percent: Option, used_gb: Option, total_gb: Option, status: Status, } #[derive(Clone)] struct StorageDrive { name: String, temperature: Option, wear_percent: Option, status: Status, } impl SystemWidget { pub fn new() -> Self { Self { nixos_build: None, config_hash: None, active_users: None, agent_hash: None, cpu_load_1min: None, cpu_load_5min: None, cpu_load_15min: None, cpu_frequency: None, cpu_status: Status::Unknown, memory_usage_percent: None, memory_used_gb: None, memory_total_gb: None, tmp_usage_percent: None, tmp_used_gb: None, tmp_total_gb: None, memory_status: Status::Unknown, storage_pools: Vec::new(), has_data: false, } } /// Format CPU load averages fn format_cpu_load(&self) -> String { match (self.cpu_load_1min, self.cpu_load_5min, self.cpu_load_15min) { (Some(l1), Some(l5), Some(l15)) => { format!("{:.2} {:.2} {:.2}", l1, l5, l15) } _ => "— — —".to_string(), } } /// Format CPU frequency fn format_cpu_frequency(&self) -> String { match self.cpu_frequency { Some(freq) => format!("{:.0} MHz", freq), None => "— MHz".to_string(), } } /// Format memory usage fn format_memory_usage(&self) -> String { match (self.memory_usage_percent, self.memory_used_gb, self.memory_total_gb) { (Some(pct), Some(used), Some(total)) => { format!("{:.0}% {:.1}GB/{:.1}GB", pct, used, total) } _ => "—% —GB/—GB".to_string(), } } /// Format /tmp usage fn format_tmp_usage(&self) -> String { match (self.tmp_usage_percent, self.tmp_used_gb, self.tmp_total_gb) { (Some(pct), Some(used), Some(total)) => { let used_str = if used < 0.1 { format!("{:.0}B", used * 1024.0) // Show as MB if very small } else { format!("{:.1}GB", used) }; format!("{:.0}% {}/{:.1}GB", pct, used_str, total) } _ => "—% —GB/—GB".to_string(), } } /// Get the current agent hash for rebuild completion detection pub fn get_agent_hash(&self) -> Option<&String> { self.agent_hash.as_ref() } /// Get mount point for a pool name fn get_mount_point_for_pool(&self, pool_name: &str) -> String { match pool_name { "root" => "/".to_string(), "steampool" => "/mnt/steampool".to_string(), "steampool_1" => "/steampool_1".to_string(), "steampool_2" => "/steampool_2".to_string(), _ => format!("/{}", pool_name), // Default fallback } } /// Parse storage metrics into pools and drives fn update_storage_from_metrics(&mut self, metrics: &[&Metric]) { let mut pools: std::collections::HashMap = std::collections::HashMap::new(); for metric in metrics { if metric.name.starts_with("disk_") { if let Some(pool_name) = self.extract_pool_name(&metric.name) { let mount_point = self.get_mount_point_for_pool(&pool_name); let pool = pools.entry(pool_name.clone()).or_insert_with(|| StoragePool { name: pool_name.clone(), mount_point: mount_point.clone(), pool_type: "Single".to_string(), // Default, could be enhanced drives: Vec::new(), usage_percent: None, used_gb: None, total_gb: None, status: Status::Unknown, }); // Parse different metric types if metric.name.contains("_usage_percent") { if let MetricValue::Float(usage) = metric.value { pool.usage_percent = Some(usage); pool.status = metric.status.clone(); } } else if metric.name.contains("_used_gb") { if let MetricValue::Float(used) = metric.value { pool.used_gb = Some(used); } } else if metric.name.contains("_total_gb") { if let MetricValue::Float(total) = metric.value { pool.total_gb = Some(total); } } else if metric.name.contains("_temperature") { if let Some(drive_name) = self.extract_drive_name(&metric.name) { // Find existing drive or create new one let drive_exists = pool.drives.iter().any(|d| d.name == drive_name); if !drive_exists { pool.drives.push(StorageDrive { name: drive_name.clone(), temperature: None, wear_percent: None, status: Status::Unknown, }); } if let Some(drive) = pool.drives.iter_mut().find(|d| d.name == drive_name) { if let MetricValue::Float(temp) = metric.value { drive.temperature = Some(temp); drive.status = metric.status.clone(); } } } } else if metric.name.contains("_wear_percent") { if let Some(drive_name) = self.extract_drive_name(&metric.name) { // Find existing drive or create new one let drive_exists = pool.drives.iter().any(|d| d.name == drive_name); if !drive_exists { pool.drives.push(StorageDrive { name: drive_name.clone(), temperature: None, wear_percent: None, status: Status::Unknown, }); } if let Some(drive) = pool.drives.iter_mut().find(|d| d.name == drive_name) { if let MetricValue::Float(wear) = metric.value { drive.wear_percent = Some(wear); drive.status = metric.status.clone(); } } } } } } } // Convert to sorted vec for consistent ordering let mut pool_list: Vec = pools.into_values().collect(); pool_list.sort_by(|a, b| a.name.cmp(&b.name)); // Sort alphabetically by name self.storage_pools = pool_list; } /// Extract pool name from disk metric name fn extract_pool_name(&self, metric_name: &str) -> Option { if let Some(captures) = metric_name.strip_prefix("disk_") { if let Some(pos) = captures.find('_') { return Some(captures[..pos].to_string()); } } None } /// Extract drive name from disk metric name fn extract_drive_name(&self, metric_name: &str) -> Option { // Pattern: disk_pool_drive_metric let parts: Vec<&str> = metric_name.split('_').collect(); if parts.len() >= 3 && parts[0] == "disk" { return Some(parts[2].to_string()); } None } /// Render storage section with tree structure fn render_storage(&self) -> Vec> { let mut lines = Vec::new(); for pool in &self.storage_pools { // Pool header line let usage_text = match (pool.usage_percent, pool.used_gb, pool.total_gb) { (Some(pct), Some(used), Some(total)) => { format!("{:.0}% {:.1}GB/{:.1}GB", pct, used, total) } _ => "—% —GB/—GB".to_string(), }; let pool_label = if pool.pool_type.to_lowercase() == "single" { format!("{}:", pool.mount_point) } else { format!("{} ({}):", pool.mount_point, pool.pool_type) }; let pool_spans = StatusIcons::create_status_spans( pool.status.clone(), &pool_label ); lines.push(Line::from(pool_spans)); // Drive lines with tree structure let has_usage_line = pool.usage_percent.is_some(); for (i, drive) in pool.drives.iter().enumerate() { let is_last_drive = i == pool.drives.len() - 1; let tree_symbol = if is_last_drive && !has_usage_line { "└─" } else { "├─" }; let mut drive_info = Vec::new(); if let Some(temp) = drive.temperature { drive_info.push(format!("T: {:.0}C", temp)); } if let Some(wear) = drive.wear_percent { drive_info.push(format!("W: {:.0}%", wear)); } let drive_text = if drive_info.is_empty() { drive.name.clone() } else { format!("{} {}", drive.name, drive_info.join(" • ")) }; let mut drive_spans = vec![ Span::raw(" "), Span::styled(tree_symbol, Typography::tree()), Span::raw(" "), ]; drive_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text)); lines.push(Line::from(drive_spans)); } // Usage line if pool.usage_percent.is_some() { let tree_symbol = "└─"; let mut usage_spans = vec![ Span::raw(" "), Span::styled(tree_symbol, Typography::tree()), Span::raw(" "), ]; usage_spans.extend(StatusIcons::create_status_spans(pool.status.clone(), &usage_text)); lines.push(Line::from(usage_spans)); } } lines } } impl Widget for SystemWidget { fn update_from_metrics(&mut self, metrics: &[&Metric]) { self.has_data = !metrics.is_empty(); for metric in metrics { match metric.name.as_str() { // NixOS metrics "system_nixos_build" => { if let MetricValue::String(build) = &metric.value { self.nixos_build = Some(build.clone()); } } "system_config_hash" => { if let MetricValue::String(hash) = &metric.value { self.config_hash = Some(hash.clone()); } } "system_active_users" => { if let MetricValue::String(users) = &metric.value { self.active_users = Some(users.clone()); } } "agent_version" => { if let MetricValue::String(version) = &metric.value { self.agent_hash = Some(version.clone()); } } // CPU metrics "cpu_load_1min" => { if let MetricValue::Float(load) = metric.value { self.cpu_load_1min = Some(load); self.cpu_status = metric.status.clone(); } } "cpu_load_5min" => { if let MetricValue::Float(load) = metric.value { self.cpu_load_5min = Some(load); } } "cpu_load_15min" => { if let MetricValue::Float(load) = metric.value { self.cpu_load_15min = Some(load); } } "cpu_frequency_mhz" => { if let MetricValue::Float(freq) = metric.value { self.cpu_frequency = Some(freq); } } // Memory metrics "memory_usage_percent" => { if let MetricValue::Float(usage) = metric.value { self.memory_usage_percent = Some(usage); self.memory_status = metric.status.clone(); } } "memory_used_gb" => { if let MetricValue::Float(used) = metric.value { self.memory_used_gb = Some(used); } } "memory_total_gb" => { if let MetricValue::Float(total) = metric.value { self.memory_total_gb = Some(total); } } // Tmpfs metrics "memory_tmp_usage_percent" => { if let MetricValue::Float(usage) = metric.value { self.tmp_usage_percent = Some(usage); } } "memory_tmp_used_gb" => { if let MetricValue::Float(used) = metric.value { self.tmp_used_gb = Some(used); } } "memory_tmp_total_gb" => { if let MetricValue::Float(total) = metric.value { self.tmp_total_gb = Some(total); } } _ => {} } } // Update storage from all disk metrics self.update_storage_from_metrics(metrics); } } impl SystemWidget { /// Render with scroll offset support pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize) { let mut lines = Vec::new(); // NixOS section lines.push(Line::from(vec![ Span::styled("NixOS:", Typography::widget_title()) ])); let config_text = self.config_hash.as_deref().unwrap_or("unknown"); lines.push(Line::from(vec![ Span::styled(format!("Build: {}", config_text), Typography::secondary()) ])); let agent_hash_text = self.agent_hash.as_deref().unwrap_or("unknown"); let short_hash = if agent_hash_text.len() > 8 && agent_hash_text != "unknown" { &agent_hash_text[..8] } else { agent_hash_text }; lines.push(Line::from(vec![ Span::styled(format!("Agent: {}", short_hash), Typography::secondary()) ])); // CPU section lines.push(Line::from(vec![ Span::styled("CPU:", Typography::widget_title()) ])); let load_text = self.format_cpu_load(); let cpu_spans = StatusIcons::create_status_spans( self.cpu_status.clone(), &format!("Load: {}", load_text) ); lines.push(Line::from(cpu_spans)); let freq_text = self.format_cpu_frequency(); lines.push(Line::from(vec![ Span::styled(" └─ ", Typography::tree()), Span::styled(format!("Freq: {}", freq_text), Typography::secondary()) ])); // RAM section lines.push(Line::from(vec![ Span::styled("RAM:", Typography::widget_title()) ])); let memory_text = self.format_memory_usage(); let memory_spans = StatusIcons::create_status_spans( self.memory_status.clone(), &format!("Usage: {}", memory_text) ); lines.push(Line::from(memory_spans)); let tmp_text = self.format_tmp_usage(); let mut tmp_spans = vec![ Span::styled(" └─ ", Typography::tree()), ]; tmp_spans.extend(StatusIcons::create_status_spans( self.memory_status.clone(), &format!("/tmp: {}", tmp_text) )); lines.push(Line::from(tmp_spans)); // Storage section lines.push(Line::from(vec![ Span::styled("Storage:", Typography::widget_title()) ])); // Storage items with overflow handling let storage_lines = self.render_storage(); let remaining_space = area.height.saturating_sub(lines.len() as u16); if storage_lines.len() <= remaining_space as usize { // All storage lines fit lines.extend(storage_lines); } else if remaining_space >= 2 { // Show what we can and add overflow indicator let lines_to_show = (remaining_space - 1) as usize; // Reserve 1 line for overflow lines.extend(storage_lines.iter().take(lines_to_show).cloned()); // Count hidden pools let mut hidden_pools = 0; let mut current_pool = String::new(); for (i, line) in storage_lines.iter().enumerate() { if i >= lines_to_show { // Check if this line represents a new pool (no indentation) if let Some(first_span) = line.spans.first() { let text = first_span.content.as_ref(); if !text.starts_with(" ") && text.contains(':') { let pool_name = text.split(':').next().unwrap_or("").trim(); if pool_name != current_pool { hidden_pools += 1; current_pool = pool_name.to_string(); } } } } } if hidden_pools > 0 { let overflow_text = format!( "... and {} more pool{}", hidden_pools, if hidden_pools == 1 { "" } else { "s" } ); lines.push(Line::from(vec![ Span::styled(overflow_text, Typography::muted()) ])); } } // Apply scroll offset let total_lines = lines.len(); let available_height = area.height as usize; // Always apply scrolling if scroll_offset > 0, even if content fits if scroll_offset > 0 || total_lines > available_height { let max_scroll = if total_lines > available_height { total_lines - available_height } else { total_lines.saturating_sub(1) }; let effective_scroll = scroll_offset.min(max_scroll); // Take only the visible portion after scrolling let visible_lines: Vec = lines .into_iter() .skip(effective_scroll) .take(available_height) .collect(); let paragraph = Paragraph::new(Text::from(visible_lines)); frame.render_widget(paragraph, area); } else { // All content fits and no scroll offset, render normally let paragraph = Paragraph::new(Text::from(lines)); frame.render_widget(paragraph, area); } } }