Reorganize dashboard UI with tabbed layout and improved status bars
All checks were successful
Build and Release / build-and-release (push) Successful in 1m36s

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
This commit is contained in:
2025-12-14 10:03:33 +01:00
parent 1656f20e96
commit 516d159d2f
8 changed files with 545 additions and 294 deletions

View File

@@ -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<String>,
/// Available hosts
available_hosts: Vec<String>,
/// 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<PopupMenu>,
/// 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<String> {
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;