All checks were successful
Build and Release / build-and-release (push) Successful in 1m12s
Fix service selection scrolling to prevent selector bar from being hidden by "... X more below" message. When scrolling down, position selected service one line above the bottom if there's content below, ensuring the selector remains visible above the overflow message. Remove unused get_zmq_stats method and service_type field to eliminate compilation warnings and dead code.
908 lines
36 KiB
Rust
908 lines
36 KiB
Rust
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<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)
|
|
pub selected_index: usize,
|
|
/// Scroll offset for viewport (which display line is at the top)
|
|
pub scroll_offset: usize,
|
|
/// Last rendered viewport height (for accurate scroll bounds)
|
|
last_viewport_height: usize,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ServiceInfo {
|
|
metrics: Vec<(String, f32, Option<String>)>, // (label, value, unit)
|
|
widget_status: Status,
|
|
memory_bytes: Option<u64>,
|
|
restart_count: Option<u32>,
|
|
uptime_seconds: Option<u64>,
|
|
}
|
|
|
|
impl ServicesWidget {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
parent_services: HashMap::new(),
|
|
sub_services: HashMap::new(),
|
|
status: Status::Unknown,
|
|
has_data: false,
|
|
selected_index: 0,
|
|
scroll_offset: 0,
|
|
last_viewport_height: 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<String>)> {
|
|
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!("{:<width$}", short_name, width = ColumnVisibility::NAME_WIDTH as usize));
|
|
}
|
|
if columns.show_status {
|
|
parts.push(format!("{:<width$}", status_str, width = ColumnVisibility::STATUS_WIDTH as usize));
|
|
}
|
|
if columns.show_ram {
|
|
parts.push(format!("{:<width$}", memory_str, width = ColumnVisibility::RAM_WIDTH as usize));
|
|
}
|
|
if columns.show_uptime {
|
|
parts.push(format!("{:<width$}", uptime_str, width = ColumnVisibility::UPTIME_WIDTH as usize));
|
|
}
|
|
if columns.show_restarts {
|
|
parts.push(format!("{:<width$}", restart_str, width = ColumnVisibility::RESTARTS_WIDTH as usize));
|
|
}
|
|
|
|
parts.join(" ")
|
|
}
|
|
|
|
|
|
|
|
/// 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>> {
|
|
// 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;
|
|
self.ensure_selected_visible();
|
|
}
|
|
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;
|
|
self.ensure_selected_visible();
|
|
}
|
|
debug!("Service selection: {}/{}", self.selected_index, total_services);
|
|
}
|
|
|
|
/// Convert parent service index to display line index
|
|
fn parent_index_to_display_line(&self, parent_index: usize) -> usize {
|
|
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
|
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
|
|
let mut display_line = 0;
|
|
for (idx, (parent_name, _)) in parent_services.iter().enumerate() {
|
|
if idx == parent_index {
|
|
return display_line;
|
|
}
|
|
display_line += 1; // Parent service line
|
|
|
|
// Add sub-service lines
|
|
if let Some(sub_list) = self.sub_services.get(*parent_name) {
|
|
display_line += sub_list.len();
|
|
}
|
|
}
|
|
display_line
|
|
}
|
|
|
|
/// Ensure the currently selected service is visible in the viewport
|
|
fn ensure_selected_visible(&mut self) {
|
|
if self.last_viewport_height == 0 {
|
|
return; // Can't adjust without knowing viewport size
|
|
}
|
|
|
|
let display_line = self.parent_index_to_display_line(self.selected_index);
|
|
let total_display_lines = self.get_total_display_lines();
|
|
let viewport_height = self.last_viewport_height;
|
|
|
|
// Check if selected line is above visible area
|
|
if display_line < self.scroll_offset {
|
|
self.scroll_offset = display_line;
|
|
return;
|
|
}
|
|
|
|
// Calculate current effective viewport (accounting for "more below" if present)
|
|
let current_remaining = total_display_lines.saturating_sub(self.scroll_offset);
|
|
let current_has_more = current_remaining > viewport_height;
|
|
let current_effective = if current_has_more {
|
|
viewport_height.saturating_sub(1)
|
|
} else {
|
|
viewport_height
|
|
};
|
|
|
|
// Check if selected line is below current visible area
|
|
if display_line >= self.scroll_offset + current_effective {
|
|
// Need to scroll down. Position selected line so there's room for "more below" if needed
|
|
// Strategy: if there are lines below the selected line, don't put it at the very bottom
|
|
let has_content_below = display_line < total_display_lines - 1;
|
|
|
|
if has_content_below {
|
|
// Leave room for "... X more below" message by positioning selected line
|
|
// one position higher than the last line
|
|
let target_position = viewport_height.saturating_sub(2);
|
|
self.scroll_offset = display_line.saturating_sub(target_position);
|
|
} else {
|
|
// This is the last line, can put it at the bottom
|
|
self.scroll_offset = display_line.saturating_sub(viewport_height - 1);
|
|
}
|
|
}
|
|
|
|
debug!("Auto-scroll: selected={}, display_line={}, scroll_offset={}, viewport={}, total={}",
|
|
self.selected_index, display_line, self.scroll_offset, viewport_height, total_display_lines);
|
|
}
|
|
|
|
/// 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<String> {
|
|
// 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()
|
|
}
|
|
|
|
/// Get total display lines (parent services + sub-services)
|
|
pub fn get_total_display_lines(&self) -> usize {
|
|
let mut total = self.parent_services.len();
|
|
for sub_list in self.sub_services.values() {
|
|
total += sub_list.len();
|
|
}
|
|
total
|
|
}
|
|
|
|
/// Scroll down by one line
|
|
pub fn scroll_down(&mut self, _visible_height: usize) {
|
|
let total_lines = self.get_total_display_lines();
|
|
|
|
// Use last_viewport_height if available (more accurate), otherwise can't scroll
|
|
let viewport_height = if self.last_viewport_height > 0 {
|
|
self.last_viewport_height
|
|
} else {
|
|
return; // Can't scroll without knowing viewport size
|
|
};
|
|
|
|
// Calculate exact max scroll to match render logic
|
|
// Stop scrolling when all remaining content fits in viewport
|
|
// At scroll_offset N: remaining = total_lines - N
|
|
// We can show all when: remaining <= viewport_height
|
|
// So max_scroll is when: total_lines - max_scroll = viewport_height
|
|
// Therefore: max_scroll = total_lines - viewport_height (but at least 0)
|
|
let max_scroll = total_lines.saturating_sub(viewport_height);
|
|
|
|
debug!("Scroll down: total={}, viewport={}, offset={}, max={}", total_lines, viewport_height, self.scroll_offset, max_scroll);
|
|
|
|
if self.scroll_offset < max_scroll {
|
|
self.scroll_offset += 1;
|
|
}
|
|
}
|
|
|
|
/// Scroll up by one line
|
|
pub fn scroll_up(&mut self) {
|
|
if self.scroll_offset > 0 {
|
|
self.scroll_offset -= 1;
|
|
}
|
|
}
|
|
|
|
|
|
/// Map a display line index to a parent service index (returns None if clicked on sub-service)
|
|
pub fn display_line_to_parent_index(&self, display_line_index: usize) -> Option<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 {
|
|
// Check if this line index matches a parent service
|
|
if line_index == display_line_index {
|
|
return Some(parent_index);
|
|
}
|
|
line_index += 1;
|
|
|
|
// Add sub-services for this parent (if any)
|
|
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
|
for _ in sub_list {
|
|
if line_index == display_line_index {
|
|
// Clicked on a sub-service - return None (can't select sub-services)
|
|
return None;
|
|
}
|
|
line_index += 1;
|
|
}
|
|
}
|
|
|
|
parent_index += 1;
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// 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,
|
|
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<String>)> = 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,
|
|
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,
|
|
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,
|
|
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;
|
|
}
|
|
|
|
// Clamp scroll offset to valid range after update
|
|
// This prevents scroll issues when switching between hosts or when service count changes
|
|
let total_display_lines = self.get_total_display_lines();
|
|
if total_display_lines == 0 {
|
|
self.scroll_offset = 0;
|
|
} else if self.scroll_offset >= total_display_lines {
|
|
// Clamp to max valid value, not reset to 0
|
|
self.scroll_offset = total_display_lines.saturating_sub(1);
|
|
}
|
|
|
|
debug!(
|
|
"Services widget updated: {} parent services, {} sub-service groups, total={}, selected={}, scroll={}, status={:?}",
|
|
self.parent_services.len(),
|
|
self.sub_services.len(),
|
|
total_count,
|
|
self.selected_index,
|
|
self.scroll_offset,
|
|
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!("{:<width$}", "Service:", width = ColumnVisibility::NAME_WIDTH as usize));
|
|
}
|
|
if columns.show_status {
|
|
header_parts.push(format!("{:<width$}", "Status:", width = ColumnVisibility::STATUS_WIDTH as usize));
|
|
}
|
|
if columns.show_ram {
|
|
header_parts.push(format!("{:<width$}", "RAM:", width = ColumnVisibility::RAM_WIDTH as usize));
|
|
}
|
|
if columns.show_uptime {
|
|
header_parts.push(format!("{:<width$}", "Uptime:", width = ColumnVisibility::UPTIME_WIDTH as usize));
|
|
}
|
|
if columns.show_restarts {
|
|
header_parts.push(format!("{:<width$}", "↻:", width = ColumnVisibility::RESTARTS_WIDTH as usize));
|
|
}
|
|
let header = header_parts.join(" ");
|
|
|
|
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, columns);
|
|
}
|
|
|
|
/// Render services list
|
|
fn render_services(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, columns: ColumnVisibility) {
|
|
// 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, 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();
|
|
|
|
// Store viewport height for accurate scroll calculations
|
|
self.last_viewport_height = available_lines;
|
|
|
|
// Clamp scroll_offset to valid range based on current viewport and content
|
|
// This handles dynamic viewport size changes
|
|
let max_valid_scroll = total_lines.saturating_sub(available_lines);
|
|
if self.scroll_offset > max_valid_scroll {
|
|
self.scroll_offset = max_valid_scroll;
|
|
}
|
|
|
|
// Calculate how many lines remain after scroll offset
|
|
let remaining_lines = total_lines.saturating_sub(self.scroll_offset);
|
|
|
|
debug!("Render: total={}, viewport={}, offset={}, max={}, remaining={}",
|
|
total_lines, available_lines, self.scroll_offset, max_valid_scroll, remaining_lines);
|
|
|
|
// Check if all remaining content fits in viewport
|
|
let will_show_more_below = remaining_lines > available_lines;
|
|
|
|
// Reserve one line for "X more below" only if we can't fit everything
|
|
let lines_for_content = if will_show_more_below {
|
|
available_lines.saturating_sub(1)
|
|
} else {
|
|
available_lines.min(remaining_lines)
|
|
};
|
|
|
|
// Apply scroll offset
|
|
let visible_lines: Vec<_> = display_lines
|
|
.iter()
|
|
.skip(self.scroll_offset)
|
|
.take(lines_for_content)
|
|
.collect();
|
|
|
|
// Only calculate hidden_below if we actually reserved space for the message
|
|
let hidden_below = if will_show_more_below {
|
|
remaining_lines.saturating_sub(lines_for_content)
|
|
} else {
|
|
0
|
|
};
|
|
|
|
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 = self.scroll_offset + i; // Account for scroll offset
|
|
|
|
// 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(Style::default().fg(Theme::border()));
|
|
frame.render_widget(more_para, service_chunks[lines_to_show]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for ServicesWidget {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|