Reorganize dashboard UI with tabbed layout and improved status bars
All checks were successful
Build and Release / build-and-release (push) Successful in 1m36s
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user