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.
812 lines
32 KiB
Rust
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);
|
|
}
|
|
}
|
|
|
|
|
|
}
|