use anyhow::Result; use crossterm::event::{Event, KeyCode}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::Style, widgets::{Block, Paragraph}, Frame, }; use std::time::Instant; use tracing::info; pub mod widgets; pub mod theme; use widgets::{CpuWidget, MemoryWidget, ServicesWidget, BackupWidget, Widget}; use crate::metrics::{MetricStore, WidgetType}; use cm_dashboard_shared::{Metric, Status}; use theme::{Theme, Layout as ThemeLayout, Typography, Components, StatusIcons}; /// Main TUI application pub struct TuiApp { /// CPU widget cpu_widget: CpuWidget, /// Memory widget memory_widget: MemoryWidget, /// Services widget services_widget: ServicesWidget, /// Backup widget backup_widget: BackupWidget, /// Current active host current_host: Option, /// Available hosts available_hosts: Vec, /// Host index for navigation host_index: usize, /// Last update time last_update: Option, /// Should quit application should_quit: bool, } impl TuiApp { pub fn new() -> Self { Self { cpu_widget: CpuWidget::new(), memory_widget: MemoryWidget::new(), services_widget: ServicesWidget::new(), backup_widget: BackupWidget::new(), current_host: None, available_hosts: Vec::new(), host_index: 0, last_update: None, should_quit: false, } } /// Update widgets with metrics from store pub fn update_metrics(&mut self, metric_store: &MetricStore) { if let Some(ref hostname) = self.current_host { // Update CPU widget let cpu_metrics = metric_store.get_metrics_for_widget(hostname, WidgetType::Cpu); self.cpu_widget.update_from_metrics(&cpu_metrics); // Update Memory widget let memory_metrics = metric_store.get_metrics_for_widget(hostname, WidgetType::Memory); self.memory_widget.update_from_metrics(&memory_metrics); // Update Services widget - get all metrics that start with "service_" let all_metrics = metric_store.get_metrics_for_host(hostname); let service_metrics: Vec<&Metric> = all_metrics.iter() .filter(|m| m.name.starts_with("service_")) .copied() .collect(); self.services_widget.update_from_metrics(&service_metrics); // Update Backup widget - get all metrics that start with "backup_" let all_backup_metrics: Vec<&Metric> = all_metrics.iter() .filter(|m| m.name.starts_with("backup_")) .copied() .collect(); self.backup_widget.update_from_metrics(&all_backup_metrics); self.last_update = Some(Instant::now()); } } /// Update available hosts pub fn update_hosts(&mut self, hosts: Vec) { self.available_hosts = hosts; // Set current host if none selected if self.current_host.is_none() && !self.available_hosts.is_empty() { self.current_host = Some(self.available_hosts[0].clone()); self.host_index = 0; } } /// Handle keyboard input pub fn handle_input(&mut self, event: Event) -> Result<()> { 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') => { info!("Manual refresh requested"); // Refresh will be handled by main loop } KeyCode::Tab => { self.navigate_host(1); // Tab cycles to next host } _ => {} } } Ok(()) } /// 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()); info!("Switched to host: {}", self.current_host.as_ref().unwrap()); } /// 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 // Top section: title bar // Bottom section: split into left (mem + disks) and right (CPU + processes) let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // Title bar Constraint::Min(0), // Main content area ]) .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]); // Left side: system on top, backup on bottom (equal height) let left_chunks = 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]); // Render title bar self.render_btop_title(frame, main_chunks[0]); // Render new panel layout self.render_system_panel(frame, left_chunks[0], metric_store); self.render_backup_panel(frame, left_chunks[1]); self.services_widget.render(frame, content_chunks[1]); // Services takes full right side } /// Render btop-style minimal title fn render_btop_title(&self, frame: &mut Frame, area: Rect) { use ratatui::text::{Line, Span}; use ratatui::style::Modifier; 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 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())); } 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 spans.push(Span::styled(host.clone(), Typography::title())); } } let title_line = Line::from(spans); let title = Paragraph::new(vec![title_line]); frame.render_widget(title, area); } fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) { let system_block = Components::widget_block("system"); let inner_area = system_block.inner(area); frame.render_widget(system_block, area); let content_chunks = ratatui::layout::Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(ThemeLayout::CPU_SECTION_HEIGHT), // CPU section (title, load) Constraint::Length(ThemeLayout::MEMORY_SECTION_HEIGHT), // Memory section (title, used, /tmp) Constraint::Min(0) // Storage section ]) .split(inner_area); self.cpu_widget.render(frame, content_chunks[0]); self.memory_widget.render(frame, content_chunks[1]); self.render_storage_section(frame, content_chunks[2], metric_store); } fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) { let backup_block = Components::widget_block("backup"); let inner_area = backup_block.inner(area); frame.render_widget(backup_block, area); self.backup_widget.render(frame, inner_area); } fn render_storage_section(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) { if area.height < 2 { return; } if let Some(ref hostname) = self.current_host { // Get disk count to determine how many disks to display let disk_count = if let Some(count_metric) = metric_store.get_metric(hostname, "disk_count") { count_metric.value.as_i64().unwrap_or(0) as usize } else { 0 }; if disk_count == 0 { // No disks found - show error/waiting message let content_chunks = ratatui::layout::Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1), Constraint::Min(0)]) .split(area); let disk_title = Paragraph::new("Storage:").style(Typography::widget_title()); frame.render_widget(disk_title, content_chunks[0]); let no_disks_spans = StatusIcons::create_status_spans(Status::Unknown, "No mounted disks detected"); let no_disks_para = Paragraph::new(ratatui::text::Line::from(no_disks_spans)); frame.render_widget(no_disks_para, content_chunks[1]); return; } // Group disks by physical device let mut physical_devices: std::collections::HashMap> = std::collections::HashMap::new(); for disk_index in 0..disk_count { if let Some(physical_device_metric) = metric_store.get_metric(hostname, &format!("disk_{}_physical_device", disk_index)) { let physical_device = physical_device_metric.value.as_string(); physical_devices.entry(physical_device).or_insert_with(Vec::new).push(disk_index); } } // Calculate how many lines we need let mut total_lines_needed = 0; for partitions in physical_devices.values() { total_lines_needed += 2 + partitions.len(); // title + health + usage_per_partition } let available_lines = area.height as usize; // Create constraints dynamically based on physical devices let mut constraints = Vec::new(); let mut devices_to_show = Vec::new(); let mut current_line = 0; for (physical_device, partitions) in &physical_devices { let lines_for_this_device = 2 + partitions.len(); if current_line + lines_for_this_device <= available_lines { devices_to_show.push((physical_device.clone(), partitions.clone())); // Add constraints for this device constraints.push(Constraint::Length(1)); // Device title constraints.push(Constraint::Length(1)); // Health line for _ in 0..partitions.len() { constraints.push(Constraint::Length(1)); // Usage line per partition } current_line += lines_for_this_device; } else { break; // Can't fit more devices } } // Add remaining space if any if constraints.len() < available_lines { constraints.push(Constraint::Min(0)); } let content_chunks = ratatui::layout::Layout::default() .direction(Direction::Vertical) .constraints(constraints) .split(area); let mut chunk_index = 0; // Display each physical device for (physical_device, partitions) in &devices_to_show { // Device title let disk_title_text = format!("Disk {}:", physical_device); let disk_title_para = Paragraph::new(disk_title_text).style(Typography::widget_title()); frame.render_widget(disk_title_para, content_chunks[chunk_index]); chunk_index += 1; // Health status (one per physical device) let smart_health = metric_store.get_metric(hostname, &format!("disk_smart_{}_health", physical_device)) .map(|m| (m.value.as_string(), m.status)) .unwrap_or_else(|| ("Unknown".to_string(), Status::Unknown)); let smart_temp = metric_store.get_metric(hostname, &format!("disk_smart_{}_temperature", physical_device)) .and_then(|m| m.value.as_f32()); let temp_text = if let Some(temp) = smart_temp { format!(" {}°C", temp as i32) } else { String::new() }; let health_spans = StatusIcons::create_status_spans( smart_health.1, &format!("Health: {}{}", smart_health.0, temp_text) ); let health_para = Paragraph::new(ratatui::text::Line::from(health_spans)); frame.render_widget(health_para, content_chunks[chunk_index]); chunk_index += 1; // Usage lines (one per partition/mount point) for &disk_index in partitions { let mount_point = metric_store.get_metric(hostname, &format!("disk_{}_mount_point", disk_index)) .map(|m| m.value.as_string()) .unwrap_or("?".to_string()); let usage_percent = metric_store.get_metric(hostname, &format!("disk_{}_usage_percent", disk_index)) .and_then(|m| m.value.as_f32()) .unwrap_or(0.0); let used_gb = metric_store.get_metric(hostname, &format!("disk_{}_used_gb", disk_index)) .and_then(|m| m.value.as_f32()) .unwrap_or(0.0); let total_gb = metric_store.get_metric(hostname, &format!("disk_{}_total_gb", disk_index)) .and_then(|m| m.value.as_f32()) .unwrap_or(0.0); let usage_status = metric_store.get_metric(hostname, &format!("disk_{}_usage_percent", disk_index)) .map(|m| m.status) .unwrap_or(Status::Unknown); // Format mount point for usage line let mount_display = if mount_point == "/" { "root".to_string() } else if mount_point == "/boot" { "boot".to_string() } else if mount_point.starts_with("/") { mount_point[1..].to_string() // Remove leading slash } else { mount_point.clone() }; let usage_spans = StatusIcons::create_status_spans( usage_status, &format!("Usage @{}: {:.1}% • {:.1}/{:.1} GB", mount_display, usage_percent, used_gb, total_gb) ); let usage_para = Paragraph::new(ratatui::text::Line::from(usage_spans)); frame.render_widget(usage_para, content_chunks[chunk_index]); chunk_index += 1; } } // Show truncation indicator if we couldn't display all devices if devices_to_show.len() < physical_devices.len() { if let Some(last_chunk) = content_chunks.last() { let truncated_count = physical_devices.len() - devices_to_show.len(); let truncated_text = format!("... and {} more disk{}", truncated_count, if truncated_count == 1 { "" } else { "s" }); let truncated_para = Paragraph::new(truncated_text).style(Typography::muted()); frame.render_widget(truncated_para, *last_chunk); } } } else { // No host connected let content_chunks = ratatui::layout::Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1), Constraint::Min(0)]) .split(area); let disk_title = Paragraph::new("Storage:").style(Typography::widget_title()); frame.render_widget(disk_title, content_chunks[0]); let no_host_spans = StatusIcons::create_status_spans(Status::Unknown, "No host connected"); let no_host_para = Paragraph::new(ratatui::text::Line::from(no_host_spans)); frame.render_widget(no_host_para, content_chunks[1]); } } }