Complete unified pool visualization with filesystem children
All checks were successful
Build and Release / build-and-release (push) Successful in 2m17s
All checks were successful
Build and Release / build-and-release (push) Successful in 2m17s
- Implement filesystem children display under physical drive pools - Agent generates individual filesystem metrics for each mount point - Dashboard parses filesystem metrics and displays as tree children - Add filesystem usage, total, and available space metrics - Support target format: drive info + filesystem children hierarchy - Fix compilation warnings by properly using available_bytes calculation
This commit is contained in:
parent
d1272a6c13
commit
d68ecfbc64
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.100"
|
||||
version = "0.1.102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -301,7 +301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.100"
|
||||
version = "0.1.102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -324,7 +324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.100"
|
||||
version = "0.1.103"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.101"
|
||||
version = "0.1.103"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -343,6 +343,9 @@ impl DiskCollector {
|
||||
storage_pools.push(pool);
|
||||
}
|
||||
|
||||
// IMPORTANT: Do not create individual filesystem pools when using auto-discovery
|
||||
// All single disk filesystems should be grouped into physical drive pools above
|
||||
|
||||
// Process mergerfs pools (these remain as logical pools)
|
||||
for pool_info in &topology.mergerfs_pools {
|
||||
if let Ok((total_bytes, used_bytes)) = self.get_filesystem_info(&pool_info.mount_point) {
|
||||
@ -887,6 +890,11 @@ impl DiskCollector {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert bytes to gigabytes
|
||||
fn bytes_to_gb(&self, bytes: u64) -> f32 {
|
||||
bytes as f32 / (1024.0 * 1024.0 * 1024.0)
|
||||
}
|
||||
|
||||
/// Detect device backing a mount point using lsblk (static version for startup)
|
||||
fn detect_device_for_mount_point_static(mount_point: &str) -> Result<Vec<String>> {
|
||||
let output = Command::new("lsblk")
|
||||
@ -1212,6 +1220,79 @@ impl Collector for DiskCollector {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Individual filesystem metrics for PhysicalDrive pools
|
||||
if let StoragePoolType::PhysicalDrive { filesystems } = &storage_pool.pool_type {
|
||||
for filesystem_mount in filesystems {
|
||||
if let Ok((total_bytes, used_bytes)) = self.get_filesystem_info(filesystem_mount) {
|
||||
let available_bytes = total_bytes - used_bytes;
|
||||
let usage_percent = if total_bytes > 0 {
|
||||
(used_bytes as f64 / total_bytes as f64) * 100.0
|
||||
} else { 0.0 };
|
||||
|
||||
let filesystem_name = if filesystem_mount == "/" {
|
||||
"root".to_string()
|
||||
} else {
|
||||
filesystem_mount.trim_start_matches('/').replace('/', "_")
|
||||
};
|
||||
|
||||
// Calculate filesystem status based on usage
|
||||
let fs_status = if usage_percent >= self.config.usage_critical_percent as f64 {
|
||||
Status::Critical
|
||||
} else if usage_percent >= self.config.usage_warning_percent as f64 {
|
||||
Status::Warning
|
||||
} else {
|
||||
Status::Ok
|
||||
};
|
||||
|
||||
// Filesystem usage metrics
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_fs_{}_usage_percent", pool_name, filesystem_name),
|
||||
value: MetricValue::Float(usage_percent as f32),
|
||||
unit: Some("%".to_string()),
|
||||
description: Some(format!("{}: {:.0}%", filesystem_mount, usage_percent)),
|
||||
status: fs_status.clone(),
|
||||
timestamp,
|
||||
});
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_fs_{}_used_gb", pool_name, filesystem_name),
|
||||
value: MetricValue::Float(self.bytes_to_gb(used_bytes)),
|
||||
unit: Some("GB".to_string()),
|
||||
description: Some(format!("{}: {}GB used", filesystem_mount, self.bytes_to_human_readable(used_bytes))),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_fs_{}_total_gb", pool_name, filesystem_name),
|
||||
value: MetricValue::Float(self.bytes_to_gb(total_bytes)),
|
||||
unit: Some("GB".to_string()),
|
||||
description: Some(format!("{}: {}GB total", filesystem_mount, self.bytes_to_human_readable(total_bytes))),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_fs_{}_available_gb", pool_name, filesystem_name),
|
||||
value: MetricValue::Float(self.bytes_to_gb(available_bytes)),
|
||||
unit: Some("GB".to_string()),
|
||||
description: Some(format!("{}: {}GB available", filesystem_mount, self.bytes_to_human_readable(available_bytes))),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_fs_{}_mount_point", pool_name, filesystem_name),
|
||||
value: MetricValue::String(filesystem_mount.clone()),
|
||||
unit: None,
|
||||
description: Some(format!("Mount: {}", filesystem_mount)),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add storage pool count metric
|
||||
@ -1234,5 +1315,4 @@ impl Collector for DiskCollector {
|
||||
|
||||
Ok(metrics)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.101"
|
||||
version = "0.1.103"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -48,6 +48,7 @@ struct StoragePool {
|
||||
pool_type: String, // "single", "mergerfs (2+1)", "RAID5 (3+1)", etc.
|
||||
pool_health: Option<String>, // "healthy", "degraded", "critical", "rebuilding"
|
||||
drives: Vec<StorageDrive>,
|
||||
filesystems: Vec<FileSystem>, // For physical drive pools: individual filesystem children
|
||||
usage_percent: Option<f32>,
|
||||
used_gb: Option<f32>,
|
||||
total_gb: Option<f32>,
|
||||
@ -63,6 +64,15 @@ struct StorageDrive {
|
||||
status: Status,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FileSystem {
|
||||
mount_point: String,
|
||||
usage_percent: Option<f32>,
|
||||
used_gb: Option<f32>,
|
||||
total_gb: Option<f32>,
|
||||
status: Status,
|
||||
}
|
||||
|
||||
impl SystemWidget {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@ -160,6 +170,7 @@ impl SystemWidget {
|
||||
pool_type: "single".to_string(), // Default, will be updated
|
||||
pool_health: None,
|
||||
drives: Vec::new(),
|
||||
filesystems: Vec::new(),
|
||||
usage_percent: None,
|
||||
used_gb: None,
|
||||
total_gb: None,
|
||||
@ -230,6 +241,75 @@ impl SystemWidget {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if metric.name.contains("_fs_") {
|
||||
// Handle filesystem metrics for physical drive pools (disk_{pool}_fs_{fs_name}_{metric})
|
||||
if let (Some(fs_name), Some(metric_type)) = self.extract_filesystem_metric(&metric.name) {
|
||||
// Find or create filesystem entry
|
||||
let fs_exists = pool.filesystems.iter().any(|fs| {
|
||||
let fs_id = if fs.mount_point == "/" {
|
||||
"root".to_string()
|
||||
} else {
|
||||
fs.mount_point.trim_start_matches('/').replace('/', "_")
|
||||
};
|
||||
fs_id == fs_name
|
||||
});
|
||||
|
||||
if !fs_exists {
|
||||
// Extract actual mount point from mount_point metric if available
|
||||
let mount_point = if metric_type == "mount_point" {
|
||||
if let MetricValue::String(mount) = &metric.value {
|
||||
mount.clone()
|
||||
} else {
|
||||
format!("/{}", fs_name.replace('_', "/"))
|
||||
}
|
||||
} else {
|
||||
format!("/{}", fs_name.replace('_', "/"))
|
||||
};
|
||||
|
||||
pool.filesystems.push(FileSystem {
|
||||
mount_point,
|
||||
usage_percent: None,
|
||||
used_gb: None,
|
||||
total_gb: None,
|
||||
status: Status::Unknown,
|
||||
});
|
||||
}
|
||||
|
||||
// Update the filesystem with the metric value
|
||||
if let Some(filesystem) = pool.filesystems.iter_mut().find(|fs| {
|
||||
let fs_id = if fs.mount_point == "/" {
|
||||
"root".to_string()
|
||||
} else {
|
||||
fs.mount_point.trim_start_matches('/').replace('/', "_")
|
||||
};
|
||||
fs_id == fs_name
|
||||
}) {
|
||||
match metric_type.as_str() {
|
||||
"usage_percent" => {
|
||||
if let MetricValue::Float(usage) = metric.value {
|
||||
filesystem.usage_percent = Some(usage);
|
||||
filesystem.status = metric.status.clone();
|
||||
}
|
||||
}
|
||||
"used_gb" => {
|
||||
if let MetricValue::Float(used) = metric.value {
|
||||
filesystem.used_gb = Some(used);
|
||||
}
|
||||
}
|
||||
"total_gb" => {
|
||||
if let MetricValue::Float(total) = metric.value {
|
||||
filesystem.total_gb = Some(total);
|
||||
}
|
||||
}
|
||||
"mount_point" => {
|
||||
if let MetricValue::String(mount) = &metric.value {
|
||||
filesystem.mount_point = mount.clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -272,6 +352,25 @@ impl SystemWidget {
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract filesystem name and metric type from filesystem metric names
|
||||
/// Pattern: disk_{pool}_fs_{filesystem_name}_{metric_type}
|
||||
fn extract_filesystem_metric(&self, metric_name: &str) -> (Option<String>, Option<String>) {
|
||||
if metric_name.starts_with("disk_") && metric_name.contains("_fs_") {
|
||||
// Find the _fs_ part
|
||||
if let Some(fs_start) = metric_name.find("_fs_") {
|
||||
let after_fs = &metric_name[fs_start + 4..]; // Skip "_fs_"
|
||||
|
||||
// Find the last underscore to separate filesystem name from metric type
|
||||
if let Some(last_underscore) = after_fs.rfind('_') {
|
||||
let fs_name = after_fs[..last_underscore].to_string();
|
||||
let metric_type = after_fs[last_underscore + 1..].to_string();
|
||||
return (Some(fs_name), Some(metric_type));
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
||||
/// Extract drive name from disk metric name
|
||||
fn extract_drive_name(&self, metric_name: &str) -> Option<String> {
|
||||
// Pattern: disk_{pool_name}_{drive_name}_{metric_type}
|
||||
@ -337,7 +436,9 @@ impl SystemWidget {
|
||||
};
|
||||
|
||||
let has_drives = !pool.drives.is_empty();
|
||||
let tree_symbol = if has_drives { "├─" } else { "└─" };
|
||||
let has_filesystems = !pool.filesystems.is_empty();
|
||||
let has_children = has_drives || has_filesystems;
|
||||
let tree_symbol = if has_children { "├─" } else { "└─" };
|
||||
let mut usage_spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(tree_symbol, Typography::tree()),
|
||||
@ -397,6 +498,54 @@ impl SystemWidget {
|
||||
self.render_drive_line(&mut lines, drive, tree_symbol);
|
||||
}
|
||||
}
|
||||
} else if pool.pool_type.starts_with("drive (") {
|
||||
// Physical drive pools: show drive info + filesystem children
|
||||
// First show drive information
|
||||
for drive in &pool.drives {
|
||||
let mut drive_info = Vec::new();
|
||||
if let Some(temp) = drive.temperature {
|
||||
drive_info.push(format!("T: {:.0}°C", temp));
|
||||
}
|
||||
if let Some(wear) = drive.wear_percent {
|
||||
drive_info.push(format!("W: {:.0}%", wear));
|
||||
}
|
||||
let drive_text = if drive_info.is_empty() {
|
||||
format!("Drive: {}", drive.name)
|
||||
} else {
|
||||
format!("Drive: {}", drive_info.join(" "))
|
||||
};
|
||||
|
||||
let has_filesystems = !pool.filesystems.is_empty();
|
||||
let tree_symbol = if has_filesystems { "├─" } else { "└─" };
|
||||
let mut drive_spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(tree_symbol, Typography::tree()),
|
||||
Span::raw(" "),
|
||||
];
|
||||
drive_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
|
||||
lines.push(Line::from(drive_spans));
|
||||
}
|
||||
|
||||
// Then show filesystem children
|
||||
for (i, filesystem) in pool.filesystems.iter().enumerate() {
|
||||
let is_last = i == pool.filesystems.len() - 1;
|
||||
let tree_symbol = if is_last { "└─" } else { "├─" };
|
||||
|
||||
let fs_text = match (filesystem.usage_percent, filesystem.used_gb, filesystem.total_gb) {
|
||||
(Some(pct), Some(used), Some(total)) => {
|
||||
format!("{}: {:.0}% {:.1}GB/{:.1}GB", filesystem.mount_point, pct, used, total)
|
||||
}
|
||||
_ => format!("{}: —% —GB/—GB", filesystem.mount_point),
|
||||
};
|
||||
|
||||
let mut fs_spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(tree_symbol, Typography::tree()),
|
||||
Span::raw(" "),
|
||||
];
|
||||
fs_spans.extend(StatusIcons::create_status_spans(filesystem.status.clone(), &fs_text));
|
||||
lines.push(Line::from(fs_spans));
|
||||
}
|
||||
} else {
|
||||
// Single drive or simple pools
|
||||
for (i, drive) in pool.drives.iter().enumerate() {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.101"
|
||||
version = "0.1.103"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user