Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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]]
|
[[package]]
|
||||||
name = "cm-dashboard"
|
name = "cm-dashboard"
|
||||||
version = "0.1.252"
|
version = "0.1.261"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -301,7 +301,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard-agent"
|
name = "cm-dashboard-agent"
|
||||||
version = "0.1.252"
|
version = "0.1.261"
|
||||||
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.252"
|
version = "0.1.261"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard-agent"
|
name = "cm-dashboard-agent"
|
||||||
version = "0.1.253"
|
version = "0.1.262"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -282,8 +282,8 @@ impl Collector for CpuCollector {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate status using thresholds
|
// 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_1min);
|
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 {
|
agent_data.system.cpu.temperature_status = if let Some(temp) = agent_data.system.cpu.temperature_celsius {
|
||||||
self.calculate_temperature_status(temp)
|
self.calculate_temperature_status(temp)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -181,6 +181,7 @@ impl NetworkCollector {
|
|||||||
link_status,
|
link_status,
|
||||||
parent_interface,
|
parent_interface,
|
||||||
vlan_id,
|
vlan_id,
|
||||||
|
connection_method: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,19 @@ impl SystemdCollector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if service_name == "tailscaled" && status_info.active_state == "active" {
|
||||||
|
// Add Tailscale connection method as sub-service
|
||||||
|
if let Some(conn_method) = self.get_tailscale_connection_method() {
|
||||||
|
let metrics = Vec::new();
|
||||||
|
sub_services.push(SubServiceData {
|
||||||
|
name: format!("Connection: {}", conn_method),
|
||||||
|
service_status: Status::Info,
|
||||||
|
metrics,
|
||||||
|
service_type: "tailscale_connection".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 +936,58 @@ impl SystemdCollector {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get Tailscale connection method (direct, relay, or proxy)
|
||||||
|
fn get_tailscale_connection_method(&self) -> Option<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);
|
||||||
|
|
||||||
|
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(peers) = json_data["Peer"].as_object() {
|
||||||
|
// Find the first active peer connection to determine connection method
|
||||||
|
for (_peer_id, peer_data) in peers {
|
||||||
|
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() {
|
||||||
|
return Some("relay".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if using direct connection
|
||||||
|
if let Some(endpoints) = peer_data["CurAddr"].as_str() {
|
||||||
|
if !endpoints.is_empty() {
|
||||||
|
return Some("direct".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if using proxy from backend state
|
||||||
|
if let Some(backend_state) = json_data["BackendState"].as_str() {
|
||||||
|
if backend_state == "Running" {
|
||||||
|
// If we're running but have no direct or relay, might be proxy
|
||||||
|
// This is a fallback heuristic
|
||||||
|
return Some("unknown".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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", "nft", "list", "ruleset"])
|
.args(&["/run/current-system/sw/bin/nft", "list", "ruleset"])
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
let output = match output {
|
let output = match output {
|
||||||
@ -1088,6 +1160,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]
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard"
|
name = "cm-dashboard"
|
||||||
version = "0.1.253"
|
version = "0.1.262"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard-shared"
|
name = "cm-dashboard-shared"
|
||||||
version = "0.1.253"
|
version = "0.1.262"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user