diff --git a/Cargo.lock b/Cargo.lock index de30a22..e2db7de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,7 @@ dependencies = [ "clap", "cm-dashboard-shared", "crossterm", + "gethostname", "ratatui", "serde", "serde_json", diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 90f602e..993b3b9 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -17,4 +17,5 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } ratatui = { workspace = true } crossterm = { workspace = true } -toml = { workspace = true } \ No newline at end of file +toml = { workspace = true } +gethostname = { workspace = true } \ No newline at end of file diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index c802368..308107f 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -10,13 +10,13 @@ use std::collections::HashMap; use std::time::Instant; use tracing::info; -pub mod widgets; pub mod theme; +pub mod widgets; -use widgets::{CpuWidget, MemoryWidget, ServicesWidget, BackupWidget, Widget}; use crate::metrics::MetricStore; use cm_dashboard_shared::{Metric, Status}; -use theme::{Theme, Layout as ThemeLayout, Typography, Components, StatusIcons}; +use theme::{Components, Layout as ThemeLayout, StatusIcons, Theme, Typography}; +use widgets::{BackupWidget, CpuWidget, MemoryWidget, ServicesWidget, Widget}; /// Widget states for a specific host #[derive(Clone)] @@ -69,12 +69,14 @@ impl TuiApp { should_quit: 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) + 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) { if let Some(hostname) = self.current_host.clone() { @@ -82,47 +84,98 @@ impl TuiApp { 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_")) + 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() + 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() + 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() + 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); - + host_widgets.cpu_widget.update_from_metrics(&cpu_metrics); - host_widgets.memory_widget.update_from_metrics(&memory_metrics); - host_widgets.services_widget.update_from_metrics(&service_metrics); - host_widgets.backup_widget.update_from_metrics(&all_backup_metrics); - + host_widgets + .memory_widget + .update_from_metrics(&memory_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 + + /// Update available hosts with localhost prioritization pub fn update_hosts(&mut self, hosts: Vec) { - self.available_hosts = hosts; - - // Set current host if none selected + // Get the current hostname (localhost) + let localhost = gethostname::gethostname().to_string_lossy().to_string(); + + // Sort hosts with localhost first, then others in predefined order + let predefined_order = vec!["cmbox", "labbox", "simonbox", "steambox", "srv01", "srv02"]; + + let mut sorted_hosts = Vec::new(); + + // Add localhost first if it's available + if hosts.contains(&localhost) { + sorted_hosts.push(localhost.clone()); + } + + // Add remaining hosts in predefined order + for predefined_host in &predefined_order { + if hosts.contains(&predefined_host.to_string()) && *predefined_host != localhost { + sorted_hosts.push(predefined_host.to_string()); + } + } + + // Add any remaining hosts not in predefined order + for host in &hosts { + if !sorted_hosts.contains(host) { + sorted_hosts.push(host.clone()); + } + } + + self.available_hosts = sorted_hosts; + + // Always select localhost first if available, otherwise use first available host if self.current_host.is_none() && !self.available_hosts.is_empty() { self.current_host = Some(self.available_hosts[0].clone()); self.host_index = 0; } + + // If current host is not in the new list, reset to first available + if let Some(ref current) = self.current_host { + if !self.available_hosts.contains(current) && !self.available_hosts.is_empty() { + self.current_host = Some(self.available_hosts[0].clone()); + self.host_index = 0; + } else if let Some(index) = self.available_hosts.iter().position(|h| h == current) { + self.host_index = index; + } + } } - + /// Handle keyboard input pub fn handle_input(&mut self, event: Event) -> Result<()> { if let Event::Key(key) = event { @@ -148,119 +201,163 @@ impl TuiApp { } 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.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 + 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 + 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) + 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 + 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]); - + self.render_btop_title(frame, main_chunks[0], metric_store); + // Render new panel layout self.render_system_panel(frame, left_chunks[0], metric_store); self.render_backup_panel(frame, left_chunks[1]); - + // Render services widget for current host if let Some(hostname) = self.current_host.clone() { let host_widgets = self.get_or_create_host_widgets(&hostname); - host_widgets.services_widget.render(frame, content_chunks[1]); // Services takes full right side + host_widgets + .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}; + + /// 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()); + let title = Paragraph::new(title_text).style(Typography::title()); frame.render_widget(title, area); return; } - - // Create spans for each host + + // 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())); } - + + // Calculate overall host status from metrics + let host_status = self.calculate_host_status(host, metric_store); + let status_icon = StatusIcons::get_icon(host_status); + let status_color = 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) + Typography::title().add_modifier(Modifier::BOLD), )); } else { - // Other hosts in normal style - spans.push(Span::styled(host.clone(), Typography::title())); + // 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; + } + + // Check if any metric is critical + if metrics.iter().any(|m| m.status == Status::Critical) { + return Status::Critical; + } + + // Check if any metric is warning + if metrics.iter().any(|m| m.status == Status::Warning) { + return Status::Warning; + } + + // Check if all metrics are ok + if metrics.iter().all(|m| m.status == Status::Ok) { + return Status::Ok; + } + + // Default to unknown if mixed statuses + Status::Unknown + } + 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); @@ -268,12 +365,12 @@ impl TuiApp { 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 + 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); - + // Get current host widgets, create if none exist if let Some(hostname) = self.current_host.clone() { let host_widgets = self.get_or_create_host_widgets(&hostname); @@ -282,20 +379,19 @@ impl TuiApp { } 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); - + // Get current host widgets for backup widget if let Some(hostname) = self.current_host.clone() { let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.backup_widget.render(frame, inner_area); } } - - + fn render_storage_section(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) { if area.height < 2 { return; @@ -303,11 +399,12 @@ impl TuiApp { 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 - }; + 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 @@ -319,19 +416,26 @@ impl TuiApp { 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_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(); - + 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)) { + 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); + physical_devices + .entry(physical_device) + .or_insert_with(Vec::new) + .push(disk_index); } } @@ -342,7 +446,7 @@ impl TuiApp { } 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(); @@ -352,14 +456,14 @@ impl TuiApp { 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 @@ -382,16 +486,22 @@ impl TuiApp { 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()); + 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)) + 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)) + 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 { @@ -402,7 +512,7 @@ impl TuiApp { let health_spans = StatusIcons::create_status_spans( smart_health.1, - &format!("Health: {}{}", smart_health.0, temp_text) + &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]); @@ -410,27 +520,32 @@ impl TuiApp { // 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)) + 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)) + 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)) + 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)) + 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)) + 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 + // Format mount point for usage line let mount_display = if mount_point == "/" { "root".to_string() } else if mount_point == "/boot" { @@ -443,7 +558,10 @@ impl TuiApp { let usage_spans = StatusIcons::create_status_spans( usage_status, - &format!("Usage @{}: {:.1}% • {:.1}/{:.1} GB", mount_display, usage_percent, used_gb, total_gb) + &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]); @@ -455,9 +573,11 @@ impl TuiApp { 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{}", + let truncated_text = format!( + "... and {} more disk{}", truncated_count, - if truncated_count == 1 { "" } else { "s" }); + if truncated_count == 1 { "" } else { "s" } + ); let truncated_para = Paragraph::new(truncated_text).style(Typography::muted()); frame.render_widget(truncated_para, *last_chunk); } @@ -472,11 +592,10 @@ impl TuiApp { 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_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]); } } - } -