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::{Components, StatusIcons, Theme, Typography}; 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, /// Currently selected service index (for navigation cursor) selected_index: usize, } #[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, selected_index: 0, } } /// 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::Pending => "pending".to_string(), Status::Warning => "inactive".to_string(), Status::Critical => "failed".to_string(), Status::Unknown => "unknown".to_string(), }; format!( "{:<23} {:<10} {:<8} {:<8}", short_name, status_str, memory_str, disk_str ) } /// Create spans for sub-service with icon next to name fn create_sub_service_spans( &self, name: &str, info: &ServiceInfo, is_last: bool, ) -> 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::Pending => "pending".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::Pending => Theme::highlight(), Status::Warning => Theme::warning(), Status::Critical => Theme::error(), Status::Unknown => Theme::muted_text(), }; let icon = StatusIcons::get_icon(info.widget_status); let tree_symbol = if is_last { "└─" } else { "├─" }; vec![ // Indentation and tree prefix ratatui::text::Span::styled( format!(" {} ", tree_symbol), Typography::tree(), ), // 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()), ), ] } /// Move selection up pub fn select_previous(&mut self) { if self.selected_index > 0 { self.selected_index -= 1; } debug!("Service selection moved up to: {}", self.selected_index); } /// Move selection down pub fn select_next(&mut self, total_services: usize) { if total_services > 0 && self.selected_index < total_services.saturating_sub(1) { self.selected_index += 1; } debug!("Service selection: {}/{}", self.selected_index, total_services); } /// Get currently selected service name (for actions) pub fn get_selected_service(&self) -> Option { // Build the same display list to find the selected service let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new(); 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 { display_lines.push((parent_name.clone(), parent_info.widget_status, false, None)); if let Some(sub_list) = self.sub_services.get(parent_name) { let mut sorted_subs = sub_list.clone(); sorted_subs.sort_by(|(a, _), (b, _)| a.cmp(b)); for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() { let is_last_sub = i == sorted_subs.len() - 1; display_lines.push(( format!("{}_{}", parent_name, sub_name), // Use parent_sub format for sub-services sub_info.widget_status, true, Some((sub_info.clone(), is_last_sub)), )); } } } display_lines.get(self.selected_index).map(|(name, _, _, _)| name.clone()) } /// Get total count of services (parent + sub-services) pub fn get_total_services_count(&self) -> usize { let mut count = 0; // Count parent services count += self.parent_services.len(); // Count sub-services for sub_list in self.sub_services.values() { count += sub_list.len(); } count } } 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(); // Ensure selection index is within bounds after update let total_count = self.get_total_services_count(); if self.selected_index >= total_count && total_count > 0 { self.selected_index = total_count - 1; } debug!( "Services widget updated: {} parent services, {} sub-service groups, total={}, selected={}, status={:?}", self.parent_services.len(), self.sub_services.len(), total_count, self.selected_index, self.status ); } fn render(&mut self, frame: &mut Frame, area: Rect) { self.render_with_focus(frame, area, false); } } impl ServicesWidget { /// Render with optional focus indicator and scroll support pub fn render_with_focus(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) { self.render_with_focus_and_scroll(frame, area, is_focused, 0); } /// Render with focus indicator and scroll offset pub fn render_with_focus_and_scroll(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize) { let services_block = if is_focused { Components::focused_widget_block("services") } else { 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<(String, Status, bool, Option<(ServiceInfo, bool)>)> = 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 (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() { let is_last_sub = i == sorted_subs.len() - 1; // Store sub-service info for custom span rendering display_lines.push(( sub_name.clone(), sub_info.widget_status, true, Some((sub_info.clone(), is_last_sub)), )); // true = sub-service, with is_last info } } } // Apply scroll offset and render visible lines let available_lines = content_chunks[1].height as usize; let total_lines = display_lines.len(); // Calculate scroll boundaries let max_scroll = if total_lines > available_lines { total_lines - available_lines } else { total_lines.saturating_sub(1) }; let effective_scroll = scroll_offset.min(max_scroll); // Get visible lines after scrolling let visible_lines: Vec<_> = display_lines .iter() .skip(effective_scroll) .take(available_lines) .collect(); let lines_to_show = visible_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 visible_lines.iter().enumerate() { let actual_index = effective_scroll + i; // Real index in the full list let is_selected = actual_index == self.selected_index; let mut spans = if *is_sub && sub_info.is_some() { // Use custom sub-service span creation let (service_info, is_last) = sub_info.as_ref().unwrap(); self.create_sub_service_spans(line_text, service_info, *is_last) } else { // Use regular status spans for parent services StatusIcons::create_status_spans(*line_status, line_text) }; // Apply selection highlighting to spans if is_selected { for span in spans.iter_mut() { span.style = span.style .bg(Theme::highlight()) .fg(Theme::background()); } } let service_para = Paragraph::new(ratatui::text::Line::from(spans)); frame.render_widget(service_para, service_chunks[i]); } } // Show scroll indicator if there are more services than we can display if total_lines > available_lines { let hidden_above = effective_scroll; let hidden_below = total_lines.saturating_sub(effective_scroll + available_lines); if hidden_above > 0 || hidden_below > 0 { let scroll_text = if hidden_above > 0 && hidden_below > 0 { format!("... {} above, {} below", hidden_above, hidden_below) } else if hidden_above > 0 { format!("... {} more above", hidden_above) } else { format!("... {} more below", hidden_below) }; if available_lines > 0 && lines_to_show > 0 { let last_line_area = Rect { x: content_chunks[1].x, y: content_chunks[1].y + (lines_to_show - 1) as u16, width: content_chunks[1].width, height: 1, }; let scroll_para = Paragraph::new(scroll_text).style(Typography::muted()); frame.render_widget(scroll_para, last_line_area); } } } } } impl Default for ServicesWidget { fn default() -> Self { Self::new() } }