From 516d159d2fddbde2878fdab0de9256750881ce5f Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sun, 14 Dec 2025 10:03:33 +0100 Subject: [PATCH] Reorganize dashboard UI with tabbed layout and improved status bars Add tabbed navigation in right panel with "hosts | services" tabs to better utilize vertical space. Hosts tab displays all available hosts with blue selector bar (j/k navigation, Enter to switch). Services tab shows services for currently selected host. Status bar improvements: - Move dashboard IP to top-right status bar (non-bold) - Restructure bottom status bar with right-aligned build/agent versions - Fix overflow crashes using saturating_sub for small terminal windows Additional changes: - Add HostsWidget with scroll handling and mouse click support - Bold styling for currently active host - Create render_content() methods to avoid nested blocks in tabs - Display SMB share read/write mode in share listings --- Cargo.lock | 6 +- agent/src/collectors/systemd.rs | 31 ++- dashboard/Cargo.toml | 2 +- dashboard/src/app.rs | 187 +++++---------- dashboard/src/ui/mod.rs | 333 ++++++++++++++------------- dashboard/src/ui/widgets/hosts.rs | 229 ++++++++++++++++++ dashboard/src/ui/widgets/mod.rs | 2 + dashboard/src/ui/widgets/services.rs | 49 +++- 8 files changed, 545 insertions(+), 294 deletions(-) create mode 100644 dashboard/src/ui/widgets/hosts.rs diff --git a/Cargo.lock b/Cargo.lock index 641ec72..217701b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.274" +version = "0.1.276" dependencies = [ "anyhow", "chrono", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.274" +version = "0.1.275" dependencies = [ "anyhow", "async-trait", @@ -325,7 +325,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.274" +version = "0.1.275" dependencies = [ "chrono", "serde", diff --git a/agent/src/collectors/systemd.rs b/agent/src/collectors/systemd.rs index 9dcdcfc..159f537 100644 --- a/agent/src/collectors/systemd.rs +++ b/agent/src/collectors/systemd.rs @@ -251,12 +251,11 @@ impl SystemdCollector { if (service_name == "smbd" || service_name == "samba-smbd") && status_info.active_state == "active" { // Add SMB shares as sub-services let shares = self.get_smb_shares(); - for (share_name, share_path) in shares { - let metrics = Vec::new(); + for (share_name, share_path, mode) in shares { sub_services.push(SubServiceData { - name: format!("{}: {}", share_name, share_path), + name: format!("{}: {} {}", share_name, share_path, mode), service_status: Status::Info, - metrics, + metrics: Vec::new(), service_type: "smb_share".to_string(), }); } @@ -1133,13 +1132,14 @@ impl SystemdCollector { } /// Get SMB shares from smb.conf - /// Returns a list of (share_name, share_path) tuples - fn get_smb_shares(&self) -> Vec<(String, String)> { + /// Returns a list of (share_name, share_path, mode) tuples + fn get_smb_shares(&self) -> Vec<(String, String, String)> { match std::fs::read_to_string("/etc/samba/smb.conf") { Ok(config) => { let mut shares = Vec::new(); let mut current_share: Option = None; let mut current_path: Option = None; + let mut current_mode: String = "ro".to_string(); // Default to read-only for line in config.lines() { let line = line.trim(); @@ -1155,7 +1155,7 @@ impl SystemdCollector { if let (Some(name), Some(path)) = (current_share.take(), current_path.take()) { // Skip special sections if name != "global" && name != "homes" && name != "printers" { - shares.push((name, path)); + shares.push((name, path, current_mode.clone())); } } @@ -1163,6 +1163,7 @@ impl SystemdCollector { let share_name = line[1..line.len()-1].trim().to_string(); current_share = Some(share_name); current_path = None; + current_mode = "ro".to_string(); // Reset to default } // Look for path = /some/path else if line.starts_with("path") && line.contains('=') { @@ -1170,12 +1171,26 @@ impl SystemdCollector { current_path = Some(path_value.trim().to_string()); } } + // Look for read only = yes/no + else if line.to_lowercase().starts_with("read only") && line.contains('=') { + if let Some(value) = line.split('=').nth(1) { + let val = value.trim().to_lowercase(); + current_mode = if val == "no" || val == "false" { "rw" } else { "ro" }.to_string(); + } + } + // Look for writable = yes/no (opposite of read only) + else if line.to_lowercase().starts_with("writable") && line.contains('=') { + if let Some(value) = line.split('=').nth(1) { + let val = value.trim().to_lowercase(); + current_mode = if val == "yes" || val == "true" { "rw" } else { "ro" }.to_string(); + } + } } // Don't forget the last share if let (Some(name), Some(path)) = (current_share, current_path) { if name != "global" && name != "homes" && name != "printers" { - shares.push((name, path)); + shares.push((name, path, current_mode)); } } diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 694e1ff..9b5e801 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.275" +version = "0.1.276" edition = "2021" [dependencies] diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs index 58fd384..05cc5ec 100644 --- a/dashboard/src/app.rs +++ b/dashboard/src/app.rs @@ -22,7 +22,6 @@ pub struct Dashboard { headless: bool, initial_commands_sent: std::collections::HashSet, config: DashboardConfig, - title_area: Rect, // Store title area for mouse event handling system_area: Rect, // Store system area for mouse event handling services_area: Rect, // Store services area for mouse event handling } @@ -124,7 +123,6 @@ impl Dashboard { headless, initial_commands_sent: std::collections::HashSet::new(), config, - title_area: Rect::default(), system_area: Rect::default(), services_area: Rect::default(), }) @@ -272,22 +270,9 @@ impl Dashboard { warn!("Error autoresizing terminal: {}", e); } - // Check minimum terminal size to prevent panics - let size = terminal.size().unwrap_or_default(); - if size.width < 90 || size.height < 15 { - // Terminal too small, show error message - let msg_text = format!("Terminal too small\n\nMinimum: 90x15\nCurrent: {}x{}", size.width, size.height); - let _ = terminal.draw(|frame| { - use ratatui::widgets::{Paragraph, Block, Borders}; - use ratatui::layout::Alignment; - let msg = Paragraph::new(msg_text.clone()) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - frame.render_widget(msg, frame.size()); - }); - } else if let Err(e) = terminal.draw(|frame| { - let (title_area, system_area, services_area) = tui_app.render(frame, &self.metric_store); - self.title_area = title_area; + // Render TUI regardless of terminal size + if let Err(e) = terminal.draw(|frame| { + let (_title_area, system_area, services_area) = tui_app.render(frame, &self.metric_store); self.system_area = system_area; self.services_area = services_area; }) { @@ -392,19 +377,13 @@ impl Dashboard { return Ok(()); } - // Check for title bar clicks (host selection) + // Check for tab clicks in right panel (hosts | services) if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - if is_in_area(x, y, &self.title_area) { - // Click in title bar - check if it's on a hostname - // The title bar has "cm-dashboard vX.X.X" on the left (22 chars) - // Then hostnames start at position 22 - if x >= 22 { - let hostname = self.find_hostname_at_position(x); - if let Some(host) = hostname { - if let Some(ref mut tui_app) = self.tui_app { - tui_app.switch_to_host(&host); - } - } + 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(()); } @@ -468,44 +447,66 @@ impl Dashboard { return Ok(()); } - // Calculate which service was clicked - // 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(ref mut tui_app) = self.tui_app { - if let Some(hostname) = tui_app.current_host.clone() { - let host_widgets = tui_app.get_or_create_host_widgets(&hostname); + 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 - // 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; + let total_hosts = tui_app.get_available_hosts().len(); + let clicked_index = tui_app.hosts_widget.y_to_host_index(relative_y); + if clicked_index < total_hosts { 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, - }); - } + // 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); } _ => {} } } + } 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, + }); + } + } + _ => {} + } + } + } } } } @@ -600,76 +601,12 @@ impl Dashboard { .unwrap_or_else(|| hostname.to_string()) } - /// Find which hostname is at a given x position in the title bar - fn find_hostname_at_position(&self, x: u16) -> Option { - if let Some(ref tui_app) = self.tui_app { - // The hosts are RIGHT-ALIGNED in chunks[1]! - // Need to calculate total width first, then right-align - - // Get terminal width - let terminal_width = if let Some(ref terminal) = self.terminal { - terminal.size().unwrap_or_default().width - } else { - 80 - }; - - // Calculate total width of all host text - let mut total_width = 0_u16; - for (i, host) in tui_app.get_available_hosts().iter().enumerate() { - if i > 0 { - total_width += 1; // space between hosts - } - total_width += 2; // icon + space - let is_selected = Some(host) == tui_app.current_host.as_ref(); - if is_selected { - total_width += 1 + host.len() as u16 + 1; // [hostname] - } else { - total_width += host.len() as u16; - } - } - total_width += 1; // right padding - - // chunks[1] starts at 22, has width of (terminal_width - 22) - let chunk_width = terminal_width - 22; - - // Right-aligned position - let hosts_start_x = if total_width < chunk_width { - 22 + (chunk_width - total_width) - } else { - 22 - }; - - // Now calculate positions starting from hosts_start_x - let mut pos = hosts_start_x; - - for (i, host) in tui_app.get_available_hosts().iter().enumerate() { - if i > 0 { - pos += 1; // " " - } - - let host_start = pos; - pos += 2; // "● " - - let is_selected = Some(host) == tui_app.current_host.as_ref(); - if is_selected { - pos += 1 + host.len() as u16 + 1; // [hostname] - } else { - pos += host.len() as u16; - } - - if x >= host_start && x < pos { - return Some(host.clone()); - } - } - } - None - } } /// Check if a point is within a rectangular area fn is_in_area(x: u16, y: u16, area: &Rect) -> bool { - x >= area.x && x < area.x + area.width - && y >= area.y && y < area.y + area.height + x >= area.x && x < area.x.saturating_add(area.width) + && y >= area.y && y < area.y.saturating_add(area.height) } impl Drop for Dashboard { diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index 849cfa3..4d9602a 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -18,7 +18,7 @@ use crate::config::DashboardConfig; use crate::metrics::MetricStore; use cm_dashboard_shared::Status; use theme::{Components, Layout as ThemeLayout, Theme}; -use widgets::{ServicesWidget, SystemWidget, Widget}; +use widgets::{HostsWidget, ServicesWidget, SystemWidget, Widget}; @@ -64,8 +64,6 @@ pub struct TuiApp { pub current_host: Option, /// Available hosts available_hosts: Vec, - /// Host index for navigation - host_index: usize, /// Should quit application should_quit: bool, /// Track if user manually navigated away from localhost @@ -76,6 +74,10 @@ 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, } impl TuiApp { @@ -85,12 +87,13 @@ impl TuiApp { host_widgets: HashMap::new(), current_host: None, available_hosts: config.hosts.keys().cloned().collect(), - host_index: 0, should_quit: false, user_navigated_away: false, config, localhost, popup_menu: None, + focus_hosts: true, // Start with Hosts tab focused by default + hosts_widget: HostsWidget::new(), }; // Sort predefined hosts @@ -142,27 +145,32 @@ impl TuiApp { all_hosts.sort(); self.available_hosts = all_hosts; - + + // Track if we had a host before this update + let had_host = self.current_host.is_some(); + // Get the current hostname (localhost) for auto-selection if !self.available_hosts.is_empty() { if self.available_hosts.contains(&self.localhost) && !self.user_navigated_away { // Localhost is available and user hasn't navigated away - switch to it self.current_host = Some(self.localhost.clone()); - // Find the actual index of localhost in the sorted list - self.host_index = self.available_hosts.iter().position(|h| h == &self.localhost).unwrap_or(0); + // Initialize selector bar on first host selection + if !had_host { + let index = self.available_hosts.iter().position(|h| h == &self.localhost).unwrap_or(0); + self.hosts_widget.set_selected_index(index, self.available_hosts.len()); + } } else if self.current_host.is_none() { // No current host - select first available (which is localhost if available) self.current_host = Some(self.available_hosts[0].clone()); - self.host_index = 0; + // Initialize selector bar + self.hosts_widget.set_selected_index(0, self.available_hosts.len()); } else if let Some(ref current) = self.current_host { if !self.available_hosts.contains(current) { - // Current host disconnected - select first available and reset navigation flag + // Current host disconnected - FORCE switch to first available self.current_host = Some(self.available_hosts[0].clone()); - self.host_index = 0; + // Reset selector bar since we're forcing a host change + self.hosts_widget.set_selected_index(0, self.available_hosts.len()); self.user_navigated_away = false; // Reset since we're forced to switch - } else if let Some(index) = self.available_hosts.iter().position(|h| h == current) { - // Update index for current host - self.host_index = index; } } } @@ -183,12 +191,6 @@ impl TuiApp { KeyCode::Char('q') => { self.should_quit = true; } - KeyCode::Left => { - self.navigate_host(-1); - } - KeyCode::Right => { - self.navigate_host(1); - } KeyCode::Char('r') => { // System rebuild command - works on any panel for current host if let Some(hostname) = self.current_host.clone() { @@ -356,25 +358,46 @@ impl TuiApp { } } KeyCode::Tab => { - // Tab cycles to next host - self.navigate_host(1); + // Tab toggles between Services and Hosts tabs + self.focus_hosts = !self.focus_hosts; } KeyCode::Up | KeyCode::Char('k') => { - // 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(); + 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(); + } } } KeyCode::Down | KeyCode::Char('j') => { - // Move service selection down - if let Some(hostname) = self.current_host.clone() { - let total_services = { + 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() + }; let host_widgets = self.get_or_create_host_widgets(&hostname); - 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); + 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); + } } } _ => {} @@ -386,7 +409,8 @@ impl TuiApp { /// Switch to a specific host by name pub fn switch_to_host(&mut self, hostname: &str) { if let Some(index) = self.available_hosts.iter().position(|h| h == hostname) { - self.host_index = index; + // Update selector bar position + self.hosts_widget.set_selected_index(index, self.available_hosts.len()); self.current_host = Some(hostname.to_string()); // Check if user navigated away from localhost @@ -400,41 +424,33 @@ impl TuiApp { } } - /// Navigate between hosts - fn navigate_host(&mut self, direction: i32) { - if self.available_hosts.is_empty() { - return; + /// 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; } - - let len = self.available_hosts.len(); - if direction > 0 { - self.host_index = (self.host_index + 1) % len; - } else { - self.host_index = if self.host_index == 0 { - len - 1 - } else { - self.host_index - 1 - }; - } - - self.current_host = Some(self.available_hosts[self.host_index].clone()); - - // Check if user navigated away from localhost - if let Some(ref current) = self.current_host { - if current != &self.localhost { - self.user_navigated_away = true; - } else { - self.user_navigated_away = false; // User navigated back to localhost - } - } - - info!("Switched to host: {}", self.current_host.as_ref().unwrap()); } + /// Get the currently selected service name from the services widget fn get_selected_service(&self) -> Option { if let Some(hostname) = &self.current_host { @@ -519,15 +535,9 @@ impl TuiApp { let system_area = left_chunks[0]; self.render_system_panel(frame, system_area, metric_store); - // Render services widget for current host + // Render right panel with tabs (Services | Hosts) let services_area = content_chunks[1]; - if let Some(hostname) = self.current_host.clone() { - let is_focused = true; // Always show service selection - let host_widgets = self.get_or_create_host_widgets(&hostname); - host_widgets - .services_widget - .render(frame, services_area, is_focused); // Services takes full right side - } + self.render_right_panel_with_tabs(frame, services_area, metric_store); // Render statusbar at the bottom self.render_statusbar(frame, main_chunks[2], metric_store); @@ -545,7 +555,6 @@ impl TuiApp { fn render_btop_title(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) { use ratatui::style::Modifier; use ratatui::text::{Line, Span}; - use theme::StatusIcons; if self.available_hosts.is_empty() { let title_text = "cm-dashboard • no hosts discovered"; @@ -568,86 +577,34 @@ impl TuiApp { // Use the worst status color as background let background_color = Theme::status_color(worst_status); - // Split the title bar into left and right sections - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(22), Constraint::Min(0)]) - .split(area); + // Single line title bar showing dashboard name (left) and dashboard IP (right) + let left_text = format!(" cm-dashboard v{}", env!("CARGO_PKG_VERSION")); - // Left side: "cm-dashboard" text with version - let title_text = format!(" cm-dashboard v{}", env!("CARGO_PKG_VERSION")); - let left_span = Span::styled( - &title_text, - Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD) - ); - let left_title = Paragraph::new(Line::from(vec![left_span])) - .style(Style::default().bg(background_color)); - frame.render_widget(left_title, chunks[0]); + // Get dashboard local IP for right side + let dashboard_ip = Self::get_local_ip(); + let right_text = format!("{} ", dashboard_ip); - // Right side: hosts with status indicators - let mut host_spans = Vec::new(); - - for (i, host) in self.available_hosts.iter().enumerate() { - if i > 0 { - host_spans.push(Span::styled( - " ", - Style::default().fg(Theme::background()).bg(background_color) - )); - } + // Calculate spacing to push right text to the right + let total_text_len = left_text.len() + right_text.len(); + let spacing = (area.width as usize).saturating_sub(total_text_len).max(1); + let spacing_str = " ".repeat(spacing); - // Always show normal status icon based on metrics (no command status at host level) - let host_status = self.calculate_host_status(host, metric_store); - let status_icon = StatusIcons::get_icon(host_status); - - // Add status icon with background color as foreground against status background - host_spans.push(Span::styled( - format!("{} ", status_icon), - Style::default().fg(Theme::background()).bg(background_color), - )); - - if Some(host) == self.current_host.as_ref() { - // Selected host with brackets in bold background color against status background - host_spans.push(Span::styled( - "[", - Style::default() - .fg(Theme::background()) - .bg(background_color) - .add_modifier(Modifier::BOLD), - )); - host_spans.push(Span::styled( - host.clone(), - Style::default() - .fg(Theme::background()) - .bg(background_color) - .add_modifier(Modifier::BOLD), - )); - host_spans.push(Span::styled( - "]", - Style::default() - .fg(Theme::background()) - .bg(background_color) - .add_modifier(Modifier::BOLD), - )); - } else { - // Other hosts in normal background color against status background - host_spans.push(Span::styled( - host.clone(), - Style::default().fg(Theme::background()).bg(background_color), - )); - } - } - - // Add right padding - host_spans.push(Span::styled( - " ", - Style::default().fg(Theme::background()).bg(background_color) - )); - - let host_line = Line::from(host_spans); - let host_title = Paragraph::new(vec![host_line]) - .style(Style::default().bg(background_color)) - .alignment(ratatui::layout::Alignment::Right); - frame.render_widget(host_title, chunks[1]); + let title = Paragraph::new(Line::from(vec![ + Span::styled( + left_text, + Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD) + ), + Span::styled( + spacing_str, + Style::default().bg(background_color) + ), + Span::styled( + right_text, + Style::default().fg(Theme::background()).bg(background_color) + ), + ])) + .style(Style::default().bg(background_color)); + frame.render_widget(title, area); } /// Calculate overall status for a host based on its structured data @@ -757,18 +714,15 @@ impl TuiApp { ("None".to_string(), "N/A".to_string(), "N/A".to_string(), "N/A".to_string()) }; - let left_text = format!("Host: {} | {} | Build:{} | Agent:{}", hostname_str, host_ip, build_version, agent_version); + let left_text = format!(" Host: {} | {}", hostname_str, host_ip); + let right_text = format!("Build:{} | Agent:{} ", build_version, agent_version); - // Get dashboard local IP - let dashboard_ip = Self::get_local_ip(); - let right_text = format!("Dashboard: {}", dashboard_ip); - - // Calculate spacing to push right text to the right (accounting for 1 char left padding) - let spacing = area.width as usize - left_text.len() - right_text.len() - 2; // -2 for left padding - let spacing_str = " ".repeat(spacing.max(1)); + // Calculate spacing to push right text to the right + let total_text_len = left_text.len() + right_text.len(); + let spacing = (area.width as usize).saturating_sub(total_text_len).max(1); + let spacing_str = " ".repeat(spacing); let line = Line::from(vec![ - Span::raw(" "), // 1 char left padding Span::styled(left_text, Style::default().fg(Theme::border())), Span::raw(spacing_str), Span::styled(right_text, Style::default().fg(Theme::border())), @@ -808,6 +762,73 @@ 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}; + 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() + .borders(Borders::ALL) + .title(title.clone()) + .style(Style::default().fg(Theme::border()).bg(Theme::background())); + + let inner_area = main_block.inner(area); + frame.render_widget(main_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); + } + } + } + + /// Render offline host message with wake-up option fn render_offline_host_message(&self, frame: &mut Frame, area: Rect) { use ratatui::layout::Alignment; diff --git a/dashboard/src/ui/widgets/hosts.rs b/dashboard/src/ui/widgets/hosts.rs new file mode 100644 index 0000000..c67bf20 --- /dev/null +++ b/dashboard/src/ui/widgets/hosts.rs @@ -0,0 +1,229 @@ +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{List, ListItem}, + Frame, +}; + +use crate::metrics::MetricStore; +use crate::ui::theme::Theme; +use cm_dashboard_shared::Status; + +/// Hosts widget displaying all available hosts with selector bar navigation +#[derive(Clone)] +pub struct HostsWidget { + /// Currently selected host index (for blue selector bar) + pub selected_index: usize, + /// Scroll offset for viewport + pub scroll_offset: usize, + /// Last rendered viewport height for scroll calculations + last_viewport_height: usize, +} + +impl HostsWidget { + pub fn new() -> Self { + Self { + selected_index: 0, + scroll_offset: 0, + last_viewport_height: 0, + } + } + + /// 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; + } + } + + /// 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) + pub fn set_selected_index(&mut self, index: usize, total_hosts: usize) { + if index < total_hosts { + self.selected_index = index; + self.ensure_selected_visible(); + } + } + + /// 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 + } + + /// Render hosts list with selector bar + pub fn render( + &mut self, + frame: &mut Frame, + area: Rect, + available_hosts: &[String], + localhost: &str, + current_host: Option<&str>, + metric_store: &MetricStore, + mut calculate_host_status: F, + is_focused: bool, + ) where F: FnMut(&str, &MetricStore) -> Status { + use crate::ui::theme::{StatusIcons, Typography}; + use ratatui::widgets::Paragraph; + + // 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); + } + + // 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); + + // Check if this is the selected host (for blue selector bar) + let is_selected = is_focused && idx == self.selected_index; + + // 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) + }) + .collect(); + + let hosts_list = List::new(items); + frame.render_widget(hosts_list, chunks[1]); + } +} diff --git a/dashboard/src/ui/widgets/mod.rs b/dashboard/src/ui/widgets/mod.rs index 6d97c15..c7d4b42 100644 --- a/dashboard/src/ui/widgets/mod.rs +++ b/dashboard/src/ui/widgets/mod.rs @@ -1,8 +1,10 @@ use cm_dashboard_shared::AgentData; +pub mod hosts; pub mod services; pub mod system; +pub use hosts::HostsWidget; pub use services::ServicesWidget; pub use system::SystemWidget; diff --git a/dashboard/src/ui/widgets/services.rs b/dashboard/src/ui/widgets/services.rs index 9bcfc95..b599451 100644 --- a/dashboard/src/ui/widgets/services.rs +++ b/dashboard/src/ui/widgets/services.rs @@ -713,7 +713,11 @@ 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"); + self.render_with_title(frame, area, is_focused, "services"); + } + + pub fn render_with_title(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, title: &str) { + let services_block = Components::widget_block(title); let inner_area = services_block.inner(area); frame.render_widget(services_block, area); @@ -758,6 +762,49 @@ impl ServicesWidget { self.render_services(frame, content_chunks[1], is_focused, columns); } + /// Render services content WITHOUT block (for tab mode like cm-player) + pub fn render_content(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) { + let content_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(area); + + // Determine which columns to show based on available width + let columns = ColumnVisibility::from_width(area.width); + + // Build header based on visible columns + let mut header_parts = Vec::new(); + if columns.show_name { + header_parts.push(format!("{: