use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyModifiers}; 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::config::DashboardConfig; use crate::metrics::MetricStore; use cm_dashboard_shared::{Metric, Status}; use theme::{Components, Layout as ThemeLayout, Theme, Typography}; use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget}; /// Commands that can be triggered from the UI #[derive(Debug, Clone)] pub enum UiCommand { ServiceStart { hostname: String, service_name: String }, ServiceStop { hostname: String, service_name: String }, TriggerBackup { hostname: String }, } /// Types of commands for status tracking #[derive(Debug, Clone)] pub enum CommandType { ServiceStart, ServiceStop, BackupTrigger, } /// Panel types for focus management #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PanelType { System, Services, Backup, } impl PanelType { } /// Widget states for a specific host #[derive(Clone)] pub struct HostWidgets { /// System widget state (includes CPU, Memory, NixOS info, Storage) pub system_widget: SystemWidget, /// Services widget state pub services_widget: ServicesWidget, /// Backup widget state pub backup_widget: BackupWidget, /// Scroll offsets for each panel pub system_scroll_offset: usize, pub services_scroll_offset: usize, pub backup_scroll_offset: usize, /// Last update time for this host pub last_update: Option, /// Pending service transitions for immediate visual feedback pub pending_service_transitions: HashMap, // service_name -> (command_type, original_status, start_time) } impl HostWidgets { pub fn new() -> Self { Self { system_widget: SystemWidget::new(), services_widget: ServicesWidget::new(), backup_widget: BackupWidget::new(), system_scroll_offset: 0, services_scroll_offset: 0, backup_scroll_offset: 0, last_update: None, pending_service_transitions: HashMap::new(), } } } /// 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, /// Currently focused panel focused_panel: PanelType, /// Should quit application should_quit: bool, /// Track if user manually navigated away from localhost user_navigated_away: bool, /// Dashboard configuration config: DashboardConfig, } impl TuiApp { pub fn new(config: DashboardConfig) -> Self { Self { host_widgets: HashMap::new(), current_host: None, available_hosts: Vec::new(), host_index: 0, focused_panel: PanelType::System, // Start with System panel focused should_quit: false, user_navigated_away: false, config, } } /// 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) { // Check for rebuild completion by agent hash change 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(); // Clear completed transitions first self.clear_completed_transitions(&hostname, &service_metrics); // Now get host widgets and update them let host_widgets = self.get_or_create_host_widgets(&hostname); // Collect all system metrics (CPU, memory, NixOS, disk/storage) let mut system_metrics = cpu_metrics; system_metrics.extend(memory_metrics); // Add NixOS metrics - using exact matching for build display fix let nixos_metrics: Vec<&Metric> = all_metrics .iter() .filter(|m| m.name == "system_nixos_build" || m.name == "system_active_users" || m.name == "agent_version") .copied() .collect(); system_metrics.extend(nixos_metrics); // Add disk/storage metrics let disk_metrics: Vec<&Metric> = all_metrics .iter() .filter(|m| m.name.starts_with("disk_")) .copied() .collect(); system_metrics.extend(disk_metrics); host_widgets.system_widget.update_from_metrics(&system_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(); // Keep hosts that have pending transitions even if they're offline for (hostname, host_widgets) in &self.host_widgets { if !host_widgets.pending_service_transitions.is_empty() { if !sorted_hosts.contains(hostname) { sorted_hosts.push(hostname.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(); 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') => { // System rebuild command - works on any panel for current host if let Some(hostname) = self.current_host.clone() { // Launch tmux popup with SSH using config values let ssh_command = format!( "ssh -tt {}@{} 'bash -ic {}'", self.config.ssh.rebuild_user, hostname, self.config.ssh.rebuild_alias ); std::process::Command::new("tmux") .arg("display-popup") .arg(&ssh_command) .spawn() .ok(); // Ignore errors, tmux will handle them } } KeyCode::Char('s') => { if self.focused_panel == PanelType::Services { // Service start command if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { if self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()) { return Ok(Some(UiCommand::ServiceStart { hostname, service_name })); } } } } KeyCode::Char('S') => { if self.focused_panel == PanelType::Services { // Service stop command if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { if self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()) { return Ok(Some(UiCommand::ServiceStop { hostname, service_name })); } } } } KeyCode::Char('b') => { if self.focused_panel == PanelType::Backup { // Trigger backup if let Some(hostname) = self.current_host.clone() { self.start_command(&hostname, CommandType::BackupTrigger, hostname.clone()); return Ok(Some(UiCommand::TriggerBackup { hostname })); } } } KeyCode::Tab => { if key.modifiers.contains(KeyModifiers::SHIFT) { // Shift+Tab cycles through panels self.next_panel(); } else { // Tab cycles to next host self.navigate_host(1); } } KeyCode::BackTab => { // BackTab (Shift+Tab on some terminals) also cycles panels self.next_panel(); } KeyCode::Up => { // Scroll up in focused panel self.scroll_focused_panel(-1); } KeyCode::Down => { // Scroll down in focused panel self.scroll_focused_panel(1); } _ => {} } } Ok(None) } /// 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()); } /// Switch to next panel (Shift+Tab) - only cycles through visible panels pub fn next_panel(&mut self) { let visible_panels = self.get_visible_panels(); if visible_panels.len() <= 1 { return; // Can't switch if only one or no panels visible } // Find current panel index in visible panels if let Some(current_index) = visible_panels.iter().position(|&p| p == self.focused_panel) { // Move to next visible panel let next_index = (current_index + 1) % visible_panels.len(); self.focused_panel = visible_panels[next_index]; } else { // Current panel not visible, switch to first visible panel self.focused_panel = visible_panels[0]; } info!("Switched to panel: {:?}", self.focused_panel); } /// Get the currently selected service name from the services widget fn get_selected_service(&self) -> Option { if let Some(hostname) = &self.current_host { if let Some(host_widgets) = self.host_widgets.get(hostname) { return host_widgets.services_widget.get_selected_service(); } } None } /// Should quit application pub fn should_quit(&self) -> bool { self.should_quit } /// Get current service status for state-aware command validation fn get_current_service_status(&self, hostname: &str, service_name: &str) -> Option { if let Some(host_widgets) = self.host_widgets.get(hostname) { return host_widgets.services_widget.get_service_status(service_name); } None } /// Start command execution with immediate visual feedback pub fn start_command(&mut self, hostname: &str, command_type: CommandType, target: String) -> bool { // Get current service status to validate command let current_status = self.get_current_service_status(hostname, &target); // Validate if command makes sense for current state let should_execute = match (&command_type, current_status.as_deref()) { (CommandType::ServiceStart, Some("inactive") | Some("failed") | Some("dead")) => true, (CommandType::ServiceStop, Some("active")) => true, (CommandType::ServiceStart, Some("active")) => { // Already running - don't execute false }, (CommandType::ServiceStop, Some("inactive") | Some("failed") | Some("dead")) => { // Already stopped - don't execute false }, (_, None) => { // Unknown service state - allow command to proceed true }, _ => true, // Default: allow other combinations }; // ALWAYS store the pending transition for immediate visual feedback, even if we don't execute if let Some(host_widgets) = self.host_widgets.get_mut(hostname) { host_widgets.pending_service_transitions.insert( target.clone(), (command_type, current_status.unwrap_or_else(|| "unknown".to_string()), Instant::now()) ); } should_execute } /// Clear pending transitions when real status updates arrive or timeout fn clear_completed_transitions(&mut self, hostname: &str, service_metrics: &[&Metric]) { if let Some(host_widgets) = self.host_widgets.get_mut(hostname) { let mut completed_services = Vec::new(); // Check each pending transition to see if real status has changed for (service_name, (command_type, original_status, _start_time)) in &host_widgets.pending_service_transitions { // Look for status metric for this service for metric in service_metrics { if metric.name == format!("service_{}_status", service_name) { let new_status = metric.value.as_string(); // Check if status has changed from original (command completed) if &new_status != original_status { // Verify it changed in the expected direction let expected_change = match command_type { CommandType::ServiceStart => &new_status == "active", CommandType::ServiceStop => &new_status != "active", _ => false, }; if expected_change { completed_services.push(service_name.clone()); } } break; } } } // Remove completed transitions for service_name in completed_services { host_widgets.pending_service_transitions.remove(&service_name); } } } /// Scroll the focused panel up or down pub fn scroll_focused_panel(&mut self, direction: i32) { if let Some(hostname) = self.current_host.clone() { let focused_panel = self.focused_panel; // Get the value before borrowing let host_widgets = self.get_or_create_host_widgets(&hostname); match focused_panel { PanelType::System => { if direction > 0 { host_widgets.system_scroll_offset = host_widgets.system_scroll_offset.saturating_add(1); } else { host_widgets.system_scroll_offset = host_widgets.system_scroll_offset.saturating_sub(1); } info!("System panel scroll offset: {}", host_widgets.system_scroll_offset); } PanelType::Services => { // For services panel, Up/Down moves selection cursor, not scroll let total_services = host_widgets.services_widget.get_total_services_count(); if direction > 0 { host_widgets.services_widget.select_next(total_services); info!("Services selection moved down"); } else { host_widgets.services_widget.select_previous(); info!("Services selection moved up"); } } PanelType::Backup => { if direction > 0 { host_widgets.backup_scroll_offset = host_widgets.backup_scroll_offset.saturating_add(1); } else { host_widgets.backup_scroll_offset = host_widgets.backup_scroll_offset.saturating_sub(1); } info!("Backup panel scroll offset: {}", host_widgets.backup_scroll_offset); } } } } /// Get list of currently visible panels fn get_visible_panels(&self) -> Vec { let mut visible_panels = vec![PanelType::System, PanelType::Services]; // Check if backup panel should be shown if let Some(hostname) = &self.current_host { if let Some(host_widgets) = self.host_widgets.get(hostname) { if host_widgets.backup_widget.has_data() { visible_panels.push(PanelType::Backup); } } } visible_panels } /// 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 // Three-section layout: title bar, main content, statusbar let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // Title bar Constraint::Min(0), // Main content area Constraint::Length(1), // Statusbar ]) .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]); // main_chunks[1] is now the content area (between title and statusbar) // 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 is_focused = self.focused_panel == PanelType::Services; let (scroll_offset, pending_transitions) = { let host_widgets = self.get_or_create_host_widgets(&hostname); (host_widgets.services_scroll_offset, host_widgets.pending_service_transitions.clone()) }; let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets .services_widget .render_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, &pending_transitions); // Services takes full right side } // Render statusbar at the bottom self.render_statusbar(frame, main_chunks[2]); // main_chunks[2] is the statusbar area } /// 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())); } // Always show normal status icon based on metrics (no command status at host level) let host_status = self.calculate_host_status(host, metric_store); let (status_icon, status_color) = (StatusIcons::get_icon(host_status), 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; } // First check if we have the aggregated host status summary from the agent if let Some(host_summary_metric) = metric_store.get_metric(hostname, "host_status_summary") { return host_summary_metric.status; } // Fallback to old aggregation logic with proper Pending handling let mut has_critical = false; let mut has_warning = false; let mut has_pending = false; let mut ok_count = 0; for metric in &metrics { match metric.status { Status::Critical => has_critical = true, Status::Warning => has_warning = true, Status::Pending => has_pending = true, Status::Ok => ok_count += 1, Status::Unknown => {} // Ignore unknown for aggregation } } // Priority order: Critical > Warning > Pending > Ok > Unknown if has_critical { Status::Critical } else if has_warning { Status::Warning } else if has_pending { Status::Pending } else if ok_count > 0 { Status::Ok } else { Status::Unknown } } /// Render dynamic statusbar with context-aware shortcuts fn render_statusbar(&self, frame: &mut Frame, area: Rect) { let shortcuts = self.get_context_shortcuts(); let statusbar_text = shortcuts.join(" • "); let statusbar = Paragraph::new(statusbar_text) .style(Typography::secondary()) .alignment(ratatui::layout::Alignment::Center); frame.render_widget(statusbar, area); } /// Get context-aware shortcuts based on focused panel fn get_context_shortcuts(&self) -> Vec { let mut shortcuts = Vec::new(); // Global shortcuts shortcuts.push("Tab: Switch Host".to_string()); shortcuts.push("Shift+Tab: Switch Panel".to_string()); // Scroll shortcuts (always available) shortcuts.push("↑↓: Scroll".to_string()); // Global rebuild shortcut (works on any panel) shortcuts.push("R: Rebuild Host".to_string()); // Panel-specific shortcuts match self.focused_panel { PanelType::Services => { shortcuts.push("S: Start".to_string()); shortcuts.push("Shift+S: Stop".to_string()); } PanelType::Backup => { shortcuts.push("B: Trigger Backup".to_string()); } _ => {} } // Always show quit shortcuts.push("Q: Quit".to_string()); shortcuts } fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) { let system_block = if self.focused_panel == PanelType::System { Components::focused_widget_block("system") } else { Components::widget_block("system") }; let inner_area = system_block.inner(area); frame.render_widget(system_block, area); // Get current host widgets, create if none exist if let Some(hostname) = self.current_host.clone() { let scroll_offset = { let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.system_scroll_offset }; let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.system_widget.render_with_scroll(frame, inner_area, scroll_offset); } } fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) { let backup_block = if self.focused_panel == PanelType::Backup { Components::focused_widget_block("backup") } else { 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 scroll_offset = { let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.backup_scroll_offset }; let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.backup_widget.render_with_scroll(frame, inner_area, scroll_offset); } } }