All checks were successful
Build and Release / build-and-release (push) Successful in 1m37s
906 lines
36 KiB
Rust
906 lines
36 KiB
Rust
use anyhow::Result;
|
|
use crossterm::event::{Event, KeyCode};
|
|
use ratatui::{
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
style::Style,
|
|
widgets::{Block, Paragraph},
|
|
Frame,
|
|
};
|
|
use std::collections::HashMap;
|
|
use std::time::Instant;
|
|
use tracing::info;
|
|
use wake_on_lan::MagicPacket;
|
|
|
|
pub mod theme;
|
|
pub mod widgets;
|
|
|
|
use crate::config::DashboardConfig;
|
|
use crate::metrics::MetricStore;
|
|
use cm_dashboard_shared::Status;
|
|
use theme::{Components, Layout as ThemeLayout, Theme};
|
|
use widgets::{HostsWidget, ServicesWidget, SystemWidget, Widget};
|
|
|
|
|
|
|
|
|
|
/// Panel types for focus management
|
|
|
|
/// Widget states for a specific host
|
|
#[derive(Clone)]
|
|
pub struct HostWidgets {
|
|
/// System widget state (includes CPU, Memory, NixOS info, Storage)
|
|
pub system_widget: SystemWidget,
|
|
/// Services widget state
|
|
pub services_widget: ServicesWidget,
|
|
/// Last update time for this host
|
|
pub last_update: Option<Instant>,
|
|
}
|
|
|
|
impl HostWidgets {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
system_widget: SystemWidget::new(),
|
|
services_widget: ServicesWidget::new(),
|
|
last_update: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// 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
|
|
pub current_host: Option<String>,
|
|
/// Available hosts
|
|
available_hosts: Vec<String>,
|
|
/// Should quit application
|
|
should_quit: bool,
|
|
/// Track if user manually navigated away from localhost
|
|
user_navigated_away: bool,
|
|
/// Dashboard configuration
|
|
config: DashboardConfig,
|
|
/// Cached localhost hostname to avoid repeated system calls
|
|
localhost: String,
|
|
/// Active popup menu (if any)
|
|
pub popup_menu: Option<PopupMenu>,
|
|
/// Focus on hosts tab (false = Services, true = Hosts)
|
|
pub focus_hosts: bool,
|
|
/// Hosts widget for navigation and rendering
|
|
pub hosts_widget: HostsWidget,
|
|
}
|
|
|
|
impl TuiApp {
|
|
pub fn new(config: DashboardConfig) -> Self {
|
|
let localhost = gethostname::gethostname().to_string_lossy().to_string();
|
|
let mut app = Self {
|
|
host_widgets: HashMap::new(),
|
|
current_host: None,
|
|
available_hosts: config.hosts.keys().cloned().collect(),
|
|
should_quit: false,
|
|
user_navigated_away: false,
|
|
config,
|
|
localhost,
|
|
popup_menu: None,
|
|
focus_hosts: true, // Start with Hosts tab focused by default
|
|
hosts_widget: HostsWidget::new(),
|
|
};
|
|
|
|
// Sort predefined hosts
|
|
app.available_hosts.sort();
|
|
|
|
// Initialize with first host if available
|
|
if !app.available_hosts.is_empty() {
|
|
app.current_host = Some(app.available_hosts[0].clone());
|
|
}
|
|
|
|
app
|
|
}
|
|
|
|
/// Get or create host widgets for the given hostname
|
|
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)
|
|
}
|
|
|
|
/// Update widgets with structured data from store (only for current host)
|
|
pub fn update_metrics(&mut self, metric_store: &mut MetricStore) {
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
// Get structured data for this host
|
|
if let Some(agent_data) = metric_store.get_agent_data(&hostname) {
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
|
|
// Update all widgets with structured data directly
|
|
host_widgets.system_widget.update_from_agent_data(agent_data);
|
|
host_widgets.services_widget.update_from_agent_data(agent_data);
|
|
|
|
host_widgets.last_update = Some(Instant::now());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update available hosts with localhost prioritization
|
|
pub fn update_hosts(&mut self, discovered_hosts: Vec<String>) {
|
|
// Start with configured hosts (always visible)
|
|
let mut all_hosts: Vec<String> = self.config.hosts.keys().cloned().collect();
|
|
|
|
// Add any discovered hosts that aren't already configured
|
|
for host in discovered_hosts {
|
|
if !all_hosts.contains(&host) {
|
|
all_hosts.push(host);
|
|
}
|
|
}
|
|
|
|
|
|
all_hosts.sort();
|
|
self.available_hosts = all_hosts;
|
|
|
|
// Track if we had a host before this update
|
|
let had_host = self.current_host.is_some();
|
|
|
|
// Get the current hostname (localhost) for auto-selection
|
|
if !self.available_hosts.is_empty() {
|
|
if self.available_hosts.contains(&self.localhost) && !self.user_navigated_away {
|
|
// Localhost is available and user hasn't navigated away - switch to it
|
|
self.current_host = Some(self.localhost.clone());
|
|
// Initialize selector bar on first host selection
|
|
if !had_host {
|
|
let index = self.available_hosts.iter().position(|h| h == &self.localhost).unwrap_or(0);
|
|
self.hosts_widget.set_selected_index(index, self.available_hosts.len());
|
|
}
|
|
} else if self.current_host.is_none() {
|
|
// No current host - select first available (which is localhost if available)
|
|
self.current_host = Some(self.available_hosts[0].clone());
|
|
// Initialize selector bar
|
|
self.hosts_widget.set_selected_index(0, self.available_hosts.len());
|
|
} else if let Some(ref current) = self.current_host {
|
|
if !self.available_hosts.contains(current) {
|
|
// Current host disconnected - FORCE switch to first available
|
|
self.current_host = Some(self.available_hosts[0].clone());
|
|
// Reset selector bar since we're forcing a host change
|
|
self.hosts_widget.set_selected_index(0, self.available_hosts.len());
|
|
self.user_navigated_away = false; // Reset since we're forced to switch
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
KeyCode::Char('r') => {
|
|
// System rebuild command - works on any panel for current host
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let connection_ip = self.get_connection_ip(&hostname);
|
|
// Create command that shows logo, rebuilds, and waits for user input
|
|
let logo_and_rebuild = format!(
|
|
"echo 'Rebuilding system: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"",
|
|
hostname,
|
|
connection_ip,
|
|
self.config.ssh.rebuild_user,
|
|
connection_ip,
|
|
self.config.ssh.rebuild_cmd
|
|
);
|
|
|
|
std::process::Command::new("tmux")
|
|
.arg("split-window")
|
|
.arg("-v")
|
|
.arg("-p")
|
|
.arg("30")
|
|
.arg(&logo_and_rebuild)
|
|
.spawn()
|
|
.ok(); // Ignore errors, tmux will handle them
|
|
}
|
|
}
|
|
KeyCode::Char('B') => {
|
|
// Backup command - works on any panel for current host
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let connection_ip = self.get_connection_ip(&hostname);
|
|
// Create command that shows logo, runs backup, and waits for user input
|
|
let logo_and_backup = format!(
|
|
"echo 'Running backup: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"",
|
|
hostname,
|
|
connection_ip,
|
|
self.config.ssh.rebuild_user,
|
|
connection_ip,
|
|
format!("{} start borgbackup", self.config.ssh.service_manage_cmd)
|
|
);
|
|
|
|
std::process::Command::new("tmux")
|
|
.arg("split-window")
|
|
.arg("-v")
|
|
.arg("-p")
|
|
.arg("30")
|
|
.arg(&logo_and_backup)
|
|
.spawn()
|
|
.ok(); // Ignore errors, tmux will handle them
|
|
}
|
|
}
|
|
KeyCode::Char('s') => {
|
|
// Service start command via SSH with progress display
|
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
|
let connection_ip = self.get_connection_ip(&hostname);
|
|
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(); // Ignore errors, tmux will handle them
|
|
}
|
|
}
|
|
KeyCode::Char('S') => {
|
|
// Service stop command via SSH with progress display
|
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
|
let connection_ip = self.get_connection_ip(&hostname);
|
|
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(); // Ignore errors, tmux will handle them
|
|
}
|
|
}
|
|
KeyCode::Char('L') => {
|
|
// Show service logs via service-manage script in tmux split window
|
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
|
let connection_ip = self.get_connection_ip(&hostname);
|
|
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(); // Ignore errors, tmux will handle them
|
|
}
|
|
}
|
|
KeyCode::Char('w') => {
|
|
// Wake on LAN for offline hosts
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
// Check if host has MAC address configured
|
|
if let Some(host_details) = self.config.hosts.get(&hostname) {
|
|
if let Some(mac_address) = &host_details.mac_address {
|
|
// Parse MAC address and send WoL packet
|
|
let mac_bytes = Self::parse_mac_address(mac_address);
|
|
match mac_bytes {
|
|
Ok(mac) => {
|
|
match MagicPacket::new(&mac).send() {
|
|
Ok(_) => {
|
|
info!("WakeOnLAN packet sent successfully to {} ({})", hostname, mac_address);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Failed to send WakeOnLAN packet to {}: {}", hostname, e);
|
|
}
|
|
}
|
|
}
|
|
Err(_) => {
|
|
tracing::error!("Invalid MAC address format for {}: {}", hostname, mac_address);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Char('t') => {
|
|
// Open SSH terminal session in tmux window
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let connection_ip = self.get_connection_ip(&hostname);
|
|
let ssh_command = format!(
|
|
"echo 'Opening SSH terminal to: {}' && ssh -tt {}@{}",
|
|
hostname,
|
|
self.config.ssh.rebuild_user,
|
|
connection_ip
|
|
);
|
|
|
|
std::process::Command::new("tmux")
|
|
.arg("split-window")
|
|
.arg("-v")
|
|
.arg("-p")
|
|
.arg("30") // Use 30% like other commands
|
|
.arg(&ssh_command)
|
|
.spawn()
|
|
.ok(); // Ignore errors, tmux will handle them
|
|
}
|
|
}
|
|
KeyCode::Tab => {
|
|
// Tab toggles between Services and Hosts tabs
|
|
self.focus_hosts = !self.focus_hosts;
|
|
}
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
if self.focus_hosts {
|
|
// Move blue selector bar up when in Hosts tab
|
|
self.hosts_widget.select_previous();
|
|
} else {
|
|
// Move service selection up when in Services tab
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.services_widget.select_previous();
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
if self.focus_hosts {
|
|
// Move blue selector bar down when in Hosts tab
|
|
let total_hosts = self.available_hosts.len();
|
|
self.hosts_widget.select_next(total_hosts);
|
|
} else {
|
|
// Move service selection down when in Services tab
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let total_services = {
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.services_widget.get_total_services_count()
|
|
};
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.services_widget.select_next(total_services);
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if self.focus_hosts {
|
|
// Enter key switches to the selected host
|
|
let selected_idx = self.hosts_widget.get_selected_index();
|
|
if selected_idx < self.available_hosts.len() {
|
|
let selected_host = self.available_hosts[selected_idx].clone();
|
|
self.switch_to_host(&selected_host);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
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) {
|
|
// Update selector bar position
|
|
self.hosts_widget.set_selected_index(index, self.available_hosts.len());
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// Handle mouse click on tab title area
|
|
pub fn handle_tab_click(&mut self, x: u16, area: &Rect) {
|
|
// Tab title format: "hosts | services"
|
|
// Calculate positions relative to area start
|
|
let title_start_x = area.x + 1; // +1 for left border
|
|
|
|
// "hosts | services"
|
|
// 0123456789...
|
|
let hosts_start = title_start_x;
|
|
let hosts_end = hosts_start + 5; // "hosts" is 5 chars
|
|
let services_start = hosts_end + 3; // After " | "
|
|
let services_end = services_start + 8; // "services" is 8 chars
|
|
|
|
if x >= hosts_start && x < hosts_end {
|
|
// Clicked on "hosts"
|
|
self.focus_hosts = true;
|
|
} else if x >= services_start && x < services_end {
|
|
// Clicked on "services"
|
|
self.focus_hosts = false;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Get the currently selected service name from the services widget
|
|
fn get_selected_service(&self) -> Option<String> {
|
|
if let Some(hostname) = &self.current_host {
|
|
if let Some(host_widgets) = self.host_widgets.get(hostname) {
|
|
return host_widgets.services_widget.get_selected_service();
|
|
}
|
|
}
|
|
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 {
|
|
self.should_quit
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Render the dashboard (real btop-style multi-panel layout)
|
|
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
|
|
frame.render_widget(
|
|
Block::default().style(Style::default().bg(Theme::background())),
|
|
size,
|
|
);
|
|
|
|
// Create real btop-style layout: multi-panel with borders
|
|
// Three-section layout: title bar, main content, statusbar
|
|
let main_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(1), // Title bar
|
|
Constraint::Min(0), // Main content area
|
|
Constraint::Length(1), // Statusbar
|
|
])
|
|
.split(size);
|
|
|
|
// New layout: left panels | right services (100% height)
|
|
let content_chunks = ratatui::layout::Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage(ThemeLayout::LEFT_PANEL_WIDTH), // Left side: system, backup
|
|
Constraint::Percentage(ThemeLayout::RIGHT_PANEL_WIDTH), // Right side: services (100% height)
|
|
])
|
|
.split(main_chunks[1]); // main_chunks[1] is now the content area (between title and statusbar)
|
|
|
|
// Check if current host is offline
|
|
let current_host_offline = if let Some(hostname) = self.current_host.clone() {
|
|
self.calculate_host_status(&hostname, metric_store) == Status::Offline
|
|
} else {
|
|
true // No host selected is considered offline
|
|
};
|
|
|
|
// Left side: system panel only (full height)
|
|
let left_chunks = ratatui::layout::Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Percentage(100)]) // System section takes full height
|
|
.split(content_chunks[0]);
|
|
|
|
// Render title bar
|
|
self.render_btop_title(frame, main_chunks[0], metric_store);
|
|
|
|
// Render system panel or offline message in system panel area
|
|
let system_area = left_chunks[0];
|
|
if current_host_offline {
|
|
self.render_offline_host_message(frame, system_area);
|
|
} else {
|
|
self.render_system_panel(frame, system_area, metric_store);
|
|
}
|
|
|
|
// Render right panel with tabs (Services | Hosts)
|
|
let services_area = content_chunks[1];
|
|
self.render_right_panel_with_tabs(frame, services_area, metric_store);
|
|
|
|
// Render statusbar at the bottom
|
|
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
|
|
fn render_btop_title(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
|
use ratatui::style::Modifier;
|
|
use ratatui::text::{Line, Span};
|
|
|
|
if self.available_hosts.is_empty() {
|
|
let title_text = "cm-dashboard • no hosts discovered";
|
|
let title = Paragraph::new(title_text)
|
|
.style(Style::default().fg(Theme::background()).bg(Theme::status_color(Status::Unknown)));
|
|
frame.render_widget(title, area);
|
|
return;
|
|
}
|
|
|
|
// Calculate worst-case status across all hosts (excluding offline)
|
|
let mut worst_status = Status::Ok;
|
|
for host in &self.available_hosts {
|
|
let host_status = self.calculate_host_status(host, metric_store);
|
|
// Don't include offline hosts in status aggregation
|
|
if host_status != Status::Offline {
|
|
worst_status = Status::aggregate(&[worst_status, host_status]);
|
|
}
|
|
}
|
|
|
|
// Use the worst status color as background
|
|
let background_color = Theme::status_color(worst_status);
|
|
|
|
// Single line title bar showing dashboard name (left) and dashboard IP (right)
|
|
let left_text = format!(" cm-dashboard v{}", env!("CARGO_PKG_VERSION"));
|
|
|
|
// Get dashboard local IP for right side
|
|
let dashboard_ip = Self::get_local_ip();
|
|
let right_text = format!("{} ", dashboard_ip);
|
|
|
|
// Calculate spacing to push right text to the right
|
|
let total_text_len = left_text.len() + right_text.len();
|
|
let spacing = (area.width as usize).saturating_sub(total_text_len).max(1);
|
|
let spacing_str = " ".repeat(spacing);
|
|
|
|
let title = Paragraph::new(Line::from(vec![
|
|
Span::styled(
|
|
left_text,
|
|
Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD)
|
|
),
|
|
Span::styled(
|
|
spacing_str,
|
|
Style::default().bg(background_color)
|
|
),
|
|
Span::styled(
|
|
right_text,
|
|
Style::default().fg(Theme::background()).bg(background_color)
|
|
),
|
|
]))
|
|
.style(Style::default().bg(background_color));
|
|
frame.render_widget(title, area);
|
|
}
|
|
|
|
/// Calculate overall status for a host based on its structured data
|
|
fn calculate_host_status(&self, hostname: &str, metric_store: &MetricStore) -> Status {
|
|
// Check if we have structured data for this host
|
|
if let Some(_agent_data) = metric_store.get_agent_data(hostname) {
|
|
// Return OK since we have data
|
|
Status::Ok
|
|
} else {
|
|
Status::Offline
|
|
}
|
|
}
|
|
|
|
/// 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, kernel_version, 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 kernel, build and agent versions from system widget
|
|
let (kernel, build, agent) = if let Some(host_widgets) = self.host_widgets.get(hostname) {
|
|
let kernel = host_widgets.system_widget.get_kernel_version().unwrap_or("N/A".to_string());
|
|
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());
|
|
(kernel, build, agent)
|
|
} else {
|
|
("N/A".to_string(), "N/A".to_string(), "N/A".to_string())
|
|
};
|
|
|
|
(hostname.clone(), ip, kernel, build, agent)
|
|
} else {
|
|
("None".to_string(), "N/A".to_string(), "N/A".to_string(), "N/A".to_string(), "N/A".to_string())
|
|
};
|
|
|
|
let left_text = format!(" Host: {} | {} | {}", hostname_str, host_ip, kernel_version);
|
|
let right_text = format!("Build:{} | Agent:{} ", build_version, agent_version);
|
|
|
|
// Calculate spacing to push right text to the right
|
|
let total_text_len = left_text.len() + right_text.len();
|
|
let spacing = (area.width as usize).saturating_sub(total_text_len).max(1);
|
|
let spacing_str = " ".repeat(spacing);
|
|
|
|
let line = Line::from(vec![
|
|
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 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) {
|
|
let system_block = Components::widget_block("system");
|
|
let inner_area = system_block.inner(area);
|
|
frame.render_widget(system_block, area);
|
|
// Get current host widgets, create if none exist
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
// Clone the config to avoid borrowing issues
|
|
let config = self.config.clone();
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.system_widget.render(frame, inner_area, &hostname, Some(&config));
|
|
}
|
|
}
|
|
|
|
|
|
/// Render right panel with tabs (hosts | services)
|
|
fn render_right_panel_with_tabs(&mut self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
|
use ratatui::style::Modifier;
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders};
|
|
|
|
// Build tab title with bold styling for active tab (like cm-player)
|
|
let hosts_style = if self.focus_hosts {
|
|
Style::default().fg(Theme::border_title()).add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default().fg(Theme::border_title())
|
|
};
|
|
|
|
let services_style = if !self.focus_hosts {
|
|
Style::default().fg(Theme::border_title()).add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default().fg(Theme::border_title())
|
|
};
|
|
|
|
let title = Line::from(vec![
|
|
Span::styled("hosts", hosts_style),
|
|
Span::raw(" | "),
|
|
Span::styled("services", services_style),
|
|
]);
|
|
|
|
// Create ONE block with tab title (like cm-player)
|
|
let main_block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(title.clone())
|
|
.style(Style::default().fg(Theme::border()).bg(Theme::background()));
|
|
|
|
let inner_area = main_block.inner(area);
|
|
frame.render_widget(main_block, area);
|
|
|
|
// Render appropriate content based on active tab
|
|
if self.focus_hosts {
|
|
// Render hosts list (no additional borders)
|
|
let localhost = self.localhost.clone();
|
|
let current_host = self.current_host.as_deref();
|
|
self.hosts_widget.render(
|
|
frame,
|
|
inner_area,
|
|
&self.available_hosts,
|
|
&localhost,
|
|
current_host,
|
|
metric_store,
|
|
|hostname, store| {
|
|
// Inline calculate_host_status logic
|
|
if store.get_agent_data(hostname).is_some() {
|
|
Status::Ok
|
|
} else {
|
|
Status::Offline
|
|
}
|
|
},
|
|
true, // Always focused when visible
|
|
);
|
|
} else {
|
|
// Render services for current host (no additional borders - just content!)
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let is_focused = true;
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.services_widget.render_content(frame, inner_area, is_focused);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// Render offline host message in system panel area
|
|
fn render_offline_host_message(&self, frame: &mut Frame, area: Rect) {
|
|
use ratatui::style::Modifier;
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
|
|
|
// Get hostname for message
|
|
let hostname = self.current_host.as_ref()
|
|
.map(|h| h.as_str())
|
|
.unwrap_or("Unknown");
|
|
|
|
// Check if host has MAC address for wake-on-LAN
|
|
let has_mac = self.current_host.as_ref()
|
|
.and_then(|hostname| self.config.hosts.get(hostname))
|
|
.and_then(|details| details.mac_address.as_ref())
|
|
.is_some();
|
|
|
|
// Create message content
|
|
let mut lines = vec![
|
|
Line::from(""),
|
|
Line::from(Span::styled(
|
|
format!(" Host '{}' is offline", hostname),
|
|
Style::default().fg(Theme::muted_text()).add_modifier(Modifier::BOLD),
|
|
)),
|
|
Line::from(""),
|
|
];
|
|
|
|
if has_mac {
|
|
lines.push(Line::from(Span::styled(
|
|
" Press 'w' to wake up host",
|
|
Style::default().fg(Theme::primary_text()),
|
|
)));
|
|
} else {
|
|
lines.push(Line::from(Span::styled(
|
|
" No MAC address configured",
|
|
Style::default().fg(Theme::muted_text()),
|
|
)));
|
|
}
|
|
|
|
// Render message in system panel with border
|
|
let message = Paragraph::new(lines)
|
|
.block(Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Theme::muted_text()))
|
|
.title(" Offline ")
|
|
.title_style(Style::default().fg(Theme::muted_text()).add_modifier(Modifier::BOLD)))
|
|
.style(Style::default().bg(Theme::background()).fg(Theme::primary_text()));
|
|
|
|
frame.render_widget(message, area);
|
|
}
|
|
|
|
/// Parse MAC address string (e.g., "AA:BB:CC:DD:EE:FF") to [u8; 6]
|
|
/// Get the connection IP for a hostname based on host configuration
|
|
fn get_connection_ip(&self, hostname: &str) -> String {
|
|
if let Some(host_details) = self.config.hosts.get(hostname) {
|
|
host_details.get_connection_ip(hostname)
|
|
} else {
|
|
hostname.to_string()
|
|
}
|
|
}
|
|
|
|
fn parse_mac_address(mac_str: &str) -> Result<[u8; 6], &'static str> {
|
|
let parts: Vec<&str> = mac_str.split(':').collect();
|
|
if parts.len() != 6 {
|
|
return Err("MAC address must have 6 parts separated by colons");
|
|
}
|
|
|
|
let mut mac = [0u8; 6];
|
|
for (i, part) in parts.iter().enumerate() {
|
|
match u8::from_str_radix(part, 16) {
|
|
Ok(byte) => mac[i] = byte,
|
|
Err(_) => return Err("Invalid hexadecimal byte in MAC address"),
|
|
}
|
|
}
|
|
Ok(mac)
|
|
}
|
|
}
|