From 6179bd51a70b77fedb7a184ddb6fa878574a759d Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Fri, 31 Oct 2025 09:03:01 +0100 Subject: [PATCH] Implement WakeOnLAN functionality with simplified configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Status::Offline enum variant for disconnected hosts - All configured hosts now always visible showing offline status when disconnected - Add WakeOnLAN support using wake-on-lan Rust crate - Implement w key binding to wake offline hosts with MAC addresses - Simplify configuration to single [hosts] section with MAC addresses only - Change critical status icon from ◯ to ! for better visibility - Add proper MAC address parsing and error handling - Silent WakeOnLAN operation with logging for success/failure Configuration format: [hosts] hostname = { mac_address = "AA:BB:CC:DD:EE:FF" } --- Cargo.lock | 13 ++++- agent/Cargo.toml | 2 +- agent/src/collectors/backup.rs | 2 + dashboard/Cargo.toml | 5 +- dashboard/src/app.rs | 4 +- dashboard/src/config/mod.rs | 13 ++--- dashboard/src/ui/mod.rs | 84 ++++++++++++++++++++++++---- dashboard/src/ui/theme.rs | 5 +- dashboard/src/ui/widgets/services.rs | 1 + shared/Cargo.toml | 2 +- shared/src/metrics.rs | 11 ++++ 11 files changed, 112 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 706d6b5..98a4eae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.52" +version = "0.1.54" dependencies = [ "anyhow", "chrono", @@ -286,12 +286,13 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "wake-on-lan", "zmq", ] [[package]] name = "cm-dashboard-agent" -version = "0.1.52" +version = "0.1.54" dependencies = [ "anyhow", "async-trait", @@ -314,7 +315,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.52" +version = "0.1.54" dependencies = [ "chrono", "serde", @@ -2064,6 +2065,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wake-on-lan" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ccf60b60ad7e5b1b37372c5134cbcab4db0706c231d212e0c643a077462bc8f" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 255cf45..6a0c606 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.53" +version = "0.1.54" edition = "2021" [dependencies] diff --git a/agent/src/collectors/backup.rs b/agent/src/collectors/backup.rs index 5229342..ad3ec76 100644 --- a/agent/src/collectors/backup.rs +++ b/agent/src/collectors/backup.rs @@ -140,6 +140,7 @@ impl Collector for BackupCollector { Status::Warning => "warning".to_string(), Status::Critical => "critical".to_string(), Status::Unknown => "unknown".to_string(), + Status::Offline => "offline".to_string(), }), status: overall_status, timestamp, @@ -202,6 +203,7 @@ impl Collector for BackupCollector { Status::Warning => "warning".to_string(), Status::Critical => "critical".to_string(), Status::Unknown => "unknown".to_string(), + Status::Offline => "offline".to_string(), }), status: service_status, timestamp, diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 3a76723..ed5f488 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.53" +version = "0.1.54" edition = "2021" [dependencies] @@ -18,4 +18,5 @@ tracing-subscriber = { workspace = true } ratatui = { workspace = true } crossterm = { workspace = true } toml = { workspace = true } -gethostname = { workspace = true } \ No newline at end of file +gethostname = { workspace = true } +wake-on-lan = "0.2" \ No newline at end of file diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs index a1feb31..c78b8cf 100644 --- a/dashboard/src/app.rs +++ b/dashboard/src/app.rs @@ -67,8 +67,8 @@ impl Dashboard { } }; - // Connect to predefined hosts from configuration - let hosts = config.hosts.predefined_hosts.clone(); + // Connect to configured hosts from configuration + let hosts: Vec = config.hosts.keys().cloned().collect(); // Try to connect to hosts but don't fail if none are available match zmq_consumer.connect_to_predefined_hosts(&hosts).await { diff --git a/dashboard/src/config/mod.rs b/dashboard/src/config/mod.rs index ab2975b..f731b1a 100644 --- a/dashboard/src/config/mod.rs +++ b/dashboard/src/config/mod.rs @@ -6,7 +6,7 @@ use std::path::Path; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DashboardConfig { pub zmq: ZmqConfig, - pub hosts: HostsConfig, + pub hosts: std::collections::HashMap, pub system: SystemConfig, pub ssh: SshConfig, pub service_logs: std::collections::HashMap>, @@ -18,10 +18,10 @@ pub struct ZmqConfig { pub subscriber_ports: Vec, } -/// Hosts configuration +/// Individual host configuration details #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HostsConfig { - pub predefined_hosts: Vec, +pub struct HostDetails { + pub mac_address: Option, } /// System configuration @@ -68,8 +68,3 @@ impl Default for ZmqConfig { } } -impl Default for HostsConfig { - fn default() -> Self { - panic!("Dashboard configuration must be loaded from file - no hardcoded defaults allowed") - } -} diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index 3f3cb25..ef9b831 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -9,6 +9,7 @@ use ratatui::{ use std::collections::HashMap; use std::time::Instant; use tracing::info; +use wake_on_lan::MagicPacket; pub mod theme; pub mod widgets; @@ -93,15 +94,25 @@ pub struct TuiApp { impl TuiApp { pub fn new(config: DashboardConfig) -> Self { - Self { + let mut app = Self { host_widgets: HashMap::new(), current_host: None, - available_hosts: Vec::new(), + available_hosts: config.hosts.keys().cloned().collect(), host_index: 0, should_quit: false, user_navigated_away: false, config, + }; + + // 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 @@ -186,21 +197,28 @@ impl TuiApp { } /// Update available hosts with localhost prioritization - pub fn update_hosts(&mut self, hosts: Vec) { - // Sort hosts alphabetically - let mut sorted_hosts = hosts.clone(); + pub fn update_hosts(&mut self, discovered_hosts: Vec) { + // Start with configured hosts (always visible) + let mut all_hosts: Vec = 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); + } + } // Keep hosts that have pending transitions even if they're offline for (hostname, host_widgets) in &self.host_widgets { if !host_widgets.pending_service_transitions.is_empty() { - if !sorted_hosts.contains(hostname) { - sorted_hosts.push(hostname.clone()); + if !all_hosts.contains(hostname) { + all_hosts.push(hostname.clone()); } } } - sorted_hosts.sort(); - self.available_hosts = sorted_hosts; + all_hosts.sort(); + self.available_hosts = all_hosts; // Get the current hostname (localhost) for auto-selection let localhost = gethostname::gethostname().to_string_lossy().to_string(); @@ -331,6 +349,33 @@ impl TuiApp { return Ok(Some(UiCommand::TriggerBackup { hostname })); } } + 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::Tab => { // Tab cycles to next host self.navigate_host(1); @@ -674,7 +719,7 @@ impl TuiApp { let metrics = metric_store.get_metrics_for_host(hostname); if metrics.is_empty() { - return Status::Unknown; + return Status::Offline; } // First check if we have the aggregated host status summary from the agent @@ -694,7 +739,8 @@ impl TuiApp { Status::Warning => has_warning = true, Status::Pending => has_pending = true, Status::Ok => ok_count += 1, - Status::Unknown => {} // Ignore unknown for aggregation + Status::Unknown => {}, // Ignore unknown for aggregation + Status::Offline => {}, // Ignore offline for aggregation } } @@ -735,6 +781,7 @@ impl TuiApp { shortcuts.push("s/S: Start/Stop".to_string()); shortcuts.push("J: Logs".to_string()); shortcuts.push("L: Custom".to_string()); + shortcuts.push("w: Wake".to_string()); // Always show quit shortcuts.push("q: Quit".to_string()); @@ -773,5 +820,20 @@ impl TuiApp { } } + /// Parse MAC address string (e.g., "AA:BB:CC:DD:EE:FF") to [u8; 6] + 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) + } } diff --git a/dashboard/src/ui/theme.rs b/dashboard/src/ui/theme.rs index 1458655..dbcdd4c 100644 --- a/dashboard/src/ui/theme.rs +++ b/dashboard/src/ui/theme.rs @@ -147,6 +147,7 @@ impl Theme { Status::Warning => Self::warning(), Status::Critical => Self::error(), Status::Unknown => Self::muted_text(), + Status::Offline => Self::muted_text(), // Dark gray for offline } } @@ -244,8 +245,9 @@ impl StatusIcons { Status::Ok => "●", Status::Pending => "◉", // Hollow circle for pending Status::Warning => "◐", - Status::Critical => "◯", + Status::Critical => "!", Status::Unknown => "?", + Status::Offline => "○", // Empty circle for offline } } @@ -258,6 +260,7 @@ impl StatusIcons { Status::Warning => Theme::warning(), // Yellow Status::Critical => Theme::error(), // Red Status::Unknown => Theme::muted_text(), // Gray + Status::Offline => Theme::muted_text(), // Dark gray for offline }; vec![ diff --git a/dashboard/src/ui/widgets/services.rs b/dashboard/src/ui/widgets/services.rs index 463b35f..1456457 100644 --- a/dashboard/src/ui/widgets/services.rs +++ b/dashboard/src/ui/widgets/services.rs @@ -146,6 +146,7 @@ impl ServicesWidget { Status::Warning => Theme::warning(), Status::Critical => Theme::error(), Status::Unknown => Theme::muted_text(), + Status::Offline => Theme::muted_text(), }; (icon.to_string(), info.status.clone(), status_color) diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 5e2b005..2e39527 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.53" +version = "0.1.54" edition = "2021" [dependencies] diff --git a/shared/src/metrics.rs b/shared/src/metrics.rs index fc0776c..fb98c70 100644 --- a/shared/src/metrics.rs +++ b/shared/src/metrics.rs @@ -87,6 +87,7 @@ pub enum Status { Warning, Critical, Unknown, + Offline, } impl Status { @@ -190,6 +191,16 @@ impl HysteresisThresholds { Status::Ok } } + Status::Offline => { + // Host coming back online, use normal thresholds like first measurement + if value >= self.critical_high { + Status::Critical + } else if value >= self.warning_high { + Status::Warning + } else { + Status::Ok + } + } } } }