Implement enhanced storage pool visualization
All checks were successful
Build and Release / build-and-release (push) Successful in 2m34s
All checks were successful
Build and Release / build-and-release (push) Successful in 2m34s
- Add support for mergerfs pool grouping with data and parity disk separation - Implement pool health monitoring (healthy/degraded/critical status) - Create hierarchical tree view for multi-disk storage arrays - Add automatic pool type detection and member disk association - Maintain backward compatibility for single disk configurations - Support future extension for RAID and ZFS pool types
This commit is contained in:
@@ -45,12 +45,14 @@ pub struct SystemWidget {
|
||||
struct StoragePool {
|
||||
name: String,
|
||||
mount_point: String,
|
||||
pool_type: String, // "Single", "Raid0", etc.
|
||||
pool_type: String, // "single", "mergerfs (2+1)", "RAID5 (3+1)", etc.
|
||||
pool_health: Option<String>, // "healthy", "degraded", "critical", "rebuilding"
|
||||
drives: Vec<StorageDrive>,
|
||||
usage_percent: Option<f32>,
|
||||
used_gb: Option<f32>,
|
||||
total_gb: Option<f32>,
|
||||
status: Status,
|
||||
health_status: Status, // Separate status for pool health vs usage
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -155,12 +157,14 @@ impl SystemWidget {
|
||||
let pool = pools.entry(pool_name.clone()).or_insert_with(|| StoragePool {
|
||||
name: pool_name.clone(),
|
||||
mount_point: mount_point.clone(),
|
||||
pool_type: "Single".to_string(), // Default, could be enhanced
|
||||
pool_type: "single".to_string(), // Default, will be updated
|
||||
pool_health: None,
|
||||
drives: Vec::new(),
|
||||
usage_percent: None,
|
||||
used_gb: None,
|
||||
total_gb: None,
|
||||
status: Status::Unknown,
|
||||
health_status: Status::Unknown,
|
||||
});
|
||||
|
||||
// Parse different metric types
|
||||
@@ -177,6 +181,15 @@ impl SystemWidget {
|
||||
if let MetricValue::Float(total) = metric.value {
|
||||
pool.total_gb = Some(total);
|
||||
}
|
||||
} else if metric.name.contains("_pool_type") {
|
||||
if let MetricValue::String(pool_type) = &metric.value {
|
||||
pool.pool_type = pool_type.clone();
|
||||
}
|
||||
} else if metric.name.contains("_pool_health") {
|
||||
if let MetricValue::String(health) = &metric.value {
|
||||
pool.pool_health = Some(health.clone());
|
||||
pool.health_status = metric.status.clone();
|
||||
}
|
||||
} else if metric.name.contains("_temperature") {
|
||||
if let Some(drive_name) = self.extract_drive_name(&metric.name) {
|
||||
// Find existing drive or create new one
|
||||
@@ -277,73 +290,149 @@ impl SystemWidget {
|
||||
None
|
||||
}
|
||||
|
||||
/// Render storage section with tree structure
|
||||
/// Render storage section with enhanced tree structure
|
||||
fn render_storage(&self) -> Vec<Line<'_>> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for pool in &self.storage_pools {
|
||||
// Pool header line
|
||||
let usage_text = match (pool.usage_percent, pool.used_gb, pool.total_gb) {
|
||||
(Some(pct), Some(used), Some(total)) => {
|
||||
format!("{:.0}% {:.1}GB/{:.1}GB", pct, used, total)
|
||||
}
|
||||
_ => "—% —GB/—GB".to_string(),
|
||||
};
|
||||
|
||||
let pool_label = if pool.pool_type.to_lowercase() == "single" {
|
||||
// Pool header line with type and health
|
||||
let pool_label = if pool.pool_type == "single" {
|
||||
format!("{}:", pool.mount_point)
|
||||
} else {
|
||||
format!("{} ({}):", pool.mount_point, pool.pool_type)
|
||||
};
|
||||
let pool_spans = StatusIcons::create_status_spans(
|
||||
pool.status.clone(),
|
||||
pool.health_status.clone(),
|
||||
&pool_label
|
||||
);
|
||||
lines.push(Line::from(pool_spans));
|
||||
|
||||
// Drive lines with tree structure
|
||||
let has_usage_line = pool.usage_percent.is_some();
|
||||
for (i, drive) in pool.drives.iter().enumerate() {
|
||||
let is_last_drive = i == pool.drives.len() - 1;
|
||||
let tree_symbol = if is_last_drive && !has_usage_line { "└─" } else { "├─" };
|
||||
|
||||
let mut drive_info = Vec::new();
|
||||
if let Some(temp) = drive.temperature {
|
||||
drive_info.push(format!("T: {:.0}C", temp));
|
||||
// Pool health line (for multi-disk pools)
|
||||
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));
|
||||
}
|
||||
if let Some(wear) = drive.wear_percent {
|
||||
drive_info.push(format!("W: {:.0}%", wear));
|
||||
}
|
||||
let drive_text = if drive_info.is_empty() {
|
||||
drive.name.clone()
|
||||
} else {
|
||||
format!("{} {}", drive.name, drive_info.join(" • "))
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// Usage line
|
||||
if pool.usage_percent.is_some() {
|
||||
let tree_symbol = "└─";
|
||||
let mut usage_spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(tree_symbol, Typography::tree()),
|
||||
Span::raw(" "),
|
||||
];
|
||||
usage_spans.extend(StatusIcons::create_status_spans(pool.status.clone(), &usage_text));
|
||||
lines.push(Line::from(usage_spans));
|
||||
// Total usage line (always show for pools)
|
||||
let usage_text = match (pool.usage_percent, pool.used_gb, pool.total_gb) {
|
||||
(Some(pct), Some(used), Some(total)) => {
|
||||
format!("Total: {:.0}% {:.1}GB/{:.1}GB", pct, used, total)
|
||||
}
|
||||
_ => "Total: —% —GB/—GB".to_string(),
|
||||
};
|
||||
|
||||
let has_drives = !pool.drives.is_empty();
|
||||
let tree_symbol = if has_drives { "├─" } else { "└─" };
|
||||
let mut usage_spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(tree_symbol, Typography::tree()),
|
||||
Span::raw(" "),
|
||||
];
|
||||
usage_spans.extend(StatusIcons::create_status_spans(pool.status.clone(), &usage_text));
|
||||
lines.push(Line::from(usage_spans));
|
||||
|
||||
// Drive lines with enhanced grouping
|
||||
if pool.pool_type != "single" && pool.drives.len() > 1 {
|
||||
// Group drives by type for mergerfs pools
|
||||
let (data_drives, parity_drives): (Vec<_>, Vec<_>) = pool.drives.iter().enumerate()
|
||||
.partition(|(_, drive)| {
|
||||
// Simple heuristic: drives with 'parity' in name or sdc (common parity drive)
|
||||
!drive.name.to_lowercase().contains("parity") && drive.name != "sdc"
|
||||
});
|
||||
|
||||
// Show data drives
|
||||
if !data_drives.is_empty() && pool.pool_type.contains("mergerfs") {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("├─ ", Typography::tree()),
|
||||
Span::styled("Data Disks:", Typography::secondary()),
|
||||
]));
|
||||
|
||||
for (i, (_, drive)) in data_drives.iter().enumerate() {
|
||||
let is_last = i == data_drives.len() - 1;
|
||||
if is_last && parity_drives.is_empty() {
|
||||
self.render_drive_line(&mut lines, drive, "│ └─");
|
||||
} else {
|
||||
self.render_drive_line(&mut lines, drive, "│ ├─");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show parity drives
|
||||
if !parity_drives.is_empty() && pool.pool_type.contains("mergerfs") {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("└─ ", Typography::tree()),
|
||||
Span::styled("Parity:", Typography::secondary()),
|
||||
]));
|
||||
|
||||
for (i, (_, drive)) in parity_drives.iter().enumerate() {
|
||||
let is_last = i == parity_drives.len() - 1;
|
||||
if is_last {
|
||||
self.render_drive_line(&mut lines, drive, " └─");
|
||||
} else {
|
||||
self.render_drive_line(&mut lines, drive, " ├─");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular drive listing for non-mergerfs pools
|
||||
for (i, drive) in pool.drives.iter().enumerate() {
|
||||
let is_last = i == pool.drives.len() - 1;
|
||||
let tree_symbol = if is_last { "└─" } else { "├─" };
|
||||
self.render_drive_line(&mut lines, drive, tree_symbol);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single drive or simple pools
|
||||
for (i, drive) in pool.drives.iter().enumerate() {
|
||||
let is_last = i == pool.drives.len() - 1;
|
||||
let tree_symbol = if is_last { "└─" } else { "├─" };
|
||||
self.render_drive_line(&mut lines, drive, tree_symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Helper to render a single drive line
|
||||
fn render_drive_line<'a>(&self, lines: &mut Vec<Line<'a>>, drive: &StorageDrive, tree_symbol: &'a str) {
|
||||
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() {
|
||||
drive.name.clone()
|
||||
} else {
|
||||
format!("{} {}", drive.name, drive_info.join(" • "))
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for SystemWidget {
|
||||
|
||||
Reference in New Issue
Block a user