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, /// Sub-services grouped by parent (nginx -> [gitea, mariehall, ...], docker -> [container1, ...]) sub_services: HashMap>, /// Aggregated status status: Status, /// Last update indicator has_data: bool, } #[derive(Clone)] struct ServiceInfo { status: String, memory_mb: Option, disk_gb: Option, latency_ms: Option, 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)> { 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> { // 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() } }