Implement remote command execution and visual feedback for service control
This implements the core functionality for executing remote commands through the dashboard and providing real-time visual feedback to users. Key Features: - Remote service control (start/stop/restart) via existing keyboard shortcuts - System rebuild command with maintenance mode integration - Real-time visual feedback with service status transitions - ZMQ command protocol extension for service and system operations Implementation Details: - Extended AgentCommand enum with ServiceControl and SystemRebuild variants - Added agent-side handlers for systemctl and nixos-rebuild execution - Implemented command status tracking system for visual feedback - Enhanced services widget to show progress states (⏳ restarting) - Integrated command execution with existing keyboard navigation Keyboard Controls: - Services Panel: Space (start/stop), R (restart) - System Panel: R (nixos-rebuild switch) - Backup Panel: B (trigger backup) Technical Architecture: - Command flow: UI → Dashboard → ZMQ → Agent → systemctl/nixos-rebuild - Status tracking: InProgress/Success/Failed states with visual indicators - Maintenance mode: Automatic /tmp/cm-maintenance file management - Service feedback: Icon transitions (● → ⏳ → ● with status text)
This commit is contained in:
@@ -9,6 +9,7 @@ use tracing::debug;
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
|
||||
use crate::ui::{CommandStatus, CommandType};
|
||||
use ratatui::style::Style;
|
||||
|
||||
/// Services widget displaying hierarchical systemd service statuses
|
||||
@@ -127,12 +128,56 @@ 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 {
|
||||
let status_text = match command_type {
|
||||
CommandType::ServiceRestart => "restarting",
|
||||
CommandType::ServiceStart => "starting",
|
||||
CommandType::ServiceStop => "stopping",
|
||||
_ => &info.status,
|
||||
};
|
||||
return ("⏳".to_string(), status_text.to_string(), Theme::highlight());
|
||||
}
|
||||
}
|
||||
_ => {} // Success/Failed states will show normal status
|
||||
}
|
||||
}
|
||||
|
||||
// Normal status display
|
||||
let icon = StatusIcons::get_icon(info.widget_status);
|
||||
let status_color = match info.widget_status {
|
||||
Status::Ok => Theme::success(),
|
||||
Status::Pending => Theme::highlight(),
|
||||
Status::Warning => Theme::warning(),
|
||||
Status::Critical => Theme::error(),
|
||||
Status::Unknown => Theme::muted_text(),
|
||||
};
|
||||
|
||||
(icon.to_string(), info.status.clone(), status_color)
|
||||
}
|
||||
|
||||
/// Create spans for sub-service with icon next to name
|
||||
fn create_sub_service_spans(
|
||||
&self,
|
||||
name: &str,
|
||||
info: &ServiceInfo,
|
||||
is_last: bool,
|
||||
) -> Vec<ratatui::text::Span<'static>> {
|
||||
self.create_sub_service_spans_with_status(name, info, is_last, None)
|
||||
}
|
||||
|
||||
/// Create spans for sub-service with icon next to name, considering command status
|
||||
fn create_sub_service_spans_with_status(
|
||||
&self,
|
||||
name: &str,
|
||||
info: &ServiceInfo,
|
||||
is_last: bool,
|
||||
command_status: Option<&CommandStatus>,
|
||||
) -> Vec<ratatui::text::Span<'static>> {
|
||||
// Truncate long sub-service names to fit layout (accounting for indentation)
|
||||
let short_name = if name.len() > 18 {
|
||||
@@ -141,32 +186,19 @@ impl ServicesWidget {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
// Sub-services show latency if available, otherwise status
|
||||
let status_str = if let Some(latency) = info.latency_ms {
|
||||
if latency < 0.0 {
|
||||
"timeout".to_string()
|
||||
} else {
|
||||
format!("{:.0}ms", latency)
|
||||
}
|
||||
} else {
|
||||
match info.widget_status {
|
||||
Status::Ok => "active".to_string(),
|
||||
Status::Pending => "pending".to_string(),
|
||||
Status::Warning => "inactive".to_string(),
|
||||
Status::Critical => "failed".to_string(),
|
||||
Status::Unknown => "unknown".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);
|
||||
|
||||
let status_color = match info.widget_status {
|
||||
Status::Ok => Theme::success(),
|
||||
Status::Pending => Theme::highlight(),
|
||||
Status::Warning => Theme::warning(),
|
||||
Status::Critical => Theme::error(),
|
||||
Status::Unknown => Theme::muted_text(),
|
||||
};
|
||||
|
||||
let icon = StatusIcons::get_icon(info.widget_status);
|
||||
// For sub-services, prefer latency if available (unless command is in progress)
|
||||
if command_status.is_none() {
|
||||
if let Some(latency) = info.latency_ms {
|
||||
status_str = if latency < 0.0 {
|
||||
"timeout".to_string()
|
||||
} else {
|
||||
format!("{:.0}ms", latency)
|
||||
};
|
||||
}
|
||||
}
|
||||
let tree_symbol = if is_last { "└─" } else { "├─" };
|
||||
|
||||
vec![
|
||||
@@ -409,6 +441,199 @@ impl ServicesWidget {
|
||||
self.render_with_focus_and_scroll(frame, area, is_focused, 0);
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
let services_block = if is_focused {
|
||||
Components::focused_widget_block("services")
|
||||
} else {
|
||||
Components::widget_block("services")
|
||||
};
|
||||
let inner_area = services_block.inner(area);
|
||||
frame.render_widget(services_block, area);
|
||||
|
||||
let content_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
||||
.split(inner_area);
|
||||
|
||||
// Header
|
||||
let header = format!(
|
||||
"{:<25} {:<10} {:<8} {:<8}",
|
||||
"Service:", "Status:", "RAM:", "Disk:"
|
||||
);
|
||||
let header_para = Paragraph::new(header).style(Typography::muted());
|
||||
frame.render_widget(header_para, content_chunks[0]);
|
||||
|
||||
// Check if we have any services to display
|
||||
if self.parent_services.is_empty() && self.sub_services.is_empty() {
|
||||
let empty_text = Paragraph::new("No process data").style(Typography::muted());
|
||||
frame.render_widget(empty_text, content_chunks[1]);
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
// Build hierarchical service list for display (same as existing logic)
|
||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
|
||||
|
||||
// Sort parent services alphabetically for consistent order
|
||||
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
||||
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
|
||||
for (parent_name, parent_info) in parent_services {
|
||||
// Add parent service line
|
||||
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
||||
display_lines.push((parent_line, parent_info.widget_status, false, None)); // false = not sub-service
|
||||
|
||||
// Add sub-services for this parent (if any)
|
||||
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
||||
// Sort sub-services by name for consistent display
|
||||
let mut sorted_subs = sub_list.clone();
|
||||
sorted_subs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
|
||||
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
|
||||
let is_last_sub = i == sorted_subs.len() - 1;
|
||||
// Store sub-service info for custom span rendering
|
||||
display_lines.push((
|
||||
sub_name.clone(),
|
||||
sub_info.widget_status,
|
||||
true,
|
||||
Some((sub_info.clone(), is_last_sub)),
|
||||
)); // true = sub-service, with is_last info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply scroll offset and render visible lines (same as existing logic)
|
||||
let available_lines = area.height as usize;
|
||||
let total_lines = display_lines.len();
|
||||
|
||||
// Calculate scroll boundaries
|
||||
let max_scroll = if total_lines > available_lines {
|
||||
total_lines - available_lines
|
||||
} else {
|
||||
total_lines.saturating_sub(1)
|
||||
};
|
||||
let effective_scroll = scroll_offset.min(max_scroll);
|
||||
|
||||
// Get visible lines after scrolling
|
||||
let visible_lines: Vec<_> = display_lines
|
||||
.iter()
|
||||
.skip(effective_scroll)
|
||||
.take(available_lines)
|
||||
.collect();
|
||||
|
||||
let lines_to_show = visible_lines.len();
|
||||
|
||||
if lines_to_show > 0 {
|
||||
let service_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Length(1); lines_to_show])
|
||||
.split(area);
|
||||
|
||||
for (i, (line_text, line_status, is_sub, sub_info)) in visible_lines.iter().enumerate()
|
||||
{
|
||||
let actual_index = effective_scroll + i; // Real index in the full list
|
||||
|
||||
// Only parent services can be selected - calculate parent service index
|
||||
let is_selected = if !*is_sub {
|
||||
// This is a parent service - count how many parent services came before this one
|
||||
let parent_index = self.calculate_parent_service_index(&actual_index);
|
||||
parent_index == self.selected_index
|
||||
} else {
|
||||
false // Sub-services are never selected
|
||||
};
|
||||
|
||||
let mut spans = if *is_sub && sub_info.is_some() {
|
||||
// Use custom sub-service span creation WITH command status
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
_ => StatusIcons::create_status_spans(*line_status, line_text)
|
||||
}
|
||||
} 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 {
|
||||
for (i, span) in spans.iter_mut().enumerate() {
|
||||
if i == 0 {
|
||||
// First span is the status icon - preserve its color
|
||||
span.style = span.style.bg(Theme::highlight());
|
||||
} else {
|
||||
// Other spans (text) get full selection highlighting
|
||||
span.style = span.style
|
||||
.bg(Theme::highlight())
|
||||
.fg(Theme::background());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let service_para = Paragraph::new(ratatui::text::Line::from(spans));
|
||||
|
||||
frame.render_widget(service_para, service_chunks[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Show scroll indicator if there are more services than we can display (same as existing)
|
||||
if total_lines > available_lines {
|
||||
let hidden_above = effective_scroll;
|
||||
let hidden_below = total_lines.saturating_sub(effective_scroll + available_lines);
|
||||
|
||||
if hidden_above > 0 || hidden_below > 0 {
|
||||
let scroll_text = if hidden_above > 0 && hidden_below > 0 {
|
||||
format!("... {} above, {} below", hidden_above, hidden_below)
|
||||
} else if hidden_above > 0 {
|
||||
format!("... {} more above", hidden_above)
|
||||
} else {
|
||||
format!("... {} more below", hidden_below)
|
||||
};
|
||||
|
||||
if available_lines > 0 && lines_to_show > 0 {
|
||||
let last_line_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + (lines_to_show - 1) as u16,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let scroll_para = Paragraph::new(scroll_text).style(Typography::muted());
|
||||
frame.render_widget(scroll_para, last_line_area);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render with focus indicator and scroll offset
|
||||
pub fn render_with_focus_and_scroll(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize) {
|
||||
let services_block = if is_focused {
|
||||
|
||||
Reference in New Issue
Block a user