Files
cm-dashboard/dashboard/src/app.rs
Christoffer Martinsson bc9015e96b
All checks were successful
Build and Release / build-and-release (push) Successful in 1m21s
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
2025-12-08 19:56:06 +01:00

715 lines
30 KiB
Rust

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<TuiApp>,
terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>,
headless: bool,
initial_commands_sent: std::collections::HashSet<String>,
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 {
pub async fn new(config_path: Option<String>, headless: bool) -> Result<Self> {
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,
title_area: Rect::default(),
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
loop {
// 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 {
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);
}
}
}
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
}
_ => {}
}
}
}
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;
}
}
// 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 {
// 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);
}
}
}
}
// 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);
}
}
// 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();
}
// Render TUI (only if not headless)
if !self.headless {
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);
}
// 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;
}
}
}
}
// Small sleep to prevent excessive CPU usage
tokio::time::sleep(Duration::from_millis(10)).await;
}
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(());
}
// 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<String> {
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 {
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();
}
}
}
}