Compare commits

...

3 Commits

Author SHA1 Message Date
1656f20e96 Fix NFS export parsing to handle both inline and continuation formats
All checks were successful
Build and Release / build-and-release (push) Successful in 1m20s
Support exportfs output where network info appears on same line as path
(e.g. /srv/media/tv 192.168.0.0/16(...)) in addition to continuation
line format. Ensures all NFS exports are detected correctly.
2025-12-11 11:10:59 +01:00
dcd350ec2c Add NFS export permissions and network display, fix SMB service detection
All checks were successful
Build and Release / build-and-release (push) Successful in 1m13s
Display NFS exports with ro/rw permissions and network ranges for better
visibility into share configuration. Support both smbd and samba-smbd
service names for SMB share detection across different distributions.
2025-12-11 10:59:00 +01:00
a34b095857 Simplify NFS export options display
All checks were successful
Build and Release / build-and-release (push) Successful in 1m14s
Filter NFS export options to show only key settings (rw/ro, sync/async)
instead of verbose option strings. Improves readability while maintaining
essential information about export configuration.
2025-12-11 10:26:27 +01:00
5 changed files with 84 additions and 54 deletions

6
Cargo.lock generated
View File

@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]] [[package]]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.271" version = "0.1.274"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -301,7 +301,7 @@ dependencies = [
[[package]] [[package]]
name = "cm-dashboard-agent" name = "cm-dashboard-agent"
version = "0.1.271" version = "0.1.274"
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.271" version = "0.1.274"
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.272" version = "0.1.275"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -233,23 +233,22 @@ impl SystemdCollector {
if service_name == "nfs-server" && status_info.active_state == "active" { if service_name == "nfs-server" && status_info.active_state == "active" {
// Add NFS exports as sub-services // Add NFS exports as sub-services
let exports = self.get_nfs_exports(); let exports = self.get_nfs_exports();
for (export_path, options) in exports { for (export_path, info) in exports {
let metrics = Vec::new(); let display = if !info.is_empty() {
let display = if !options.is_empty() { format!("{} {}", export_path, info)
format!("{} ({})", export_path, options)
} else { } else {
export_path export_path
}; };
sub_services.push(SubServiceData { sub_services.push(SubServiceData {
name: display, name: display,
service_status: Status::Info, service_status: Status::Info,
metrics, metrics: Vec::new(),
service_type: "nfs_export".to_string(), service_type: "nfs_export".to_string(),
}); });
} }
} }
if service_name == "smbd" && status_info.active_state == "active" { if (service_name == "smbd" || service_name == "samba-smbd") && status_info.active_state == "active" {
// Add SMB shares as sub-services // Add SMB shares as sub-services
let shares = self.get_smb_shares(); let shares = self.get_smb_shares();
for (share_name, share_path) in shares { for (share_name, share_path) in shares {
@@ -1045,61 +1044,92 @@ impl SystemdCollector {
} }
/// Get NFS exports from exportfs /// Get NFS exports from exportfs
/// Returns a list of (export_path, options) tuples /// Returns a list of (export_path, info_string) tuples
fn get_nfs_exports(&self) -> Vec<(String, String)> { fn get_nfs_exports(&self) -> Vec<(String, String)> {
match Command::new("timeout") let output = match Command::new("timeout")
.args(["2", "exportfs", "-v"]) .args(["2", "exportfs", "-v"])
.output() .output()
{ {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => output,
let exports_output = String::from_utf8_lossy(&output.stdout); _ => return Vec::new(),
let mut exports_map: std::collections::HashMap<String, String> = std::collections::HashMap::new(); };
for line in exports_output.lines() { let exports_output = String::from_utf8_lossy(&output.stdout);
let line = line.trim(); let mut exports_map: std::collections::HashMap<String, Vec<(String, String)>> =
if line.is_empty() || line.starts_with('#') { std::collections::HashMap::new();
continue; let mut current_path: Option<String> = None;
}
// Format: "/path/to/export hostname(options)" or "/path/to/export 192.168.1.0/24(options)" for line in exports_output.lines() {
// exportfs -v shows each export once per client/network let trimmed = line.trim();
// We want to deduplicate by path and just show one entry
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
continue;
}
let export_path = parts[0].to_string(); if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Extract options from parentheses (from the client specification) if trimmed.starts_with('/') {
let options = if parts.len() > 1 { // Export path line - may have network on same line or continuation
let client_spec = parts[1]; let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect();
if let Some(start) = client_spec.find('(') { let path = parts[0].to_string();
if let Some(end) = client_spec.find(')') { current_path = Some(path.clone());
client_spec[start+1..end].to_string()
// Check if network info is on the same line
if parts.len() > 1 {
let rest = parts[1].trim();
if let Some(paren_pos) = rest.find('(') {
let network = rest[..paren_pos].trim();
if let Some(end_paren) = rest.find(')') {
let options = &rest[paren_pos+1..end_paren];
let mode = if options.contains(",rw,") || options.ends_with(",rw") {
"rw"
} else { } else {
String::new() "ro"
} };
} else {
String::new()
}
} else {
String::new()
};
// Only store the first occurrence of each path (deduplicates multiple clients) exports_map.entry(path)
exports_map.entry(export_path).or_insert(options); .or_insert_with(Vec::new)
.push((network.to_string(), mode.to_string()));
}
}
}
} else if let Some(ref path) = current_path {
// Continuation line with network and options
if let Some(paren_pos) = trimmed.find('(') {
let network = trimmed[..paren_pos].trim();
if let Some(end_paren) = trimmed.find(')') {
let options = &trimmed[paren_pos+1..end_paren];
let mode = if options.contains(",rw,") || options.ends_with(",rw") {
"rw"
} else {
"ro"
};
exports_map.entry(path.clone())
.or_insert_with(Vec::new)
.push((network.to_string(), mode.to_string()));
}
}
}
}
// Build display strings: "path: mode [networks]"
let mut exports: Vec<(String, String)> = exports_map
.into_iter()
.map(|(path, mut entries)| {
if entries.is_empty() {
return (path, String::new());
} }
// Convert HashMap to Vec let mode = entries[0].1.clone();
let mut exports: Vec<(String, String)> = exports_map.into_iter().collect(); let networks: Vec<String> = entries.drain(..).map(|(n, _)| n).collect();
// Sort by path for consistent display let info = format!("{} [{}]", mode, networks.join(", "));
exports.sort_by(|a, b| a.0.cmp(&b.0)); (path, info)
})
.collect();
exports exports.sort_by(|a, b| a.0.cmp(&b.0));
} exports
_ => Vec::new(),
}
} }
/// Get SMB shares from smb.conf /// Get SMB shares from smb.conf

View File

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

View File

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