Compare commits

..

4 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
6 changed files with 86 additions and 86 deletions

6
Cargo.lock generated
View File

@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]] [[package]]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.261" 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.261" 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.261" 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.262" version = "0.1.266"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -217,14 +217,15 @@ impl SystemdCollector {
} }
if service_name == "tailscaled" && status_info.active_state == "active" { if service_name == "tailscaled" && status_info.active_state == "active" {
// Add Tailscale connection method as sub-service // Add Tailscale peers with their connection methods as sub-services
if let Some(conn_method) = self.get_tailscale_connection_method() { let peers = self.get_tailscale_peers();
for (peer_name, conn_method) in peers {
let metrics = Vec::new(); let metrics = Vec::new();
sub_services.push(SubServiceData { sub_services.push(SubServiceData {
name: format!("Connection: {}", conn_method), name: format!("{}: {}", peer_name, conn_method),
service_status: Status::Info, service_status: Status::Info,
metrics, metrics,
service_type: "tailscale_connection".to_string(), service_type: "tailscale_peer".to_string(),
}); });
} }
} }
@@ -936,50 +937,77 @@ impl SystemdCollector {
None None
} }
/// Get Tailscale connection method (direct, relay, or proxy) /// Get Tailscale connected peers with their connection methods
fn get_tailscale_connection_method(&self) -> Option<String> { /// Returns a list of (device_name, connection_method) tuples
fn get_tailscale_peers(&self) -> Vec<(String, String)> {
match Command::new("timeout") match Command::new("timeout")
.args(["2", "tailscale", "status", "--json"]) .args(["2", "tailscale", "status"])
.output() .output()
{ {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => {
let json_str = String::from_utf8_lossy(&output.stdout); let status_output = String::from_utf8_lossy(&output.stdout);
let mut peers = Vec::new();
if let Ok(json_data) = serde_json::from_str::<serde_json::Value>(&json_str) { // Get current hostname to filter it out
// Look for the self peer (current node) in the peer list let current_hostname = gethostname::gethostname()
if let Some(peers) = json_data["Peer"].as_object() { .to_string_lossy()
// Find the first active peer connection to determine connection method .to_string();
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 // Parse tailscale status output
if let Some(endpoints) = peer_data["CurAddr"].as_str() { // Format: IP hostname user os status
if !endpoints.is_empty() { // Example: 100.110.98.3 wslbox cm@ linux active; direct 192.168.30.227:53757
return Some("direct".to_string()); // 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)
}
} }
// Check if using proxy from backend state let parts: Vec<&str> = line.split_whitespace().collect();
if let Some(backend_state) = json_data["BackendState"].as_str() { if parts.len() < 5 {
if backend_state == "Running" { continue; // Skip invalid lines
// If we're running but have no direct or relay, might be proxy
// This is a fallback heuristic
return Some("unknown".to_string());
}
} }
// 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()));
} }
None peers
} }
_ => None, _ => Vec::new(),
} }
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.262" 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

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