use anyhow::Result; use serde::{Deserialize, Serialize}; use std::path::Path; /// Main dashboard configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DashboardConfig { pub zmq: ZmqConfig, pub hosts: std::collections::HashMap, pub system: SystemConfig, pub ssh: SshConfig, pub service_logs: std::collections::HashMap>, } /// ZMQ consumer configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ZmqConfig { pub subscriber_ports: Vec, /// Heartbeat timeout in seconds - hosts considered offline if no heartbeat received within this time #[serde(default = "default_heartbeat_timeout_seconds")] pub heartbeat_timeout_seconds: u64, } fn default_heartbeat_timeout_seconds() -> u64 { 10 // Default to 10 seconds - allows for multiple missed heartbeats } /// Individual host configuration details #[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 local first, then tailscale, then hostname if let Some(ref local_ip) = self.ip { local_ip.clone() } else if let Some(ref ts_ip) = self.tailscale_ip { ts_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); // Add fallbacks in priority order: local first, then tailscale if let Some(ref local_ip) = self.ip { if local_ip != &primary { fallbacks.push(local_ip.clone()); } } if let Some(ref ts_ip) = self.tailscale_ip { if ts_ip != &primary { fallbacks.push(ts_ip.clone()); } } // Always include hostname as final fallback if not already primary if hostname != primary { fallbacks.push(hostname.to_string()); } fallbacks } } /// System configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SystemConfig { pub nixos_config_git_url: String, pub nixos_config_branch: String, pub nixos_config_working_dir: String, pub nixos_config_api_key_file: Option, } /// SSH configuration for rebuild operations #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SshConfig { pub rebuild_user: String, pub rebuild_alias: String, } /// Service log file configuration per host #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServiceLogConfig { pub service_name: String, pub log_file_path: String, } impl DashboardConfig { pub fn load_from_file>(path: P) -> Result { let path = path.as_ref(); let content = std::fs::read_to_string(path)?; let config: DashboardConfig = toml::from_str(&content)?; Ok(config) } } impl Default for DashboardConfig { fn default() -> Self { panic!("Dashboard configuration must be loaded from file - no hardcoded defaults allowed") } } impl Default for ZmqConfig { fn default() -> Self { panic!("Dashboard configuration must be loaded from file - no hardcoded defaults allowed") } }