use anyhow::Result; use crossterm::{ event::{self, EnableMouseCapture, DisableMouseCapture, Event, MouseEvent, MouseEventKind, MouseButton}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{backend::CrosstermBackend, Terminal, layout::Rect}; use std::io; use std::time::{Duration, Instant}; use tracing::{debug, error, info, warn}; use crate::communication::{ZmqConsumer}; use crate::config::DashboardConfig; use crate::metrics::MetricStore; use crate::ui::TuiApp; pub struct Dashboard { zmq_consumer: ZmqConsumer, metric_store: MetricStore, tui_app: Option, terminal: Option>>, headless: bool, initial_commands_sent: std::collections::HashSet, config: DashboardConfig, system_area: Rect, // Store system area for mouse event handling services_area: Rect, // Store services area for mouse event handling } impl Dashboard { pub async fn new(config_path: Option, headless: bool) -> Result { info!("Initializing dashboard"); // Load configuration - try default path if not specified let config = match config_path { Some(path) => DashboardConfig::load_from_file(&path)?, None => { // Try default NixOS config path let default_path = "/etc/cm-dashboard/dashboard.toml"; match DashboardConfig::load_from_file(default_path) { Ok(config) => { info!("Using default config file: {}", default_path); config } Err(e) => { error!("Configuration file is required. Use --config to specify path or ensure {} exists.", default_path); error!("Failed to load default config: {}", e); return Err(anyhow::anyhow!("Missing required configuration file")); } } } }; // Initialize ZMQ consumer let mut zmq_consumer = match ZmqConsumer::new(&config.zmq).await { Ok(consumer) => consumer, Err(e) => { error!("Failed to initialize ZMQ consumer: {}", e); return Err(e); } }; // Try to connect to hosts but don't fail if none are available match zmq_consumer.connect_to_predefined_hosts(&config.hosts).await { Ok(_) => info!("Successfully connected to ZMQ hosts"), Err(e) => { warn!( "Failed to connect to hosts (this is normal if no agents are running): {}", e ); info!("Dashboard will start anyway and connect when agents become available"); } } // Initialize metric store let metric_store = MetricStore::new(10000, 24); // 10k metrics, 24h retention // Initialize TUI components only if not headless let (tui_app, terminal) = if headless { info!("Running in headless mode (no TUI)"); (None, None) } else { // Initialize TUI app let tui_app = TuiApp::new(config.clone()); // Setup terminal if let Err(e) = enable_raw_mode() { error!("Failed to enable raw mode: {}", e); error!( "This usually means the dashboard is being run without a proper terminal (TTY)" ); error!("Try running with --headless flag or in a proper terminal"); return Err(e.into()); } let mut stdout = io::stdout(); if let Err(e) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture) { error!("Failed to enter alternate screen: {}", e); let _ = disable_raw_mode(); return Err(e.into()); } let backend = CrosstermBackend::new(stdout); let terminal = match Terminal::new(backend) { Ok(term) => term, Err(e) => { error!("Failed to create terminal: {}", e); let _ = disable_raw_mode(); return Err(e.into()); } }; (Some(tui_app), Some(terminal)) }; info!("Dashboard initialization complete"); Ok(Self { zmq_consumer, metric_store, tui_app, terminal, headless, initial_commands_sent: std::collections::HashSet::new(), config, system_area: Rect::default(), services_area: Rect::default(), }) } pub async fn run(&mut self) -> Result<()> { info!("Starting dashboard main loop"); let mut last_metrics_check = Instant::now(); let metrics_check_interval = Duration::from_millis(100); // Check for metrics every 100ms let mut last_heartbeat_check = Instant::now(); let heartbeat_check_interval = Duration::from_secs(1); // Check for host connectivity every 1 second let mut needs_render = true; // Track if we need to render loop { // Handle terminal events (keyboard and mouse input) only if not headless if !self.headless { match event::poll(Duration::from_millis(200)) { Ok(true) => { match event::read() { Ok(event) => { if let Some(ref mut tui_app) = self.tui_app { match event { Event::Key(_) => { // Handle keyboard input match tui_app.handle_input(event) { Ok(_) => { needs_render = true; // Check if we should quit if tui_app.should_quit() { info!("Quit requested, exiting dashboard"); break; } } Err(e) => { error!("Error handling input: {}", e); } } } Event::Mouse(mouse_event) => { // Handle mouse events if let Err(e) = self.handle_mouse_event(mouse_event) { error!("Error handling mouse event: {}", e); } needs_render = true; } Event::Resize(_width, _height) => { // Terminal was resized - mark for re-render needs_render = true; } _ => {} } } } Err(e) => { error!("Error reading terminal event: {}", e); break; } } } Ok(false) => {} // No events available (timeout) Err(e) => { error!("Error polling for terminal events: {}", e); break; } } } // Check for new metrics if last_metrics_check.elapsed() >= metrics_check_interval { if let Ok(Some(agent_data)) = self.zmq_consumer.receive_agent_data().await { debug!( "Received agent data from {}", agent_data.hostname ); // Track first contact with host (no command needed - agent sends data every 2s) let is_new_host = !self .initial_commands_sent .contains(&agent_data.hostname); if is_new_host { info!( "First contact with host {} - data will update automatically", agent_data.hostname ); self.initial_commands_sent .insert(agent_data.hostname.clone()); } // Store structured data directly self.metric_store.store_agent_data(agent_data); // Check for agent version mismatches across hosts if let Some((current_version, outdated_hosts)) = self.metric_store.get_version_mismatches() { for outdated_host in &outdated_hosts { warn!("Host {} has outdated agent version (current: {})", outdated_host, current_version); } } // Update TUI with new metrics (only if not headless) if let Some(ref mut tui_app) = self.tui_app { tui_app.update_metrics(&mut self.metric_store); } needs_render = true; // New metrics received, need to render } // Also check for command output messages if let Ok(Some(cmd_output)) = self.zmq_consumer.receive_command_output().await { debug!( "Received command output from {}: {}", cmd_output.hostname, cmd_output.output_line ); // Command output (terminal popup removed - output not displayed) } last_metrics_check = Instant::now(); } // Check for host connectivity changes (heartbeat timeouts) periodically if last_heartbeat_check.elapsed() >= heartbeat_check_interval { let timeout = Duration::from_secs(self.config.zmq.heartbeat_timeout_seconds); // Clean up metrics for offline hosts self.metric_store.cleanup_offline_hosts(timeout); if let Some(ref mut tui_app) = self.tui_app { let connected_hosts = self.metric_store.get_connected_hosts(timeout); tui_app.update_hosts(connected_hosts); } last_heartbeat_check = Instant::now(); needs_render = true; // Heartbeat check happened, may have changed hosts } // Render TUI only when needed (not headless and something changed) if !self.headless && needs_render { if let Some(ref mut terminal) = self.terminal { if let Some(ref mut tui_app) = self.tui_app { // Clear and autoresize terminal to handle any resize events if let Err(e) = terminal.autoresize() { warn!("Error autoresizing terminal: {}", e); } // Render TUI regardless of terminal size if let Err(e) = terminal.draw(|frame| { let (_title_area, system_area, services_area) = tui_app.render(frame, &self.metric_store); self.system_area = system_area; self.services_area = services_area; }) { error!("Error rendering TUI: {}", e); break; } } } needs_render = false; // Reset flag after rendering } } info!("Dashboard main loop ended"); Ok(()) } /// Handle mouse events fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<()> { let x = mouse.column; let y = mouse.row; // Handle popup menu if open let popup_info = if let Some(ref tui_app) = self.tui_app { tui_app.popup_menu.clone().map(|popup| { let hostname = tui_app.current_host.clone(); (popup, hostname) }) } else { None }; if let Some((popup, hostname)) = popup_info { // Calculate popup bounds using screen coordinates let popup_width = 20; let popup_height = 5; // 3 items + 2 borders // Get terminal size let (screen_width, screen_height) = if let Some(ref terminal) = self.terminal { let size = terminal.size().unwrap_or_default(); (size.width, size.height) } else { (80, 24) // fallback }; let popup_x = if popup.x + popup_width < screen_width { popup.x } else { screen_width.saturating_sub(popup_width) }; let popup_y = if popup.y + popup_height < screen_height { popup.y } else { screen_height.saturating_sub(popup_height) }; let popup_area = Rect { x: popup_x, y: popup_y, width: popup_width, height: popup_height, }; // Update selected index on mouse move if matches!(mouse.kind, MouseEventKind::Moved) { if is_in_area(x, y, &popup_area) { let relative_y = y.saturating_sub(popup_y + 1) as usize; // +1 for top border if relative_y < 3 { if let Some(ref mut tui_app) = self.tui_app { if let Some(ref mut popup) = tui_app.popup_menu { popup.selected_index = relative_y; } } } } return Ok(()); } if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { if is_in_area(x, y, &popup_area) { // Click inside popup - execute action let relative_y = y.saturating_sub(popup_y + 1) as usize; // +1 for top border if relative_y < 3 { // Execute the selected action self.execute_service_action(relative_y, &popup.service_name, hostname.as_deref())?; } // Close popup after action if let Some(ref mut tui_app) = self.tui_app { tui_app.popup_menu = None; } return Ok(()); } else { // Click outside popup - close it if let Some(ref mut tui_app) = self.tui_app { tui_app.popup_menu = None; } return Ok(()); } } // Any other event while popup is open - don't process panels return Ok(()); } // Determine which panel the mouse is over let in_system_area = is_in_area(x, y, &self.system_area); let in_services_area = is_in_area(x, y, &self.services_area); if !in_system_area && !in_services_area { return Ok(()); } // Handle mouse events match mouse.kind { MouseEventKind::ScrollDown => { if in_system_area { // Scroll down in system panel if let Some(ref mut tui_app) = self.tui_app { if let Some(hostname) = tui_app.current_host.clone() { let host_widgets = tui_app.get_or_create_host_widgets(&hostname); let visible_height = self.system_area.height as usize; let total_lines = host_widgets.system_widget.get_total_lines(); host_widgets.system_widget.scroll_down(visible_height, total_lines); } } } else if in_services_area { // Scroll down in services panel if let Some(ref mut tui_app) = self.tui_app { if let Some(hostname) = tui_app.current_host.clone() { let host_widgets = tui_app.get_or_create_host_widgets(&hostname); // Calculate visible height (panel height - borders and header) let visible_height = self.services_area.height.saturating_sub(3) as usize; host_widgets.services_widget.scroll_down(visible_height); } } } } MouseEventKind::ScrollUp => { if in_system_area { // Scroll up in system panel if let Some(ref mut tui_app) = self.tui_app { if let Some(hostname) = tui_app.current_host.clone() { let host_widgets = tui_app.get_or_create_host_widgets(&hostname); host_widgets.system_widget.scroll_up(); } } } else if in_services_area { // Scroll up in services panel if let Some(ref mut tui_app) = self.tui_app { if let Some(hostname) = tui_app.current_host.clone() { let host_widgets = tui_app.get_or_create_host_widgets(&hostname); host_widgets.services_widget.scroll_up(); } } } } MouseEventKind::Down(button) => { // Only handle clicks in services area (not system area) if !in_services_area { return Ok(()); } if let Some(ref mut tui_app) = self.tui_app { // Handle service click // The services area includes a border, so we need to account for that let relative_y = y.saturating_sub(self.services_area.y + 2) as usize; // +2 for border and header if let Some(hostname) = tui_app.current_host.clone() { let host_widgets = tui_app.get_or_create_host_widgets(&hostname); // Account for scroll offset - the clicked line is relative to viewport let display_line_index = host_widgets.services_widget.scroll_offset + relative_y; // Map display line to parent service index if let Some(parent_index) = host_widgets.services_widget.display_line_to_parent_index(display_line_index) { // Set the selected index to the clicked parent service host_widgets.services_widget.selected_index = parent_index; match button { MouseButton::Left => { // Left click just selects the service debug!("Left-clicked service at display line {} (parent index: {})", display_line_index, parent_index); } MouseButton::Right => { // Right click opens context menu debug!("Right-clicked service at display line {} (parent index: {})", display_line_index, parent_index); // Get the service name for the popup if let Some(service_name) = host_widgets.services_widget.get_selected_service() { tui_app.popup_menu = Some(crate::ui::PopupMenu { service_name, x, y, selected_index: 0, }); } } _ => {} } } } } } _ => {} } Ok(()) } /// Execute service action from popup menu fn execute_service_action(&self, action_index: usize, service_name: &str, hostname: Option<&str>) -> Result<()> { let Some(hostname) = hostname else { return Ok(()); }; let connection_ip = self.get_connection_ip(hostname); match action_index { 0 => { // Start Service let service_start_command = format!( "echo 'Starting service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} start {}'\"", service_name, hostname, self.config.ssh.rebuild_user, connection_ip, self.config.ssh.service_manage_cmd, service_name ); std::process::Command::new("tmux") .arg("split-window") .arg("-v") .arg("-p") .arg("30") .arg(&service_start_command) .spawn() .ok(); } 1 => { // Stop Service let service_stop_command = format!( "echo 'Stopping service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} stop {}'\"", service_name, hostname, self.config.ssh.rebuild_user, connection_ip, self.config.ssh.service_manage_cmd, service_name ); std::process::Command::new("tmux") .arg("split-window") .arg("-v") .arg("-p") .arg("30") .arg(&service_stop_command) .spawn() .ok(); } 2 => { // View Logs let logs_command = format!( "ssh -tt {}@{} '{} logs {}'", self.config.ssh.rebuild_user, connection_ip, self.config.ssh.service_manage_cmd, service_name ); std::process::Command::new("tmux") .arg("split-window") .arg("-v") .arg("-p") .arg("30") .arg(&logs_command) .spawn() .ok(); } _ => {} } Ok(()) } /// Get connection IP for a host fn get_connection_ip(&self, hostname: &str) -> String { self.config .hosts .get(hostname) .and_then(|h| h.ip.clone()) .unwrap_or_else(|| hostname.to_string()) } } /// Check if a point is within a rectangular area fn is_in_area(x: u16, y: u16, area: &Rect) -> bool { x >= area.x && x < area.x.saturating_add(area.width) && y >= area.y && y < area.y.saturating_add(area.height) } impl Drop for Dashboard { fn drop(&mut self) { // Restore terminal (only if not headless) if !self.headless { let _ = disable_raw_mode(); if let Some(ref mut terminal) = self.terminal { let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture); let _ = terminal.show_cursor(); } } } }