Compare commits

..

9 Commits

Author SHA1 Message Date
5b1e39cfca Show all connected Tailscale peers with connection methods
All checks were successful
Build and Release / build-and-release (push) Successful in 1m38s
Replace single connection method display with individual sub-service
rows for each online Tailscale peer. Each peer shows hostname and
connection type (direct, relay, or idle) allowing monitoring of all
connected devices and their connection quality.

Query tailscale status --json to enumerate all online peers and display
each as a separate sub-service under tailscaled.
2025-12-09 08:35:15 +01:00
ffecbc3166 Fix service widget auto-scroll and remove dead code
All checks were successful
Build and Release / build-and-release (push) Successful in 1m12s
Fix service selection scrolling to prevent selector bar from being
hidden by "... X more below" message. When scrolling down, position
selected service one line above the bottom if there's content below,
ensuring the selector remains visible above the overflow message.

Remove unused get_zmq_stats method and service_type field to eliminate
compilation warnings and dead code.
2025-12-08 23:10:57 +01:00
49f9504429 Add Tailscale connection method monitoring
All checks were successful
Build and Release / build-and-release (push) Successful in 1m25s
Add connection_method field to NetworkInterfaceData to track whether
Tailscale is using direct P2P, DERP relay, or HTTP proxy connections.
The connection method is displayed as a sub-service under tailscaled
service, following the same pattern as VPN routes and firewall ports.

Query tailscale status --json to determine active connection type and
display as informational sub-service when tailscaled is active.
2025-12-08 21:01:47 +01:00
bc9015e96b Add mouse support and improve terminal resize handling
All checks were successful
Build and Release / build-and-release (push) Successful in 1m21s
- Add mouse click support for hostname selection in title bar
- Fix right-aligned hostname position calculation
- Add mouse scroll support for both panels
- Add mouse click to select service rows
- Add right-click popup menu for service actions (Start/Stop/Logs)
- Add hover highlighting for popup menu items
- Improve terminal resize crash protection with 90x15 minimum size
- Add "Host:" prefix and separators to status bar
- Move NixOS metrics from system panel to status bar
- Change "... X more below" indicator to use border color
- Remove service name from popup menu title
2025-12-08 19:56:06 +01:00
aaec8e691c Bump version to 0.1.259
All checks were successful
Build and Release / build-and-release (push) Successful in 1m28s
2025-12-07 14:52:12 +01:00
4a8cfbbde4 Support multiple concurrent torrent copy operations
Update monitoring to handle multiple simultaneous torrent copy operations
using the new directory-based marker structure.

Changes:
- Rename get_active_torrent_copy() to get_active_torrent_copies()
- Read all marker files from /tmp/torrent-copy/ directory
- Return Vec<String> instead of Option<String> for multiple copies
- Display each active copy as separate sub-service
- Unsanitize filenames by replacing _ with /

This enables monitoring when multiple torrents finish simultaneously
and are being copied in parallel to permanent storage.
2025-12-07 14:47:49 +01:00
d93260529b Add torrent copy operation monitoring
All checks were successful
Build and Release / build-and-release (push) Successful in 1m46s
Add real-time monitoring of torrent copy operations when completed
downloads are copied from SSD to HDD storage.

Changes:
- Add marker file tracking during rsync operations
- Monitor active copy operations via /tmp/torrent-copy-active
- Display copy status as sub-service under openvpn-vpn-download
- Show currently copying torrent name in dashboard

The copy status appears as an informational sub-service while rsync
is actively copying completed torrents to permanent storage, providing
visibility into potentially long-running file transfer operations.
2025-12-07 13:59:28 +01:00
41e1be451e Display selected host with brackets in title bar
All checks were successful
Build and Release / build-and-release (push) Successful in 1m25s
- Change nftables port labels to lowercase 'wan tcp:' and 'wan udp:'
- Add brackets around selected host in title bar for clarity
2025-12-04 18:47:30 +01:00
2863526ec8 Remove timeout wrapper from nft command
All checks were successful
Build and Release / build-and-release (push) Successful in 1m21s
Run sudo nft directly without timeout wrapper to preserve capabilities.
The timeout -> sudo chain was preventing nft from accessing netlink
with proper permissions.

- Change from 'timeout 3 sudo nft' to 'sudo nft'
- Allows CAP_NET_ADMIN to pass through correctly
- Update version to v0.1.256
2025-12-04 16:10:25 +01:00
12 changed files with 1128 additions and 134 deletions

6
Cargo.lock generated
View File

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

View File

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

View File

@ -181,6 +181,7 @@ impl NetworkCollector {
link_status, link_status,
parent_interface, parent_interface,
vlan_id, vlan_id,
connection_method: None,
}); });
} }
} }

View File

@ -178,6 +178,18 @@ impl SystemdCollector {
service_type: "torrent_stats".to_string(), service_type: "torrent_stats".to_string(),
}); });
} }
// Add active torrent copy status for each copy operation
for torrent_name in self.get_active_torrent_copies() {
let metrics = Vec::new();
sub_services.push(SubServiceData {
name: format!("Copy: {}", torrent_name),
service_status: Status::Info,
metrics,
service_type: "torrent_copy".to_string(),
});
}
} }
if service_name == "nftables" && status_info.active_state == "active" { if service_name == "nftables" && status_info.active_state == "active" {
@ -186,7 +198,7 @@ impl SystemdCollector {
if !tcp_ports.is_empty() { if !tcp_ports.is_empty() {
let metrics = Vec::new(); let metrics = Vec::new();
sub_services.push(SubServiceData { sub_services.push(SubServiceData {
name: format!("TCP: {}", tcp_ports), name: format!("wan tcp: {}", tcp_ports),
service_status: Status::Info, service_status: Status::Info,
metrics, metrics,
service_type: "firewall_port".to_string(), service_type: "firewall_port".to_string(),
@ -196,7 +208,7 @@ impl SystemdCollector {
if !udp_ports.is_empty() { if !udp_ports.is_empty() {
let metrics = Vec::new(); let metrics = Vec::new();
sub_services.push(SubServiceData { sub_services.push(SubServiceData {
name: format!("UDP: {}", udp_ports), name: format!("wan udp: {}", udp_ports),
service_status: Status::Info, service_status: Status::Info,
metrics, metrics,
service_type: "firewall_port".to_string(), service_type: "firewall_port".to_string(),
@ -204,6 +216,20 @@ impl SystemdCollector {
} }
} }
if service_name == "tailscaled" && status_info.active_state == "active" {
// Add Tailscale peers with their connection methods as sub-services
let peers = self.get_tailscale_peers();
for (peer_name, conn_method) in peers {
let metrics = Vec::new();
sub_services.push(SubServiceData {
name: format!("{}: {}", peer_name, conn_method),
service_status: Status::Info,
metrics,
service_type: "tailscale_peer".to_string(),
});
}
}
// Create complete service data // Create complete service data
let service_data = ServiceData { let service_data = ServiceData {
name: service_name.clone(), name: service_name.clone(),
@ -911,11 +937,71 @@ impl SystemdCollector {
None None
} }
/// Get Tailscale connected peers with their connection methods
/// Returns a list of (device_name, connection_method) tuples
fn get_tailscale_peers(&self) -> Vec<(String, String)> {
match Command::new("timeout")
.args(["2", "tailscale", "status", "--json"])
.output()
{
Ok(output) if output.status.success() => {
let json_str = String::from_utf8_lossy(&output.stdout);
let mut peers = Vec::new();
if let Ok(json_data) = serde_json::from_str::<serde_json::Value>(&json_str) {
// Look for the self peer (current node) in the peer list
if let Some(peer_map) = json_data["Peer"].as_object() {
// Iterate through all peers
for (_peer_id, peer_data) in peer_map {
// Only include active/online peers
if !peer_data["Online"].as_bool().unwrap_or(false) {
continue;
}
// Get peer hostname or DNS name
let peer_name = peer_data["HostName"]
.as_str()
.or_else(|| peer_data["DNSName"].as_str())
.unwrap_or("unknown")
.trim_end_matches('.')
.to_string();
// Determine connection method
let connection_method = if peer_data["Active"].as_bool().unwrap_or(false) {
// Check if using relay
let relay_node = peer_data["Relay"].as_str().unwrap_or("");
if !relay_node.is_empty() {
"relay"
} else if let Some(cur_addr) = peer_data["CurAddr"].as_str() {
// Check if using direct connection
if !cur_addr.is_empty() {
"direct"
} else {
"unknown"
}
} else {
"unknown"
}
} else {
"idle"
};
peers.push((peer_name, connection_method.to_string()));
}
}
}
peers
}
_ => Vec::new(),
}
}
/// Get nftables open ports grouped by protocol /// Get nftables open ports grouped by protocol
/// Returns: (tcp_ports_string, udp_ports_string) /// Returns: (tcp_ports_string, udp_ports_string)
fn get_nftables_open_ports(&self) -> (String, String) { fn get_nftables_open_ports(&self) -> (String, String) {
let output = Command::new("timeout") let output = Command::new("sudo")
.args(&["3", "sudo", "/run/current-system/sw/bin/nft", "list", "ruleset"]) .args(&["/run/current-system/sw/bin/nft", "list", "ruleset"])
.output(); .output();
let output = match output { let output = match output {
@ -1088,6 +1174,31 @@ impl SystemdCollector {
Some((active_count, download_mbps, upload_mbps)) Some((active_count, download_mbps, upload_mbps))
} }
/// Check for active torrent copy operations
/// Returns: Vec of filenames currently being copied
fn get_active_torrent_copies(&self) -> Vec<String> {
let marker_dir = "/tmp/torrent-copy";
let mut active_copies = Vec::new();
// Read all marker files from directory
if let Ok(entries) = std::fs::read_dir(marker_dir) {
for entry in entries.flatten() {
if let Ok(file_type) = entry.file_type() {
if file_type.is_file() {
// Filename is the marker (sanitized torrent name)
if let Some(filename) = entry.file_name().to_str() {
// Convert sanitized name back (replace _ with /)
let display_name = filename.replace('_', "/");
active_copies.push(display_name);
}
}
}
}
}
active_copies
}
} }
#[async_trait] #[async_trait]

View File

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

View File

@ -1,10 +1,10 @@
use anyhow::Result; use anyhow::Result;
use crossterm::{ use crossterm::{
event::{self}, event::{self, EnableMouseCapture, DisableMouseCapture, Event, MouseEvent, MouseEventKind, MouseButton},
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use ratatui::{backend::CrosstermBackend, Terminal}; use ratatui::{backend::CrosstermBackend, Terminal, layout::Rect};
use std::io; use std::io;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
@ -22,6 +22,9 @@ pub struct Dashboard {
headless: bool, headless: bool,
initial_commands_sent: std::collections::HashSet<String>, initial_commands_sent: std::collections::HashSet<String>,
config: DashboardConfig, 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
} }
impl Dashboard { impl Dashboard {
@ -92,7 +95,7 @@ impl Dashboard {
} }
let mut stdout = io::stdout(); let mut stdout = io::stdout();
if let Err(e) = execute!(stdout, EnterAlternateScreen) { if let Err(e) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture) {
error!("Failed to enter alternate screen: {}", e); error!("Failed to enter alternate screen: {}", e);
let _ = disable_raw_mode(); let _ = disable_raw_mode();
return Err(e.into()); return Err(e.into());
@ -121,6 +124,9 @@ impl Dashboard {
headless, headless,
initial_commands_sent: std::collections::HashSet::new(), initial_commands_sent: std::collections::HashSet::new(),
config, config,
title_area: Rect::default(),
system_area: Rect::default(),
services_area: Rect::default(),
}) })
} }
@ -134,25 +140,40 @@ impl Dashboard {
let heartbeat_check_interval = Duration::from_secs(1); // Check for host connectivity every 1 second let heartbeat_check_interval = Duration::from_secs(1); // Check for host connectivity every 1 second
loop { loop {
// Handle terminal events (keyboard input) only if not headless // Handle terminal events (keyboard and mouse input) only if not headless
if !self.headless { if !self.headless {
match event::poll(Duration::from_millis(50)) { match event::poll(Duration::from_millis(50)) {
Ok(true) => { Ok(true) => {
match event::read() { match event::read() {
Ok(event) => { Ok(event) => {
if let Some(ref mut tui_app) = self.tui_app { if let Some(ref mut tui_app) = self.tui_app {
// Handle input match event {
match tui_app.handle_input(event) { Event::Key(_) => {
Ok(_) => { // Handle keyboard input
// Check if we should quit match tui_app.handle_input(event) {
if tui_app.should_quit() { Ok(_) => {
info!("Quit requested, exiting dashboard"); // Check if we should quit
break; if tui_app.should_quit() {
info!("Quit requested, exiting dashboard");
break;
}
}
Err(e) => {
error!("Error handling input: {}", e);
}
} }
} }
Err(e) => { Event::Mouse(mouse_event) => {
error!("Error handling input: {}", e); // Handle mouse events
if let Err(e) = self.handle_mouse_event(mouse_event) {
error!("Error handling mouse event: {}", e);
}
} }
Event::Resize(_width, _height) => {
// Terminal was resized - just continue and re-render
// The next render will automatically use the new size
}
_ => {}
} }
} }
} }
@ -172,8 +193,29 @@ impl Dashboard {
// Render UI immediately after handling input for responsive feedback // Render UI immediately after handling input for responsive feedback
if let Some(ref mut terminal) = self.terminal { if let Some(ref mut terminal) = self.terminal {
if let Some(ref mut tui_app) = self.tui_app { if let Some(ref mut tui_app) = self.tui_app {
if let Err(e) = terminal.draw(|frame| { // Clear and autoresize terminal to handle any resize events
tui_app.render(frame, &self.metric_store); if let Err(e) = terminal.autoresize() {
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;
self.system_area = system_area;
self.services_area = services_area;
}) { }) {
error!("Error rendering TUI after input: {}", e); error!("Error rendering TUI after input: {}", e);
} }
@ -251,8 +293,29 @@ impl Dashboard {
if !self.headless { if !self.headless {
if let Some(ref mut terminal) = self.terminal { if let Some(ref mut terminal) = self.terminal {
if let Some(ref mut tui_app) = self.tui_app { if let Some(ref mut tui_app) = self.tui_app {
if let Err(e) = terminal.draw(|frame| { // Clear and autoresize terminal to handle any resize events
tui_app.render(frame, &self.metric_store); if let Err(e) = terminal.autoresize() {
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;
self.system_area = system_area;
self.services_area = services_area;
}) { }) {
error!("Error rendering TUI: {}", e); error!("Error rendering TUI: {}", e);
break; break;
@ -269,7 +332,372 @@ impl Dashboard {
Ok(()) Ok(())
} }
/// Handle mouse events
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<()> {
let x = mouse.column;
let y = mouse.row;
// Handle popup menu if open
let popup_info = if let Some(ref tui_app) = self.tui_app {
tui_app.popup_menu.clone().map(|popup| {
let hostname = tui_app.current_host.clone();
(popup, hostname)
})
} else {
None
};
if let Some((popup, hostname)) = popup_info {
// Calculate popup bounds using screen coordinates
let popup_width = 20;
let popup_height = 5; // 3 items + 2 borders
// Get terminal size
let (screen_width, screen_height) = if let Some(ref terminal) = self.terminal {
let size = terminal.size().unwrap_or_default();
(size.width, size.height)
} else {
(80, 24) // fallback
};
let popup_x = if popup.x + popup_width < screen_width {
popup.x
} else {
screen_width.saturating_sub(popup_width)
};
let popup_y = if popup.y + popup_height < screen_height {
popup.y
} else {
screen_height.saturating_sub(popup_height)
};
let popup_area = Rect {
x: popup_x,
y: popup_y,
width: popup_width,
height: popup_height,
};
// Update selected index on mouse move
if matches!(mouse.kind, MouseEventKind::Moved) {
if is_in_area(x, y, &popup_area) {
let relative_y = y.saturating_sub(popup_y + 1) as usize; // +1 for top border
if relative_y < 3 {
if let Some(ref mut tui_app) = self.tui_app {
if let Some(ref mut popup) = tui_app.popup_menu {
popup.selected_index = relative_y;
}
}
}
}
return Ok(());
}
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
if is_in_area(x, y, &popup_area) {
// Click inside popup - execute action
let relative_y = y.saturating_sub(popup_y + 1) as usize; // +1 for top border
if relative_y < 3 {
// Execute the selected action
self.execute_service_action(relative_y, &popup.service_name, hostname.as_deref())?;
}
// Close popup after action
if let Some(ref mut tui_app) = self.tui_app {
tui_app.popup_menu = None;
}
return Ok(());
} else {
// Click outside popup - close it
if let Some(ref mut tui_app) = self.tui_app {
tui_app.popup_menu = None;
}
return Ok(());
}
}
// Any other event while popup is open - don't process panels
return Ok(());
}
// Check for title bar clicks (host selection)
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);
}
}
}
return Ok(());
}
}
// Determine which panel the mouse is over
let in_system_area = is_in_area(x, y, &self.system_area);
let in_services_area = is_in_area(x, y, &self.services_area);
if !in_system_area && !in_services_area {
return Ok(());
}
// Handle mouse events
match mouse.kind {
MouseEventKind::ScrollDown => {
if in_system_area {
// Scroll down in system panel
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);
let visible_height = self.system_area.height as usize;
let total_lines = host_widgets.system_widget.get_total_lines();
host_widgets.system_widget.scroll_down(visible_height, total_lines);
}
}
} else if in_services_area {
// Scroll down in services panel
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);
// Calculate visible height (panel height - borders and header)
let visible_height = self.services_area.height.saturating_sub(3) as usize;
host_widgets.services_widget.scroll_down(visible_height);
}
}
}
}
MouseEventKind::ScrollUp => {
if in_system_area {
// Scroll up in system panel
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);
host_widgets.system_widget.scroll_up();
}
}
} else if in_services_area {
// Scroll up in services panel
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);
host_widgets.services_widget.scroll_up();
}
}
}
}
MouseEventKind::Down(button) => {
// Only handle clicks in services area (not system area)
if !in_services_area {
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);
// 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,
});
}
}
_ => {}
}
}
}
}
}
_ => {}
}
Ok(())
}
/// Execute service action from popup menu
fn execute_service_action(&self, action_index: usize, service_name: &str, hostname: Option<&str>) -> Result<()> {
let Some(hostname) = hostname else {
return Ok(());
};
let connection_ip = self.get_connection_ip(hostname);
match action_index {
0 => {
// Start Service
let service_start_command = format!(
"echo 'Starting service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} start {}'\"",
service_name,
hostname,
self.config.ssh.rebuild_user,
connection_ip,
self.config.ssh.service_manage_cmd,
service_name
);
std::process::Command::new("tmux")
.arg("split-window")
.arg("-v")
.arg("-p")
.arg("30")
.arg(&service_start_command)
.spawn()
.ok();
}
1 => {
// Stop Service
let service_stop_command = format!(
"echo 'Stopping service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} stop {}'\"",
service_name,
hostname,
self.config.ssh.rebuild_user,
connection_ip,
self.config.ssh.service_manage_cmd,
service_name
);
std::process::Command::new("tmux")
.arg("split-window")
.arg("-v")
.arg("-p")
.arg("30")
.arg(&service_stop_command)
.spawn()
.ok();
}
2 => {
// View Logs
let logs_command = format!(
"ssh -tt {}@{} '{} logs {}'",
self.config.ssh.rebuild_user,
connection_ip,
self.config.ssh.service_manage_cmd,
service_name
);
std::process::Command::new("tmux")
.arg("split-window")
.arg("-v")
.arg("-p")
.arg("30")
.arg(&logs_command)
.spawn()
.ok();
}
_ => {}
}
Ok(())
}
/// Get connection IP for a host
fn get_connection_ip(&self, hostname: &str) -> String {
self.config
.hosts
.get(hostname)
.and_then(|h| h.ip.clone())
.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
} }
impl Drop for Dashboard { impl Drop for Dashboard {
@ -278,7 +706,7 @@ impl Drop for Dashboard {
if !self.headless { if !self.headless {
let _ = disable_raw_mode(); let _ = disable_raw_mode();
if let Some(ref mut terminal) = self.terminal { if let Some(ref mut terminal) = self.terminal {
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen); let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture);
let _ = terminal.show_cursor(); let _ = terminal.show_cursor();
} }
} }

View File

@ -86,16 +86,6 @@ impl MetricStore {
self.current_agent_data.get(hostname) self.current_agent_data.get(hostname)
} }
/// Get ZMQ communication statistics for a host
pub fn get_zmq_stats(&mut self, hostname: &str) -> Option<ZmqStats> {
let now = Instant::now();
self.zmq_stats.get_mut(hostname).map(|stats| {
// Update packet age
stats.last_packet_age_secs = now.duration_since(stats.last_packet_time).as_secs_f64();
stats.clone()
})
}
/// Get connected hosts (hosts with recent heartbeats) /// Get connected hosts (hosts with recent heartbeats)
pub fn get_connected_hosts(&self, timeout: Duration) -> Vec<String> { pub fn get_connected_hosts(&self, timeout: Duration) -> Vec<String> {
let now = Instant::now(); let now = Instant::now();

View File

@ -17,7 +17,7 @@ pub mod widgets;
use crate::config::DashboardConfig; use crate::config::DashboardConfig;
use crate::metrics::MetricStore; use crate::metrics::MetricStore;
use cm_dashboard_shared::Status; use cm_dashboard_shared::Status;
use theme::{Components, Layout as ThemeLayout, Theme, Typography}; use theme::{Components, Layout as ThemeLayout, Theme};
use widgets::{ServicesWidget, SystemWidget, Widget}; use widgets::{ServicesWidget, SystemWidget, Widget};
@ -47,12 +47,21 @@ impl HostWidgets {
} }
/// Popup menu state
#[derive(Clone)]
pub struct PopupMenu {
pub service_name: String,
pub x: u16,
pub y: u16,
pub selected_index: usize,
}
/// Main TUI application /// Main TUI application
pub struct TuiApp { pub struct TuiApp {
/// Widget states per host (hostname -> HostWidgets) /// Widget states per host (hostname -> HostWidgets)
host_widgets: HashMap<String, HostWidgets>, host_widgets: HashMap<String, HostWidgets>,
/// Current active host /// Current active host
current_host: Option<String>, pub current_host: Option<String>,
/// Available hosts /// Available hosts
available_hosts: Vec<String>, available_hosts: Vec<String>,
/// Host index for navigation /// Host index for navigation
@ -65,6 +74,8 @@ pub struct TuiApp {
config: DashboardConfig, config: DashboardConfig,
/// Cached localhost hostname to avoid repeated system calls /// Cached localhost hostname to avoid repeated system calls
localhost: String, localhost: String,
/// Active popup menu (if any)
pub popup_menu: Option<PopupMenu>,
} }
impl TuiApp { impl TuiApp {
@ -79,6 +90,7 @@ impl TuiApp {
user_navigated_away: false, user_navigated_away: false,
config, config,
localhost, localhost,
popup_menu: None,
}; };
// Sort predefined hosts // Sort predefined hosts
@ -93,7 +105,7 @@ impl TuiApp {
} }
/// Get or create host widgets for the given hostname /// Get or create host widgets for the given hostname
fn get_or_create_host_widgets(&mut self, hostname: &str) -> &mut HostWidgets { pub fn get_or_create_host_widgets(&mut self, hostname: &str) -> &mut HostWidgets {
self.host_widgets self.host_widgets
.entry(hostname.to_string()) .entry(hostname.to_string())
.or_insert_with(HostWidgets::new) .or_insert_with(HostWidgets::new)
@ -159,6 +171,14 @@ impl TuiApp {
/// Handle keyboard input /// Handle keyboard input
pub fn handle_input(&mut self, event: Event) -> Result<()> { pub fn handle_input(&mut self, event: Event) -> Result<()> {
if let Event::Key(key) = event { if let Event::Key(key) = event {
// Close popup on Escape
if matches!(key.code, KeyCode::Esc) {
if self.popup_menu.is_some() {
self.popup_menu = None;
return Ok(());
}
}
match key.code { match key.code {
KeyCode::Char('q') => { KeyCode::Char('q') => {
self.should_quit = true; self.should_quit = true;
@ -363,6 +383,23 @@ impl TuiApp {
Ok(()) Ok(())
} }
/// 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;
self.current_host = Some(hostname.to_string());
// Check if user navigated away from localhost
if hostname != &self.localhost {
self.user_navigated_away = true;
} else {
self.user_navigated_away = false; // User navigated back to localhost
}
info!("Switched to host: {}", hostname);
}
}
/// Navigate between hosts /// Navigate between hosts
fn navigate_host(&mut self, direction: i32) { fn navigate_host(&mut self, direction: i32) {
if self.available_hosts.is_empty() { if self.available_hosts.is_empty() {
@ -381,7 +418,7 @@ impl TuiApp {
} }
self.current_host = Some(self.available_hosts[self.host_index].clone()); self.current_host = Some(self.available_hosts[self.host_index].clone());
// Check if user navigated away from localhost // Check if user navigated away from localhost
if let Some(ref current) = self.current_host { if let Some(ref current) = self.current_host {
if current != &self.localhost { if current != &self.localhost {
@ -390,7 +427,7 @@ impl TuiApp {
self.user_navigated_away = false; // User navigated back to localhost self.user_navigated_away = false; // User navigated back to localhost
} }
} }
info!("Switched to host: {}", self.current_host.as_ref().unwrap()); info!("Switched to host: {}", self.current_host.as_ref().unwrap());
} }
@ -408,6 +445,10 @@ impl TuiApp {
None None
} }
/// Get the list of available hosts
pub fn get_available_hosts(&self) -> &Vec<String> {
&self.available_hosts
}
/// Should quit application /// Should quit application
pub fn should_quit(&self) -> bool { pub fn should_quit(&self) -> bool {
@ -421,7 +462,7 @@ impl TuiApp {
/// Render the dashboard (real btop-style multi-panel layout) /// Render the dashboard (real btop-style multi-panel layout)
pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) { pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) -> (Rect, Rect, Rect) {
let size = frame.size(); let size = frame.size();
// Clear background to true black like btop // Clear background to true black like btop
@ -461,8 +502,8 @@ impl TuiApp {
if current_host_offline { if current_host_offline {
self.render_offline_host_message(frame, main_chunks[1]); self.render_offline_host_message(frame, main_chunks[1]);
self.render_btop_title(frame, main_chunks[0], metric_store); self.render_btop_title(frame, main_chunks[0], metric_store);
self.render_statusbar(frame, main_chunks[2]); self.render_statusbar(frame, main_chunks[2], metric_store);
return; return (main_chunks[0], Rect::default(), Rect::default()); // Return title area and empty areas when offline
} }
// Left side: system panel only (full height) // Left side: system panel only (full height)
@ -475,20 +516,29 @@ impl TuiApp {
self.render_btop_title(frame, main_chunks[0], metric_store); self.render_btop_title(frame, main_chunks[0], metric_store);
// Render system panel // Render system panel
self.render_system_panel(frame, left_chunks[0], metric_store); let system_area = left_chunks[0];
self.render_system_panel(frame, system_area, metric_store);
// Render services widget for current host // Render services widget for current host
let services_area = content_chunks[1];
if let Some(hostname) = self.current_host.clone() { if let Some(hostname) = self.current_host.clone() {
let is_focused = true; // Always show service selection let is_focused = true; // Always show service selection
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets host_widgets
.services_widget .services_widget
.render(frame, content_chunks[1], is_focused); // Services takes full right side .render(frame, services_area, is_focused); // Services takes full right side
} }
// Render statusbar at the bottom // Render statusbar at the bottom
self.render_statusbar(frame, main_chunks[2]); // main_chunks[2] is the statusbar area self.render_statusbar(frame, main_chunks[2], metric_store);
// Render popup menu on top of everything if active
if let Some(ref popup) = self.popup_menu {
self.render_popup_menu(frame, popup);
}
// Return all areas for mouse event handling
(main_chunks[0], system_area, services_area)
} }
/// Render btop-style minimal title with host status colors /// Render btop-style minimal title with host status colors
@ -556,7 +606,14 @@ impl TuiApp {
)); ));
if Some(host) == self.current_host.as_ref() { if Some(host) == self.current_host.as_ref() {
// Selected host in bold background color against status background // 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_spans.push(Span::styled(
host.clone(), host.clone(),
Style::default() Style::default()
@ -564,6 +621,13 @@ impl TuiApp {
.bg(background_color) .bg(background_color)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)); ));
host_spans.push(Span::styled(
"]",
Style::default()
.fg(Theme::background())
.bg(background_color)
.add_modifier(Modifier::BOLD),
));
} else { } else {
// Other hosts in normal background color against status background // Other hosts in normal background color against status background
host_spans.push(Span::styled( host_spans.push(Span::styled(
@ -597,36 +661,137 @@ impl TuiApp {
} }
} }
/// Render dynamic statusbar with context-aware shortcuts /// Render popup menu for service actions
fn render_statusbar(&self, frame: &mut Frame, area: Rect) { fn render_popup_menu(&self, frame: &mut Frame, popup: &PopupMenu) {
let shortcuts = self.get_context_shortcuts(); use ratatui::widgets::{Block, Borders, Clear, List, ListItem};
let statusbar_text = shortcuts.join(""); use ratatui::style::{Color, Modifier};
let statusbar = Paragraph::new(statusbar_text) // Menu items
.style(Typography::secondary()) let items = vec![
.alignment(ratatui::layout::Alignment::Center); "Start Service",
"Stop Service",
"View Logs",
];
// Calculate popup size
let width = 20;
let height = items.len() as u16 + 2; // +2 for borders
// Position popup near click location, but keep it on screen
let screen_width = frame.size().width;
let screen_height = frame.size().height;
let x = if popup.x + width < screen_width {
popup.x
} else {
screen_width.saturating_sub(width)
};
let y = if popup.y + height < screen_height {
popup.y
} else {
screen_height.saturating_sub(height)
};
let popup_area = Rect {
x,
y,
width,
height,
};
// Create menu items with selection highlight
let menu_items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, item)| {
let style = if i == popup.selected_index {
Style::default()
.fg(Color::Black)
.bg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::primary_text())
};
ListItem::new(*item).style(style)
})
.collect();
let menu_list = List::new(menu_items)
.block(
Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(Theme::background()).fg(Theme::primary_text()))
);
// Clear the area and render menu
frame.render_widget(Clear, popup_area);
frame.render_widget(menu_list, popup_area);
}
/// Render statusbar with host and client IPs
fn render_statusbar(&self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) {
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
// Get current host info
let (hostname_str, host_ip, build_version, agent_version) = if let Some(hostname) = &self.current_host {
// Get the connection IP (the IP dashboard uses to connect to the agent)
let ip = if let Some(host_details) = self.config.hosts.get(hostname) {
host_details.get_connection_ip(hostname)
} else {
hostname.clone()
};
// Get build and agent versions from system widget
let (build, agent) = if let Some(host_widgets) = self.host_widgets.get(hostname) {
let build = host_widgets.system_widget.get_build_version().unwrap_or("N/A".to_string());
let agent = host_widgets.system_widget.get_agent_version().unwrap_or("N/A".to_string());
(build, agent)
} else {
("N/A".to_string(), "N/A".to_string())
};
(hostname.clone(), ip, build, agent)
} else {
("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);
// 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));
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())),
]);
let statusbar = Paragraph::new(line);
frame.render_widget(statusbar, area); frame.render_widget(statusbar, area);
} }
/// Get context-aware shortcuts based on focused panel /// Get local IP address of the dashboard
fn get_context_shortcuts(&self) -> Vec<String> { fn get_local_ip() -> String {
let mut shortcuts = Vec::new(); use std::net::UdpSocket;
// Global shortcuts // Try to get local IP by creating a UDP socket
shortcuts.push("Tab: Host".to_string()); // This doesn't actually send data, just determines routing
shortcuts.push("↑↓/jk: Select".to_string()); if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") {
shortcuts.push("r: Rebuild".to_string()); if socket.connect("8.8.8.8:80").is_ok() {
shortcuts.push("B: Backup".to_string()); if let Ok(addr) = socket.local_addr() {
shortcuts.push("s/S: Start/Stop".to_string()); return addr.ip().to_string();
shortcuts.push("L: Logs".to_string()); }
shortcuts.push("t: Terminal".to_string()); }
shortcuts.push("w: Wake".to_string()); }
"N/A".to_string()
// Always show quit
shortcuts.push("q: Quit".to_string());
shortcuts
} }
fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) { fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) {

View File

@ -91,14 +91,17 @@ pub struct ServicesWidget {
/// Last update indicator /// Last update indicator
has_data: bool, has_data: bool,
/// Currently selected service index (for navigation cursor) /// Currently selected service index (for navigation cursor)
selected_index: usize, pub selected_index: usize,
/// Scroll offset for viewport (which display line is at the top)
pub scroll_offset: usize,
/// Last rendered viewport height (for accurate scroll bounds)
last_viewport_height: usize,
} }
#[derive(Clone)] #[derive(Clone)]
struct ServiceInfo { struct ServiceInfo {
metrics: Vec<(String, f32, Option<String>)>, // (label, value, unit) metrics: Vec<(String, f32, Option<String>)>, // (label, value, unit)
widget_status: Status, widget_status: Status,
service_type: String, // "nginx_site", "container", "image", or empty for parent services
memory_bytes: Option<u64>, memory_bytes: Option<u64>,
restart_count: Option<u32>, restart_count: Option<u32>,
uptime_seconds: Option<u64>, uptime_seconds: Option<u64>,
@ -112,6 +115,8 @@ impl ServicesWidget {
status: Status::Unknown, status: Status::Unknown,
has_data: false, has_data: false,
selected_index: 0, selected_index: 0,
scroll_offset: 0,
last_viewport_height: 0,
} }
} }
@ -338,18 +343,86 @@ impl ServicesWidget {
pub fn select_previous(&mut self) { pub fn select_previous(&mut self) {
if self.selected_index > 0 { if self.selected_index > 0 {
self.selected_index -= 1; self.selected_index -= 1;
self.ensure_selected_visible();
} }
debug!("Service selection moved up to: {}", self.selected_index); debug!("Service selection moved up to: {}", self.selected_index);
} }
/// Move selection down /// Move selection down
pub fn select_next(&mut self, total_services: usize) { pub fn select_next(&mut self, total_services: usize) {
if total_services > 0 && self.selected_index < total_services.saturating_sub(1) { if total_services > 0 && self.selected_index < total_services.saturating_sub(1) {
self.selected_index += 1; self.selected_index += 1;
self.ensure_selected_visible();
} }
debug!("Service selection: {}/{}", self.selected_index, total_services); debug!("Service selection: {}/{}", self.selected_index, total_services);
} }
/// Convert parent service index to display line index
fn parent_index_to_display_line(&self, parent_index: usize) -> usize {
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
let mut display_line = 0;
for (idx, (parent_name, _)) in parent_services.iter().enumerate() {
if idx == parent_index {
return display_line;
}
display_line += 1; // Parent service line
// Add sub-service lines
if let Some(sub_list) = self.sub_services.get(*parent_name) {
display_line += sub_list.len();
}
}
display_line
}
/// Ensure the currently selected service is visible in the viewport
fn ensure_selected_visible(&mut self) {
if self.last_viewport_height == 0 {
return; // Can't adjust without knowing viewport size
}
let display_line = self.parent_index_to_display_line(self.selected_index);
let total_display_lines = self.get_total_display_lines();
let viewport_height = self.last_viewport_height;
// Check if selected line is above visible area
if display_line < self.scroll_offset {
self.scroll_offset = display_line;
return;
}
// Calculate current effective viewport (accounting for "more below" if present)
let current_remaining = total_display_lines.saturating_sub(self.scroll_offset);
let current_has_more = current_remaining > viewport_height;
let current_effective = if current_has_more {
viewport_height.saturating_sub(1)
} else {
viewport_height
};
// Check if selected line is below current visible area
if display_line >= self.scroll_offset + current_effective {
// Need to scroll down. Position selected line so there's room for "more below" if needed
// Strategy: if there are lines below the selected line, don't put it at the very bottom
let has_content_below = display_line < total_display_lines - 1;
if has_content_below {
// Leave room for "... X more below" message by positioning selected line
// one position higher than the last line
let target_position = viewport_height.saturating_sub(2);
self.scroll_offset = display_line.saturating_sub(target_position);
} else {
// This is the last line, can put it at the bottom
self.scroll_offset = display_line.saturating_sub(viewport_height - 1);
}
}
debug!("Auto-scroll: selected={}, display_line={}, scroll_offset={}, viewport={}, total={}",
self.selected_index, display_line, self.scroll_offset, viewport_height, total_display_lines);
}
/// Get currently selected service name (for actions) /// Get currently selected service name (for actions)
/// Only returns parent service names since only parent services can be selected /// Only returns parent service names since only parent services can be selected
pub fn get_selected_service(&self) -> Option<String> { pub fn get_selected_service(&self) -> Option<String> {
@ -366,6 +439,81 @@ impl ServicesWidget {
self.parent_services.len() self.parent_services.len()
} }
/// Get total display lines (parent services + sub-services)
pub fn get_total_display_lines(&self) -> usize {
let mut total = self.parent_services.len();
for sub_list in self.sub_services.values() {
total += sub_list.len();
}
total
}
/// Scroll down by one line
pub fn scroll_down(&mut self, _visible_height: usize) {
let total_lines = self.get_total_display_lines();
// Use last_viewport_height if available (more accurate), otherwise can't scroll
let viewport_height = if self.last_viewport_height > 0 {
self.last_viewport_height
} else {
return; // Can't scroll without knowing viewport size
};
// Calculate exact max scroll to match render logic
// Stop scrolling when all remaining content fits in viewport
// At scroll_offset N: remaining = total_lines - N
// We can show all when: remaining <= viewport_height
// So max_scroll is when: total_lines - max_scroll = viewport_height
// Therefore: max_scroll = total_lines - viewport_height (but at least 0)
let max_scroll = total_lines.saturating_sub(viewport_height);
debug!("Scroll down: total={}, viewport={}, offset={}, max={}", total_lines, viewport_height, self.scroll_offset, max_scroll);
if self.scroll_offset < max_scroll {
self.scroll_offset += 1;
}
}
/// Scroll up by one line
pub fn scroll_up(&mut self) {
if self.scroll_offset > 0 {
self.scroll_offset -= 1;
}
}
/// Map a display line index to a parent service index (returns None if clicked on sub-service)
pub fn display_line_to_parent_index(&self, display_line_index: usize) -> Option<usize> {
// Build the same display list to map line index to parent service index
let mut parent_index = 0;
let mut line_index = 0;
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
for (parent_name, _) in parent_services {
// Check if this line index matches a parent service
if line_index == display_line_index {
return Some(parent_index);
}
line_index += 1;
// Add sub-services for this parent (if any)
if let Some(sub_list) = self.sub_services.get(parent_name) {
for _ in sub_list {
if line_index == display_line_index {
// Clicked on a sub-service - return None (can't select sub-services)
return None;
}
line_index += 1;
}
}
parent_index += 1;
}
None
}
/// Calculate which parent service index corresponds to a display line index /// Calculate which parent service index corresponds to a display line index
fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize { fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize {
@ -407,7 +555,6 @@ impl Widget for ServicesWidget {
let parent_info = ServiceInfo { let parent_info = ServiceInfo {
metrics: Vec::new(), // Parent services don't have custom metrics metrics: Vec::new(), // Parent services don't have custom metrics
widget_status: service.service_status, widget_status: service.service_status,
service_type: String::new(), // Parent services have no type
memory_bytes: service.memory_bytes, memory_bytes: service.memory_bytes,
restart_count: service.restart_count, restart_count: service.restart_count,
uptime_seconds: service.uptime_seconds, uptime_seconds: service.uptime_seconds,
@ -426,7 +573,6 @@ impl Widget for ServicesWidget {
let sub_info = ServiceInfo { let sub_info = ServiceInfo {
metrics, metrics,
widget_status: sub_service.service_status, widget_status: sub_service.service_status,
service_type: sub_service.service_type.clone(),
memory_bytes: None, // Sub-services don't have individual metrics yet memory_bytes: None, // Sub-services don't have individual metrics yet
restart_count: None, restart_count: None,
uptime_seconds: None, uptime_seconds: None,
@ -471,7 +617,6 @@ impl ServicesWidget {
.or_insert(ServiceInfo { .or_insert(ServiceInfo {
metrics: Vec::new(), metrics: Vec::new(),
widget_status: Status::Unknown, widget_status: Status::Unknown,
service_type: String::new(),
memory_bytes: None, memory_bytes: None,
restart_count: None, restart_count: None,
uptime_seconds: None, uptime_seconds: None,
@ -500,7 +645,6 @@ impl ServicesWidget {
ServiceInfo { ServiceInfo {
metrics: Vec::new(), metrics: Vec::new(),
widget_status: Status::Unknown, widget_status: Status::Unknown,
service_type: String::new(), // Unknown type in legacy path
memory_bytes: None, memory_bytes: None,
restart_count: None, restart_count: None,
uptime_seconds: None, uptime_seconds: None,
@ -542,12 +686,23 @@ impl ServicesWidget {
self.selected_index = total_count - 1; self.selected_index = total_count - 1;
} }
// Clamp scroll offset to valid range after update
// This prevents scroll issues when switching between hosts or when service count changes
let total_display_lines = self.get_total_display_lines();
if total_display_lines == 0 {
self.scroll_offset = 0;
} else if self.scroll_offset >= total_display_lines {
// Clamp to max valid value, not reset to 0
self.scroll_offset = total_display_lines.saturating_sub(1);
}
debug!( debug!(
"Services widget updated: {} parent services, {} sub-service groups, total={}, selected={}, status={:?}", "Services widget updated: {} parent services, {} sub-service groups, total={}, selected={}, scroll={}, status={:?}",
self.parent_services.len(), self.parent_services.len(),
self.sub_services.len(), self.sub_services.len(),
total_count, total_count,
self.selected_index, self.selected_index,
self.scroll_offset,
self.status self.status
); );
} }
@ -639,20 +794,46 @@ impl ServicesWidget {
// Show only what fits, with "X more below" if needed // Show only what fits, with "X more below" if needed
let available_lines = area.height as usize; let available_lines = area.height as usize;
let total_lines = display_lines.len(); let total_lines = display_lines.len();
// Reserve one line for "X more below" if needed // Store viewport height for accurate scroll calculations
let lines_for_content = if total_lines > available_lines { self.last_viewport_height = available_lines;
// Clamp scroll_offset to valid range based on current viewport and content
// This handles dynamic viewport size changes
let max_valid_scroll = total_lines.saturating_sub(available_lines);
if self.scroll_offset > max_valid_scroll {
self.scroll_offset = max_valid_scroll;
}
// Calculate how many lines remain after scroll offset
let remaining_lines = total_lines.saturating_sub(self.scroll_offset);
debug!("Render: total={}, viewport={}, offset={}, max={}, remaining={}",
total_lines, available_lines, self.scroll_offset, max_valid_scroll, remaining_lines);
// Check if all remaining content fits in viewport
let will_show_more_below = remaining_lines > available_lines;
// Reserve one line for "X more below" only if we can't fit everything
let lines_for_content = if will_show_more_below {
available_lines.saturating_sub(1) available_lines.saturating_sub(1)
} else { } else {
available_lines available_lines.min(remaining_lines)
}; };
// Apply scroll offset
let visible_lines: Vec<_> = display_lines let visible_lines: Vec<_> = display_lines
.iter() .iter()
.skip(self.scroll_offset)
.take(lines_for_content) .take(lines_for_content)
.collect(); .collect();
let hidden_below = total_lines.saturating_sub(lines_for_content); // Only calculate hidden_below if we actually reserved space for the message
let hidden_below = if will_show_more_below {
remaining_lines.saturating_sub(lines_for_content)
} else {
0
};
let lines_to_show = visible_lines.len(); let lines_to_show = visible_lines.len();
@ -666,8 +847,8 @@ impl ServicesWidget {
for (i, (line_text, line_status, is_sub, sub_info)) in visible_lines.iter().enumerate() for (i, (line_text, line_status, is_sub, sub_info)) in visible_lines.iter().enumerate()
{ {
let actual_index = i; // Simple index since we're not scrolling let actual_index = self.scroll_offset + i; // Account for scroll offset
// Only parent services can be selected - calculate parent service index // Only parent services can be selected - calculate parent service index
let is_selected = if !*is_sub { let is_selected = if !*is_sub {
// This is a parent service - count how many parent services came before this one // This is a parent service - count how many parent services came before this one
@ -712,7 +893,7 @@ impl ServicesWidget {
// Show "X more below" message if content was truncated // Show "X more below" message if content was truncated
if hidden_below > 0 { if hidden_below > 0 {
let more_text = format!("... {} more below", hidden_below); let more_text = format!("... {} more below", hidden_below);
let more_para = Paragraph::new(more_text).style(Typography::muted()); let more_para = Paragraph::new(more_text).style(Style::default().fg(Theme::border()));
frame.render_widget(more_para, service_chunks[lines_to_show]); frame.render_widget(more_para, service_chunks[lines_to_show]);
} }
} }

View File

@ -1,12 +1,13 @@
use cm_dashboard_shared::Status; use cm_dashboard_shared::Status;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
style::Style,
text::{Line, Span, Text}, text::{Line, Span, Text},
widgets::Paragraph, widgets::Paragraph,
Frame, Frame,
}; };
use crate::ui::theme::{StatusIcons, Typography}; use crate::ui::theme::{StatusIcons, Theme, Typography};
/// System widget displaying NixOS info, Network, CPU, RAM, and Storage in unified layout /// System widget displaying NixOS info, Network, CPU, RAM, and Storage in unified layout
#[derive(Clone)] #[derive(Clone)]
@ -49,6 +50,11 @@ pub struct SystemWidget {
// Overall status // Overall status
has_data: bool, has_data: bool,
// Scroll offset for viewport
pub scroll_offset: usize,
/// Last rendered viewport height (for accurate scroll bounds)
last_viewport_height: usize,
} }
#[derive(Clone)] #[derive(Clone)]
@ -110,6 +116,8 @@ impl SystemWidget {
backup_repository_status: Status::Unknown, backup_repository_status: Status::Unknown,
backup_disks: Vec::new(), backup_disks: Vec::new(),
has_data: false, has_data: false,
scroll_offset: 0,
last_viewport_height: 0,
} }
} }
@ -153,6 +161,16 @@ impl SystemWidget {
pub fn _get_agent_hash(&self) -> Option<&String> { pub fn _get_agent_hash(&self) -> Option<&String> {
self.agent_hash.as_ref() self.agent_hash.as_ref()
} }
/// Get the build version
pub fn get_build_version(&self) -> Option<String> {
self.nixos_build.clone()
}
/// Get the agent version
pub fn get_agent_version(&self) -> Option<String> {
self.agent_hash.clone()
}
} }
use super::Widget; use super::Widget;
@ -206,6 +224,16 @@ impl Widget for SystemWidget {
self.backup_repositories = backup.repositories.clone(); self.backup_repositories = backup.repositories.clone();
self.backup_repository_status = backup.repository_status; self.backup_repository_status = backup.repository_status;
self.backup_disks = backup.disks.clone(); self.backup_disks = backup.disks.clone();
// Clamp scroll offset to valid range after update
// This prevents scroll issues when switching between hosts
let total_lines = self.get_total_lines();
if total_lines == 0 {
self.scroll_offset = 0;
} else if self.scroll_offset >= total_lines {
// Clamp to max valid value, not reset to 0
self.scroll_offset = total_lines.saturating_sub(1);
}
} }
} }
@ -781,23 +809,90 @@ impl SystemWidget {
} }
/// Render system widget /// Render system widget
pub fn render(&mut self, frame: &mut Frame, area: Rect, hostname: &str, _config: Option<&crate::config::DashboardConfig>) { /// Scroll down by one line
let mut lines = Vec::new(); pub fn scroll_down(&mut self, _visible_height: usize, _total_lines: usize) {
let total_lines = self.get_total_lines();
// NixOS section // Use last_viewport_height if available (more accurate), otherwise can't scroll
lines.push(Line::from(vec![ let viewport_height = if self.last_viewport_height > 0 {
Span::styled(format!("NixOS {}:", hostname), Typography::widget_title()) self.last_viewport_height
])); } else {
return; // Can't scroll without knowing viewport size
let build_text = self.nixos_build.as_deref().unwrap_or("unknown"); };
lines.push(Line::from(vec![
Span::styled(format!("Build: {}", build_text), Typography::secondary()) // Max scroll should allow us to see all remaining content
])); // When scroll_offset + viewport_height >= total_lines, we can see everything
let max_scroll = if total_lines > viewport_height {
let agent_version_text = self.agent_hash.as_deref().unwrap_or("unknown"); total_lines - viewport_height
lines.push(Line::from(vec![ } else {
Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary()) 0
])); };
if self.scroll_offset < max_scroll {
self.scroll_offset += 1;
}
}
/// Scroll up by one line
pub fn scroll_up(&mut self) {
if self.scroll_offset > 0 {
self.scroll_offset -= 1;
}
}
/// Get total line count (needs to be calculated before rendering)
pub fn get_total_lines(&self) -> usize {
let mut count = 0;
// CPU section (2+ lines for load/cstate, +1 if has model/cores)
count += 2;
if self.cpu_model_name.is_some() || self.cpu_core_count.is_some() {
count += 1;
}
// RAM section (1 + tmpfs mounts)
count += 2;
count += self.tmpfs_mounts.len();
// Network section
if !self.network_interfaces.is_empty() {
count += 1; // Header
// Count network lines (would need to mirror render_network logic)
for iface in &self.network_interfaces {
count += 1; // Interface name
count += iface.ipv4_addresses.len();
count += iface.ipv6_addresses.len();
}
}
// Storage section
count += 1; // Header
for pool in &self.storage_pools {
count += 1; // Pool header
count += pool.drives.len();
count += pool.data_drives.len();
count += pool.parity_drives.len();
count += pool.filesystems.len();
}
// Backup section
if !self.backup_repositories.is_empty() || !self.backup_disks.is_empty() {
count += 1; // Header
if !self.backup_repositories.is_empty() {
count += 1; // Repo header
count += self.backup_repositories.len();
}
count += self.backup_disks.len() * 3; // Each disk has 3 lines
}
count
}
pub fn render(&mut self, frame: &mut Frame, area: Rect, _hostname: &str, _config: Option<&crate::config::DashboardConfig>) {
// Store viewport height for accurate scroll calculations
self.last_viewport_height = area.height as usize;
let mut lines = Vec::new();
// CPU section // CPU section
lines.push(Line::from(vec![ lines.push(Line::from(vec![
@ -905,29 +1000,51 @@ impl SystemWidget {
// Apply scroll offset // Apply scroll offset
let total_lines = lines.len(); let total_lines = lines.len();
let available_height = area.height as usize; let available_height = area.height as usize;
// Show only what fits, with "X more below" if needed // Clamp scroll_offset to valid range based on current viewport and content
if total_lines > available_height { // This handles dynamic viewport size changes
let lines_for_content = available_height.saturating_sub(1); // Reserve one line for "more below" let max_valid_scroll = total_lines.saturating_sub(available_height);
let mut visible_lines: Vec<Line> = lines let clamped_scroll = self.scroll_offset.min(max_valid_scroll);
.into_iter()
.take(lines_for_content) // Calculate how many lines remain after scroll offset
.collect(); let remaining_lines = total_lines.saturating_sub(clamped_scroll);
let hidden_below = total_lines.saturating_sub(lines_for_content); // Check if all remaining content fits in viewport
if hidden_below > 0 { let will_show_more_below = remaining_lines > available_height;
let more_line = Line::from(vec![
Span::styled(format!("... {} more below", hidden_below), Typography::muted()) // Reserve one line for "X more below" only if we can't fit everything
]); let lines_for_content = if will_show_more_below {
visible_lines.push(more_line); available_height.saturating_sub(1)
}
let paragraph = Paragraph::new(Text::from(visible_lines));
frame.render_widget(paragraph, area);
} else { } else {
// All content fits and no scroll offset, render normally available_height.min(remaining_lines)
let paragraph = Paragraph::new(Text::from(lines)); };
frame.render_widget(paragraph, area);
// Apply clamped scroll offset and take only what fits
let mut visible_lines: Vec<Line> = lines
.into_iter()
.skip(clamped_scroll)
.take(lines_for_content)
.collect();
// Note: we don't update self.scroll_offset here due to borrow checker constraints
// It will be clamped on next render if still out of bounds
// Only calculate hidden_below if we actually reserved space for the message
let hidden_below = if will_show_more_below {
remaining_lines.saturating_sub(lines_for_content)
} else {
0
};
// Add "more below" message if needed
if hidden_below > 0 {
let more_line = Line::from(vec![
Span::styled(format!("... {} more below", hidden_below), Style::default().fg(Theme::border()))
]);
visible_lines.push(more_line);
} }
let paragraph = Paragraph::new(Text::from(visible_lines));
frame.render_widget(paragraph, area);
} }
} }

View File

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

View File

@ -38,6 +38,7 @@ pub struct NetworkInterfaceData {
pub link_status: Status, pub link_status: Status,
pub parent_interface: Option<String>, pub parent_interface: Option<String>,
pub vlan_id: Option<u16>, pub vlan_id: Option<u16>,
pub connection_method: Option<String>, // For Tailscale: "direct", "relay", or "proxy"
} }
/// CPU C-state usage information /// CPU C-state usage information