Christoffer Martinsson 46cc813a68 Implement Tab key host switching functionality
- Add KeyCode::Tab support to main dashboard event loop
- Add Tab key handling to TuiApp handle_input method
- Tab key now cycles to next host using existing navigate_host logic
- Host switching infrastructure was already implemented, just needed Tab key support
- Current host displayed in bold in title bar, other hosts shown normally
- Metrics filtered by selected host, full navigation working
2025-10-18 19:26:58 +02:00

441 lines
18 KiB
Rust

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<String>,
/// Available hosts
available_hosts: Vec<String>,
/// Host index for navigation
host_index: usize,
/// Last update time
last_update: Option<Instant>,
/// 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<String>) {
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<String, Vec<usize>> = 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]);
}
}
}