Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9384d9df6 | |||
| 156d707377 | |||
| dc1a2e3a0f |
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.95"
|
||||
version = "0.1.98"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -301,7 +301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.95"
|
||||
version = "0.1.98"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -324,7 +324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.95"
|
||||
version = "0.1.98"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.96"
|
||||
version = "0.1.99"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -25,6 +25,25 @@ impl BackupCollector {
|
||||
}
|
||||
|
||||
async fn read_backup_status(&self) -> Result<Option<BackupStatusToml>, CollectorError> {
|
||||
// Check if we're in maintenance mode
|
||||
if std::fs::metadata("/tmp/cm-maintenance").is_ok() {
|
||||
// Return special maintenance mode status
|
||||
let maintenance_status = BackupStatusToml {
|
||||
backup_name: "maintenance".to_string(),
|
||||
start_time: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||
current_time: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||
duration_seconds: 0,
|
||||
status: "pending".to_string(),
|
||||
last_updated: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||
disk_space: None,
|
||||
disk_product_name: None,
|
||||
disk_serial_number: None,
|
||||
disk_wear_percent: None,
|
||||
services: HashMap::new(),
|
||||
};
|
||||
return Ok(Some(maintenance_status));
|
||||
}
|
||||
|
||||
// Check if backup status file exists
|
||||
if !std::path::Path::new(&self.backup_status_file).exists() {
|
||||
return Ok(None); // File doesn't exist, but this is not an error
|
||||
@@ -79,7 +98,9 @@ impl BackupCollector {
|
||||
}
|
||||
}
|
||||
"failed" => Status::Critical,
|
||||
"warning" => Status::Warning, // Backup completed with warnings
|
||||
"running" => Status::Ok, // Currently running is OK
|
||||
"pending" => Status::Pending, // Maintenance mode or backup starting
|
||||
_ => Status::Unknown,
|
||||
}
|
||||
}
|
||||
@@ -379,6 +400,25 @@ impl Collector for BackupCollector {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(wear_percent) = backup_status.disk_wear_percent {
|
||||
let wear_status = if wear_percent >= 90.0 {
|
||||
Status::Critical
|
||||
} else if wear_percent >= 75.0 {
|
||||
Status::Warning
|
||||
} else {
|
||||
Status::Ok
|
||||
};
|
||||
|
||||
metrics.push(Metric {
|
||||
name: "backup_disk_wear_percent".to_string(),
|
||||
value: MetricValue::Float(wear_percent),
|
||||
status: wear_status,
|
||||
timestamp,
|
||||
description: Some("Backup disk wear percentage from SMART data".to_string()),
|
||||
unit: Some("percent".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Count services by status
|
||||
let mut status_counts = HashMap::new();
|
||||
for service in backup_status.services.values() {
|
||||
@@ -412,6 +452,7 @@ pub struct BackupStatusToml {
|
||||
pub disk_space: Option<DiskSpace>,
|
||||
pub disk_product_name: Option<String>,
|
||||
pub disk_serial_number: Option<String>,
|
||||
pub disk_wear_percent: Option<f32>,
|
||||
pub services: HashMap<String, ServiceStatus>,
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,45 @@ struct StoragePool {
|
||||
name: String, // e.g., "steampool", "root"
|
||||
mount_point: String, // e.g., "/mnt/steampool", "/"
|
||||
filesystem: String, // e.g., "mergerfs", "ext4", "zfs", "btrfs"
|
||||
storage_type: String, // e.g., "mergerfs", "single", "raid", "zfs"
|
||||
pool_type: StoragePoolType, // Enhanced pool type with configuration
|
||||
size: String, // e.g., "2.5TB"
|
||||
used: String, // e.g., "2.1TB"
|
||||
available: String, // e.g., "400GB"
|
||||
usage_percent: f32, // e.g., 85.0
|
||||
underlying_drives: Vec<DriveInfo>, // Individual physical drives
|
||||
pool_health: PoolHealth, // Overall pool health status
|
||||
}
|
||||
|
||||
/// Enhanced storage pool types with specific configurations
|
||||
#[derive(Debug, Clone)]
|
||||
enum StoragePoolType {
|
||||
Single, // Traditional single disk
|
||||
MergerfsPool { // MergerFS with optional parity
|
||||
data_disks: Vec<String>, // Member disk names (sdb, sdd)
|
||||
parity_disks: Vec<String>, // Parity disk names (sdc)
|
||||
},
|
||||
#[allow(dead_code)]
|
||||
RaidArray { // Hardware RAID (future)
|
||||
level: String, // "RAID1", "RAID5", etc.
|
||||
member_disks: Vec<String>,
|
||||
spare_disks: Vec<String>,
|
||||
},
|
||||
#[allow(dead_code)]
|
||||
ZfsPool { // ZFS pool (future)
|
||||
pool_name: String,
|
||||
vdevs: Vec<String>,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pool health status for redundant storage
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum PoolHealth {
|
||||
Healthy, // All drives OK, parity current
|
||||
Degraded, // One drive failed or parity outdated, still functional
|
||||
Critical, // Multiple failures, data at risk
|
||||
#[allow(dead_code)]
|
||||
Rebuilding, // Actively rebuilding/scrubbing (future: SnapRAID status integration)
|
||||
Unknown, // Cannot determine status
|
||||
}
|
||||
|
||||
/// Information about an individual physical drive
|
||||
@@ -75,12 +108,39 @@ impl DiskCollector {
|
||||
/// Get configured storage pools with individual drive information
|
||||
fn get_configured_storage_pools(&self) -> Result<Vec<StoragePool>> {
|
||||
let mut storage_pools = Vec::new();
|
||||
let mut processed_pools = std::collections::HashSet::new();
|
||||
|
||||
// First pass: Create enhanced pools (mergerfs, etc.)
|
||||
for fs_config in &self.config.filesystems {
|
||||
if !fs_config.monitor {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (pool_type, skip_in_single_mode) = self.determine_pool_type(&fs_config.storage_type);
|
||||
|
||||
// Skip member disks if they're part of a pool
|
||||
if skip_in_single_mode {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this pool was already processed (in case of multiple member disks)
|
||||
let pool_key = match &pool_type {
|
||||
StoragePoolType::MergerfsPool { .. } => {
|
||||
// For mergerfs pools, use the main mount point
|
||||
if fs_config.fs_type == "fuse.mergerfs" {
|
||||
fs_config.mount_point.clone()
|
||||
} else {
|
||||
continue; // Skip member disks
|
||||
}
|
||||
}
|
||||
_ => fs_config.mount_point.clone()
|
||||
};
|
||||
|
||||
if processed_pools.contains(&pool_key) {
|
||||
continue;
|
||||
}
|
||||
processed_pools.insert(pool_key.clone());
|
||||
|
||||
// Get filesystem stats for the mount point
|
||||
match self.get_filesystem_info(&fs_config.mount_point) {
|
||||
Ok((total_bytes, used_bytes)) => {
|
||||
@@ -96,25 +156,29 @@ impl DiskCollector {
|
||||
let used = self.bytes_to_human_readable(used_bytes);
|
||||
let available = self.bytes_to_human_readable(available_bytes);
|
||||
|
||||
// Get individual drive information using pre-detected devices
|
||||
let device_names = self.detected_devices.get(&fs_config.mount_point).cloned().unwrap_or_default();
|
||||
let underlying_drives = self.get_drive_info_for_devices(&device_names)?;
|
||||
// Get underlying drives based on pool type
|
||||
let underlying_drives = self.get_pool_drives(&pool_type, &fs_config.mount_point)?;
|
||||
|
||||
// Calculate pool health
|
||||
let pool_health = self.calculate_pool_health(&pool_type, &underlying_drives);
|
||||
let drive_count = underlying_drives.len();
|
||||
|
||||
storage_pools.push(StoragePool {
|
||||
name: fs_config.name.clone(),
|
||||
mount_point: fs_config.mount_point.clone(),
|
||||
filesystem: fs_config.fs_type.clone(),
|
||||
storage_type: fs_config.storage_type.clone(),
|
||||
pool_type: pool_type.clone(),
|
||||
size,
|
||||
used,
|
||||
available,
|
||||
usage_percent: usage_percent as f32,
|
||||
underlying_drives,
|
||||
pool_health,
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Storage pool '{}' ({}) at {} with {} detected drives",
|
||||
fs_config.name, fs_config.storage_type, fs_config.mount_point, device_names.len()
|
||||
"Storage pool '{}' ({:?}) at {} with {} drives, health: {:?}",
|
||||
fs_config.name, pool_type, fs_config.mount_point, drive_count, pool_health
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -129,6 +193,123 @@ impl DiskCollector {
|
||||
Ok(storage_pools)
|
||||
}
|
||||
|
||||
/// Determine the storage pool type from configuration
|
||||
fn determine_pool_type(&self, storage_type: &str) -> (StoragePoolType, bool) {
|
||||
match storage_type {
|
||||
"single" => (StoragePoolType::Single, false),
|
||||
"mergerfs_pool" | "mergerfs" => {
|
||||
// Find associated member disks
|
||||
let data_disks = self.find_pool_member_disks("mergerfs_member");
|
||||
let parity_disks = self.find_pool_member_disks("parity");
|
||||
(StoragePoolType::MergerfsPool { data_disks, parity_disks }, false)
|
||||
}
|
||||
"mergerfs_member" => (StoragePoolType::Single, true), // Skip, part of pool
|
||||
"parity" => (StoragePoolType::Single, true), // Skip, part of pool
|
||||
"raid1" | "raid5" | "raid6" => {
|
||||
let member_disks = self.find_pool_member_disks(&format!("{}_member", storage_type));
|
||||
(StoragePoolType::RaidArray {
|
||||
level: storage_type.to_uppercase(),
|
||||
member_disks,
|
||||
spare_disks: Vec::new()
|
||||
}, false)
|
||||
}
|
||||
_ => (StoragePoolType::Single, false) // Default to single
|
||||
}
|
||||
}
|
||||
|
||||
/// Find member disks for a specific storage type
|
||||
fn find_pool_member_disks(&self, member_type: &str) -> Vec<String> {
|
||||
let mut member_disks = Vec::new();
|
||||
|
||||
for fs_config in &self.config.filesystems {
|
||||
if fs_config.storage_type == member_type && fs_config.monitor {
|
||||
// Get device names for this mount point
|
||||
if let Some(devices) = self.detected_devices.get(&fs_config.mount_point) {
|
||||
member_disks.extend(devices.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
member_disks
|
||||
}
|
||||
|
||||
/// Get drive information for a specific pool type
|
||||
fn get_pool_drives(&self, pool_type: &StoragePoolType, mount_point: &str) -> Result<Vec<DriveInfo>> {
|
||||
match pool_type {
|
||||
StoragePoolType::Single => {
|
||||
// Single disk - use detected devices for this mount point
|
||||
let device_names = self.detected_devices.get(mount_point).cloned().unwrap_or_default();
|
||||
self.get_drive_info_for_devices(&device_names)
|
||||
}
|
||||
StoragePoolType::MergerfsPool { data_disks, parity_disks } => {
|
||||
// Mergerfs pool - collect all member drives
|
||||
let mut all_disks = data_disks.clone();
|
||||
all_disks.extend(parity_disks.clone());
|
||||
self.get_drive_info_for_devices(&all_disks)
|
||||
}
|
||||
StoragePoolType::RaidArray { member_disks, spare_disks, .. } => {
|
||||
// RAID array - collect member and spare drives
|
||||
let mut all_disks = member_disks.clone();
|
||||
all_disks.extend(spare_disks.clone());
|
||||
self.get_drive_info_for_devices(&all_disks)
|
||||
}
|
||||
StoragePoolType::ZfsPool { .. } => {
|
||||
// ZFS pool - use detected devices (future implementation)
|
||||
let device_names = self.detected_devices.get(mount_point).cloned().unwrap_or_default();
|
||||
self.get_drive_info_for_devices(&device_names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate pool health based on drive status and pool type
|
||||
fn calculate_pool_health(&self, pool_type: &StoragePoolType, drives: &[DriveInfo]) -> PoolHealth {
|
||||
match pool_type {
|
||||
StoragePoolType::Single => {
|
||||
// Single disk - health is just the drive health
|
||||
if drives.is_empty() {
|
||||
PoolHealth::Unknown
|
||||
} else if drives.iter().all(|d| d.health_status == "PASSED") {
|
||||
PoolHealth::Healthy
|
||||
} else {
|
||||
PoolHealth::Critical
|
||||
}
|
||||
}
|
||||
StoragePoolType::MergerfsPool { data_disks, parity_disks } => {
|
||||
let failed_data = drives.iter()
|
||||
.filter(|d| data_disks.contains(&d.device) && d.health_status != "PASSED")
|
||||
.count();
|
||||
let failed_parity = drives.iter()
|
||||
.filter(|d| parity_disks.contains(&d.device) && d.health_status != "PASSED")
|
||||
.count();
|
||||
|
||||
match (failed_data, failed_parity) {
|
||||
(0, 0) => PoolHealth::Healthy,
|
||||
(1, 0) => PoolHealth::Degraded, // Can recover with parity
|
||||
(0, 1) => PoolHealth::Degraded, // Lost parity protection
|
||||
_ => PoolHealth::Critical, // Multiple failures
|
||||
}
|
||||
}
|
||||
StoragePoolType::RaidArray { level, .. } => {
|
||||
let failed_drives = drives.iter().filter(|d| d.health_status != "PASSED").count();
|
||||
|
||||
// Basic RAID health logic (can be enhanced per RAID level)
|
||||
match failed_drives {
|
||||
0 => PoolHealth::Healthy,
|
||||
1 if level.contains('1') || level.contains('5') || level.contains('6') => PoolHealth::Degraded,
|
||||
_ => PoolHealth::Critical,
|
||||
}
|
||||
}
|
||||
StoragePoolType::ZfsPool { .. } => {
|
||||
// ZFS health would require zpool status parsing (future)
|
||||
if drives.iter().all(|d| d.health_status == "PASSED") {
|
||||
PoolHealth::Healthy
|
||||
} else {
|
||||
PoolHealth::Degraded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get drive information for a list of device names
|
||||
fn get_drive_info_for_devices(&self, device_names: &[String]) -> Result<Vec<DriveInfo>> {
|
||||
let mut drives = Vec::new();
|
||||
@@ -448,8 +629,8 @@ impl Collector for DiskCollector {
|
||||
let used_gb = self.parse_size_to_gb(&storage_pool.used);
|
||||
let avail_gb = self.parse_size_to_gb(&storage_pool.available);
|
||||
|
||||
// Calculate status based on configured thresholds
|
||||
let pool_status = if storage_pool.usage_percent >= self.config.usage_critical_percent {
|
||||
// Calculate status based on configured thresholds and pool health
|
||||
let usage_status = if storage_pool.usage_percent >= self.config.usage_critical_percent {
|
||||
Status::Critical
|
||||
} else if storage_pool.usage_percent >= self.config.usage_warning_percent {
|
||||
Status::Warning
|
||||
@@ -457,6 +638,14 @@ impl Collector for DiskCollector {
|
||||
Status::Ok
|
||||
};
|
||||
|
||||
let pool_status = match storage_pool.pool_health {
|
||||
PoolHealth::Critical => Status::Critical,
|
||||
PoolHealth::Degraded => Status::Warning,
|
||||
PoolHealth::Rebuilding => Status::Warning,
|
||||
PoolHealth::Healthy => usage_status,
|
||||
PoolHealth::Unknown => Status::Unknown,
|
||||
};
|
||||
|
||||
// Storage pool info metrics
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_mount_point", pool_name),
|
||||
@@ -476,15 +665,47 @@ impl Collector for DiskCollector {
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// Enhanced pool type information
|
||||
let pool_type_str = match &storage_pool.pool_type {
|
||||
StoragePoolType::Single => "single".to_string(),
|
||||
StoragePoolType::MergerfsPool { data_disks, parity_disks } => {
|
||||
format!("mergerfs ({}+{})", data_disks.len(), parity_disks.len())
|
||||
}
|
||||
StoragePoolType::RaidArray { level, member_disks, spare_disks } => {
|
||||
format!("{} ({}+{})", level, member_disks.len(), spare_disks.len())
|
||||
}
|
||||
StoragePoolType::ZfsPool { pool_name, .. } => {
|
||||
format!("zfs ({})", pool_name)
|
||||
}
|
||||
};
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_storage_type", pool_name),
|
||||
value: MetricValue::String(storage_pool.storage_type.clone()),
|
||||
name: format!("disk_{}_pool_type", pool_name),
|
||||
value: MetricValue::String(pool_type_str.clone()),
|
||||
unit: None,
|
||||
description: Some(format!("Type: {}", storage_pool.storage_type)),
|
||||
description: Some(format!("Type: {}", pool_type_str)),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// Pool health status
|
||||
let health_str = match storage_pool.pool_health {
|
||||
PoolHealth::Healthy => "healthy",
|
||||
PoolHealth::Degraded => "degraded",
|
||||
PoolHealth::Critical => "critical",
|
||||
PoolHealth::Rebuilding => "rebuilding",
|
||||
PoolHealth::Unknown => "unknown",
|
||||
};
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_pool_health", pool_name),
|
||||
value: MetricValue::String(health_str.to_string()),
|
||||
unit: None,
|
||||
description: Some(format!("Health: {}", health_str)),
|
||||
status: pool_status,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// Storage pool size metrics
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_total_gb", pool_name),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.96"
|
||||
version = "0.1.99"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -589,12 +589,13 @@ impl TuiApp {
|
||||
// Split the title bar into left and right sections
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(15), Constraint::Min(0)])
|
||||
.constraints([Constraint::Length(22), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
// Left side: "cm-dashboard" text
|
||||
// Left side: "cm-dashboard" text with version
|
||||
let title_text = format!(" cm-dashboard v{}", env!("CARGO_PKG_VERSION"));
|
||||
let left_span = Span::styled(
|
||||
" cm-dashboard",
|
||||
&title_text,
|
||||
Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD)
|
||||
);
|
||||
let left_title = Paragraph::new(Line::from(vec![left_span]))
|
||||
@@ -666,32 +667,27 @@ impl TuiApp {
|
||||
return host_summary_metric.status;
|
||||
}
|
||||
|
||||
// Fallback to old aggregation logic with proper Pending handling
|
||||
// Rewritten status aggregation - only Critical, Warning, or OK for top bar
|
||||
let mut has_critical = false;
|
||||
let mut has_warning = false;
|
||||
let mut ok_count = 0;
|
||||
|
||||
for metric in &metrics {
|
||||
match metric.status {
|
||||
Status::Critical => has_critical = true,
|
||||
Status::Warning => has_warning = true,
|
||||
Status::Pending => ok_count += 1, // Treat pending as OK for aggregation
|
||||
Status::Ok => ok_count += 1,
|
||||
Status::Inactive => ok_count += 1, // Treat inactive as OK for aggregation
|
||||
Status::Unknown => {}, // Ignore unknown for aggregation
|
||||
Status::Offline => {}, // Ignore offline for aggregation
|
||||
// Treat all other statuses as OK for top bar aggregation
|
||||
Status::Ok | Status::Pending | Status::Inactive | Status::Unknown => {},
|
||||
Status::Offline => {}, // Ignore offline
|
||||
}
|
||||
}
|
||||
|
||||
// Priority order: Critical > Warning > Ok > Unknown (no Pending)
|
||||
// Only return Critical, Warning, or OK - no other statuses
|
||||
if has_critical {
|
||||
Status::Critical
|
||||
} else if has_warning {
|
||||
Status::Warning
|
||||
} else if ok_count > 0 {
|
||||
Status::Ok
|
||||
} else {
|
||||
Status::Unknown
|
||||
Status::Ok
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct BackupWidget {
|
||||
backup_disk_product_name: Option<String>,
|
||||
/// Backup disk serial number from SMART data
|
||||
backup_disk_serial_number: Option<String>,
|
||||
/// Backup disk wear percentage from SMART data
|
||||
backup_disk_wear_percent: Option<f32>,
|
||||
/// Backup disk filesystem label
|
||||
backup_disk_filesystem_label: Option<String>,
|
||||
/// Number of completed services
|
||||
@@ -65,6 +67,7 @@ impl BackupWidget {
|
||||
backup_disk_used_gb: None,
|
||||
backup_disk_product_name: None,
|
||||
backup_disk_serial_number: None,
|
||||
backup_disk_wear_percent: None,
|
||||
backup_disk_filesystem_label: None,
|
||||
services_completed_count: None,
|
||||
services_failed_count: None,
|
||||
@@ -197,6 +200,9 @@ impl Widget for BackupWidget {
|
||||
"backup_disk_serial_number" => {
|
||||
self.backup_disk_serial_number = Some(metric.value.as_string());
|
||||
}
|
||||
"backup_disk_wear_percent" => {
|
||||
self.backup_disk_wear_percent = metric.value.as_f32();
|
||||
}
|
||||
"backup_disk_filesystem_label" => {
|
||||
self.backup_disk_filesystem_label = Some(metric.value.as_string());
|
||||
}
|
||||
@@ -328,21 +334,31 @@ impl BackupWidget {
|
||||
);
|
||||
lines.push(ratatui::text::Line::from(disk_spans));
|
||||
|
||||
// Serial number as sub-item
|
||||
// Collect sub-items to determine tree structure
|
||||
let mut sub_items = Vec::new();
|
||||
|
||||
if let Some(serial) = &self.backup_disk_serial_number {
|
||||
lines.push(ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled(" ├─ ", Typography::tree()),
|
||||
ratatui::text::Span::styled(format!("S/N: {}", serial), Typography::secondary())
|
||||
]));
|
||||
sub_items.push(format!("S/N: {}", serial));
|
||||
}
|
||||
|
||||
// Usage as sub-item
|
||||
|
||||
if let Some(wear) = self.backup_disk_wear_percent {
|
||||
sub_items.push(format!("Wear: {:.0}%", wear));
|
||||
}
|
||||
|
||||
if let (Some(used), Some(total)) = (self.backup_disk_used_gb, self.backup_disk_total_gb) {
|
||||
let used_str = Self::format_size_with_proper_units(used);
|
||||
let total_str = Self::format_size_with_proper_units(total);
|
||||
sub_items.push(format!("Usage: {}/{}", used_str, total_str));
|
||||
}
|
||||
|
||||
// Render sub-items with proper tree structure
|
||||
let num_items = sub_items.len();
|
||||
for (i, item) in sub_items.into_iter().enumerate() {
|
||||
let is_last = i == num_items - 1;
|
||||
let tree_char = if is_last { " └─ " } else { " ├─ " };
|
||||
lines.push(ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled(" └─ ", Typography::tree()),
|
||||
ratatui::text::Span::styled(format!("Usage: {}/{}", used_str, total_str), Typography::secondary())
|
||||
ratatui::text::Span::styled(tree_char, Typography::tree()),
|
||||
ratatui::text::Span::styled(item, Typography::secondary())
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -513,48 +602,9 @@ impl SystemWidget {
|
||||
Span::styled("Storage:", Typography::widget_title())
|
||||
]));
|
||||
|
||||
// Storage items with overflow handling
|
||||
// Storage items - let main overflow logic handle truncation
|
||||
let storage_lines = self.render_storage();
|
||||
let remaining_space = area.height.saturating_sub(lines.len() as u16);
|
||||
|
||||
if storage_lines.len() <= remaining_space as usize {
|
||||
// All storage lines fit
|
||||
lines.extend(storage_lines);
|
||||
} else if remaining_space >= 2 {
|
||||
// Show what we can and add overflow indicator
|
||||
let lines_to_show = (remaining_space - 1) as usize; // Reserve 1 line for overflow
|
||||
lines.extend(storage_lines.iter().take(lines_to_show).cloned());
|
||||
|
||||
// Count hidden pools
|
||||
let mut hidden_pools = 0;
|
||||
let mut current_pool = String::new();
|
||||
for (i, line) in storage_lines.iter().enumerate() {
|
||||
if i >= lines_to_show {
|
||||
// Check if this line represents a new pool (no indentation)
|
||||
if let Some(first_span) = line.spans.first() {
|
||||
let text = first_span.content.as_ref();
|
||||
if !text.starts_with(" ") && text.contains(':') {
|
||||
let pool_name = text.split(':').next().unwrap_or("").trim();
|
||||
if pool_name != current_pool {
|
||||
hidden_pools += 1;
|
||||
current_pool = pool_name.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hidden_pools > 0 {
|
||||
let overflow_text = format!(
|
||||
"... and {} more pool{}",
|
||||
hidden_pools,
|
||||
if hidden_pools == 1 { "" } else { "s" }
|
||||
);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(overflow_text, Typography::muted())
|
||||
]));
|
||||
}
|
||||
}
|
||||
lines.extend(storage_lines);
|
||||
|
||||
// Apply scroll offset
|
||||
let total_lines = lines.len();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.96"
|
||||
version = "0.1.99"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -82,13 +82,13 @@ impl MetricValue {
|
||||
/// Health status for metrics
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Status {
|
||||
Inactive, // Lowest priority - treated as good
|
||||
Ok, // Second lowest - also good
|
||||
Unknown,
|
||||
Offline,
|
||||
Pending,
|
||||
Warning,
|
||||
Critical,
|
||||
Inactive, // Lowest priority
|
||||
Unknown, //
|
||||
Offline, //
|
||||
Pending, //
|
||||
Ok, // 5th place - good status has higher priority than unknown states
|
||||
Warning, //
|
||||
Critical, // Highest priority
|
||||
}
|
||||
|
||||
impl Status {
|
||||
|
||||
Reference in New Issue
Block a user