diff --git a/Cargo.lock b/Cargo.lock index 8101bdf..6334380 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.65" +version = "0.1.66" dependencies = [ "anyhow", "chrono", @@ -292,7 +292,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.65" +version = "0.1.66" dependencies = [ "anyhow", "async-trait", @@ -315,7 +315,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.65" +version = "0.1.66" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 8b328ca..41d7383 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.66" +version = "0.1.67" edition = "2021" [dependencies] diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 953dd48..6d74406 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.66" +version = "0.1.67" edition = "2021" [dependencies] diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs index 92cc74f..893ab07 100644 --- a/dashboard/src/app.rs +++ b/dashboard/src/app.rs @@ -67,11 +67,8 @@ impl Dashboard { } }; - // 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 { + match zmq_consumer.connect_to_predefined_hosts(&config.hosts).await { Ok(_) => info!("Successfully connected to ZMQ hosts"), Err(e) => { warn!( diff --git a/dashboard/src/communication/mod.rs b/dashboard/src/communication/mod.rs index 2d9688d..3643853 100644 --- a/dashboard/src/communication/mod.rs +++ b/dashboard/src/communication/mod.rs @@ -84,13 +84,13 @@ impl ZmqConsumer { } } - /// Connect to predefined hosts - pub async fn connect_to_predefined_hosts(&mut self, hosts: &[String]) -> Result<()> { + /// Connect to predefined hosts using their configuration + pub async fn connect_to_predefined_hosts(&mut self, hosts: &std::collections::HashMap) -> Result<()> { let default_port = self.config.subscriber_ports[0]; - for hostname in hosts { - // Try to connect, but don't fail if some hosts are unreachable - if let Err(e) = self.connect_to_host(hostname, default_port).await { + for (hostname, host_details) in hosts { + // Try to connect using configured IP, but don't fail if some hosts are unreachable + if let Err(e) = self.connect_to_host_with_details(hostname, host_details, default_port).await { warn!("Could not connect to {}: {}", hostname, e); } } @@ -104,6 +104,29 @@ impl ZmqConsumer { Ok(()) } + /// Connect to a host using its configuration details with fallback support + pub async fn connect_to_host_with_details(&mut self, hostname: &str, host_details: &crate::config::HostDetails, port: u16) -> Result<()> { + // Get primary connection IP + let primary_ip = host_details.get_connection_ip(hostname); + + // Try primary connection + if let Ok(()) = self.connect_to_host(&primary_ip, port).await { + info!("Connected to {} via primary address: {}", hostname, primary_ip); + return Ok(()); + } + + // Try fallback IPs if primary fails + let fallbacks = host_details.get_fallback_ips(hostname); + for fallback_ip in fallbacks { + if let Ok(()) = self.connect_to_host(&fallback_ip, port).await { + info!("Connected to {} via fallback address: {}", hostname, fallback_ip); + return Ok(()); + } + } + + Err(anyhow::anyhow!("Failed to connect to {} using all available addresses", hostname)) + } + /// Receive command output from any connected agent (non-blocking) pub async fn receive_command_output(&mut self) -> Result> { match self.subscriber.recv_bytes(zmq::DONTWAIT) { diff --git a/dashboard/src/config/mod.rs b/dashboard/src/config/mod.rs index 601f91f..3422903 100644 --- a/dashboard/src/config/mod.rs +++ b/dashboard/src/config/mod.rs @@ -29,6 +29,77 @@ fn default_heartbeat_timeout_seconds() -> u64 { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HostDetails { pub mac_address: Option, + /// Primary IP address (local network) + pub ip: Option, + /// Tailscale network IP address + pub tailscale_ip: Option, + /// Preferred connection type: "local", "tailscale", or "auto" (fallback) + #[serde(default = "default_connection_type")] + pub connection_type: String, +} + +fn default_connection_type() -> String { + "auto".to_string() +} + +impl HostDetails { + /// Get the preferred IP address for connection based on connection_type + pub fn get_connection_ip(&self, hostname: &str) -> String { + match self.connection_type.as_str() { + "tailscale" => { + if let Some(ref ts_ip) = self.tailscale_ip { + ts_ip.clone() + } else { + // Fallback to local IP or hostname + self.ip.as_ref().unwrap_or(&hostname.to_string()).clone() + } + } + "local" => { + if let Some(ref local_ip) = self.ip { + local_ip.clone() + } else { + hostname.to_string() + } + } + "auto" | _ => { + // Try tailscale first, then local, then hostname + if let Some(ref ts_ip) = self.tailscale_ip { + ts_ip.clone() + } else if let Some(ref local_ip) = self.ip { + local_ip.clone() + } else { + hostname.to_string() + } + } + } + } + + /// Get fallback IP addresses for connection retry + pub fn get_fallback_ips(&self, hostname: &str) -> Vec { + let mut fallbacks = Vec::new(); + + // Add all available IPs except the primary one + let primary = self.get_connection_ip(hostname); + + if let Some(ref ts_ip) = self.tailscale_ip { + if ts_ip != &primary { + fallbacks.push(ts_ip.clone()); + } + } + + if let Some(ref local_ip) = self.ip { + if local_ip != &primary { + fallbacks.push(local_ip.clone()); + } + } + + // Always include hostname as final fallback if not already primary + if hostname != primary { + fallbacks.push(hostname.to_string()); + } + + fallbacks + } } /// System configuration diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index 4ee16f5..c3f9489 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -251,12 +251,14 @@ impl TuiApp { 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!( - "bash -c 'cat << \"EOF\"\nNixOS System Rebuild\nTarget: {}\n\nEOF\nssh -tt {}@{} \"bash -ic {}\"\necho\necho \"========================================\"\necho \"Rebuild completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'", + "bash -c 'cat << \"EOF\"\nNixOS System Rebuild\nTarget: {} ({})\n\nEOF\nssh -tt {}@{} \"bash -ic {}\"\necho\necho \"========================================\"\necho \"Rebuild completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'", hostname, + connection_ip, self.config.ssh.rebuild_user, - hostname, + connection_ip, self.config.ssh.rebuild_alias ); @@ -289,10 +291,11 @@ impl TuiApp { KeyCode::Char('J') => { // Show service logs via journalctl 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 journalctl_command = format!( "bash -c \"ssh -tt {}@{} 'sudo journalctl -u {}.service -f --no-pager -n 50'; exit\"", self.config.ssh.rebuild_user, - hostname, + connection_ip, service_name ); @@ -312,10 +315,11 @@ impl TuiApp { // Check if this service has a custom log file configured if let Some(host_logs) = self.config.service_logs.get(&hostname) { if let Some(log_config) = host_logs.iter().find(|config| config.service_name == service_name) { + let connection_ip = self.get_connection_ip(&hostname); let tail_command = format!( "bash -c \"ssh -tt {}@{} 'sudo tail -n 50 -f {}'; exit\"", self.config.ssh.rebuild_user, - hostname, + connection_ip, log_config.log_file_path ); @@ -368,10 +372,11 @@ impl TuiApp { 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!( "ssh -tt {}@{}", self.config.ssh.rebuild_user, - hostname + connection_ip ); std::process::Command::new("tmux") @@ -917,6 +922,15 @@ impl TuiApp { } /// 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 { diff --git a/shared/Cargo.toml b/shared/Cargo.toml index c437ccd..a3590bc 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.66" +version = "0.1.67" edition = "2021" [dependencies]