Compare commits

..

5 Commits

Author SHA1 Message Date
516d159d2f 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
2025-12-14 10:03:33 +01:00
1656f20e96 Fix NFS export parsing to handle both inline and continuation formats
All checks were successful
Build and Release / build-and-release (push) Successful in 1m20s
Support exportfs output where network info appears on same line as path
(e.g. /srv/media/tv 192.168.0.0/16(...)) in addition to continuation
line format. Ensures all NFS exports are detected correctly.
2025-12-11 11:10:59 +01:00
dcd350ec2c Add NFS export permissions and network display, fix SMB service detection
All checks were successful
Build and Release / build-and-release (push) Successful in 1m13s
Display NFS exports with ro/rw permissions and network ranges for better
visibility into share configuration. Support both smbd and samba-smbd
service names for SMB share detection across different distributions.
2025-12-11 10:59:00 +01:00
a34b095857 Simplify NFS export options display
All checks were successful
Build and Release / build-and-release (push) Successful in 1m14s
Filter NFS export options to show only key settings (rw/ro, sync/async)
instead of verbose option strings. Improves readability while maintaining
essential information about export configuration.
2025-12-11 10:26:27 +01:00
7362464b46 Deduplicate NFS exports and remove client info
All checks were successful
Build and Release / build-and-release (push) Successful in 1m48s
Fix NFS export display to show each export path only once instead of
once per client. Use HashMap to deduplicate by path and sort results
alphabetically. Remove IP addresses and client specifications from
display, showing only export paths with their options.

Prevents duplicate entries when a single export is shared with multiple
clients or networks.
2025-12-11 10:06:59 +01:00
10 changed files with 626 additions and 338 deletions

6
Cargo.lock generated
View File

@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "cm-dashboard"
version = "0.1.270"
version = "0.1.276"
dependencies = [
"anyhow",
"chrono",
@@ -301,7 +301,7 @@ dependencies = [
[[package]]
name = "cm-dashboard-agent"
version = "0.1.270"
version = "0.1.275"
dependencies = [
"anyhow",
"async-trait",
@@ -325,7 +325,7 @@ dependencies = [
[[package]]
name = "cm-dashboard-shared"
version = "0.1.270"
version = "0.1.275"
dependencies = [
"chrono",
"serde",

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-dashboard-agent"
version = "0.1.271"
version = "0.1.275"
edition = "2021"
[dependencies]

View File

@@ -233,31 +233,29 @@ impl SystemdCollector {
if service_name == "nfs-server" && status_info.active_state == "active" {
// Add NFS exports as sub-services
let exports = self.get_nfs_exports();
for (export_path, options) in exports {
let metrics = Vec::new();
let display = if !options.is_empty() {
format!("{} ({})", export_path, options)
for (export_path, info) in exports {
let display = if !info.is_empty() {
format!("{} {}", export_path, info)
} else {
export_path
};
sub_services.push(SubServiceData {
name: display,
service_status: Status::Info,
metrics,
metrics: Vec::new(),
service_type: "nfs_export".to_string(),
});
}
}
if service_name == "smbd" && status_info.active_state == "active" {
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(),
});
}
@@ -1045,64 +1043,103 @@ impl SystemdCollector {
}
/// Get NFS exports from exportfs
/// Returns a list of (export_path, options) tuples
/// Returns a list of (export_path, info_string) tuples
fn get_nfs_exports(&self) -> Vec<(String, String)> {
match Command::new("timeout")
let output = match Command::new("timeout")
.args(["2", "exportfs", "-v"])
.output()
{
Ok(output) if output.status.success() => {
let exports_output = String::from_utf8_lossy(&output.stdout);
let mut exports = Vec::new();
Ok(output) if output.status.success() => output,
_ => return Vec::new(),
};
for line in exports_output.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let exports_output = String::from_utf8_lossy(&output.stdout);
let mut exports_map: std::collections::HashMap<String, Vec<(String, String)>> =
std::collections::HashMap::new();
let mut current_path: Option<String> = None;
// Format: "/path/to/export hostname(options)"
// We want just the path and a summary of options
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
continue;
}
for line in exports_output.lines() {
let trimmed = line.trim();
let export_path = parts[0].to_string();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Extract options from parentheses (simplified)
let options = if parts.len() > 1 {
let opts_str = parts[1..].join(" ");
if let Some(start) = opts_str.find('(') {
if let Some(end) = opts_str.find(')') {
opts_str[start+1..end].to_string()
if trimmed.starts_with('/') {
// Export path line - may have network on same line or continuation
let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect();
let path = parts[0].to_string();
current_path = Some(path.clone());
// Check if network info is on the same line
if parts.len() > 1 {
let rest = parts[1].trim();
if let Some(paren_pos) = rest.find('(') {
let network = rest[..paren_pos].trim();
if let Some(end_paren) = rest.find(')') {
let options = &rest[paren_pos+1..end_paren];
let mode = if options.contains(",rw,") || options.ends_with(",rw") {
"rw"
} else {
String::new()
}
} else {
String::new()
}
} else {
String::new()
};
"ro"
};
exports.push((export_path, options));
exports_map.entry(path)
.or_insert_with(Vec::new)
.push((network.to_string(), mode.to_string()));
}
}
}
} else if let Some(ref path) = current_path {
// Continuation line with network and options
if let Some(paren_pos) = trimmed.find('(') {
let network = trimmed[..paren_pos].trim();
if let Some(end_paren) = trimmed.find(')') {
let options = &trimmed[paren_pos+1..end_paren];
let mode = if options.contains(",rw,") || options.ends_with(",rw") {
"rw"
} else {
"ro"
};
exports_map.entry(path.clone())
.or_insert_with(Vec::new)
.push((network.to_string(), mode.to_string()));
}
}
}
}
// Build display strings: "path: mode [networks]"
let mut exports: Vec<(String, String)> = exports_map
.into_iter()
.map(|(path, mut entries)| {
if entries.is_empty() {
return (path, String::new());
}
exports
}
_ => Vec::new(),
}
let mode = entries[0].1.clone();
let networks: Vec<String> = entries.drain(..).map(|(n, _)| n).collect();
let info = format!("{} [{}]", mode, networks.join(", "));
(path, info)
})
.collect();
exports.sort_by(|a, b| a.0.cmp(&b.0));
exports
}
/// 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<String> = None;
let mut current_path: Option<String> = None;
let mut current_mode: String = "ro".to_string(); // Default to read-only
for line in config.lines() {
let line = line.trim();
@@ -1118,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()));
}
}
@@ -1126,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('=') {
@@ -1133,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));
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-dashboard"
version = "0.1.271"
version = "0.1.276"
edition = "2021"
[dependencies]

View File

@@ -22,7 +22,6 @@ pub struct Dashboard {
headless: bool,
initial_commands_sent: std::collections::HashSet<String>,
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<String> {
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 {

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
@@ -143,26 +146,31 @@ 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();
// 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);
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)
));
}
// 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;

View File

@@ -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<F>(
&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<ListItem> = 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]);
}
}

View File

@@ -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;

View File

@@ -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!("{:<width$}", "Service:", width = ColumnVisibility::NAME_WIDTH as usize));
}
if columns.show_status {
header_parts.push(format!("{:<width$}", "Status:", width = ColumnVisibility::STATUS_WIDTH as usize));
}
if columns.show_ram {
header_parts.push(format!("{:<width$}", "RAM:", width = ColumnVisibility::RAM_WIDTH as usize));
}
if columns.show_uptime {
header_parts.push(format!("{:<width$}", "Uptime:", width = ColumnVisibility::UPTIME_WIDTH as usize));
}
if columns.show_restarts {
header_parts.push(format!("{:<width$}", "↻:", width = ColumnVisibility::RESTARTS_WIDTH as usize));
}
let header = header_parts.join(" ");
let header_para = Paragraph::new(header).style(Typography::muted());
frame.render_widget(header_para, content_chunks[0]);
// Check if we have any services to display
if self.parent_services.is_empty() && self.sub_services.is_empty() {
let empty_text = Paragraph::new("No process data").style(Typography::muted());
frame.render_widget(empty_text, content_chunks[1]);
return;
}
// Render the services list
self.render_services(frame, content_chunks[1], is_focused, columns);
}
/// Render services list
fn render_services(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, columns: ColumnVisibility) {
// Build hierarchical service list for display

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-dashboard-shared"
version = "0.1.271"
version = "0.1.275"
edition = "2021"
[dependencies]