Compare commits

...

9 Commits

Author SHA1 Message Date
e61a845965 Replace complex SystemRebuild with simple SSH + tmux popup approach
All checks were successful
Build and Release / build-and-release (push) Successful in 2m6s
- Remove all SystemRebuild command infrastructure from agent and dashboard
- Replace with direct tmux popup execution: ssh {user}@{host} {alias}
- Add configurable SSH user and rebuild alias in dashboard config
- Eliminate agent process crashes during rebuilds
- Simplify architecture by removing ZMQ command streaming complexity
- Clean up all related dead code and fix compilation warnings

Benefits:
- Process isolation: rebuild runs independently via SSH
- Crash resilience: agent/dashboard can restart without affecting rebuilds
- Configuration flexibility: SSH user and alias configurable per deployment
- Operational simplicity: standard tmux popup interface
2025-10-27 14:25:45 +01:00
ac5d2d4db5 Fix compilation error in agent service status check
All checks were successful
Build and Release / build-and-release (push) Successful in 1m31s
2025-10-26 23:42:19 +01:00
69892a2d84 Implement systemd service approach for nixos-rebuild operations
Some checks failed
Build and Release / build-and-release (push) Failing after 1m58s
- Add cm-rebuild systemd service for process isolation
- Add sudo permissions for service control and journal access
- Remove verbose flag for cleaner output
- Ensures reliable rebuild operations without agent crashes
2025-10-26 23:18:09 +01:00
a928d73134 Update Cargo.toml versions to 0.1.11
All checks were successful
Build and Release / build-and-release (push) Successful in 3m4s
- Update agent, dashboard, and shared package versions from 0.1.0 to 0.1.11
- Ensures agent version reporting shows correct v0.1.11 instead of v0.1.0
- Synchronize package versions with git tag for consistent version tracking
2025-10-26 14:12:03 +01:00
af52d49194 Fix system panel layout and switch to version-based agent reporting
All checks were successful
Build and Release / build-and-release (push) Successful in 2m6s
- Remove auto-close behavior from terminal popup for manual review
- Fix system panel to show correct NixOS section layout
- Add missing Active users line after Agent version
- Switch agent version from nix store hash to actual version number (v0.1.11)
- Display full version string without truncation for clear version tracking
2025-10-26 13:34:56 +01:00
bc94f75328 Enable real-time output streaming for nixos-rebuild command
All checks were successful
Build and Release / build-and-release (push) Successful in 1m24s
- Replace simulated progress messages with actual stdout/stderr capture
- Stream all nixos-rebuild output line-by-line to terminal popup
- Show transparent build process including downloads, compilation, and activation
- Maintain real-time visibility into complete rebuild process
2025-10-26 13:00:53 +01:00
b6da71b7e7 Implement real-time terminal popup for system rebuild operations
All checks were successful
Build and Release / build-and-release (push) Successful in 1m21s
- Add terminal popup UI component with 80% screen coverage and terminal styling
- Extend ZMQ protocol with CommandOutputMessage for streaming output
- Implement real-time output streaming in agent system rebuild handler
- Add keyboard controls (ESC/Q to close, ↑↓ to scroll) for popup interaction
- Fix system panel Build display to show actual NixOS build instead of config hash
- Update service filters in README with wildcard patterns for better matching
- Add periodic progress updates during nixos-rebuild execution
- Integrate command output handling in dashboard main loop
2025-10-26 11:39:03 +01:00
aaf7edfbce Implement cross-host agent version comparison
- MetricStore tracks agent versions from all hosts
- Detects version mismatches using most common version as reference
- Dashboard logs warnings for hosts with outdated agents
- Foundation for visual version mismatch indicators in UI
- Helps identify deployment inconsistencies across infrastructure
2025-10-26 10:42:26 +01:00
bb72c42726 Add agent version reporting and display
- Agent reports version via agent_version metric using nix store hash
- Dashboard displays agent version in system widget
- Foundation for cross-host version comparison
- Both agent -V and dashboard show versions
2025-10-26 10:38:20 +01:00
18 changed files with 554 additions and 283 deletions

View File

@@ -28,18 +28,35 @@ 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: v0.1.14" ✅
- Cross-host version comparison implemented ✅
- Automated binary release system working ✅
- SMART data consolidated into disk collector ✅
**Target Layout:**
**CRITICAL ISSUE - Remote Rebuild Functionality:**
-**System Rebuild**: Agent crashes during nixos-rebuild operations
-**Systemd Service**: cm-rebuild.service fails with exit status 1
-**Output Streaming**: Terminal popup shows agent messages but not rebuild output
- ⚠️ **Service Control**: Works correctly for start/stop/restart of services
**Problem Details:**
- Implemented systemd service approach to prevent agent crashes
- Terminal popup implemented with real-time streaming capability
- Service produces empty journal lines then exits with status 1
- Permission issues addressed by moving working directory to /tmp
- Issue persists despite multiple troubleshooting attempts
- Manual rebuilds work perfectly when done directly
**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 +72,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
@@ -88,6 +106,56 @@ Storage:
-**Git Clone Approach**: Implemented for nixos-rebuild to avoid directory permissions
-**Visual Feedback**: Directional arrows for service status (↑ starting, ↓ stopping, ↻ restarting)
### Terminal Popup for Real-time Output - IMPLEMENTED ✅
**Status (as of 2025-10-26):**
-**Terminal Popup UI**: 80% screen coverage with terminal styling and color-coded output
-**ZMQ Streaming Protocol**: CommandOutputMessage for real-time output transmission
-**Keyboard Controls**: ESC/Q to close, ↑↓ to scroll, manual close (no auto-close)
-**Real-time Display**: Live streaming of command output as it happens
-**Version-based Agent Reporting**: Shows "Agent: v0.1.13" instead of nix store hash
**Current Implementation Issues:**
-**Agent Process Crashes**: Agent dies during nixos-rebuild execution
-**Inconsistent Output**: Different outputs each time 'R' is pressed
-**Limited Output Visibility**: Not capturing all nixos-rebuild progress
**PLANNED SOLUTION - Systemd Service Approach:**
**Problem**: Direct nixos-rebuild execution in agent causes process crashes and inconsistent output.
**Solution**: Create dedicated systemd service for rebuild operations.
**Implementation Plan:**
1. **NixOS Systemd Service**:
```nix
systemd.services.cm-rebuild = {
description = "CM Dashboard NixOS Rebuild";
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.nixos-rebuild}/bin/nixos-rebuild switch --flake . --option sandbox false";
WorkingDirectory = "/var/lib/cm-dashboard/nixos-config";
User = "root";
StandardOutput = "journal";
StandardError = "journal";
};
};
```
2. **Agent Modification**:
- Replace direct nixos-rebuild execution with: `systemctl start cm-rebuild`
- Stream output via: `journalctl -u cm-rebuild -f --no-pager`
- Monitor service status for completion detection
3. **Benefits**:
- **Process Isolation**: Service runs independently, won't crash agent
- **Consistent Output**: Always same deterministic rebuild process
- **Proper Logging**: systemd journal handles all output management
- **Resource Management**: systemd manages cleanup and resource limits
- **Status Tracking**: Can query service status (running/failed/success)
**Next Priority**: Implement systemd service approach for reliable rebuild operations.
**Keyboard Controls Status:**
- **Services Panel**:
- R (restart) ✅ Working

6
Cargo.lock generated
View File

@@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "cm-dashboard"
version = "0.1.0"
version = "0.1.13"
dependencies = [
"anyhow",
"chrono",
@@ -291,7 +291,7 @@ dependencies = [
[[package]]
name = "cm-dashboard-agent"
version = "0.1.0"
version = "0.1.13"
dependencies = [
"anyhow",
"async-trait",
@@ -314,7 +314,7 @@ dependencies = [
[[package]]
name = "cm-dashboard-shared"
version = "0.1.0"
version = "0.1.13"
dependencies = [
"chrono",
"serde",

View File

@@ -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]

View File

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

View File

@@ -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};
use cm_dashboard_shared::{Metric, MetricMessage, MetricValue, Status};
pub struct Agent {
hostname: String,
@@ -162,6 +162,10 @@ impl Agent {
let host_status_metric = self.host_status_manager.get_host_status_metric();
metrics.push(host_status_metric);
// Add agent version metric for cross-host version comparison
let version_metric = self.get_agent_version_metric();
metrics.push(version_metric);
if metrics.is_empty() {
debug!("No metrics to broadcast");
return Ok(());
@@ -183,6 +187,24 @@ impl Agent {
}
}
/// Create agent version metric for cross-host version comparison
fn get_agent_version_metric(&self) -> Metric {
// Get version from executable path (same logic as main.rs get_version)
let version = self.get_agent_version();
Metric::new(
"agent_version".to_string(),
MetricValue::String(version),
Status::Ok,
)
}
/// Get agent version from Cargo package version
fn get_agent_version(&self) -> String {
// Use the version from Cargo.toml (e.g., "0.1.11")
format!("v{}", env!("CARGO_PKG_VERSION"))
}
async fn handle_commands(&mut self) -> Result<()> {
// Try to receive commands (non-blocking)
match self.zmq_handler.try_receive_command() {
@@ -232,12 +254,6 @@ impl Agent {
error!("Failed to execute service control: {}", e);
}
}
AgentCommand::SystemRebuild { git_url, git_branch, working_dir, api_key_file } => {
info!("Processing SystemRebuild command: {} @ {} -> {}", git_url, git_branch, working_dir);
if let Err(e) = self.handle_system_rebuild(&git_url, &git_branch, &working_dir, api_key_file.as_deref()).await {
error!("Failed to execute system rebuild: {}", e);
}
}
}
Ok(())
}
@@ -281,136 +297,4 @@ impl Agent {
Ok(())
}
/// Handle NixOS system rebuild commands with git clone approach
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);
// 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);
} else {
info!("Maintenance mode enabled");
}
// Clone or update repository
let git_result = self.ensure_git_repository(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!());
};
// 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);
}
} else {
info!("Maintenance mode disabled");
}
// Check rebuild start 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
}
Err(e) => {
error!("Failed to start nixos-rebuild: {}", e);
return Err(anyhow::anyhow!("Failed to start nixos-rebuild: {}", e));
}
}
info!("System rebuild completed, triggering metric refresh");
Ok(())
}
/// 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;
// Read API key if provided
let auth_url = if let Some(key_file) = api_key_file {
match tokio::fs::read_to_string(key_file).await {
Ok(api_key) => {
let api_key = api_key.trim();
if !api_key.is_empty() {
// Convert https://gitea.cmtec.se/cm/nixosbox.git to https://token@gitea.cmtec.se/cm/nixosbox.git
if git_url.starts_with("https://") {
let url_without_protocol = &git_url[8..]; // Remove "https://"
format!("https://{}@{}", api_key, url_without_protocol)
} else {
info!("API key provided but URL is not HTTPS, using original URL");
git_url.to_string()
}
} else {
info!("API key file is empty, using original URL");
git_url.to_string()
}
}
Err(e) => {
info!("Could not read API key file {}: {}, using original URL", key_file, e);
git_url.to_string()
}
}
} else {
git_url.to_string()
};
// Always remove existing directory and do fresh clone for consistent state
let working_path = Path::new(working_dir);
if working_path.exists() {
info!("Removing existing repository directory: {}", working_dir);
if let Err(e) = tokio::fs::remove_dir_all(working_path).await {
error!("Failed to remove existing directory: {}", e);
return Err(anyhow::anyhow!("Failed to remove existing directory: {}", e));
}
}
info!("Force cloning git repository from {} (branch: {})", git_url, git_branch);
// Force clone with depth 1 for efficiency (no history needed for deployment)
let output = tokio::process::Command::new("git")
.arg("clone")
.arg("--depth")
.arg("1")
.arg("--branch")
.arg(git_branch)
.arg(&auth_url)
.arg(working_dir)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
error!("Git clone failed: {}", stderr);
return Err(anyhow::anyhow!("Git clone failed: {}", stderr));
}
info!("Git repository cloned successfully with latest state");
Ok(())
}
}
}

View File

@@ -65,6 +65,7 @@ impl ZmqHandler {
Ok(())
}
/// Send heartbeat (placeholder for future use)
/// Try to receive a command (non-blocking)
@@ -104,13 +105,6 @@ pub enum AgentCommand {
service_name: String,
action: ServiceAction,
},
/// Rebuild NixOS system
SystemRebuild {
git_url: String,
git_branch: String,
working_dir: String,
api_key_file: Option<String>,
},
}
/// Service control actions

View File

@@ -141,6 +141,7 @@ pub struct NotificationConfig {
pub rate_limit_minutes: u64,
}
impl AgentConfig {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
loader::load_config(path)

View File

@@ -13,10 +13,26 @@ mod status;
use agent::Agent;
/// Get version showing cm-dashboard-agent package hash for easy deployment verification
fn get_version() -> &'static str {
// Get the path of the current executable
let exe_path = std::env::current_exe().expect("Failed to get executable path");
let exe_str = exe_path.to_string_lossy();
// Extract Nix store hash from path like /nix/store/HASH-cm-dashboard-v0.1.8/bin/cm-dashboard-agent
let hash_part = exe_str.strip_prefix("/nix/store/").expect("Not a nix store path");
let hash = hash_part.split('-').next().expect("Invalid nix store path format");
assert!(hash.len() >= 8, "Hash too short");
// Return first 8 characters of nix store hash
let short_hash = hash[..8].to_string();
Box::leak(short_hash.into_boxed_str())
}
#[derive(Parser)]
#[command(name = "cm-dashboard-agent")]
#[command(about = "CM Dashboard metrics agent with individual metric collection")]
#[command(version)]
#[command(version = get_version())]
struct Cli {
/// Increase logging verbosity (-v, -vv)
#[arg(short, long, action = clap::ArgAction::Count)]

View File

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

View File

@@ -22,7 +22,7 @@ pub struct Dashboard {
terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>,
headless: bool,
initial_commands_sent: std::collections::HashSet<String>,
config: DashboardConfig,
_config: DashboardConfig,
}
impl Dashboard {
@@ -91,7 +91,7 @@ impl Dashboard {
(None, None)
} else {
// Initialize TUI app
let tui_app = TuiApp::new();
let tui_app = TuiApp::new(config.clone());
// Setup terminal
if let Err(e) = enable_raw_mode() {
@@ -133,7 +133,7 @@ impl Dashboard {
terminal,
headless,
initial_commands_sent: std::collections::HashSet::new(),
config,
_config: config,
})
}
@@ -236,44 +236,54 @@ impl Dashboard {
self.metric_store
.update_metrics(&metric_message.hostname, metric_message.metrics);
// Check for agent version mismatches across hosts
if let Some((current_version, outdated_hosts)) = self.metric_store.get_version_mismatches() {
for outdated_host in &outdated_hosts {
warn!("Host {} has outdated agent version (current: {})", outdated_host, current_version);
}
}
// Update TUI with new hosts and metrics (only if not headless)
if let Some(ref mut tui_app) = self.tui_app {
let mut connected_hosts = self
let connected_hosts = self
.metric_store
.get_connected_hosts(Duration::from_secs(30));
// Add hosts that are rebuilding but may be temporarily disconnected
// Use extended timeout (5 minutes) for rebuilding hosts
let rebuilding_hosts = self
.metric_store
.get_connected_hosts(Duration::from_secs(300));
for host in rebuilding_hosts {
if !connected_hosts.contains(&host) {
// Check if this host is rebuilding in the UI
if tui_app.is_host_rebuilding(&host) {
connected_hosts.push(host);
}
}
}
tui_app.update_hosts(connected_hosts);
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);
// Note: Popup stays open for manual review - close with ESC/Q
}
}
last_metrics_check = Instant::now();
}
// Render TUI (only if not headless)
if !self.headless {
if let (Some(ref mut terminal), Some(ref mut tui_app)) =
(&mut self.terminal, &mut self.tui_app)
{
if let Err(e) = terminal.draw(|frame| {
tui_app.render(frame, &self.metric_store);
}) {
error!("Error rendering TUI: {}", e);
break;
if let Some(ref mut terminal) = self.terminal {
if let Some(ref mut tui_app) = self.tui_app {
if let Err(e) = terminal.draw(|frame| {
tui_app.render(frame, &self.metric_store);
}) {
error!("Error rendering TUI: {}", e);
break;
}
}
}
}
@@ -313,16 +323,6 @@ impl Dashboard {
};
self.zmq_command_sender.send_command(&hostname, agent_command).await?;
}
UiCommand::SystemRebuild { hostname } => {
info!("Sending system rebuild command to {}", hostname);
let agent_command = AgentCommand::SystemRebuild {
git_url: self.config.system.nixos_config_git_url.clone(),
git_branch: self.config.system.nixos_config_branch.clone(),
working_dir: self.config.system.nixos_config_working_dir.clone(),
api_key_file: self.config.system.nixos_config_api_key_file.clone(),
};
self.zmq_command_sender.send_command(&hostname, agent_command).await?;
}
UiCommand::TriggerBackup { hostname } => {
info!("Trigger backup requested for {}", hostname);
// TODO: Implement backup trigger command

View File

@@ -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<Option<CommandOutputMessage>> {
match self.subscriber.recv_bytes(zmq::DONTWAIT) {
Ok(data) => {
// Deserialize envelope
let envelope: MessageEnvelope = serde_json::from_slice(&data)
.map_err(|e| anyhow::anyhow!("Failed to deserialize envelope: {}", e))?;
// Check message type
match envelope.message_type {
MessageType::CommandOutput => {
let cmd_output = envelope
.decode_command_output()
.map_err(|e| anyhow::anyhow!("Failed to decode command output: {}", e))?;
debug!(
"Received command output from {}: {}",
cmd_output.hostname,
cmd_output.output_line
);
Ok(Some(cmd_output))
}
_ => Ok(None), // Not a command output message
}
}
Err(zmq::Error::EAGAIN) => {
// No message available (non-blocking mode)
Ok(None)
}
Err(e) => {
error!("ZMQ receive error: {}", e);
Err(anyhow::anyhow!("ZMQ receive error: {}", e))
}
}
}
/// Receive metrics from any connected agent (non-blocking)
pub async fn receive_metrics(&mut self) -> Result<Option<MetricMessage>> {
match self.subscriber.recv_bytes(zmq::DONTWAIT) {
@@ -132,6 +169,10 @@ impl ZmqConsumer {
debug!("Received heartbeat");
Ok(None) // Don't return heartbeats as metrics
}
MessageType::CommandOutput => {
debug!("Received command output (will be handled by receive_command_output)");
Ok(None) // Command output handled by separate method
}
_ => {
debug!("Received non-metrics message: {:?}", envelope.message_type);
Ok(None)

View File

@@ -8,6 +8,7 @@ pub struct DashboardConfig {
pub zmq: ZmqConfig,
pub hosts: HostsConfig,
pub system: SystemConfig,
pub ssh: SshConfig,
}
/// ZMQ consumer configuration
@@ -31,6 +32,13 @@ pub struct SystemConfig {
pub nixos_config_api_key_file: Option<String>,
}
/// SSH configuration for rebuild operations
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshConfig {
pub rebuild_user: String,
pub rebuild_alias: String,
}
impl DashboardConfig {
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();

View File

@@ -124,4 +124,52 @@ impl MetricStore {
}
}
}
/// Get agent versions from all hosts for cross-host comparison
pub fn get_agent_versions(&self) -> HashMap<String, String> {
let mut versions = HashMap::new();
for (hostname, metrics) in &self.current_metrics {
if let Some(version_metric) = metrics.get("agent_version") {
if let cm_dashboard_shared::MetricValue::String(version) = &version_metric.value {
versions.insert(hostname.clone(), version.clone());
}
}
}
versions
}
/// Check for agent version mismatches across hosts
pub fn get_version_mismatches(&self) -> Option<(String, Vec<String>)> {
let versions = self.get_agent_versions();
if versions.len() < 2 {
return None; // Need at least 2 hosts to compare
}
// Find the most common version (assume it's the "current" version)
let mut version_counts = HashMap::new();
for version in versions.values() {
*version_counts.entry(version.clone()).or_insert(0) += 1;
}
let most_common_version = version_counts
.iter()
.max_by_key(|(_, count)| *count)
.map(|(version, _)| version.clone())?;
// Find hosts with different versions
let outdated_hosts: Vec<String> = versions
.iter()
.filter(|(_, version)| *version != &most_common_version)
.map(|(hostname, _)| hostname.clone())
.collect();
if outdated_hosts.is_empty() {
None
} else {
Some((most_common_version, outdated_hosts))
}
}
}

View File

@@ -13,6 +13,7 @@ use tracing::info;
pub mod theme;
pub mod widgets;
use crate::config::DashboardConfig;
use crate::metrics::MetricStore;
use cm_dashboard_shared::{Metric, Status};
use theme::{Components, Layout as ThemeLayout, Theme, Typography};
@@ -24,7 +25,6 @@ pub enum UiCommand {
ServiceRestart { hostname: String, service_name: String },
ServiceStart { hostname: String, service_name: String },
ServiceStop { hostname: String, service_name: String },
SystemRebuild { hostname: String },
TriggerBackup { hostname: String },
}
@@ -33,8 +33,6 @@ pub enum UiCommand {
pub enum CommandStatus {
/// Command is executing
InProgress { command_type: CommandType, target: String, start_time: std::time::Instant },
/// Command completed successfully
Success { command_type: CommandType, completed_at: std::time::Instant },
}
/// Types of commands for status tracking
@@ -43,7 +41,6 @@ pub enum CommandType {
ServiceRestart,
ServiceStart,
ServiceStop,
SystemRebuild,
BackupTrigger,
}
@@ -92,6 +89,51 @@ impl HostWidgets {
}
}
/// Terminal popup for streaming command output
#[derive(Clone)]
pub struct TerminalPopup {
/// Is the popup currently visible
pub visible: bool,
/// Command being executed
pub _command_type: CommandType,
/// Target hostname
pub hostname: String,
/// Target service/operation name
pub target: String,
/// Output lines collected so far
pub output_lines: Vec<String>,
/// Scroll offset for the output
pub scroll_offset: usize,
/// Start time of the operation
pub start_time: Instant,
}
impl TerminalPopup {
pub fn _new(command_type: CommandType, hostname: String, target: String) -> Self {
Self {
visible: true,
_command_type: command_type,
hostname,
target,
output_lines: Vec::new(),
scroll_offset: 0,
start_time: Instant::now(),
}
}
pub fn add_output_line(&mut self, line: String) {
self.output_lines.push(line);
// Auto-scroll to bottom when new content arrives
if self.output_lines.len() > 20 {
self.scroll_offset = self.output_lines.len().saturating_sub(20);
}
}
pub fn close(&mut self) {
self.visible = false;
}
}
/// Main TUI application
pub struct TuiApp {
/// Widget states per host (hostname -> HostWidgets)
@@ -108,10 +150,14 @@ pub struct TuiApp {
should_quit: bool,
/// Track if user manually navigated away from localhost
user_navigated_away: bool,
/// Terminal popup for streaming command output
terminal_popup: Option<TerminalPopup>,
/// Dashboard configuration
config: DashboardConfig,
}
impl TuiApp {
pub fn new() -> Self {
pub fn new(config: DashboardConfig) -> Self {
Self {
host_widgets: HashMap::new(),
current_host: None,
@@ -120,6 +166,8 @@ impl TuiApp {
focused_panel: PanelType::System, // Start with System panel focused
should_quit: false,
user_navigated_away: false,
terminal_popup: None,
config,
}
}
@@ -136,7 +184,6 @@ impl TuiApp {
self.check_command_timeouts();
// Check for rebuild completion by agent hash change
self.check_rebuild_completion(metric_store);
if let Some(hostname) = self.current_host.clone() {
// Only update widgets if we have metrics for this host
@@ -178,7 +225,7 @@ impl TuiApp {
// Add NixOS metrics - using exact matching for build display fix
let nixos_metrics: Vec<&Metric> = all_metrics
.iter()
.filter(|m| m.name == "system_nixos_build" || m.name == "system_active_users" || m.name == "system_agent_hash")
.filter(|m| m.name == "system_nixos_build" || m.name == "system_active_users" || m.name == "agent_version")
.copied()
.collect();
system_metrics.extend(nixos_metrics);
@@ -209,9 +256,9 @@ impl TuiApp {
// Sort hosts alphabetically
let mut sorted_hosts = hosts.clone();
// Keep hosts that are undergoing SystemRebuild even if they're offline
// Keep hosts that have ongoing commands even if they're offline
for (hostname, host_widgets) in &self.host_widgets {
if let Some(CommandStatus::InProgress { command_type: CommandType::SystemRebuild, .. }) = &host_widgets.command_status {
if let Some(CommandStatus::InProgress { .. }) = &host_widgets.command_status {
if !sorted_hosts.contains(hostname) {
sorted_hosts.push(hostname.clone());
}
@@ -250,6 +297,38 @@ impl TuiApp {
/// Handle keyboard input
pub fn handle_input(&mut self, event: Event) -> Result<Option<UiCommand>> {
if let Event::Key(key) = event {
// If terminal popup is visible, handle popup-specific keys first
if let Some(ref mut popup) = self.terminal_popup {
if popup.visible {
match key.code {
KeyCode::Esc => {
popup.close();
self.terminal_popup = None;
return Ok(None);
}
KeyCode::Up => {
popup.scroll_offset = popup.scroll_offset.saturating_sub(1);
return Ok(None);
}
KeyCode::Down => {
let max_scroll = if popup.output_lines.len() > 20 {
popup.output_lines.len() - 20
} else {
0
};
popup.scroll_offset = (popup.scroll_offset + 1).min(max_scroll);
return Ok(None);
}
KeyCode::Char('q') => {
popup.close();
self.terminal_popup = None;
return Ok(None);
}
_ => return Ok(None), // Consume all other keys when popup is open
}
}
}
match key.code {
KeyCode::Char('q') => {
self.should_quit = true;
@@ -263,10 +342,20 @@ impl TuiApp {
KeyCode::Char('r') => {
match self.focused_panel {
PanelType::System => {
// System rebuild command
// Simple tmux popup with SSH rebuild using configured user and alias
if let Some(hostname) = self.current_host.clone() {
self.start_command(&hostname, CommandType::SystemRebuild, hostname.clone());
return Ok(Some(UiCommand::SystemRebuild { hostname }));
// Launch tmux popup with SSH using config values
std::process::Command::new("tmux")
.arg("popup")
.arg("-d")
.arg("#{pane_current_path}")
.arg("-xC")
.arg("-yC")
.arg("ssh")
.arg(&format!("{}@{}", self.config.ssh.rebuild_user, hostname))
.arg(&self.config.ssh.rebuild_alias)
.spawn()
.ok(); // Ignore errors, tmux will handle them
}
}
PanelType::Services => {
@@ -367,17 +456,6 @@ impl TuiApp {
info!("Switched to host: {}", self.current_host.as_ref().unwrap());
}
/// Check if a host is currently rebuilding
pub fn is_host_rebuilding(&self, hostname: &str) -> bool {
if let Some(host_widgets) = self.host_widgets.get(hostname) {
matches!(
&host_widgets.command_status,
Some(CommandStatus::InProgress { command_type: CommandType::SystemRebuild, .. })
)
} else {
false
}
}
/// Switch to next panel (Shift+Tab) - only cycles through visible panels
pub fn next_panel(&mut self) {
@@ -429,14 +507,10 @@ impl TuiApp {
}
/// Mark command as completed successfully
pub fn complete_command(&mut self, hostname: &str) {
pub fn _complete_command(&mut self, hostname: &str) {
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
if let Some(CommandStatus::InProgress { command_type, .. }) = &host_widgets.command_status {
host_widgets.command_status = Some(CommandStatus::Success {
command_type: command_type.clone(),
completed_at: Instant::now(),
});
}
// Simply clear the command status when completed
host_widgets.command_status = None;
}
}
@@ -447,22 +521,13 @@ impl TuiApp {
let mut hosts_to_clear = Vec::new();
for (hostname, host_widgets) in &self.host_widgets {
if let Some(CommandStatus::InProgress { command_type, start_time, .. }) = &host_widgets.command_status {
let timeout_duration = match command_type {
CommandType::SystemRebuild => Duration::from_secs(300), // 5 minutes for rebuilds
_ => Duration::from_secs(30), // 30 seconds for service commands
};
if let Some(CommandStatus::InProgress { command_type: _, start_time, .. }) = &host_widgets.command_status {
let timeout_duration = Duration::from_secs(30); // 30 seconds for service commands
if now.duration_since(*start_time) > timeout_duration {
hosts_to_clear.push(hostname.clone());
}
}
// Also clear success/failed status after display time
else if let Some(CommandStatus::Success { completed_at, .. }) = &host_widgets.command_status {
if now.duration_since(*completed_at) > Duration::from_secs(3) {
hosts_to_clear.push(hostname.clone());
}
}
}
// Clear timed out commands
@@ -473,33 +538,26 @@ impl TuiApp {
}
}
/// Check for rebuild completion by detecting agent hash changes
pub fn check_rebuild_completion(&mut self, metric_store: &MetricStore) {
let mut hosts_to_complete = Vec::new();
for (hostname, host_widgets) in &self.host_widgets {
if let Some(CommandStatus::InProgress { command_type: CommandType::SystemRebuild, .. }) = &host_widgets.command_status {
// Check if agent hash has changed (indicating successful rebuild)
if let Some(agent_hash_metric) = metric_store.get_metric(hostname, "system_agent_hash") {
if let cm_dashboard_shared::MetricValue::String(current_hash) = &agent_hash_metric.value {
// Compare with stored hash (if we have one)
if let Some(stored_hash) = host_widgets.system_widget.get_agent_hash() {
if current_hash != stored_hash {
// Agent hash changed - rebuild completed successfully
hosts_to_complete.push(hostname.clone());
}
}
}
}
/// Add output line to terminal popup
pub fn add_terminal_output(&mut self, hostname: &str, line: String) {
if let Some(ref mut popup) = self.terminal_popup {
if popup.hostname == hostname && popup.visible {
popup.add_output_line(line);
}
}
// Mark rebuilds as completed
for hostname in hosts_to_complete {
self.complete_command(&hostname);
}
/// Close terminal popup for a specific hostname
pub fn _close_terminal_popup(&mut self, hostname: &str) {
if let Some(ref mut popup) = self.terminal_popup {
if popup.hostname == hostname {
popup.close();
self.terminal_popup = None;
}
}
}
/// Scroll the focused panel up or down
pub fn scroll_focused_panel(&mut self, direction: i32) {
if let Some(hostname) = self.current_host.clone() {
@@ -636,6 +694,13 @@ impl TuiApp {
// Render statusbar at the bottom
self.render_statusbar(frame, main_chunks[2]); // main_chunks[2] is the statusbar area
// Render terminal popup on top of everything else
if let Some(ref popup) = self.terminal_popup {
if popup.visible {
self.render_terminal_popup(frame, size, popup);
}
}
}
/// Render btop-style minimal title with host status colors
@@ -662,13 +727,9 @@ impl TuiApp {
// Check if this host has a command status that affects the icon
let (status_icon, status_color) = if let Some(host_widgets) = self.host_widgets.get(host) {
match &host_widgets.command_status {
Some(CommandStatus::InProgress { command_type: CommandType::SystemRebuild, .. }) => {
// Show blue circular arrow during rebuild
("", Theme::highlight())
}
Some(CommandStatus::Success { command_type: CommandType::SystemRebuild, .. }) => {
// Show green checkmark for successful rebuild
("", Theme::success())
Some(CommandStatus::InProgress { .. }) => {
// Show working indicator for in-progress commands
("", Theme::highlight())
}
_ => {
// Normal status icon based on metrics
@@ -835,4 +896,112 @@ impl TuiApp {
}
}
/// Render terminal popup with streaming output
fn render_terminal_popup(&self, frame: &mut Frame, area: Rect, popup: &TerminalPopup) {
use ratatui::{
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
// Calculate popup size (80% of screen, centered)
let popup_width = area.width * 80 / 100;
let popup_height = area.height * 80 / 100;
let popup_x = (area.width - popup_width) / 2;
let popup_y = (area.height - popup_height) / 2;
let popup_area = Rect {
x: popup_x,
y: popup_y,
width: popup_width,
height: popup_height,
};
// Clear background
frame.render_widget(Clear, popup_area);
// Create terminal-style block
let title = format!(" {}{} ({:.1}s) ",
popup.hostname,
popup.target,
popup.start_time.elapsed().as_secs_f32()
);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.style(Style::default().bg(Color::Black));
let inner_area = block.inner(popup_area);
frame.render_widget(block, popup_area);
// Render output content
let available_height = inner_area.height as usize;
let total_lines = popup.output_lines.len();
// Calculate which lines to show based on scroll offset
let start_line = popup.scroll_offset;
let end_line = (start_line + available_height).min(total_lines);
let visible_lines: Vec<Line> = popup.output_lines[start_line..end_line]
.iter()
.map(|line| {
// Style output lines with terminal colors
if line.contains("error") || line.contains("Error") || line.contains("failed") {
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Red)))
} else if line.contains("warning") || line.contains("Warning") {
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Yellow)))
} else if line.contains("building") || line.contains("Building") {
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Blue)))
} else if line.contains("") || line.contains("success") || line.contains("completed") {
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Green)))
} else {
Line::from(Span::styled(line.clone(), Style::default().fg(Color::White)))
}
})
.collect();
let content = Paragraph::new(visible_lines)
.wrap(Wrap { trim: false })
.style(Style::default().bg(Color::Black));
frame.render_widget(content, inner_area);
// Render scroll indicator if needed
if total_lines > available_height {
let scroll_info = format!(" {}% ",
if total_lines > 0 {
(end_line * 100) / total_lines
} else {
100
}
);
let scroll_area = Rect {
x: popup_area.x + popup_area.width - scroll_info.len() as u16 - 1,
y: popup_area.y + popup_area.height - 1,
width: scroll_info.len() as u16,
height: 1,
};
let scroll_widget = Paragraph::new(scroll_info)
.style(Style::default().fg(Color::Cyan).bg(Color::Black));
frame.render_widget(scroll_widget, scroll_area);
}
// Instructions at bottom
let instructions = " ESC/Q: Close • ↑↓: Scroll ";
let instructions_area = Rect {
x: popup_area.x + 1,
y: popup_area.y + popup_area.height - 1,
width: instructions.len() as u16,
height: 1,
};
let instructions_widget = Paragraph::new(instructions)
.style(Style::default().fg(Color::Gray).bg(Color::Black));
frame.render_widget(instructions_widget, instructions_area);
}
}

View File

@@ -146,7 +146,6 @@ impl ServicesWidget {
}
}
}
_ => {} // Success/Failed states will show normal status
}
}
@@ -561,7 +560,6 @@ impl ServicesWidget {
StatusIcons::create_status_spans(*line_status, line_text)
}
}
_ => StatusIcons::create_status_spans(*line_status, line_text)
}
} else {
StatusIcons::create_status_spans(*line_status, line_text)

View File

@@ -129,7 +129,7 @@ impl SystemWidget {
}
/// Get the current agent hash for rebuild completion detection
pub fn get_agent_hash(&self) -> Option<&String> {
pub fn _get_agent_hash(&self) -> Option<&String> {
self.agent_hash.as_ref()
}
@@ -339,9 +339,9 @@ impl Widget for SystemWidget {
self.active_users = Some(users.clone());
}
}
"system_agent_hash" => {
if let MetricValue::String(hash) = &metric.value {
self.agent_hash = Some(hash.clone());
"agent_version" => {
if let MetricValue::String(version) = &metric.value {
self.agent_hash = Some(version.clone());
}
}
@@ -422,19 +422,19 @@ impl SystemWidget {
Span::styled("NixOS:", Typography::widget_title())
]));
let config_text = self.config_hash.as_deref().unwrap_or("unknown");
let build_text = self.nixos_build.as_deref().unwrap_or("unknown");
lines.push(Line::from(vec![
Span::styled(format!("Build: {}", config_text), Typography::secondary())
Span::styled(format!("Build: {}", build_text), Typography::secondary())
]));
let agent_hash_text = self.agent_hash.as_deref().unwrap_or("unknown");
let short_hash = if agent_hash_text.len() > 8 && agent_hash_text != "unknown" {
&agent_hash_text[..8]
} else {
agent_hash_text
};
let agent_version_text = self.agent_hash.as_deref().unwrap_or("unknown");
lines.push(Line::from(vec![
Span::styled(format!("Agent: {}", short_hash), Typography::secondary())
Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary())
]));
let users_text = self.active_users.as_deref().unwrap_or("unknown");
lines.push(Line::from(vec![
Span::styled(format!("Active users: {}", users_text), Typography::secondary())
]));
// CPU section

View File

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

View File

@@ -9,6 +9,17 @@ pub struct MetricMessage {
pub metrics: Vec<Metric>,
}
/// Command output streaming message
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandOutputMessage {
pub hostname: String,
pub command_id: String,
pub command_type: String,
pub output_line: String,
pub is_complete: bool,
pub timestamp: u64,
}
impl MetricMessage {
pub fn new(hostname: String, metrics: Vec<Metric>) -> Self {
Self {
@@ -19,6 +30,19 @@ impl MetricMessage {
}
}
impl CommandOutputMessage {
pub fn new(hostname: String, command_id: String, command_type: String, output_line: String, is_complete: bool) -> Self {
Self {
hostname,
command_id,
command_type,
output_line,
is_complete,
timestamp: chrono::Utc::now().timestamp() as u64,
}
}
}
/// Commands that can be sent from dashboard to agent
#[derive(Debug, Serialize, Deserialize)]
pub enum Command {
@@ -55,6 +79,7 @@ pub enum MessageType {
Metrics,
Command,
CommandResponse,
CommandOutput,
Heartbeat,
}
@@ -80,6 +105,13 @@ impl MessageEnvelope {
})
}
pub fn command_output(message: CommandOutputMessage) -> Result<Self, crate::SharedError> {
Ok(Self {
message_type: MessageType::CommandOutput,
payload: serde_json::to_vec(&message)?,
})
}
pub fn heartbeat() -> Result<Self, crate::SharedError> {
Ok(Self {
message_type: MessageType::Heartbeat,
@@ -113,4 +145,13 @@ impl MessageEnvelope {
}),
}
}
pub fn decode_command_output(&self) -> Result<CommandOutputMessage, crate::SharedError> {
match self.message_type {
MessageType::CommandOutput => Ok(serde_json::from_slice(&self.payload)?),
_ => Err(crate::SharedError::Protocol {
message: "Expected command output message".to_string(),
}),
}
}
}