diff --git a/CLAUDE.md b/CLAUDE.md
index ce2b8a5..9b766f8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -28,18 +28,21 @@ All keyboard navigation and service selection features successfully implemented:
- ✅ **Smart Panel Switching**: Only cycles through panels with data (backup panel conditional)
- ✅ **Scroll Support**: All panels support content scrolling with proper overflow indicators
-**Current Status - October 25, 2025:**
+**Current Status - October 26, 2025:**
- All keyboard navigation features working correctly ✅
- Service selection cursor implemented with focus-aware highlighting ✅
- Panel scrolling fixed for System, Services, and Backup panels ✅
- Build display working: "Build: 25.05.20251004.3bcc93c" ✅
-- Configuration hash display: Currently shows git hash, needs to be fixed ❌
+- Agent version display working: "Agent: 3kvc03nd" ✅
+- Cross-host version comparison implemented ✅
+- Automated binary release system working ✅
+- SMART data consolidated into disk collector ✅
-**Target Layout:**
+**Current Layout:**
```
NixOS:
Build: 25.05.20251004.3bcc93c
-Config: d8ivwiar # Should show nix store hash (8 chars) from deployed system
+Agent: 3kvc03nd # Shows agent version (nix store hash)
Active users: cm, simon
CPU:
● Load: 0.02 0.31 0.86 • 3000MHz
@@ -55,7 +58,8 @@ Storage:
**System panel layout fully implemented with blue tree symbols ✅**
**Tree symbols now use consistent blue theming across all panels ✅**
**Overflow handling restored for all widgets ("... and X more") ✅**
-**Agent hash display working correctly ✅**
+**Agent version display working correctly ✅**
+**Cross-host version comparison logging warnings ✅**
### Current Keyboard Navigation Implementation
diff --git a/README.md b/README.md
index 89383c3..66f5f01 100644
--- a/README.md
+++ b/README.md
@@ -152,10 +152,13 @@ interval_seconds = 10
memory_warning_mb = 1000.0
memory_critical_mb = 2000.0
service_name_filters = [
- "nginx", "postgresql", "redis", "docker", "sshd"
+ "nginx*", "postgresql*", "redis*", "docker*", "sshd*",
+ "gitea*", "immich*", "haasp*", "mosquitto*", "mysql*",
+ "unifi*", "vaultwarden*"
]
excluded_services = [
- "nginx-config-reload", "sshd-keygen"
+ "nginx-config-reload", "sshd-keygen", "systemd-",
+ "getty@", "user@", "dbus-", "NetworkManager-"
]
[notifications]
diff --git a/agent/src/agent.rs b/agent/src/agent.rs
index bf91d88..a9fa354 100644
--- a/agent/src/agent.rs
+++ b/agent/src/agent.rs
@@ -9,7 +9,7 @@ use crate::config::AgentConfig;
use crate::metrics::MetricCollectionManager;
use crate::notifications::NotificationManager;
use crate::status::HostStatusManager;
-use cm_dashboard_shared::{Metric, MetricMessage, MetricValue, Status};
+use cm_dashboard_shared::{CommandOutputMessage, Metric, MetricMessage, MetricValue, Status};
pub struct Agent {
hostname: String,
@@ -318,73 +318,134 @@ impl Agent {
Ok(())
}
- /// Handle NixOS system rebuild commands with git clone approach
+ /// Handle NixOS system rebuild commands with real-time output streaming
async fn handle_system_rebuild(&self, git_url: &str, git_branch: &str, working_dir: &str, api_key_file: Option<&str>) -> Result<()> {
info!("Starting NixOS system rebuild: {} @ {} -> {}", git_url, git_branch, working_dir);
+ let command_id = format!("rebuild_{}", chrono::Utc::now().timestamp());
+
+ // Send initial status
+ self.send_command_output(&command_id, "SystemRebuild", "Starting NixOS system rebuild...").await?;
+
// Enable maintenance mode before rebuild
let maintenance_file = "/tmp/cm-maintenance";
if let Err(e) = tokio::fs::File::create(maintenance_file).await {
- error!("Failed to create maintenance mode file: {}", e);
+ self.send_command_output(&command_id, "SystemRebuild", &format!("Warning: Failed to create maintenance mode file: {}", e)).await?;
} else {
- info!("Maintenance mode enabled");
+ self.send_command_output(&command_id, "SystemRebuild", "Maintenance mode enabled").await?;
}
// Clone or update repository
- let git_result = self.ensure_git_repository(git_url, git_branch, working_dir, api_key_file).await;
+ self.send_command_output(&command_id, "SystemRebuild", "Cloning/updating git repository...").await?;
+ let git_result = self.ensure_git_repository_with_output(&command_id, git_url, git_branch, working_dir, api_key_file).await;
- // Execute nixos-rebuild if git operation succeeded - run detached but log output
- let rebuild_result = if git_result.is_ok() {
- info!("Git repository ready, executing nixos-rebuild in detached mode");
- let log_file = std::fs::OpenOptions::new()
- .create(true)
- .append(true)
- .open("/var/log/cm-dashboard/nixos-rebuild.log")
- .map_err(|e| anyhow::anyhow!("Failed to open rebuild log: {}", e))?;
-
- tokio::process::Command::new("nohup")
- .arg("sudo")
- .arg("/run/current-system/sw/bin/nixos-rebuild")
- .arg("switch")
- .arg("--option")
- .arg("sandbox")
- .arg("false")
- .arg("--flake")
- .arg(".")
- .current_dir(working_dir)
- .stdin(std::process::Stdio::null())
- .stdout(std::process::Stdio::from(log_file.try_clone().unwrap()))
- .stderr(std::process::Stdio::from(log_file))
- .spawn()
- } else {
- return git_result.and_then(|_| unreachable!());
- };
+ if git_result.is_err() {
+ self.send_command_output(&command_id, "SystemRebuild", &format!("Git operation failed: {:?}", git_result)).await?;
+ self.send_command_output_complete(&command_id, "SystemRebuild").await?;
+ return git_result;
+ }
+
+ self.send_command_output(&command_id, "SystemRebuild", "Git repository ready, starting nixos-rebuild...").await?;
+
+ // Execute nixos-rebuild with real-time output streaming
+ let rebuild_result = self.execute_nixos_rebuild_with_streaming(&command_id, working_dir).await;
// Always try to remove maintenance mode file
if let Err(e) = tokio::fs::remove_file(maintenance_file).await {
if e.kind() != std::io::ErrorKind::NotFound {
- error!("Failed to remove maintenance mode file: {}", e);
+ self.send_command_output(&command_id, "SystemRebuild", &format!("Warning: Failed to remove maintenance mode file: {}", e)).await?;
}
} else {
- info!("Maintenance mode disabled");
+ self.send_command_output(&command_id, "SystemRebuild", "Maintenance mode disabled").await?;
}
- // Check rebuild start result
+ // Handle rebuild result
match rebuild_result {
- Ok(_child) => {
- info!("NixOS rebuild started successfully in background");
- // Don't wait for completion to avoid agent being killed during rebuild
+ Ok(()) => {
+ self.send_command_output(&command_id, "SystemRebuild", "✓ NixOS rebuild completed successfully!").await?;
}
Err(e) => {
- error!("Failed to start nixos-rebuild: {}", e);
- return Err(anyhow::anyhow!("Failed to start nixos-rebuild: {}", e));
+ self.send_command_output(&command_id, "SystemRebuild", &format!("✗ NixOS rebuild failed: {}", e)).await?;
}
}
- info!("System rebuild completed, triggering metric refresh");
+ // Signal completion
+ self.send_command_output_complete(&command_id, "SystemRebuild").await?;
+
+ info!("System rebuild streaming completed");
Ok(())
}
+ /// Send command output line to dashboard
+ async fn send_command_output(&self, command_id: &str, command_type: &str, output_line: &str) -> Result<()> {
+ let message = CommandOutputMessage::new(
+ self.hostname.clone(),
+ command_id.to_string(),
+ command_type.to_string(),
+ output_line.to_string(),
+ false,
+ );
+ self.zmq_handler.publish_command_output(&message).await
+ }
+
+ /// Send command completion signal to dashboard
+ async fn send_command_output_complete(&self, command_id: &str, command_type: &str) -> Result<()> {
+ let message = CommandOutputMessage::new(
+ self.hostname.clone(),
+ command_id.to_string(),
+ command_type.to_string(),
+ "Command completed".to_string(),
+ true,
+ );
+ self.zmq_handler.publish_command_output(&message).await
+ }
+
+ /// Execute nixos-rebuild with simulated output streaming (for now)
+ async fn execute_nixos_rebuild_with_streaming(&self, command_id: &str, working_dir: &str) -> Result<()> {
+ use tokio::process::Command;
+
+ // Send progress updates during rebuild
+ self.send_command_output(command_id, "SystemRebuild", "Building...").await?;
+
+ let mut child = Command::new("sudo")
+ .arg("/run/current-system/sw/bin/nixos-rebuild")
+ .arg("switch")
+ .arg("--option")
+ .arg("sandbox")
+ .arg("false")
+ .arg("--flake")
+ .arg(".")
+ .current_dir(working_dir)
+ .spawn()?;
+
+ // Send periodic updates while waiting
+ let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
+ let mut update_count = 0;
+
+ loop {
+ tokio::select! {
+ _ = interval.tick() => {
+ update_count += 1;
+ self.send_command_output(command_id, "SystemRebuild", &format!("Building... ({} seconds)", update_count * 5)).await?;
+ }
+ result = child.wait() => {
+ let status = result?;
+ if status.success() {
+ return Ok(());
+ } else {
+ return Err(anyhow::anyhow!("nixos-rebuild exited with status: {}", status));
+ }
+ }
+ }
+ }
+ }
+
+ /// Ensure git repository with output streaming
+ async fn ensure_git_repository_with_output(&self, command_id: &str, git_url: &str, git_branch: &str, working_dir: &str, api_key_file: Option<&str>) -> Result<()> {
+ // This is a simplified version - we can enhance this later with git output streaming
+ self.ensure_git_repository(git_url, git_branch, working_dir, api_key_file).await
+ }
+
/// Ensure git repository is cloned and up to date with force clone approach
async fn ensure_git_repository(&self, git_url: &str, git_branch: &str, working_dir: &str, api_key_file: Option<&str>) -> Result<()> {
use std::path::Path;
diff --git a/agent/src/communication/mod.rs b/agent/src/communication/mod.rs
index ef51fc5..efa3b6a 100644
--- a/agent/src/communication/mod.rs
+++ b/agent/src/communication/mod.rs
@@ -1,5 +1,5 @@
use anyhow::Result;
-use cm_dashboard_shared::{MessageEnvelope, MetricMessage};
+use cm_dashboard_shared::{CommandOutputMessage, MessageEnvelope, MetricMessage};
use tracing::{debug, info};
use zmq::{Context, Socket, SocketType};
@@ -65,6 +65,24 @@ impl ZmqHandler {
Ok(())
}
+ /// Publish command output message via ZMQ
+ pub async fn publish_command_output(&self, message: &CommandOutputMessage) -> Result<()> {
+ debug!(
+ "Publishing command output for host {} (command: {}): {}",
+ message.hostname,
+ message.command_type,
+ message.output_line
+ );
+
+ let envelope = MessageEnvelope::command_output(message.clone())?;
+ let serialized = serde_json::to_vec(&envelope)?;
+
+ self.publisher.send(&serialized, 0)?;
+
+ debug!("Command output published successfully");
+ Ok(())
+ }
+
/// Send heartbeat (placeholder for future use)
/// Try to receive a command (non-blocking)
diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs
index aad349b..477a73b 100644
--- a/dashboard/src/app.rs
+++ b/dashboard/src/app.rs
@@ -268,6 +268,26 @@ impl Dashboard {
tui_app.update_metrics(&self.metric_store);
}
}
+
+ // Also check for command output messages
+ if let Ok(Some(cmd_output)) = self.zmq_consumer.receive_command_output().await {
+ debug!(
+ "Received command output from {}: {}",
+ cmd_output.hostname,
+ cmd_output.output_line
+ );
+
+ // Forward to TUI if not headless
+ if let Some(ref mut tui_app) = self.tui_app {
+ tui_app.add_terminal_output(&cmd_output.hostname, cmd_output.output_line);
+
+ // Close popup when command completes
+ if cmd_output.is_complete {
+ tui_app.close_terminal_popup(&cmd_output.hostname);
+ }
+ }
+ }
+
last_metrics_check = Instant::now();
}
diff --git a/dashboard/src/communication/mod.rs b/dashboard/src/communication/mod.rs
index 3d10235..25c9652 100644
--- a/dashboard/src/communication/mod.rs
+++ b/dashboard/src/communication/mod.rs
@@ -1,5 +1,5 @@
use anyhow::Result;
-use cm_dashboard_shared::{MessageEnvelope, MessageType, MetricMessage};
+use cm_dashboard_shared::{CommandOutputMessage, MessageEnvelope, MessageType, MetricMessage};
use tracing::{debug, error, info, warn};
use zmq::{Context, Socket, SocketType};
@@ -103,6 +103,43 @@ impl ZmqConsumer {
Ok(())
}
+ /// Receive command output from any connected agent (non-blocking)
+ pub async fn receive_command_output(&mut self) -> Result