Compare commits

...

5 Commits

Author SHA1 Message Date
192eea6e0c Integrate SnapRAID parity drives into mergerfs pools
All checks were successful
Build and Release / build-and-release (push) Successful in 1m19s
- Add SnapRAID parity drive detection to mergerfs discovery
- Remove Pool Status health line as discussed
- Update drive display to always show wear data when available
- Include /mnt/parity drives as part of mergerfs pool structure
2025-11-23 18:05:19 +01:00
43fb838c9b Fix duplicate drive display in mergerfs pools
All checks were successful
Build and Release / build-and-release (push) Successful in 2m9s
- Restructure storage rendering logic to prevent drive duplication
- Use specific mergerfs check instead of generic multi-drive condition
- Ensure drives only appear once under organized data/parity sections
2025-11-23 17:46:09 +01:00
54483653f9 Fix mergerfs drive metric parsing for proper pool consolidation
All checks were successful
Build and Release / build-and-release (push) Successful in 2m11s
- Update extract_pool_name to handle data_/parity_ drive metrics correctly
- Fix extract_drive_name to parse mergerfs drive roles properly
- Prevent srv_media_data from being parsed as separate pool
2025-11-23 17:40:12 +01:00
e47803b705 Fix mergerfs pool consolidation and naming
All checks were successful
Build and Release / build-and-release (push) Successful in 1m18s
- Improve pool name extraction in dashboard parsing
- Use consistent mergerfs pool naming in agent
- Add mount_point metric parsing to use actual mount paths
- Fix pool consolidation to prevent duplicate entries
2025-11-23 17:35:23 +01:00
439d0d9af6 Fix mergerfs numeric reference parsing for proper pool detection
All checks were successful
Build and Release / build-and-release (push) Successful in 2m11s
Add support for numeric mergerfs references like "1:2" by mapping them
to actual mount points (/mnt/disk1, /mnt/disk2). This enables proper
mergerfs pool detection and hides individual member drives as intended.
2025-11-23 17:27:45 +01:00
6 changed files with 129 additions and 65 deletions

6
Cargo.lock generated
View File

@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]] [[package]]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.119" version = "0.1.124"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -301,7 +301,7 @@ dependencies = [
[[package]] [[package]]
name = "cm-dashboard-agent" name = "cm-dashboard-agent"
version = "0.1.119" version = "0.1.124"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -324,7 +324,7 @@ dependencies = [
[[package]] [[package]]
name = "cm-dashboard-shared" name = "cm-dashboard-shared"
version = "0.1.119" version = "0.1.124"
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.119" version = "0.1.124"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -203,17 +203,24 @@ impl DiskCollector {
.unwrap_or((0, 0)); .unwrap_or((0, 0));
// Parse member paths - handle both full paths and numeric references // Parse member paths - handle both full paths and numeric references
let member_paths: Vec<String> = device_sources let raw_paths: Vec<String> = device_sources
.split(':') .split(':')
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.collect(); .collect();
// Skip this pool if we can't parse the member paths (e.g., numeric references like "1:2") // Convert numeric references to actual mount points if needed
if member_paths.iter().any(|path| !path.starts_with('/')) { let mut member_paths = if raw_paths.iter().any(|path| !path.starts_with('/')) {
debug!("Skipping mergerfs pool {} with unparseable member paths: {:?}", mount_point, member_paths); // Handle numeric format like "1:2" by finding corresponding /mnt/disk* paths
continue; self.resolve_numeric_mergerfs_paths(&raw_paths)?
} } else {
// Already full paths
raw_paths
};
// For SnapRAID setups, also include parity drives as part of the pool
let snapraid_parity_paths = self.discover_snapraid_parity_drives()?;
member_paths.extend(snapraid_parity_paths);
// Categorize as data vs parity drives // Categorize as data vs parity drives
let (data_drives, parity_drives) = match self.categorize_pool_drives(&member_paths) { let (data_drives, parity_drives) = match self.categorize_pool_drives(&member_paths) {
@@ -237,6 +244,16 @@ impl DiskCollector {
Ok(pools) Ok(pools)
} }
/// Discover SnapRAID parity drives
fn discover_snapraid_parity_drives(&self) -> Result<Vec<String>> {
let mount_devices = self.get_mount_devices()?;
let parity_paths: Vec<String> = mount_devices.keys()
.filter(|path| path.contains("parity"))
.cloned()
.collect();
Ok(parity_paths)
}
/// Categorize pool member drives as data vs parity /// Categorize pool member drives as data vs parity
fn categorize_pool_drives(&self, member_paths: &[String]) -> Result<(Vec<DriveInfo>, Vec<DriveInfo>)> { fn categorize_pool_drives(&self, member_paths: &[String]) -> Result<(Vec<DriveInfo>, Vec<DriveInfo>)> {
let mut data_drives = Vec::new(); let mut data_drives = Vec::new();
@@ -298,6 +315,35 @@ impl DiskCollector {
}) })
} }
/// Resolve numeric mergerfs references like "1:2" to actual mount paths
fn resolve_numeric_mergerfs_paths(&self, numeric_refs: &[String]) -> Result<Vec<String>> {
let mut resolved_paths = Vec::new();
// Get all mount points that look like /mnt/disk* or /mnt/parity*
let mount_devices = self.get_mount_devices()?;
let mut disk_mounts: Vec<String> = mount_devices.keys()
.filter(|path| path.starts_with("/mnt/disk") || path.starts_with("/mnt/parity"))
.cloned()
.collect();
disk_mounts.sort(); // Ensure consistent ordering
for num_ref in numeric_refs {
if let Ok(index) = num_ref.parse::<usize>() {
// Convert 1-based index to 0-based
if index > 0 && index <= disk_mounts.len() {
resolved_paths.push(disk_mounts[index - 1].clone());
}
}
}
// Fallback: if we couldn't resolve, return the original paths
if resolved_paths.is_empty() {
resolved_paths = numeric_refs.to_vec();
}
Ok(resolved_paths)
}
/// Extract base device name from partition (e.g., "nvme0n1p2" -> "nvme0n1", "sda1" -> "sda") /// Extract base device name from partition (e.g., "nvme0n1p2" -> "nvme0n1", "sda1" -> "sda")
fn extract_base_device(&self, device_name: &str) -> String { fn extract_base_device(&self, device_name: &str) -> String {
// Handle NVMe devices (nvme0n1p1 -> nvme0n1) // Handle NVMe devices (nvme0n1p1 -> nvme0n1)
@@ -749,7 +795,13 @@ impl DiskCollector {
timestamp: u64, timestamp: u64,
status_tracker: &mut StatusTracker status_tracker: &mut StatusTracker
) { ) {
let pool_name = pool.mount_point.trim_start_matches('/').replace('/', "_"); // Use consistent pool naming: extract mount point without leading slash
let pool_name = if pool.mount_point == "/" {
"root".to_string()
} else {
pool.mount_point.trim_start_matches('/').replace('/', "_")
};
if pool_name.is_empty() { if pool_name.is_empty() {
return; return;
} }

View File

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

View File

@@ -164,10 +164,9 @@ impl SystemWidget {
for metric in metrics { for metric in metrics {
if metric.name.starts_with("disk_") { if metric.name.starts_with("disk_") {
if let Some(pool_name) = self.extract_pool_name(&metric.name) { if let Some(pool_name) = self.extract_pool_name(&metric.name) {
let mount_point = self.get_mount_point_for_pool(&pool_name);
let pool = pools.entry(pool_name.clone()).or_insert_with(|| StoragePool { let pool = pools.entry(pool_name.clone()).or_insert_with(|| StoragePool {
name: pool_name.clone(), name: pool_name.clone(),
mount_point: mount_point.clone(), mount_point: self.get_mount_point_for_pool(&pool_name), // Default fallback
pool_type: "single".to_string(), // Default, will be updated pool_type: "single".to_string(), // Default, will be updated
pool_health: None, pool_health: None,
drives: Vec::new(), drives: Vec::new(),
@@ -196,6 +195,10 @@ impl SystemWidget {
if let MetricValue::Float(total) = metric.value { if let MetricValue::Float(total) = metric.value {
pool.total_gb = Some(total); pool.total_gb = Some(total);
} }
} else if metric.name.contains("_mount_point") {
if let MetricValue::String(mount_point) = &metric.value {
pool.mount_point = mount_point.clone();
}
} else if metric.name.contains("_pool_type") { } else if metric.name.contains("_pool_type") {
if let MetricValue::String(pool_type) = &metric.value { if let MetricValue::String(pool_type) = &metric.value {
pool.pool_type = pool_type.clone(); pool.pool_type = pool_type.clone();
@@ -343,35 +346,52 @@ impl SystemWidget {
/// Extract pool name from disk metric name /// Extract pool name from disk metric name
fn extract_pool_name(&self, metric_name: &str) -> Option<String> { fn extract_pool_name(&self, metric_name: &str) -> Option<String> {
// Pattern: disk_{pool_name}_{drive_name}_{metric_type} // Pattern: disk_{pool_name}_{various suffixes}
// Since pool_name can contain underscores, work backwards from known metric suffixes // Since pool_name can contain underscores, work backwards from known metric suffixes
if metric_name.starts_with("disk_") { if metric_name.starts_with("disk_") {
// First try drive-specific metrics that have device names // Handle filesystem metrics: disk_{pool}_fs_{filesystem}_{metric}
if let Some(suffix_pos) = metric_name.rfind("_temperature") if metric_name.contains("_fs_") {
if let Some(fs_pos) = metric_name.find("_fs_") {
return Some(metric_name[5..fs_pos].to_string()); // Skip "disk_", extract pool name before "_fs_"
}
}
// Handle pool-level metrics (usage_percent, used_gb, total_gb, mount_point, pool_type, pool_health)
else if let Some(suffix_pos) = metric_name.rfind("_usage_percent")
.or_else(|| metric_name.rfind("_used_gb"))
.or_else(|| metric_name.rfind("_total_gb"))
.or_else(|| metric_name.rfind("_available_gb"))
.or_else(|| metric_name.rfind("_mount_point"))
.or_else(|| metric_name.rfind("_pool_type"))
.or_else(|| metric_name.rfind("_pool_health")) {
return Some(metric_name[5..suffix_pos].to_string()); // Skip "disk_"
}
// Handle drive-specific metrics: disk_{pool}_{drive_role}_{metric} (for mergerfs) or disk_{pool}_{drive}_{metric} (for physical drives)
else if let Some(suffix_pos) = metric_name.rfind("_temperature")
.or_else(|| metric_name.rfind("_wear_percent")) .or_else(|| metric_name.rfind("_wear_percent"))
.or_else(|| metric_name.rfind("_health")) { .or_else(|| metric_name.rfind("_health")) {
// Find the second-to-last underscore to get pool name // For mergerfs pools, metrics look like: disk_srv_media_data_0_temperature or disk_srv_media_parity_0_temperature
// We need to extract just "srv_media" as the pool name
let before_suffix = &metric_name[..suffix_pos]; let before_suffix = &metric_name[..suffix_pos];
// Check if this looks like a mergerfs drive metric (contains data_ or parity_)
if before_suffix.contains("_data_") {
if let Some(data_pos) = before_suffix.find("_data_") {
return Some(metric_name[5..data_pos].to_string()); // Extract pool name before "_data_"
}
} else if before_suffix.contains("_parity_") {
if let Some(parity_pos) = before_suffix.find("_parity_") {
return Some(metric_name[5..parity_pos].to_string()); // Extract pool name before "_parity_"
}
}
// Fallback for physical drive metrics: find the second-to-last underscore
if let Some(drive_start) = before_suffix.rfind('_') { if let Some(drive_start) = before_suffix.rfind('_') {
if drive_start > 5 { if drive_start > 5 {
return Some(metric_name[5..drive_start].to_string()); // Skip "disk_" return Some(metric_name[5..drive_start].to_string()); // Skip "disk_"
} }
} }
} }
// Handle filesystem metrics: disk_{pool}_fs_{filesystem}_{metric} // Fallback: extract first component after disk_ prefix
else if metric_name.contains("_fs_") {
if let Some(fs_pos) = metric_name.find("_fs_") {
return Some(metric_name[5..fs_pos].to_string()); // Skip "disk_", extract pool name before "_fs_"
}
}
// For pool-level metrics (usage_percent, used_gb, total_gb), take everything before the metric suffix
else if let Some(suffix_pos) = metric_name.rfind("_usage_percent")
.or_else(|| metric_name.rfind("_used_gb"))
.or_else(|| metric_name.rfind("_total_gb"))
.or_else(|| metric_name.rfind("_available_gb")) {
return Some(metric_name[5..suffix_pos].to_string()); // Skip "disk_"
}
// Fallback to old behavior for unknown patterns
else if let Some(captures) = metric_name.strip_prefix("disk_") { else if let Some(captures) = metric_name.strip_prefix("disk_") {
if let Some(pos) = captures.find('_') { if let Some(pos) = captures.find('_') {
return Some(captures[..pos].to_string()); return Some(captures[..pos].to_string());
@@ -409,13 +429,22 @@ impl SystemWidget {
/// Extract drive name from disk metric name /// Extract drive name from disk metric name
fn extract_drive_name(&self, metric_name: &str) -> Option<String> { fn extract_drive_name(&self, metric_name: &str) -> Option<String> {
// Pattern: disk_{pool_name}_{drive_name}_{metric_type} // Pattern: disk_{pool_name}_{drive_name}_{metric_type}
// For mergerfs: disk_{pool_name}_{data|parity}_{index}_{metric_type}
// Since pool_name can contain underscores, work backwards from known metric suffixes // Since pool_name can contain underscores, work backwards from known metric suffixes
if metric_name.starts_with("disk_") { if metric_name.starts_with("disk_") {
if let Some(suffix_pos) = metric_name.rfind("_temperature") if let Some(suffix_pos) = metric_name.rfind("_temperature")
.or_else(|| metric_name.rfind("_wear_percent")) .or_else(|| metric_name.rfind("_wear_percent"))
.or_else(|| metric_name.rfind("_health")) { .or_else(|| metric_name.rfind("_health")) {
// Find the second-to-last underscore to get the drive name
let before_suffix = &metric_name[..suffix_pos]; let before_suffix = &metric_name[..suffix_pos];
// For mergerfs drive metrics: extract the role_index part (e.g., "data_0", "parity_1")
if before_suffix.contains("_data_") || before_suffix.contains("_parity_") {
if let Some(role_start) = before_suffix.rfind("_data_").or_else(|| before_suffix.rfind("_parity_")) {
return Some(before_suffix[role_start + 1..].to_string()); // e.g., "data_0" or "parity_1"
}
}
// Fallback for physical drive metrics: get the last component
if let Some(drive_start) = before_suffix.rfind('_') { if let Some(drive_start) = before_suffix.rfind('_') {
return Some(before_suffix[drive_start + 1..].to_string()); return Some(before_suffix[drive_start + 1..].to_string());
} }
@@ -441,26 +470,7 @@ impl SystemWidget {
); );
lines.push(Line::from(pool_spans)); lines.push(Line::from(pool_spans));
// Pool health line (for multi-disk pools) // Skip pool health line as discussed - removed
if pool.pool_type != "single" {
if let Some(health) = &pool.pool_health {
let health_text = match health.as_str() {
"healthy" => format!("Pool Status: {} Healthy",
if pool.drives.len() > 1 { format!("({} drives)", pool.drives.len()) } else { String::new() }),
"degraded" => "Pool Status: ⚠ Degraded".to_string(),
"critical" => "Pool Status: ✗ Critical".to_string(),
"rebuilding" => "Pool Status: ⟳ Rebuilding".to_string(),
_ => format!("Pool Status: ? {}", health),
};
let mut health_spans = vec![
Span::raw(" "),
Span::styled("├─ ", Typography::tree()),
];
health_spans.extend(StatusIcons::create_status_spans(pool.health_status.clone(), &health_text));
lines.push(Line::from(health_spans));
}
}
// Total usage line (always show for pools) // Total usage line (always show for pools)
let usage_text = match (pool.usage_percent, pool.used_gb, pool.total_gb) { let usage_text = match (pool.usage_percent, pool.used_gb, pool.total_gb) {
@@ -483,7 +493,7 @@ impl SystemWidget {
lines.push(Line::from(usage_spans)); lines.push(Line::from(usage_spans));
// Drive lines with enhanced grouping // Drive lines with enhanced grouping
if pool.pool_type != "single" && pool.drives.len() > 1 { if pool.pool_type.contains("mergerfs") && pool.drives.len() > 1 {
// Group drives by type for mergerfs pools // Group drives by type for mergerfs pools
let (data_drives, parity_drives): (Vec<_>, Vec<_>) = pool.drives.iter().enumerate() let (data_drives, parity_drives): (Vec<_>, Vec<_>) = pool.drives.iter().enumerate()
.partition(|(_, drive)| { .partition(|(_, drive)| {
@@ -492,7 +502,7 @@ impl SystemWidget {
}); });
// Show data drives // Show data drives
if !data_drives.is_empty() && pool.pool_type.contains("mergerfs") { if !data_drives.is_empty() {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::raw(" "), Span::raw(" "),
Span::styled("├─ ", Typography::tree()), Span::styled("├─ ", Typography::tree()),
@@ -510,7 +520,7 @@ impl SystemWidget {
} }
// Show parity drives // Show parity drives
if !parity_drives.is_empty() && pool.pool_type.contains("mergerfs") { if !parity_drives.is_empty() {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::raw(" "), Span::raw(" "),
Span::styled("└─ ", Typography::tree()), Span::styled("└─ ", Typography::tree()),
@@ -525,13 +535,13 @@ impl SystemWidget {
self.render_drive_line(&mut lines, drive, " ├─"); self.render_drive_line(&mut lines, drive, " ├─");
} }
} }
} else { }
// Regular drive listing for non-mergerfs pools } else if pool.pool_type != "single" && pool.drives.len() > 1 {
for (i, drive) in pool.drives.iter().enumerate() { // Regular drive listing for non-mergerfs multi-drive pools
let is_last = i == pool.drives.len() - 1; for (i, drive) in pool.drives.iter().enumerate() {
let tree_symbol = if is_last { "└─" } else { "├─" }; let is_last = i == pool.drives.len() - 1;
self.render_drive_line(&mut lines, drive, tree_symbol); let tree_symbol = if is_last { "└─" } else { "├─" };
} self.render_drive_line(&mut lines, drive, tree_symbol);
} }
} else if pool.pool_type.starts_with("drive (") { } else if pool.pool_type.starts_with("drive (") {
// Physical drive pools: show drive info + filesystem children // Physical drive pools: show drive info + filesystem children
@@ -612,10 +622,12 @@ impl SystemWidget {
if let Some(wear) = drive.wear_percent { if let Some(wear) = drive.wear_percent {
drive_info.push(format!("W: {:.0}%", wear)); drive_info.push(format!("W: {:.0}%", wear));
} }
// Always show drive name with info, or just name if no info available
let drive_text = if drive_info.is_empty() { let drive_text = if drive_info.is_empty() {
drive.name.clone() drive.name.clone()
} else { } else {
format!("{} {}", drive.name, drive_info.join(" ")) format!("{} {}", drive.name, drive_info.join(" "))
}; };
let mut drive_spans = vec![ let mut drive_spans = vec![

View File

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