Compare commits

..

5 Commits

Author SHA1 Message Date
0ca06d2507 Add smart service start with automatic log exit
All checks were successful
Build and Release / build-and-release (push) Successful in 1m8s
- Service start now follows logs in real-time until service becomes active
- Automatically stops log following when systemctl reports service as active
- Eliminates need for manual Ctrl+C to exit log stream
- Shows final service status after startup completes
- Background monitoring loop checks service state every second
2025-11-18 16:50:33 +01:00
6693f3a05f Remove transitional icons and improve service logs
All checks were successful
Build and Release / build-and-release (push) Successful in 1m31s
- Remove all transitional icon infrastructure (CommandType, pending transitions)
- Clean up ZMQ command system remnants after SSH migration
- Add real-time log streaming for service start operations
- Show final logs and status for service stop operations
- Fix compilation warnings by removing unused methods
- Simplify UI architecture with pure SSH-based service control
2025-11-18 16:40:14 +01:00
de252d27b9 Migrate service control from ZMQ to SSH with real-time progress
All checks were successful
Build and Release / build-and-release (push) Successful in 2m34s
Replace ZMQ-based service start/stop commands with SSH execution in tmux
popups. This provides better user feedback with real-time systemctl output
while eliminating blocking operations from the main message processing loop.

Changes:
- Service start/stop now use SSH with progress display
- Added backup functionality with 'B' key
- Preserved transitional icons (↑/↓) for immediate visual feedback
- Removed all ZMQ service control commands and handlers
- Updated configuration to include backup_alias setting
- All operations (rebuild, backup, services) now use consistent SSH interface

This ensures stable heartbeat processing while providing superior user
experience with live command output and service status feedback.
2025-11-18 16:02:15 +01:00
db0e41a7d3 Remove blocking CollectNow commands to fix heartbeat stability
All checks were successful
Build and Release / build-and-release (push) Successful in 1m9s
Eliminates automatic CollectNow command sending on host connection which
was blocking the main message processing loop for up to 5 seconds per
command. Since agents transmit cached data every 2 seconds anyway, the
CollectNow optimization provided minimal benefit while causing heartbeat
detection issues. Also removes unused send_command wrapper method.

This should completely resolve intermittent host connection dropping.
2025-11-15 11:41:58 +01:00
ec460496d8 Remove blocking TCP connectivity tests for fast startup
All checks were successful
Build and Release / build-and-release (push) Successful in 1m10s
Eliminates test_tcp_connectivity function that was causing 5-10 second
startup delays. ZMQ connections are non-blocking and we rely entirely
on heartbeat mechanism for connectivity detection. This restores fast
dashboard startup time.
2025-11-15 11:09:49 +01:00
14 changed files with 143 additions and 507 deletions

View File

@@ -49,8 +49,12 @@ hostname2 = [
### Navigation ### Navigation
- **Tab**: Switch between hosts - **Tab**: Switch between hosts
- **↑↓ or j/k**: Select services - **↑↓ or j/k**: Select services
- **s**: Start selected service (UserStart)
- **S**: Stop selected service (UserStop)
- **J**: Show service logs (journalctl) - **J**: Show service logs (journalctl)
- **L**: Show custom log files - **L**: Show custom log files
- **R**: Rebuild current host
- **B**: Run backup on current host
- **q**: Quit dashboard - **q**: Quit dashboard
## Core Architecture Principles ## Core Architecture Principles

6
Cargo.lock generated
View File

@@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]] [[package]]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.69" version = "0.1.75"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -292,7 +292,7 @@ dependencies = [
[[package]] [[package]]
name = "cm-dashboard-agent" name = "cm-dashboard-agent"
version = "0.1.69" version = "0.1.75"
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.69" version = "0.1.75"
dependencies = [ dependencies = [
"chrono", "chrono",
"serde", "serde",

View File

@@ -88,7 +88,9 @@ cm-dashboard • ● cmbox ● srv01 ● srv02 ● steambox
- **s**: Start selected service (UserStart) - **s**: Start selected service (UserStart)
- **S**: Stop selected service (UserStop) - **S**: Stop selected service (UserStop)
- **J**: Show service logs (journalctl in tmux popup) - **J**: Show service logs (journalctl in tmux popup)
- **L**: Show custom log files (tail -f custom paths in tmux popup)
- **R**: Rebuild current host - **R**: Rebuild current host
- **B**: Run backup on current host
- **q**: Quit - **q**: Quit
### Status Indicators ### Status Indicators
@@ -173,9 +175,10 @@ subscriber_ports = [6130]
[hosts] [hosts]
predefined_hosts = ["cmbox", "srv01", "srv02"] predefined_hosts = ["cmbox", "srv01", "srv02"]
[ui] [ssh]
ssh_user = "cm" rebuild_user = "cm"
rebuild_alias = "nixos-rebuild-cmtec" rebuild_alias = "nixos-rebuild-cmtec"
backup_alias = "cm-backup-run"
``` ```
## Technical Implementation ## Technical Implementation

View File

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

View File

@@ -4,7 +4,7 @@ use std::time::Duration;
use tokio::time::interval; use tokio::time::interval;
use tracing::{debug, error, info}; use tracing::{debug, error, info};
use crate::communication::{AgentCommand, ServiceAction, ZmqHandler}; use crate::communication::{AgentCommand, ZmqHandler};
use crate::config::AgentConfig; use crate::config::AgentConfig;
use crate::metrics::MetricCollectionManager; use crate::metrics::MetricCollectionManager;
use crate::notifications::NotificationManager; use crate::notifications::NotificationManager;
@@ -315,79 +315,10 @@ impl Agent {
info!("Processing Ping command - agent is alive"); info!("Processing Ping command - agent is alive");
// Could send a response back via ZMQ if needed // Could send a response back via ZMQ if needed
} }
AgentCommand::ServiceControl { service_name, action } => {
info!("Processing ServiceControl command: {} {:?}", service_name, action);
if let Err(e) = self.handle_service_control(&service_name, &action).await {
error!("Failed to execute service control: {}", e);
}
}
} }
Ok(()) Ok(())
} }
/// Handle systemd service control commands
async fn handle_service_control(&mut self, service_name: &str, action: &ServiceAction) -> Result<()> {
let (action_str, is_user_action) = match action {
ServiceAction::Start => ("start", false),
ServiceAction::Stop => ("stop", false),
ServiceAction::Status => ("status", false),
ServiceAction::UserStart => ("start", true),
ServiceAction::UserStop => ("stop", true),
};
info!("Executing systemctl {} {} (user action: {})", action_str, service_name, is_user_action);
// Handle user-stopped service tracking before systemctl execution (stop only)
match action {
ServiceAction::UserStop => {
info!("Marking service '{}' as user-stopped", service_name);
if let Err(e) = self.service_tracker.mark_user_stopped(service_name) {
error!("Failed to mark service as user-stopped: {}", e);
} else {
// Sync to global tracker
UserStoppedServiceTracker::update_global(&self.service_tracker);
}
}
_ => {}
}
// Spawn the systemctl command asynchronously to avoid blocking the agent
let service_name_clone = service_name.to_string();
let action_str_clone = action_str.to_string();
tokio::spawn(async move {
let result = tokio::process::Command::new("sudo")
.arg("systemctl")
.arg(&action_str_clone)
.arg(format!("{}.service", service_name_clone))
.output()
.await;
match result {
Ok(output) => {
if output.status.success() {
info!("Service {} {} completed successfully", service_name_clone, action_str_clone);
if !output.stdout.is_empty() {
debug!("stdout: {}", String::from_utf8_lossy(&output.stdout));
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
error!("Service {} {} failed: {}", service_name_clone, action_str_clone, stderr);
}
}
Err(e) => {
error!("Failed to execute systemctl {} {}: {}", action_str_clone, service_name_clone, e);
}
}
});
info!("Service {} {} command initiated (non-blocking)", service_name, action_str);
// Note: Service status will be updated by the normal metric collection cycle
// once the systemctl operation completes
Ok(())
}
/// Check metrics for user-stopped services that are now active and clear their flags /// Check metrics for user-stopped services that are now active and clear their flags
fn clear_user_stopped_flags_for_active_services(&mut self, metrics: &[Metric]) { fn clear_user_stopped_flags_for_active_services(&mut self, metrics: &[Metric]) {

View File

@@ -98,19 +98,4 @@ pub enum AgentCommand {
ToggleCollector { name: String, enabled: bool }, ToggleCollector { name: String, enabled: bool },
/// Request status/health check /// Request status/health check
Ping, Ping,
/// Control systemd service
ServiceControl {
service_name: String,
action: ServiceAction,
},
}
/// Service control actions
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub enum ServiceAction {
Start,
Stop,
Status,
UserStart, // User-initiated start (clears user-stopped flag)
UserStop, // User-initiated stop (marks as user-stopped)
} }

View File

@@ -90,14 +90,6 @@ impl UserStoppedServiceTracker {
tracker tracker
} }
/// Mark a service as user-stopped
pub fn mark_user_stopped(&mut self, service_name: &str) -> Result<()> {
info!("Marking service '{}' as user-stopped", service_name);
self.user_stopped_services.insert(service_name.to_string());
self.save_to_storage()?;
debug!("Service '{}' marked as user-stopped and saved to storage", service_name);
Ok(())
}
/// Clear user-stopped flag for a service (when user starts it) /// Clear user-stopped flag for a service (when user starts it)
pub fn clear_user_stopped(&mut self, service_name: &str) -> Result<()> { pub fn clear_user_stopped(&mut self, service_name: &str) -> Result<()> {

View File

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

View File

@@ -9,14 +9,13 @@ use std::io;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use crate::communication::{AgentCommand, ServiceAction, ZmqCommandSender, ZmqConsumer}; use crate::communication::{ZmqConsumer};
use crate::config::DashboardConfig; use crate::config::DashboardConfig;
use crate::metrics::MetricStore; use crate::metrics::MetricStore;
use crate::ui::{TuiApp, UiCommand}; use crate::ui::TuiApp;
pub struct Dashboard { pub struct Dashboard {
zmq_consumer: ZmqConsumer, zmq_consumer: ZmqConsumer,
zmq_command_sender: ZmqCommandSender,
metric_store: MetricStore, metric_store: MetricStore,
tui_app: Option<TuiApp>, tui_app: Option<TuiApp>,
terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>, terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>,
@@ -58,14 +57,6 @@ impl Dashboard {
} }
}; };
// Initialize ZMQ command sender
let zmq_command_sender = match ZmqCommandSender::new(&config.zmq) {
Ok(sender) => sender,
Err(e) => {
error!("Failed to initialize ZMQ command sender: {}", e);
return Err(e);
}
};
// 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(&config.hosts).await { match zmq_consumer.connect_to_predefined_hosts(&config.hosts).await {
@@ -124,7 +115,6 @@ impl Dashboard {
Ok(Self { Ok(Self {
zmq_consumer, zmq_consumer,
zmq_command_sender,
metric_store, metric_store,
tui_app, tui_app,
terminal, terminal,
@@ -134,12 +124,6 @@ impl Dashboard {
}) })
} }
/// Send a command to a specific agent
pub async fn send_command(&mut self, hostname: &str, command: AgentCommand) -> Result<()> {
self.zmq_command_sender
.send_command(hostname, command)
.await
}
pub async fn run(&mut self) -> Result<()> { pub async fn run(&mut self) -> Result<()> {
info!("Starting dashboard main loop"); info!("Starting dashboard main loop");
@@ -157,16 +141,10 @@ impl Dashboard {
match event::read() { match event::read() {
Ok(event) => { Ok(event) => {
if let Some(ref mut tui_app) = self.tui_app { if let Some(ref mut tui_app) = self.tui_app {
// Handle input and check for commands // Handle input
match tui_app.handle_input(event) { match tui_app.handle_input(event) {
Ok(Some(command)) => { Ok(_) => {
// Execute the command // Check if we should quit
if let Err(e) = self.execute_ui_command(command).await {
error!("Failed to execute UI command: {}", e);
}
}
Ok(None) => {
// No command, check if we should quit
if tui_app.should_quit() { if tui_app.should_quit() {
info!("Quit requested, exiting dashboard"); info!("Quit requested, exiting dashboard");
break; break;
@@ -212,35 +190,19 @@ impl Dashboard {
metric_message.metrics.len() metric_message.metrics.len()
); );
// Check if this is the first time we've seen this host // Track first contact with host (no command needed - agent sends data every 2s)
let is_new_host = !self let is_new_host = !self
.initial_commands_sent .initial_commands_sent
.contains(&metric_message.hostname); .contains(&metric_message.hostname);
if is_new_host { if is_new_host {
info!( info!(
"First contact with host {}, sending initial CollectNow command", "First contact with host {} - data will update automatically",
metric_message.hostname
);
// Send CollectNow command for immediate refresh
if let Err(e) = self
.send_command(&metric_message.hostname, AgentCommand::CollectNow)
.await
{
error!(
"Failed to send initial CollectNow command to {}: {}",
metric_message.hostname, e
);
} else {
info!(
"✓ Sent initial CollectNow command to {}",
metric_message.hostname metric_message.hostname
); );
self.initial_commands_sent self.initial_commands_sent
.insert(metric_message.hostname.clone()); .insert(metric_message.hostname.clone());
} }
}
// Update metric store // Update metric store
self.metric_store self.metric_store
@@ -309,33 +271,6 @@ impl Dashboard {
Ok(()) Ok(())
} }
/// Execute a UI command by sending it to the appropriate agent
async fn execute_ui_command(&self, command: UiCommand) -> Result<()> {
match command {
UiCommand::ServiceStart { hostname, service_name } => {
info!("Sending user start command for service {} on {}", service_name, hostname);
let agent_command = AgentCommand::ServiceControl {
service_name: service_name.clone(),
action: ServiceAction::UserStart,
};
self.zmq_command_sender.send_command(&hostname, agent_command).await?;
}
UiCommand::ServiceStop { hostname, service_name } => {
info!("Sending user stop command for service {} on {}", service_name, hostname);
let agent_command = AgentCommand::ServiceControl {
service_name: service_name.clone(),
action: ServiceAction::UserStop,
};
self.zmq_command_sender.send_command(&hostname, agent_command).await?;
}
UiCommand::TriggerBackup { hostname } => {
info!("Trigger backup requested for {}", hostname);
// TODO: Implement backup trigger command
info!("Backup trigger not yet implemented");
}
}
Ok(())
}
} }

View File

@@ -5,40 +5,6 @@ use zmq::{Context, Socket, SocketType};
use crate::config::ZmqConfig; use crate::config::ZmqConfig;
/// Commands that can be sent to agents
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub enum AgentCommand {
/// Request immediate metric collection
CollectNow,
/// Change collection interval
SetInterval { seconds: u64 },
/// Enable/disable a collector
ToggleCollector { name: String, enabled: bool },
/// Request status/health check
Ping,
/// Control systemd service
ServiceControl {
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
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub enum ServiceAction {
Start,
Stop,
Status,
UserStart, // User-initiated start (clears user-stopped flag)
UserStop, // User-initiated stop (marks as user-stopped)
}
/// ZMQ consumer for receiving metrics from agents /// ZMQ consumer for receiving metrics from agents
pub struct ZmqConsumer { pub struct ZmqConsumer {
@@ -71,12 +37,6 @@ impl ZmqConsumer {
pub async fn connect_to_host(&mut self, hostname: &str, port: u16) -> Result<()> { pub async fn connect_to_host(&mut self, hostname: &str, port: u16) -> Result<()> {
let address = format!("tcp://{}:{}", hostname, port); let address = format!("tcp://{}:{}", hostname, port);
// First test basic TCP connectivity to the port
if let Err(e) = self.test_tcp_connectivity(hostname, port).await {
error!("TCP connectivity test failed for {}: {}", address, e);
return Err(e);
}
match self.subscriber.connect(&address) { match self.subscriber.connect(&address) {
Ok(()) => { Ok(()) => {
info!("Connected to agent at {}", address); info!("Connected to agent at {}", address);
@@ -90,25 +50,6 @@ impl ZmqConsumer {
} }
} }
/// Test TCP connectivity to a host and port with timeout
async fn test_tcp_connectivity(&self, hostname: &str, port: u16) -> Result<()> {
let timeout = std::time::Duration::from_secs(3);
match tokio::time::timeout(timeout, tokio::net::TcpStream::connect((hostname, port))).await {
Ok(Ok(_stream)) => {
debug!("TCP connectivity test passed for {}:{}", hostname, port);
Ok(())
}
Ok(Err(e)) => {
debug!("TCP connectivity test failed for {}:{}: {}", hostname, port, e);
Err(anyhow::anyhow!("TCP connection failed: {}", e))
}
Err(_) => {
debug!("TCP connectivity test timed out for {}:{}", hostname, port);
Err(anyhow::anyhow!("TCP connection timed out"))
}
}
}
/// Connect to predefined hosts using their configuration /// Connect to predefined hosts using their configuration
pub async fn connect_to_predefined_hosts(&mut self, hosts: &std::collections::HashMap<String, crate::config::HostDetails>) -> Result<()> { pub async fn connect_to_predefined_hosts(&mut self, hosts: &std::collections::HashMap<String, crate::config::HostDetails>) -> Result<()> {
@@ -227,42 +168,3 @@ impl ZmqConsumer {
} }
} }
/// ZMQ command sender for sending commands to agents
pub struct ZmqCommandSender {
context: Context,
}
impl ZmqCommandSender {
pub fn new(_config: &ZmqConfig) -> Result<Self> {
let context = Context::new();
info!("ZMQ command sender initialized");
Ok(Self { context })
}
/// Send a command to a specific agent
pub async fn send_command(&self, hostname: &str, command: AgentCommand) -> Result<()> {
// Create a new PUSH socket for this command (ZMQ best practice)
let socket = self.context.socket(SocketType::PUSH)?;
// Set socket options
socket.set_linger(1000)?; // Wait up to 1 second on close
socket.set_sndtimeo(5000)?; // 5 second send timeout
// Connect to agent's command port (6131)
let address = format!("tcp://{}:6131", hostname);
socket.connect(&address)?;
// Serialize command
let serialized = serde_json::to_vec(&command)?;
// Send command
socket.send(&serialized, 0)?;
info!("Sent command {:?} to agent at {}", command, hostname);
// Socket will be automatically closed when dropped
Ok(())
}
}

View File

@@ -51,11 +51,12 @@ pub struct SystemConfig {
pub nixos_config_api_key_file: Option<String>, pub nixos_config_api_key_file: Option<String>,
} }
/// SSH configuration for rebuild operations /// SSH configuration for rebuild and backup operations
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshConfig { pub struct SshConfig {
pub rebuild_user: String, pub rebuild_user: String,
pub rebuild_alias: String, pub rebuild_alias: String,
pub backup_alias: String,
} }
/// Service log file configuration per host /// Service log file configuration per host

View File

@@ -16,26 +16,12 @@ pub mod widgets;
use crate::config::DashboardConfig; use crate::config::DashboardConfig;
use crate::metrics::MetricStore; use crate::metrics::MetricStore;
use cm_dashboard_shared::{Metric, Status}; use cm_dashboard_shared::Status;
use theme::{Components, Layout as ThemeLayout, Theme, Typography}; use theme::{Components, Layout as ThemeLayout, Theme, Typography};
use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget}; use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget};
/// Commands that can be triggered from the UI
#[derive(Debug, Clone)]
pub enum UiCommand {
ServiceStart { hostname: String, service_name: String },
ServiceStop { hostname: String, service_name: String },
TriggerBackup { hostname: String },
}
/// Types of commands for status tracking
#[derive(Debug, Clone)]
pub enum CommandType {
ServiceStart,
ServiceStop,
BackupTrigger,
}
/// Panel types for focus management /// Panel types for focus management
@@ -54,8 +40,6 @@ pub struct HostWidgets {
pub backup_scroll_offset: usize, pub backup_scroll_offset: usize,
/// Last update time for this host /// Last update time for this host
pub last_update: Option<Instant>, pub last_update: Option<Instant>,
/// Pending service transitions for immediate visual feedback
pub pending_service_transitions: HashMap<String, (CommandType, String, Instant)>, // service_name -> (command_type, original_status, start_time)
} }
impl HostWidgets { impl HostWidgets {
@@ -68,7 +52,6 @@ impl HostWidgets {
services_scroll_offset: 0, services_scroll_offset: 0,
backup_scroll_offset: 0, backup_scroll_offset: 0,
last_update: None, last_update: None,
pending_service_transitions: HashMap::new(),
} }
} }
} }
@@ -161,8 +144,6 @@ impl TuiApp {
} }
} }
// Clear completed transitions first
self.clear_completed_transitions(&hostname, &service_metrics);
// Now get host widgets and update them // Now get host widgets and update them
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
@@ -198,14 +179,6 @@ impl TuiApp {
} }
} }
// Keep hosts that have pending transitions even if they're offline
for (hostname, host_widgets) in &self.host_widgets {
if !host_widgets.pending_service_transitions.is_empty() {
if !all_hosts.contains(hostname) {
all_hosts.push(hostname.clone());
}
}
}
all_hosts.sort(); all_hosts.sort();
self.available_hosts = all_hosts; self.available_hosts = all_hosts;
@@ -236,7 +209,7 @@ impl TuiApp {
} }
/// Handle keyboard input /// Handle keyboard input
pub fn handle_input(&mut self, event: Event) -> Result<Option<UiCommand>> { pub fn handle_input(&mut self, event: Event) -> Result<()> {
if let Event::Key(key) = event { if let Event::Key(key) = event {
match key.code { match key.code {
KeyCode::Char('q') => { KeyCode::Char('q') => {
@@ -272,20 +245,81 @@ impl TuiApp {
.ok(); // Ignore errors, tmux will handle them .ok(); // Ignore errors, tmux will handle them
} }
} }
KeyCode::Char('s') => { KeyCode::Char('B') => {
// Service start command // Backup command - works on any panel for current host
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { if let Some(hostname) = self.current_host.clone() {
if self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()) { let connection_ip = self.get_connection_ip(&hostname);
return Ok(Some(UiCommand::ServiceStart { hostname, service_name })); // Create command that shows logo, runs backup, and waits for user input
let logo_and_backup = format!(
"bash -c 'cat << \"EOF\"\nBackup Operation\nTarget: {} ({})\n\nEOF\nssh -tt {}@{} \"bash -ic {}\"\necho\necho \"========================================\"\necho \"Backup completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'",
hostname,
connection_ip,
self.config.ssh.rebuild_user,
connection_ip,
self.config.ssh.backup_alias
);
std::process::Command::new("tmux")
.arg("split-window")
.arg("-v")
.arg("-p")
.arg("30")
.arg(&logo_and_backup)
.spawn()
.ok(); // Ignore errors, tmux will handle them
} }
} }
KeyCode::Char('s') => {
// Service start command via SSH with progress display
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
let connection_ip = self.get_connection_ip(&hostname);
let service_start_command = format!(
"bash -c 'cat << \"EOF\"\nService Start: {}.service\nTarget: {} ({})\n\nEOF\nssh -tt {}@{} \"echo \\\"Starting service...\\\" && sudo systemctl start {}.service && echo \\\"Following logs until service is active...\\\" && echo \\\"========================================\\\" && {{ sudo journalctl -u {}.service -f --no-pager -n 10 & JOURNAL_PID=\\$!; while true; do if sudo systemctl is-active {}.service --quiet; then echo; echo \\\"========================================\\\"; echo \\\"Service is now active!\\\"; kill \\$JOURNAL_PID 2>/dev/null; break; fi; sleep 1; done; wait \\$JOURNAL_PID 2>/dev/null; }} && sudo systemctl status {}.service --no-pager -l\"\necho\necho \"========================================\"\necho \"Operation completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'",
service_name,
hostname,
connection_ip,
self.config.ssh.rebuild_user,
connection_ip,
service_name,
service_name,
service_name,
service_name
);
std::process::Command::new("tmux")
.arg("split-window")
.arg("-v")
.arg("-p")
.arg("30")
.arg(&service_start_command)
.spawn()
.ok(); // Ignore errors, tmux will handle them
}
} }
KeyCode::Char('S') => { KeyCode::Char('S') => {
// Service stop command // Service stop command via SSH with progress display
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()) {
if self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()) { let connection_ip = self.get_connection_ip(&hostname);
return Ok(Some(UiCommand::ServiceStop { hostname, service_name })); let service_stop_command = format!(
} "bash -c 'cat << \"EOF\"\nService Stop: {}.service\nTarget: {} ({})\n\nEOF\nssh -tt {}@{} \"echo \\\"Stopping service...\\\" && sudo systemctl stop {}.service && echo \\\"Service stopped! Final logs:\\\" && echo \\\"========================================\\\" && sudo journalctl -u {}.service --no-pager -n 10 && echo \\\"========================================\\\" && sudo systemctl status {}.service --no-pager -l\"\necho\necho \"========================================\"\necho \"Operation completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'",
service_name,
hostname,
connection_ip,
self.config.ssh.rebuild_user,
connection_ip,
service_name,
service_name,
service_name
);
std::process::Command::new("tmux")
.arg("split-window")
.arg("-v")
.arg("-p")
.arg("30")
.arg(&service_stop_command)
.spawn()
.ok(); // Ignore errors, tmux will handle them
} }
} }
KeyCode::Char('J') => { KeyCode::Char('J') => {
@@ -335,13 +369,6 @@ impl TuiApp {
} }
} }
} }
KeyCode::Char('b') => {
// Trigger backup
if let Some(hostname) = self.current_host.clone() {
self.start_command(&hostname, CommandType::BackupTrigger, hostname.clone());
return Ok(Some(UiCommand::TriggerBackup { hostname }));
}
}
KeyCode::Char('w') => { KeyCode::Char('w') => {
// Wake on LAN for offline hosts // Wake on LAN for offline hosts
if let Some(hostname) = self.current_host.clone() { if let Some(hostname) = self.current_host.clone() {
@@ -414,7 +441,7 @@ impl TuiApp {
_ => {} _ => {}
} }
} }
Ok(None) Ok(())
} }
/// Navigate between hosts /// Navigate between hosts
@@ -468,86 +495,8 @@ impl TuiApp {
self.should_quit self.should_quit
} }
/// Get current service status for state-aware command validation
fn get_current_service_status(&self, hostname: &str, service_name: &str) -> Option<String> {
if let Some(host_widgets) = self.host_widgets.get(hostname) {
return host_widgets.services_widget.get_service_status(service_name);
}
None
}
/// Start command execution with immediate visual feedback
pub fn start_command(&mut self, hostname: &str, command_type: CommandType, target: String) -> bool {
// Get current service status to validate command
let current_status = self.get_current_service_status(hostname, &target);
// Validate if command makes sense for current state
let should_execute = match (&command_type, current_status.as_deref()) {
(CommandType::ServiceStart, Some("inactive") | Some("failed") | Some("dead")) => true,
(CommandType::ServiceStop, Some("active")) => true,
(CommandType::ServiceStart, Some("active")) => {
// Already running - don't execute
false
},
(CommandType::ServiceStop, Some("inactive") | Some("failed") | Some("dead")) => {
// Already stopped - don't execute
false
},
(_, None) => {
// Unknown service state - allow command to proceed
true
},
_ => true, // Default: allow other combinations
};
// ALWAYS store the pending transition for immediate visual feedback, even if we don't execute
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
host_widgets.pending_service_transitions.insert(
target.clone(),
(command_type, current_status.unwrap_or_else(|| "unknown".to_string()), Instant::now())
);
}
should_execute
}
/// Clear pending transitions when real status updates arrive or timeout
fn clear_completed_transitions(&mut self, hostname: &str, service_metrics: &[&Metric]) {
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
let mut completed_services = Vec::new();
// Check each pending transition to see if real status has changed
for (service_name, (command_type, original_status, _start_time)) in &host_widgets.pending_service_transitions {
// Look for status metric for this service
for metric in service_metrics {
if metric.name == format!("service_{}_status", service_name) {
let new_status = metric.value.as_string();
// Check if status has changed from original (command completed)
if &new_status != original_status {
// Verify it changed in the expected direction
let expected_change = match command_type {
CommandType::ServiceStart => &new_status == "active",
CommandType::ServiceStop => &new_status != "active",
_ => false,
};
if expected_change {
completed_services.push(service_name.clone());
}
}
break;
}
}
}
// Remove completed transitions
for service_name in completed_services {
host_widgets.pending_service_transitions.remove(&service_name);
}
}
}
@@ -635,14 +584,14 @@ impl TuiApp {
// Render services widget for current host // Render services widget for current host
if let Some(hostname) = self.current_host.clone() { if let Some(hostname) = self.current_host.clone() {
let is_focused = true; // Always show service selection let is_focused = true; // Always show service selection
let (scroll_offset, pending_transitions) = { let scroll_offset = {
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
(host_widgets.services_scroll_offset, host_widgets.pending_service_transitions.clone()) host_widgets.services_scroll_offset
}; };
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets host_widgets
.services_widget .services_widget
.render_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, &pending_transitions); // Services takes full right side .render(frame, content_chunks[1], is_focused, scroll_offset); // Services takes full right side
} }
// Render statusbar at the bottom // Render statusbar at the bottom

View File

@@ -9,7 +9,6 @@ use tracing::debug;
use super::Widget; use super::Widget;
use crate::ui::theme::{Components, StatusIcons, Theme, Typography}; use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
use crate::ui::CommandType;
use ratatui::style::Style; use ratatui::style::Style;
/// Services widget displaying hierarchical systemd service statuses /// Services widget displaying hierarchical systemd service statuses
@@ -125,20 +124,23 @@ impl ServicesWidget {
) )
} }
/// Get status icon for service, considering pending transitions for visual feedback
fn get_service_icon_and_status(&self, service_name: &str, info: &ServiceInfo, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) -> (String, String, ratatui::prelude::Color) {
// Check if this service has a pending transition
if let Some((command_type, _original_status, _start_time)) = pending_transitions.get(service_name) {
// Show transitional icons for pending commands
let (icon, status_text) = match command_type {
CommandType::ServiceStart => ("", "starting"),
CommandType::ServiceStop => ("", "stopping"),
_ => return (StatusIcons::get_icon(info.widget_status).to_string(), info.status.clone(), Theme::status_color(info.widget_status)), // Not a service command
};
return (icon.to_string(), status_text.to_string(), Theme::highlight());
}
// Normal status display
/// Create spans for sub-service with icon next to name
fn create_sub_service_spans(
&self,
name: &str,
info: &ServiceInfo,
is_last: bool,
) -> Vec<ratatui::text::Span<'static>> {
// Truncate long sub-service names to fit layout (accounting for indentation)
let short_name = if name.len() > 18 {
format!("{}...", &name[..15])
} else {
name.to_string()
};
// Get status icon and text
let icon = StatusIcons::get_icon(info.widget_status); let icon = StatusIcons::get_icon(info.widget_status);
let status_color = match info.widget_status { let status_color = match info.widget_status {
Status::Ok => Theme::success(), Status::Ok => Theme::success(),
@@ -149,38 +151,16 @@ impl ServicesWidget {
Status::Offline => Theme::muted_text(), Status::Offline => Theme::muted_text(),
}; };
(icon.to_string(), info.status.clone(), status_color) // For sub-services, prefer latency if available
} let status_str = if let Some(latency) = info.latency_ms {
if latency < 0.0 {
/// Create spans for sub-service with icon next to name, considering pending transitions
fn create_sub_service_spans_with_transitions(
&self,
name: &str,
info: &ServiceInfo,
is_last: bool,
pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>,
) -> Vec<ratatui::text::Span<'static>> {
// Truncate long sub-service names to fit layout (accounting for indentation)
let short_name = if name.len() > 18 {
format!("{}...", &name[..15])
} else {
name.to_string()
};
// Get status icon and text, considering pending transitions
let (icon, mut status_str, status_color) = self.get_service_icon_and_status(name, info, pending_transitions);
// For sub-services, prefer latency if available (unless transition is pending)
if !pending_transitions.contains_key(name) {
if let Some(latency) = info.latency_ms {
status_str = if latency < 0.0 {
"timeout".to_string() "timeout".to_string()
} else { } else {
format!("{:.0}ms", latency) format!("{:.0}ms", latency)
}
} else {
info.status.clone()
}; };
}
}
let tree_symbol = if is_last { "└─" } else { "├─" }; let tree_symbol = if is_last { "└─" } else { "├─" };
vec![ vec![
@@ -266,25 +246,6 @@ impl ServicesWidget {
self.parent_services.len() self.parent_services.len()
} }
/// Get current status of a specific service by name
pub fn get_service_status(&self, service_name: &str) -> Option<String> {
// Check if it's a parent service
if let Some(parent_info) = self.parent_services.get(service_name) {
return Some(parent_info.status.clone());
}
// Check sub-services (format: parent_sub)
for (parent_name, sub_list) in &self.sub_services {
for (sub_name, sub_info) in sub_list {
let full_sub_name = format!("{}_{}", parent_name, sub_name);
if full_sub_name == service_name {
return Some(sub_info.status.clone());
}
}
}
None
}
/// Calculate which parent service index corresponds to a display line index /// Calculate which parent service index corresponds to a display line index
fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize { fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize {
@@ -439,8 +400,8 @@ impl Widget for ServicesWidget {
impl ServicesWidget { impl ServicesWidget {
/// Render with focus, scroll, and pending transitions for visual feedback /// Render with focus and scroll
pub fn render_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) { pub fn render(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize) {
let services_block = Components::widget_block("services"); let services_block = Components::widget_block("services");
let inner_area = services_block.inner(area); let inner_area = services_block.inner(area);
frame.render_widget(services_block, area); frame.render_widget(services_block, area);
@@ -465,14 +426,14 @@ impl ServicesWidget {
return; return;
} }
// Use the existing render logic but with pending transitions // Render the services list
self.render_services_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, pending_transitions); self.render_services(frame, content_chunks[1], is_focused, scroll_offset);
} }
/// Render services list with pending transitions awareness /// Render services list
fn render_services_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) { fn render_services(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize) {
// Build hierarchical service list for display - include raw service name for pending transition lookups // Build hierarchical service list for display
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>, String)> = Vec::new(); // Added raw service name let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
// Sort parent services alphabetically for consistent order // Sort parent services alphabetically for consistent order
let mut parent_services: Vec<_> = self.parent_services.iter().collect(); let mut parent_services: Vec<_> = self.parent_services.iter().collect();
@@ -481,7 +442,7 @@ impl ServicesWidget {
for (parent_name, parent_info) in parent_services { for (parent_name, parent_info) in parent_services {
// Add parent service line // Add parent service line
let parent_line = self.format_parent_service_line(parent_name, parent_info); let parent_line = self.format_parent_service_line(parent_name, parent_info);
display_lines.push((parent_line, parent_info.widget_status, false, None, parent_name.clone())); // Include raw name display_lines.push((parent_line, parent_info.widget_status, false, None));
// Add sub-services for this parent (if any) // Add sub-services for this parent (if any)
if let Some(sub_list) = self.sub_services.get(parent_name) { if let Some(sub_list) = self.sub_services.get(parent_name) {
@@ -491,14 +452,12 @@ impl ServicesWidget {
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() { for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
let is_last_sub = i == sorted_subs.len() - 1; let is_last_sub = i == sorted_subs.len() - 1;
let full_sub_name = format!("{}_{}", parent_name, sub_name);
// Store sub-service info for custom span rendering // Store sub-service info for custom span rendering
display_lines.push(( display_lines.push((
sub_name.clone(), sub_name.clone(),
sub_info.widget_status, sub_info.widget_status,
true, true,
Some((sub_info.clone(), is_last_sub)), Some((sub_info.clone(), is_last_sub)),
full_sub_name, // Raw service name for pending transition lookup
)); // true = sub-service, with is_last info )); // true = sub-service, with is_last info
} }
} }
@@ -531,7 +490,7 @@ impl ServicesWidget {
.constraints(vec![Constraint::Length(1); lines_to_show]) .constraints(vec![Constraint::Length(1); lines_to_show])
.split(area); .split(area);
for (i, (line_text, line_status, is_sub, sub_info, raw_service_name)) in visible_lines.iter().enumerate() for (i, (line_text, line_status, is_sub, sub_info)) in visible_lines.iter().enumerate()
{ {
let actual_index = effective_scroll + i; // Real index in the full list let actual_index = effective_scroll + i; // Real index in the full list
@@ -545,41 +504,16 @@ impl ServicesWidget {
}; };
let mut spans = if *is_sub && sub_info.is_some() { let mut spans = if *is_sub && sub_info.is_some() {
// Use custom sub-service span creation WITH pending transitions // Use custom sub-service span creation
let (service_info, is_last) = sub_info.as_ref().unwrap(); let (service_info, is_last) = sub_info.as_ref().unwrap();
self.create_sub_service_spans_with_transitions(line_text, service_info, *is_last, pending_transitions) self.create_sub_service_spans(line_text, service_info, *is_last)
} else {
// Parent services - check if this parent service has a pending transition using RAW service name
if pending_transitions.contains_key(raw_service_name) {
// Create spans with transitional status
let (icon, status_text, _) = self.get_service_icon_and_status(raw_service_name, &ServiceInfo {
status: "".to_string(),
memory_mb: None,
disk_gb: None,
latency_ms: None,
widget_status: *line_status
}, pending_transitions);
// Use blue for transitional icons when not selected, background color when selected
let icon_color = if is_selected && !*is_sub && is_focused {
Theme::background() // Dark background color for visibility against blue selection
} else {
Theme::highlight() // Blue for normal case
};
vec![
ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
ratatui::text::Span::styled(line_text.clone(), Style::default().fg(Theme::primary_text())),
ratatui::text::Span::styled(format!(" {}", status_text), Style::default().fg(icon_color)),
]
} else { } else {
// Parent services - use normal status spans
StatusIcons::create_status_spans(*line_status, line_text) StatusIcons::create_status_spans(*line_status, line_text)
}
}; };
// Apply selection highlighting to parent services only, making icons background color when selected // Apply selection highlighting to parent services only
// Only show selection when Services panel is focused // Only show selection when Services panel is focused
// Show selection highlighting even when transitional icons are present
if is_selected && !*is_sub && is_focused { if is_selected && !*is_sub && is_focused {
for (i, span) in spans.iter_mut().enumerate() { for (i, span) in spans.iter_mut().enumerate() {
if i == 0 { if i == 0 {

View File

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