- Remove unused fields from CommandStatus variants - Clean up unused methods and unused collector fields - Fix lifetime syntax warning in SystemWidget - Delete unused cache module completely - Remove redundant render methods from widgets All agent and dashboard warnings eliminated while preserving panel switching and scrolling functionality.
839 lines
33 KiB
Rust
839 lines
33 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::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 },
|
|
SystemRebuild { hostname: 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 },
|
|
/// Command completed successfully
|
|
Success { command_type: CommandType, completed_at: std::time::Instant },
|
|
}
|
|
|
|
/// Types of commands for status tracking
|
|
#[derive(Debug, Clone)]
|
|
pub enum CommandType {
|
|
ServiceRestart,
|
|
ServiceStart,
|
|
ServiceStop,
|
|
SystemRebuild,
|
|
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,
|
|
}
|
|
|
|
impl TuiApp {
|
|
pub fn new() -> 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,
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
self.check_rebuild_completion(metric_store);
|
|
|
|
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 == "system_agent_hash")
|
|
.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 are undergoing SystemRebuild even if they're offline
|
|
for (hostname, host_widgets) in &self.host_widgets {
|
|
if let Some(CommandStatus::InProgress { command_type: CommandType::SystemRebuild, .. }) = &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 => {
|
|
// System rebuild command
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
self.start_command(&hostname, CommandType::SystemRebuild, hostname.clone());
|
|
return Ok(Some(UiCommand::SystemRebuild { hostname }));
|
|
}
|
|
}
|
|
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 }));
|
|
}
|
|
}
|
|
_ => {
|
|
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()) {
|
|
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()) {
|
|
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());
|
|
}
|
|
|
|
/// Check if a host is currently rebuilding
|
|
pub fn is_host_rebuilding(&self, hostname: &str) -> bool {
|
|
if let Some(host_widgets) = self.host_widgets.get(hostname) {
|
|
matches!(
|
|
&host_widgets.command_status,
|
|
Some(CommandStatus::InProgress { command_type: CommandType::SystemRebuild, .. })
|
|
)
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// 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(),
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Mark command as completed successfully
|
|
pub fn complete_command(&mut self, hostname: &str) {
|
|
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
|
|
if let Some(CommandStatus::InProgress { command_type, .. }) = &host_widgets.command_status {
|
|
host_widgets.command_status = Some(CommandStatus::Success {
|
|
command_type: command_type.clone(),
|
|
completed_at: Instant::now(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// 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 = match command_type {
|
|
CommandType::SystemRebuild => Duration::from_secs(300), // 5 minutes for rebuilds
|
|
_ => Duration::from_secs(30), // 30 seconds for service commands
|
|
};
|
|
|
|
if now.duration_since(*start_time) > timeout_duration {
|
|
hosts_to_clear.push(hostname.clone());
|
|
}
|
|
}
|
|
// Also clear success/failed status after display time
|
|
else if let Some(CommandStatus::Success { completed_at, .. }) = &host_widgets.command_status {
|
|
if now.duration_since(*completed_at) > Duration::from_secs(3) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check for rebuild completion by detecting agent hash changes
|
|
pub fn check_rebuild_completion(&mut self, metric_store: &MetricStore) {
|
|
let mut hosts_to_complete = Vec::new();
|
|
|
|
for (hostname, host_widgets) in &self.host_widgets {
|
|
if let Some(CommandStatus::InProgress { command_type: CommandType::SystemRebuild, .. }) = &host_widgets.command_status {
|
|
// Check if agent hash has changed (indicating successful rebuild)
|
|
if let Some(agent_hash_metric) = metric_store.get_metric(hostname, "system_agent_hash") {
|
|
if let cm_dashboard_shared::MetricValue::String(current_hash) = &agent_hash_metric.value {
|
|
// Compare with stored hash (if we have one)
|
|
if let Some(stored_hash) = host_widgets.system_widget.get_agent_hash() {
|
|
if current_hash != stored_hash {
|
|
// Agent hash changed - rebuild completed successfully
|
|
hosts_to_complete.push(hostname.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark rebuilds as completed
|
|
for hostname in hosts_to_complete {
|
|
self.complete_command(&hostname);
|
|
}
|
|
}
|
|
|
|
/// 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()));
|
|
}
|
|
|
|
// Check if this host has a command status that affects the icon
|
|
let (status_icon, status_color) = if let Some(host_widgets) = self.host_widgets.get(host) {
|
|
match &host_widgets.command_status {
|
|
Some(CommandStatus::InProgress { command_type: CommandType::SystemRebuild, .. }) => {
|
|
// Show blue circular arrow during rebuild
|
|
("↻", Theme::highlight())
|
|
}
|
|
Some(CommandStatus::Success { command_type: CommandType::SystemRebuild, .. }) => {
|
|
// Show green checkmark for successful rebuild
|
|
("✓", Theme::success())
|
|
}
|
|
_ => {
|
|
// Normal status icon based on metrics
|
|
let host_status = self.calculate_host_status(host, metric_store);
|
|
(StatusIcons::get_icon(host_status), Theme::status_color(host_status))
|
|
}
|
|
}
|
|
} else {
|
|
// No host widgets yet, use normal status
|
|
let host_status = self.calculate_host_status(host, metric_store);
|
|
(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);
|
|
}
|
|
}
|
|
|
|
}
|