From bc9015e96bb9220234322fa1d199db0e82063ebf Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Mon, 8 Dec 2025 19:56:06 +0100 Subject: [PATCH] Add mouse support and improve terminal resize handling - Add mouse click support for hostname selection in title bar - Fix right-aligned hostname position calculation - Add mouse scroll support for both panels - Add mouse click to select service rows - Add right-click popup menu for service actions (Start/Stop/Logs) - Add hover highlighting for popup menu items - Improve terminal resize crash protection with 90x15 minimum size - Add "Host:" prefix and separators to status bar - Move NixOS metrics from system panel to status bar - Change "... X more below" indicator to use border color - Remove service name from popup menu title --- Cargo.lock | 6 +- agent/Cargo.toml | 2 +- dashboard/Cargo.toml | 2 +- dashboard/src/app.rs | 464 +++++++++++++++++++++++++-- dashboard/src/ui/mod.rs | 227 ++++++++++--- dashboard/src/ui/widgets/services.rs | 142 +++++++- dashboard/src/ui/widgets/system.rs | 195 ++++++++--- shared/Cargo.toml | 2 +- 8 files changed, 927 insertions(+), 113 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f4753c..a1cfa80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.258" +version = "0.1.259" dependencies = [ "anyhow", "chrono", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.258" +version = "0.1.259" dependencies = [ "anyhow", "async-trait", @@ -325,7 +325,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.258" +version = "0.1.259" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 0c5748c..5683fbe 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.259" +version = "0.1.260" edition = "2021" [dependencies] diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 8ea3ec6..dd2db54 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.259" +version = "0.1.260" edition = "2021" [dependencies] diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs index 76710fe..0f1e43f 100644 --- a/dashboard/src/app.rs +++ b/dashboard/src/app.rs @@ -1,10 +1,10 @@ use anyhow::Result; use crossterm::{ - event::{self}, + event::{self, EnableMouseCapture, DisableMouseCapture, Event, MouseEvent, MouseEventKind, MouseButton}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; -use ratatui::{backend::CrosstermBackend, Terminal}; +use ratatui::{backend::CrosstermBackend, Terminal, layout::Rect}; use std::io; use std::time::{Duration, Instant}; use tracing::{debug, error, info, warn}; @@ -22,6 +22,9 @@ pub struct Dashboard { headless: bool, initial_commands_sent: std::collections::HashSet, config: DashboardConfig, + title_area: Rect, // Store title area for mouse event handling + system_area: Rect, // Store system area for mouse event handling + services_area: Rect, // Store services area for mouse event handling } impl Dashboard { @@ -92,7 +95,7 @@ impl Dashboard { } let mut stdout = io::stdout(); - if let Err(e) = execute!(stdout, EnterAlternateScreen) { + if let Err(e) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture) { error!("Failed to enter alternate screen: {}", e); let _ = disable_raw_mode(); return Err(e.into()); @@ -121,6 +124,9 @@ impl Dashboard { headless, initial_commands_sent: std::collections::HashSet::new(), config, + title_area: Rect::default(), + system_area: Rect::default(), + services_area: Rect::default(), }) } @@ -134,25 +140,40 @@ impl Dashboard { let heartbeat_check_interval = Duration::from_secs(1); // Check for host connectivity every 1 second loop { - // Handle terminal events (keyboard input) only if not headless + // Handle terminal events (keyboard and mouse input) only if not headless if !self.headless { match event::poll(Duration::from_millis(50)) { Ok(true) => { match event::read() { Ok(event) => { if let Some(ref mut tui_app) = self.tui_app { - // Handle input - match tui_app.handle_input(event) { - Ok(_) => { - // Check if we should quit - if tui_app.should_quit() { - info!("Quit requested, exiting dashboard"); - break; + match event { + Event::Key(_) => { + // Handle keyboard input + match tui_app.handle_input(event) { + Ok(_) => { + // Check if we should quit + if tui_app.should_quit() { + info!("Quit requested, exiting dashboard"); + break; + } + } + Err(e) => { + error!("Error handling input: {}", e); + } } } - 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); + } } + Event::Resize(_width, _height) => { + // Terminal was resized - just continue and re-render + // The next render will automatically use the new size + } + _ => {} } } } @@ -172,8 +193,29 @@ impl Dashboard { // Render UI immediately after handling input for responsive feedback if let Some(ref mut terminal) = self.terminal { if let Some(ref mut tui_app) = self.tui_app { - if let Err(e) = terminal.draw(|frame| { - tui_app.render(frame, &self.metric_store); + // Clear and autoresize terminal to handle any resize events + if let Err(e) = terminal.autoresize() { + warn!("Error autoresizing terminal: {}", e); + } + + // Check minimum terminal size to prevent panics + let size = terminal.size().unwrap_or_default(); + if size.width < 90 || size.height < 15 { + // Terminal too small, show error message + let msg_text = format!("Terminal too small\n\nMinimum: 90x15\nCurrent: {}x{}", size.width, size.height); + let _ = terminal.draw(|frame| { + use ratatui::widgets::{Paragraph, Block, Borders}; + use ratatui::layout::Alignment; + let msg = Paragraph::new(msg_text.clone()) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(msg, frame.size()); + }); + } else if let Err(e) = terminal.draw(|frame| { + let (title_area, system_area, services_area) = tui_app.render(frame, &self.metric_store); + self.title_area = title_area; + self.system_area = system_area; + self.services_area = services_area; }) { error!("Error rendering TUI after input: {}", e); } @@ -251,8 +293,29 @@ impl Dashboard { if !self.headless { if let Some(ref mut terminal) = self.terminal { if let Some(ref mut tui_app) = self.tui_app { - if let Err(e) = terminal.draw(|frame| { - tui_app.render(frame, &self.metric_store); + // Clear and autoresize terminal to handle any resize events + if let Err(e) = terminal.autoresize() { + warn!("Error autoresizing terminal: {}", e); + } + + // Check minimum terminal size to prevent panics + let size = terminal.size().unwrap_or_default(); + if size.width < 90 || size.height < 15 { + // Terminal too small, show error message + let msg_text = format!("Terminal too small\n\nMinimum: 90x15\nCurrent: {}x{}", size.width, size.height); + let _ = terminal.draw(|frame| { + use ratatui::widgets::{Paragraph, Block, Borders}; + use ratatui::layout::Alignment; + let msg = Paragraph::new(msg_text.clone()) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(msg, frame.size()); + }); + } else if let Err(e) = terminal.draw(|frame| { + let (title_area, system_area, services_area) = tui_app.render(frame, &self.metric_store); + self.title_area = title_area; + self.system_area = system_area; + self.services_area = services_area; }) { error!("Error rendering TUI: {}", e); break; @@ -269,7 +332,372 @@ impl Dashboard { 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(()); + } + + // Check for title bar clicks (host selection) + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + if is_in_area(x, y, &self.title_area) { + // Click in title bar - check if it's on a hostname + // The title bar has "cm-dashboard vX.X.X" on the left (22 chars) + // Then hostnames start at position 22 + if x >= 22 { + let hostname = self.find_hostname_at_position(x); + if let Some(host) = hostname { + if let Some(ref mut tui_app) = self.tui_app { + tui_app.switch_to_host(&host); + } + } + } + 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(()); + } + + // Calculate which service was clicked + // 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(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); + + // 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()) + } + + /// Find which hostname is at a given x position in the title bar + fn find_hostname_at_position(&self, x: u16) -> Option { + if let Some(ref tui_app) = self.tui_app { + // The hosts are RIGHT-ALIGNED in chunks[1]! + // Need to calculate total width first, then right-align + + // Get terminal width + let terminal_width = if let Some(ref terminal) = self.terminal { + terminal.size().unwrap_or_default().width + } else { + 80 + }; + + // Calculate total width of all host text + let mut total_width = 0_u16; + for (i, host) in tui_app.get_available_hosts().iter().enumerate() { + if i > 0 { + total_width += 1; // space between hosts + } + total_width += 2; // icon + space + let is_selected = Some(host) == tui_app.current_host.as_ref(); + if is_selected { + total_width += 1 + host.len() as u16 + 1; // [hostname] + } else { + total_width += host.len() as u16; + } + } + total_width += 1; // right padding + + // chunks[1] starts at 22, has width of (terminal_width - 22) + let chunk_width = terminal_width - 22; + + // Right-aligned position + let hosts_start_x = if total_width < chunk_width { + 22 + (chunk_width - total_width) + } else { + 22 + }; + + // Now calculate positions starting from hosts_start_x + let mut pos = hosts_start_x; + + for (i, host) in tui_app.get_available_hosts().iter().enumerate() { + if i > 0 { + pos += 1; // " " + } + + let host_start = pos; + pos += 2; // "● " + + let is_selected = Some(host) == tui_app.current_host.as_ref(); + if is_selected { + pos += 1 + host.len() as u16 + 1; // [hostname] + } else { + pos += host.len() as u16; + } + + if x >= host_start && x < pos { + return Some(host.clone()); + } + } + } + None + } +} + +/// 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 + area.width + && y >= area.y && y < area.y + area.height } impl Drop for Dashboard { @@ -278,7 +706,7 @@ impl Drop for Dashboard { if !self.headless { let _ = disable_raw_mode(); if let Some(ref mut terminal) = self.terminal { - let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen); + let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture); let _ = terminal.show_cursor(); } } diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index 4ea78ac..849cfa3 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -17,7 +17,7 @@ pub mod widgets; use crate::config::DashboardConfig; use crate::metrics::MetricStore; use cm_dashboard_shared::Status; -use theme::{Components, Layout as ThemeLayout, Theme, Typography}; +use theme::{Components, Layout as ThemeLayout, Theme}; use widgets::{ServicesWidget, SystemWidget, Widget}; @@ -47,12 +47,21 @@ impl HostWidgets { } +/// Popup menu state +#[derive(Clone)] +pub struct PopupMenu { + pub service_name: String, + pub x: u16, + pub y: u16, + pub selected_index: usize, +} + /// Main TUI application pub struct TuiApp { /// Widget states per host (hostname -> HostWidgets) host_widgets: HashMap, /// Current active host - current_host: Option, + pub current_host: Option, /// Available hosts available_hosts: Vec, /// Host index for navigation @@ -65,6 +74,8 @@ pub struct TuiApp { config: DashboardConfig, /// Cached localhost hostname to avoid repeated system calls localhost: String, + /// Active popup menu (if any) + pub popup_menu: Option, } impl TuiApp { @@ -79,6 +90,7 @@ impl TuiApp { user_navigated_away: false, config, localhost, + popup_menu: None, }; // Sort predefined hosts @@ -93,7 +105,7 @@ impl TuiApp { } /// Get or create host widgets for the given hostname - fn get_or_create_host_widgets(&mut self, hostname: &str) -> &mut HostWidgets { + pub fn get_or_create_host_widgets(&mut self, hostname: &str) -> &mut HostWidgets { self.host_widgets .entry(hostname.to_string()) .or_insert_with(HostWidgets::new) @@ -159,6 +171,14 @@ impl TuiApp { /// Handle keyboard input pub fn handle_input(&mut self, event: Event) -> Result<()> { if let Event::Key(key) = event { + // Close popup on Escape + if matches!(key.code, KeyCode::Esc) { + if self.popup_menu.is_some() { + self.popup_menu = None; + return Ok(()); + } + } + match key.code { KeyCode::Char('q') => { self.should_quit = true; @@ -363,6 +383,23 @@ impl TuiApp { Ok(()) } + /// Switch to a specific host by name + pub fn switch_to_host(&mut self, hostname: &str) { + if let Some(index) = self.available_hosts.iter().position(|h| h == hostname) { + self.host_index = index; + self.current_host = Some(hostname.to_string()); + + // Check if user navigated away from localhost + if hostname != &self.localhost { + self.user_navigated_away = true; + } else { + self.user_navigated_away = false; // User navigated back to localhost + } + + info!("Switched to host: {}", hostname); + } + } + /// Navigate between hosts fn navigate_host(&mut self, direction: i32) { if self.available_hosts.is_empty() { @@ -381,7 +418,7 @@ impl TuiApp { } self.current_host = Some(self.available_hosts[self.host_index].clone()); - + // Check if user navigated away from localhost if let Some(ref current) = self.current_host { if current != &self.localhost { @@ -390,7 +427,7 @@ impl TuiApp { self.user_navigated_away = false; // User navigated back to localhost } } - + info!("Switched to host: {}", self.current_host.as_ref().unwrap()); } @@ -408,6 +445,10 @@ impl TuiApp { None } + /// Get the list of available hosts + pub fn get_available_hosts(&self) -> &Vec { + &self.available_hosts + } /// Should quit application pub fn should_quit(&self) -> bool { @@ -421,7 +462,7 @@ impl TuiApp { /// Render the dashboard (real btop-style multi-panel layout) - pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) { + pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) -> (Rect, Rect, Rect) { let size = frame.size(); // Clear background to true black like btop @@ -461,8 +502,8 @@ impl TuiApp { if current_host_offline { self.render_offline_host_message(frame, main_chunks[1]); self.render_btop_title(frame, main_chunks[0], metric_store); - self.render_statusbar(frame, main_chunks[2]); - return; + self.render_statusbar(frame, main_chunks[2], metric_store); + return (main_chunks[0], Rect::default(), Rect::default()); // Return title area and empty areas when offline } // Left side: system panel only (full height) @@ -475,20 +516,29 @@ impl TuiApp { self.render_btop_title(frame, main_chunks[0], metric_store); // Render system panel - self.render_system_panel(frame, left_chunks[0], metric_store); + let system_area = left_chunks[0]; + self.render_system_panel(frame, system_area, metric_store); // Render services widget for current host + let services_area = content_chunks[1]; if let Some(hostname) = self.current_host.clone() { let is_focused = true; // Always show service selection let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets .services_widget - .render(frame, content_chunks[1], is_focused); // Services takes full right side + .render(frame, services_area, is_focused); // Services takes full right side } // Render statusbar at the bottom - self.render_statusbar(frame, main_chunks[2]); // main_chunks[2] is the statusbar area + self.render_statusbar(frame, main_chunks[2], metric_store); + // Render popup menu on top of everything if active + if let Some(ref popup) = self.popup_menu { + self.render_popup_menu(frame, popup); + } + + // Return all areas for mouse event handling + (main_chunks[0], system_area, services_area) } /// Render btop-style minimal title with host status colors @@ -611,36 +661,137 @@ impl TuiApp { } } - /// 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); - + /// Render popup menu for service actions + fn render_popup_menu(&self, frame: &mut Frame, popup: &PopupMenu) { + use ratatui::widgets::{Block, Borders, Clear, List, ListItem}; + use ratatui::style::{Color, Modifier}; + + // Menu items + let items = vec![ + "Start Service", + "Stop Service", + "View Logs", + ]; + + // Calculate popup size + let width = 20; + let height = items.len() as u16 + 2; // +2 for borders + + // Position popup near click location, but keep it on screen + let screen_width = frame.size().width; + let screen_height = frame.size().height; + + let x = if popup.x + width < screen_width { + popup.x + } else { + screen_width.saturating_sub(width) + }; + + let y = if popup.y + height < screen_height { + popup.y + } else { + screen_height.saturating_sub(height) + }; + + let popup_area = Rect { + x, + y, + width, + height, + }; + + // Create menu items with selection highlight + let menu_items: Vec = items + .iter() + .enumerate() + .map(|(i, item)| { + let style = if i == popup.selected_index { + Style::default() + .fg(Color::Black) + .bg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Theme::primary_text()) + }; + ListItem::new(*item).style(style) + }) + .collect(); + + let menu_list = List::new(menu_items) + .block( + Block::default() + .borders(Borders::ALL) + .style(Style::default().bg(Theme::background()).fg(Theme::primary_text())) + ); + + // Clear the area and render menu + frame.render_widget(Clear, popup_area); + frame.render_widget(menu_list, popup_area); + } + + /// Render statusbar with host and client IPs + fn render_statusbar(&self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) { + use ratatui::text::{Line, Span}; + use ratatui::widgets::Paragraph; + + // Get current host info + let (hostname_str, host_ip, build_version, agent_version) = if let Some(hostname) = &self.current_host { + // Get the connection IP (the IP dashboard uses to connect to the agent) + let ip = if let Some(host_details) = self.config.hosts.get(hostname) { + host_details.get_connection_ip(hostname) + } else { + hostname.clone() + }; + + // Get build and agent versions from system widget + let (build, agent) = if let Some(host_widgets) = self.host_widgets.get(hostname) { + let build = host_widgets.system_widget.get_build_version().unwrap_or("N/A".to_string()); + let agent = host_widgets.system_widget.get_agent_version().unwrap_or("N/A".to_string()); + (build, agent) + } else { + ("N/A".to_string(), "N/A".to_string()) + }; + + (hostname.clone(), ip, build, agent) + } else { + ("None".to_string(), "N/A".to_string(), "N/A".to_string(), "N/A".to_string()) + }; + + let left_text = format!("Host: {} | {} | Build:{} | Agent:{}", hostname_str, host_ip, build_version, agent_version); + + // Get dashboard local IP + let dashboard_ip = Self::get_local_ip(); + let right_text = format!("Dashboard: {}", dashboard_ip); + + // Calculate spacing to push right text to the right (accounting for 1 char left padding) + let spacing = area.width as usize - left_text.len() - right_text.len() - 2; // -2 for left padding + let spacing_str = " ".repeat(spacing.max(1)); + + let line = Line::from(vec![ + Span::raw(" "), // 1 char left padding + Span::styled(left_text, Style::default().fg(Theme::border())), + Span::raw(spacing_str), + Span::styled(right_text, Style::default().fg(Theme::border())), + ]); + + let statusbar = Paragraph::new(line); 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: Host".to_string()); - shortcuts.push("↑↓/jk: Select".to_string()); - shortcuts.push("r: Rebuild".to_string()); - shortcuts.push("B: Backup".to_string()); - shortcuts.push("s/S: Start/Stop".to_string()); - shortcuts.push("L: Logs".to_string()); - shortcuts.push("t: Terminal".to_string()); - shortcuts.push("w: Wake".to_string()); - - // Always show quit - shortcuts.push("q: Quit".to_string()); - - shortcuts + /// Get local IP address of the dashboard + fn get_local_ip() -> String { + use std::net::UdpSocket; + + // Try to get local IP by creating a UDP socket + // This doesn't actually send data, just determines routing + if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") { + if socket.connect("8.8.8.8:80").is_ok() { + if let Ok(addr) = socket.local_addr() { + return addr.ip().to_string(); + } + } + } + "N/A".to_string() } fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) { diff --git a/dashboard/src/ui/widgets/services.rs b/dashboard/src/ui/widgets/services.rs index 4c04d79..061b4a6 100644 --- a/dashboard/src/ui/widgets/services.rs +++ b/dashboard/src/ui/widgets/services.rs @@ -91,7 +91,11 @@ pub struct ServicesWidget { /// Last update indicator has_data: bool, /// Currently selected service index (for navigation cursor) - selected_index: usize, + pub selected_index: usize, + /// Scroll offset for viewport (which display line is at the top) + pub scroll_offset: usize, + /// Last rendered viewport height (for accurate scroll bounds) + last_viewport_height: usize, } #[derive(Clone)] @@ -112,6 +116,8 @@ impl ServicesWidget { status: Status::Unknown, has_data: false, selected_index: 0, + scroll_offset: 0, + last_viewport_height: 0, } } @@ -366,6 +372,81 @@ impl ServicesWidget { self.parent_services.len() } + /// Get total display lines (parent services + sub-services) + pub fn get_total_display_lines(&self) -> usize { + let mut total = self.parent_services.len(); + for sub_list in self.sub_services.values() { + total += sub_list.len(); + } + total + } + + /// Scroll down by one line + pub fn scroll_down(&mut self, _visible_height: usize) { + let total_lines = self.get_total_display_lines(); + + // Use last_viewport_height if available (more accurate), otherwise can't scroll + let viewport_height = if self.last_viewport_height > 0 { + self.last_viewport_height + } else { + return; // Can't scroll without knowing viewport size + }; + + // Calculate exact max scroll to match render logic + // Stop scrolling when all remaining content fits in viewport + // At scroll_offset N: remaining = total_lines - N + // We can show all when: remaining <= viewport_height + // So max_scroll is when: total_lines - max_scroll = viewport_height + // Therefore: max_scroll = total_lines - viewport_height (but at least 0) + let max_scroll = total_lines.saturating_sub(viewport_height); + + debug!("Scroll down: total={}, viewport={}, offset={}, max={}", total_lines, viewport_height, self.scroll_offset, max_scroll); + + if self.scroll_offset < max_scroll { + self.scroll_offset += 1; + } + } + + /// Scroll up by one line + pub fn scroll_up(&mut self) { + if self.scroll_offset > 0 { + self.scroll_offset -= 1; + } + } + + + /// Map a display line index to a parent service index (returns None if clicked on sub-service) + pub fn display_line_to_parent_index(&self, display_line_index: usize) -> Option { + // Build the same display list to map line index to parent service index + let mut parent_index = 0; + let mut line_index = 0; + + let mut parent_services: Vec<_> = self.parent_services.iter().collect(); + parent_services.sort_by(|(a, _), (b, _)| a.cmp(b)); + + for (parent_name, _) in parent_services { + // Check if this line index matches a parent service + if line_index == display_line_index { + return Some(parent_index); + } + line_index += 1; + + // Add sub-services for this parent (if any) + if let Some(sub_list) = self.sub_services.get(parent_name) { + for _ in sub_list { + if line_index == display_line_index { + // Clicked on a sub-service - return None (can't select sub-services) + return None; + } + line_index += 1; + } + } + + parent_index += 1; + } + + None + } /// Calculate which parent service index corresponds to a display line index fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize { @@ -542,12 +623,23 @@ impl ServicesWidget { self.selected_index = total_count - 1; } + // Clamp scroll offset to valid range after update + // This prevents scroll issues when switching between hosts or when service count changes + let total_display_lines = self.get_total_display_lines(); + if total_display_lines == 0 { + self.scroll_offset = 0; + } else if self.scroll_offset >= total_display_lines { + // Clamp to max valid value, not reset to 0 + self.scroll_offset = total_display_lines.saturating_sub(1); + } + debug!( - "Services widget updated: {} parent services, {} sub-service groups, total={}, selected={}, status={:?}", + "Services widget updated: {} parent services, {} sub-service groups, total={}, selected={}, scroll={}, status={:?}", self.parent_services.len(), self.sub_services.len(), total_count, self.selected_index, + self.scroll_offset, self.status ); } @@ -639,20 +731,46 @@ impl ServicesWidget { // Show only what fits, with "X more below" if needed let available_lines = area.height as usize; let total_lines = display_lines.len(); - - // Reserve one line for "X more below" if needed - let lines_for_content = if total_lines > available_lines { + + // Store viewport height for accurate scroll calculations + self.last_viewport_height = available_lines; + + // Clamp scroll_offset to valid range based on current viewport and content + // This handles dynamic viewport size changes + let max_valid_scroll = total_lines.saturating_sub(available_lines); + if self.scroll_offset > max_valid_scroll { + self.scroll_offset = max_valid_scroll; + } + + // Calculate how many lines remain after scroll offset + let remaining_lines = total_lines.saturating_sub(self.scroll_offset); + + debug!("Render: total={}, viewport={}, offset={}, max={}, remaining={}", + total_lines, available_lines, self.scroll_offset, max_valid_scroll, remaining_lines); + + // Check if all remaining content fits in viewport + let will_show_more_below = remaining_lines > available_lines; + + // Reserve one line for "X more below" only if we can't fit everything + let lines_for_content = if will_show_more_below { available_lines.saturating_sub(1) } else { - available_lines + available_lines.min(remaining_lines) }; - + + // Apply scroll offset let visible_lines: Vec<_> = display_lines .iter() + .skip(self.scroll_offset) .take(lines_for_content) .collect(); - - let hidden_below = total_lines.saturating_sub(lines_for_content); + + // Only calculate hidden_below if we actually reserved space for the message + let hidden_below = if will_show_more_below { + remaining_lines.saturating_sub(lines_for_content) + } else { + 0 + }; let lines_to_show = visible_lines.len(); @@ -666,8 +784,8 @@ impl ServicesWidget { for (i, (line_text, line_status, is_sub, sub_info)) in visible_lines.iter().enumerate() { - let actual_index = i; // Simple index since we're not scrolling - + let actual_index = self.scroll_offset + i; // Account for scroll offset + // Only parent services can be selected - calculate parent service index let is_selected = if !*is_sub { // This is a parent service - count how many parent services came before this one @@ -712,7 +830,7 @@ impl ServicesWidget { // Show "X more below" message if content was truncated if hidden_below > 0 { let more_text = format!("... {} more below", hidden_below); - let more_para = Paragraph::new(more_text).style(Typography::muted()); + let more_para = Paragraph::new(more_text).style(Style::default().fg(Theme::border())); frame.render_widget(more_para, service_chunks[lines_to_show]); } } diff --git a/dashboard/src/ui/widgets/system.rs b/dashboard/src/ui/widgets/system.rs index fef829f..0c7ec9b 100644 --- a/dashboard/src/ui/widgets/system.rs +++ b/dashboard/src/ui/widgets/system.rs @@ -1,12 +1,13 @@ use cm_dashboard_shared::Status; use ratatui::{ layout::Rect, + style::Style, text::{Line, Span, Text}, widgets::Paragraph, Frame, }; -use crate::ui::theme::{StatusIcons, Typography}; +use crate::ui::theme::{StatusIcons, Theme, Typography}; /// System widget displaying NixOS info, Network, CPU, RAM, and Storage in unified layout #[derive(Clone)] @@ -49,6 +50,11 @@ pub struct SystemWidget { // Overall status has_data: bool, + + // Scroll offset for viewport + pub scroll_offset: usize, + /// Last rendered viewport height (for accurate scroll bounds) + last_viewport_height: usize, } #[derive(Clone)] @@ -110,6 +116,8 @@ impl SystemWidget { backup_repository_status: Status::Unknown, backup_disks: Vec::new(), has_data: false, + scroll_offset: 0, + last_viewport_height: 0, } } @@ -153,6 +161,16 @@ impl SystemWidget { pub fn _get_agent_hash(&self) -> Option<&String> { self.agent_hash.as_ref() } + + /// Get the build version + pub fn get_build_version(&self) -> Option { + self.nixos_build.clone() + } + + /// Get the agent version + pub fn get_agent_version(&self) -> Option { + self.agent_hash.clone() + } } use super::Widget; @@ -206,6 +224,16 @@ impl Widget for SystemWidget { self.backup_repositories = backup.repositories.clone(); self.backup_repository_status = backup.repository_status; self.backup_disks = backup.disks.clone(); + + // Clamp scroll offset to valid range after update + // This prevents scroll issues when switching between hosts + let total_lines = self.get_total_lines(); + if total_lines == 0 { + self.scroll_offset = 0; + } else if self.scroll_offset >= total_lines { + // Clamp to max valid value, not reset to 0 + self.scroll_offset = total_lines.saturating_sub(1); + } } } @@ -781,23 +809,90 @@ impl SystemWidget { } /// 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(); + /// Scroll down by one line + pub fn scroll_down(&mut self, _visible_height: usize, _total_lines: usize) { + let total_lines = self.get_total_lines(); - // 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()) - ])); + // Use last_viewport_height if available (more accurate), otherwise can't scroll + let viewport_height = if self.last_viewport_height > 0 { + self.last_viewport_height + } else { + return; // Can't scroll without knowing viewport size + }; + + // Max scroll should allow us to see all remaining content + // When scroll_offset + viewport_height >= total_lines, we can see everything + let max_scroll = if total_lines > viewport_height { + total_lines - viewport_height + } else { + 0 + }; + + if self.scroll_offset < max_scroll { + self.scroll_offset += 1; + } + } + + /// Scroll up by one line + pub fn scroll_up(&mut self) { + if self.scroll_offset > 0 { + self.scroll_offset -= 1; + } + } + + /// Get total line count (needs to be calculated before rendering) + pub fn get_total_lines(&self) -> usize { + let mut count = 0; + + // CPU section (2+ lines for load/cstate, +1 if has model/cores) + count += 2; + if self.cpu_model_name.is_some() || self.cpu_core_count.is_some() { + count += 1; + } + + // RAM section (1 + tmpfs mounts) + count += 2; + count += self.tmpfs_mounts.len(); + + // Network section + if !self.network_interfaces.is_empty() { + count += 1; // Header + // Count network lines (would need to mirror render_network logic) + for iface in &self.network_interfaces { + count += 1; // Interface name + count += iface.ipv4_addresses.len(); + count += iface.ipv6_addresses.len(); + } + } + + // Storage section + count += 1; // Header + for pool in &self.storage_pools { + count += 1; // Pool header + count += pool.drives.len(); + count += pool.data_drives.len(); + count += pool.parity_drives.len(); + count += pool.filesystems.len(); + } + + // Backup section + if !self.backup_repositories.is_empty() || !self.backup_disks.is_empty() { + count += 1; // Header + if !self.backup_repositories.is_empty() { + count += 1; // Repo header + count += self.backup_repositories.len(); + } + count += self.backup_disks.len() * 3; // Each disk has 3 lines + } + + count + } + + pub fn render(&mut self, frame: &mut Frame, area: Rect, _hostname: &str, _config: Option<&crate::config::DashboardConfig>) { + // Store viewport height for accurate scroll calculations + self.last_viewport_height = area.height as usize; + + let mut lines = Vec::new(); // CPU section lines.push(Line::from(vec![ @@ -905,29 +1000,51 @@ impl SystemWidget { // 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); + + // Clamp scroll_offset to valid range based on current viewport and content + // This handles dynamic viewport size changes + let max_valid_scroll = total_lines.saturating_sub(available_height); + let clamped_scroll = self.scroll_offset.min(max_valid_scroll); + + // Calculate how many lines remain after scroll offset + let remaining_lines = total_lines.saturating_sub(clamped_scroll); + + // Check if all remaining content fits in viewport + let will_show_more_below = remaining_lines > available_height; + + // Reserve one line for "X more below" only if we can't fit everything + let lines_for_content = if will_show_more_below { + available_height.saturating_sub(1) } else { - // All content fits and no scroll offset, render normally - let paragraph = Paragraph::new(Text::from(lines)); - frame.render_widget(paragraph, area); + available_height.min(remaining_lines) + }; + + // Apply clamped scroll offset and take only what fits + let mut visible_lines: Vec = lines + .into_iter() + .skip(clamped_scroll) + .take(lines_for_content) + .collect(); + + // Note: we don't update self.scroll_offset here due to borrow checker constraints + // It will be clamped on next render if still out of bounds + + // Only calculate hidden_below if we actually reserved space for the message + let hidden_below = if will_show_more_below { + remaining_lines.saturating_sub(lines_for_content) + } else { + 0 + }; + + // Add "more below" message if needed + if hidden_below > 0 { + let more_line = Line::from(vec![ + Span::styled(format!("... {} more below", hidden_below), Style::default().fg(Theme::border())) + ]); + visible_lines.push(more_line); } + + let paragraph = Paragraph::new(Text::from(visible_lines)); + frame.render_widget(paragraph, area); } } \ No newline at end of file diff --git a/shared/Cargo.toml b/shared/Cargo.toml index f9e476b..d31e316 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.259" +version = "0.1.260" edition = "2021" [dependencies]