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
715 lines
30 KiB
Rust
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();
|
|
}
|
|
}
|
|
}
|
|
}
|