Compare commits

..

4 Commits

Author SHA1 Message Date
5b12c12228 Fix transitional icons by always storing pending transitions for visual feedback
All checks were successful
Build and Release / build-and-release (push) Successful in 1m13s
- Store pending transitions even for redundant commands (start active service)
- Add 3-second timeout for redundant command visual feedback
- Include timestamp in pending transitions to enable timeout clearing
- Show directional arrows immediately regardless of command validation result
- Fix core issue where state validation prevented visual feedback storage

Now pressing s/S/r always shows immediate directional arrows, even for
redundant operations, providing consistent visual feedback to users.
2025-10-28 14:38:33 +01:00
651b801de3 Fix transitional service icons being overridden by selection highlighting
All checks were successful
Build and Release / build-and-release (push) Successful in 1m14s
- Prevent selection highlighting when service has pending transition
- Allow directional arrows (↑ ↓ ↻) to show through on selected services
- Fix core issue where selection styling was overwriting transitional icons
- Transitional icons now properly visible during service command execution

The selection highlighting logic now skips services with pending transitions,
ensuring that directional arrows are visible when executing service commands.
2025-10-28 14:22:40 +01:00
71b9f93d7c 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.
2025-10-28 14:11:59 +01:00
ae70946c61 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.
2025-10-28 13:48:24 +01:00
8 changed files with 173 additions and 341 deletions

6
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

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, Instant)>, // service_name -> (command_type, original_status, start_time)
}
impl HostWidgets {
@@ -84,55 +78,11 @@ impl HostWidgets {
services_scroll_offset: 0,
backup_scroll_offset: 0,
last_update: None,
command_status: None,
pending_service_transitions: HashMap::new(),
}
}
}
/// 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 +100,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 +114,6 @@ impl TuiApp {
focused_panel: PanelType::System, // Start with System panel focused
should_quit: false,
user_navigated_away: false,
terminal_popup: None,
config,
}
}
@@ -180,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
@@ -215,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);
@@ -256,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());
}
@@ -297,38 +245,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 +277,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 +291,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 +301,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,69 +414,95 @@ 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;
}
}
/// 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();
/// 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);
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
// 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 - 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();
let now = Instant::now();
// Check each pending transition to see if real status has changed or timed out
for (service_name, (command_type, original_status, start_time)) in &host_widgets.pending_service_transitions {
// Clear if too much time has passed (3 seconds for redundant commands)
if now.duration_since(*start_time).as_secs() > 3 {
completed_services.push(service_name.clone());
continue;
}
if now.duration_since(*start_time) > timeout_duration {
hosts_to_clear.push(hostname.clone());
// 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);
}
}
}
/// 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() {
@@ -682,25 +627,19 @@ 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
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 +820,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

@@ -9,7 +9,7 @@ use tracing::debug;
use super::Widget;
use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
use crate::ui::{CommandStatus, CommandType};
use crate::ui::CommandType;
use ratatui::style::Style;
/// Services widget displaying hierarchical systemd service statuses
@@ -128,25 +128,18 @@ impl ServicesWidget {
)
}
/// Get status icon for service, considering command status for visual feedback
fn get_service_icon_and_status(&self, service_name: &str, info: &ServiceInfo, command_status: Option<&CommandStatus>) -> (String, String, ratatui::prelude::Color) {
// Check if this service is currently being operated on
if let Some(status) = command_status {
match status {
CommandStatus::InProgress { command_type, target, .. } => {
if target == service_name {
// Only show special icons for service commands
if let Some((icon, status_text)) = match command_type {
CommandType::ServiceRestart => Some(("", "restarting")),
CommandType::ServiceStart => Some(("", "starting")),
CommandType::ServiceStop => Some(("", "stopping")),
_ => None, // Don't handle non-service commands here
} {
return (icon.to_string(), status_text.to_string(), Theme::highlight());
}
}
}
}
/// 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, (CommandType, String, std::time::Instant)>) -> (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::ServiceRestart => ("", "restarting"),
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
@@ -163,13 +156,13 @@ impl ServicesWidget {
}
/// Create spans for sub-service with icon next to name, considering command status
fn create_sub_service_spans_with_status(
/// 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,
command_status: Option<&CommandStatus>,
pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>,
) -> Vec<ratatui::text::Span<'static>> {
// Truncate long sub-service names to fit layout (accounting for indentation)
let short_name = if name.len() > 18 {
@@ -178,11 +171,11 @@ impl ServicesWidget {
name.to_string()
};
// Get status icon and text, considering command status
let (icon, mut status_str, status_color) = self.get_service_icon_and_status(name, info, command_status);
// 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 command is in progress)
if command_status.is_none() {
// 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()
@@ -273,6 +266,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
@@ -426,8 +439,8 @@ impl Widget for ServicesWidget {
impl ServicesWidget {
/// Render with focus, scroll, and command status for visual feedback
pub fn render_with_command_status(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, command_status: Option<&CommandStatus>) {
/// 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<String, (CommandType, String, std::time::Instant)>) {
let services_block = if is_focused {
Components::focused_widget_block("services")
} else {
@@ -456,12 +469,12 @@ impl ServicesWidget {
return;
}
// Use the existing render logic but with command status
self.render_services_with_status(frame, content_chunks[1], is_focused, scroll_offset, command_status);
// 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 services list with command status awareness
fn render_services_with_status(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, command_status: Option<&CommandStatus>) {
/// 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<String, (CommandType, String, std::time::Instant)>) {
// Build hierarchical service list for display (same as existing logic)
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
@@ -534,42 +547,34 @@ impl ServicesWidget {
};
let mut spans = if *is_sub && sub_info.is_some() {
// Use custom sub-service span creation WITH command status
// Use custom sub-service span creation WITH pending transitions
let (service_info, is_last) = sub_info.as_ref().unwrap();
self.create_sub_service_spans_with_status(line_text, service_info, *is_last, command_status)
self.create_sub_service_spans_with_transitions(line_text, service_info, *is_last, pending_transitions)
} else {
// Parent services - check if this parent service has a command in progress
let service_spans = if let Some(status) = command_status {
match status {
CommandStatus::InProgress { target, .. } => {
if target == line_text {
// Create spans with progress status
let (icon, status_text, status_color) = self.get_service_icon_and_status(line_text, &ServiceInfo {
status: "".to_string(),
memory_mb: None,
disk_gb: None,
latency_ms: None,
widget_status: *line_status
}, command_status);
vec![
ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(status_color)),
ratatui::text::Span::styled(line_text.clone(), Style::default().fg(Theme::primary_text())),
ratatui::text::Span::styled(format!(" {}", status_text), Style::default().fg(status_color)),
]
} else {
StatusIcons::create_status_spans(*line_status, line_text)
}
}
}
// Parent services - check if this parent service has a pending transition
if pending_transitions.contains_key(line_text) {
// Create spans with transitional status
let (icon, status_text, status_color) = self.get_service_icon_and_status(line_text, &ServiceInfo {
status: "".to_string(),
memory_mb: None,
disk_gb: None,
latency_ms: None,
widget_status: *line_status
}, pending_transitions);
vec![
ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(status_color)),
ratatui::text::Span::styled(line_text.clone(), Style::default().fg(Theme::primary_text())),
ratatui::text::Span::styled(format!(" {}", status_text), Style::default().fg(status_color)),
]
} else {
StatusIcons::create_status_spans(*line_status, line_text)
};
service_spans
}
};
// Apply selection highlighting to parent services only, preserving status icon color
// Only show selection when Services panel is focused
if is_selected && !*is_sub && is_focused {
// IMPORTANT: Don't override transitional icons that show pending commands
if is_selected && !*is_sub && is_focused && !pending_transitions.contains_key(line_text) {
for (i, span) in spans.iter_mut().enumerate() {
if i == 0 {
// First span is the status icon - preserve its color

View File

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