use cm_dashboard_shared::Status; use ratatui::{ layout::Rect, text::{Line, Span, Text}, widgets::Paragraph, Frame, }; 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, 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, tmp_status: Status, /// All tmpfs mounts (for auto-discovery support) tmpfs_mounts: Vec, // Storage metrics (collected from disk metrics) storage_pools: Vec, // Backup metrics backup_status: String, backup_start_time_raw: Option, backup_disk_serial: Option, backup_disk_usage_percent: Option, backup_disk_used_gb: Option, backup_disk_total_gb: Option, backup_disk_wear_percent: Option, backup_disk_temperature: Option, backup_last_size_gb: Option, // Overall status has_data: bool, } #[derive(Clone)] struct StoragePool { name: String, mount_point: String, pool_type: String, // "single", "mergerfs (2+1)", "RAID5 (3+1)", etc. drives: Vec, // For physical drives data_drives: Vec, // For MergerFS pools parity_drives: Vec, // For MergerFS pools filesystems: Vec, // For physical drive pools: individual filesystem children usage_percent: Option, used_gb: Option, total_gb: Option, status: Status, } #[derive(Clone)] struct StorageDrive { name: String, temperature: Option, wear_percent: Option, status: Status, } #[derive(Clone)] struct FileSystem { mount_point: String, usage_percent: Option, used_gb: Option, total_gb: Option, status: Status, } impl SystemWidget { pub fn new() -> Self { Self { nixos_build: 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, tmp_status: Status::Unknown, tmpfs_mounts: Vec::new(), storage_pools: Vec::new(), backup_status: "unknown".to_string(), backup_start_time_raw: None, backup_disk_serial: None, backup_disk_usage_percent: None, backup_disk_used_gb: None, backup_disk_total_gb: None, backup_disk_wear_percent: None, backup_disk_temperature: None, backup_last_size_gb: None, 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(), } } /// Get the current agent hash for rebuild completion detection pub fn _get_agent_hash(&self) -> Option<&String> { self.agent_hash.as_ref() } } use super::Widget; impl Widget for SystemWidget { fn update_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) { self.has_data = true; // Extract agent version self.agent_hash = Some(agent_data.agent_version.clone()); // Extract build version self.nixos_build = agent_data.build_version.clone(); // Extract CPU data directly let cpu = &agent_data.system.cpu; self.cpu_load_1min = Some(cpu.load_1min); self.cpu_load_5min = Some(cpu.load_5min); self.cpu_load_15min = Some(cpu.load_15min); self.cpu_frequency = Some(cpu.frequency_mhz); self.cpu_status = Status::Ok; // Extract memory data directly let memory = &agent_data.system.memory; self.memory_usage_percent = Some(memory.usage_percent); self.memory_used_gb = Some(memory.used_gb); self.memory_total_gb = Some(memory.total_gb); self.memory_status = Status::Ok; // Store all tmpfs mounts for display self.tmpfs_mounts = memory.tmpfs.clone(); // Extract tmpfs data (maintain backward compatibility for /tmp) if let Some(tmp_data) = memory.tmpfs.iter().find(|t| t.mount == "/tmp") { self.tmp_usage_percent = Some(tmp_data.usage_percent); self.tmp_used_gb = Some(tmp_data.used_gb); self.tmp_total_gb = Some(tmp_data.total_gb); self.tmp_status = Status::Ok; } // Convert storage data to internal format self.update_storage_from_agent_data(agent_data); // Extract backup data let backup = &agent_data.backup; self.backup_status = backup.status.clone(); self.backup_start_time_raw = backup.start_time_raw.clone(); self.backup_last_size_gb = backup.last_backup_size_gb; if let Some(disk) = &backup.repository_disk { self.backup_disk_serial = Some(disk.serial.clone()); self.backup_disk_usage_percent = Some(disk.usage_percent); self.backup_disk_used_gb = Some(disk.used_gb); self.backup_disk_total_gb = Some(disk.total_gb); self.backup_disk_wear_percent = disk.wear_percent; self.backup_disk_temperature = disk.temperature_celsius; } else { self.backup_disk_serial = None; self.backup_disk_usage_percent = None; self.backup_disk_used_gb = None; self.backup_disk_total_gb = None; self.backup_disk_wear_percent = None; self.backup_disk_temperature = None; } } } impl SystemWidget { /// Convert structured storage data to internal format fn update_storage_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) { let mut pools: std::collections::HashMap = std::collections::HashMap::new(); // Convert drives for drive in &agent_data.system.storage.drives { let mut pool = StoragePool { name: drive.name.clone(), mount_point: drive.name.clone(), pool_type: "drive".to_string(), drives: Vec::new(), data_drives: Vec::new(), parity_drives: Vec::new(), filesystems: Vec::new(), usage_percent: None, used_gb: None, total_gb: None, status: Status::Ok, }; // Add drive info let display_name = drive.serial_number.as_ref() .map(|s| truncate_serial(s)) .unwrap_or(drive.name.clone()); let storage_drive = StorageDrive { name: display_name, temperature: drive.temperature_celsius, wear_percent: drive.wear_percent, status: Status::Ok, }; pool.drives.push(storage_drive); // Calculate totals from filesystems let total_used: f32 = drive.filesystems.iter().map(|fs| fs.used_gb).sum(); let total_size: f32 = drive.filesystems.iter().map(|fs| fs.total_gb).sum(); let average_usage = if total_size > 0.0 { (total_used / total_size) * 100.0 } else { 0.0 }; pool.usage_percent = Some(average_usage); pool.used_gb = Some(total_used); pool.total_gb = Some(total_size); // Add filesystems for fs in &drive.filesystems { let filesystem = FileSystem { mount_point: fs.mount.clone(), usage_percent: Some(fs.usage_percent), used_gb: Some(fs.used_gb), total_gb: Some(fs.total_gb), status: Status::Ok, }; pool.filesystems.push(filesystem); } pools.insert(drive.name.clone(), pool); } // Convert pools (MergerFS, RAID, etc.) for pool in &agent_data.system.storage.pools { // Use agent-calculated status (combined health and usage status) let pool_status = if pool.health_status == Status::Critical || pool.usage_status == Status::Critical { Status::Critical } else if pool.health_status == Status::Warning || pool.usage_status == Status::Warning { Status::Warning } else if pool.health_status == Status::Ok && pool.usage_status == Status::Ok { Status::Ok } else { Status::Unknown }; let mut storage_pool = StoragePool { name: pool.name.clone(), mount_point: pool.mount.clone(), pool_type: pool.pool_type.clone(), drives: Vec::new(), data_drives: Vec::new(), parity_drives: Vec::new(), filesystems: Vec::new(), usage_percent: Some(pool.usage_percent), used_gb: Some(pool.used_gb), total_gb: Some(pool.total_gb), status: pool_status, }; // Add data drives - use agent-calculated status for drive in &pool.data_drives { // Use combined health and temperature status let drive_status = if drive.health_status == Status::Critical || drive.temperature_status == Status::Critical { Status::Critical } else if drive.health_status == Status::Warning || drive.temperature_status == Status::Warning { Status::Warning } else if drive.health_status == Status::Ok && drive.temperature_status == Status::Ok { Status::Ok } else { Status::Unknown }; let display_name = drive.serial_number.as_ref() .map(|s| truncate_serial(s)) .unwrap_or(drive.name.clone()); let storage_drive = StorageDrive { name: display_name, temperature: drive.temperature_celsius, wear_percent: drive.wear_percent, status: drive_status, }; storage_pool.data_drives.push(storage_drive); } // Add parity drives - use agent-calculated status for drive in &pool.parity_drives { // Use combined health and temperature status let drive_status = if drive.health_status == Status::Critical || drive.temperature_status == Status::Critical { Status::Critical } else if drive.health_status == Status::Warning || drive.temperature_status == Status::Warning { Status::Warning } else if drive.health_status == Status::Ok && drive.temperature_status == Status::Ok { Status::Ok } else { Status::Unknown }; let display_name = drive.serial_number.as_ref() .map(|s| truncate_serial(s)) .unwrap_or(drive.name.clone()); let storage_drive = StorageDrive { name: display_name, temperature: drive.temperature_celsius, wear_percent: drive.wear_percent, status: drive_status, }; storage_pool.parity_drives.push(storage_drive); } pools.insert(pool.name.clone(), storage_pool); } // Store pools let mut pool_list: Vec = pools.into_values().collect(); pool_list.sort_by(|a, b| a.name.cmp(&b.name)); self.storage_pools = pool_list; } /// Render storage section with enhanced tree structure fn render_storage(&self) -> Vec> { let mut lines = Vec::new(); for pool in &self.storage_pools { // Pool header line with type and health let pool_label = if pool.pool_type == "drive" { // For physical drives, show the drive name with temperature and wear percentage if available // Physical drives only have one drive entry if let Some(drive) = pool.drives.first() { let mut drive_details = Vec::new(); if let Some(temp) = drive.temperature { drive_details.push(format!("T: {}°C", temp as i32)); } if let Some(wear) = drive.wear_percent { drive_details.push(format!("W: {}%", wear as i32)); } if !drive_details.is_empty() { format!("{} {}", drive.name, drive_details.join(" ")) } else { drive.name.clone() } } else { pool.name.clone() } } else { // For mergerfs pools, show pool type with mount point format!("mergerfs {}:", pool.mount_point) }; let pool_spans = StatusIcons::create_status_spans(pool.status.clone(), &pool_label); lines.push(Line::from(pool_spans)); // Show individual filesystems for physical drives (matching CLAUDE.md format) if pool.pool_type == "drive" { // Show filesystem entries like: ├─ ● /: 55% 250.5GB/456.4GB for (i, filesystem) in pool.filesystems.iter().enumerate() { let is_last = i == pool.filesystems.len() - 1; let tree_symbol = if is_last { " └─ " } else { " ├─ " }; let fs_text = format!("{}: {:.0}% {:.1}GB/{:.1}GB", filesystem.mount_point, filesystem.usage_percent.unwrap_or(0.0), filesystem.used_gb.unwrap_or(0.0), filesystem.total_gb.unwrap_or(0.0)); let mut fs_spans = vec![ Span::styled(tree_symbol, Typography::tree()), ]; fs_spans.extend(StatusIcons::create_status_spans( filesystem.status.clone(), &fs_text )); lines.push(Line::from(fs_spans)); } } else { // For mergerfs pools, show structure matching CLAUDE.md format: // ● mergerfs (2+1): // ├─ Total: ● 63% 2355.2GB/3686.4GB // ├─ Data Disks: // │ ├─ ● sdb T: 24°C W: 5% // │ └─ ● sdd T: 27°C W: 5% // ├─ Parity: ● sdc T: 24°C W: 5% // └─ Mount: /srv/media // Pool total usage let total_text = format!("{:.0}% {:.1}GB/{:.1}GB", pool.usage_percent.unwrap_or(0.0), pool.used_gb.unwrap_or(0.0), pool.total_gb.unwrap_or(0.0) ); let mut total_spans = vec![ Span::styled(" ├─ ", Typography::tree()), ]; total_spans.extend(StatusIcons::create_status_spans(Status::Ok, &total_text)); lines.push(Line::from(total_spans)); // Data drives - at same level as parity let has_parity = !pool.parity_drives.is_empty(); for (i, drive) in pool.data_drives.iter().enumerate() { let is_last_data = i == pool.data_drives.len() - 1; let mut drive_details = Vec::new(); if let Some(temp) = drive.temperature { drive_details.push(format!("T: {}°C", temp as i32)); } if let Some(wear) = drive.wear_percent { drive_details.push(format!("W: {}%", wear as i32)); } let drive_text = if !drive_details.is_empty() { format!("Data_{}: {} {}", i + 1, drive.name, drive_details.join(" ")) } else { format!("Data_{}: {}", i + 1, drive.name) }; // Last data drive uses └─ if there's no parity, otherwise ├─ let tree_symbol = if is_last_data && !has_parity { " └─ " } else { " ├─ " }; let mut data_spans = vec![ Span::styled(tree_symbol, Typography::tree()), ]; data_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text)); lines.push(Line::from(data_spans)); } // Parity drives - last item(s) if !pool.parity_drives.is_empty() { for (i, drive) in pool.parity_drives.iter().enumerate() { let is_last = i == pool.parity_drives.len() - 1; let mut drive_details = Vec::new(); if let Some(temp) = drive.temperature { drive_details.push(format!("T: {}°C", temp as i32)); } if let Some(wear) = drive.wear_percent { drive_details.push(format!("W: {}%", wear as i32)); } let drive_text = if !drive_details.is_empty() { format!("Parity: {} {}", drive.name, drive_details.join(" ")) } else { format!("Parity: {}", drive.name) }; let tree_symbol = if is_last { " └─ " } else { " ├─ " }; let mut parity_spans = vec![ Span::styled(tree_symbol, Typography::tree()), ]; parity_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text)); lines.push(Line::from(parity_spans)); } } } } lines } } /// Truncate serial number to last 8 characters fn truncate_serial(serial: &str) -> String { let len = serial.len(); if len > 8 { serial[len - 8..].to_string() } else { serial.to_string() } } /// Helper function to render a drive in a MergerFS pool fn render_mergerfs_drive<'a>(drive: &StorageDrive, tree_symbol: &'a str, lines: &mut Vec>) { let mut drive_details = Vec::new(); if let Some(temp) = drive.temperature { drive_details.push(format!("T: {}°C", temp as i32)); } if let Some(wear) = drive.wear_percent { drive_details.push(format!("W: {}%", wear as i32)); } let drive_text = if !drive_details.is_empty() { format!("{} {}", drive.name, drive_details.join(" ")) } else { drive.name.clone() }; let mut drive_spans = vec![ Span::styled(tree_symbol, Typography::tree()), ]; drive_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text)); lines.push(Line::from(drive_spans)); } /// Helper function to render a drive in a storage pool fn render_pool_drive(drive: &StorageDrive, is_last: bool, lines: &mut Vec>) { let tree_symbol = if is_last { " └─" } else { " ├─" }; let mut drive_details = Vec::new(); if let Some(temp) = drive.temperature { drive_details.push(format!("T: {}°C", temp as i32)); } if let Some(wear) = drive.wear_percent { drive_details.push(format!("W: {}%", wear as i32)); } let drive_text = if !drive_details.is_empty() { format!("● {} {}", drive.name, drive_details.join(" ")) } else { format!("● {}", drive.name) }; let mut drive_spans = vec![ 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)); } impl SystemWidget { /// Render backup section for display fn render_backup(&self) -> Vec> { let mut lines = Vec::new(); // First line: serial number with temperature and wear if let Some(serial) = &self.backup_disk_serial { let truncated_serial = truncate_serial(serial); let mut details = Vec::new(); if let Some(temp) = self.backup_disk_temperature { details.push(format!("T: {}°C", temp as i32)); } if let Some(wear) = self.backup_disk_wear_percent { details.push(format!("W: {}%", wear as i32)); } let disk_text = if !details.is_empty() { format!("{} {}", truncated_serial, details.join(" ")) } else { truncated_serial }; let backup_status = match self.backup_status.as_str() { "completed" | "success" => Status::Ok, "running" => Status::Pending, "failed" => Status::Critical, _ => Status::Unknown, }; let disk_spans = StatusIcons::create_status_spans(backup_status, &disk_text); lines.push(Line::from(disk_spans)); // Show backup time from TOML if available if let Some(start_time) = &self.backup_start_time_raw { let time_text = if let Some(size) = self.backup_last_size_gb { format!("Time: {} ({:.1}GB)", start_time, size) } else { format!("Time: {}", start_time) }; lines.push(Line::from(vec![ Span::styled(" ├─ ", Typography::tree()), Span::styled(time_text, Typography::secondary()) ])); } // Usage information if let (Some(used), Some(total), Some(usage_percent)) = ( self.backup_disk_used_gb, self.backup_disk_total_gb, self.backup_disk_usage_percent ) { let usage_text = format!("Usage: {:.0}% {:.0}GB/{:.0}GB", usage_percent, used, total); let usage_spans = StatusIcons::create_status_spans(Status::Ok, &usage_text); let mut full_spans = vec![ Span::styled(" └─ ", Typography::tree()), ]; full_spans.extend(usage_spans); lines.push(Line::from(full_spans)); } } lines } /// Format time ago from timestamp fn format_time_ago(&self, timestamp: u64) -> String { let now = chrono::Utc::now().timestamp() as u64; let seconds_ago = now.saturating_sub(timestamp); let hours = seconds_ago / 3600; let minutes = (seconds_ago % 3600) / 60; if hours > 0 { format!("{}h ago", hours) } else if minutes > 0 { format!("{}m ago", minutes) } else { "now".to_string() } } /// Format time until from future timestamp fn format_time_until(&self, timestamp: u64) -> String { let now = chrono::Utc::now().timestamp() as u64; if timestamp <= now { return "overdue".to_string(); } let seconds_until = timestamp - now; let hours = seconds_until / 3600; let minutes = (seconds_until % 3600) / 60; if hours > 0 { format!("in {}h", hours) } else if minutes > 0 { format!("in {}m", minutes) } else { "soon".to_string() } } /// Render system widget pub fn render(&mut self, frame: &mut Frame, area: Rect, hostname: &str, config: Option<&crate::config::DashboardConfig>) { let mut lines = Vec::new(); // NixOS section lines.push(Line::from(vec![ Span::styled(format!("NixOS {}:", hostname), Typography::widget_title()) ])); let build_text = self.nixos_build.as_deref().unwrap_or("unknown"); lines.push(Line::from(vec![ Span::styled(format!("Build: {}", build_text), Typography::secondary()) ])); let agent_version_text = self.agent_hash.as_deref().unwrap_or("unknown"); lines.push(Line::from(vec![ Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary()) ])); // Display detected connection IP if let Some(config) = config { if let Some(host_details) = config.hosts.get(hostname) { let detected_ip = host_details.get_connection_ip(hostname); lines.push(Line::from(vec![ Span::styled(format!("IP: {}", detected_ip), 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)); // Display all tmpfs mounts for (i, tmpfs) in self.tmpfs_mounts.iter().enumerate() { let is_last = i == self.tmpfs_mounts.len() - 1; let tree_symbol = if is_last { " └─ " } else { " ├─ " }; let usage_text = if tmpfs.total_gb > 0.0 { format!("{:.0}% {:.1}GB/{:.1}GB", tmpfs.usage_percent, tmpfs.used_gb, tmpfs.total_gb) } else { "— —/—".to_string() }; let mut tmpfs_spans = vec![ Span::styled(tree_symbol, Typography::tree()), ]; tmpfs_spans.extend(StatusIcons::create_status_spans( Status::Ok, // TODO: Calculate status based on usage_percent &format!("{}: {}", tmpfs.mount, usage_text) )); lines.push(Line::from(tmpfs_spans)); } // Storage section lines.push(Line::from(vec![ Span::styled("Storage:", Typography::widget_title()) ])); // Storage items - let main overflow logic handle truncation let storage_lines = self.render_storage(); lines.extend(storage_lines); // Backup section (if available) if self.backup_status != "unavailable" && self.backup_status != "unknown" { lines.push(Line::from(vec![ Span::styled("Backup:", Typography::widget_title()) ])); let backup_lines = self.render_backup(); lines.extend(backup_lines); } // Apply scroll offset let total_lines = lines.len(); let available_height = area.height as usize; // Show only what fits, with "X more below" if needed if total_lines > available_height { let lines_for_content = available_height.saturating_sub(1); // Reserve one line for "more below" let mut visible_lines: Vec = lines .into_iter() .take(lines_for_content) .collect(); let hidden_below = total_lines.saturating_sub(lines_for_content); if hidden_below > 0 { let more_line = Line::from(vec![ Span::styled(format!("... {} more below", hidden_below), Typography::muted()) ]); visible_lines.push(more_line); } 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); } } }