diff --git a/Cargo.lock b/Cargo.lock index f8b34c7..4f88417 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.21" +version = "0.1.22" dependencies = [ "anyhow", "chrono", @@ -291,7 +291,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.21" +version = "0.1.22" dependencies = [ "anyhow", "async-trait", @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.21" +version = "0.1.22" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 6dc7c20..7c65b97 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.22" +version = "0.1.23" edition = "2021" [dependencies] diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 8b279c8..18ce133 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.22" +version = "0.1.23" edition = "2021" [dependencies] diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs index b472e19..7ca4a67 100644 --- a/dashboard/src/app.rs +++ b/dashboard/src/app.rs @@ -263,12 +263,7 @@ impl Dashboard { 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 - } + // Command output (terminal popup removed - output not displayed) } last_metrics_check = Instant::now(); diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index 6ee4dba..a0a03d5 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.22" + "v0.1.23" } /// Check if running inside tmux session diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index b5bad87..ebf663a 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -89,50 +89,6 @@ 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, - /// 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 { @@ -150,8 +106,6 @@ 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, /// Dashboard configuration config: DashboardConfig, } @@ -166,7 +120,6 @@ impl TuiApp { focused_panel: PanelType::System, // Start with System panel focused should_quit: false, user_navigated_away: false, - terminal_popup: None, config, } } @@ -297,38 +250,6 @@ impl TuiApp { /// Handle keyboard input pub fn handle_input(&mut self, event: Event) -> Result> { 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; @@ -361,8 +282,9 @@ impl TuiApp { PanelType::Services => { // Service restart command if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { - self.start_command(&hostname, CommandType::ServiceRestart, service_name.clone()); - return Ok(Some(UiCommand::ServiceRestart { hostname, service_name })); + if self.start_command(&hostname, CommandType::ServiceRestart, service_name.clone()) { + return Ok(Some(UiCommand::ServiceRestart { hostname, service_name })); + } } } _ => { @@ -374,8 +296,9 @@ impl TuiApp { if self.focused_panel == PanelType::Services { // Service start command if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { - self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()); - return Ok(Some(UiCommand::ServiceStart { hostname, service_name })); + if self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()) { + return Ok(Some(UiCommand::ServiceStart { hostname, service_name })); + } } } } @@ -383,8 +306,9 @@ impl TuiApp { if self.focused_panel == PanelType::Services { // Service stop command if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { - self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()); - return Ok(Some(UiCommand::ServiceStop { hostname, service_name })); + if self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()) { + return Ok(Some(UiCommand::ServiceStop { hostname, service_name })); + } } } } @@ -495,26 +419,54 @@ impl TuiApp { self.should_quit } - /// Start command execution and track status for visual feedback - pub fn start_command(&mut self, hostname: &str, command_type: CommandType, target: String) { - 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(), - }); + /// 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 } - /// Mark command as completed successfully - pub fn _complete_command(&mut self, hostname: &str) { - if let Some(host_widgets) = self.host_widgets.get_mut(hostname) { - // Simply clear the command status when completed - host_widgets.command_status = None; + /// Start command execution and track status for visual feedback (with state validation) + 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::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 + 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 + false + }, + (_, None) => { + // Unknown service state - allow command to proceed + true + }, + _ => true, // Default: allow other combinations + }; + + 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(), + }); + } } + + should_execute } - /// Check for command timeouts and automatically clear them pub fn check_command_timeouts(&mut self) { let now = Instant::now(); @@ -538,26 +490,6 @@ impl TuiApp { } } - /// 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); - } - } - } - - /// 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() { @@ -695,12 +627,6 @@ 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 @@ -881,112 +807,5 @@ 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 = 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); - } } diff --git a/dashboard/src/ui/widgets/services.rs b/dashboard/src/ui/widgets/services.rs index 10389d8..a5f82e2 100644 --- a/dashboard/src/ui/widgets/services.rs +++ b/dashboard/src/ui/widgets/services.rs @@ -273,6 +273,26 @@ 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 { // Build the same display list to map line index to parent service index diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 5462aec..ac38834 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.22" +version = "0.1.23" edition = "2021" [dependencies]