All checks were successful
Build and Release / build-and-release (push) Successful in 1m39s
Change docker images to use name field for all data instead of metrics, matching the pattern used by torrent stats and VPN routes. Increase display width for Status::Info sub-services from 18 to 50 characters to accommodate longer informational text without truncation. - Docker images now show: "image-name size: 994.0 MB" in name field - Torrent stats show: "17 active, ↓ 2.5 MB/s, ↑ 1.2 MB/s" in name field - Remove fixed-width padding for Info status sub-services - Update version to v0.1.245
727 lines
29 KiB
Rust
727 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>> {
|
|
// 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<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()
|
|
}
|
|
}
|