Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9384d9df6 | |||
| 156d707377 | |||
| dc1a2e3a0f | |||
| 5d6b8e6253 | |||
| 0cba083305 | |||
| a6be7a4788 |
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard"
|
name = "cm-dashboard"
|
||||||
version = "0.1.92"
|
version = "0.1.98"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -301,7 +301,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard-agent"
|
name = "cm-dashboard-agent"
|
||||||
version = "0.1.92"
|
version = "0.1.98"
|
||||||
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.92"
|
version = "0.1.98"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard-agent"
|
name = "cm-dashboard-agent"
|
||||||
version = "0.1.93"
|
version = "0.1.99"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ impl BackupCollector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn read_backup_status(&self) -> Result<Option<BackupStatusToml>, CollectorError> {
|
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
|
// Check if backup status file exists
|
||||||
if !std::path::Path::new(&self.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
|
return Ok(None); // File doesn't exist, but this is not an error
|
||||||
@@ -79,7 +98,9 @@ impl BackupCollector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"failed" => Status::Critical,
|
"failed" => Status::Critical,
|
||||||
|
"warning" => Status::Warning, // Backup completed with warnings
|
||||||
"running" => Status::Ok, // Currently running is OK
|
"running" => Status::Ok, // Currently running is OK
|
||||||
|
"pending" => Status::Pending, // Maintenance mode or backup starting
|
||||||
_ => Status::Unknown,
|
_ => 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
|
// Count services by status
|
||||||
let mut status_counts = HashMap::new();
|
let mut status_counts = HashMap::new();
|
||||||
for service in backup_status.services.values() {
|
for service in backup_status.services.values() {
|
||||||
@@ -412,6 +452,7 @@ pub struct BackupStatusToml {
|
|||||||
pub disk_space: Option<DiskSpace>,
|
pub disk_space: Option<DiskSpace>,
|
||||||
pub disk_product_name: Option<String>,
|
pub disk_product_name: Option<String>,
|
||||||
pub disk_serial_number: Option<String>,
|
pub disk_serial_number: Option<String>,
|
||||||
|
pub disk_wear_percent: Option<f32>,
|
||||||
pub services: HashMap<String, ServiceStatus>,
|
pub services: HashMap<String, ServiceStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,45 @@ struct StoragePool {
|
|||||||
name: String, // e.g., "steampool", "root"
|
name: String, // e.g., "steampool", "root"
|
||||||
mount_point: String, // e.g., "/mnt/steampool", "/"
|
mount_point: String, // e.g., "/mnt/steampool", "/"
|
||||||
filesystem: String, // e.g., "mergerfs", "ext4", "zfs", "btrfs"
|
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"
|
size: String, // e.g., "2.5TB"
|
||||||
used: String, // e.g., "2.1TB"
|
used: String, // e.g., "2.1TB"
|
||||||
available: String, // e.g., "400GB"
|
available: String, // e.g., "400GB"
|
||||||
usage_percent: f32, // e.g., 85.0
|
usage_percent: f32, // e.g., 85.0
|
||||||
underlying_drives: Vec<DriveInfo>, // Individual physical drives
|
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
|
/// Information about an individual physical drive
|
||||||
@@ -75,12 +108,39 @@ impl DiskCollector {
|
|||||||
/// Get configured storage pools with individual drive information
|
/// Get configured storage pools with individual drive information
|
||||||
fn get_configured_storage_pools(&self) -> Result<Vec<StoragePool>> {
|
fn get_configured_storage_pools(&self) -> Result<Vec<StoragePool>> {
|
||||||
let mut storage_pools = Vec::new();
|
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 {
|
for fs_config in &self.config.filesystems {
|
||||||
if !fs_config.monitor {
|
if !fs_config.monitor {
|
||||||
continue;
|
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
|
// Get filesystem stats for the mount point
|
||||||
match self.get_filesystem_info(&fs_config.mount_point) {
|
match self.get_filesystem_info(&fs_config.mount_point) {
|
||||||
Ok((total_bytes, used_bytes)) => {
|
Ok((total_bytes, used_bytes)) => {
|
||||||
@@ -96,25 +156,29 @@ impl DiskCollector {
|
|||||||
let used = self.bytes_to_human_readable(used_bytes);
|
let used = self.bytes_to_human_readable(used_bytes);
|
||||||
let available = self.bytes_to_human_readable(available_bytes);
|
let available = self.bytes_to_human_readable(available_bytes);
|
||||||
|
|
||||||
// Get individual drive information using pre-detected devices
|
// Get underlying drives based on pool type
|
||||||
let device_names = self.detected_devices.get(&fs_config.mount_point).cloned().unwrap_or_default();
|
let underlying_drives = self.get_pool_drives(&pool_type, &fs_config.mount_point)?;
|
||||||
let underlying_drives = self.get_drive_info_for_devices(&device_names)?;
|
|
||||||
|
// Calculate pool health
|
||||||
|
let pool_health = self.calculate_pool_health(&pool_type, &underlying_drives);
|
||||||
|
let drive_count = underlying_drives.len();
|
||||||
|
|
||||||
storage_pools.push(StoragePool {
|
storage_pools.push(StoragePool {
|
||||||
name: fs_config.name.clone(),
|
name: fs_config.name.clone(),
|
||||||
mount_point: fs_config.mount_point.clone(),
|
mount_point: fs_config.mount_point.clone(),
|
||||||
filesystem: fs_config.fs_type.clone(),
|
filesystem: fs_config.fs_type.clone(),
|
||||||
storage_type: fs_config.storage_type.clone(),
|
pool_type: pool_type.clone(),
|
||||||
size,
|
size,
|
||||||
used,
|
used,
|
||||||
available,
|
available,
|
||||||
usage_percent: usage_percent as f32,
|
usage_percent: usage_percent as f32,
|
||||||
underlying_drives,
|
underlying_drives,
|
||||||
|
pool_health,
|
||||||
});
|
});
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"Storage pool '{}' ({}) at {} with {} detected drives",
|
"Storage pool '{}' ({:?}) at {} with {} drives, health: {:?}",
|
||||||
fs_config.name, fs_config.storage_type, fs_config.mount_point, device_names.len()
|
fs_config.name, pool_type, fs_config.mount_point, drive_count, pool_health
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -129,6 +193,123 @@ impl DiskCollector {
|
|||||||
Ok(storage_pools)
|
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
|
/// Get drive information for a list of device names
|
||||||
fn get_drive_info_for_devices(&self, device_names: &[String]) -> Result<Vec<DriveInfo>> {
|
fn get_drive_info_for_devices(&self, device_names: &[String]) -> Result<Vec<DriveInfo>> {
|
||||||
let mut drives = Vec::new();
|
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 used_gb = self.parse_size_to_gb(&storage_pool.used);
|
||||||
let avail_gb = self.parse_size_to_gb(&storage_pool.available);
|
let avail_gb = self.parse_size_to_gb(&storage_pool.available);
|
||||||
|
|
||||||
// Calculate status based on configured thresholds
|
// Calculate status based on configured thresholds and pool health
|
||||||
let pool_status = if storage_pool.usage_percent >= self.config.usage_critical_percent {
|
let usage_status = if storage_pool.usage_percent >= self.config.usage_critical_percent {
|
||||||
Status::Critical
|
Status::Critical
|
||||||
} else if storage_pool.usage_percent >= self.config.usage_warning_percent {
|
} else if storage_pool.usage_percent >= self.config.usage_warning_percent {
|
||||||
Status::Warning
|
Status::Warning
|
||||||
@@ -457,6 +638,14 @@ impl Collector for DiskCollector {
|
|||||||
Status::Ok
|
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
|
// Storage pool info metrics
|
||||||
metrics.push(Metric {
|
metrics.push(Metric {
|
||||||
name: format!("disk_{}_mount_point", pool_name),
|
name: format!("disk_{}_mount_point", pool_name),
|
||||||
@@ -476,15 +665,47 @@ impl Collector for DiskCollector {
|
|||||||
timestamp,
|
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 {
|
metrics.push(Metric {
|
||||||
name: format!("disk_{}_storage_type", pool_name),
|
name: format!("disk_{}_pool_type", pool_name),
|
||||||
value: MetricValue::String(storage_pool.storage_type.clone()),
|
value: MetricValue::String(pool_type_str.clone()),
|
||||||
unit: None,
|
unit: None,
|
||||||
description: Some(format!("Type: {}", storage_pool.storage_type)),
|
description: Some(format!("Type: {}", pool_type_str)),
|
||||||
status: Status::Ok,
|
status: Status::Ok,
|
||||||
timestamp,
|
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
|
// Storage pool size metrics
|
||||||
metrics.push(Metric {
|
metrics.push(Metric {
|
||||||
name: format!("disk_{}_total_gb", pool_name),
|
name: format!("disk_{}_total_gb", pool_name),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard"
|
name = "cm-dashboard"
|
||||||
version = "0.1.93"
|
version = "0.1.99"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ pub struct SshConfig {
|
|||||||
pub rebuild_user: String,
|
pub rebuild_user: String,
|
||||||
pub rebuild_cmd: String,
|
pub rebuild_cmd: String,
|
||||||
pub service_manage_cmd: String,
|
pub service_manage_cmd: String,
|
||||||
pub service_logs_cmd: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service log file configuration per host
|
/// Service log file configuration per host
|
||||||
|
|||||||
@@ -311,14 +311,14 @@ impl TuiApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('L') => {
|
KeyCode::Char('L') => {
|
||||||
// Show service logs via script in tmux split window
|
// Show service logs via service-manage script in tmux split window
|
||||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||||
let connection_ip = self.get_connection_ip(&hostname);
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
let logs_command = format!(
|
let logs_command = format!(
|
||||||
"ssh -tt {}@{} '{} {}'",
|
"ssh -tt {}@{} '{} logs {}'",
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.rebuild_user,
|
||||||
connection_ip,
|
connection_ip,
|
||||||
self.config.ssh.service_logs_cmd,
|
self.config.ssh.service_manage_cmd,
|
||||||
service_name
|
service_name
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -589,12 +589,13 @@ impl TuiApp {
|
|||||||
// Split the title bar into left and right sections
|
// Split the title bar into left and right sections
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Length(15), Constraint::Min(0)])
|
.constraints([Constraint::Length(22), Constraint::Min(0)])
|
||||||
.split(area);
|
.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(
|
let left_span = Span::styled(
|
||||||
" cm-dashboard",
|
&title_text,
|
||||||
Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD)
|
Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD)
|
||||||
);
|
);
|
||||||
let left_title = Paragraph::new(Line::from(vec![left_span]))
|
let left_title = Paragraph::new(Line::from(vec![left_span]))
|
||||||
@@ -666,35 +667,27 @@ impl TuiApp {
|
|||||||
return host_summary_metric.status;
|
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_critical = false;
|
||||||
let mut has_warning = false;
|
let mut has_warning = false;
|
||||||
let mut has_pending = false;
|
|
||||||
let mut ok_count = 0;
|
|
||||||
|
|
||||||
for metric in &metrics {
|
for metric in &metrics {
|
||||||
match metric.status {
|
match metric.status {
|
||||||
Status::Critical => has_critical = true,
|
Status::Critical => has_critical = true,
|
||||||
Status::Warning => has_warning = true,
|
Status::Warning => has_warning = true,
|
||||||
Status::Pending => has_pending = true,
|
// Treat all other statuses as OK for top bar aggregation
|
||||||
Status::Ok => ok_count += 1,
|
Status::Ok | Status::Pending | Status::Inactive | Status::Unknown => {},
|
||||||
Status::Inactive => ok_count += 1, // Treat inactive as OK for aggregation
|
Status::Offline => {}, // Ignore offline
|
||||||
Status::Unknown => {}, // Ignore unknown for aggregation
|
|
||||||
Status::Offline => {}, // Ignore offline for aggregation
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority order: Critical > Warning > Pending > Ok > Unknown
|
// Only return Critical, Warning, or OK - no other statuses
|
||||||
if has_critical {
|
if has_critical {
|
||||||
Status::Critical
|
Status::Critical
|
||||||
} else if has_warning {
|
} else if has_warning {
|
||||||
Status::Warning
|
Status::Warning
|
||||||
} else if has_pending {
|
|
||||||
Status::Pending
|
|
||||||
} else if ok_count > 0 {
|
|
||||||
Status::Ok
|
|
||||||
} else {
|
} else {
|
||||||
Status::Unknown
|
Status::Ok
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ pub struct BackupWidget {
|
|||||||
backup_disk_product_name: Option<String>,
|
backup_disk_product_name: Option<String>,
|
||||||
/// Backup disk serial number from SMART data
|
/// Backup disk serial number from SMART data
|
||||||
backup_disk_serial_number: Option<String>,
|
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
|
||||||
backup_disk_filesystem_label: Option<String>,
|
backup_disk_filesystem_label: Option<String>,
|
||||||
/// Number of completed services
|
/// Number of completed services
|
||||||
@@ -65,6 +67,7 @@ impl BackupWidget {
|
|||||||
backup_disk_used_gb: None,
|
backup_disk_used_gb: None,
|
||||||
backup_disk_product_name: None,
|
backup_disk_product_name: None,
|
||||||
backup_disk_serial_number: None,
|
backup_disk_serial_number: None,
|
||||||
|
backup_disk_wear_percent: None,
|
||||||
backup_disk_filesystem_label: None,
|
backup_disk_filesystem_label: None,
|
||||||
services_completed_count: None,
|
services_completed_count: None,
|
||||||
services_failed_count: None,
|
services_failed_count: None,
|
||||||
@@ -197,6 +200,9 @@ impl Widget for BackupWidget {
|
|||||||
"backup_disk_serial_number" => {
|
"backup_disk_serial_number" => {
|
||||||
self.backup_disk_serial_number = Some(metric.value.as_string());
|
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" => {
|
"backup_disk_filesystem_label" => {
|
||||||
self.backup_disk_filesystem_label = Some(metric.value.as_string());
|
self.backup_disk_filesystem_label = Some(metric.value.as_string());
|
||||||
}
|
}
|
||||||
@@ -328,21 +334,31 @@ impl BackupWidget {
|
|||||||
);
|
);
|
||||||
lines.push(ratatui::text::Line::from(disk_spans));
|
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 {
|
if let Some(serial) = &self.backup_disk_serial_number {
|
||||||
lines.push(ratatui::text::Line::from(vec![
|
sub_items.push(format!("S/N: {}", serial));
|
||||||
ratatui::text::Span::styled(" ├─ ", Typography::tree()),
|
}
|
||||||
ratatui::text::Span::styled(format!("S/N: {}", serial), Typography::secondary())
|
|
||||||
]));
|
if let Some(wear) = self.backup_disk_wear_percent {
|
||||||
|
sub_items.push(format!("Wear: {:.0}%", wear));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage as sub-item
|
|
||||||
if let (Some(used), Some(total)) = (self.backup_disk_used_gb, self.backup_disk_total_gb) {
|
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 used_str = Self::format_size_with_proper_units(used);
|
||||||
let total_str = Self::format_size_with_proper_units(total);
|
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![
|
lines.push(ratatui::text::Line::from(vec![
|
||||||
ratatui::text::Span::styled(" └─ ", Typography::tree()),
|
ratatui::text::Span::styled(tree_char, Typography::tree()),
|
||||||
ratatui::text::Span::styled(format!("Usage: {}/{}", used_str, total_str), Typography::secondary())
|
ratatui::text::Span::styled(item, Typography::secondary())
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,12 +45,14 @@ pub struct SystemWidget {
|
|||||||
struct StoragePool {
|
struct StoragePool {
|
||||||
name: String,
|
name: String,
|
||||||
mount_point: 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>,
|
drives: Vec<StorageDrive>,
|
||||||
usage_percent: Option<f32>,
|
usage_percent: Option<f32>,
|
||||||
used_gb: Option<f32>,
|
used_gb: Option<f32>,
|
||||||
total_gb: Option<f32>,
|
total_gb: Option<f32>,
|
||||||
status: Status,
|
status: Status,
|
||||||
|
health_status: Status, // Separate status for pool health vs usage
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -155,12 +157,14 @@ impl SystemWidget {
|
|||||||
let pool = pools.entry(pool_name.clone()).or_insert_with(|| StoragePool {
|
let pool = pools.entry(pool_name.clone()).or_insert_with(|| StoragePool {
|
||||||
name: pool_name.clone(),
|
name: pool_name.clone(),
|
||||||
mount_point: mount_point.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(),
|
drives: Vec::new(),
|
||||||
usage_percent: None,
|
usage_percent: None,
|
||||||
used_gb: None,
|
used_gb: None,
|
||||||
total_gb: None,
|
total_gb: None,
|
||||||
status: Status::Unknown,
|
status: Status::Unknown,
|
||||||
|
health_status: Status::Unknown,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse different metric types
|
// Parse different metric types
|
||||||
@@ -177,6 +181,15 @@ impl SystemWidget {
|
|||||||
if let MetricValue::Float(total) = metric.value {
|
if let MetricValue::Float(total) = metric.value {
|
||||||
pool.total_gb = Some(total);
|
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") {
|
} else if metric.name.contains("_temperature") {
|
||||||
if let Some(drive_name) = self.extract_drive_name(&metric.name) {
|
if let Some(drive_name) = self.extract_drive_name(&metric.name) {
|
||||||
// Find existing drive or create new one
|
// Find existing drive or create new one
|
||||||
@@ -277,39 +290,131 @@ impl SystemWidget {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render storage section with tree structure
|
/// Render storage section with enhanced tree structure
|
||||||
fn render_storage(&self) -> Vec<Line<'_>> {
|
fn render_storage(&self) -> Vec<Line<'_>> {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
for pool in &self.storage_pools {
|
for pool in &self.storage_pools {
|
||||||
// Pool header line
|
// Pool header line with type and health
|
||||||
let usage_text = match (pool.usage_percent, pool.used_gb, pool.total_gb) {
|
let pool_label = if pool.pool_type == "single" {
|
||||||
(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" {
|
|
||||||
format!("{}:", pool.mount_point)
|
format!("{}:", pool.mount_point)
|
||||||
} else {
|
} else {
|
||||||
format!("{} ({}):", pool.mount_point, pool.pool_type)
|
format!("{} ({}):", pool.mount_point, pool.pool_type)
|
||||||
};
|
};
|
||||||
let pool_spans = StatusIcons::create_status_spans(
|
let pool_spans = StatusIcons::create_status_spans(
|
||||||
pool.status.clone(),
|
pool.health_status.clone(),
|
||||||
&pool_label
|
&pool_label
|
||||||
);
|
);
|
||||||
lines.push(Line::from(pool_spans));
|
lines.push(Line::from(pool_spans));
|
||||||
|
|
||||||
// Drive lines with tree structure
|
// Pool health line (for multi-disk pools)
|
||||||
let has_usage_line = pool.usage_percent.is_some();
|
if pool.pool_type != "single" {
|
||||||
for (i, drive) in pool.drives.iter().enumerate() {
|
if let Some(health) = &pool.pool_health {
|
||||||
let is_last_drive = i == pool.drives.len() - 1;
|
let health_text = match health.as_str() {
|
||||||
let tree_symbol = if is_last_drive && !has_usage_line { "└─" } else { "├─" };
|
"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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
let mut drive_info = Vec::new();
|
||||||
if let Some(temp) = drive.temperature {
|
if let Some(temp) = drive.temperature {
|
||||||
drive_info.push(format!("T: {:.0}C", temp));
|
drive_info.push(format!("T: {:.0}°C", temp));
|
||||||
}
|
}
|
||||||
if let Some(wear) = drive.wear_percent {
|
if let Some(wear) = drive.wear_percent {
|
||||||
drive_info.push(format!("W: {:.0}%", wear));
|
drive_info.push(format!("W: {:.0}%", wear));
|
||||||
@@ -328,22 +433,6 @@ impl SystemWidget {
|
|||||||
drive_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
|
drive_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
|
||||||
lines.push(Line::from(drive_spans));
|
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for SystemWidget {
|
impl Widget for SystemWidget {
|
||||||
@@ -513,48 +602,9 @@ impl SystemWidget {
|
|||||||
Span::styled("Storage:", Typography::widget_title())
|
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 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);
|
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())
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply scroll offset
|
// Apply scroll offset
|
||||||
let total_lines = lines.len();
|
let total_lines = lines.len();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard-shared"
|
name = "cm-dashboard-shared"
|
||||||
version = "0.1.93"
|
version = "0.1.99"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -82,13 +82,13 @@ impl MetricValue {
|
|||||||
/// Health status for metrics
|
/// Health status for metrics
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum Status {
|
pub enum Status {
|
||||||
Inactive, // Lowest priority - treated as good
|
Inactive, // Lowest priority
|
||||||
Ok, // Second lowest - also good
|
Unknown, //
|
||||||
Unknown,
|
Offline, //
|
||||||
Offline,
|
Pending, //
|
||||||
Pending,
|
Ok, // 5th place - good status has higher priority than unknown states
|
||||||
Warning,
|
Warning, //
|
||||||
Critical,
|
Critical, // Highest priority
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Status {
|
impl Status {
|
||||||
|
|||||||
Reference in New Issue
Block a user