use anyhow::Result; use crossterm::event::{Event, KeyCode}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::Style, widgets::{Block, Paragraph}, Frame, }; use std::collections::HashMap; use std::time::Instant; use tracing::info; pub mod theme; pub mod widgets; use crate::metrics::MetricStore; use cm_dashboard_shared::{Metric, Status}; use theme::{Components, Layout as ThemeLayout, StatusIcons, Theme, Typography}; use widgets::{BackupWidget, CpuWidget, MemoryWidget, ServicesWidget, Widget}; /// Widget states for a specific host #[derive(Clone)] pub struct HostWidgets { /// CPU widget state pub cpu_widget: CpuWidget, /// Memory widget state pub memory_widget: MemoryWidget, /// Services widget state pub services_widget: ServicesWidget, /// Backup widget state pub backup_widget: BackupWidget, /// Last update time for this host pub last_update: Option, } impl HostWidgets { pub fn new() -> Self { Self { cpu_widget: CpuWidget::new(), memory_widget: MemoryWidget::new(), services_widget: ServicesWidget::new(), backup_widget: BackupWidget::new(), last_update: None, } } } /// Main TUI application pub struct TuiApp { /// Widget states per host (hostname -> HostWidgets) host_widgets: HashMap, /// Current active host current_host: Option, /// Available hosts available_hosts: Vec, /// Host index for navigation host_index: usize, /// Should quit application should_quit: bool, /// Track if user manually navigated away from localhost user_navigated_away: bool, } impl TuiApp { pub fn new() -> Self { Self { host_widgets: HashMap::new(), current_host: None, available_hosts: Vec::new(), host_index: 0, should_quit: false, user_navigated_away: false, } } /// Get or create host widgets for the given hostname fn get_or_create_host_widgets(&mut self, hostname: &str) -> &mut HostWidgets { self.host_widgets .entry(hostname.to_string()) .or_insert_with(HostWidgets::new) } /// Update widgets with metrics from store (only for current host) pub fn update_metrics(&mut self, metric_store: &MetricStore) { if let Some(hostname) = self.current_host.clone() { // Only update widgets if we have metrics for this host let all_metrics = metric_store.get_metrics_for_host(&hostname); if !all_metrics.is_empty() { // Get metrics first while hostname is borrowed let cpu_metrics: Vec<&Metric> = all_metrics .iter() .filter(|m| { m.name.starts_with("cpu_") || m.name.contains("c_state_") || m.name.starts_with("process_top_") }) .copied() .collect(); let memory_metrics: Vec<&Metric> = all_metrics .iter() .filter(|m| m.name.starts_with("memory_") || m.name.starts_with("disk_tmp_")) .copied() .collect(); let service_metrics: Vec<&Metric> = all_metrics .iter() .filter(|m| m.name.starts_with("service_")) .copied() .collect(); let all_backup_metrics: Vec<&Metric> = all_metrics .iter() .filter(|m| m.name.starts_with("backup_")) .copied() .collect(); // Now get host widgets and update them let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.cpu_widget.update_from_metrics(&cpu_metrics); host_widgets .memory_widget .update_from_metrics(&memory_metrics); host_widgets .services_widget .update_from_metrics(&service_metrics); host_widgets .backup_widget .update_from_metrics(&all_backup_metrics); host_widgets.last_update = Some(Instant::now()); } } } /// Update available hosts with localhost prioritization pub fn update_hosts(&mut self, hosts: Vec) { // Sort hosts alphabetically let mut sorted_hosts = hosts.clone(); sorted_hosts.sort(); self.available_hosts = sorted_hosts; // Get the current hostname (localhost) for auto-selection let localhost = gethostname::gethostname().to_string_lossy().to_string(); // Prioritize localhost if it becomes available, but respect user navigation let localhost = gethostname::gethostname().to_string_lossy().to_string(); if !self.available_hosts.is_empty() { if self.available_hosts.contains(&localhost) && !self.user_navigated_away { // Localhost is available and user hasn't navigated away - switch to it self.current_host = Some(localhost.clone()); // Find the actual index of localhost in the sorted list self.host_index = self.available_hosts.iter().position(|h| h == &localhost).unwrap_or(0); } else if self.current_host.is_none() { // No current host - select first available (which is localhost if available) self.current_host = Some(self.available_hosts[0].clone()); self.host_index = 0; } else if let Some(ref current) = self.current_host { if !self.available_hosts.contains(current) { // Current host disconnected - select first available and reset navigation flag self.current_host = Some(self.available_hosts[0].clone()); self.host_index = 0; self.user_navigated_away = false; // Reset since we're forced to switch } else if let Some(index) = self.available_hosts.iter().position(|h| h == current) { // Update index for current host self.host_index = index; } } } } /// Handle keyboard input pub fn handle_input(&mut self, event: Event) -> Result<()> { if let Event::Key(key) = event { match key.code { KeyCode::Char('q') => { self.should_quit = true; } KeyCode::Left => { self.navigate_host(-1); } KeyCode::Right => { self.navigate_host(1); } KeyCode::Char('r') => { info!("Manual refresh requested"); // Refresh will be handled by main loop } KeyCode::Tab => { self.navigate_host(1); // Tab cycles to next host } _ => {} } } Ok(()) } /// Navigate between hosts fn navigate_host(&mut self, direction: i32) { if self.available_hosts.is_empty() { return; } let len = self.available_hosts.len(); if direction > 0 { self.host_index = (self.host_index + 1) % len; } else { self.host_index = if self.host_index == 0 { len - 1 } else { self.host_index - 1 }; } self.current_host = Some(self.available_hosts[self.host_index].clone()); // Check if user navigated away from localhost let localhost = gethostname::gethostname().to_string_lossy().to_string(); if let Some(ref current) = self.current_host { if current != &localhost { self.user_navigated_away = true; } else { self.user_navigated_away = false; // User navigated back to localhost } } info!("Switched to host: {}", self.current_host.as_ref().unwrap()); } /// Render the dashboard (real btop-style multi-panel layout) pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) { let size = frame.size(); // Clear background to true black like btop frame.render_widget( Block::default().style(Style::default().bg(Theme::background())), size, ); // Create real btop-style layout: multi-panel with borders // Top section: title bar // Bottom section: split into left (mem + disks) and right (CPU + processes) let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // Title bar Constraint::Min(0), // Main content area ]) .split(size); // New layout: left panels | right services (100% height) let content_chunks = ratatui::layout::Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(ThemeLayout::LEFT_PANEL_WIDTH), // Left side: system, backup Constraint::Percentage(ThemeLayout::RIGHT_PANEL_WIDTH), // Right side: services (100% height) ]) .split(main_chunks[1]); // Check if backup panel should be shown let show_backup = if let Some(hostname) = self.current_host.clone() { let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.backup_widget.has_data() } else { false }; // Left side: dynamic layout based on backup data availability let left_chunks = if show_backup { // Show both system and backup panels ratatui::layout::Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Percentage(ThemeLayout::SYSTEM_PANEL_HEIGHT), // System section Constraint::Percentage(ThemeLayout::BACKUP_PANEL_HEIGHT), // Backup section ]) .split(content_chunks[0]) } else { // Show only system panel (full height) ratatui::layout::Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(100)]) // System section takes full height .split(content_chunks[0]) }; // Render title bar self.render_btop_title(frame, main_chunks[0], metric_store); // Render new panel layout self.render_system_panel(frame, left_chunks[0], metric_store); if show_backup && left_chunks.len() > 1 { self.render_backup_panel(frame, left_chunks[1]); } // Render services widget for current host if let Some(hostname) = self.current_host.clone() { let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets .services_widget .render(frame, content_chunks[1]); // Services takes full right side } } /// Render btop-style minimal title with host status colors fn render_btop_title(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) { use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use theme::StatusIcons; if self.available_hosts.is_empty() { let title_text = "cm-dashboard • no hosts discovered"; let title = Paragraph::new(title_text).style(Typography::title()); frame.render_widget(title, area); return; } // Create spans for each host with status indicators let mut spans = vec![Span::styled("cm-dashboard • ", Typography::title())]; for (i, host) in self.available_hosts.iter().enumerate() { if i > 0 { spans.push(Span::styled(" ", Typography::title())); } // Calculate overall host status from metrics let host_status = self.calculate_host_status(host, metric_store); let status_icon = StatusIcons::get_icon(host_status); let status_color = Theme::status_color(host_status); // Add status icon spans.push(Span::styled( format!("{} ", status_icon), Style::default().fg(status_color), )); if Some(host) == self.current_host.as_ref() { // Selected host in bold bright white spans.push(Span::styled( host.clone(), Typography::title().add_modifier(Modifier::BOLD), )); } else { // Other hosts in normal style with status color spans.push(Span::styled( host.clone(), Style::default().fg(status_color), )); } } let title_line = Line::from(spans); let title = Paragraph::new(vec![title_line]); frame.render_widget(title, area); } /// Calculate overall status for a host based on its metrics fn calculate_host_status(&self, hostname: &str, metric_store: &MetricStore) -> Status { let metrics = metric_store.get_metrics_for_host(hostname); if metrics.is_empty() { return Status::Unknown; } // Check if any metric is critical if metrics.iter().any(|m| m.status == Status::Critical) { return Status::Critical; } // Check if any metric is warning if metrics.iter().any(|m| m.status == Status::Warning) { return Status::Warning; } // Check if all metrics are ok if metrics.iter().all(|m| m.status == Status::Ok) { return Status::Ok; } // Default to unknown if mixed statuses Status::Unknown } fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) { let system_block = Components::widget_block("system"); let inner_area = system_block.inner(area); frame.render_widget(system_block, area); let content_chunks = ratatui::layout::Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(ThemeLayout::CPU_SECTION_HEIGHT), // CPU section (title, load) Constraint::Length(ThemeLayout::MEMORY_SECTION_HEIGHT), // Memory section (title, used, /tmp) Constraint::Min(0), // Storage section ]) .split(inner_area); // Get current host widgets, create if none exist if let Some(hostname) = self.current_host.clone() { let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.cpu_widget.render(frame, content_chunks[0]); host_widgets.memory_widget.render(frame, content_chunks[1]); } self.render_storage_section(frame, content_chunks[2], metric_store); } fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) { let backup_block = Components::widget_block("backup"); let inner_area = backup_block.inner(area); frame.render_widget(backup_block, area); // Get current host widgets for backup widget if let Some(hostname) = self.current_host.clone() { let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.backup_widget.render(frame, inner_area); } } fn render_storage_section(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) { if area.height < 2 { return; } if let Some(ref hostname) = self.current_host { // Get disk count to determine how many disks to display let disk_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 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 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]); return; } // Group disks by physical device let mut physical_devices: std::collections::HashMap> = 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); } } // 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 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()); 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())); // 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 } current_line += lines_for_this_device; } else { break; // Can't fit more devices } } // Add remaining space if any if constraints.len() < available_lines { constraints.push(Constraint::Min(0)); } let content_chunks = ratatui::layout::Layout::default() .direction(Direction::Vertical) .constraints(constraints) .split(area); 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) } else { String::new() }; 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]); 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()); 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", 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", 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", disk_index)) .map(|m| m.status) .unwrap_or(Status::Unknown); // 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 } else { mount_point.clone() }; 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]); chunk_index += 1; } } // Show truncation indicator if we couldn't display all devices if devices_to_show.len() < physical_devices.len() { if let Some(last_chunk) = content_chunks.last() { let truncated_count = physical_devices.len() - devices_to_show.len(); let truncated_text = format!( "... and {} more disk{}", truncated_count, if truncated_count == 1 { "" } else { "s" } ); let truncated_para = Paragraph::new(truncated_text).style(Typography::muted()); frame.render_widget(truncated_para, *last_chunk); } } } else { // No host connected 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 no_host_spans = StatusIcons::create_status_spans(Status::Unknown, "No host connected"); let no_host_para = Paragraph::new(ratatui::text::Line::from(no_host_spans)); frame.render_widget(no_host_para, content_chunks[1]); } } }