All checks were successful
Build and Release / build-and-release (push) Successful in 1m9s
Agent uses Status enum to control display: - Status::Info: no icon, no status text (VPN IP) - Other statuses: icon + text (containers, nginx sites) Dashboard checks status, no hardcoded service_type exceptions. Version: v0.1.237
740 lines
29 KiB
Rust
740 lines
29 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)
|
|
selected_index: usize,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ServiceInfo {
|
|
metrics: Vec<(String, f32, Option<String>)>, // (label, value, unit)
|
|
widget_status: Status,
|
|
service_type: String, // "nginx_site", "container", "image", or empty for parent services
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// 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>> {
|
|
// 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::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 { "├─" };
|
|
|
|
// Docker images use docker whale icon
|
|
if info.service_type == "image" {
|
|
vec![
|
|
// Indentation and tree prefix
|
|
ratatui::text::Span::styled(
|
|
format!(" {} ", tree_symbol),
|
|
Typography::tree(),
|
|
),
|
|
// Docker icon (simple character for performance)
|
|
ratatui::text::Span::styled(
|
|
"D ".to_string(),
|
|
Style::default().fg(Theme::highlight()).bg(Theme::background()),
|
|
),
|
|
// Service name
|
|
ratatui::text::Span::styled(
|
|
format!("{:<18} ", short_name),
|
|
Style::default()
|
|
.fg(Theme::secondary_text())
|
|
.bg(Theme::background()),
|
|
),
|
|
// Status/metrics text
|
|
ratatui::text::Span::styled(
|
|
status_str,
|
|
Style::default()
|
|
.fg(Theme::secondary_text())
|
|
.bg(Theme::background()),
|
|
),
|
|
]
|
|
} else if info.widget_status == Status::Info {
|
|
// Informational data - no status icon
|
|
vec![
|
|
// Indentation and tree prefix
|
|
ratatui::text::Span::styled(
|
|
format!(" {} ", tree_symbol),
|
|
Typography::tree(),
|
|
),
|
|
// Service name (no icon)
|
|
ratatui::text::Span::styled(
|
|
short_name,
|
|
Style::default()
|
|
.fg(Theme::secondary_text())
|
|
.bg(Theme::background()),
|
|
),
|
|
]
|
|
} 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<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()
|
|
}
|
|
|
|
|
|
/// 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<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,
|
|
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!("{:<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();
|
|
|
|
// 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()
|
|
}
|
|
}
|