From 535784e849dc917d6c12de9c47cac44bd9fd323f Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Tue, 16 Dec 2025 13:15:24 +0100 Subject: [PATCH] Improve dashboard UI layout and status aggregation - Move hosts panel to left side above system panel - Add dynamic column layout for hosts based on available width - Fix status aggregation to properly calculate host status from widgets - Align service panel columns with header - Use blue color for metrics without status indicators - Add offline host popup overlay - Use foreground color for panel titles --- Cargo.lock | 6 +- agent/Cargo.toml | 2 +- dashboard/Cargo.toml | 2 +- dashboard/src/app.rs | 91 +++----- dashboard/src/ui/mod.rs | 304 ++++++++++++++------------- dashboard/src/ui/theme.rs | 13 +- dashboard/src/ui/widgets/hosts.rs | 262 +++++++++-------------- dashboard/src/ui/widgets/services.rs | 77 ++----- dashboard/src/ui/widgets/system.rs | 30 +++ shared/Cargo.toml | 2 +- 10 files changed, 342 insertions(+), 447 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3fcdd9c..0e5348c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.278" +version = "0.1.280" dependencies = [ "anyhow", "chrono", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.278" +version = "0.1.280" dependencies = [ "anyhow", "async-trait", @@ -325,7 +325,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.278" +version = "0.1.280" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 29601f8..9114793 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.278" +version = "0.1.280" edition = "2021" [dependencies] diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 9aba090..e06fb9c 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.278" +version = "0.1.280" edition = "2021" [dependencies] diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs index 05cc5ec..dfa42a8 100644 --- a/dashboard/src/app.rs +++ b/dashboard/src/app.rs @@ -377,17 +377,6 @@ impl Dashboard { return Ok(()); } - // Check for tab clicks in right panel (hosts | services) - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - let services_end = self.services_area.x.saturating_add(self.services_area.width); - if y == self.services_area.y && x >= self.services_area.x && x < services_end { - // Click on top border of services area (where tabs are) - if let Some(ref mut tui_app) = self.tui_app { - tui_app.handle_tab_click(x, &self.services_area); - } - return Ok(()); - } - } // Determine which panel the mouse is over let in_system_area = is_in_area(x, y, &self.system_area); @@ -448,65 +437,43 @@ impl Dashboard { } if let Some(ref mut tui_app) = self.tui_app { - if tui_app.focus_hosts { - // Hosts tab is active - handle host click - // The services area includes a border and header, so account for that - let relative_y = y.saturating_sub(self.services_area.y + 2) as usize; // +2 for border and header + // Handle service click + // The services area includes a border, so we need to account for that + let relative_y = y.saturating_sub(self.services_area.y + 2) as usize; // +2 for border and header - let total_hosts = tui_app.get_available_hosts().len(); - let clicked_index = tui_app.hosts_widget.y_to_host_index(relative_y); + if let Some(hostname) = tui_app.current_host.clone() { + let host_widgets = tui_app.get_or_create_host_widgets(&hostname); + + // Account for scroll offset - the clicked line is relative to viewport + let display_line_index = host_widgets.services_widget.scroll_offset + relative_y; + + // Map display line to parent service index + if let Some(parent_index) = host_widgets.services_widget.display_line_to_parent_index(display_line_index) { + // Set the selected index to the clicked parent service + host_widgets.services_widget.selected_index = parent_index; - if clicked_index < total_hosts { match button { MouseButton::Left => { - // Left click: set selector and switch to host immediately - tui_app.hosts_widget.set_selected_index(clicked_index, total_hosts); - let selected_host = tui_app.get_available_hosts()[clicked_index].clone(); - tui_app.switch_to_host(&selected_host); - debug!("Clicked host at index {}: {}", clicked_index, selected_host); + // Left click just selects the service + debug!("Left-clicked service at display line {} (parent index: {})", display_line_index, parent_index); + } + MouseButton::Right => { + // Right click opens context menu + debug!("Right-clicked service at display line {} (parent index: {})", display_line_index, parent_index); + + // Get the service name for the popup + if let Some(service_name) = host_widgets.services_widget.get_selected_service() { + tui_app.popup_menu = Some(crate::ui::PopupMenu { + service_name, + x, + y, + selected_index: 0, + }); + } } _ => {} } } - } else { - // Services tab is active - handle service click - // The services area includes a border, so we need to account for that - let relative_y = y.saturating_sub(self.services_area.y + 2) as usize; // +2 for border and header - - if let Some(hostname) = tui_app.current_host.clone() { - let host_widgets = tui_app.get_or_create_host_widgets(&hostname); - - // Account for scroll offset - the clicked line is relative to viewport - let display_line_index = host_widgets.services_widget.scroll_offset + relative_y; - - // Map display line to parent service index - if let Some(parent_index) = host_widgets.services_widget.display_line_to_parent_index(display_line_index) { - // Set the selected index to the clicked parent service - host_widgets.services_widget.selected_index = parent_index; - - match button { - MouseButton::Left => { - // Left click just selects the service - debug!("Left-clicked service at display line {} (parent index: {})", display_line_index, parent_index); - } - MouseButton::Right => { - // Right click opens context menu - debug!("Right-clicked service at display line {} (parent index: {})", display_line_index, parent_index); - - // Get the service name for the popup - if let Some(service_name) = host_widgets.services_widget.get_selected_service() { - tui_app.popup_menu = Some(crate::ui::PopupMenu { - service_name, - x, - y, - selected_index: 0, - }); - } - } - _ => {} - } - } - } } } } diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index 32b1b6f..2447c20 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -74,8 +74,6 @@ pub struct TuiApp { localhost: String, /// Active popup menu (if any) pub popup_menu: Option, - /// Focus on hosts tab (false = Services, true = Hosts) - pub focus_hosts: bool, /// Hosts widget for navigation and rendering pub hosts_widget: HostsWidget, } @@ -92,7 +90,6 @@ impl TuiApp { config, localhost, popup_menu: None, - focus_hosts: true, // Start with Hosts tab focused by default hosts_widget: HostsWidget::new(), }; @@ -358,46 +355,29 @@ impl TuiApp { } } KeyCode::Tab => { - // Tab toggles between Services and Hosts tabs - self.focus_hosts = !self.focus_hosts; + // Tab cycles to next host + self.cycle_next_host(); + } + KeyCode::BackTab => { + // Shift+Tab cycles to previous host + self.cycle_previous_host(); } KeyCode::Up | KeyCode::Char('k') => { - if self.focus_hosts { - // Move blue selector bar up when in Hosts tab - self.hosts_widget.select_previous(); - } else { - // Move service selection up when in Services tab - if let Some(hostname) = self.current_host.clone() { - let host_widgets = self.get_or_create_host_widgets(&hostname); - host_widgets.services_widget.select_previous(); - } + // Move service selection up + if let Some(hostname) = self.current_host.clone() { + let host_widgets = self.get_or_create_host_widgets(&hostname); + host_widgets.services_widget.select_previous(); } } KeyCode::Down | KeyCode::Char('j') => { - if self.focus_hosts { - // Move blue selector bar down when in Hosts tab - let total_hosts = self.available_hosts.len(); - self.hosts_widget.select_next(total_hosts); - } else { - // Move service selection down when in Services tab - if let Some(hostname) = self.current_host.clone() { - let total_services = { - let host_widgets = self.get_or_create_host_widgets(&hostname); - host_widgets.services_widget.get_total_services_count() - }; + // Move service selection down + if let Some(hostname) = self.current_host.clone() { + let total_services = { let host_widgets = self.get_or_create_host_widgets(&hostname); - host_widgets.services_widget.select_next(total_services); - } - } - } - KeyCode::Enter => { - if self.focus_hosts { - // Enter key switches to the selected host - let selected_idx = self.hosts_widget.get_selected_index(); - if selected_idx < self.available_hosts.len() { - let selected_host = self.available_hosts[selected_idx].clone(); - self.switch_to_host(&selected_host); - } + host_widgets.services_widget.get_total_services_count() + }; + let host_widgets = self.get_or_create_host_widgets(&hostname); + host_widgets.services_widget.select_next(total_services); } } _ => {} @@ -424,29 +404,41 @@ impl TuiApp { } } - /// Handle mouse click on tab title area - pub fn handle_tab_click(&mut self, x: u16, area: &Rect) { - // Tab title format: "hosts | services" - // Calculate positions relative to area start - let title_start_x = area.x + 1; // +1 for left border - - // "hosts | services" - // 0123456789... - let hosts_start = title_start_x; - let hosts_end = hosts_start + 5; // "hosts" is 5 chars - let services_start = hosts_end + 3; // After " | " - let services_end = services_start + 8; // "services" is 8 chars - - if x >= hosts_start && x < hosts_end { - // Clicked on "hosts" - self.focus_hosts = true; - } else if x >= services_start && x < services_end { - // Clicked on "services" - self.focus_hosts = false; + /// Cycle to next host (TAB) + fn cycle_next_host(&mut self) { + if self.available_hosts.is_empty() { + return; } + + let current_idx = self.current_host + .as_ref() + .and_then(|h| self.available_hosts.iter().position(|x| x == h)) + .unwrap_or(0); + + let next_idx = (current_idx + 1) % self.available_hosts.len(); + let next_host = self.available_hosts[next_idx].clone(); + self.switch_to_host(&next_host); } + /// Cycle to previous host (Shift+TAB) + fn cycle_previous_host(&mut self) { + if self.available_hosts.is_empty() { + return; + } + let current_idx = self.current_host + .as_ref() + .and_then(|h| self.available_hosts.iter().position(|x| x == h)) + .unwrap_or(0); + + let prev_idx = if current_idx == 0 { + self.available_hosts.len() - 1 + } else { + current_idx - 1 + }; + let prev_host = self.available_hosts[prev_idx].clone(); + self.switch_to_host(&prev_host); + } @@ -461,11 +453,6 @@ impl TuiApp { None } - /// Get the list of available hosts - pub fn get_available_hosts(&self) -> &Vec { - &self.available_hosts - } - /// Should quit application pub fn should_quit(&self) -> bool { self.should_quit @@ -498,11 +485,11 @@ impl TuiApp { ]) .split(size); - // New layout: left panels | right services (100% height) + // New layout: left panels (hosts + system) | right services (100% height) let content_chunks = ratatui::layout::Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Percentage(ThemeLayout::LEFT_PANEL_WIDTH), // Left side: system, backup + Constraint::Percentage(ThemeLayout::LEFT_PANEL_WIDTH), // Left side: hosts, system Constraint::Percentage(ThemeLayout::RIGHT_PANEL_WIDTH), // Right side: services (100% height) ]) .split(main_chunks[1]); // main_chunks[1] is now the content area (between title and statusbar) @@ -514,26 +501,33 @@ impl TuiApp { true // No host selected is considered offline }; - // Left side: system panel only (full height) + // Calculate hosts panel height dynamically based on available width + let hosts_inner_width = content_chunks[0].width.saturating_sub(2); + let hosts_content_height = HostsWidget::required_height(self.available_hosts.len(), hosts_inner_width); + let hosts_height = hosts_content_height + 2; // Add borders + + // Left side: hosts panel on top, system panel below let left_chunks = ratatui::layout::Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Percentage(100)]) // System section takes full height + .constraints([ + Constraint::Length(hosts_height), // Hosts panel (compact, dynamic) + Constraint::Min(0), // System panel (rest) + ]) .split(content_chunks[0]); // Render title bar self.render_btop_title(frame, main_chunks[0], metric_store); - // Render system panel or offline message in system panel area - let system_area = left_chunks[0]; - if current_host_offline { - self.render_offline_host_message(frame, system_area); - } else { - self.render_system_panel(frame, system_area, metric_store); - } + // Render hosts panel on left + self.render_hosts_panel(frame, left_chunks[0], metric_store); - // Render right panel with tabs (Services | Hosts) + // Render system panel below hosts + let system_area = left_chunks[1]; + self.render_system_panel(frame, system_area, metric_store); + + // Render services panel on right let services_area = content_chunks[1]; - self.render_right_panel_with_tabs(frame, services_area, metric_store); + self.render_services_panel(frame, services_area); // Render statusbar at the bottom self.render_statusbar(frame, main_chunks[2], metric_store); @@ -543,6 +537,11 @@ impl TuiApp { self.render_popup_menu(frame, popup); } + // Render offline host popup on top of everything + if current_host_offline { + self.render_offline_popup(frame, size); + } + // Return all areas for mouse event handling (main_chunks[0], system_area, services_area) } @@ -603,14 +602,20 @@ impl TuiApp { frame.render_widget(title, area); } - /// Calculate overall status for a host based on its structured data + /// Calculate overall status for a host based on its widget statuses fn calculate_host_status(&self, hostname: &str, metric_store: &MetricStore) -> Status { - // Check if we have structured data for this host - if let Some(_agent_data) = metric_store.get_agent_data(hostname) { - // Return OK since we have data - Status::Ok + // Check if we have data for this host + if metric_store.get_agent_data(hostname).is_none() { + return Status::Offline; + } + + // Get actual statuses from host widgets + if let Some(host_widgets) = self.host_widgets.get(hostname) { + let system_status = host_widgets.system_widget.get_overall_status(); + let services_status = host_widgets.services_widget.get_overall_status(); + Status::aggregate(&[system_status, services_status]) } else { - Status::Offline + Status::Ok // No widgets yet, but data exists } } @@ -759,78 +764,64 @@ impl TuiApp { } - /// Render right panel with tabs (hosts | services) - fn render_right_panel_with_tabs(&mut self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) { - use ratatui::style::Modifier; - use ratatui::text::{Line, Span}; + /// Render hosts panel + fn render_hosts_panel(&mut self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) { use ratatui::widgets::{Block, Borders}; - // Build tab title with bold styling for active tab (like cm-player) - let hosts_style = if self.focus_hosts { - Style::default().fg(Theme::border_title()).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::border_title()) - }; - - let services_style = if !self.focus_hosts { - Style::default().fg(Theme::border_title()).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::border_title()) - }; - - let title = Line::from(vec![ - Span::styled("hosts", hosts_style), - Span::raw(" | "), - Span::styled("services", services_style), - ]); - - // Create ONE block with tab title (like cm-player) - let main_block = Block::default() + let hosts_block = Block::default() .borders(Borders::ALL) - .title(title.clone()) - .style(Style::default().fg(Theme::border()).bg(Theme::background())); + .title("hosts") + .style(Style::default().fg(Theme::border()).bg(Theme::background())) + .title_style(Style::default().fg(Theme::primary_text())); - let inner_area = main_block.inner(area); - frame.render_widget(main_block, area); + let hosts_inner = hosts_block.inner(area); + frame.render_widget(hosts_block, area); - // Render appropriate content based on active tab - if self.focus_hosts { - // Render hosts list (no additional borders) - let localhost = self.localhost.clone(); - let current_host = self.current_host.as_deref(); - self.hosts_widget.render( - frame, - inner_area, - &self.available_hosts, - &localhost, - current_host, - metric_store, - |hostname, store| { - // Inline calculate_host_status logic - if store.get_agent_data(hostname).is_some() { - Status::Ok - } else { - Status::Offline - } - }, - true, // Always focused when visible - ); - } else { - // Render services for current host (no additional borders - just content!) - if let Some(hostname) = self.current_host.clone() { - let is_focused = true; - let host_widgets = self.get_or_create_host_widgets(&hostname); - host_widgets.services_widget.render_content(frame, inner_area, is_focused); - } + let localhost = self.localhost.clone(); + let current_host = self.current_host.as_deref(); + self.hosts_widget.render( + frame, + hosts_inner, + &self.available_hosts, + &localhost, + current_host, + metric_store, + |hostname, store| { + if store.get_agent_data(hostname).is_some() { + Status::Ok + } else { + Status::Offline + } + }, + false, + ); + } + + /// Render services panel + fn render_services_panel(&mut self, frame: &mut Frame, area: Rect) { + use ratatui::widgets::{Block, Borders}; + + let services_block = Block::default() + .borders(Borders::ALL) + .title("services") + .style(Style::default().fg(Theme::border()).bg(Theme::background())) + .title_style(Style::default().fg(Theme::primary_text())); + + let services_inner = services_block.inner(area); + frame.render_widget(services_block, area); + + if let Some(hostname) = self.current_host.clone() { + let host_widgets = self.get_or_create_host_widgets(&hostname); + host_widgets.services_widget.render_content(frame, services_inner, true); } } - /// Render offline host message in system panel area - fn render_offline_host_message(&self, frame: &mut Frame, area: Rect) { + /// Render offline host popup centered on screen + fn render_offline_popup(&self, frame: &mut Frame, screen: Rect) { use ratatui::style::Modifier; use ratatui::text::{Line, Span}; - use ratatui::widgets::{Block, Borders, Paragraph}; + use ratatui::widgets::{Block, Borders, Clear, Paragraph}; // Get hostname for message let hostname = self.current_host.as_ref() @@ -845,36 +836,49 @@ impl TuiApp { // Create message content let mut lines = vec![ - Line::from(""), Line::from(Span::styled( - format!(" Host '{}' is offline", hostname), - Style::default().fg(Theme::muted_text()).add_modifier(Modifier::BOLD), + format!("Host '{}' is offline", hostname), + Style::default().fg(Theme::status_color(Status::Offline)).add_modifier(Modifier::BOLD), )), Line::from(""), ]; if has_mac { lines.push(Line::from(Span::styled( - " Press 'w' to wake up host", + "Press 'w' to wake up host", Style::default().fg(Theme::primary_text()), ))); } else { lines.push(Line::from(Span::styled( - " No MAC address configured", + "No MAC address configured", Style::default().fg(Theme::muted_text()), ))); } - // Render message in system panel with border + // Calculate popup size and center it + let popup_width = 32u16; + let popup_height = 5u16; + let x = screen.width.saturating_sub(popup_width) / 2; + let y = screen.height.saturating_sub(popup_height) / 2; + + let popup_area = Rect { + x, + y, + width: popup_width, + height: popup_height, + }; + + // Render popup with border let message = Paragraph::new(lines) .block(Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::muted_text())) + .border_style(Style::default().fg(Theme::status_color(Status::Offline))) .title(" Offline ") - .title_style(Style::default().fg(Theme::muted_text()).add_modifier(Modifier::BOLD))) + .title_style(Style::default().fg(Theme::status_color(Status::Offline)).add_modifier(Modifier::BOLD))) .style(Style::default().bg(Theme::background()).fg(Theme::primary_text())); - frame.render_widget(message, area); + frame.render_widget(Clear, popup_area); + frame.render_widget(message, popup_area); } /// Parse MAC address string (e.g., "AA:BB:CC:DD:EE:FF") to [u8; 6] diff --git a/dashboard/src/ui/theme.rs b/dashboard/src/ui/theme.rs index 75028b9..46eda25 100644 --- a/dashboard/src/ui/theme.rs +++ b/dashboard/src/ui/theme.rs @@ -282,19 +282,14 @@ impl StatusIcons { } impl Components { - /// Standard widget block with title using bright foreground for title + /// Standard widget block with title using primary text color for title pub fn widget_block(title: &str) -> Block<'_> { Block::default() .title(title) .borders(Borders::ALL) .style(Style::default().fg(Theme::border()).bg(Theme::background())) - .title_style( - Style::default() - .fg(Theme::border_title()) - .bg(Theme::background()), - ) + .title_style(Style::default().fg(Theme::primary_text())) } - } impl Typography { @@ -307,10 +302,10 @@ impl Typography { .add_modifier(Modifier::BOLD) } - /// Secondary content text + /// Secondary content text (metrics without status) pub fn secondary() -> Style { Style::default() - .fg(Theme::secondary_text()) + .fg(Theme::highlight()) .bg(Theme::background()) } diff --git a/dashboard/src/ui/widgets/hosts.rs b/dashboard/src/ui/widgets/hosts.rs index c67bf20..7c9a6f3 100644 --- a/dashboard/src/ui/widgets/hosts.rs +++ b/dashboard/src/ui/widgets/hosts.rs @@ -2,7 +2,6 @@ use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span}, - widgets::{List, ListItem}, Frame, }; @@ -30,68 +29,24 @@ impl HostsWidget { } } - /// Move selection up - pub fn select_previous(&mut self) { - if self.selected_index > 0 { - self.selected_index -= 1; - self.ensure_selected_visible(); - } - } - - /// Move selection down - pub fn select_next(&mut self, total_hosts: usize) { - if total_hosts > 0 && self.selected_index < total_hosts.saturating_sub(1) { - self.selected_index += 1; - self.ensure_selected_visible(); - } - } - /// Ensure selected item is visible in viewport (auto-scroll) fn ensure_selected_visible(&mut self) { - if self.last_viewport_height == 0 { - return; // Can't calculate without viewport height - } - - let viewport_height = self.last_viewport_height; - - // If selection is above viewport, scroll up to show it - if self.selected_index < self.scroll_offset { - self.scroll_offset = self.selected_index; - } - - // If selection is below viewport, scroll down to show it - if self.selected_index >= self.scroll_offset + viewport_height { - self.scroll_offset = self.selected_index.saturating_sub(viewport_height.saturating_sub(1)); - } - } - - /// Scroll down manually - pub fn scroll_down(&mut self, total_hosts: usize) { if self.last_viewport_height == 0 { return; } let viewport_height = self.last_viewport_height; - let max_scroll = total_hosts.saturating_sub(viewport_height); - if self.scroll_offset < max_scroll { - self.scroll_offset += 1; + if self.selected_index < self.scroll_offset { + self.scroll_offset = self.selected_index; + } + + if self.selected_index >= self.scroll_offset + viewport_height { + self.scroll_offset = self.selected_index.saturating_sub(viewport_height.saturating_sub(1)); } } - /// Scroll up manually - pub fn scroll_up(&mut self) { - if self.scroll_offset > 0 { - self.scroll_offset -= 1; - } - } - - /// Get the currently selected host index - pub fn get_selected_index(&self) -> usize { - self.selected_index - } - - /// Set selected index (used when switching to host via mouse) + /// Set selected index (used when switching hosts via TAB) pub fn set_selected_index(&mut self, index: usize, total_hosts: usize) { if index < total_hosts { self.selected_index = index; @@ -99,12 +54,19 @@ impl HostsWidget { } } - /// Convert y coordinate to host index (accounting for scroll) - pub fn y_to_host_index(&self, relative_y: usize) -> usize { - self.scroll_offset + relative_y + /// Calculate the required height for hosts panel based on host count and available width + pub fn required_height(num_hosts: usize, available_width: u16) -> u16 { + if num_hosts == 0 { + return 1; + } + // Estimate column width: icon(2) + arrow(2) + max_hostname(~12) + padding(2) = ~18 + let col_width = 18u16; + let num_columns = (available_width / col_width).max(1) as usize; + let rows_needed = (num_hosts + num_columns - 1) / num_columns; + rows_needed.max(1) as u16 } - /// Render hosts list with selector bar + /// Render hosts list in dynamic columns based on available width pub fn render( &mut self, frame: &mut Frame, @@ -114,116 +76,98 @@ impl HostsWidget { current_host: Option<&str>, metric_store: &MetricStore, mut calculate_host_status: F, - is_focused: bool, + _is_focused: bool, ) where F: FnMut(&str, &MetricStore) -> Status { - use crate::ui::theme::{StatusIcons, Typography}; - use ratatui::widgets::Paragraph; + use crate::ui::theme::StatusIcons; + use ratatui::layout::{Constraint, Direction, Layout}; - // Split area for header and list - let chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Length(1), // Header - ratatui::layout::Constraint::Min(0), // List - ]) - .split(area); - - // Render header - let header = Paragraph::new("Hosts:").style(Typography::muted()); - frame.render_widget(header, chunks[0]); - - // Store viewport height for scroll calculations (minus header) - self.last_viewport_height = chunks[1].height as usize; - - // Validate scroll offset - if self.scroll_offset >= available_hosts.len() && !available_hosts.is_empty() { - self.scroll_offset = available_hosts.len().saturating_sub(1); + if available_hosts.is_empty() { + return; } - // Create list items for visible hosts - let items: Vec = available_hosts - .iter() - .enumerate() - .skip(self.scroll_offset) - .take(chunks[1].height as usize) - .map(|(idx, hostname)| { - let host_status = calculate_host_status(hostname, metric_store); - let status_icon = StatusIcons::get_icon(host_status); - let status_color = Theme::status_color(host_status); + // Store viewport height for scroll calculations + self.last_viewport_height = area.height as usize; - // Check if this is the selected host (for blue selector bar) - let is_selected = is_focused && idx == self.selected_index; + // Calculate column width and number of columns that fit + let col_width = 18u16; + let num_columns = (area.width / col_width).max(1) as usize; + let rows_per_column = (available_hosts.len() + num_columns - 1) / num_columns; - // Check if this is the current (active) host - let is_current = current_host == Some(hostname.as_str()); - - // Check if this is localhost - let is_localhost = hostname == localhost; - - // Build the line with icon and hostname - let mut spans = vec![Span::styled( - format!("{} ", status_icon), - if is_selected { - Style::default() - .fg(Theme::background()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(status_color) - }, - )]; - - // Add arrow indicator if this is the current host (like cm-player) - if is_current { - spans.push(Span::styled( - "▸ ", - if is_selected { - Style::default() - .fg(Theme::background()) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(Theme::primary_text()) - .add_modifier(Modifier::BOLD) - }, - )); - } - - // Add hostname with appropriate styling - let hostname_text = if is_localhost { - format!("{} (localhost)", hostname) - } else { - hostname.clone() - }; - - spans.push(Span::styled( - hostname_text, - if is_selected { - Style::default() - .fg(Theme::background()) - .add_modifier(Modifier::BOLD) - } else if is_current { - Style::default() - .fg(Theme::primary_text()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::primary_text()) - }, - )); - - let line = Line::from(spans); - - // Apply blue background to selected row - let base_style = if is_selected { - Style::default().bg(Theme::highlight()) // Blue background - } else { - Style::default().bg(Theme::background()) - }; - - ListItem::new(line).style(base_style) - }) + // Create column constraints + let constraints: Vec = (0..num_columns) + .map(|_| Constraint::Ratio(1, num_columns as u32)) .collect(); - let hosts_list = List::new(items); - frame.render_widget(hosts_list, chunks[1]); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints(constraints) + .split(area); + + // Build host line helper + let mut build_host_line = |hostname: &str| -> Line { + let host_status = calculate_host_status(hostname, metric_store); + let status_icon = StatusIcons::get_icon(host_status); + let status_color = Theme::status_color(host_status); + + let is_current = current_host == Some(hostname); + let is_localhost = hostname == localhost; + + let mut spans = vec![Span::styled( + format!("{} ", status_icon), + Style::default().fg(status_color), + )]; + + if is_current { + spans.push(Span::styled( + "► ", + Style::default() + .fg(Theme::primary_text()) + .add_modifier(Modifier::BOLD), + )); + } + + let hostname_display = if is_localhost { + format!("{}*", hostname) + } else { + hostname.to_string() + }; + + spans.push(Span::styled( + hostname_display, + if is_current { + Style::default() + .fg(Theme::primary_text()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Theme::primary_text()) + }, + )); + + Line::from(spans) + }; + + // Render each column + for col_idx in 0..num_columns { + let start = col_idx * rows_per_column; + let hosts_in_col: Vec = available_hosts + .iter() + .skip(start) + .take(rows_per_column) + .map(|hostname| build_host_line(hostname)) + .collect(); + + if !hosts_in_col.is_empty() { + let text = ratatui::text::Text::from(hosts_in_col); + let para = ratatui::widgets::Paragraph::new(text); + frame.render_widget(para, columns[col_idx]); + } + } + + // Update selected index to match current host + if let Some(current) = current_host { + if let Some(idx) = available_hosts.iter().position(|h| h == current) { + self.selected_index = idx; + } + } } } diff --git a/dashboard/src/ui/widgets/services.rs b/dashboard/src/ui/widgets/services.rs index b599451..2b1f388 100644 --- a/dashboard/src/ui/widgets/services.rs +++ b/dashboard/src/ui/widgets/services.rs @@ -8,7 +8,7 @@ use ratatui::{ use std::collections::HashMap; use tracing::debug; -use crate::ui::theme::{Components, StatusIcons, Theme, Typography}; +use crate::ui::theme::{StatusIcons, Theme, Typography}; use ratatui::style::Style; /// Column visibility configuration based on terminal width @@ -120,6 +120,11 @@ impl ServicesWidget { } } + /// Get overall services status + pub fn get_overall_status(&self) -> Status { + self.status + } + /// 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)> { @@ -150,9 +155,10 @@ impl ServicesWidget { /// Format parent service line - returns text without icon for span formatting fn format_parent_service_line(&self, name: &str, info: &ServiceInfo, columns: ColumnVisibility) -> String { + // Account for icon prefix "● " (2 chars) in name column width + let name_width = ColumnVisibility::NAME_WIDTH.saturating_sub(2) as usize; // 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 max_name_len = name_width.saturating_sub(3); // -3 for "..." let short_name = if name.len() > max_name_len { format!("{}...", &name[..max_name_len.saturating_sub(3)]) } else { @@ -208,7 +214,7 @@ impl ServicesWidget { // Build format string based on column visibility let mut parts = Vec::new(); if columns.show_name { - parts.push(format!("{: Option { self.kernel_version.clone() } + + /// Get overall status by aggregating all component statuses + pub fn get_overall_status(&self) -> Status { + if !self.has_data { + return Status::Offline; + } + + let mut statuses = vec![self.cpu_status, self.memory_status, self.backup_status]; + + // Add storage pool and drive statuses + for pool in &self.storage_pools { + statuses.push(pool.status); + for drive in &pool.drives { + statuses.push(drive.status); + } + for drive in &pool.data_drives { + statuses.push(drive.status); + } + for drive in &pool.parity_drives { + statuses.push(drive.status); + } + } + + // Add backup repository statuses + for repo in &self.backup_repositories { + statuses.push(repo.status); + } + + Status::aggregate(&statuses) + } } use super::Widget; diff --git a/shared/Cargo.toml b/shared/Cargo.toml index a1af780..65e1aa7 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.278" +version = "0.1.280" edition = "2021" [dependencies]