All checks were successful
Build and Release / build-and-release (push) Successful in 2m8s
- Use tmux display-popup instead of popup with incorrect arguments - Add -tt flag for proper pseudo-terminal allocation - Use bash -ic to load shell aliases in SSH session - Enable rebuild_git alias to work through SSH popup
1008 lines
39 KiB
Rust
1008 lines
39 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Terminal popup for streaming command output
|
|
#[derive(Clone)]
|
|
pub struct TerminalPopup {
|
|
/// Is the popup currently visible
|
|
pub visible: bool,
|
|
/// Command being executed
|
|
pub _command_type: CommandType,
|
|
/// Target hostname
|
|
pub hostname: String,
|
|
/// Target service/operation name
|
|
pub target: String,
|
|
/// Output lines collected so far
|
|
pub output_lines: Vec<String>,
|
|
/// Scroll offset for the output
|
|
pub scroll_offset: usize,
|
|
/// Start time of the operation
|
|
pub start_time: Instant,
|
|
}
|
|
|
|
impl TerminalPopup {
|
|
pub fn _new(command_type: CommandType, hostname: String, target: String) -> Self {
|
|
Self {
|
|
visible: true,
|
|
_command_type: command_type,
|
|
hostname,
|
|
target,
|
|
output_lines: Vec::new(),
|
|
scroll_offset: 0,
|
|
start_time: Instant::now(),
|
|
}
|
|
}
|
|
|
|
pub fn add_output_line(&mut self, line: String) {
|
|
self.output_lines.push(line);
|
|
// Auto-scroll to bottom when new content arrives
|
|
if self.output_lines.len() > 20 {
|
|
self.scroll_offset = self.output_lines.len().saturating_sub(20);
|
|
}
|
|
}
|
|
|
|
pub fn close(&mut self) {
|
|
self.visible = false;
|
|
}
|
|
}
|
|
|
|
/// Main TUI application
|
|
pub struct TuiApp {
|
|
/// 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,
|
|
/// Terminal popup for streaming command output
|
|
terminal_popup: Option<TerminalPopup>,
|
|
/// 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,
|
|
terminal_popup: None,
|
|
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 {
|
|
// If terminal popup is visible, handle popup-specific keys first
|
|
if let Some(ref mut popup) = self.terminal_popup {
|
|
if popup.visible {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
popup.close();
|
|
self.terminal_popup = None;
|
|
return Ok(None);
|
|
}
|
|
KeyCode::Up => {
|
|
popup.scroll_offset = popup.scroll_offset.saturating_sub(1);
|
|
return Ok(None);
|
|
}
|
|
KeyCode::Down => {
|
|
let max_scroll = if popup.output_lines.len() > 20 {
|
|
popup.output_lines.len() - 20
|
|
} else {
|
|
0
|
|
};
|
|
popup.scroll_offset = (popup.scroll_offset + 1).min(max_scroll);
|
|
return Ok(None);
|
|
}
|
|
KeyCode::Char('q') => {
|
|
popup.close();
|
|
self.terminal_popup = None;
|
|
return Ok(None);
|
|
}
|
|
_ => return Ok(None), // Consume all other keys when popup is open
|
|
}
|
|
}
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Char('q') => {
|
|
self.should_quit = true;
|
|
}
|
|
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()) {
|
|
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());
|
|
}
|
|
|
|
|
|
/// 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) {
|
|
// Simply clear the command status when completed
|
|
host_widgets.command_status = None;
|
|
}
|
|
}
|
|
|
|
|
|
/// Check for command timeouts and automatically clear them
|
|
pub fn check_command_timeouts(&mut self) {
|
|
let now = Instant::now();
|
|
let mut hosts_to_clear = Vec::new();
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Add output line to terminal popup
|
|
pub fn add_terminal_output(&mut self, hostname: &str, line: String) {
|
|
if let Some(ref mut popup) = self.terminal_popup {
|
|
if popup.hostname == hostname && popup.visible {
|
|
popup.add_output_line(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Close terminal popup for a specific hostname
|
|
pub fn _close_terminal_popup(&mut self, hostname: &str) {
|
|
if let Some(ref mut popup) = self.terminal_popup {
|
|
if popup.hostname == hostname {
|
|
popup.close();
|
|
self.terminal_popup = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// Scroll the focused panel up or down
|
|
pub fn scroll_focused_panel(&mut self, direction: i32) {
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
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 terminal popup on top of everything else
|
|
if let Some(ref popup) = self.terminal_popup {
|
|
if popup.visible {
|
|
self.render_terminal_popup(frame, size, popup);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Render btop-style minimal title with host status colors
|
|
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 { .. }) => {
|
|
// Show working indicator for in-progress commands
|
|
("⏳", Theme::highlight())
|
|
}
|
|
_ => {
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
/// Render terminal popup with streaming output
|
|
fn render_terminal_popup(&self, frame: &mut Frame, area: Rect, popup: &TerminalPopup) {
|
|
use ratatui::{
|
|
style::{Color, Style},
|
|
text::{Line, Span},
|
|
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
|
};
|
|
|
|
// Calculate popup size (80% of screen, centered)
|
|
let popup_width = area.width * 80 / 100;
|
|
let popup_height = area.height * 80 / 100;
|
|
let popup_x = (area.width - popup_width) / 2;
|
|
let popup_y = (area.height - popup_height) / 2;
|
|
|
|
let popup_area = Rect {
|
|
x: popup_x,
|
|
y: popup_y,
|
|
width: popup_width,
|
|
height: popup_height,
|
|
};
|
|
|
|
// Clear background
|
|
frame.render_widget(Clear, popup_area);
|
|
|
|
// Create terminal-style block
|
|
let title = format!(" {} → {} ({:.1}s) ",
|
|
popup.hostname,
|
|
popup.target,
|
|
popup.start_time.elapsed().as_secs_f32()
|
|
);
|
|
|
|
let block = Block::default()
|
|
.title(title)
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Cyan))
|
|
.style(Style::default().bg(Color::Black));
|
|
|
|
let inner_area = block.inner(popup_area);
|
|
frame.render_widget(block, popup_area);
|
|
|
|
// Render output content
|
|
let available_height = inner_area.height as usize;
|
|
let total_lines = popup.output_lines.len();
|
|
|
|
// Calculate which lines to show based on scroll offset
|
|
let start_line = popup.scroll_offset;
|
|
let end_line = (start_line + available_height).min(total_lines);
|
|
|
|
let visible_lines: Vec<Line> = popup.output_lines[start_line..end_line]
|
|
.iter()
|
|
.map(|line| {
|
|
// Style output lines with terminal colors
|
|
if line.contains("error") || line.contains("Error") || line.contains("failed") {
|
|
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Red)))
|
|
} else if line.contains("warning") || line.contains("Warning") {
|
|
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Yellow)))
|
|
} else if line.contains("building") || line.contains("Building") {
|
|
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Blue)))
|
|
} else if line.contains("✓") || line.contains("success") || line.contains("completed") {
|
|
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Green)))
|
|
} else {
|
|
Line::from(Span::styled(line.clone(), Style::default().fg(Color::White)))
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let content = Paragraph::new(visible_lines)
|
|
.wrap(Wrap { trim: false })
|
|
.style(Style::default().bg(Color::Black));
|
|
|
|
frame.render_widget(content, inner_area);
|
|
|
|
// Render scroll indicator if needed
|
|
if total_lines > available_height {
|
|
let scroll_info = format!(" {}% ",
|
|
if total_lines > 0 {
|
|
(end_line * 100) / total_lines
|
|
} else {
|
|
100
|
|
}
|
|
);
|
|
|
|
let scroll_area = Rect {
|
|
x: popup_area.x + popup_area.width - scroll_info.len() as u16 - 1,
|
|
y: popup_area.y + popup_area.height - 1,
|
|
width: scroll_info.len() as u16,
|
|
height: 1,
|
|
};
|
|
|
|
let scroll_widget = Paragraph::new(scroll_info)
|
|
.style(Style::default().fg(Color::Cyan).bg(Color::Black));
|
|
frame.render_widget(scroll_widget, scroll_area);
|
|
}
|
|
|
|
// Instructions at bottom
|
|
let instructions = " ESC/Q: Close • ↑↓: Scroll ";
|
|
let instructions_area = Rect {
|
|
x: popup_area.x + 1,
|
|
y: popup_area.y + popup_area.height - 1,
|
|
width: instructions.len() as u16,
|
|
height: 1,
|
|
};
|
|
|
|
let instructions_widget = Paragraph::new(instructions)
|
|
.style(Style::default().fg(Color::Gray).bg(Color::Black));
|
|
frame.render_widget(instructions_widget, instructions_area);
|
|
}
|
|
|
|
}
|