Update version to v0.1.133
All checks were successful
Build and Release / build-and-release (push) Successful in 2m9s
All checks were successful
Build and Release / build-and-release (push) Successful in 2m9s
Bump version across all workspace crates for next release including agent, dashboard, and shared components.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use cm_dashboard_shared::{Metric, Status};
|
||||
use super::Widget;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
widgets::Paragraph,
|
||||
@@ -6,7 +7,6 @@ use ratatui::{
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::{StatusIcons, Typography};
|
||||
|
||||
/// Backup widget displaying backup status, services, and repository information
|
||||
@@ -137,6 +137,23 @@ impl BackupWidget {
|
||||
}
|
||||
|
||||
impl Widget for BackupWidget {
|
||||
fn update_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) {
|
||||
self.has_data = true;
|
||||
|
||||
let backup = &agent_data.backup;
|
||||
self.overall_status = Status::Ok;
|
||||
|
||||
if let Some(size) = backup.total_size_gb {
|
||||
self.total_repo_size_gb = Some(size);
|
||||
}
|
||||
|
||||
if let Some(last_run) = backup.last_run {
|
||||
self.last_run_timestamp = Some(last_run as i64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupWidget {
|
||||
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
||||
debug!("Backup widget updating with {} metrics", metrics.len());
|
||||
for metric in metrics {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use cm_dashboard_shared::Metric;
|
||||
use cm_dashboard_shared::AgentData;
|
||||
|
||||
pub mod backup;
|
||||
pub mod cpu;
|
||||
@@ -10,9 +10,8 @@ pub use backup::BackupWidget;
|
||||
pub use services::ServicesWidget;
|
||||
pub use system::SystemWidget;
|
||||
|
||||
/// Widget trait for UI components that display metrics
|
||||
/// Widget trait for UI components that display structured data
|
||||
pub trait Widget {
|
||||
/// Update widget with new metrics data
|
||||
fn update_from_metrics(&mut self, metrics: &[&Metric]);
|
||||
|
||||
/// Update widget with structured agent data
|
||||
fn update_from_agent_data(&mut self, agent_data: &AgentData);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use cm_dashboard_shared::{Metric, Status};
|
||||
use super::Widget;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
widgets::Paragraph,
|
||||
@@ -7,7 +8,6 @@ use ratatui::{
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
|
||||
use ratatui::style::Style;
|
||||
|
||||
@@ -255,6 +255,28 @@ impl ServicesWidget {
|
||||
}
|
||||
|
||||
impl Widget for ServicesWidget {
|
||||
fn update_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) {
|
||||
self.has_data = true;
|
||||
self.parent_services.clear();
|
||||
self.sub_services.clear();
|
||||
|
||||
for service in &agent_data.services {
|
||||
let service_info = ServiceInfo {
|
||||
status: service.status.clone(),
|
||||
memory_mb: Some(service.memory_mb),
|
||||
disk_gb: Some(service.disk_gb),
|
||||
latency_ms: None,
|
||||
widget_status: Status::Ok,
|
||||
};
|
||||
|
||||
self.parent_services.insert(service.name.clone(), service_info);
|
||||
}
|
||||
|
||||
self.status = Status::Ok;
|
||||
}
|
||||
}
|
||||
|
||||
impl ServicesWidget {
|
||||
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
||||
debug!("Services widget updating with {} metrics", metrics.len());
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use cm_dashboard_shared::{Metric, MetricValue, Status};
|
||||
use cm_dashboard_shared::Status;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
text::{Line, Span, Text},
|
||||
@@ -6,7 +6,6 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::{StatusIcons, Typography};
|
||||
|
||||
/// System widget displaying NixOS info, CPU, RAM, and Storage in unified layout
|
||||
@@ -14,7 +13,6 @@ use crate::ui::theme::{StatusIcons, Typography};
|
||||
pub struct SystemWidget {
|
||||
// NixOS information
|
||||
nixos_build: Option<String>,
|
||||
config_hash: Option<String>,
|
||||
agent_hash: Option<String>,
|
||||
|
||||
// CPU metrics
|
||||
@@ -46,7 +44,6 @@ struct StoragePool {
|
||||
name: String,
|
||||
mount_point: String,
|
||||
pool_type: String, // "single", "mergerfs (2+1)", "RAID5 (3+1)", etc.
|
||||
pool_health: Option<String>, // "healthy", "degraded", "critical", "rebuilding"
|
||||
drives: Vec<StorageDrive>,
|
||||
filesystems: Vec<FileSystem>, // For physical drive pools: individual filesystem children
|
||||
usage_percent: Option<f32>,
|
||||
@@ -70,7 +67,6 @@ struct FileSystem {
|
||||
usage_percent: Option<f32>,
|
||||
used_gb: Option<f32>,
|
||||
total_gb: Option<f32>,
|
||||
available_gb: Option<f32>,
|
||||
status: Status,
|
||||
}
|
||||
|
||||
@@ -78,7 +74,6 @@ impl SystemWidget {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nixos_build: None,
|
||||
config_hash: None,
|
||||
agent_hash: None,
|
||||
cpu_load_1min: None,
|
||||
cpu_load_5min: None,
|
||||
@@ -145,324 +140,106 @@ impl SystemWidget {
|
||||
pub fn _get_agent_hash(&self) -> Option<&String> {
|
||||
self.agent_hash.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get default mount point for a pool name (fallback only - should use actual mount_point metrics)
|
||||
fn get_mount_point_for_pool(&self, pool_name: &str) -> String {
|
||||
// For device names, use the device name directly as display name
|
||||
if pool_name.starts_with("nvme") || pool_name.starts_with("sd") || pool_name.starts_with("hd") {
|
||||
pool_name.to_string()
|
||||
} else {
|
||||
// For other pools, use the pool name as-is (will be overridden by mount_point metric)
|
||||
pool_name.to_string()
|
||||
use super::Widget;
|
||||
|
||||
impl Widget for SystemWidget {
|
||||
fn update_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) {
|
||||
self.has_data = true;
|
||||
|
||||
// Extract agent version
|
||||
self.agent_hash = Some(agent_data.agent_version.clone());
|
||||
|
||||
// Extract CPU data directly
|
||||
let cpu = &agent_data.system.cpu;
|
||||
self.cpu_load_1min = Some(cpu.load_1min);
|
||||
self.cpu_load_5min = Some(cpu.load_5min);
|
||||
self.cpu_load_15min = Some(cpu.load_15min);
|
||||
self.cpu_frequency = Some(cpu.frequency_mhz);
|
||||
self.cpu_status = Status::Ok;
|
||||
|
||||
// Extract memory data directly
|
||||
let memory = &agent_data.system.memory;
|
||||
self.memory_usage_percent = Some(memory.usage_percent);
|
||||
self.memory_used_gb = Some(memory.used_gb);
|
||||
self.memory_total_gb = Some(memory.total_gb);
|
||||
self.memory_status = Status::Ok;
|
||||
|
||||
// Extract tmpfs data
|
||||
if let Some(tmp_data) = memory.tmpfs.iter().find(|t| t.mount == "/tmp") {
|
||||
self.tmp_usage_percent = Some(tmp_data.usage_percent);
|
||||
self.tmp_used_gb = Some(tmp_data.used_gb);
|
||||
self.tmp_total_gb = Some(tmp_data.total_gb);
|
||||
self.tmp_status = Status::Ok;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse storage metrics into pools and drives
|
||||
fn update_storage_from_metrics(&mut self, metrics: &[&Metric]) {
|
||||
// Convert storage data to internal format
|
||||
self.update_storage_from_agent_data(agent_data);
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemWidget {
|
||||
/// Convert structured storage data to internal format
|
||||
fn update_storage_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) {
|
||||
let mut pools: std::collections::HashMap<String, StoragePool> = std::collections::HashMap::new();
|
||||
|
||||
for metric in metrics {
|
||||
if metric.name.starts_with("disk_") {
|
||||
if let Some(pool_name) = self.extract_pool_name(&metric.name) {
|
||||
let pool = pools.entry(pool_name.clone()).or_insert_with(|| StoragePool {
|
||||
name: pool_name.clone(),
|
||||
mount_point: self.get_mount_point_for_pool(&pool_name), // Default fallback
|
||||
pool_type: "single".to_string(), // Default, will be updated
|
||||
pool_health: None,
|
||||
drives: Vec::new(),
|
||||
filesystems: Vec::new(),
|
||||
usage_percent: None,
|
||||
used_gb: None,
|
||||
total_gb: None,
|
||||
status: Status::Unknown,
|
||||
health_status: Status::Unknown,
|
||||
});
|
||||
// Convert drives
|
||||
for drive in &agent_data.system.storage.drives {
|
||||
let mut pool = StoragePool {
|
||||
name: drive.name.clone(),
|
||||
mount_point: drive.name.clone(),
|
||||
pool_type: "drive".to_string(),
|
||||
drives: Vec::new(),
|
||||
filesystems: Vec::new(),
|
||||
usage_percent: None,
|
||||
used_gb: None,
|
||||
total_gb: None,
|
||||
status: Status::Ok,
|
||||
health_status: Status::Ok,
|
||||
};
|
||||
|
||||
// Parse different metric types
|
||||
if metric.name.contains("_usage_percent") && !metric.name.contains("_fs_") {
|
||||
// Only use drive-level metrics for pool totals, not filesystem metrics
|
||||
if let MetricValue::Float(usage) = metric.value {
|
||||
pool.usage_percent = Some(usage);
|
||||
pool.status = metric.status.clone();
|
||||
}
|
||||
} else if metric.name.contains("_used_gb") && !metric.name.contains("_fs_") {
|
||||
// Only use drive-level metrics for pool totals, not filesystem metrics
|
||||
if let MetricValue::Float(used) = metric.value {
|
||||
pool.used_gb = Some(used);
|
||||
}
|
||||
} else if metric.name.contains("_total_gb") && !metric.name.contains("_fs_") {
|
||||
// Only use drive-level metrics for pool totals, not filesystem metrics
|
||||
if let MetricValue::Float(total) = metric.value {
|
||||
pool.total_gb = Some(total);
|
||||
}
|
||||
} else if metric.name.contains("_mount_point") {
|
||||
if let MetricValue::String(mount_point) = &metric.value {
|
||||
pool.mount_point = mount_point.clone();
|
||||
}
|
||||
} 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("_health") && !metric.name.contains("_pool_health") {
|
||||
// Handle physical drive health metrics (disk_{drive}_health)
|
||||
if let MetricValue::String(health) = &metric.value {
|
||||
// For physical drives, use the drive health as pool health
|
||||
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
|
||||
let drive_exists = pool.drives.iter().any(|d| d.name == drive_name);
|
||||
if !drive_exists {
|
||||
pool.drives.push(StorageDrive {
|
||||
name: drive_name.clone(),
|
||||
temperature: None,
|
||||
wear_percent: None,
|
||||
status: Status::Unknown,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(drive) = pool.drives.iter_mut().find(|d| d.name == drive_name) {
|
||||
if let MetricValue::Float(temp) = metric.value {
|
||||
drive.temperature = Some(temp);
|
||||
drive.status = metric.status.clone();
|
||||
// For physical drives, if this is the main drive, also update pool health
|
||||
if drive.name == pool.name && pool.health_status == Status::Unknown {
|
||||
pool.health_status = metric.status.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if metric.name.contains("_wear_percent") {
|
||||
if let Some(drive_name) = self.extract_drive_name(&metric.name) {
|
||||
// For physical drives, ensure we create the drive object
|
||||
let drive_exists = pool.drives.iter().any(|d| d.name == drive_name);
|
||||
if !drive_exists {
|
||||
pool.drives.push(StorageDrive {
|
||||
name: drive_name.clone(),
|
||||
temperature: None,
|
||||
wear_percent: None,
|
||||
status: Status::Unknown,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(drive) = pool.drives.iter_mut().find(|d| d.name == drive_name) {
|
||||
if let MetricValue::Float(wear) = metric.value {
|
||||
drive.wear_percent = Some(wear);
|
||||
drive.status = metric.status.clone();
|
||||
// For physical drives, if this is the main drive, also update pool health
|
||||
if drive.name == pool.name && pool.health_status == Status::Unknown {
|
||||
pool.health_status = metric.status.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if metric.name.contains("_fs_") {
|
||||
// Handle filesystem metrics for physical drive pools (disk_{pool}_fs_{fs_name}_{metric})
|
||||
if let (Some(fs_name), Some(metric_type)) = self.extract_filesystem_metric(&metric.name) {
|
||||
// Find or create filesystem entry
|
||||
let fs_exists = pool.filesystems.iter().any(|fs| {
|
||||
let fs_id = if fs.mount_point == "/" {
|
||||
"root".to_string()
|
||||
} else {
|
||||
fs.mount_point.trim_start_matches('/').replace('/', "_")
|
||||
};
|
||||
fs_id == fs_name
|
||||
});
|
||||
// Add drive info
|
||||
let storage_drive = StorageDrive {
|
||||
name: drive.name.clone(),
|
||||
temperature: drive.temperature_celsius,
|
||||
wear_percent: drive.wear_percent,
|
||||
status: Status::Ok,
|
||||
};
|
||||
pool.drives.push(storage_drive);
|
||||
|
||||
if !fs_exists {
|
||||
// Create filesystem entry with correct mount point
|
||||
let mount_point = if metric_type == "mount_point" {
|
||||
if let MetricValue::String(mount) = &metric.value {
|
||||
mount.clone()
|
||||
} else {
|
||||
// Fallback: handle special cases
|
||||
if fs_name == "root" {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}", fs_name.replace('_', "/"))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for non-mount_point metrics: generate mount point from fs_name
|
||||
if fs_name == "root" {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}", fs_name.replace('_', "/"))
|
||||
}
|
||||
};
|
||||
// Calculate totals from filesystems
|
||||
let total_used: f32 = drive.filesystems.iter().map(|fs| fs.used_gb).sum();
|
||||
let total_size: f32 = drive.filesystems.iter().map(|fs| fs.total_gb).sum();
|
||||
let average_usage = if total_size > 0.0 { (total_used / total_size) * 100.0 } else { 0.0 };
|
||||
|
||||
pool.filesystems.push(FileSystem {
|
||||
mount_point,
|
||||
usage_percent: None,
|
||||
used_gb: None,
|
||||
total_gb: None,
|
||||
available_gb: None,
|
||||
status: Status::Unknown,
|
||||
});
|
||||
}
|
||||
pool.usage_percent = Some(average_usage);
|
||||
pool.used_gb = Some(total_used);
|
||||
pool.total_gb = Some(total_size);
|
||||
|
||||
// Update the filesystem with the metric value
|
||||
if let Some(filesystem) = pool.filesystems.iter_mut().find(|fs| {
|
||||
let fs_id = if fs.mount_point == "/" {
|
||||
"root".to_string()
|
||||
} else {
|
||||
fs.mount_point.trim_start_matches('/').replace('/', "_")
|
||||
};
|
||||
fs_id == fs_name
|
||||
}) {
|
||||
match metric_type.as_str() {
|
||||
"usage_percent" => {
|
||||
if let MetricValue::Float(usage) = metric.value {
|
||||
filesystem.usage_percent = Some(usage);
|
||||
filesystem.status = metric.status.clone();
|
||||
}
|
||||
}
|
||||
"used_gb" => {
|
||||
if let MetricValue::Float(used) = metric.value {
|
||||
filesystem.used_gb = Some(used);
|
||||
}
|
||||
}
|
||||
"total_gb" => {
|
||||
if let MetricValue::Float(total) = metric.value {
|
||||
filesystem.total_gb = Some(total);
|
||||
}
|
||||
}
|
||||
"available_gb" => {
|
||||
if let MetricValue::Float(available) = metric.value {
|
||||
filesystem.available_gb = Some(available);
|
||||
}
|
||||
}
|
||||
"mount_point" => {
|
||||
if let MetricValue::String(mount) = &metric.value {
|
||||
filesystem.mount_point = mount.clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add filesystems
|
||||
for fs in &drive.filesystems {
|
||||
let filesystem = FileSystem {
|
||||
mount_point: fs.mount.clone(),
|
||||
usage_percent: Some(fs.usage_percent),
|
||||
used_gb: Some(fs.used_gb),
|
||||
total_gb: Some(fs.total_gb),
|
||||
status: Status::Ok,
|
||||
};
|
||||
pool.filesystems.push(filesystem);
|
||||
}
|
||||
|
||||
pools.insert(drive.name.clone(), pool);
|
||||
}
|
||||
|
||||
// Convert to sorted vec for consistent ordering
|
||||
// Convert pools
|
||||
|
||||
// Store pools
|
||||
let mut pool_list: Vec<StoragePool> = pools.into_values().collect();
|
||||
pool_list.sort_by(|a, b| a.name.cmp(&b.name)); // Sort alphabetically by name
|
||||
pool_list.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
self.storage_pools = pool_list;
|
||||
}
|
||||
|
||||
/// Extract pool name from disk metric name
|
||||
fn extract_pool_name(&self, metric_name: &str) -> Option<String> {
|
||||
// Pattern: disk_{pool_name}_{various suffixes}
|
||||
// Since pool_name can contain underscores, work backwards from known metric suffixes
|
||||
if metric_name.starts_with("disk_") {
|
||||
// Handle filesystem metrics: disk_{pool}_fs_{filesystem}_{metric}
|
||||
if metric_name.contains("_fs_") {
|
||||
if let Some(fs_pos) = metric_name.find("_fs_") {
|
||||
return Some(metric_name[5..fs_pos].to_string()); // Skip "disk_", extract pool name before "_fs_"
|
||||
}
|
||||
}
|
||||
|
||||
// Handle pool-level metrics (usage_percent, used_gb, total_gb, mount_point, pool_type, pool_health)
|
||||
// Use rfind to get the last occurrence of these suffixes
|
||||
let pool_suffixes = ["_usage_percent", "_used_gb", "_total_gb", "_available_gb", "_mount_point", "_pool_type", "_pool_health"];
|
||||
for suffix in pool_suffixes {
|
||||
if let Some(suffix_pos) = metric_name.rfind(suffix) {
|
||||
return Some(metric_name[5..suffix_pos].to_string()); // Skip "disk_"
|
||||
}
|
||||
}
|
||||
|
||||
// Handle physical drive metrics: disk_{drive}_health, disk_{drive}_wear_percent, and disk_{drive}_temperature
|
||||
if (metric_name.ends_with("_health") && !metric_name.contains("_pool_health"))
|
||||
|| metric_name.ends_with("_wear_percent")
|
||||
|| metric_name.ends_with("_temperature") {
|
||||
// Count underscores to distinguish physical drive metrics (disk_{drive}_metric)
|
||||
// from pool drive metrics (disk_{pool}_{drive}_metric)
|
||||
let underscore_count = metric_name.matches('_').count();
|
||||
// disk_nvme0n1_wear_percent has 3 underscores: disk_nvme0n1_wear_percent
|
||||
if underscore_count == 3 { // disk_{drive}_metric (where drive has underscores)
|
||||
if let Some(suffix_pos) = metric_name.rfind("_health")
|
||||
.or_else(|| metric_name.rfind("_wear_percent"))
|
||||
.or_else(|| metric_name.rfind("_temperature")) {
|
||||
return Some(metric_name[5..suffix_pos].to_string()); // Skip "disk_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drive-specific metrics: disk_{pool}_{drive}_{metric}
|
||||
let drive_suffixes = ["_temperature", "_health"];
|
||||
for suffix in drive_suffixes {
|
||||
if let Some(suffix_pos) = metric_name.rfind(suffix) {
|
||||
// Extract pool name by finding the second-to-last underscore
|
||||
let before_suffix = &metric_name[..suffix_pos];
|
||||
if let Some(drive_start) = before_suffix.rfind('_') {
|
||||
if drive_start > 5 {
|
||||
return Some(metric_name[5..drive_start].to_string()); // Skip "disk_"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract filesystem name and metric type from filesystem metric names
|
||||
/// Pattern: disk_{pool}_fs_{filesystem_name}_{metric_type}
|
||||
fn extract_filesystem_metric(&self, metric_name: &str) -> (Option<String>, Option<String>) {
|
||||
if metric_name.starts_with("disk_") && metric_name.contains("_fs_") {
|
||||
// Find the _fs_ part
|
||||
if let Some(fs_start) = metric_name.find("_fs_") {
|
||||
let after_fs = &metric_name[fs_start + 4..]; // Skip "_fs_"
|
||||
|
||||
// Look for known metric suffixes (these can contain underscores)
|
||||
let known_suffixes = ["usage_percent", "used_gb", "total_gb", "available_gb", "mount_point"];
|
||||
|
||||
for suffix in known_suffixes {
|
||||
if after_fs.ends_with(suffix) {
|
||||
// Extract filesystem name by removing suffix and underscore
|
||||
if let Some(underscore_pos) = after_fs.rfind(&format!("_{}", suffix)) {
|
||||
let fs_name = after_fs[..underscore_pos].to_string();
|
||||
return (Some(fs_name), Some(suffix.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
||||
/// Extract drive name from disk metric name
|
||||
fn extract_drive_name(&self, metric_name: &str) -> Option<String> {
|
||||
// Pattern: disk_{pool_name}_{drive_name}_{metric_type} OR disk_{drive_name}_{metric_type}
|
||||
// Pool drives: disk_srv_media_sdb_temperature
|
||||
// Physical drives: disk_nvme0n1_temperature
|
||||
if metric_name.starts_with("disk_") {
|
||||
if let Some(suffix_pos) = metric_name.rfind("_temperature")
|
||||
.or_else(|| metric_name.rfind("_wear_percent"))
|
||||
.or_else(|| metric_name.rfind("_health")) {
|
||||
let before_suffix = &metric_name[..suffix_pos];
|
||||
|
||||
// Extract the last component as drive name (e.g., "sdb", "sdc", "nvme0n1")
|
||||
if let Some(drive_start) = before_suffix.rfind('_') {
|
||||
return Some(before_suffix[drive_start + 1..].to_string());
|
||||
} else {
|
||||
// Handle physical drive metrics: disk_{drive}_metric (no pool)
|
||||
// Extract everything after "disk_" as the drive name
|
||||
return Some(before_suffix[5..].to_string()); // Skip "disk_"
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Render storage section with enhanced tree structure
|
||||
fn render_storage(&self) -> Vec<Line<'_>> {
|
||||
let mut lines = Vec::new();
|
||||
@@ -472,269 +249,126 @@ impl SystemWidget {
|
||||
let pool_label = if pool.pool_type.starts_with("drive (") {
|
||||
// For physical drives, show the drive name with temperature and wear percentage if available
|
||||
// Look for any drive with temp/wear data (physical drives may have drives named after the pool)
|
||||
let temp_opt = pool.drives.iter()
|
||||
.find_map(|d| d.temperature);
|
||||
let wear_opt = pool.drives.iter()
|
||||
.find_map(|d| d.wear_percent);
|
||||
|
||||
let mut drive_info = Vec::new();
|
||||
if let Some(temp) = temp_opt {
|
||||
drive_info.push(format!("T: {:.0}°C", temp));
|
||||
}
|
||||
if let Some(wear) = wear_opt {
|
||||
drive_info.push(format!("W: {:.0}%", wear));
|
||||
}
|
||||
|
||||
if drive_info.is_empty() {
|
||||
format!("{}:", pool.name)
|
||||
let drive_info = pool.drives.iter()
|
||||
.find(|d| d.name == pool.name)
|
||||
.or_else(|| pool.drives.first());
|
||||
|
||||
if let Some(drive) = drive_info {
|
||||
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));
|
||||
}
|
||||
|
||||
if !drive_details.is_empty() {
|
||||
format!("{} ({})", pool.name, drive_details.join(" "))
|
||||
} else {
|
||||
pool.name.clone()
|
||||
}
|
||||
} else {
|
||||
format!("{} {}:", pool.name, drive_info.join(" "))
|
||||
pool.name.clone()
|
||||
}
|
||||
} else if pool.pool_type == "single" {
|
||||
format!("{}:", pool.mount_point)
|
||||
} else {
|
||||
format!("{} ({}):", pool.mount_point, pool.pool_type)
|
||||
// For mergerfs pools, show pool name with format
|
||||
format!("{} ({})", pool.mount_point, pool.pool_type)
|
||||
};
|
||||
let pool_spans = StatusIcons::create_status_spans(
|
||||
pool.health_status.clone(),
|
||||
&pool_label
|
||||
);
|
||||
|
||||
let pool_spans = StatusIcons::create_status_spans(pool.status.clone(), &pool_label);
|
||||
lines.push(Line::from(pool_spans));
|
||||
|
||||
// Skip pool health line as discussed - removed
|
||||
|
||||
// Total usage line (only show for multi-drive pools, skip for single physical drives)
|
||||
if !pool.pool_type.starts_with("drive (") {
|
||||
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 has_filesystems = !pool.filesystems.is_empty();
|
||||
let has_children = has_drives || has_filesystems;
|
||||
let tree_symbol = if has_children { "├─" } else { "└─" };
|
||||
let mut usage_spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(tree_symbol, Typography::tree()),
|
||||
// Pool total usage line
|
||||
if let (Some(usage), Some(used), Some(total)) = (pool.usage_percent, pool.used_gb, pool.total_gb) {
|
||||
let usage_spans = vec![
|
||||
Span::styled(" ├─ ", Typography::tree()),
|
||||
Span::raw(" "),
|
||||
];
|
||||
usage_spans.extend(StatusIcons::create_status_spans(pool.status.clone(), &usage_text));
|
||||
lines.push(Line::from(usage_spans));
|
||||
let mut usage_line_spans = usage_spans;
|
||||
usage_line_spans.extend(StatusIcons::create_status_spans(pool.status.clone(), &format!("Total: {}% {:.1}GB/{:.1}GB", usage as i32, used, total)));
|
||||
lines.push(Line::from(usage_line_spans));
|
||||
}
|
||||
|
||||
// Drive lines with enhanced grouping
|
||||
if pool.pool_type.contains("mergerfs") && 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() {
|
||||
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, "│ ├─");
|
||||
// Drive details for physical drives
|
||||
if pool.pool_type.starts_with("drive") {
|
||||
for drive in &pool.drives {
|
||||
if drive.name == pool.name {
|
||||
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));
|
||||
}
|
||||
|
||||
if !drive_details.is_empty() {
|
||||
let drive_text = format!("● {} {}", drive.name, drive_details.join(" "));
|
||||
let drive_spans = vec![
|
||||
Span::styled(" └─ ", Typography::tree()),
|
||||
Span::raw(" "),
|
||||
];
|
||||
let mut drive_line_spans = drive_spans;
|
||||
drive_line_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
|
||||
lines.push(Line::from(drive_line_spans));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show parity drives
|
||||
if !parity_drives.is_empty() {
|
||||
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 if pool.pool_type != "single" && pool.drives.len() > 1 {
|
||||
// Regular drive listing for non-mergerfs multi-drive 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 if pool.pool_type.starts_with("drive (") {
|
||||
// Physical drive pools: wear data shown in header, skip drive lines, show filesystems directly
|
||||
for (i, filesystem) in pool.filesystems.iter().enumerate() {
|
||||
let is_last = i == pool.filesystems.len() - 1;
|
||||
let tree_symbol = if is_last { "└─" } else { "├─" };
|
||||
|
||||
let fs_text = match (filesystem.usage_percent, filesystem.used_gb, filesystem.total_gb) {
|
||||
(Some(pct), Some(used), Some(total)) => {
|
||||
format!("{}: {:.0}% {:.1}GB/{:.1}GB", filesystem.mount_point, pct, used, total)
|
||||
}
|
||||
(Some(pct), _, Some(total)) => {
|
||||
format!("{}: {:.0}% —GB/{:.1}GB", filesystem.mount_point, pct, total)
|
||||
}
|
||||
(Some(pct), _, _) => {
|
||||
format!("{}: {:.0}% —GB/—GB", filesystem.mount_point, pct)
|
||||
}
|
||||
(_, Some(used), Some(total)) => {
|
||||
format!("{}: —% {:.1}GB/{:.1}GB", filesystem.mount_point, used, total)
|
||||
}
|
||||
_ => format!("{}: —% —GB/—GB", filesystem.mount_point),
|
||||
};
|
||||
|
||||
let mut fs_spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(tree_symbol, Typography::tree()),
|
||||
Span::raw(" "),
|
||||
];
|
||||
fs_spans.extend(StatusIcons::create_status_spans(filesystem.status.clone(), &fs_text));
|
||||
lines.push(Line::from(fs_spans));
|
||||
}
|
||||
} else {
|
||||
// Single drive or simple pools
|
||||
for (i, drive) in pool.drives.iter().enumerate() {
|
||||
let is_last = i == pool.drives.len() - 1;
|
||||
let tree_symbol = if is_last { "└─" } else { "├─" };
|
||||
self.render_drive_line(&mut lines, drive, tree_symbol);
|
||||
// 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"));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// Always show drive name with info, or just name if no info available
|
||||
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 {
|
||||
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
||||
self.has_data = !metrics.is_empty();
|
||||
|
||||
for metric in metrics {
|
||||
match metric.name.as_str() {
|
||||
// NixOS metrics
|
||||
"system_nixos_build" => {
|
||||
if let MetricValue::String(build) = &metric.value {
|
||||
self.nixos_build = Some(build.clone());
|
||||
}
|
||||
}
|
||||
"system_config_hash" => {
|
||||
if let MetricValue::String(hash) = &metric.value {
|
||||
self.config_hash = Some(hash.clone());
|
||||
}
|
||||
}
|
||||
"agent_version" => {
|
||||
if let MetricValue::String(version) = &metric.value {
|
||||
self.agent_hash = Some(version.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// CPU metrics
|
||||
"cpu_load_1min" => {
|
||||
if let MetricValue::Float(load) = metric.value {
|
||||
self.cpu_load_1min = Some(load);
|
||||
self.cpu_status = metric.status.clone();
|
||||
}
|
||||
}
|
||||
"cpu_load_5min" => {
|
||||
if let MetricValue::Float(load) = metric.value {
|
||||
self.cpu_load_5min = Some(load);
|
||||
}
|
||||
}
|
||||
"cpu_load_15min" => {
|
||||
if let MetricValue::Float(load) = metric.value {
|
||||
self.cpu_load_15min = Some(load);
|
||||
}
|
||||
}
|
||||
"cpu_frequency_mhz" => {
|
||||
if let MetricValue::Float(freq) = metric.value {
|
||||
self.cpu_frequency = Some(freq);
|
||||
}
|
||||
}
|
||||
|
||||
// Memory metrics
|
||||
"memory_usage_percent" => {
|
||||
if let MetricValue::Float(usage) = metric.value {
|
||||
self.memory_usage_percent = Some(usage);
|
||||
self.memory_status = metric.status.clone();
|
||||
}
|
||||
}
|
||||
"memory_used_gb" => {
|
||||
if let MetricValue::Float(used) = metric.value {
|
||||
self.memory_used_gb = Some(used);
|
||||
}
|
||||
}
|
||||
"memory_total_gb" => {
|
||||
if let MetricValue::Float(total) = metric.value {
|
||||
self.memory_total_gb = Some(total);
|
||||
}
|
||||
}
|
||||
|
||||
// Tmpfs metrics
|
||||
"memory_tmp_usage_percent" => {
|
||||
if let MetricValue::Float(usage) = metric.value {
|
||||
self.tmp_usage_percent = Some(usage);
|
||||
self.tmp_status = metric.status.clone();
|
||||
}
|
||||
}
|
||||
"memory_tmp_used_gb" => {
|
||||
if let MetricValue::Float(used) = metric.value {
|
||||
self.tmp_used_gb = Some(used);
|
||||
}
|
||||
}
|
||||
"memory_tmp_total_gb" => {
|
||||
if let MetricValue::Float(total) = metric.value {
|
||||
self.tmp_total_gb = Some(total);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Update storage from all disk metrics
|
||||
self.update_storage_from_metrics(metrics);
|
||||
/// Helper function to render a drive in a storage pool
|
||||
fn render_pool_drive(drive: &StorageDrive, is_last: bool, lines: &mut Vec<Line<'_>>) {
|
||||
let tree_symbol = if is_last { " └─" } else { " ├─" };
|
||||
|
||||
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 {
|
||||
format!("● {}", drive.name)
|
||||
};
|
||||
|
||||
let mut drive_spans = vec![
|
||||
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 SystemWidget {
|
||||
|
||||
Reference in New Issue
Block a user