use cm_dashboard_shared::{Metric, Status}; use super::Widget; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, widgets::Paragraph, Frame, }; use std::collections::HashMap; use tracing::debug; use crate::ui::theme::{Components, StatusIcons, Theme, Typography}; use ratatui::style::Style; /// Column visibility configuration based on terminal width #[derive(Debug, Clone, Copy)] struct ColumnVisibility { show_name: bool, show_status: bool, show_ram: bool, show_uptime: bool, show_restarts: bool, } impl ColumnVisibility { /// Calculate actual width needed for all columns const NAME_WIDTH: u16 = 23; const STATUS_WIDTH: u16 = 10; const RAM_WIDTH: u16 = 8; const UPTIME_WIDTH: u16 = 8; const RESTARTS_WIDTH: u16 = 5; const COLUMN_SPACING: u16 = 1; // Space between columns /// Determine which columns to show based on available width /// Priority order: Name > Status > RAM > Uptime > Restarts fn from_width(width: u16) -> Self { // Calculate cumulative widths for each configuration let minimal = Self::NAME_WIDTH + Self::COLUMN_SPACING + Self::STATUS_WIDTH; // 34 let with_ram = minimal + Self::COLUMN_SPACING + Self::RAM_WIDTH; // 43 let with_uptime = with_ram + Self::COLUMN_SPACING + Self::UPTIME_WIDTH; // 52 let full = with_uptime + Self::COLUMN_SPACING + Self::RESTARTS_WIDTH; // 58 if width >= full { // Show all columns Self { show_name: true, show_status: true, show_ram: true, show_uptime: true, show_restarts: true, } } else if width >= with_uptime { // Hide restarts Self { show_name: true, show_status: true, show_ram: true, show_uptime: true, show_restarts: false, } } else if width >= with_ram { // Hide uptime and restarts Self { show_name: true, show_status: true, show_ram: true, show_uptime: false, show_restarts: false, } } else { // Minimal: Name + Status only Self { show_name: true, show_status: true, show_ram: false, show_uptime: false, show_restarts: false, } } } } /// 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 { metrics: Vec<(String, f32, Option)>, // (label, value, unit) widget_status: Status, service_type: String, // "nginx_site", "container", "image", or empty for parent services memory_bytes: Option, restart_count: Option, uptime_seconds: Option, } 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 #[allow(dead_code)] 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("_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 parent service line - returns text without icon for span formatting fn format_parent_service_line(&self, name: &str, info: &ServiceInfo, columns: ColumnVisibility) -> String { // Truncate long service names to fit layout // NAME_WIDTH - 3 chars for "..." = max displayable chars let max_name_len = (ColumnVisibility::NAME_WIDTH - 3) as usize; let short_name = if name.len() > max_name_len { format!("{}...", &name[..max_name_len.saturating_sub(3)]) } else { name.to_string() }; // Convert Status enum to display text let status_str = match info.widget_status { Status::Info => "", // Shouldn't happen for parent services Status::Ok => "active", Status::Inactive => "inactive", Status::Critical => "failed", Status::Pending => "pending", Status::Warning => "warning", Status::Unknown => "unknown", Status::Offline => "offline", }; // Format memory let memory_str = info.memory_bytes.map_or("-".to_string(), |bytes| { let mb = bytes as f64 / (1024.0 * 1024.0); if mb >= 1000.0 { format!("{:.1}G", mb / 1024.0) } else { format!("{:.0}M", mb) } }); // Format uptime let uptime_str = info.uptime_seconds.map_or("-".to_string(), |secs| { let days = secs / 86400; let hours = (secs % 86400) / 3600; let mins = (secs % 3600) / 60; if days > 0 { format!("{}d{}h", days, hours) } else if hours > 0 { format!("{}h{}m", hours, mins) } else { format!("{}m", mins) } }); // Format restarts (show "!" if > 0 to indicate instability) let restart_str = info.restart_count.map_or("-".to_string(), |count| { if count > 0 { format!("!{}", count) } else { "0".to_string() } }); // Build format string based on column visibility let mut parts = Vec::new(); if columns.show_name { parts.push(format!("{: Vec> { // Informational sub-services (Status::Info) can use more width since they don't show columns let max_width = if info.widget_status == Status::Info { 50 } else { 18 }; // Truncate long sub-service names to fit layout (accounting for indentation) let short_name = if name.len() > max_width { format!("{}...", &name[..(max_width.saturating_sub(3))]) } 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::Info => Theme::muted_text(), 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(), }; // Display metrics or status for sub-services let status_str = if !info.metrics.is_empty() { // Show first metric with label and unit let (label, value, unit) = &info.metrics[0]; match unit { Some(u) => format!("{}: {:.1} {}", label, value, u), None => format!("{}: {:.1}", label, value), } } else { // Convert Status enum to display text for sub-services match info.widget_status { Status::Info => "", Status::Ok => "active", Status::Inactive => "inactive", Status::Critical => "failed", Status::Pending => "pending", Status::Warning => "warning", Status::Unknown => "unknown", Status::Offline => "offline", }.to_string() }; let tree_symbol = if is_last { "└─" } else { "├─" }; if info.widget_status == Status::Info { // Informational data - no status icon, show metrics if available let mut spans = vec![ // Indentation and tree prefix ratatui::text::Span::styled( format!(" {} ", tree_symbol), Typography::tree(), ), // Service name (no icon) - no fixed width padding for Info status ratatui::text::Span::styled( short_name, Style::default() .fg(Theme::secondary_text()) .bg(Theme::background()), ), ]; // Add metrics if available (e.g., Docker image size) if !status_str.is_empty() { spans.push(ratatui::text::Span::styled( status_str, Style::default() .fg(Theme::secondary_text()) .bg(Theme::background()), )); } spans } 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) /// Only returns parent service names since only parent services can be selected pub fn get_selected_service(&self) -> Option { // Only parent services can be selected, so just get the parent service at selected_index let mut parent_services: Vec<_> = self.parent_services.iter().collect(); parent_services.sort_by(|(a, _), (b, _)| a.cmp(b)); parent_services.get(self.selected_index).map(|(name, _)| name.to_string()) } /// 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_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) { self.has_data = true; self.parent_services.clear(); self.sub_services.clear(); for service in &agent_data.services { // Store parent service let parent_info = ServiceInfo { metrics: Vec::new(), // Parent services don't have custom metrics widget_status: service.service_status, service_type: String::new(), // Parent services have no type memory_bytes: service.memory_bytes, restart_count: service.restart_count, uptime_seconds: service.uptime_seconds, }; self.parent_services.insert(service.name.clone(), parent_info); // Process sub-services if any if !service.sub_services.is_empty() { let mut sub_list = Vec::new(); for sub_service in &service.sub_services { // Convert metrics to display format let metrics: Vec<(String, f32, Option)> = sub_service.metrics.iter() .map(|m| (m.label.clone(), m.value, m.unit.clone())) .collect(); let sub_info = ServiceInfo { metrics, widget_status: sub_service.service_status, service_type: sub_service.service_type.clone(), memory_bytes: None, // Sub-services don't have individual metrics yet restart_count: None, uptime_seconds: None, }; sub_list.push((sub_service.name.clone(), sub_info)); } self.sub_services.insert(service.name.clone(), sub_list); } } // Aggregate status from all services let mut all_statuses = Vec::new(); all_statuses.extend(self.parent_services.values().map(|info| info.widget_status)); 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) }; } } impl ServicesWidget { #[allow(dead_code)] 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 { metrics: Vec::new(), widget_status: Status::Unknown, service_type: String::new(), memory_bytes: None, restart_count: None, uptime_seconds: None, }); if metric.name.ends_with("_status") { service_info.widget_status = metric.status; } } 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 { metrics: Vec::new(), widget_status: Status::Unknown, service_type: String::new(), // Unknown type in legacy path memory_bytes: None, restart_count: None, uptime_seconds: None, }, )); &mut sub_service_list.last_mut().unwrap().1 }; if metric.name.ends_with("_status") { 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); // Determine which columns to show based on available width let columns = ColumnVisibility::from_width(inner_area.width); // Build header based on visible columns let mut header_parts = Vec::new(); if columns.show_name { header_parts.push(format!("{:)> = 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, columns); 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() } }