Christoffer Martinsson 999e7b5db5 Fix service selection cursor highlighting and bounds checking
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
2025-10-23 21:37:10 +02:00

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()
}
}