diff --git a/Cargo.lock b/Cargo.lock index 6f7e520..71978fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.73" +version = "0.1.74" dependencies = [ "anyhow", "chrono", @@ -292,7 +292,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.73" +version = "0.1.74" dependencies = [ "anyhow", "async-trait", @@ -315,7 +315,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.73" +version = "0.1.74" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 7dd43f8..f667dbf 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.74" +version = "0.1.75" edition = "2021" [dependencies] diff --git a/agent/src/service_tracker.rs b/agent/src/service_tracker.rs index 3f7d992..70254e0 100644 --- a/agent/src/service_tracker.rs +++ b/agent/src/service_tracker.rs @@ -90,14 +90,6 @@ impl UserStoppedServiceTracker { 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) pub fn clear_user_stopped(&mut self, service_name: &str) -> Result<()> { diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index a44fc36..92d517c 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.74" +version = "0.1.75" edition = "2021" [dependencies] diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs index d3ffd38..cbc42fd 100644 --- a/dashboard/src/app.rs +++ b/dashboard/src/app.rs @@ -12,7 +12,7 @@ use tracing::{debug, error, info, warn}; use crate::communication::{ZmqConsumer}; use crate::config::DashboardConfig; use crate::metrics::MetricStore; -use crate::ui::{TuiApp, UiCommand}; +use crate::ui::TuiApp; pub struct Dashboard { zmq_consumer: ZmqConsumer, @@ -141,16 +141,10 @@ impl Dashboard { match event::read() { Ok(event) => { if let Some(ref mut tui_app) = self.tui_app { - // Handle input and check for commands + // Handle input match tui_app.handle_input(event) { - Ok(Some(command)) => { - // Execute the command - 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 + Ok(_) => { + // Check if we should quit if tui_app.should_quit() { info!("Quit requested, exiting dashboard"); break; @@ -277,17 +271,6 @@ impl Dashboard { Ok(()) } - /// Execute a UI command by sending it to the appropriate agent - async fn execute_ui_command(&self, command: UiCommand) -> Result<()> { - match command { - UiCommand::TriggerBackup { hostname } => { - info!("Trigger backup requested for {}", hostname); - // TODO: Implement backup trigger command - info!("Backup trigger not yet implemented"); - } - } - Ok(()) - } } diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index 523f745..fa5e0ba 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -16,24 +16,12 @@ pub mod widgets; use crate::config::DashboardConfig; use crate::metrics::MetricStore; -use cm_dashboard_shared::{Metric, Status}; +use cm_dashboard_shared::Status; use theme::{Components, Layout as ThemeLayout, Theme, Typography}; use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget}; -/// Commands that can be triggered from the UI -#[derive(Debug, Clone)] -pub enum UiCommand { - TriggerBackup { hostname: String }, -} -/// Types of commands for status tracking -#[derive(Debug, Clone)] -pub enum CommandType { - ServiceStart, - ServiceStop, - BackupTrigger, -} /// Panel types for focus management @@ -52,8 +40,6 @@ pub struct HostWidgets { pub backup_scroll_offset: usize, /// Last update time for this host pub last_update: Option, - /// Pending service transitions for immediate visual feedback - pub pending_service_transitions: HashMap, // service_name -> (command_type, original_status, start_time) } impl HostWidgets { @@ -66,7 +52,6 @@ impl HostWidgets { services_scroll_offset: 0, backup_scroll_offset: 0, last_update: None, - pending_service_transitions: HashMap::new(), } } } @@ -159,8 +144,6 @@ impl TuiApp { } } - // 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); @@ -196,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(); self.available_hosts = all_hosts; @@ -234,7 +209,7 @@ impl TuiApp { } /// Handle keyboard input - pub fn handle_input(&mut self, event: Event) -> Result> { + pub fn handle_input(&mut self, event: Event) -> Result<()> { if let Event::Key(key) = event { match key.code { KeyCode::Char('q') => { @@ -297,12 +272,9 @@ impl TuiApp { 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()) { - // Start transition tracking for visual feedback - self.start_command(&hostname, CommandType::ServiceStart, service_name.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 {}@{} \"sudo systemctl start {}.service && echo \\\"Service started successfully\\\" && sudo systemctl status {}.service --no-pager -l\"\necho\necho \"========================================\"\necho \"Operation completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'", + "bash -c 'cat << \"EOF\"\nService Start: {}.service\nTarget: {} ({})\n\nEOF\nssh -tt {}@{} \"echo \\\"Starting service...\\\" && sudo systemctl start {}.service && echo \\\"Service started! Following logs (Ctrl+C to stop):\\\" && echo \\\"========================================\\\" && sudo journalctl -u {}.service -f --no-pager -n 20\"\necho\necho \"========================================\"\necho \"Operation completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'", service_name, hostname, connection_ip, @@ -325,18 +297,16 @@ impl TuiApp { KeyCode::Char('S') => { // Service stop command via SSH with progress display if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { - // Start transition tracking for visual feedback - self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()); - let connection_ip = self.get_connection_ip(&hostname); let service_stop_command = format!( - "bash -c 'cat << \"EOF\"\nService Stop: {}.service\nTarget: {} ({})\n\nEOF\nssh -tt {}@{} \"sudo systemctl stop {}.service && echo \\\"Service stopped successfully\\\" && sudo systemctl status {}.service --no-pager -l\"\necho\necho \"========================================\"\necho \"Operation completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'", + "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 ); @@ -397,13 +367,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') => { // Wake on LAN for offline hosts if let Some(hostname) = self.current_host.clone() { @@ -476,7 +439,7 @@ impl TuiApp { _ => {} } } - Ok(None) + Ok(()) } /// Navigate between hosts @@ -530,86 +493,8 @@ impl TuiApp { self.should_quit } - /// Get current service status for state-aware command validation - fn get_current_service_status(&self, hostname: &str, service_name: &str) -> Option { - 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); - } - } - } @@ -697,14 +582,14 @@ impl TuiApp { // Render services widget for current host if let Some(hostname) = self.current_host.clone() { 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); - (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); host_widgets .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 diff --git a/dashboard/src/ui/widgets/services.rs b/dashboard/src/ui/widgets/services.rs index 1456457..dc9829e 100644 --- a/dashboard/src/ui/widgets/services.rs +++ b/dashboard/src/ui/widgets/services.rs @@ -9,7 +9,6 @@ use tracing::debug; use super::Widget; use crate::ui::theme::{Components, StatusIcons, Theme, Typography}; -use crate::ui::CommandType; use ratatui::style::Style; /// 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, 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> { + // 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 status_color = match info.widget_status { Status::Ok => Theme::success(), @@ -148,39 +150,17 @@ impl ServicesWidget { Status::Unknown => Theme::muted_text(), Status::Offline => Theme::muted_text(), }; - - (icon.to_string(), info.status.clone(), status_color) - } - - /// 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, - ) -> Vec> { - // 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() - } else { - format!("{:.0}ms", latency) - }; + // For sub-services, prefer latency if available + let status_str = if let Some(latency) = info.latency_ms { + if latency < 0.0 { + "timeout".to_string() + } else { + format!("{:.0}ms", latency) } - } + } else { + info.status.clone() + }; let tree_symbol = if is_last { "└─" } else { "├─" }; vec![ @@ -266,25 +246,6 @@ impl ServicesWidget { self.parent_services.len() } - /// Get current status of a specific service by name - pub fn get_service_status(&self, service_name: &str) -> Option { - // 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 fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize { @@ -439,8 +400,8 @@ impl Widget for ServicesWidget { impl ServicesWidget { - /// 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) { + /// Render with focus and scroll + pub fn render(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize) { let services_block = Components::widget_block("services"); let inner_area = services_block.inner(area); frame.render_widget(services_block, area); @@ -465,14 +426,14 @@ impl ServicesWidget { return; } - // 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 the services list + self.render_services(frame, content_chunks[1], is_focused, scroll_offset); } - /// 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 - include raw service name for pending transition lookups - let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>, String)> = Vec::new(); // Added raw service name + /// Render services list + fn render_services(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize) { + // Build hierarchical service list for display + let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new(); // Sort parent services alphabetically for consistent order let mut parent_services: Vec<_> = self.parent_services.iter().collect(); @@ -481,7 +442,7 @@ impl ServicesWidget { for (parent_name, parent_info) in parent_services { // Add parent service line 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) 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() { 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 display_lines.push(( sub_name.clone(), sub_info.widget_status, true, Some((sub_info.clone(), is_last_sub)), - full_sub_name, // Raw service name for pending transition lookup )); // true = sub-service, with is_last info } } @@ -531,7 +490,7 @@ impl ServicesWidget { .constraints(vec![Constraint::Length(1); lines_to_show]) .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 @@ -545,41 +504,16 @@ impl ServicesWidget { }; 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(); - 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 { - StatusIcons::create_status_spans(*line_status, line_text) - } + // Parent services - use normal status spans + 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 - // Show selection highlighting even when transitional icons are present if is_selected && !*is_sub && is_focused { for (i, span) in spans.iter_mut().enumerate() { if i == 0 { diff --git a/shared/Cargo.toml b/shared/Cargo.toml index e65d13e..6c58ad3 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.74" +version = "0.1.75" edition = "2021" [dependencies]