Implement immediate transitional service icons with pending state tracking
All checks were successful
Build and Release / build-and-release (push) Successful in 2m8s

- Replace timeout-based command status with pending service transitions
- Show immediate directional arrows when pressing service commands (↑ ↓ ↻)
- Track original service status and command type for each pending operation
- Automatically clear transitional icons when real status updates arrive
- Remove unused TerminalPopup and CommandStatus infrastructure
- Simplify visual feedback system using state-based approach

Service commands now provide instant visual feedback that persists until
the actual service state changes, eliminating timing issues and improving UX.
This commit is contained in:
2025-10-28 14:11:59 +01:00
parent ae70946c61
commit 71b9f93d7c
7 changed files with 103 additions and 111 deletions

View File

@@ -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<Instant>,
/// Active command status for visual feedback
pub command_status: Option<CommandStatus>,
/// Pending service transitions for immediate visual feedback
pub pending_service_transitions: HashMap<String, (CommandType, String)>, // 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