Compare commits

..

2 Commits

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

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

Remove unused get_zmq_stats method and service_type field to eliminate
compilation warnings and dead code.
2025-12-08 23:10:57 +01:00
7 changed files with 117 additions and 50 deletions

6
Cargo.lock generated
View File

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

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-dashboard-agent" name = "cm-dashboard-agent"
version = "0.1.261" version = "0.1.263"
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,63 @@ 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", "--json"])
.output() .output()
{ {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => {
let json_str = String::from_utf8_lossy(&output.stdout); let json_str = String::from_utf8_lossy(&output.stdout);
let mut peers = Vec::new();
if let Ok(json_data) = serde_json::from_str::<serde_json::Value>(&json_str) { 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 // Look for the self peer (current node) in the peer list
if let Some(peers) = json_data["Peer"].as_object() { if let Some(peer_map) = json_data["Peer"].as_object() {
// Find the first active peer connection to determine connection method // Iterate through all peers
for (_peer_id, peer_data) in peers { for (_peer_id, peer_data) in peer_map {
if peer_data["Active"].as_bool().unwrap_or(false) { // Only include active/online peers
if !peer_data["Online"].as_bool().unwrap_or(false) {
continue;
}
// Get peer hostname or DNS name
let peer_name = peer_data["HostName"]
.as_str()
.or_else(|| peer_data["DNSName"].as_str())
.unwrap_or("unknown")
.trim_end_matches('.')
.to_string();
// Determine connection method
let connection_method = if peer_data["Active"].as_bool().unwrap_or(false) {
// Check if using relay // Check if using relay
let relay_node = peer_data["Relay"].as_str().unwrap_or(""); let relay_node = peer_data["Relay"].as_str().unwrap_or("");
if !relay_node.is_empty() { if !relay_node.is_empty() {
return Some("relay".to_string()); "relay"
} } else if let Some(cur_addr) = peer_data["CurAddr"].as_str() {
// Check if using direct connection
// Check if using direct connection if !cur_addr.is_empty() {
if let Some(endpoints) = peer_data["CurAddr"].as_str() { "direct"
if !endpoints.is_empty() { } else {
return Some("direct".to_string()); "unknown"
} }
} else {
"unknown"
} }
} } else {
} "idle"
} };
// Check if using proxy from backend state peers.push((peer_name, connection_method.to_string()));
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 peers
} }
_ => None, _ => Vec::new(),
} }
} }

View File

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

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.261" version = "0.1.263"
edition = "2021" edition = "2021"
[dependencies] [dependencies]