All checks were successful
Build and Release / build-and-release (push) Successful in 1m12s
Apply same logic used for inactive status to pending status. Pending services now contribute to OK count instead of being ignored, preventing blue title bar during service transitions.
852 lines
34 KiB
Rust
852 lines
34 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, Typography};
|
|
use widgets::{BackupWidget, 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,
|
|
/// Backup widget state
|
|
pub backup_widget: BackupWidget,
|
|
/// 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(),
|
|
backup_widget: BackupWidget::new(),
|
|
last_update: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// Main TUI application
|
|
pub struct TuiApp {
|
|
/// Widget states per host (hostname -> HostWidgets)
|
|
host_widgets: HashMap<String, HostWidgets>,
|
|
/// Current active host
|
|
current_host: Option<String>,
|
|
/// Available hosts
|
|
available_hosts: Vec<String>,
|
|
/// Host index for navigation
|
|
host_index: usize,
|
|
/// 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,
|
|
}
|
|
|
|
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(),
|
|
host_index: 0,
|
|
should_quit: false,
|
|
user_navigated_away: false,
|
|
config,
|
|
localhost,
|
|
};
|
|
|
|
// 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
|
|
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 metrics from store (only for current host)
|
|
pub fn update_metrics(&mut self, metric_store: &MetricStore) {
|
|
|
|
// Check for rebuild completion by agent hash change
|
|
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
// Only update widgets if we have metrics for this host
|
|
let all_metrics = metric_store.get_metrics_for_host(&hostname);
|
|
if !all_metrics.is_empty() {
|
|
// Single pass metric categorization for better performance
|
|
let mut cpu_metrics = Vec::new();
|
|
let mut memory_metrics = Vec::new();
|
|
let mut service_metrics = Vec::new();
|
|
let mut backup_metrics = Vec::new();
|
|
let mut nixos_metrics = Vec::new();
|
|
let mut disk_metrics = Vec::new();
|
|
|
|
for metric in all_metrics {
|
|
if metric.name.starts_with("cpu_")
|
|
|| metric.name.contains("c_state_")
|
|
|| metric.name.starts_with("process_top_") {
|
|
cpu_metrics.push(metric);
|
|
} else if metric.name.starts_with("memory_") || metric.name.starts_with("disk_tmp_") {
|
|
memory_metrics.push(metric);
|
|
} else if metric.name.starts_with("service_") {
|
|
service_metrics.push(metric);
|
|
} else if metric.name.starts_with("backup_") {
|
|
backup_metrics.push(metric);
|
|
} else if metric.name == "system_nixos_build" || metric.name == "system_active_users" || metric.name == "agent_version" {
|
|
nixos_metrics.push(metric);
|
|
} else if metric.name.starts_with("disk_") {
|
|
disk_metrics.push(metric);
|
|
}
|
|
}
|
|
|
|
|
|
// Now get host widgets and update them
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
|
|
// Collect all system metrics (CPU, memory, NixOS, disk/storage)
|
|
let mut system_metrics = cpu_metrics;
|
|
system_metrics.extend(memory_metrics);
|
|
system_metrics.extend(nixos_metrics);
|
|
system_metrics.extend(disk_metrics);
|
|
|
|
host_widgets.system_widget.update_from_metrics(&system_metrics);
|
|
host_widgets
|
|
.services_widget
|
|
.update_from_metrics(&service_metrics);
|
|
host_widgets
|
|
.backup_widget
|
|
.update_from_metrics(&backup_metrics);
|
|
|
|
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;
|
|
|
|
// 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());
|
|
// Find the actual index of localhost in the sorted list
|
|
self.host_index = self.available_hosts.iter().position(|h| h == &self.localhost).unwrap_or(0);
|
|
} 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());
|
|
self.host_index = 0;
|
|
} else if let Some(ref current) = self.current_host {
|
|
if !self.available_hosts.contains(current) {
|
|
// Current host disconnected - select first available and reset navigation flag
|
|
self.current_host = Some(self.available_hosts[0].clone());
|
|
self.host_index = 0;
|
|
self.user_navigated_away = false; // Reset since we're forced to switch
|
|
} else if let Some(index) = self.available_hosts.iter().position(|h| h == current) {
|
|
// Update index for current host
|
|
self.host_index = index;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle keyboard input
|
|
pub fn handle_input(&mut self, event: Event) -> Result<()> {
|
|
if let Event::Key(key) = event {
|
|
match key.code {
|
|
KeyCode::Char('q') => {
|
|
self.should_quit = true;
|
|
}
|
|
KeyCode::Left => {
|
|
self.navigate_host(-1);
|
|
}
|
|
KeyCode::Right => {
|
|
self.navigate_host(1);
|
|
}
|
|
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 cycles to next host
|
|
self.navigate_host(1);
|
|
}
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
// Move service selection up
|
|
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') => {
|
|
// Move service selection down
|
|
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);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Navigate between hosts
|
|
fn navigate_host(&mut self, direction: i32) {
|
|
if self.available_hosts.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let len = self.available_hosts.len();
|
|
if direction > 0 {
|
|
self.host_index = (self.host_index + 1) % len;
|
|
} else {
|
|
self.host_index = if self.host_index == 0 {
|
|
len - 1
|
|
} else {
|
|
self.host_index - 1
|
|
};
|
|
}
|
|
|
|
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 {
|
|
self.user_navigated_away = true;
|
|
} else {
|
|
self.user_navigated_away = false; // User navigated back to localhost
|
|
}
|
|
}
|
|
|
|
info!("Switched to host: {}", self.current_host.as_ref().unwrap());
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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
|
|
}
|
|
|
|
|
|
/// 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) {
|
|
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
|
|
};
|
|
|
|
// If host is offline, render wake-up message instead of panels
|
|
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;
|
|
}
|
|
|
|
// Check if backup panel should be shown
|
|
let show_backup = if let Some(hostname) = self.current_host.clone() {
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.backup_widget.has_data()
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Left side: dynamic layout based on backup data availability
|
|
let left_chunks = if show_backup {
|
|
// Show both system and backup panels
|
|
ratatui::layout::Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage(ThemeLayout::SYSTEM_PANEL_HEIGHT), // System section
|
|
Constraint::Percentage(ThemeLayout::BACKUP_PANEL_HEIGHT), // Backup section
|
|
])
|
|
.split(content_chunks[0])
|
|
} else {
|
|
// Show only system panel (full height)
|
|
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 new panel layout
|
|
self.render_system_panel(frame, left_chunks[0], metric_store);
|
|
if show_backup && left_chunks.len() > 1 {
|
|
self.render_backup_panel(frame, left_chunks[1]);
|
|
}
|
|
|
|
// Render services widget for current host
|
|
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 statusbar at the bottom
|
|
self.render_statusbar(frame, main_chunks[2]); // main_chunks[2] is the statusbar 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};
|
|
use theme::StatusIcons;
|
|
|
|
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);
|
|
|
|
// Split the title bar into left and right sections
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Length(15), Constraint::Min(0)])
|
|
.split(area);
|
|
|
|
// Left side: "cm-dashboard" text
|
|
let left_span = Span::styled(
|
|
" cm-dashboard",
|
|
Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD)
|
|
);
|
|
let left_title = Paragraph::new(Line::from(vec![left_span]))
|
|
.style(Style::default().bg(background_color));
|
|
frame.render_widget(left_title, chunks[0]);
|
|
|
|
// Right side: hosts with status indicators
|
|
let mut host_spans = Vec::new();
|
|
|
|
for (i, host) in self.available_hosts.iter().enumerate() {
|
|
if i > 0 {
|
|
host_spans.push(Span::styled(
|
|
" ",
|
|
Style::default().fg(Theme::background()).bg(background_color)
|
|
));
|
|
}
|
|
|
|
// Always show normal status icon based on metrics (no command status at host level)
|
|
let host_status = self.calculate_host_status(host, metric_store);
|
|
let status_icon = StatusIcons::get_icon(host_status);
|
|
|
|
// Add status icon with background color as foreground against status background
|
|
host_spans.push(Span::styled(
|
|
format!("{} ", status_icon),
|
|
Style::default().fg(Theme::background()).bg(background_color),
|
|
));
|
|
|
|
if Some(host) == self.current_host.as_ref() {
|
|
// Selected host in bold background color against status background
|
|
host_spans.push(Span::styled(
|
|
host.clone(),
|
|
Style::default()
|
|
.fg(Theme::background())
|
|
.bg(background_color)
|
|
.add_modifier(Modifier::BOLD),
|
|
));
|
|
} else {
|
|
// Other hosts in normal background color against status background
|
|
host_spans.push(Span::styled(
|
|
host.clone(),
|
|
Style::default().fg(Theme::background()).bg(background_color),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Add right padding
|
|
host_spans.push(Span::styled(
|
|
" ",
|
|
Style::default().fg(Theme::background()).bg(background_color)
|
|
));
|
|
|
|
let host_line = Line::from(host_spans);
|
|
let host_title = Paragraph::new(vec![host_line])
|
|
.style(Style::default().bg(background_color))
|
|
.alignment(ratatui::layout::Alignment::Right);
|
|
frame.render_widget(host_title, chunks[1]);
|
|
}
|
|
|
|
/// Calculate overall status for a host based on its metrics
|
|
fn calculate_host_status(&self, hostname: &str, metric_store: &MetricStore) -> Status {
|
|
let metrics = metric_store.get_metrics_for_host(hostname);
|
|
|
|
if metrics.is_empty() {
|
|
return Status::Offline;
|
|
}
|
|
|
|
// First check if we have the aggregated host status summary from the agent
|
|
if let Some(host_summary_metric) = metric_store.get_metric(hostname, "host_status_summary") {
|
|
return host_summary_metric.status;
|
|
}
|
|
|
|
// Fallback to old aggregation logic with proper Pending handling
|
|
let mut has_critical = false;
|
|
let mut has_warning = false;
|
|
let mut ok_count = 0;
|
|
|
|
for metric in &metrics {
|
|
match metric.status {
|
|
Status::Critical => has_critical = true,
|
|
Status::Warning => has_warning = true,
|
|
Status::Pending => ok_count += 1, // Treat pending as OK for aggregation
|
|
Status::Ok => ok_count += 1,
|
|
Status::Inactive => ok_count += 1, // Treat inactive as OK for aggregation
|
|
Status::Unknown => {}, // Ignore unknown for aggregation
|
|
Status::Offline => {}, // Ignore offline for aggregation
|
|
}
|
|
}
|
|
|
|
// Priority order: Critical > Warning > Ok > Unknown (no Pending)
|
|
if has_critical {
|
|
Status::Critical
|
|
} else if has_warning {
|
|
Status::Warning
|
|
} else if ok_count > 0 {
|
|
Status::Ok
|
|
} else {
|
|
Status::Unknown
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
|
|
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
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) {
|
|
let backup_block = Components::widget_block("backup");
|
|
let inner_area = backup_block.inner(area);
|
|
frame.render_widget(backup_block, area);
|
|
|
|
// Get current host widgets for backup widget
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.backup_widget.render(frame, inner_area);
|
|
}
|
|
}
|
|
|
|
/// Render offline host message with wake-up option
|
|
fn render_offline_host_message(&self, frame: &mut Frame, area: Rect) {
|
|
use ratatui::layout::Alignment;
|
|
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(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()).add_modifier(Modifier::BOLD),
|
|
)));
|
|
} else {
|
|
lines.push(Line::from(Span::styled(
|
|
"No MAC address configured - cannot wake up",
|
|
Style::default().fg(Theme::muted_text()),
|
|
)));
|
|
}
|
|
|
|
// Create centered message
|
|
let message = Paragraph::new(lines)
|
|
.block(Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Theme::muted_text()))
|
|
.title(" Offline Host ")
|
|
.title_style(Style::default().fg(Theme::muted_text()).add_modifier(Modifier::BOLD)))
|
|
.style(Style::default().bg(Theme::background()).fg(Theme::primary_text()))
|
|
.alignment(Alignment::Center);
|
|
|
|
// Center the message in the available area
|
|
let popup_area = ratatui::layout::Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage(40),
|
|
Constraint::Length(6),
|
|
Constraint::Percentage(40),
|
|
])
|
|
.split(area)[1];
|
|
|
|
let popup_area = ratatui::layout::Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage(25),
|
|
Constraint::Percentage(50),
|
|
Constraint::Percentage(25),
|
|
])
|
|
.split(popup_area)[1];
|
|
|
|
frame.render_widget(message, popup_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)
|
|
}
|
|
}
|