Add mouse support and improve terminal resize handling
All checks were successful
Build and Release / build-and-release (push) Successful in 1m21s

- 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
This commit is contained in:
2025-12-08 19:56:06 +01:00
parent aaec8e691c
commit bc9015e96b
8 changed files with 927 additions and 113 deletions

View File

@@ -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<String, HostWidgets>,
/// Current active host
current_host: Option<String>,
pub current_host: Option<String>,
/// Available hosts
available_hosts: Vec<String>,
/// 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<PopupMenu>,
}
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<String> {
&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<ListItem> = 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<String> {
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) {