From fc247bd0ada54b4cb7bcbf2264ca33a132531878 Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Wed, 26 Nov 2025 19:02:50 +0100 Subject: [PATCH] Create dedicated network collector with physical/virtual interface grouping Move network collection from NixOS collector to dedicated NetworkCollector. Add link status detection for physical interfaces (up/down). Group interfaces by physical/virtual, show status icons for physical NICs only. Down interfaces show as Inactive instead of Critical. Version bump to 0.1.165 --- Cargo.lock | 6 +- agent/Cargo.toml | 2 +- agent/src/agent.rs | 7 +- agent/src/collectors/mod.rs | 1 + agent/src/collectors/network.rs | 137 +++++++++++++++++++++++++++++ agent/src/collectors/nixos.rs | 71 +-------------- dashboard/Cargo.toml | 2 +- dashboard/src/ui/widgets/system.rs | 46 +++++++++- shared/Cargo.toml | 2 +- shared/src/agent_data.rs | 3 + 10 files changed, 196 insertions(+), 81 deletions(-) create mode 100644 agent/src/collectors/network.rs diff --git a/Cargo.lock b/Cargo.lock index f0cb1a4..61b4208 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.164" +version = "0.1.165" dependencies = [ "anyhow", "chrono", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.164" +version = "0.1.165" dependencies = [ "anyhow", "async-trait", @@ -324,7 +324,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.164" +version = "0.1.165" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 189f00e..6c20f08 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.164" +version = "0.1.165" edition = "2021" [dependencies] diff --git a/agent/src/agent.rs b/agent/src/agent.rs index 143064c..d74a4c7 100644 --- a/agent/src/agent.rs +++ b/agent/src/agent.rs @@ -12,6 +12,7 @@ use crate::collectors::{ cpu::CpuCollector, disk::DiskCollector, memory::MemoryCollector, + network::NetworkCollector, nixos::NixOSCollector, systemd::SystemdCollector, }; @@ -77,7 +78,11 @@ impl Agent { if config.collectors.backup.enabled { collectors.push(Box::new(BackupCollector::new())); } - + + if config.collectors.network.enabled { + collectors.push(Box::new(NetworkCollector::new(config.collectors.network.clone()))); + } + if config.collectors.nixos.enabled { collectors.push(Box::new(NixOSCollector::new(config.collectors.nixos.clone()))); } diff --git a/agent/src/collectors/mod.rs b/agent/src/collectors/mod.rs index a729cfe..b969f7f 100644 --- a/agent/src/collectors/mod.rs +++ b/agent/src/collectors/mod.rs @@ -7,6 +7,7 @@ pub mod cpu; pub mod disk; pub mod error; pub mod memory; +pub mod network; pub mod nixos; pub mod systemd; diff --git a/agent/src/collectors/network.rs b/agent/src/collectors/network.rs new file mode 100644 index 0000000..3274ac8 --- /dev/null +++ b/agent/src/collectors/network.rs @@ -0,0 +1,137 @@ +use async_trait::async_trait; +use cm_dashboard_shared::{AgentData, NetworkInterfaceData, Status}; +use std::process::Command; +use tracing::debug; + +use super::{Collector, CollectorError}; +use crate::config::NetworkConfig; + +/// Network interface collector with physical/virtual classification and link status +pub struct NetworkCollector { + _config: NetworkConfig, +} + +impl NetworkCollector { + pub fn new(config: NetworkConfig) -> Self { + Self { _config: config } + } + + /// Check if interface is physical (not virtual) + fn is_physical_interface(name: &str) -> bool { + // Physical interface patterns + matches!( + &name[..], + s if s.starts_with("eth") + || s.starts_with("ens") + || s.starts_with("enp") + || s.starts_with("wlan") + || s.starts_with("wlp") + || s.starts_with("eno") + || s.starts_with("enx") + ) + } + + /// Get link status for an interface + fn get_link_status(interface: &str) -> Status { + let operstate_path = format!("/sys/class/net/{}/operstate", interface); + + match std::fs::read_to_string(&operstate_path) { + Ok(state) => { + let state = state.trim(); + match state { + "up" => Status::Ok, + "down" => Status::Inactive, + "unknown" => Status::Warning, + _ => Status::Unknown, + } + } + Err(_) => Status::Unknown, + } + } + + /// Collect network interfaces using ip command + async fn collect_interfaces(&self) -> Vec { + let mut interfaces = Vec::new(); + + match Command::new("ip").args(["-j", "addr"]).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::(&json_str) { + if let Some(ifaces) = json_data.as_array() { + for iface in ifaces { + let name = iface["ifname"].as_str().unwrap_or("").to_string(); + + // Skip loopback and empty names + if name.is_empty() || name == "lo" { + continue; + } + + let mut ipv4_addresses = Vec::new(); + let mut ipv6_addresses = Vec::new(); + + // Extract IP addresses + if let Some(addr_info) = iface["addr_info"].as_array() { + for addr in addr_info { + if let Some(family) = addr["family"].as_str() { + if let Some(local) = addr["local"].as_str() { + match family { + "inet" => ipv4_addresses.push(local.to_string()), + "inet6" => { + // Skip link-local IPv6 addresses (fe80::) + if !local.starts_with("fe80:") { + ipv6_addresses.push(local.to_string()); + } + } + _ => {} + } + } + } + } + } + + // Determine if physical and get status + let is_physical = Self::is_physical_interface(&name); + let link_status = if is_physical { + Self::get_link_status(&name) + } else { + Status::Unknown // Virtual interfaces don't have meaningful link status + }; + + interfaces.push(NetworkInterfaceData { + name, + ipv4_addresses, + ipv6_addresses, + is_physical, + link_status, + parent_interface: None, // TODO: Implement virtual interface parent detection + }); + } + } + } + } + Err(e) => { + debug!("Failed to execute ip command: {}", e); + } + Ok(output) => { + debug!("ip command failed with status: {}", output.status); + } + } + + interfaces + } +} + +#[async_trait] +impl Collector for NetworkCollector { + async fn collect_structured(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> { + debug!("Collecting network interface data"); + + // Collect all network interfaces + let interfaces = self.collect_interfaces().await; + + agent_data.system.network.interfaces = interfaces; + + Ok(()) + } +} diff --git a/agent/src/collectors/nixos.rs b/agent/src/collectors/nixos.rs index 183811e..ec9d246 100644 --- a/agent/src/collectors/nixos.rs +++ b/agent/src/collectors/nixos.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use cm_dashboard_shared::{AgentData, NetworkInterfaceData}; +use cm_dashboard_shared::AgentData; use std::fs; use std::process::Command; use tracing::debug; @@ -32,9 +32,6 @@ impl NixOSCollector { // Set NixOS build/generation information agent_data.build_version = self.get_nixos_generation().await; - // Collect network interfaces - agent_data.system.network.interfaces = self.get_network_interfaces().await; - // Set current timestamp agent_data.timestamp = chrono::Utc::now().timestamp() as u64; @@ -104,72 +101,6 @@ impl NixOSCollector { } } } - - /// Get network interfaces and their IP addresses - async fn get_network_interfaces(&self) -> Vec { - let mut interfaces = Vec::new(); - - // Use ip command with JSON output for easier parsing - match Command::new("ip").args(["-j", "addr"]).output() { - Ok(output) if output.status.success() => { - let json_str = String::from_utf8_lossy(&output.stdout); - - // Parse JSON output - if let Ok(json_data) = serde_json::from_str::(&json_str) { - if let Some(ifaces) = json_data.as_array() { - for iface in ifaces { - let name = iface["ifname"].as_str().unwrap_or("").to_string(); - - // Skip loopback and empty names - if name.is_empty() || name == "lo" { - continue; - } - - let mut ipv4_addresses = Vec::new(); - let mut ipv6_addresses = Vec::new(); - - // Extract IP addresses - if let Some(addr_info) = iface["addr_info"].as_array() { - for addr in addr_info { - if let Some(family) = addr["family"].as_str() { - if let Some(local) = addr["local"].as_str() { - match family { - "inet" => ipv4_addresses.push(local.to_string()), - "inet6" => { - // Skip link-local IPv6 addresses (fe80::) - if !local.starts_with("fe80:") { - ipv6_addresses.push(local.to_string()); - } - } - _ => {} - } - } - } - } - } - - // Only add interfaces that have at least one IP address - if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() { - interfaces.push(NetworkInterfaceData { - name, - ipv4_addresses, - ipv6_addresses, - }); - } - } - } - } - } - Err(e) => { - debug!("Failed to execute ip command: {}", e); - } - Ok(output) => { - debug!("ip command failed with status: {}", output.status); - } - } - - interfaces - } } #[async_trait] diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 9b13707..0e1b5cd 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.164" +version = "0.1.165" edition = "2021" [dependencies] diff --git a/dashboard/src/ui/widgets/system.rs b/dashboard/src/ui/widgets/system.rs index 8768323..66f00fa 100644 --- a/dashboard/src/ui/widgets/system.rs +++ b/dashboard/src/ui/widgets/system.rs @@ -616,7 +616,7 @@ impl SystemWidget { result.join(", ") } - /// Render network section for display + /// Render network section for display with physical/virtual grouping fn render_network(&self) -> Vec> { let mut lines = Vec::new(); @@ -624,11 +624,16 @@ impl SystemWidget { return lines; } - for (i, interface) in self.network_interfaces.iter().enumerate() { - let is_last = i == self.network_interfaces.len() - 1; + // Separate physical and virtual interfaces + let physical: Vec<_> = self.network_interfaces.iter().filter(|i| i.is_physical).collect(); + let virtual_interfaces: Vec<_> = self.network_interfaces.iter().filter(|i| !i.is_physical).collect(); + + // Render physical interfaces first + for (i, interface) in physical.iter().enumerate() { + let is_last = i == physical.len() - 1 && virtual_interfaces.is_empty(); let tree_symbol = if is_last { " └─ " } else { " ├─ " }; - // Show interface name + // Show interface name with IPs let mut interface_text = format!("{}: ", interface.name); // Add compressed IPv4 addresses @@ -644,6 +649,39 @@ impl SystemWidget { interface_text.push_str(&interface.ipv6_addresses.join(", ")); } + // Physical interfaces show status icon + let mut spans = vec![ + Span::styled(tree_symbol, Typography::tree()), + ]; + spans.extend(StatusIcons::create_status_spans( + interface.link_status.clone(), + &interface_text + )); + lines.push(Line::from(spans)); + } + + // Render virtual interfaces + for (i, interface) in virtual_interfaces.iter().enumerate() { + let is_last = i == virtual_interfaces.len() - 1; + let tree_symbol = if is_last { " └─ " } else { " ├─ " }; + + // Show interface name with IPs + let mut interface_text = format!("{}: ", interface.name); + + // Add compressed IPv4 addresses + if !interface.ipv4_addresses.is_empty() { + interface_text.push_str(&Self::compress_ipv4_addresses(&interface.ipv4_addresses)); + } + + // Add IPv6 addresses (no compression for now) + if !interface.ipv6_addresses.is_empty() { + if !interface.ipv4_addresses.is_empty() { + interface_text.push_str(", "); + } + interface_text.push_str(&interface.ipv6_addresses.join(", ")); + } + + // Virtual interfaces don't show status icon lines.push(Line::from(vec![ Span::styled(tree_symbol, Typography::tree()), Span::styled(interface_text, Typography::secondary()), diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 29bf71f..5c396d8 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.164" +version = "0.1.165" edition = "2021" [dependencies] diff --git a/shared/src/agent_data.rs b/shared/src/agent_data.rs index c93dc32..d93e7b2 100644 --- a/shared/src/agent_data.rs +++ b/shared/src/agent_data.rs @@ -34,6 +34,9 @@ pub struct NetworkInterfaceData { pub name: String, pub ipv4_addresses: Vec, pub ipv6_addresses: Vec, + pub is_physical: bool, + pub link_status: Status, + pub parent_interface: Option, } /// CPU monitoring data