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 actual systemctl status let status_str = match info.widget_status { Status::Pending => "pending".to_string(), _ => info.status.clone(), // Use actual status from agent (active/inactive/failed) }; 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() }; // Get status icon and text let icon = StatusIcons::get_icon(info.widget_status); let status_color = match info.widget_status { Status::Ok => Theme::success(), Status::Inactive => Theme::muted_text(), Status::Pending => Theme::highlight(), Status::Warning => Theme::warning(), Status::Critical => Theme::error(), Status::Unknown => Theme::muted_text(), Status::Offline => Theme::muted_text(), }; // For sub-services, prefer latency if available let status_str = if let Some(latency) = info.latency_ms { if latency < 0.0 { "timeout".to_string() } else { format!("{:.0}ms", latency) } } else { info.status.clone() }; 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)>, String)> = 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 { let parent_line = self.format_parent_service_line(parent_name, parent_info); display_lines.push((parent_line, parent_info.widget_status, false, None, parent_name.clone())); 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; let full_sub_name = format!("{}_{}", parent_name, sub_name); display_lines.push(( sub_name.clone(), sub_info.widget_status, true, Some((sub_info.clone(), is_last_sub)), full_sub_name, )); } } } display_lines.get(self.selected_index).map(|(_, _, _, _, raw_name)| raw_name.clone()) } /// Get total count of selectable services (parent services only, not sub-services) pub fn get_total_services_count(&self) -> usize { // Only count parent services - sub-services are not selectable self.parent_services.len() } /// Calculate which parent service index corresponds to a display line index fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize { // Build the same display list to map line index to parent service index let mut parent_index = 0; let mut line_index = 0; let mut parent_services: Vec<_> = self.parent_services.iter().collect(); parent_services.sort_by(|(a, _), (b, _)| a.cmp(b)); for (parent_name, _) in parent_services { if line_index == *display_line_index { return parent_index; } line_index += 1; // Parent service line // Skip sub-services but count them in line_index if let Some(sub_list) = self.sub_services.get(parent_name) { line_index += sub_list.len(); } parent_index += 1; } // If we get here, the display_line_index was probably for a sub-service // Return the last valid parent index (should not happen with our logic) parent_index.saturating_sub(1) } } 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 ); } } impl ServicesWidget { /// Render with focus pub fn render(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) { 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; } // Render the services list self.render_services(frame, content_chunks[1], is_focused); } /// Render services list fn render_services(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) { // 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)); // 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 } } } // Show only what fits, with "X more below" if needed let available_lines = area.height as usize; let total_lines = display_lines.len(); // Reserve one line for "X more below" if needed let lines_for_content = if total_lines > available_lines { available_lines.saturating_sub(1) } else { available_lines }; let visible_lines: Vec<_> = display_lines .iter() .take(lines_for_content) .collect(); let hidden_below = total_lines.saturating_sub(lines_for_content); let lines_to_show = visible_lines.len(); if lines_to_show > 0 { // Add space for "X more below" message if needed let total_chunks_needed = if hidden_below > 0 { lines_to_show + 1 } else { lines_to_show }; let service_chunks = Layout::default() .direction(Direction::Vertical) .constraints(vec![Constraint::Length(1); total_chunks_needed]) .split(area); for (i, (line_text, line_status, is_sub, sub_info)) in visible_lines.iter().enumerate() { let actual_index = i; // Simple index since we're not scrolling // Only parent services can be selected - calculate parent service index let is_selected = if !*is_sub { // This is a parent service - count how many parent services came before this one let parent_index = self.calculate_parent_service_index(&actual_index); parent_index == self.selected_index } else { false // Sub-services are never selected }; 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 { // Parent services - use normal status spans StatusIcons::create_status_spans(*line_status, line_text) }; // Apply selection highlighting to parent services only // Only show selection when Services panel is focused if is_selected && !*is_sub && is_focused { for (i, span) in spans.iter_mut().enumerate() { if i == 0 { // First span is the status icon - use background color for visibility against blue selection span.style = span.style .bg(Theme::highlight()) .fg(Theme::background()); } else { // Other spans (text) get full selection highlighting 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 "X more below" message if content was truncated if hidden_below > 0 { let more_text = format!("... {} more below", hidden_below); let more_para = Paragraph::new(more_text).style(Typography::muted()); frame.render_widget(more_para, service_chunks[lines_to_show]); } } } } impl Default for ServicesWidget { fn default() -> Self { Self::new() } }