Implement state-aware service command validation with immediate visual feedback
All checks were successful
Build and Release / build-and-release (push) Successful in 1m12s

- Add service state detection before executing start/stop/restart commands
- Prevent redundant operations (start active services, stop inactive services)
- Show immediate directional arrows for command feedback (↑ starting, ↓ stopping, ↻ restarting)
- Add get_service_status() method to ServicesWidget for state access
- Remove unused TerminalPopup code and dangling methods
- Clean up warnings and unused code throughout codebase

Service commands now validate current state and provide instant UX feedback while
preserving existing status icons and colors during transitions.
This commit is contained in:
Christoffer Martinsson 2025-10-28 13:48:24 +01:00
parent 2910b7d875
commit ae70946c61
8 changed files with 79 additions and 245 deletions

6
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -263,12 +263,7 @@ impl Dashboard {
cmd_output.output_line cmd_output.output_line
); );
// Forward to TUI if not headless // Command output (terminal popup removed - output not displayed)
if let Some(ref mut tui_app) = self.tui_app {
tui_app.add_terminal_output(&cmd_output.hostname, cmd_output.output_line);
// Note: Popup stays open for manual review - close with ESC/Q
}
} }
last_metrics_check = Instant::now(); last_metrics_check = Instant::now();

View File

@ -14,7 +14,7 @@ use app::Dashboard;
/// Get hardcoded version /// Get hardcoded version
fn get_version() -> &'static str { fn get_version() -> &'static str {
"v0.1.22" "v0.1.23"
} }
/// Check if running inside tmux session /// Check if running inside tmux session

View File

@ -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<String>,
/// Scroll offset for the output
pub scroll_offset: usize,
/// Start time of the operation
pub start_time: Instant,
}
impl TerminalPopup {
pub fn _new(command_type: CommandType, hostname: String, target: String) -> Self {
Self {
visible: true,
_command_type: command_type,
hostname,
target,
output_lines: Vec::new(),
scroll_offset: 0,
start_time: Instant::now(),
}
}
pub fn add_output_line(&mut self, line: String) {
self.output_lines.push(line);
// Auto-scroll to bottom when new content arrives
if self.output_lines.len() > 20 {
self.scroll_offset = self.output_lines.len().saturating_sub(20);
}
}
pub fn close(&mut self) {
self.visible = false;
}
}
/// Main TUI application /// Main TUI application
pub struct TuiApp { pub struct TuiApp {
@ -150,8 +106,6 @@ pub struct TuiApp {
should_quit: bool, should_quit: bool,
/// Track if user manually navigated away from localhost /// Track if user manually navigated away from localhost
user_navigated_away: bool, user_navigated_away: bool,
/// Terminal popup for streaming command output
terminal_popup: Option<TerminalPopup>,
/// Dashboard configuration /// Dashboard configuration
config: DashboardConfig, config: DashboardConfig,
} }
@ -166,7 +120,6 @@ impl TuiApp {
focused_panel: PanelType::System, // Start with System panel focused focused_panel: PanelType::System, // Start with System panel focused
should_quit: false, should_quit: false,
user_navigated_away: false, user_navigated_away: false,
terminal_popup: None,
config, config,
} }
} }
@ -297,38 +250,6 @@ 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<Option<UiCommand>> {
if let Event::Key(key) = event { 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 { match key.code {
KeyCode::Char('q') => { KeyCode::Char('q') => {
self.should_quit = true; self.should_quit = true;
@ -361,8 +282,9 @@ impl TuiApp {
PanelType::Services => { PanelType::Services => {
// Service restart command // Service restart command
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()) {
self.start_command(&hostname, CommandType::ServiceRestart, service_name.clone()); if self.start_command(&hostname, CommandType::ServiceRestart, service_name.clone()) {
return Ok(Some(UiCommand::ServiceRestart { hostname, service_name })); return Ok(Some(UiCommand::ServiceRestart { hostname, service_name }));
}
} }
} }
_ => { _ => {
@ -374,8 +296,9 @@ impl TuiApp {
if self.focused_panel == PanelType::Services { if self.focused_panel == PanelType::Services {
// Service start command // Service start command
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()) {
self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()); if self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()) {
return Ok(Some(UiCommand::ServiceStart { hostname, service_name })); return Ok(Some(UiCommand::ServiceStart { hostname, service_name }));
}
} }
} }
} }
@ -383,8 +306,9 @@ impl TuiApp {
if self.focused_panel == PanelType::Services { if self.focused_panel == PanelType::Services {
// Service stop command // Service stop command
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()) {
self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()); if self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()) {
return Ok(Some(UiCommand::ServiceStop { hostname, service_name })); return Ok(Some(UiCommand::ServiceStop { hostname, service_name }));
}
} }
} }
} }
@ -495,26 +419,54 @@ impl TuiApp {
self.should_quit self.should_quit
} }
/// Start command execution and track status for visual feedback /// Get current service status for state-aware command validation
pub fn start_command(&mut self, hostname: &str, command_type: CommandType, target: String) { fn get_current_service_status(&self, hostname: &str, service_name: &str) -> Option<String> {
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) { if let Some(host_widgets) = self.host_widgets.get(hostname) {
host_widgets.command_status = Some(CommandStatus::InProgress { return host_widgets.services_widget.get_service_status(service_name);
command_type,
target,
start_time: Instant::now(),
});
} }
None
} }
/// Mark command as completed successfully /// Start command execution and track status for visual feedback (with state validation)
pub fn _complete_command(&mut self, hostname: &str) { pub fn start_command(&mut self, hostname: &str, command_type: CommandType, target: String) -> bool {
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) { // Get current service status to validate command
// Simply clear the command status when completed let current_status = self.get_current_service_status(hostname, &target);
host_widgets.command_status = None;
// 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 /// Check for command timeouts and automatically clear them
pub fn check_command_timeouts(&mut self) { pub fn check_command_timeouts(&mut self) {
let now = Instant::now(); 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 /// Scroll the focused panel up or down
pub fn scroll_focused_panel(&mut self, direction: i32) { pub fn scroll_focused_panel(&mut self, direction: i32) {
if let Some(hostname) = self.current_host.clone() { if let Some(hostname) = self.current_host.clone() {
@ -695,12 +627,6 @@ impl TuiApp {
// Render statusbar at the bottom // Render statusbar at the bottom
self.render_statusbar(frame, main_chunks[2]); // main_chunks[2] is the statusbar area 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 /// 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<Line> = popup.output_lines[start_line..end_line]
.iter()
.map(|line| {
// Style output lines with terminal colors
if line.contains("error") || line.contains("Error") || line.contains("failed") {
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Red)))
} else if line.contains("warning") || line.contains("Warning") {
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Yellow)))
} else if line.contains("building") || line.contains("Building") {
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Blue)))
} else if line.contains("") || line.contains("success") || line.contains("completed") {
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Green)))
} else {
Line::from(Span::styled(line.clone(), Style::default().fg(Color::White)))
}
})
.collect();
let content = Paragraph::new(visible_lines)
.wrap(Wrap { trim: false })
.style(Style::default().bg(Color::Black));
frame.render_widget(content, inner_area);
// Render scroll indicator if needed
if total_lines > available_height {
let scroll_info = format!(" {}% ",
if total_lines > 0 {
(end_line * 100) / total_lines
} else {
100
}
);
let scroll_area = Rect {
x: popup_area.x + popup_area.width - scroll_info.len() as u16 - 1,
y: popup_area.y + popup_area.height - 1,
width: scroll_info.len() as u16,
height: 1,
};
let scroll_widget = Paragraph::new(scroll_info)
.style(Style::default().fg(Color::Cyan).bg(Color::Black));
frame.render_widget(scroll_widget, scroll_area);
}
// Instructions at bottom
let instructions = " ESC/Q: Close • ↑↓: Scroll ";
let instructions_area = Rect {
x: popup_area.x + 1,
y: popup_area.y + popup_area.height - 1,
width: instructions.len() as u16,
height: 1,
};
let instructions_widget = Paragraph::new(instructions)
.style(Style::default().fg(Color::Gray).bg(Color::Black));
frame.render_widget(instructions_widget, instructions_area);
}
} }

View File

@ -273,6 +273,26 @@ 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 {
// Build the same display list to map line index to parent service index // Build the same display list to map line index to parent service index

View File

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