Compare commits

...

6 Commits

Author SHA1 Message Date
407bc9dbc2 Reduce CPU usage with conditional rendering
All checks were successful
Build and Release / build-and-release (push) Successful in 1m12s
Implement event-driven rendering to dramatically reduce CPU usage.
Only render when something actually changes instead of constantly
rendering at 20 FPS.

Changes:
- Increase poll timeout from 50ms to 200ms (5 FPS)
- Add needs_render flag to track when rendering is required
- Trigger rendering only on: user input, new metrics, heartbeat
  checks, or terminal resize events
- Reset render flag after each render cycle

Based on cm-player optimization approach.
2025-12-09 11:56:40 +01:00
3c278351c9 Filter out current host from Tailscale peer list
All checks were successful
Build and Release / build-and-release (push) Successful in 1m50s
Skip the first line in tailscale status output which is always the
current host showing as idle. Add additional hostname check to prevent
showing the current host in the peer list. Only display actual remote
peers with their connection methods.
2025-12-09 10:47:18 +01:00
8da4522d85 Fix Tailscale peer detection by parsing text output
All checks were successful
Build and Release / build-and-release (push) Successful in 1m13s
Replace JSON parsing with simpler text output parsing from tailscale
status command. The text format clearly shows hostname and connection
method (direct/relay/idle) making detection more reliable.

Fixes issues with incorrect hostname (localhost instead of actual name)
and incorrect connection method detection (showing relay when actually
using direct connection).
2025-12-09 10:34:55 +01:00
5b1e39cfca Show all connected Tailscale peers with connection methods
All checks were successful
Build and Release / build-and-release (push) Successful in 1m38s
Replace single connection method display with individual sub-service
rows for each online Tailscale peer. Each peer shows hostname and
connection type (direct, relay, or idle) allowing monitoring of all
connected devices and their connection quality.

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

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

Query tailscale status --json to determine active connection type and
display as informational sub-service when tailscaled is active.
2025-12-08 21:01:47 +01:00
10 changed files with 181 additions and 66 deletions

6
Cargo.lock generated
View File

@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]] [[package]]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.259" version = "0.1.265"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -301,7 +301,7 @@ dependencies = [
[[package]] [[package]]
name = "cm-dashboard-agent" name = "cm-dashboard-agent"
version = "0.1.259" version = "0.1.265"
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.259" version = "0.1.265"
dependencies = [ dependencies = [
"chrono", "chrono",
"serde", "serde",

View File

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

View File

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

View File

@@ -216,6 +216,20 @@ impl SystemdCollector {
} }
} }
if service_name == "tailscaled" && status_info.active_state == "active" {
// Add Tailscale peers with their connection methods as sub-services
let peers = self.get_tailscale_peers();
for (peer_name, conn_method) in peers {
let metrics = Vec::new();
sub_services.push(SubServiceData {
name: format!("{}: {}", peer_name, conn_method),
service_status: Status::Info,
metrics,
service_type: "tailscale_peer".to_string(),
});
}
}
// Create complete service data // Create complete service data
let service_data = ServiceData { let service_data = ServiceData {
name: service_name.clone(), name: service_name.clone(),
@@ -923,6 +937,80 @@ impl SystemdCollector {
None None
} }
/// Get Tailscale connected peers with their connection methods
/// Returns a list of (device_name, connection_method) tuples
fn get_tailscale_peers(&self) -> Vec<(String, String)> {
match Command::new("timeout")
.args(["2", "tailscale", "status"])
.output()
{
Ok(output) if output.status.success() => {
let status_output = String::from_utf8_lossy(&output.stdout);
let mut peers = Vec::new();
// Get current hostname to filter it out
let current_hostname = gethostname::gethostname()
.to_string_lossy()
.to_string();
// Parse tailscale status output
// Format: IP hostname user os status
// Example: 100.110.98.3 wslbox cm@ linux active; direct 192.168.30.227:53757
// Note: First line is always the current host, skip it
for (idx, line) in status_output.lines().enumerate() {
if idx == 0 {
continue; // Skip first line (current host)
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 5 {
continue; // Skip invalid lines
}
// parts[0] = IP
// parts[1] = hostname
// parts[2] = user
// parts[3] = OS
// parts[4+] = status (e.g., "active;", "direct", "192.168.30.227:53757" or "idle;" or "offline")
let hostname = parts[1];
// Skip if this is the current host (double-check in case format changes)
if hostname == current_hostname {
continue;
}
let status_parts = &parts[4..];
// Determine connection method from status
let connection_method = if status_parts.is_empty() {
continue; // Skip if no status
} else {
let status_str = status_parts.join(" ");
if status_str.contains("offline") {
continue; // Skip offline peers
} else if status_str.contains("direct") {
"direct"
} else if status_str.contains("relay") {
"relay"
} else if status_str.contains("idle") {
"idle"
} else if status_str.contains("active") {
"active"
} else {
continue; // Skip unknown status
}
};
peers.push((hostname.to_string(), connection_method.to_string()));
}
peers
}
_ => Vec::new(),
}
}
/// Get nftables open ports grouped by protocol /// Get nftables open ports grouped by protocol
/// Returns: (tcp_ports_string, udp_ports_string) /// Returns: (tcp_ports_string, udp_ports_string)
fn get_nftables_open_ports(&self) -> (String, String) { fn get_nftables_open_ports(&self) -> (String, String) {

View File

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

View File

@@ -138,11 +138,12 @@ impl Dashboard {
let metrics_check_interval = Duration::from_millis(100); // Check for metrics every 100ms let metrics_check_interval = Duration::from_millis(100); // Check for metrics every 100ms
let mut last_heartbeat_check = Instant::now(); let mut last_heartbeat_check = Instant::now();
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
let mut needs_render = true; // Track if we need to render
loop { loop {
// Handle terminal events (keyboard and mouse 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(200)) {
Ok(true) => { Ok(true) => {
match event::read() { match event::read() {
Ok(event) => { Ok(event) => {
@@ -152,6 +153,7 @@ impl Dashboard {
// Handle keyboard input // Handle keyboard input
match tui_app.handle_input(event) { match tui_app.handle_input(event) {
Ok(_) => { Ok(_) => {
needs_render = true;
// Check if we should quit // Check if we should quit
if tui_app.should_quit() { if tui_app.should_quit() {
info!("Quit requested, exiting dashboard"); info!("Quit requested, exiting dashboard");
@@ -168,10 +170,11 @@ impl Dashboard {
if let Err(e) = self.handle_mouse_event(mouse_event) { if let Err(e) = self.handle_mouse_event(mouse_event) {
error!("Error handling mouse event: {}", e); error!("Error handling mouse event: {}", e);
} }
needs_render = true;
} }
Event::Resize(_width, _height) => { Event::Resize(_width, _height) => {
// Terminal was resized - just continue and re-render // Terminal was resized - mark for re-render
// The next render will automatically use the new size needs_render = true;
} }
_ => {} _ => {}
} }
@@ -189,38 +192,6 @@ impl Dashboard {
break; break;
} }
} }
// 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 {
// 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);
}
}
}
} }
// Check for new metrics // Check for new metrics
@@ -259,8 +230,10 @@ impl Dashboard {
if let Some(ref mut tui_app) = self.tui_app { if let Some(ref mut tui_app) = self.tui_app {
tui_app.update_metrics(&mut self.metric_store); tui_app.update_metrics(&mut self.metric_store);
} }
needs_render = true; // New metrics received, need to render
} }
// Also check for command output messages // Also check for command output messages
if let Ok(Some(cmd_output)) = self.zmq_consumer.receive_command_output().await { if let Ok(Some(cmd_output)) = self.zmq_consumer.receive_command_output().await {
debug!( debug!(
@@ -271,26 +244,27 @@ impl Dashboard {
// Command output (terminal popup removed - output not displayed) // Command output (terminal popup removed - output not displayed)
} }
last_metrics_check = Instant::now(); last_metrics_check = Instant::now();
} }
// Check for host connectivity changes (heartbeat timeouts) periodically // Check for host connectivity changes (heartbeat timeouts) periodically
if last_heartbeat_check.elapsed() >= heartbeat_check_interval { if last_heartbeat_check.elapsed() >= heartbeat_check_interval {
let timeout = Duration::from_secs(self.config.zmq.heartbeat_timeout_seconds); let timeout = Duration::from_secs(self.config.zmq.heartbeat_timeout_seconds);
// Clean up metrics for offline hosts // Clean up metrics for offline hosts
self.metric_store.cleanup_offline_hosts(timeout); self.metric_store.cleanup_offline_hosts(timeout);
if let Some(ref mut tui_app) = self.tui_app { if let Some(ref mut tui_app) = self.tui_app {
let connected_hosts = self.metric_store.get_connected_hosts(timeout); let connected_hosts = self.metric_store.get_connected_hosts(timeout);
tui_app.update_hosts(connected_hosts); tui_app.update_hosts(connected_hosts);
} }
last_heartbeat_check = Instant::now(); last_heartbeat_check = Instant::now();
needs_render = true; // Heartbeat check happened, may have changed hosts
} }
// Render TUI (only if not headless) // Render TUI only when needed (not headless and something changed)
if !self.headless { if !self.headless && needs_render {
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 {
// Clear and autoresize terminal to handle any resize events // Clear and autoresize terminal to handle any resize events
@@ -322,10 +296,8 @@ impl Dashboard {
} }
} }
} }
needs_render = false; // Reset flag after rendering
} }
// Small sleep to prevent excessive CPU usage
tokio::time::sleep(Duration::from_millis(10)).await;
} }
info!("Dashboard main loop ended"); info!("Dashboard main loop ended");

View File

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

View File

@@ -102,7 +102,6 @@ pub struct ServicesWidget {
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>,
@@ -344,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> {
@@ -488,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,
@@ -507,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,
@@ -552,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,
@@ -581,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,

View File

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

View File

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