Resolves widget data persistence issue where switching hosts left stale data from the previous host displayed in widgets. Key improvements: - Add Clone derives to all widget structs (CpuWidget, MemoryWidget, ServicesWidget, BackupWidget) - Create HostWidgets struct to cache widget states per hostname - Update TuiApp with HashMap<String, HostWidgets> for per-host storage - Fix borrowing issues by cloning hostname before mutable self borrow - Implement instant widget state restoration when switching hosts Tab key host switching now displays cached widget data for each host without stale information persistence between switches.
400 lines
17 KiB
Rust
400 lines
17 KiB
Rust
use cm_dashboard_shared::{Metric, Status};
|
|
use ratatui::{
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
widgets::Paragraph,
|
|
Frame,
|
|
};
|
|
use std::collections::HashMap;
|
|
use tracing::debug;
|
|
|
|
use super::Widget;
|
|
use crate::ui::theme::{Theme, Typography, Components, StatusIcons};
|
|
use ratatui::style::Style;
|
|
|
|
/// Services widget displaying hierarchical systemd service statuses
|
|
#[derive(Clone)]
|
|
pub struct ServicesWidget {
|
|
/// Parent services (nginx, docker, etc.)
|
|
parent_services: HashMap<String, ServiceInfo>,
|
|
/// Sub-services grouped by parent (nginx -> [gitea, mariehall, ...], docker -> [container1, ...])
|
|
sub_services: HashMap<String, Vec<(String, ServiceInfo)>>,
|
|
/// Aggregated status
|
|
status: Status,
|
|
/// Last update indicator
|
|
has_data: bool,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ServiceInfo {
|
|
status: String,
|
|
memory_mb: Option<f32>,
|
|
disk_gb: Option<f32>,
|
|
latency_ms: Option<f32>,
|
|
widget_status: Status,
|
|
}
|
|
|
|
impl ServicesWidget {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
parent_services: HashMap::new(),
|
|
sub_services: HashMap::new(),
|
|
status: Status::Unknown,
|
|
has_data: false,
|
|
}
|
|
}
|
|
|
|
/// Extract service name and determine if it's a parent or sub-service
|
|
fn extract_service_info(metric_name: &str) -> Option<(String, Option<String>)> {
|
|
if metric_name.starts_with("service_") {
|
|
if let Some(end_pos) = metric_name.rfind("_status")
|
|
.or_else(|| metric_name.rfind("_memory_mb"))
|
|
.or_else(|| metric_name.rfind("_disk_gb"))
|
|
.or_else(|| metric_name.rfind("_latency_ms")) {
|
|
let service_part = &metric_name[8..end_pos]; // Remove "service_" prefix
|
|
|
|
// Check for sub-services patterns
|
|
if service_part.starts_with("nginx_") {
|
|
// nginx sub-services: service_nginx_gitea_latency_ms -> ("nginx", "gitea")
|
|
let sub_service = service_part.strip_prefix("nginx_").unwrap_or(service_part);
|
|
return Some(("nginx".to_string(), Some(sub_service.to_string())));
|
|
} else if service_part.starts_with("docker_") {
|
|
// docker sub-services: service_docker_container1_status -> ("docker", "container1")
|
|
let sub_service = service_part.strip_prefix("docker_").unwrap_or(service_part);
|
|
return Some(("docker".to_string(), Some(sub_service.to_string())));
|
|
} else {
|
|
// Regular parent service: service_nginx_status -> ("nginx", None)
|
|
return Some((service_part.to_string(), None));
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Format disk size with appropriate units (kB/MB/GB)
|
|
fn format_disk_size(size_gb: f32) -> String {
|
|
let size_mb = size_gb * 1024.0; // Convert GB to MB
|
|
|
|
if size_mb >= 1024.0 {
|
|
// Show as GB
|
|
format!("{:.1}GB", size_gb)
|
|
} else if size_mb >= 1.0 {
|
|
// Show as MB
|
|
format!("{:.0}MB", size_mb)
|
|
} else if size_mb >= 0.001 {
|
|
// Convert to kB
|
|
let size_kb = size_mb * 1024.0;
|
|
format!("{:.0}kB", size_kb)
|
|
} else {
|
|
// Show very small sizes as bytes
|
|
let size_bytes = size_mb * 1024.0 * 1024.0;
|
|
format!("{:.0}B", size_bytes)
|
|
}
|
|
}
|
|
|
|
/// Format parent service line - returns text without icon for span formatting
|
|
fn format_parent_service_line(&self, name: &str, info: &ServiceInfo) -> String {
|
|
let memory_str = info.memory_mb.map_or("0M".to_string(), |m| format!("{:.0}M", m));
|
|
let disk_str = info.disk_gb.map_or("0".to_string(), |d| Self::format_disk_size(d));
|
|
|
|
// Truncate long service names to fit layout (account for icon space)
|
|
let short_name = if name.len() > 22 {
|
|
format!("{}...", &name[..19])
|
|
} else {
|
|
name.to_string()
|
|
};
|
|
|
|
// Parent services always show active/inactive status
|
|
let status_str = match info.widget_status {
|
|
Status::Ok => "active".to_string(),
|
|
Status::Warning => "inactive".to_string(),
|
|
Status::Critical => "failed".to_string(),
|
|
Status::Unknown => "unknown".to_string(),
|
|
};
|
|
|
|
format!("{:<24} {:<10} {:<8} {:<8}",
|
|
short_name,
|
|
status_str,
|
|
memory_str,
|
|
disk_str)
|
|
}
|
|
|
|
/// Format sub-service line (indented, no memory/disk columns) - returns text without icon for span formatting
|
|
fn format_sub_service_line(&self, name: &str, info: &ServiceInfo) -> String {
|
|
// Truncate long sub-service names to fit layout (accounting for indentation)
|
|
let short_name = if name.len() > 18 {
|
|
format!("{}...", &name[..15])
|
|
} else {
|
|
name.to_string()
|
|
};
|
|
|
|
// Sub-services show latency if available, otherwise status
|
|
let status_str = if let Some(latency) = info.latency_ms {
|
|
if latency < 0.0 {
|
|
"timeout".to_string()
|
|
} else {
|
|
format!("{:.0}ms", latency)
|
|
}
|
|
} else {
|
|
match info.widget_status {
|
|
Status::Ok => "active".to_string(),
|
|
Status::Warning => "inactive".to_string(),
|
|
Status::Critical => "failed".to_string(),
|
|
Status::Unknown => "unknown".to_string(),
|
|
}
|
|
};
|
|
|
|
// Indent sub-services with " ├─ " prefix (no memory/disk columns)
|
|
format!(" ├─ {:<20} {:<10}",
|
|
short_name,
|
|
status_str)
|
|
}
|
|
|
|
/// Create spans for sub-service with icon next to name
|
|
fn create_sub_service_spans(&self, name: &str, info: &ServiceInfo) -> Vec<ratatui::text::Span<'static>> {
|
|
// Truncate long sub-service names to fit layout (accounting for indentation)
|
|
let short_name = if name.len() > 18 {
|
|
format!("{}...", &name[..15])
|
|
} else {
|
|
name.to_string()
|
|
};
|
|
|
|
// Sub-services show latency if available, otherwise status
|
|
let status_str = if let Some(latency) = info.latency_ms {
|
|
if latency < 0.0 {
|
|
"timeout".to_string()
|
|
} else {
|
|
format!("{:.0}ms", latency)
|
|
}
|
|
} else {
|
|
match info.widget_status {
|
|
Status::Ok => "active".to_string(),
|
|
Status::Warning => "inactive".to_string(),
|
|
Status::Critical => "failed".to_string(),
|
|
Status::Unknown => "unknown".to_string(),
|
|
}
|
|
};
|
|
|
|
let status_color = match info.widget_status {
|
|
Status::Ok => Theme::success(),
|
|
Status::Warning => Theme::warning(),
|
|
Status::Critical => Theme::error(),
|
|
Status::Unknown => Theme::muted_text(),
|
|
};
|
|
|
|
let icon = StatusIcons::get_icon(info.widget_status);
|
|
|
|
vec![
|
|
// Indentation and tree prefix
|
|
ratatui::text::Span::styled(
|
|
" ├─ ".to_string(),
|
|
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
|
|
),
|
|
// Status icon
|
|
ratatui::text::Span::styled(
|
|
format!("{} ", icon),
|
|
Style::default().fg(status_color).bg(Theme::background())
|
|
),
|
|
// Service name
|
|
ratatui::text::Span::styled(
|
|
format!("{:<18} ", short_name),
|
|
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
|
|
),
|
|
// Status/latency text
|
|
ratatui::text::Span::styled(
|
|
status_str,
|
|
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
|
|
),
|
|
]
|
|
}
|
|
}
|
|
|
|
impl Widget for ServicesWidget {
|
|
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
|
debug!("Services widget updating with {} metrics", metrics.len());
|
|
|
|
// Don't clear existing services - preserve data between metric batches
|
|
|
|
// Process individual service metrics
|
|
for metric in metrics {
|
|
if let Some((parent_service, sub_service)) = Self::extract_service_info(&metric.name) {
|
|
match sub_service {
|
|
None => {
|
|
// Parent service metric
|
|
let service_info = self.parent_services.entry(parent_service).or_insert(ServiceInfo {
|
|
status: "unknown".to_string(),
|
|
memory_mb: None,
|
|
disk_gb: None,
|
|
latency_ms: None,
|
|
widget_status: Status::Unknown,
|
|
});
|
|
|
|
if metric.name.ends_with("_status") {
|
|
service_info.status = metric.value.as_string();
|
|
service_info.widget_status = metric.status;
|
|
} else if metric.name.ends_with("_memory_mb") {
|
|
if let Some(memory) = metric.value.as_f32() {
|
|
service_info.memory_mb = Some(memory);
|
|
}
|
|
} else if metric.name.ends_with("_disk_gb") {
|
|
if let Some(disk) = metric.value.as_f32() {
|
|
service_info.disk_gb = Some(disk);
|
|
}
|
|
}
|
|
}
|
|
Some(sub_name) => {
|
|
// Sub-service metric
|
|
let sub_service_list = self.sub_services.entry(parent_service).or_insert_with(Vec::new);
|
|
|
|
// Find existing sub-service or create new one
|
|
let sub_service_info = if let Some(pos) = sub_service_list.iter().position(|(name, _)| name == &sub_name) {
|
|
&mut sub_service_list[pos].1
|
|
} else {
|
|
sub_service_list.push((sub_name.clone(), ServiceInfo {
|
|
status: "unknown".to_string(),
|
|
memory_mb: None,
|
|
disk_gb: None,
|
|
latency_ms: None,
|
|
widget_status: Status::Unknown,
|
|
}));
|
|
&mut sub_service_list.last_mut().unwrap().1
|
|
};
|
|
|
|
if metric.name.ends_with("_status") {
|
|
sub_service_info.status = metric.value.as_string();
|
|
sub_service_info.widget_status = metric.status;
|
|
} else if metric.name.ends_with("_memory_mb") {
|
|
if let Some(memory) = metric.value.as_f32() {
|
|
sub_service_info.memory_mb = Some(memory);
|
|
}
|
|
} else if metric.name.ends_with("_disk_gb") {
|
|
if let Some(disk) = metric.value.as_f32() {
|
|
sub_service_info.disk_gb = Some(disk);
|
|
}
|
|
} else if metric.name.ends_with("_latency_ms") {
|
|
if let Some(latency) = metric.value.as_f32() {
|
|
sub_service_info.latency_ms = Some(latency);
|
|
sub_service_info.widget_status = metric.status;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Aggregate status from all parent and sub-services
|
|
let mut all_statuses = Vec::new();
|
|
|
|
// Add parent service statuses
|
|
all_statuses.extend(self.parent_services.values().map(|info| info.widget_status));
|
|
|
|
// Add sub-service statuses
|
|
for sub_list in self.sub_services.values() {
|
|
all_statuses.extend(sub_list.iter().map(|(_, info)| info.widget_status));
|
|
}
|
|
|
|
self.status = if all_statuses.is_empty() {
|
|
Status::Unknown
|
|
} else {
|
|
Status::aggregate(&all_statuses)
|
|
};
|
|
|
|
self.has_data = !self.parent_services.is_empty() || !self.sub_services.is_empty();
|
|
|
|
debug!("Services widget updated: {} parent services, {} sub-service groups, status={:?}",
|
|
self.parent_services.len(), self.sub_services.len(), self.status);
|
|
}
|
|
|
|
fn render(&mut self, frame: &mut Frame, area: Rect) {
|
|
let services_block = Components::widget_block("services");
|
|
let inner_area = services_block.inner(area);
|
|
frame.render_widget(services_block, area);
|
|
|
|
let content_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
|
.split(inner_area);
|
|
|
|
// Header
|
|
let header = format!("{:<25} {:<10} {:<8} {:<8}", "Service:", "Status:", "RAM:", "Disk:");
|
|
let header_para = Paragraph::new(header).style(Typography::muted());
|
|
frame.render_widget(header_para, content_chunks[0]);
|
|
|
|
// Check if we have any services to display
|
|
if self.parent_services.is_empty() && self.sub_services.is_empty() {
|
|
let empty_text = Paragraph::new("No process data").style(Typography::muted());
|
|
frame.render_widget(empty_text, content_chunks[1]);
|
|
return;
|
|
}
|
|
|
|
// Build hierarchical service list for display
|
|
let mut display_lines = Vec::new();
|
|
|
|
// Sort parent services alphabetically for consistent order
|
|
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
|
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
|
|
for (parent_name, parent_info) in parent_services {
|
|
// Add parent service line
|
|
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
|
display_lines.push((parent_line, parent_info.widget_status, false, None)); // false = not sub-service
|
|
|
|
// Add sub-services for this parent (if any)
|
|
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
|
// Sort sub-services by name for consistent display
|
|
let mut sorted_subs = sub_list.clone();
|
|
sorted_subs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
|
|
for (sub_name, sub_info) in sorted_subs {
|
|
// Store sub-service info for custom span rendering
|
|
display_lines.push((sub_name.clone(), sub_info.widget_status, true, Some(sub_info.clone()))); // true = sub-service
|
|
}
|
|
}
|
|
}
|
|
|
|
// Render all lines within available space
|
|
let available_lines = content_chunks[1].height as usize;
|
|
let lines_to_show = available_lines.min(display_lines.len());
|
|
|
|
if lines_to_show > 0 {
|
|
let service_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(vec![Constraint::Length(1); lines_to_show])
|
|
.split(content_chunks[1]);
|
|
|
|
for (i, (line_text, line_status, is_sub, sub_info)) in display_lines.iter().take(lines_to_show).enumerate() {
|
|
let spans = if *is_sub && sub_info.is_some() {
|
|
// Use custom sub-service span creation
|
|
self.create_sub_service_spans(line_text, sub_info.as_ref().unwrap())
|
|
} else {
|
|
// Use regular status spans for parent services
|
|
StatusIcons::create_status_spans(*line_status, line_text)
|
|
};
|
|
let service_para = Paragraph::new(ratatui::text::Line::from(spans));
|
|
frame.render_widget(service_para, service_chunks[i]);
|
|
}
|
|
}
|
|
|
|
// Show indicator if there are more services than we can display
|
|
if display_lines.len() > available_lines {
|
|
let more_count = display_lines.len() - available_lines;
|
|
if available_lines > 0 {
|
|
let last_line_area = Rect {
|
|
x: content_chunks[1].x,
|
|
y: content_chunks[1].y + (available_lines - 1) as u16,
|
|
width: content_chunks[1].width,
|
|
height: 1,
|
|
};
|
|
|
|
let more_text = format!("... and {} more services", more_count);
|
|
let more_para = Paragraph::new(more_text).style(Typography::muted());
|
|
frame.render_widget(more_para, last_line_area);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for ServicesWidget {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
} |