Christoffer Martinsson ae70946c61
All checks were successful
Build and Release / build-and-release (push) Successful in 1m12s
Implement state-aware service command validation with immediate visual feedback
- 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

812 lines
32 KiB
Rust

use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyModifiers};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::Style,
widgets::{Block, Paragraph},
Frame,
};
use std::collections::HashMap;
use std::time::{Duration, Instant};
use tracing::info;
pub mod theme;
pub mod widgets;
use crate::config::DashboardConfig;
use crate::metrics::MetricStore;
use cm_dashboard_shared::{Metric, Status};
use theme::{Components, Layout as ThemeLayout, Theme, Typography};
use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget};
/// Commands that can be triggered from the UI
#[derive(Debug, Clone)]
pub enum UiCommand {
ServiceRestart { hostname: String, service_name: String },
ServiceStart { hostname: String, service_name: String },
ServiceStop { hostname: String, service_name: String },
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)]
pub enum CommandType {
ServiceRestart,
ServiceStart,
ServiceStop,
BackupTrigger,
}
/// Panel types for focus management
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelType {
System,
Services,
Backup,
}
impl PanelType {
}
/// Widget states for a specific host
#[derive(Clone)]
pub struct HostWidgets {
/// System widget state (includes CPU, Memory, NixOS info, Storage)
pub system_widget: SystemWidget,
/// Services widget state
pub services_widget: ServicesWidget,
/// Backup widget state
pub backup_widget: BackupWidget,
/// Scroll offsets for each panel
pub system_scroll_offset: usize,
pub services_scroll_offset: usize,
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>,
}
impl HostWidgets {
pub fn new() -> Self {
Self {
system_widget: SystemWidget::new(),
services_widget: ServicesWidget::new(),
backup_widget: BackupWidget::new(),
system_scroll_offset: 0,
services_scroll_offset: 0,
backup_scroll_offset: 0,
last_update: None,
command_status: None,
}
}
}
/// Main TUI application
pub struct TuiApp {
/// Widget states per host (hostname -> HostWidgets)
host_widgets: HashMap<String, HostWidgets>,
/// Current active host
current_host: Option<String>,
/// Available hosts
available_hosts: Vec<String>,
/// Host index for navigation
host_index: usize,
/// Currently focused panel
focused_panel: PanelType,
/// Should quit application
should_quit: bool,
/// Track if user manually navigated away from localhost
user_navigated_away: bool,
/// Dashboard configuration
config: DashboardConfig,
}
impl TuiApp {
pub fn new(config: DashboardConfig) -> Self {
Self {
host_widgets: HashMap::new(),
current_host: None,
available_hosts: Vec::new(),
host_index: 0,
focused_panel: PanelType::System, // Start with System panel focused
should_quit: false,
user_navigated_away: false,
config,
}
}
/// Get or create host widgets for the given hostname
fn get_or_create_host_widgets(&mut self, hostname: &str) -> &mut HostWidgets {
self.host_widgets
.entry(hostname.to_string())
.or_insert_with(HostWidgets::new)
}
/// 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
if let Some(hostname) = self.current_host.clone() {
// Only update widgets if we have metrics for this host
let all_metrics = metric_store.get_metrics_for_host(&hostname);
if !all_metrics.is_empty() {
// Get metrics first while hostname is borrowed
let cpu_metrics: Vec<&Metric> = all_metrics
.iter()
.filter(|m| {
m.name.starts_with("cpu_")
|| m.name.contains("c_state_")
|| m.name.starts_with("process_top_")
})
.copied()
.collect();
let memory_metrics: Vec<&Metric> = all_metrics
.iter()
.filter(|m| m.name.starts_with("memory_") || m.name.starts_with("disk_tmp_"))
.copied()
.collect();
let service_metrics: Vec<&Metric> = all_metrics
.iter()
.filter(|m| m.name.starts_with("service_"))
.copied()
.collect();
let all_backup_metrics: Vec<&Metric> = all_metrics
.iter()
.filter(|m| m.name.starts_with("backup_"))
.copied()
.collect();
// Now get host widgets and update them
let host_widgets = self.get_or_create_host_widgets(&hostname);
// Collect all system metrics (CPU, memory, NixOS, disk/storage)
let mut system_metrics = cpu_metrics;
system_metrics.extend(memory_metrics);
// Add NixOS metrics - using exact matching for build display fix
let nixos_metrics: Vec<&Metric> = all_metrics
.iter()
.filter(|m| m.name == "system_nixos_build" || m.name == "system_active_users" || m.name == "agent_version")
.copied()
.collect();
system_metrics.extend(nixos_metrics);
// Add disk/storage metrics
let disk_metrics: Vec<&Metric> = all_metrics
.iter()
.filter(|m| m.name.starts_with("disk_"))
.copied()
.collect();
system_metrics.extend(disk_metrics);
host_widgets.system_widget.update_from_metrics(&system_metrics);
host_widgets
.services_widget
.update_from_metrics(&service_metrics);
host_widgets
.backup_widget
.update_from_metrics(&all_backup_metrics);
host_widgets.last_update = Some(Instant::now());
}
}
}
/// Update available hosts with localhost prioritization
pub fn update_hosts(&mut self, hosts: Vec<String>) {
// Sort hosts alphabetically
let mut sorted_hosts = hosts.clone();
// Keep hosts that have ongoing commands even if they're offline
for (hostname, host_widgets) in &self.host_widgets {
if let Some(CommandStatus::InProgress { .. }) = &host_widgets.command_status {
if !sorted_hosts.contains(hostname) {
sorted_hosts.push(hostname.clone());
}
}
}
sorted_hosts.sort();
self.available_hosts = sorted_hosts;
// Get the current hostname (localhost) for auto-selection
let localhost = gethostname::gethostname().to_string_lossy().to_string();
if !self.available_hosts.is_empty() {
if self.available_hosts.contains(&localhost) && !self.user_navigated_away {
// Localhost is available and user hasn't navigated away - switch to it
self.current_host = Some(localhost.clone());
// Find the actual index of localhost in the sorted list
self.host_index = self.available_hosts.iter().position(|h| h == &localhost).unwrap_or(0);
} else if self.current_host.is_none() {
// No current host - select first available (which is localhost if available)
self.current_host = Some(self.available_hosts[0].clone());
self.host_index = 0;
} else if let Some(ref current) = self.current_host {
if !self.available_hosts.contains(current) {
// Current host disconnected - select first available and reset navigation flag
self.current_host = Some(self.available_hosts[0].clone());
self.host_index = 0;
self.user_navigated_away = false; // Reset since we're forced to switch
} else if let Some(index) = self.available_hosts.iter().position(|h| h == current) {
// Update index for current host
self.host_index = index;
}
}
}
}
/// Handle keyboard input
pub fn handle_input(&mut self, event: Event) -> Result<Option<UiCommand>> {
if let Event::Key(key) = event {
match key.code {
KeyCode::Char('q') => {
self.should_quit = true;
}
KeyCode::Left => {
self.navigate_host(-1);
}
KeyCode::Right => {
self.navigate_host(1);
}
KeyCode::Char('r') => {
match self.focused_panel {
PanelType::System => {
// Simple tmux popup with SSH rebuild using configured user and alias
if let Some(hostname) = self.current_host.clone() {
// Launch tmux popup with SSH using config values
let ssh_command = format!(
"ssh -tt {}@{} 'bash -ic {}'",
self.config.ssh.rebuild_user,
hostname,
self.config.ssh.rebuild_alias
);
std::process::Command::new("tmux")
.arg("display-popup")
.arg(&ssh_command)
.spawn()
.ok(); // Ignore errors, tmux will handle them
}
}
PanelType::Services => {
// Service restart command
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
if self.start_command(&hostname, CommandType::ServiceRestart, service_name.clone()) {
return Ok(Some(UiCommand::ServiceRestart { hostname, service_name }));
}
}
}
_ => {
info!("Manual refresh requested");
}
}
}
KeyCode::Char('s') => {
if self.focused_panel == PanelType::Services {
// Service start command
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
if self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()) {
return Ok(Some(UiCommand::ServiceStart { hostname, service_name }));
}
}
}
}
KeyCode::Char('S') => {
if self.focused_panel == PanelType::Services {
// Service stop command
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
if self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()) {
return Ok(Some(UiCommand::ServiceStop { hostname, service_name }));
}
}
}
}
KeyCode::Char('b') => {
if self.focused_panel == PanelType::Backup {
// Trigger backup
if let Some(hostname) = self.current_host.clone() {
self.start_command(&hostname, CommandType::BackupTrigger, hostname.clone());
return Ok(Some(UiCommand::TriggerBackup { hostname }));
}
}
}
KeyCode::Tab => {
if key.modifiers.contains(KeyModifiers::SHIFT) {
// Shift+Tab cycles through panels
self.next_panel();
} else {
// Tab cycles to next host
self.navigate_host(1);
}
}
KeyCode::BackTab => {
// BackTab (Shift+Tab on some terminals) also cycles panels
self.next_panel();
}
KeyCode::Up => {
// Scroll up in focused panel
self.scroll_focused_panel(-1);
}
KeyCode::Down => {
// Scroll down in focused panel
self.scroll_focused_panel(1);
}
_ => {}
}
}
Ok(None)
}
/// Navigate between hosts
fn navigate_host(&mut self, direction: i32) {
if self.available_hosts.is_empty() {
return;
}
let len = self.available_hosts.len();
if direction > 0 {
self.host_index = (self.host_index + 1) % len;
} else {
self.host_index = if self.host_index == 0 {
len - 1
} else {
self.host_index - 1
};
}
self.current_host = Some(self.available_hosts[self.host_index].clone());
// Check if user navigated away from localhost
let localhost = gethostname::gethostname().to_string_lossy().to_string();
if let Some(ref current) = self.current_host {
if current != &localhost {
self.user_navigated_away = true;
} else {
self.user_navigated_away = false; // User navigated back to localhost
}
}
info!("Switched to host: {}", self.current_host.as_ref().unwrap());
}
/// Switch to next panel (Shift+Tab) - only cycles through visible panels
pub fn next_panel(&mut self) {
let visible_panels = self.get_visible_panels();
if visible_panels.len() <= 1 {
return; // Can't switch if only one or no panels visible
}
// Find current panel index in visible panels
if let Some(current_index) = visible_panels.iter().position(|&p| p == self.focused_panel) {
// Move to next visible panel
let next_index = (current_index + 1) % visible_panels.len();
self.focused_panel = visible_panels[next_index];
} else {
// Current panel not visible, switch to first visible panel
self.focused_panel = visible_panels[0];
}
info!("Switched to panel: {:?}", self.focused_panel);
}
/// Get the currently selected service name from the services widget
fn get_selected_service(&self) -> Option<String> {
if let Some(hostname) = &self.current_host {
if let Some(host_widgets) = self.host_widgets.get(hostname) {
return host_widgets.services_widget.get_selected_service();
}
}
None
}
/// Should quit application
pub fn should_quit(&self) -> bool {
self.should_quit
}
/// 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
}
/// 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) {
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 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;
}
}
}
/// Scroll the focused panel up or down
pub fn scroll_focused_panel(&mut self, direction: i32) {
if let Some(hostname) = self.current_host.clone() {
let focused_panel = self.focused_panel; // Get the value before borrowing
let host_widgets = self.get_or_create_host_widgets(&hostname);
match focused_panel {
PanelType::System => {
if direction > 0 {
host_widgets.system_scroll_offset = host_widgets.system_scroll_offset.saturating_add(1);
} else {
host_widgets.system_scroll_offset = host_widgets.system_scroll_offset.saturating_sub(1);
}
info!("System panel scroll offset: {}", host_widgets.system_scroll_offset);
}
PanelType::Services => {
// For services panel, Up/Down moves selection cursor, not scroll
let total_services = host_widgets.services_widget.get_total_services_count();
if direction > 0 {
host_widgets.services_widget.select_next(total_services);
info!("Services selection moved down");
} else {
host_widgets.services_widget.select_previous();
info!("Services selection moved up");
}
}
PanelType::Backup => {
if direction > 0 {
host_widgets.backup_scroll_offset = host_widgets.backup_scroll_offset.saturating_add(1);
} else {
host_widgets.backup_scroll_offset = host_widgets.backup_scroll_offset.saturating_sub(1);
}
info!("Backup panel scroll offset: {}", host_widgets.backup_scroll_offset);
}
}
}
}
/// Get list of currently visible panels
fn get_visible_panels(&self) -> Vec<PanelType> {
let mut visible_panels = vec![PanelType::System, PanelType::Services];
// Check if backup panel should be shown
if let Some(hostname) = &self.current_host {
if let Some(host_widgets) = self.host_widgets.get(hostname) {
if host_widgets.backup_widget.has_data() {
visible_panels.push(PanelType::Backup);
}
}
}
visible_panels
}
/// Render the dashboard (real btop-style multi-panel layout)
pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) {
let size = frame.size();
// Clear background to true black like btop
frame.render_widget(
Block::default().style(Style::default().bg(Theme::background())),
size,
);
// Create real btop-style layout: multi-panel with borders
// Three-section layout: title bar, main content, statusbar
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Title bar
Constraint::Min(0), // Main content area
Constraint::Length(1), // Statusbar
])
.split(size);
// New layout: left panels | right services (100% height)
let content_chunks = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(ThemeLayout::LEFT_PANEL_WIDTH), // Left side: system, backup
Constraint::Percentage(ThemeLayout::RIGHT_PANEL_WIDTH), // Right side: services (100% height)
])
.split(main_chunks[1]); // main_chunks[1] is now the content area (between title and statusbar)
// Check if backup panel should be shown
let show_backup = if let Some(hostname) = self.current_host.clone() {
let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.backup_widget.has_data()
} else {
false
};
// Left side: dynamic layout based on backup data availability
let left_chunks = if show_backup {
// Show both system and backup panels
ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(ThemeLayout::SYSTEM_PANEL_HEIGHT), // System section
Constraint::Percentage(ThemeLayout::BACKUP_PANEL_HEIGHT), // Backup section
])
.split(content_chunks[0])
} else {
// Show only system panel (full height)
ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(100)]) // System section takes full height
.split(content_chunks[0])
};
// Render title bar
self.render_btop_title(frame, main_chunks[0], metric_store);
// Render new panel layout
self.render_system_panel(frame, left_chunks[0], metric_store);
if show_backup && left_chunks.len() > 1 {
self.render_backup_panel(frame, left_chunks[1]);
}
// 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 host_widgets = self.get_or_create_host_widgets(&hostname);
(host_widgets.services_scroll_offset, host_widgets.command_status.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 statusbar at the bottom
self.render_statusbar(frame, main_chunks[2]); // main_chunks[2] is the statusbar area
}
/// Render btop-style minimal title with host status colors
fn render_btop_title(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use theme::StatusIcons;
if self.available_hosts.is_empty() {
let title_text = "cm-dashboard • no hosts discovered";
let title = Paragraph::new(title_text).style(Typography::title());
frame.render_widget(title, area);
return;
}
// Create spans for each host with status indicators
let mut spans = vec![Span::styled("cm-dashboard • ", Typography::title())];
for (i, host) in self.available_hosts.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" ", Typography::title()));
}
// Always show normal status icon based on metrics (no command status at host level)
let host_status = self.calculate_host_status(host, metric_store);
let (status_icon, status_color) = (StatusIcons::get_icon(host_status), Theme::status_color(host_status));
// Add status icon
spans.push(Span::styled(
format!("{} ", status_icon),
Style::default().fg(status_color),
));
if Some(host) == self.current_host.as_ref() {
// Selected host in bold bright white
spans.push(Span::styled(
host.clone(),
Typography::title().add_modifier(Modifier::BOLD),
));
} else {
// Other hosts in normal style with status color
spans.push(Span::styled(
host.clone(),
Style::default().fg(status_color),
));
}
}
let title_line = Line::from(spans);
let title = Paragraph::new(vec![title_line]);
frame.render_widget(title, area);
}
/// Calculate overall status for a host based on its metrics
fn calculate_host_status(&self, hostname: &str, metric_store: &MetricStore) -> Status {
let metrics = metric_store.get_metrics_for_host(hostname);
if metrics.is_empty() {
return Status::Unknown;
}
// First check if we have the aggregated host status summary from the agent
if let Some(host_summary_metric) = metric_store.get_metric(hostname, "host_status_summary") {
return host_summary_metric.status;
}
// Fallback to old aggregation logic with proper Pending handling
let mut has_critical = false;
let mut has_warning = false;
let mut has_pending = false;
let mut ok_count = 0;
for metric in &metrics {
match metric.status {
Status::Critical => has_critical = true,
Status::Warning => has_warning = true,
Status::Pending => has_pending = true,
Status::Ok => ok_count += 1,
Status::Unknown => {} // Ignore unknown for aggregation
}
}
// Priority order: Critical > Warning > Pending > Ok > Unknown
if has_critical {
Status::Critical
} else if has_warning {
Status::Warning
} else if has_pending {
Status::Pending
} else if ok_count > 0 {
Status::Ok
} else {
Status::Unknown
}
}
/// Render dynamic statusbar with context-aware shortcuts
fn render_statusbar(&self, frame: &mut Frame, area: Rect) {
let shortcuts = self.get_context_shortcuts();
let statusbar_text = shortcuts.join("");
let statusbar = Paragraph::new(statusbar_text)
.style(Typography::secondary())
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(statusbar, area);
}
/// Get context-aware shortcuts based on focused panel
fn get_context_shortcuts(&self) -> Vec<String> {
let mut shortcuts = Vec::new();
// Global shortcuts
shortcuts.push("Tab: Switch Host".to_string());
shortcuts.push("Shift+Tab: Switch Panel".to_string());
// Scroll shortcuts (always available)
shortcuts.push("↑↓: Scroll".to_string());
// Panel-specific shortcuts
match self.focused_panel {
PanelType::System => {
shortcuts.push("R: Rebuild".to_string());
}
PanelType::Services => {
shortcuts.push("S: Start".to_string());
shortcuts.push("Shift+S: Stop".to_string());
shortcuts.push("R: Restart".to_string());
}
PanelType::Backup => {
shortcuts.push("B: Trigger Backup".to_string());
}
}
// Always show quit
shortcuts.push("Q: Quit".to_string());
shortcuts
}
fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) {
let system_block = if self.focused_panel == PanelType::System {
Components::focused_widget_block("system")
} else {
Components::widget_block("system")
};
let inner_area = system_block.inner(area);
frame.render_widget(system_block, area);
// Get current host widgets, create if none exist
if let Some(hostname) = self.current_host.clone() {
let scroll_offset = {
let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.system_scroll_offset
};
let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.system_widget.render_with_scroll(frame, inner_area, scroll_offset);
}
}
fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) {
let backup_block = if self.focused_panel == PanelType::Backup {
Components::focused_widget_block("backup")
} else {
Components::widget_block("backup")
};
let inner_area = backup_block.inner(area);
frame.render_widget(backup_block, area);
// Get current host widgets for backup widget
if let Some(hostname) = self.current_host.clone() {
let scroll_offset = {
let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.backup_scroll_offset
};
let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.backup_widget.render_with_scroll(frame, inner_area, scroll_offset);
}
}
}