diff --git a/Cargo.lock b/Cargo.lock index 3e7fe12..326d4ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.150" +version = "0.1.151" dependencies = [ "anyhow", "chrono", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.150" +version = "0.1.151" dependencies = [ "anyhow", "async-trait", @@ -324,7 +324,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.150" +version = "0.1.151" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 73362bb..a4c9a84 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.151" +version = "0.1.152" edition = "2021" [dependencies] diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index a88e024..c9f9a89 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.151" +version = "0.1.152" edition = "2021" [dependencies] diff --git a/dashboard/src/ui/widgets/system.rs b/dashboard/src/ui/widgets/system.rs index 8ccb90b..c0da17c 100644 --- a/dashboard/src/ui/widgets/system.rs +++ b/dashboard/src/ui/widgets/system.rs @@ -57,7 +57,9 @@ struct StoragePool { name: String, mount_point: String, pool_type: String, // "single", "mergerfs (2+1)", "RAID5 (3+1)", etc. - drives: Vec, + drives: Vec, // For physical drives + data_drives: Vec, // For MergerFS pools + parity_drives: Vec, // For MergerFS pools filesystems: Vec, // For physical drive pools: individual filesystem children usage_percent: Option, used_gb: Option, @@ -227,6 +229,8 @@ impl SystemWidget { mount_point: drive.name.clone(), pool_type: "drive".to_string(), drives: Vec::new(), + data_drives: Vec::new(), + parity_drives: Vec::new(), filesystems: Vec::new(), usage_percent: None, used_gb: None, @@ -267,7 +271,46 @@ impl SystemWidget { pools.insert(drive.name.clone(), pool); } - // Convert pools + // Convert pools (MergerFS, RAID, etc.) + for pool in &agent_data.system.storage.pools { + 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: Status::Ok, // TODO: map pool health to status + }; + + // Add data drives + for drive in &pool.data_drives { + let storage_drive = StorageDrive { + name: drive.name.clone(), + temperature: drive.temperature_celsius, + wear_percent: drive.wear_percent, + status: Status::Ok, // TODO: map drive health to status + }; + storage_pool.data_drives.push(storage_drive); + } + + // Add parity drives + for drive in &pool.parity_drives { + let storage_drive = StorageDrive { + name: drive.name.clone(), + temperature: drive.temperature_celsius, + wear_percent: drive.wear_percent, + status: Status::Ok, // TODO: map drive health to status + }; + storage_pool.parity_drives.push(storage_drive); + } + + pools.insert(pool.name.clone(), storage_pool); + } // Store pools let mut pool_list: Vec = pools.into_values().collect(); @@ -306,8 +349,8 @@ impl SystemWidget { pool.name.clone() } } else { - // For mergerfs pools, show pool name with format - format!("{} ({})", pool.mount_point, pool.pool_type) + // For mergerfs pools, show pool name with format like "mergerfs (2+1):" + format!("{}:", pool.pool_type) }; let pool_spans = StatusIcons::create_status_spans(pool.status.clone(), &pool_label); @@ -336,30 +379,70 @@ impl SystemWidget { lines.push(Line::from(fs_spans)); } } else { - // For mergerfs pools, show data drives and parity drives in tree structure - if !pool.drives.is_empty() { - // Group drives by type based on naming conventions or show all as data drives - let (data_drives, parity_drives): (Vec<_>, Vec<_>) = pool.drives.iter() - .partition(|d| !d.name.contains("parity") && !d.name.starts_with("sdc")); + // For mergerfs pools, show structure matching CLAUDE.md format: + // ● mergerfs (2+1): + // ├─ Total: ● 63% 2355.2GB/3686.4GB + // ├─ Data Disks: + // │ ├─ ● sdb T: 24°C W: 5% + // │ └─ ● sdd T: 27°C W: 5% + // ├─ Parity: ● sdc T: 24°C W: 5% + // └─ Mount: /srv/media + + // Pool total usage + let total_text = format!("Total: {:.0}% {:.1}GB/{:.1}GB", + pool.usage_percent.unwrap_or(0.0), + pool.used_gb.unwrap_or(0.0), + pool.total_gb.unwrap_or(0.0) + ); + 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 !data_drives.is_empty() { - lines.push(Line::from(vec![ - Span::styled(" ├─ Data Disks:", Typography::secondary()) - ])); - for (i, drive) in data_drives.iter().enumerate() { - render_pool_drive(drive, i == data_drives.len() - 1 && parity_drives.is_empty(), &mut lines); - } - } - - if !parity_drives.is_empty() { - lines.push(Line::from(vec![ - Span::styled(" └─ Parity:", Typography::secondary()) - ])); - for (i, drive) in parity_drives.iter().enumerate() { - render_pool_drive(drive, i == parity_drives.len() - 1, &mut lines); - } + // Data Disks section + if !pool.data_drives.is_empty() { + lines.push(Line::from(vec![ + Span::styled(" ├─ Data Disks:", Typography::secondary()) + ])); + 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() { + let parity_symbol = " ├─ Parity: "; + 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(parity_symbol, Typography::tree()), + ]; + 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 +450,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>) { + 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 fn render_pool_drive(drive: &StorageDrive, is_last: bool, lines: &mut Vec>) { let tree_symbol = if is_last { " └─" } else { " ├─" }; diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 6d1674a..8f175ae 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.151" +version = "0.1.152" edition = "2021" [dependencies]