Compare commits

...

2 Commits

Author SHA1 Message Date
8f80015273 Fix dashboard storage pool label styling
All checks were successful
Build and Release / build-and-release (push) Successful in 1m20s
Replace non-existent Typography::primary() with Typography::secondary() for
MergerFS pool labels following existing UI patterns.
2025-11-25 10:16:26 +01:00
7a95a9d762 Add MergerFS pool display to dashboard matching CLAUDE.md format
All checks were successful
Build and Release / build-and-release (push) Successful in 2m32s
Updated the dashboard system widget to properly display MergerFS storage
pools in the exact format described in CLAUDE.md:

- Pool header showing "mergerfs (2+1):" format
- Total usage line: "├─ Total: ● 63% 2355.2GB/3686.4GB"
- Data Disks section with tree structure
- Individual drive entries: "│  ├─ ● sdb T: 24°C W: 5%"
- Parity drives section: "├─ Parity: ● sdc T: 24°C W: 5%"
- Mount point footer: "└─ Mount: /srv/media"

The dashboard now processes both data_drives and parity_drives arrays from
the agent data correctly and renders the complete MergerFS pool hierarchy
with proper status indicators, temperatures, and wear levels.

Storage display now matches the enhanced tree structure format specified
in documentation with correct Unicode tree characters and spacing.
2025-11-25 09:12:13 +01:00
7 changed files with 246 additions and 43 deletions

6
Cargo.lock generated
View File

@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]] [[package]]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.150" version = "0.1.153"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -301,7 +301,7 @@ dependencies = [
[[package]] [[package]]
name = "cm-dashboard-agent" name = "cm-dashboard-agent"
version = "0.1.150" version = "0.1.153"
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.150" version = "0.1.153"
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.151" version = "0.1.153"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -76,11 +76,17 @@ impl DiskCollector {
let mount_devices = self.get_mount_devices().await?; let mount_devices = self.get_mount_devices().await?;
// Step 2: Get filesystem usage for each mount point using df // Step 2: Get filesystem usage for each mount point using df
let filesystem_usage = self.get_filesystem_usage(&mount_devices).map_err(|e| CollectorError::Parse { let mut filesystem_usage = self.get_filesystem_usage(&mount_devices).map_err(|e| CollectorError::Parse {
value: "filesystem usage".to_string(), value: "filesystem usage".to_string(),
error: format!("Failed to get filesystem usage: {}", e), error: format!("Failed to get filesystem usage: {}", e),
})?; })?;
// Step 2.5: Add MergerFS mount points that weren't in lsblk output
self.add_mergerfs_filesystem_usage(&mut filesystem_usage).map_err(|e| CollectorError::Parse {
value: "mergerfs filesystem usage".to_string(),
error: format!("Failed to get mergerfs filesystem usage: {}", e),
})?;
// Step 3: Detect MergerFS pools // Step 3: Detect MergerFS pools
let mergerfs_pools = self.detect_mergerfs_pools(&filesystem_usage).map_err(|e| CollectorError::Parse { let mergerfs_pools = self.detect_mergerfs_pools(&filesystem_usage).map_err(|e| CollectorError::Parse {
value: "mergerfs pools".to_string(), value: "mergerfs pools".to_string(),
@@ -156,6 +162,30 @@ impl DiskCollector {
Ok(filesystem_usage) Ok(filesystem_usage)
} }
/// Add filesystem usage for MergerFS mount points that aren't in lsblk
fn add_mergerfs_filesystem_usage(&self, filesystem_usage: &mut HashMap<String, (u64, u64)>) -> anyhow::Result<()> {
let mounts_content = std::fs::read_to_string("/proc/mounts")
.map_err(|e| anyhow::anyhow!("Failed to read /proc/mounts: {}", e))?;
for line in mounts_content.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 && parts[2] == "fuse.mergerfs" {
let mount_point = parts[1].to_string();
// Only add if we don't already have usage data for this mount point
if !filesystem_usage.contains_key(&mount_point) {
if let Ok((total, used)) = self.get_filesystem_info(&mount_point) {
debug!("Added MergerFS filesystem usage for {}: {}GB total, {}GB used",
mount_point, total as f32 / (1024.0 * 1024.0 * 1024.0), used as f32 / (1024.0 * 1024.0 * 1024.0));
filesystem_usage.insert(mount_point, (total, used));
}
}
}
}
Ok(())
}
/// Get filesystem info for a single mount point /// Get filesystem info for a single mount point
fn get_filesystem_info(&self, mount_point: &str) -> Result<(u64, u64), CollectorError> { fn get_filesystem_info(&self, mount_point: &str) -> Result<(u64, u64), CollectorError> {
let output = Command::new("df") let output = Command::new("df")
@@ -511,8 +541,8 @@ impl DiskCollector {
/// Populate pools data into AgentData /// Populate pools data into AgentData
fn populate_pools_data(&self, mergerfs_pools: &[MergerfsPool], smart_data: &HashMap<String, SmartData>, agent_data: &mut AgentData) -> Result<(), CollectorError> { fn populate_pools_data(&self, mergerfs_pools: &[MergerfsPool], smart_data: &HashMap<String, SmartData>, agent_data: &mut AgentData) -> Result<(), CollectorError> {
for pool in mergerfs_pools { for pool in mergerfs_pools {
// Calculate pool health based on member drive health // Calculate pool health and statuses based on member drive health
let (pool_health, data_drive_data, parity_drive_data) = self.calculate_pool_health(pool, smart_data); let (pool_health, health_status, usage_status, data_drive_data, parity_drive_data) = self.calculate_pool_health(pool, smart_data);
let pool_data = PoolData { let pool_data = PoolData {
name: pool.name.clone(), name: pool.name.clone(),
@@ -526,6 +556,8 @@ impl DiskCollector {
total_gb: pool.total_bytes as f32 / (1024.0 * 1024.0 * 1024.0), total_gb: pool.total_bytes as f32 / (1024.0 * 1024.0 * 1024.0),
data_drives: data_drive_data, data_drives: data_drive_data,
parity_drives: parity_drive_data, parity_drives: parity_drive_data,
health_status,
usage_status,
}; };
agent_data.system.storage.pools.push(pool_data); agent_data.system.storage.pools.push(pool_data);
@@ -535,7 +567,7 @@ impl DiskCollector {
} }
/// Calculate pool health based on member drive status /// Calculate pool health based on member drive status
fn calculate_pool_health(&self, pool: &MergerfsPool, smart_data: &HashMap<String, SmartData>) -> (String, Vec<cm_dashboard_shared::PoolDriveData>, Vec<cm_dashboard_shared::PoolDriveData>) { fn calculate_pool_health(&self, pool: &MergerfsPool, smart_data: &HashMap<String, SmartData>) -> (String, cm_dashboard_shared::Status, cm_dashboard_shared::Status, Vec<cm_dashboard_shared::PoolDriveData>, Vec<cm_dashboard_shared::PoolDriveData>) {
let mut failed_data = 0; let mut failed_data = 0;
let mut failed_parity = 0; let mut failed_parity = 0;
@@ -543,16 +575,23 @@ impl DiskCollector {
let data_drive_data: Vec<cm_dashboard_shared::PoolDriveData> = pool.data_drives.iter().map(|d| { let data_drive_data: Vec<cm_dashboard_shared::PoolDriveData> = pool.data_drives.iter().map(|d| {
let smart = smart_data.get(&d.name); let smart = smart_data.get(&d.name);
let health = smart.map(|s| s.health.clone()).unwrap_or_else(|| "UNKNOWN".to_string()); let health = smart.map(|s| s.health.clone()).unwrap_or_else(|| "UNKNOWN".to_string());
let temperature = smart.and_then(|s| s.temperature_celsius).or(d.temperature_celsius);
if health == "FAILED" { if health == "FAILED" {
failed_data += 1; failed_data += 1;
} }
// Calculate drive statuses using config thresholds
let health_status = self.calculate_health_status(&health);
let temperature_status = temperature.map(|t| self.temperature_thresholds.evaluate(t)).unwrap_or(cm_dashboard_shared::Status::Unknown);
cm_dashboard_shared::PoolDriveData { cm_dashboard_shared::PoolDriveData {
name: d.name.clone(), name: d.name.clone(),
temperature_celsius: smart.and_then(|s| s.temperature_celsius).or(d.temperature_celsius), temperature_celsius: temperature,
health, health,
wear_percent: smart.and_then(|s| s.wear_percent), wear_percent: smart.and_then(|s| s.wear_percent),
health_status,
temperature_status,
} }
}).collect(); }).collect();
@@ -560,27 +599,47 @@ impl DiskCollector {
let parity_drive_data: Vec<cm_dashboard_shared::PoolDriveData> = pool.parity_drives.iter().map(|d| { let parity_drive_data: Vec<cm_dashboard_shared::PoolDriveData> = pool.parity_drives.iter().map(|d| {
let smart = smart_data.get(&d.name); let smart = smart_data.get(&d.name);
let health = smart.map(|s| s.health.clone()).unwrap_or_else(|| "UNKNOWN".to_string()); let health = smart.map(|s| s.health.clone()).unwrap_or_else(|| "UNKNOWN".to_string());
let temperature = smart.and_then(|s| s.temperature_celsius).or(d.temperature_celsius);
if health == "FAILED" { if health == "FAILED" {
failed_parity += 1; failed_parity += 1;
} }
// Calculate drive statuses using config thresholds
let health_status = self.calculate_health_status(&health);
let temperature_status = temperature.map(|t| self.temperature_thresholds.evaluate(t)).unwrap_or(cm_dashboard_shared::Status::Unknown);
cm_dashboard_shared::PoolDriveData { cm_dashboard_shared::PoolDriveData {
name: d.name.clone(), name: d.name.clone(),
temperature_celsius: smart.and_then(|s| s.temperature_celsius).or(d.temperature_celsius), temperature_celsius: temperature,
health, health,
wear_percent: smart.and_then(|s| s.wear_percent), wear_percent: smart.and_then(|s| s.wear_percent),
health_status,
temperature_status,
} }
}).collect(); }).collect();
// Calculate overall pool health // Calculate overall pool health string and status
let pool_health = match (failed_data, failed_parity) { let (pool_health, health_status) = match (failed_data, failed_parity) {
(0, 0) => "healthy".to_string(), (0, 0) => ("healthy".to_string(), cm_dashboard_shared::Status::Ok),
(1, 0) | (0, 1) => "degraded".to_string(), // One failure is degraded but recoverable (1, 0) | (0, 1) => ("degraded".to_string(), cm_dashboard_shared::Status::Warning),
_ => "critical".to_string(), // Multiple failures are critical _ => ("critical".to_string(), cm_dashboard_shared::Status::Critical),
}; };
(pool_health, data_drive_data, parity_drive_data) // Calculate pool usage status using config thresholds
let usage_percent = if pool.total_bytes > 0 {
(pool.used_bytes as f32 / pool.total_bytes as f32) * 100.0
} else { 0.0 };
let usage_status = if usage_percent >= self.config.usage_critical_percent {
cm_dashboard_shared::Status::Critical
} else if usage_percent >= self.config.usage_warning_percent {
cm_dashboard_shared::Status::Warning
} else {
cm_dashboard_shared::Status::Ok
};
(pool_health, health_status, usage_status, data_drive_data, parity_drive_data)
} }
/// Calculate filesystem usage status /// Calculate filesystem usage status

View File

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

View File

@@ -57,7 +57,9 @@ struct StoragePool {
name: String, name: String,
mount_point: String, mount_point: String,
pool_type: String, // "single", "mergerfs (2+1)", "RAID5 (3+1)", etc. pool_type: String, // "single", "mergerfs (2+1)", "RAID5 (3+1)", etc.
drives: Vec<StorageDrive>, drives: Vec<StorageDrive>, // For physical drives
data_drives: Vec<StorageDrive>, // For MergerFS pools
parity_drives: Vec<StorageDrive>, // For MergerFS pools
filesystems: Vec<FileSystem>, // For physical drive pools: individual filesystem children filesystems: Vec<FileSystem>, // For physical drive pools: individual filesystem children
usage_percent: Option<f32>, usage_percent: Option<f32>,
used_gb: Option<f32>, used_gb: Option<f32>,
@@ -227,6 +229,8 @@ impl SystemWidget {
mount_point: drive.name.clone(), mount_point: drive.name.clone(),
pool_type: "drive".to_string(), pool_type: "drive".to_string(),
drives: Vec::new(), drives: Vec::new(),
data_drives: Vec::new(),
parity_drives: Vec::new(),
filesystems: Vec::new(), filesystems: Vec::new(),
usage_percent: None, usage_percent: None,
used_gb: None, used_gb: None,
@@ -267,7 +271,79 @@ impl SystemWidget {
pools.insert(drive.name.clone(), pool); pools.insert(drive.name.clone(), pool);
} }
// Convert pools // Convert pools (MergerFS, RAID, etc.)
for pool in &agent_data.system.storage.pools {
// Use agent-calculated status (combined health and usage status)
let pool_status = if pool.health_status == Status::Critical || pool.usage_status == Status::Critical {
Status::Critical
} else if pool.health_status == Status::Warning || pool.usage_status == Status::Warning {
Status::Warning
} else if pool.health_status == Status::Ok && pool.usage_status == Status::Ok {
Status::Ok
} else {
Status::Unknown
};
let mut storage_pool = StoragePool {
name: pool.name.clone(),
mount_point: pool.mount.clone(),
pool_type: pool.pool_type.clone(),
drives: Vec::new(),
data_drives: Vec::new(),
parity_drives: Vec::new(),
filesystems: Vec::new(),
usage_percent: Some(pool.usage_percent),
used_gb: Some(pool.used_gb),
total_gb: Some(pool.total_gb),
status: pool_status,
};
// Add data drives - use agent-calculated status
for drive in &pool.data_drives {
// Use combined health and temperature status
let drive_status = if drive.health_status == Status::Critical || drive.temperature_status == Status::Critical {
Status::Critical
} else if drive.health_status == Status::Warning || drive.temperature_status == Status::Warning {
Status::Warning
} else if drive.health_status == Status::Ok && drive.temperature_status == Status::Ok {
Status::Ok
} else {
Status::Unknown
};
let storage_drive = StorageDrive {
name: drive.name.clone(),
temperature: drive.temperature_celsius,
wear_percent: drive.wear_percent,
status: drive_status,
};
storage_pool.data_drives.push(storage_drive);
}
// Add parity drives - use agent-calculated status
for drive in &pool.parity_drives {
// Use combined health and temperature status
let drive_status = if drive.health_status == Status::Critical || drive.temperature_status == Status::Critical {
Status::Critical
} else if drive.health_status == Status::Warning || drive.temperature_status == Status::Warning {
Status::Warning
} else if drive.health_status == Status::Ok && drive.temperature_status == Status::Ok {
Status::Ok
} else {
Status::Unknown
};
let storage_drive = StorageDrive {
name: drive.name.clone(),
temperature: drive.temperature_celsius,
wear_percent: drive.wear_percent,
status: drive_status,
};
storage_pool.parity_drives.push(storage_drive);
}
pools.insert(pool.name.clone(), storage_pool);
}
// Store pools // Store pools
let mut pool_list: Vec<StoragePool> = pools.into_values().collect(); let mut pool_list: Vec<StoragePool> = pools.into_values().collect();
@@ -306,8 +382,8 @@ impl SystemWidget {
pool.name.clone() pool.name.clone()
} }
} else { } else {
// For mergerfs pools, show pool name with format // For mergerfs pools, show pool name with format like "mergerfs (2+1):"
format!("{} ({})", pool.mount_point, pool.pool_type) format!("{}:", pool.pool_type)
}; };
let pool_spans = StatusIcons::create_status_spans(pool.status.clone(), &pool_label); let pool_spans = StatusIcons::create_status_spans(pool.status.clone(), &pool_label);
@@ -336,30 +412,71 @@ impl SystemWidget {
lines.push(Line::from(fs_spans)); lines.push(Line::from(fs_spans));
} }
} else { } else {
// For mergerfs pools, show data drives and parity drives in tree structure // For mergerfs pools, show structure matching CLAUDE.md format:
if !pool.drives.is_empty() { // ● mergerfs (2+1):
// Group drives by type based on naming conventions or show all as data drives // ├─ Total: ● 63% 2355.2GB/3686.4GB
let (data_drives, parity_drives): (Vec<_>, Vec<_>) = pool.drives.iter() // ├─ Data Disks:
.partition(|d| !d.name.contains("parity") && !d.name.starts_with("sdc")); // ├─ ● sdb T: 24°C W: 5%
// │ └─ ● sdd T: 27°C W: 5%
// ├─ Parity: ● sdc T: 24°C W: 5%
// └─ Mount: /srv/media
if !data_drives.is_empty() { // Pool total usage
lines.push(Line::from(vec![ let total_text = format!("Total: {:.0}% {:.1}GB/{:.1}GB",
Span::styled(" ├─ Data Disks:", Typography::secondary()) pool.usage_percent.unwrap_or(0.0),
])); pool.used_gb.unwrap_or(0.0),
for (i, drive) in data_drives.iter().enumerate() { pool.total_gb.unwrap_or(0.0)
render_pool_drive(drive, i == data_drives.len() - 1 && parity_drives.is_empty(), &mut lines); );
} let mut total_spans = vec![
} Span::styled(" ├─ ", Typography::tree()),
];
total_spans.extend(StatusIcons::create_status_spans(Status::Ok, &total_text));
lines.push(Line::from(total_spans));
if !parity_drives.is_empty() { // Data Disks section
lines.push(Line::from(vec![ if !pool.data_drives.is_empty() {
Span::styled(" └─ Parity:", Typography::secondary()) lines.push(Line::from(vec![
])); Span::styled(" ├─ ", Typography::tree()),
for (i, drive) in parity_drives.iter().enumerate() { Span::styled("Data Disks:", Typography::secondary())
render_pool_drive(drive, i == parity_drives.len() - 1, &mut lines); ]));
} for (i, drive) in pool.data_drives.iter().enumerate() {
let is_last = i == pool.data_drives.len() - 1;
let tree_symbol = if is_last { " │ └─ " } else { " │ ├─ " };
render_mergerfs_drive(drive, tree_symbol, &mut lines);
} }
} }
// Parity section
if !pool.parity_drives.is_empty() {
for drive in &pool.parity_drives {
let mut drive_details = Vec::new();
if let Some(temp) = drive.temperature {
drive_details.push(format!("T: {}°C", temp as i32));
}
if let Some(wear) = drive.wear_percent {
drive_details.push(format!("W: {}%", wear as i32));
}
let drive_text = if !drive_details.is_empty() {
format!("{} {}", drive.name, drive_details.join(" "))
} else {
drive.name.clone()
};
let mut parity_spans = vec![
Span::styled(" ├─ ", Typography::tree()),
Span::styled("Parity: ", Typography::secondary()),
];
parity_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
lines.push(Line::from(parity_spans));
}
}
// Mount point
lines.push(Line::from(vec![
Span::styled(" └─ Mount: ", Typography::tree()),
Span::styled(&pool.mount_point, Typography::secondary())
]));
} }
} }
@@ -367,6 +484,29 @@ impl SystemWidget {
} }
} }
/// Helper function to render a drive in a MergerFS pool
fn render_mergerfs_drive<'a>(drive: &StorageDrive, tree_symbol: &'a str, lines: &mut Vec<Line<'a>>) {
let mut drive_details = Vec::new();
if let Some(temp) = drive.temperature {
drive_details.push(format!("T: {}°C", temp as i32));
}
if let Some(wear) = drive.wear_percent {
drive_details.push(format!("W: {}%", wear as i32));
}
let drive_text = if !drive_details.is_empty() {
format!("{} {}", drive.name, drive_details.join(" "))
} else {
drive.name.clone()
};
let mut drive_spans = vec![
Span::styled(tree_symbol, Typography::tree()),
];
drive_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
lines.push(Line::from(drive_spans));
}
/// Helper function to render a drive in a storage pool /// Helper function to render a drive in a storage pool
fn render_pool_drive(drive: &StorageDrive, is_last: bool, lines: &mut Vec<Line<'_>>) { fn render_pool_drive(drive: &StorageDrive, is_last: bool, lines: &mut Vec<Line<'_>>) {
let tree_symbol = if is_last { " └─" } else { " ├─" }; let tree_symbol = if is_last { " └─" } else { " ├─" };

View File

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

View File

@@ -96,6 +96,8 @@ pub struct PoolData {
pub total_gb: f32, pub total_gb: f32,
pub data_drives: Vec<PoolDriveData>, pub data_drives: Vec<PoolDriveData>,
pub parity_drives: Vec<PoolDriveData>, pub parity_drives: Vec<PoolDriveData>,
pub health_status: Status,
pub usage_status: Status,
} }
/// Drive in a storage pool /// Drive in a storage pool
@@ -105,6 +107,8 @@ pub struct PoolDriveData {
pub temperature_celsius: Option<f32>, pub temperature_celsius: Option<f32>,
pub wear_percent: Option<f32>, pub wear_percent: Option<f32>,
pub health: String, pub health: String,
pub health_status: Status,
pub temperature_status: Status,
} }
/// Service monitoring data /// Service monitoring data