All checks were successful
Build and Release / build-and-release (push) Successful in 1m10s
751 lines
30 KiB
Rust
751 lines
30 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::{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,
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// 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 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);
|
|
|
|
// Update ZMQ stats
|
|
if let Some(zmq_stats) = metric_store.get_zmq_stats(&hostname) {
|
|
host_widgets.system_widget.update_zmq_stats(
|
|
zmq_stats.packets_received,
|
|
zmq_stats.last_packet_age_secs
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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
|
|
self.render_system_panel(frame, left_chunks[0], metric_store);
|
|
|
|
// 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(22), Constraint::Min(0)])
|
|
.split(area);
|
|
|
|
// Left side: "cm-dashboard" text with version
|
|
let title_text = format!(" cm-dashboard v{}", env!("CARGO_PKG_VERSION"));
|
|
let left_span = Span::styled(
|
|
&title_text,
|
|
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 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 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));
|
|
}
|
|
}
|
|
|
|
|
|
/// 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)
|
|
}
|
|
}
|