From c8b79576fa327bd593e92967c5beb04819090fe3 Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Thu, 11 Dec 2025 09:30:06 +0100 Subject: [PATCH] Add NFS/SMB share monitoring and increase disk timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sub-service display for NFS exports and SMB shares under their respective services. NFS shows active exports from exportfs with options. SMB shows configured shares from smb.conf with paths. Increase disk operation timeouts to handle multiple drives: - lsblk: 2s → 10s - smartctl: 3s → 15s (critical for multi-drive systems) - df: 2s → 10s Prevents timeouts when querying SMART data from systems with multiple drives (3+ data drives plus parity). --- Cargo.lock | 6 +- agent/Cargo.toml | 2 +- agent/src/collectors/disk.rs | 8 +- agent/src/collectors/systemd.rs | 137 ++++++++++++++++++++++++++++++++ dashboard/Cargo.toml | 2 +- shared/Cargo.toml | 2 +- 6 files changed, 147 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c7240f9..76f7d97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.269" +version = "0.1.270" dependencies = [ "anyhow", "chrono", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.269" +version = "0.1.270" dependencies = [ "anyhow", "async-trait", @@ -325,7 +325,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.269" +version = "0.1.270" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index dd892dc..2136920 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.270" +version = "0.1.271" edition = "2021" [dependencies] diff --git a/agent/src/collectors/disk.rs b/agent/src/collectors/disk.rs index a3c92df..5dc82f5 100644 --- a/agent/src/collectors/disk.rs +++ b/agent/src/collectors/disk.rs @@ -114,7 +114,7 @@ impl DiskCollector { let mut cmd = TokioCommand::new("lsblk"); cmd.args(&["-rn", "-o", "NAME,MOUNTPOINT"]); - let output = run_command_with_timeout(cmd, 2).await + let output = run_command_with_timeout(cmd, 10).await .map_err(|e| CollectorError::SystemRead { path: "block devices".to_string(), error: e.to_string(), @@ -184,7 +184,7 @@ impl DiskCollector { /// Get filesystem info for a single mount point fn get_filesystem_info(&self, mount_point: &str) -> Result<(u64, u64), CollectorError> { let output = StdCommand::new("timeout") - .args(&["2", "df", "--block-size=1", mount_point]) + .args(&["10", "df", "--block-size=1", mount_point]) .output() .map_err(|e| CollectorError::SystemRead { path: format!("df {}", mount_point), @@ -433,7 +433,7 @@ impl DiskCollector { cmd.args(&["-a", &format!("/dev/{}", drive_name)]); } - let output = run_command_with_timeout(cmd, 3).await + let output = run_command_with_timeout(cmd, 15).await .map_err(|e| CollectorError::SystemRead { path: format!("SMART data for {}", drive_name), error: e.to_string(), @@ -772,7 +772,7 @@ impl DiskCollector { fn get_drive_info_for_path(&self, path: &str) -> anyhow::Result { // Use lsblk to find the backing device with timeout let output = StdCommand::new("timeout") - .args(&["2", "lsblk", "-rn", "-o", "NAME,MOUNTPOINT"]) + .args(&["10", "lsblk", "-rn", "-o", "NAME,MOUNTPOINT"]) .output() .map_err(|e| anyhow::anyhow!("Failed to run lsblk: {}", e))?; diff --git a/agent/src/collectors/systemd.rs b/agent/src/collectors/systemd.rs index 78a4892..eba2d1e 100644 --- a/agent/src/collectors/systemd.rs +++ b/agent/src/collectors/systemd.rs @@ -230,6 +230,39 @@ impl SystemdCollector { } } + if service_name == "nfs-server" && status_info.active_state == "active" { + // Add NFS exports as sub-services + let exports = self.get_nfs_exports(); + for (export_path, options) in exports { + let metrics = Vec::new(); + let display = if !options.is_empty() { + format!("{} ({})", export_path, options) + } else { + export_path + }; + sub_services.push(SubServiceData { + name: display, + service_status: Status::Info, + metrics, + service_type: "nfs_export".to_string(), + }); + } + } + + if service_name == "smbd" && status_info.active_state == "active" { + // Add SMB shares as sub-services + let shares = self.get_smb_shares(); + for (share_name, share_path) in shares { + let metrics = Vec::new(); + sub_services.push(SubServiceData { + name: format!("{}: {}", share_name, share_path), + service_status: Status::Info, + metrics, + service_type: "smb_share".to_string(), + }); + } + } + // Create complete service data let service_data = ServiceData { name: service_name.clone(), @@ -1011,6 +1044,110 @@ impl SystemdCollector { } } + /// Get NFS exports from exportfs + /// Returns a list of (export_path, options) tuples + fn get_nfs_exports(&self) -> Vec<(String, String)> { + match Command::new("timeout") + .args(["2", "exportfs", "-v"]) + .output() + { + Ok(output) if output.status.success() => { + let exports_output = String::from_utf8_lossy(&output.stdout); + let mut exports = Vec::new(); + + for line in exports_output.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Format: "/path/to/export hostname(options)" + // We want just the path and a summary of options + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.is_empty() { + continue; + } + + let export_path = parts[0].to_string(); + + // Extract options from parentheses (simplified) + let options = if parts.len() > 1 { + let opts_str = parts[1..].join(" "); + if let Some(start) = opts_str.find('(') { + if let Some(end) = opts_str.find(')') { + opts_str[start+1..end].to_string() + } else { + String::new() + } + } else { + String::new() + } + } else { + String::new() + }; + + exports.push((export_path, options)); + } + + exports + } + _ => Vec::new(), + } + } + + /// Get SMB shares from smb.conf + /// Returns a list of (share_name, share_path) tuples + fn get_smb_shares(&self) -> Vec<(String, String)> { + match std::fs::read_to_string("/etc/samba/smb.conf") { + Ok(config) => { + let mut shares = Vec::new(); + let mut current_share: Option = None; + let mut current_path: Option = None; + + for line in config.lines() { + let line = line.trim(); + + // Skip comments and empty lines + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + + // Detect share section [sharename] + if line.starts_with('[') && line.ends_with(']') { + // Save previous share if we have both name and path + if let (Some(name), Some(path)) = (current_share.take(), current_path.take()) { + // Skip special sections + if name != "global" && name != "homes" && name != "printers" { + shares.push((name, path)); + } + } + + // Start new share + let share_name = line[1..line.len()-1].trim().to_string(); + current_share = Some(share_name); + current_path = None; + } + // Look for path = /some/path + else if line.starts_with("path") && line.contains('=') { + if let Some(path_value) = line.split('=').nth(1) { + current_path = Some(path_value.trim().to_string()); + } + } + } + + // Don't forget the last share + if let (Some(name), Some(path)) = (current_share, current_path) { + if name != "global" && name != "homes" && name != "printers" { + shares.push((name, path)); + } + } + + shares + } + _ => Vec::new(), + } + } + /// Get nftables open ports grouped by protocol /// Returns: (tcp_ports_string, udp_ports_string) fn get_nftables_open_ports(&self) -> (String, String) { diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 6da30f5..30fdd2e 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.270" +version = "0.1.271" edition = "2021" [dependencies] diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 4b8ea59..f69d2c4 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.270" +version = "0.1.271" edition = "2021" [dependencies]