diff --git a/Cargo.lock b/Cargo.lock index 4f88417..3e07c8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.22" +version = "0.1.23" dependencies = [ "anyhow", "chrono", @@ -291,7 +291,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.22" +version = "0.1.23" dependencies = [ "anyhow", "async-trait", @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.22" +version = "0.1.23" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 7c65b97..229c3c8 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.23" +version = "0.1.24" edition = "2021" [dependencies] diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 18ce133..d3b1854 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.23" +version = "0.1.24" edition = "2021" [dependencies] diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index a0a03d5..26c2cb3 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -14,7 +14,7 @@ use app::Dashboard; /// Get hardcoded version fn get_version() -> &'static str { - "v0.1.23" + "v0.1.24" } /// Check if running inside tmux session diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index ebf663a..a258525 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -7,7 +7,7 @@ use ratatui::{ Frame, }; use std::collections::HashMap; -use std::time::{Duration, Instant}; +use std::time::Instant; use tracing::info; pub mod theme; @@ -28,12 +28,6 @@ pub enum UiCommand { TriggerBackup { hostname: String }, } -/// Command execution status for visual feedback -#[derive(Debug, Clone)] -pub enum CommandStatus { - /// Command is executing - InProgress { command_type: CommandType, target: String, start_time: std::time::Instant }, -} /// Types of commands for status tracking #[derive(Debug, Clone)] @@ -70,8 +64,8 @@ pub struct HostWidgets { pub backup_scroll_offset: usize, /// Last update time for this host pub last_update: Option, - /// Active command status for visual feedback - pub command_status: Option, + /// Pending service transitions for immediate visual feedback + pub pending_service_transitions: HashMap, // service_name -> (command_type, original_status) } impl HostWidgets { @@ -84,7 +78,7 @@ impl HostWidgets { services_scroll_offset: 0, backup_scroll_offset: 0, last_update: None, - command_status: None, + pending_service_transitions: HashMap::new(), } } } @@ -133,8 +127,6 @@ impl TuiApp { /// Update widgets with metrics from store (only for current host) pub fn update_metrics(&mut self, metric_store: &MetricStore) { - // Check for command timeouts first - self.check_command_timeouts(); // Check for rebuild completion by agent hash change @@ -168,6 +160,9 @@ impl TuiApp { .copied() .collect(); + // Clear completed transitions first + self.clear_completed_transitions(&hostname, &service_metrics); + // Now get host widgets and update them let host_widgets = self.get_or_create_host_widgets(&hostname); @@ -209,9 +204,9 @@ impl TuiApp { // Sort hosts alphabetically let mut sorted_hosts = hosts.clone(); - // Keep hosts that have ongoing commands even if they're offline + // Keep hosts that have pending transitions even if they're offline for (hostname, host_widgets) in &self.host_widgets { - if let Some(CommandStatus::InProgress { .. }) = &host_widgets.command_status { + if !host_widgets.pending_service_transitions.is_empty() { if !sorted_hosts.contains(hostname) { sorted_hosts.push(hostname.clone()); } @@ -427,7 +422,7 @@ impl TuiApp { None } - /// Start command execution and track status for visual feedback (with state validation) + /// 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); @@ -438,13 +433,11 @@ impl TuiApp { (CommandType::ServiceStop, Some("active")) => true, (CommandType::ServiceRestart, Some("active") | Some("inactive") | Some("failed") | Some("dead")) => true, (CommandType::ServiceStart, Some("active")) => { - // Already running - show brief feedback but don't execute - // TODO: Could show a brief "already running" message + // Already running - don't execute false }, (CommandType::ServiceStop, Some("inactive") | Some("failed") | Some("dead")) => { - // Already stopped - show brief feedback but don't execute - // TODO: Could show a brief "already stopped" message + // Already stopped - don't execute false }, (_, None) => { @@ -456,36 +449,51 @@ impl TuiApp { if should_execute { if let Some(host_widgets) = self.host_widgets.get_mut(hostname) { - host_widgets.command_status = Some(CommandStatus::InProgress { - command_type, - target, - start_time: Instant::now(), - }); + // Store the pending transition for immediate visual feedback + host_widgets.pending_service_transitions.insert( + target.clone(), + (command_type, current_status.unwrap_or_else(|| "unknown".to_string())) + ); } } should_execute } - /// Check for command timeouts and automatically clear them - pub fn check_command_timeouts(&mut self) { - let now = Instant::now(); - 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 = Duration::from_secs(30); // 30 seconds for service commands - - if now.duration_since(*start_time) > timeout_duration { - hosts_to_clear.push(hostname.clone()); + /// Clear pending transitions when real status updates arrive + 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)) 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", + CommandType::ServiceRestart => true, // Any change indicates restart completed + _ => false, + }; + + if expected_change { + completed_services.push(service_name.clone()); + } + } + break; + } } } - } - - // Clear timed out commands - for hostname in hosts_to_clear { - if let Some(host_widgets) = self.host_widgets.get_mut(&hostname) { - host_widgets.command_status = None; + + // Remove completed transitions + for service_name in completed_services { + host_widgets.pending_service_transitions.remove(&service_name); } } } @@ -614,14 +622,14 @@ impl TuiApp { // Render services widget for current host if let Some(hostname) = self.current_host.clone() { let is_focused = self.focused_panel == PanelType::Services; - let (scroll_offset, command_status) = { + let (scroll_offset, pending_transitions) = { let host_widgets = self.get_or_create_host_widgets(&hostname); - (host_widgets.services_scroll_offset, host_widgets.command_status.clone()) + (host_widgets.services_scroll_offset, host_widgets.pending_service_transitions.clone()) }; let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets .services_widget - .render_with_command_status(frame, content_chunks[1], is_focused, scroll_offset, command_status.as_ref()); // Services takes full right side + .render_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, &pending_transitions); // Services takes full right side } // Render statusbar at the bottom diff --git a/dashboard/src/ui/widgets/services.rs b/dashboard/src/ui/widgets/services.rs index a5f82e2..d431c54 100644 --- a/dashboard/src/ui/widgets/services.rs +++ b/dashboard/src/ui/widgets/services.rs @@ -9,7 +9,7 @@ use tracing::debug; use super::Widget; use crate::ui::theme::{Components, StatusIcons, Theme, Typography}; -use crate::ui::{CommandStatus, CommandType}; +use crate::ui::CommandType; use ratatui::style::Style; /// Services widget displaying hierarchical systemd service statuses @@ -128,25 +128,18 @@ impl ServicesWidget { ) } - /// Get status icon for service, considering command status for visual feedback - fn get_service_icon_and_status(&self, service_name: &str, info: &ServiceInfo, command_status: Option<&CommandStatus>) -> (String, String, ratatui::prelude::Color) { - // Check if this service is currently being operated on - if let Some(status) = command_status { - match status { - CommandStatus::InProgress { command_type, target, .. } => { - if target == service_name { - // Only show special icons for service commands - if let Some((icon, status_text)) = match command_type { - CommandType::ServiceRestart => Some(("↻", "restarting")), - CommandType::ServiceStart => Some(("↑", "starting")), - CommandType::ServiceStop => Some(("↓", "stopping")), - _ => None, // Don't handle non-service commands here - } { - return (icon.to_string(), status_text.to_string(), Theme::highlight()); - } - } - } - } + /// 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, String, ratatui::prelude::Color) { + // Check if this service has a pending transition + if let Some((command_type, _original_status)) = pending_transitions.get(service_name) { + // Show transitional icons for pending commands + let (icon, status_text) = match command_type { + CommandType::ServiceRestart => ("↻", "restarting"), + 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 @@ -163,13 +156,13 @@ impl ServicesWidget { } - /// Create spans for sub-service with icon next to name, considering command status - fn create_sub_service_spans_with_status( + /// 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, - command_status: Option<&CommandStatus>, + pending_transitions: &HashMap, ) -> Vec> { // Truncate long sub-service names to fit layout (accounting for indentation) let short_name = if name.len() > 18 { @@ -178,11 +171,11 @@ impl ServicesWidget { name.to_string() }; - // Get status icon and text, considering command status - let (icon, mut status_str, status_color) = self.get_service_icon_and_status(name, info, command_status); + // 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 command is in progress) - if command_status.is_none() { + // 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() @@ -446,8 +439,8 @@ impl Widget for ServicesWidget { impl ServicesWidget { - /// Render with focus, scroll, and command status for visual feedback - pub fn render_with_command_status(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, command_status: Option<&CommandStatus>) { + /// Render with focus, scroll, and pending transitions for visual feedback + pub fn render_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap) { let services_block = if is_focused { Components::focused_widget_block("services") } else { @@ -476,12 +469,12 @@ impl ServicesWidget { return; } - // Use the existing render logic but with command status - self.render_services_with_status(frame, content_chunks[1], is_focused, scroll_offset, command_status); + // Use the existing render logic but with pending transitions + self.render_services_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, pending_transitions); } - /// Render services list with command status awareness - fn render_services_with_status(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, command_status: Option<&CommandStatus>) { + /// Render services list with pending transitions awareness + fn render_services_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap) { // Build hierarchical service list for display (same as existing logic) let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new(); @@ -554,37 +547,28 @@ impl ServicesWidget { }; let mut spans = if *is_sub && sub_info.is_some() { - // Use custom sub-service span creation WITH command status + // Use custom sub-service span creation WITH pending transitions let (service_info, is_last) = sub_info.as_ref().unwrap(); - self.create_sub_service_spans_with_status(line_text, service_info, *is_last, command_status) + self.create_sub_service_spans_with_transitions(line_text, service_info, *is_last, pending_transitions) } else { - // Parent services - check if this parent service has a command in progress - let service_spans = if let Some(status) = command_status { - match status { - CommandStatus::InProgress { target, .. } => { - if target == line_text { - // Create spans with progress status - let (icon, status_text, status_color) = self.get_service_icon_and_status(line_text, &ServiceInfo { - status: "".to_string(), - memory_mb: None, - disk_gb: None, - latency_ms: None, - widget_status: *line_status - }, command_status); - vec![ - ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(status_color)), - ratatui::text::Span::styled(line_text.clone(), Style::default().fg(Theme::primary_text())), - ratatui::text::Span::styled(format!(" {}", status_text), Style::default().fg(status_color)), - ] - } else { - StatusIcons::create_status_spans(*line_status, line_text) - } - } - } + // Parent services - check if this parent service has a pending transition + if pending_transitions.contains_key(line_text) { + // Create spans with transitional status + let (icon, status_text, status_color) = self.get_service_icon_and_status(line_text, &ServiceInfo { + status: "".to_string(), + memory_mb: None, + disk_gb: None, + latency_ms: None, + widget_status: *line_status + }, pending_transitions); + vec![ + ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(status_color)), + ratatui::text::Span::styled(line_text.clone(), Style::default().fg(Theme::primary_text())), + ratatui::text::Span::styled(format!(" {}", status_text), Style::default().fg(status_color)), + ] } else { StatusIcons::create_status_spans(*line_status, line_text) - }; - service_spans + } }; // Apply selection highlighting to parent services only, preserving status icon color diff --git a/shared/Cargo.toml b/shared/Cargo.toml index ac38834..e6fcdca 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.23" +version = "0.1.24" edition = "2021" [dependencies]