Visual Highlighting Fixes: - Apply selection highlighting to individual text spans instead of entire paragraph - Eliminates highlighting of empty space below services on single-service hosts - Selection now only highlights actual service text content Selection Logic Improvements: - Add proper bounds checking in select_next() to prevent invalid selections - Automatically clamp selection index when services are updated - Add debug logging to track selection state and movement Bounds Safety: - Ensure selection index stays valid when service lists change - Prevent out-of-bounds access during service updates - Reset selection to last valid index when services are removed This fixes the issues where: 1. Single service hosts showed empty space highlighting 2. Multi-service hosts had no visible selection cursor 3. Selection could become invalid when services changed
545 lines
21 KiB
Rust
545 lines
21 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::{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<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,
|
|
/// Currently selected service index (for navigation cursor)
|
|
selected_index: usize,
|
|
}
|
|
|
|
#[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,
|
|
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<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::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<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::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<String> {
|
|
// 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()
|
|
}
|
|
}
|