Implement localhost prioritization and status display in dashboard

- Always select localhost as default host at startup
- Order hosts with localhost first, then predefined sequence
- Display hostname status colors in title bar based on metric aggregation
- Add gethostname dependency for localhost detection
This commit is contained in:
Christoffer Martinsson 2025-10-19 10:56:42 +02:00
parent 0141a6e111
commit 07633e4e0e
3 changed files with 224 additions and 103 deletions

1
Cargo.lock generated
View File

@ -255,6 +255,7 @@ dependencies = [
"clap", "clap",
"cm-dashboard-shared", "cm-dashboard-shared",
"crossterm", "crossterm",
"gethostname",
"ratatui", "ratatui",
"serde", "serde",
"serde_json", "serde_json",

View File

@ -18,3 +18,4 @@ tracing-subscriber = { workspace = true }
ratatui = { workspace = true } ratatui = { workspace = true }
crossterm = { workspace = true } crossterm = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
gethostname = { workspace = true }

View File

@ -10,13 +10,13 @@ use std::collections::HashMap;
use std::time::Instant; use std::time::Instant;
use tracing::info; use tracing::info;
pub mod widgets;
pub mod theme; pub mod theme;
pub mod widgets;
use widgets::{CpuWidget, MemoryWidget, ServicesWidget, BackupWidget, Widget};
use crate::metrics::MetricStore; use crate::metrics::MetricStore;
use cm_dashboard_shared::{Metric, Status}; 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 /// Widget states for a specific host
#[derive(Clone)] #[derive(Clone)]
@ -72,7 +72,9 @@ impl TuiApp {
/// Get or create host widgets for the given hostname /// Get or create host widgets for the given hostname
fn get_or_create_host_widgets(&mut self, hostname: &str) -> &mut HostWidgets { 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) /// Update widgets with metrics from store (only for current host)
@ -82,19 +84,27 @@ impl TuiApp {
let all_metrics = metric_store.get_metrics_for_host(&hostname); let all_metrics = metric_store.get_metrics_for_host(&hostname);
if !all_metrics.is_empty() { if !all_metrics.is_empty() {
// Get metrics first while hostname is borrowed // Get metrics first while hostname is borrowed
let cpu_metrics: Vec<&Metric> = all_metrics.iter() let cpu_metrics: Vec<&Metric> = all_metrics
.filter(|m| m.name.starts_with("cpu_") || m.name.contains("c_state_") || m.name.starts_with("process_top_")) .iter()
.filter(|m| {
m.name.starts_with("cpu_")
|| m.name.contains("c_state_")
|| m.name.starts_with("process_top_")
})
.copied() .copied()
.collect(); .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_")) .filter(|m| m.name.starts_with("memory_") || m.name.starts_with("disk_tmp_"))
.copied() .copied()
.collect(); .collect();
let service_metrics: Vec<&Metric> = all_metrics.iter() let service_metrics: Vec<&Metric> = all_metrics
.iter()
.filter(|m| m.name.starts_with("service_")) .filter(|m| m.name.starts_with("service_"))
.copied() .copied()
.collect(); .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_")) .filter(|m| m.name.starts_with("backup_"))
.copied() .copied()
.collect(); .collect();
@ -103,24 +113,67 @@ impl TuiApp {
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.cpu_widget.update_from_metrics(&cpu_metrics); host_widgets.cpu_widget.update_from_metrics(&cpu_metrics);
host_widgets.memory_widget.update_from_metrics(&memory_metrics); host_widgets
host_widgets.services_widget.update_from_metrics(&service_metrics); .memory_widget
host_widgets.backup_widget.update_from_metrics(&all_backup_metrics); .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()); host_widgets.last_update = Some(Instant::now());
} }
} }
} }
/// Update available hosts /// Update available hosts with localhost prioritization
pub fn update_hosts(&mut self, hosts: Vec<String>) { pub fn update_hosts(&mut self, hosts: Vec<String>) {
self.available_hosts = hosts; // Get the current hostname (localhost)
let localhost = gethostname::gethostname().to_string_lossy().to_string();
// Set current host if none selected // 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() { if self.current_host.is_none() && !self.available_hosts.is_empty() {
self.current_host = Some(self.available_hosts[0].clone()); self.current_host = Some(self.available_hosts[0].clone());
self.host_index = 0; 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 /// Handle keyboard input
@ -159,14 +212,17 @@ impl TuiApp {
if direction > 0 { if direction > 0 {
self.host_index = (self.host_index + 1) % len; self.host_index = (self.host_index + 1) % len;
} else { } 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()); self.current_host = Some(self.available_hosts[self.host_index].clone());
info!("Switched to host: {}", self.current_host.as_ref().unwrap()); info!("Switched to host: {}", self.current_host.as_ref().unwrap());
} }
/// Render the dashboard (real btop-style multi-panel layout) /// Render the dashboard (real btop-style multi-panel layout)
pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) { pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) {
let size = frame.size(); let size = frame.size();
@ -174,7 +230,7 @@ impl TuiApp {
// Clear background to true black like btop // Clear background to true black like btop
frame.render_widget( frame.render_widget(
Block::default().style(Style::default().bg(Theme::background())), Block::default().style(Style::default().bg(Theme::background())),
size size,
); );
// Create real btop-style layout: multi-panel with borders // Create real btop-style layout: multi-panel with borders
@ -207,7 +263,7 @@ impl TuiApp {
.split(content_chunks[0]); .split(content_chunks[0]);
// Render title bar // 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 // Render new panel layout
self.render_system_panel(frame, left_chunks[0], metric_store); self.render_system_panel(frame, left_chunks[0], metric_store);
@ -216,24 +272,26 @@ impl TuiApp {
// Render services widget for current host // Render services widget for current host
if let Some(hostname) = self.current_host.clone() { if let Some(hostname) = self.current_host.clone() {
let host_widgets = self.get_or_create_host_widgets(&hostname); 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 /// Render btop-style minimal title with host status colors
fn render_btop_title(&self, frame: &mut Frame, area: Rect) { fn render_btop_title(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
use ratatui::text::{Line, Span};
use ratatui::style::Modifier; use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use theme::StatusIcons;
if self.available_hosts.is_empty() { if self.available_hosts.is_empty() {
let title_text = "cm-dashboard • no hosts discovered"; let title_text = "cm-dashboard • no hosts discovered";
let title = Paragraph::new(title_text) let title = Paragraph::new(title_text).style(Typography::title());
.style(Typography::title());
frame.render_widget(title, area); frame.render_widget(title, area);
return; return;
} }
// Create spans for each host // Create spans for each host with status indicators
let mut spans = vec![Span::styled("cm-dashboard • ", Typography::title())]; let mut spans = vec![Span::styled("cm-dashboard • ", Typography::title())];
for (i, host) in self.available_hosts.iter().enumerate() { for (i, host) in self.available_hosts.iter().enumerate() {
@ -241,15 +299,29 @@ impl TuiApp {
spans.push(Span::styled(" ", Typography::title())); 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() { if Some(host) == self.current_host.as_ref() {
// Selected host in bold bright white // Selected host in bold bright white
spans.push(Span::styled( spans.push(Span::styled(
host.clone(), host.clone(),
Typography::title().add_modifier(Modifier::BOLD) Typography::title().add_modifier(Modifier::BOLD),
)); ));
} else { } else {
// Other hosts in normal style // Other hosts in normal style with status color
spans.push(Span::styled(host.clone(), Typography::title())); spans.push(Span::styled(
host.clone(),
Style::default().fg(status_color),
));
} }
} }
@ -259,7 +331,32 @@ impl TuiApp {
frame.render_widget(title, area); 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) { fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
let system_block = Components::widget_block("system"); let system_block = Components::widget_block("system");
@ -270,7 +367,7 @@ impl TuiApp {
.constraints([ .constraints([
Constraint::Length(ThemeLayout::CPU_SECTION_HEIGHT), // CPU section (title, load) Constraint::Length(ThemeLayout::CPU_SECTION_HEIGHT), // CPU section (title, load)
Constraint::Length(ThemeLayout::MEMORY_SECTION_HEIGHT), // Memory section (title, used, /tmp) Constraint::Length(ThemeLayout::MEMORY_SECTION_HEIGHT), // Memory section (title, used, /tmp)
Constraint::Min(0) // Storage section Constraint::Min(0), // Storage section
]) ])
.split(inner_area); .split(inner_area);
@ -295,7 +392,6 @@ impl TuiApp {
} }
} }
fn render_storage_section(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) { fn render_storage_section(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
if area.height < 2 { if area.height < 2 {
return; return;
@ -303,7 +399,8 @@ impl TuiApp {
if let Some(ref hostname) = self.current_host { if let Some(ref hostname) = self.current_host {
// Get disk count to determine how many disks to display // 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") { 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 count_metric.value.as_i64().unwrap_or(0) as usize
} else { } else {
0 0
@ -319,19 +416,26 @@ impl TuiApp {
let disk_title = Paragraph::new("Storage:").style(Typography::widget_title()); let disk_title = Paragraph::new("Storage:").style(Typography::widget_title());
frame.render_widget(disk_title, content_chunks[0]); 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)); let no_disks_para = Paragraph::new(ratatui::text::Line::from(no_disks_spans));
frame.render_widget(no_disks_para, content_chunks[1]); frame.render_widget(no_disks_para, content_chunks[1]);
return; return;
} }
// Group disks by physical device // Group disks by physical device
let mut physical_devices: std::collections::HashMap<String, Vec<usize>> = std::collections::HashMap::new(); let mut physical_devices: std::collections::HashMap<String, Vec<usize>> =
std::collections::HashMap::new();
for disk_index in 0..disk_count { 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(); 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);
} }
} }
@ -382,16 +486,22 @@ impl TuiApp {
for (physical_device, partitions) in &devices_to_show { for (physical_device, partitions) in &devices_to_show {
// Device title // Device title
let disk_title_text = format!("Disk {}:", physical_device); 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]); frame.render_widget(disk_title_para, content_chunks[chunk_index]);
chunk_index += 1; chunk_index += 1;
// Health status (one per physical device) // 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)) .map(|m| (m.value.as_string(), m.status))
.unwrap_or_else(|| ("Unknown".to_string(), Status::Unknown)); .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()); .and_then(|m| m.value.as_f32());
let temp_text = if let Some(temp) = smart_temp { let temp_text = if let Some(temp) = smart_temp {
@ -402,7 +512,7 @@ impl TuiApp {
let health_spans = StatusIcons::create_status_spans( let health_spans = StatusIcons::create_status_spans(
smart_health.1, 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)); let health_para = Paragraph::new(ratatui::text::Line::from(health_spans));
frame.render_widget(health_para, content_chunks[chunk_index]); frame.render_widget(health_para, content_chunks[chunk_index]);
@ -410,23 +520,28 @@ impl TuiApp {
// Usage lines (one per partition/mount point) // Usage lines (one per partition/mount point)
for &disk_index in partitions { 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()) .map(|m| m.value.as_string())
.unwrap_or("?".to_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()) .and_then(|m| m.value.as_f32())
.unwrap_or(0.0); .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()) .and_then(|m| m.value.as_f32())
.unwrap_or(0.0); .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()) .and_then(|m| m.value.as_f32())
.unwrap_or(0.0); .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) .map(|m| m.status)
.unwrap_or(Status::Unknown); .unwrap_or(Status::Unknown);
@ -443,7 +558,10 @@ impl TuiApp {
let usage_spans = StatusIcons::create_status_spans( let usage_spans = StatusIcons::create_status_spans(
usage_status, 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)); let usage_para = Paragraph::new(ratatui::text::Line::from(usage_spans));
frame.render_widget(usage_para, content_chunks[chunk_index]); frame.render_widget(usage_para, content_chunks[chunk_index]);
@ -455,9 +573,11 @@ impl TuiApp {
if devices_to_show.len() < physical_devices.len() { if devices_to_show.len() < physical_devices.len() {
if let Some(last_chunk) = content_chunks.last() { if let Some(last_chunk) = content_chunks.last() {
let truncated_count = physical_devices.len() - devices_to_show.len(); 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, truncated_count,
if truncated_count == 1 { "" } else { "s" }); if truncated_count == 1 { "" } else { "s" }
);
let truncated_para = Paragraph::new(truncated_text).style(Typography::muted()); let truncated_para = Paragraph::new(truncated_text).style(Typography::muted());
frame.render_widget(truncated_para, *last_chunk); frame.render_widget(truncated_para, *last_chunk);
} }
@ -472,11 +592,10 @@ impl TuiApp {
let disk_title = Paragraph::new("Storage:").style(Typography::widget_title()); let disk_title = Paragraph::new("Storage:").style(Typography::widget_title());
frame.render_widget(disk_title, content_chunks[0]); 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)); let no_host_para = Paragraph::new(ratatui::text::Line::from(no_host_spans));
frame.render_widget(no_host_para, content_chunks[1]); frame.render_widget(no_host_para, content_chunks[1]);
} }
} }
} }