Compare commits

...

4 Commits

Author SHA1 Message Date
76b6e3373e Change auto connection type to prioritize local IP first
All checks were successful
Build and Release / build-and-release (push) Successful in 2m36s
Update the auto connection type logic to try local network connections
before falling back to Tailscale. This provides better performance by
using faster local connections when available while maintaining Tailscale
as a reliable fallback.

Changes:
- Auto connection priority: local → tailscale → hostname (was tailscale → local)
- Fallback retry order updated to match new priority
- Supports omitting IP field in config for hosts without static local IP
2025-11-13 12:52:46 +01:00
0a13cab897 Add detected IP display in dashboard Agent row
All checks were successful
Build and Release / build-and-release (push) Successful in 1m8s
Display the connection IP address that the dashboard is configured to use
for each host below the Agent version information. Shows which network
path (local/Tailscale) is being used for connections based on host
configuration.

Features:
- Display detected IP below Agent row in system widget
- Uses existing host configuration connection logic
- Shows actual IP being used for dashboard connections
2025-11-13 11:26:58 +01:00
d33ec5d225 Add Tailscale network support for host connections
All checks were successful
Build and Release / build-and-release (push) Successful in 1m31s
Implement configurable network routing for both local and Tailscale networks.
Dashboard now supports intelligent connection selection with automatic fallback
between network types. Add IP configuration fields and connection routing logic
for ZMQ and SSH operations.

Features:
- Host configuration with local and Tailscale IP addresses
- Configurable connection types (local/tailscale/auto)
- Automatic fallback between network connections
- Updated ZMQ connection logic with retry support
- SSH command routing through configured IP addresses
2025-11-13 10:08:17 +01:00
d31c2384df Add configurable maintenance mode file support
All checks were successful
Build and Release / build-and-release (push) Successful in 1m32s
Implement maintenance_mode_file configuration option in NotificationConfig
to allow customizable file paths for suppressing email notifications.
Updates maintenance mode check to use configured path instead of hardcoded
/tmp/cm-maintenance file.
2025-11-10 07:48:15 +01:00
11 changed files with 148 additions and 23 deletions

6
Cargo.lock generated
View File

@@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]] [[package]]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.64" version = "0.1.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -292,7 +292,7 @@ dependencies = [
[[package]] [[package]]
name = "cm-dashboard-agent" name = "cm-dashboard-agent"
version = "0.1.64" version = "0.1.68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -315,7 +315,7 @@ dependencies = [
[[package]] [[package]]
name = "cm-dashboard-shared" name = "cm-dashboard-shared"
version = "0.1.64" version = "0.1.68"
dependencies = [ dependencies = [
"chrono", "chrono",
"serde", "serde",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-dashboard-agent" name = "cm-dashboard-agent"
version = "0.1.65" version = "0.1.69"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -150,6 +150,9 @@ pub struct NotificationConfig {
/// List of metric names to exclude from email notifications /// List of metric names to exclude from email notifications
#[serde(default)] #[serde(default)]
pub exclude_email_metrics: Vec<String>, pub exclude_email_metrics: Vec<String>,
/// Path to maintenance mode file that suppresses email notifications when present
#[serde(default = "default_maintenance_mode_file")]
pub maintenance_mode_file: String,
} }
@@ -157,6 +160,10 @@ fn default_heartbeat_interval_seconds() -> u64 {
5 5
} }
fn default_maintenance_mode_file() -> String {
"/tmp/cm-maintenance".to_string()
}
impl AgentConfig { impl AgentConfig {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> { pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
loader::load_config(path) loader::load_config(path)

View File

@@ -59,6 +59,6 @@ impl NotificationManager {
} }
fn is_maintenance_mode(&self) -> bool { fn is_maintenance_mode(&self) -> bool {
std::fs::metadata("/tmp/cm-maintenance").is_ok() std::fs::metadata(&self.config.maintenance_mode_file).is_ok()
} }
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.65" version = "0.1.69"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -67,11 +67,8 @@ impl Dashboard {
} }
}; };
// Connect to configured hosts from configuration
let hosts: Vec<String> = config.hosts.keys().cloned().collect();
// Try to connect to hosts but don't fail if none are available // 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"), Ok(_) => info!("Successfully connected to ZMQ hosts"),
Err(e) => { Err(e) => {
warn!( warn!(

View File

@@ -84,13 +84,13 @@ impl ZmqConsumer {
} }
} }
/// Connect to predefined hosts /// Connect to predefined hosts using their configuration
pub async fn connect_to_predefined_hosts(&mut self, hosts: &[String]) -> Result<()> { pub async fn connect_to_predefined_hosts(&mut self, hosts: &std::collections::HashMap<String, crate::config::HostDetails>) -> Result<()> {
let default_port = self.config.subscriber_ports[0]; let default_port = self.config.subscriber_ports[0];
for hostname in hosts { for (hostname, host_details) in hosts {
// Try to connect, but don't fail if some hosts are unreachable // Try to connect using configured IP, but don't fail if some hosts are unreachable
if let Err(e) = self.connect_to_host(hostname, default_port).await { if let Err(e) = self.connect_to_host_with_details(hostname, host_details, default_port).await {
warn!("Could not connect to {}: {}", hostname, e); warn!("Could not connect to {}: {}", hostname, e);
} }
} }
@@ -104,6 +104,29 @@ impl ZmqConsumer {
Ok(()) 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) /// Receive command output from any connected agent (non-blocking)
pub async fn receive_command_output(&mut self) -> Result<Option<CommandOutputMessage>> { pub async fn receive_command_output(&mut self) -> Result<Option<CommandOutputMessage>> {
match self.subscriber.recv_bytes(zmq::DONTWAIT) { match self.subscriber.recv_bytes(zmq::DONTWAIT) {

View File

@@ -29,6 +29,78 @@ fn default_heartbeat_timeout_seconds() -> u64 {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostDetails { pub struct HostDetails {
pub mac_address: Option<String>, pub mac_address: Option<String>,
/// Primary IP address (local network)
pub ip: Option<String>,
/// Tailscale network IP address
pub tailscale_ip: Option<String>,
/// 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<String> {
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 /// System configuration

View File

@@ -251,12 +251,14 @@ impl TuiApp {
KeyCode::Char('r') => { KeyCode::Char('r') => {
// System rebuild command - works on any panel for current host // System rebuild command - works on any panel for current host
if let Some(hostname) = self.current_host.clone() { 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 // Create command that shows logo, rebuilds, and waits for user input
let logo_and_rebuild = format!( 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, hostname,
connection_ip,
self.config.ssh.rebuild_user, self.config.ssh.rebuild_user,
hostname, connection_ip,
self.config.ssh.rebuild_alias self.config.ssh.rebuild_alias
); );
@@ -289,10 +291,11 @@ impl TuiApp {
KeyCode::Char('J') => { KeyCode::Char('J') => {
// Show service logs via journalctl in tmux split window // Show service logs via journalctl in tmux split window
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { 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!( let journalctl_command = format!(
"bash -c \"ssh -tt {}@{} 'sudo journalctl -u {}.service -f --no-pager -n 50'; exit\"", "bash -c \"ssh -tt {}@{} 'sudo journalctl -u {}.service -f --no-pager -n 50'; exit\"",
self.config.ssh.rebuild_user, self.config.ssh.rebuild_user,
hostname, connection_ip,
service_name service_name
); );
@@ -312,10 +315,11 @@ impl TuiApp {
// Check if this service has a custom log file configured // Check if this service has a custom log file configured
if let Some(host_logs) = self.config.service_logs.get(&hostname) { 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) { 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!( let tail_command = format!(
"bash -c \"ssh -tt {}@{} 'sudo tail -n 50 -f {}'; exit\"", "bash -c \"ssh -tt {}@{} 'sudo tail -n 50 -f {}'; exit\"",
self.config.ssh.rebuild_user, self.config.ssh.rebuild_user,
hostname, connection_ip,
log_config.log_file_path log_config.log_file_path
); );
@@ -368,10 +372,11 @@ impl TuiApp {
KeyCode::Char('t') => { KeyCode::Char('t') => {
// Open SSH terminal session in tmux window // Open SSH terminal session in tmux window
if let Some(hostname) = self.current_host.clone() { if let Some(hostname) = self.current_host.clone() {
let connection_ip = self.get_connection_ip(&hostname);
let ssh_command = format!( let ssh_command = format!(
"ssh -tt {}@{}", "ssh -tt {}@{}",
self.config.ssh.rebuild_user, self.config.ssh.rebuild_user,
hostname connection_ip
); );
std::process::Command::new("tmux") std::process::Command::new("tmux")
@@ -824,8 +829,10 @@ impl TuiApp {
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.system_scroll_offset host_widgets.system_scroll_offset
}; };
// Clone the config to avoid borrowing issues
let config = self.config.clone();
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.system_widget.render_with_scroll(frame, inner_area, scroll_offset, &hostname); host_widgets.system_widget.render_with_scroll(frame, inner_area, scroll_offset, &hostname, Some(&config));
} }
} }
@@ -917,6 +924,15 @@ impl TuiApp {
} }
/// Parse MAC address string (e.g., "AA:BB:CC:DD:EE:FF") to [u8; 6] /// 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> { fn parse_mac_address(mac_str: &str) -> Result<[u8; 6], &'static str> {
let parts: Vec<&str> = mac_str.split(':').collect(); let parts: Vec<&str> = mac_str.split(':').collect();
if parts.len() != 6 { if parts.len() != 6 {

View File

@@ -439,7 +439,7 @@ impl Widget for SystemWidget {
impl SystemWidget { impl SystemWidget {
/// Render with scroll offset support /// Render with scroll offset support
pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize, hostname: &str) { pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize, hostname: &str, config: Option<&crate::config::DashboardConfig>) {
let mut lines = Vec::new(); let mut lines = Vec::new();
// NixOS section // NixOS section
@@ -457,6 +457,16 @@ impl SystemWidget {
Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary()) Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary())
])); ]));
// Display detected connection IP
if let Some(config) = config {
if let Some(host_details) = config.hosts.get(hostname) {
let detected_ip = host_details.get_connection_ip(hostname);
lines.push(Line::from(vec![
Span::styled(format!("IP: {}", detected_ip), Typography::secondary())
]));
}
}
// CPU section // CPU section
lines.push(Line::from(vec![ lines.push(Line::from(vec![

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-dashboard-shared" name = "cm-dashboard-shared"
version = "0.1.65" version = "0.1.69"
edition = "2021" edition = "2021"
[dependencies] [dependencies]