Implement state-aware service command validation with immediate visual feedback
All checks were successful
Build and Release / build-and-release (push) Successful in 1m12s
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:
parent
2910b7d875
commit
ae70946c61
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
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<TerminalPopup>,
|
||||
/// 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<Option<UiCommand>> {
|
||||
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,25 +419,53 @@ 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<String> {
|
||||
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) {
|
||||
@ -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<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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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<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
|
||||
fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize {
|
||||
// Build the same display list to map line index to parent service index
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user