Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b1e39cfca | |||
| ffecbc3166 | |||
| 49f9504429 | |||
| bc9015e96b | |||
| aaec8e691c | |||
| 4a8cfbbde4 | |||
| d93260529b | |||
| 41e1be451e | |||
| 2863526ec8 | |||
| 5da9213da6 | |||
| a7755f02ae |
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.252"
|
||||
version = "0.1.262"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -301,7 +301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.252"
|
||||
version = "0.1.262"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -325,7 +325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.252"
|
||||
version = "0.1.262"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.253"
|
||||
version = "0.1.263"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -282,8 +282,8 @@ impl Collector for CpuCollector {
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate status using thresholds
|
||||
agent_data.system.cpu.load_status = self.calculate_load_status(agent_data.system.cpu.load_1min);
|
||||
// Calculate status using thresholds (use 5-minute average for stability)
|
||||
agent_data.system.cpu.load_status = self.calculate_load_status(agent_data.system.cpu.load_5min);
|
||||
agent_data.system.cpu.temperature_status = if let Some(temp) = agent_data.system.cpu.temperature_celsius {
|
||||
self.calculate_temperature_status(temp)
|
||||
} else {
|
||||
|
||||
@ -181,6 +181,7 @@ impl NetworkCollector {
|
||||
link_status,
|
||||
parent_interface,
|
||||
vlan_id,
|
||||
connection_method: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -178,6 +178,18 @@ impl SystemdCollector {
|
||||
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" {
|
||||
@ -186,7 +198,7 @@ impl SystemdCollector {
|
||||
if !tcp_ports.is_empty() {
|
||||
let metrics = Vec::new();
|
||||
sub_services.push(SubServiceData {
|
||||
name: format!("TCP: {}", tcp_ports),
|
||||
name: format!("wan tcp: {}", tcp_ports),
|
||||
service_status: Status::Info,
|
||||
metrics,
|
||||
service_type: "firewall_port".to_string(),
|
||||
@ -196,7 +208,7 @@ impl SystemdCollector {
|
||||
if !udp_ports.is_empty() {
|
||||
let metrics = Vec::new();
|
||||
sub_services.push(SubServiceData {
|
||||
name: format!("UDP: {}", udp_ports),
|
||||
name: format!("wan udp: {}", udp_ports),
|
||||
service_status: Status::Info,
|
||||
metrics,
|
||||
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
|
||||
let service_data = ServiceData {
|
||||
name: service_name.clone(),
|
||||
@ -911,11 +937,71 @@ impl SystemdCollector {
|
||||
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
|
||||
/// Returns: (tcp_ports_string, udp_ports_string)
|
||||
fn get_nftables_open_ports(&self) -> (String, String) {
|
||||
let output = Command::new("timeout")
|
||||
.args(&["3", "sudo", "nft", "list", "ruleset"])
|
||||
let output = Command::new("sudo")
|
||||
.args(&["/run/current-system/sw/bin/nft", "list", "ruleset"])
|
||||
.output();
|
||||
|
||||
let output = match output {
|
||||
@ -1088,6 +1174,31 @@ impl SystemdCollector {
|
||||
|
||||
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]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.253"
|
||||
version = "0.1.263"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::{
|
||||
event::{self},
|
||||
event::{self, EnableMouseCapture, DisableMouseCapture, Event, MouseEvent, MouseEventKind, MouseButton},
|
||||
execute,
|
||||
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::time::{Duration, Instant};
|
||||
use tracing::{debug, error, info, warn};
|
||||
@ -22,6 +22,9 @@ 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
|
||||
}
|
||||
|
||||
impl Dashboard {
|
||||
@ -92,7 +95,7 @@ impl Dashboard {
|
||||
}
|
||||
|
||||
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);
|
||||
let _ = disable_raw_mode();
|
||||
return Err(e.into());
|
||||
@ -121,6 +124,9 @@ impl Dashboard {
|
||||
headless,
|
||||
initial_commands_sent: std::collections::HashSet::new(),
|
||||
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
|
||||
|
||||
loop {
|
||||
// Handle terminal events (keyboard input) only if not headless
|
||||
// Handle terminal events (keyboard and mouse input) only if not headless
|
||||
if !self.headless {
|
||||
match event::poll(Duration::from_millis(50)) {
|
||||
Ok(true) => {
|
||||
match event::read() {
|
||||
Ok(event) => {
|
||||
if let Some(ref mut tui_app) = self.tui_app {
|
||||
// Handle input
|
||||
match tui_app.handle_input(event) {
|
||||
Ok(_) => {
|
||||
// Check if we should quit
|
||||
if tui_app.should_quit() {
|
||||
info!("Quit requested, exiting dashboard");
|
||||
break;
|
||||
match event {
|
||||
Event::Key(_) => {
|
||||
// Handle keyboard input
|
||||
match tui_app.handle_input(event) {
|
||||
Ok(_) => {
|
||||
// Check if we should quit
|
||||
if tui_app.should_quit() {
|
||||
info!("Quit requested, exiting dashboard");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error handling input: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error handling input: {}", e);
|
||||
Event::Mouse(mouse_event) => {
|
||||
// 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
|
||||
if let Some(ref mut terminal) = self.terminal {
|
||||
if let Some(ref mut tui_app) = self.tui_app {
|
||||
if let Err(e) = terminal.draw(|frame| {
|
||||
tui_app.render(frame, &self.metric_store);
|
||||
// Clear and autoresize terminal to handle any resize events
|
||||
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);
|
||||
}
|
||||
@ -251,8 +293,29 @@ impl Dashboard {
|
||||
if !self.headless {
|
||||
if let Some(ref mut terminal) = self.terminal {
|
||||
if let Some(ref mut tui_app) = self.tui_app {
|
||||
if let Err(e) = terminal.draw(|frame| {
|
||||
tui_app.render(frame, &self.metric_store);
|
||||
// Clear and autoresize terminal to handle any resize events
|
||||
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);
|
||||
break;
|
||||
@ -269,7 +332,372 @@ impl Dashboard {
|
||||
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 {
|
||||
@ -278,7 +706,7 @@ impl Drop for Dashboard {
|
||||
if !self.headless {
|
||||
let _ = disable_raw_mode();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,16 +86,6 @@ impl MetricStore {
|
||||
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)
|
||||
pub fn get_connected_hosts(&self, timeout: Duration) -> Vec<String> {
|
||||
let now = Instant::now();
|
||||
|
||||
@ -17,7 +17,7 @@ pub mod widgets;
|
||||
use crate::config::DashboardConfig;
|
||||
use crate::metrics::MetricStore;
|
||||
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};
|
||||
|
||||
|
||||
@ -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
|
||||
pub struct TuiApp {
|
||||
/// Widget states per host (hostname -> HostWidgets)
|
||||
host_widgets: HashMap<String, HostWidgets>,
|
||||
/// Current active host
|
||||
current_host: Option<String>,
|
||||
pub current_host: Option<String>,
|
||||
/// Available hosts
|
||||
available_hosts: Vec<String>,
|
||||
/// Host index for navigation
|
||||
@ -65,6 +74,8 @@ pub struct TuiApp {
|
||||
config: DashboardConfig,
|
||||
/// Cached localhost hostname to avoid repeated system calls
|
||||
localhost: String,
|
||||
/// Active popup menu (if any)
|
||||
pub popup_menu: Option<PopupMenu>,
|
||||
}
|
||||
|
||||
impl TuiApp {
|
||||
@ -79,6 +90,7 @@ impl TuiApp {
|
||||
user_navigated_away: false,
|
||||
config,
|
||||
localhost,
|
||||
popup_menu: None,
|
||||
};
|
||||
|
||||
// Sort predefined hosts
|
||||
@ -93,7 +105,7 @@ impl TuiApp {
|
||||
}
|
||||
|
||||
/// 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
|
||||
.entry(hostname.to_string())
|
||||
.or_insert_with(HostWidgets::new)
|
||||
@ -159,6 +171,14 @@ impl TuiApp {
|
||||
/// Handle keyboard input
|
||||
pub fn handle_input(&mut self, event: Event) -> Result<()> {
|
||||
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 {
|
||||
KeyCode::Char('q') => {
|
||||
self.should_quit = true;
|
||||
@ -363,6 +383,23 @@ impl TuiApp {
|
||||
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
|
||||
fn navigate_host(&mut self, direction: i32) {
|
||||
if self.available_hosts.is_empty() {
|
||||
@ -381,7 +418,7 @@ impl TuiApp {
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -390,7 +427,7 @@ impl TuiApp {
|
||||
self.user_navigated_away = false; // User navigated back to localhost
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
info!("Switched to host: {}", self.current_host.as_ref().unwrap());
|
||||
}
|
||||
|
||||
@ -408,6 +445,10 @@ impl TuiApp {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the list of available hosts
|
||||
pub fn get_available_hosts(&self) -> &Vec<String> {
|
||||
&self.available_hosts
|
||||
}
|
||||
|
||||
/// Should quit application
|
||||
pub fn should_quit(&self) -> bool {
|
||||
@ -421,7 +462,7 @@ impl TuiApp {
|
||||
|
||||
|
||||
/// 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();
|
||||
|
||||
// Clear background to true black like btop
|
||||
@ -461,8 +502,8 @@ impl TuiApp {
|
||||
if current_host_offline {
|
||||
self.render_offline_host_message(frame, main_chunks[1]);
|
||||
self.render_btop_title(frame, main_chunks[0], metric_store);
|
||||
self.render_statusbar(frame, main_chunks[2]);
|
||||
return;
|
||||
self.render_statusbar(frame, main_chunks[2], metric_store);
|
||||
return (main_chunks[0], Rect::default(), Rect::default()); // Return title area and empty areas when offline
|
||||
}
|
||||
|
||||
// Left side: system panel only (full height)
|
||||
@ -475,20 +516,29 @@ impl TuiApp {
|
||||
self.render_btop_title(frame, main_chunks[0], metric_store);
|
||||
|
||||
// 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
|
||||
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, 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
|
||||
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
|
||||
@ -556,7 +606,14 @@ impl TuiApp {
|
||||
));
|
||||
|
||||
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.clone(),
|
||||
Style::default()
|
||||
@ -564,6 +621,13 @@ impl TuiApp {
|
||||
.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(
|
||||
@ -597,36 +661,137 @@ impl TuiApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render dynamic statusbar with context-aware shortcuts
|
||||
fn render_statusbar(&self, frame: &mut Frame, area: Rect) {
|
||||
let shortcuts = self.get_context_shortcuts();
|
||||
let statusbar_text = shortcuts.join(" • ");
|
||||
|
||||
let statusbar = Paragraph::new(statusbar_text)
|
||||
.style(Typography::secondary())
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
|
||||
/// Render popup menu for service actions
|
||||
fn render_popup_menu(&self, frame: &mut Frame, popup: &PopupMenu) {
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem};
|
||||
use ratatui::style::{Color, Modifier};
|
||||
|
||||
// Menu items
|
||||
let items = vec![
|
||||
"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);
|
||||
}
|
||||
|
||||
/// Get context-aware shortcuts based on focused panel
|
||||
fn get_context_shortcuts(&self) -> Vec<String> {
|
||||
let mut shortcuts = Vec::new();
|
||||
|
||||
// Global shortcuts
|
||||
shortcuts.push("Tab: Host".to_string());
|
||||
shortcuts.push("↑↓/jk: Select".to_string());
|
||||
shortcuts.push("r: Rebuild".to_string());
|
||||
shortcuts.push("B: Backup".to_string());
|
||||
shortcuts.push("s/S: Start/Stop".to_string());
|
||||
shortcuts.push("L: Logs".to_string());
|
||||
shortcuts.push("t: Terminal".to_string());
|
||||
shortcuts.push("w: Wake".to_string());
|
||||
|
||||
// Always show quit
|
||||
shortcuts.push("q: Quit".to_string());
|
||||
|
||||
shortcuts
|
||||
/// Get local IP address of the dashboard
|
||||
fn get_local_ip() -> String {
|
||||
use std::net::UdpSocket;
|
||||
|
||||
// Try to get local IP by creating a UDP socket
|
||||
// This doesn't actually send data, just determines routing
|
||||
if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") {
|
||||
if socket.connect("8.8.8.8:80").is_ok() {
|
||||
if let Ok(addr) = socket.local_addr() {
|
||||
return addr.ip().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
"N/A".to_string()
|
||||
}
|
||||
|
||||
fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) {
|
||||
|
||||
@ -91,14 +91,17 @@ pub struct ServicesWidget {
|
||||
/// Last update indicator
|
||||
has_data: bool,
|
||||
/// 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)]
|
||||
struct ServiceInfo {
|
||||
metrics: Vec<(String, f32, Option<String>)>, // (label, value, unit)
|
||||
widget_status: Status,
|
||||
service_type: String, // "nginx_site", "container", "image", or empty for parent services
|
||||
memory_bytes: Option<u64>,
|
||||
restart_count: Option<u32>,
|
||||
uptime_seconds: Option<u64>,
|
||||
@ -112,6 +115,8 @@ impl ServicesWidget {
|
||||
status: Status::Unknown,
|
||||
has_data: false,
|
||||
selected_index: 0,
|
||||
scroll_offset: 0,
|
||||
last_viewport_height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@ -338,18 +343,86 @@ impl ServicesWidget {
|
||||
pub fn select_previous(&mut self) {
|
||||
if self.selected_index > 0 {
|
||||
self.selected_index -= 1;
|
||||
self.ensure_selected_visible();
|
||||
}
|
||||
debug!("Service selection moved up to: {}", self.selected_index);
|
||||
}
|
||||
|
||||
/// Move selection down
|
||||
/// Move selection down
|
||||
pub fn select_next(&mut self, total_services: usize) {
|
||||
if total_services > 0 && self.selected_index < total_services.saturating_sub(1) {
|
||||
self.selected_index += 1;
|
||||
self.ensure_selected_visible();
|
||||
}
|
||||
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)
|
||||
/// Only returns parent service names since only parent services can be selected
|
||||
pub fn get_selected_service(&self) -> Option<String> {
|
||||
@ -366,6 +439,81 @@ impl ServicesWidget {
|
||||
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
|
||||
fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize {
|
||||
@ -407,7 +555,6 @@ impl Widget for ServicesWidget {
|
||||
let parent_info = ServiceInfo {
|
||||
metrics: Vec::new(), // Parent services don't have custom metrics
|
||||
widget_status: service.service_status,
|
||||
service_type: String::new(), // Parent services have no type
|
||||
memory_bytes: service.memory_bytes,
|
||||
restart_count: service.restart_count,
|
||||
uptime_seconds: service.uptime_seconds,
|
||||
@ -426,7 +573,6 @@ impl Widget for ServicesWidget {
|
||||
let sub_info = ServiceInfo {
|
||||
metrics,
|
||||
widget_status: sub_service.service_status,
|
||||
service_type: sub_service.service_type.clone(),
|
||||
memory_bytes: None, // Sub-services don't have individual metrics yet
|
||||
restart_count: None,
|
||||
uptime_seconds: None,
|
||||
@ -471,7 +617,6 @@ impl ServicesWidget {
|
||||
.or_insert(ServiceInfo {
|
||||
metrics: Vec::new(),
|
||||
widget_status: Status::Unknown,
|
||||
service_type: String::new(),
|
||||
memory_bytes: None,
|
||||
restart_count: None,
|
||||
uptime_seconds: None,
|
||||
@ -500,7 +645,6 @@ impl ServicesWidget {
|
||||
ServiceInfo {
|
||||
metrics: Vec::new(),
|
||||
widget_status: Status::Unknown,
|
||||
service_type: String::new(), // Unknown type in legacy path
|
||||
memory_bytes: None,
|
||||
restart_count: None,
|
||||
uptime_seconds: None,
|
||||
@ -542,12 +686,23 @@ impl ServicesWidget {
|
||||
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!(
|
||||
"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.sub_services.len(),
|
||||
total_count,
|
||||
self.selected_index,
|
||||
self.scroll_offset,
|
||||
self.status
|
||||
);
|
||||
}
|
||||
@ -639,20 +794,46 @@ impl ServicesWidget {
|
||||
// Show only what fits, with "X more below" if needed
|
||||
let available_lines = area.height as usize;
|
||||
let total_lines = display_lines.len();
|
||||
|
||||
// Reserve one line for "X more below" if needed
|
||||
let lines_for_content = if total_lines > available_lines {
|
||||
|
||||
// Store viewport height for accurate scroll calculations
|
||||
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)
|
||||
} else {
|
||||
available_lines
|
||||
available_lines.min(remaining_lines)
|
||||
};
|
||||
|
||||
|
||||
// Apply scroll offset
|
||||
let visible_lines: Vec<_> = display_lines
|
||||
.iter()
|
||||
.skip(self.scroll_offset)
|
||||
.take(lines_for_content)
|
||||
.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();
|
||||
|
||||
@ -666,8 +847,8 @@ impl ServicesWidget {
|
||||
|
||||
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
|
||||
let is_selected = if !*is_sub {
|
||||
// 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
|
||||
if hidden_below > 0 {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
use cm_dashboard_shared::Status;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
widgets::Paragraph,
|
||||
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
|
||||
#[derive(Clone)]
|
||||
@ -49,6 +50,11 @@ pub struct SystemWidget {
|
||||
|
||||
// Overall status
|
||||
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)]
|
||||
@ -110,6 +116,8 @@ impl SystemWidget {
|
||||
backup_repository_status: Status::Unknown,
|
||||
backup_disks: Vec::new(),
|
||||
has_data: false,
|
||||
scroll_offset: 0,
|
||||
last_viewport_height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,6 +161,16 @@ impl SystemWidget {
|
||||
pub fn _get_agent_hash(&self) -> Option<&String> {
|
||||
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;
|
||||
@ -206,6 +224,16 @@ impl Widget for SystemWidget {
|
||||
self.backup_repositories = backup.repositories.clone();
|
||||
self.backup_repository_status = backup.repository_status;
|
||||
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
|
||||
pub fn render(&mut self, frame: &mut Frame, area: Rect, hostname: &str, _config: Option<&crate::config::DashboardConfig>) {
|
||||
let mut lines = Vec::new();
|
||||
/// Scroll down by one line
|
||||
pub fn scroll_down(&mut self, _visible_height: usize, _total_lines: usize) {
|
||||
let total_lines = self.get_total_lines();
|
||||
|
||||
// NixOS section
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("NixOS {}:", hostname), Typography::widget_title())
|
||||
]));
|
||||
|
||||
let build_text = self.nixos_build.as_deref().unwrap_or("unknown");
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("Build: {}", build_text), Typography::secondary())
|
||||
]));
|
||||
|
||||
let agent_version_text = self.agent_hash.as_deref().unwrap_or("unknown");
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary())
|
||||
]));
|
||||
// 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
|
||||
};
|
||||
|
||||
// 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 {
|
||||
total_lines - viewport_height
|
||||
} else {
|
||||
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
|
||||
lines.push(Line::from(vec![
|
||||
@ -905,29 +1000,51 @@ impl SystemWidget {
|
||||
// Apply scroll offset
|
||||
let total_lines = lines.len();
|
||||
let available_height = area.height as usize;
|
||||
|
||||
// Show only what fits, with "X more below" if needed
|
||||
if total_lines > available_height {
|
||||
let lines_for_content = available_height.saturating_sub(1); // Reserve one line for "more below"
|
||||
let mut visible_lines: Vec<Line> = lines
|
||||
.into_iter()
|
||||
.take(lines_for_content)
|
||||
.collect();
|
||||
|
||||
let hidden_below = total_lines.saturating_sub(lines_for_content);
|
||||
if hidden_below > 0 {
|
||||
let more_line = Line::from(vec![
|
||||
Span::styled(format!("... {} more below", hidden_below), Typography::muted())
|
||||
]);
|
||||
visible_lines.push(more_line);
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(Text::from(visible_lines));
|
||||
frame.render_widget(paragraph, area);
|
||||
|
||||
// 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_height);
|
||||
let clamped_scroll = self.scroll_offset.min(max_valid_scroll);
|
||||
|
||||
// Calculate how many lines remain after scroll offset
|
||||
let remaining_lines = total_lines.saturating_sub(clamped_scroll);
|
||||
|
||||
// Check if all remaining content fits in viewport
|
||||
let will_show_more_below = remaining_lines > available_height;
|
||||
|
||||
// Reserve one line for "X more below" only if we can't fit everything
|
||||
let lines_for_content = if will_show_more_below {
|
||||
available_height.saturating_sub(1)
|
||||
} else {
|
||||
// All content fits and no scroll offset, render normally
|
||||
let paragraph = Paragraph::new(Text::from(lines));
|
||||
frame.render_widget(paragraph, area);
|
||||
available_height.min(remaining_lines)
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.253"
|
||||
version = "0.1.263"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -38,6 +38,7 @@ pub struct NetworkInterfaceData {
|
||||
pub link_status: Status,
|
||||
pub parent_interface: Option<String>,
|
||||
pub vlan_id: Option<u16>,
|
||||
pub connection_method: Option<String>, // For Tailscale: "direct", "relay", or "proxy"
|
||||
}
|
||||
|
||||
/// CPU C-state usage information
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user