Add wear percentage display and NVMe temperature collection
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
- Display wear percentage in storage headers for single physical drives - Remove redundant drive type indicators, show wear data instead - Fix wear metric parsing for physical drives (underscore count issue) - Add NVMe temperature parsing support (Temperature: format) - Add raw metrics debugging functionality for troubleshooting - Clean up physical drive display to remove redundant information
This commit is contained in:
parent
9b4191b2c3
commit
41ded0170c
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.128"
|
||||
version = "0.1.129"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -301,7 +301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.128"
|
||||
version = "0.1.129"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -324,7 +324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.128"
|
||||
version = "0.1.129"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.129"
|
||||
version = "0.1.130"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -499,6 +499,17 @@ impl DiskCollector {
|
||||
}
|
||||
}
|
||||
}
|
||||
// NVMe format: "Temperature:" (capital T)
|
||||
if line.contains("Temperature:") {
|
||||
if let Some(temp_part) = line.split("Temperature:").nth(1) {
|
||||
if let Some(temp_str) = temp_part.split_whitespace().next() {
|
||||
if let Ok(temp) = temp_str.parse::<f32>() {
|
||||
return Some(temp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Legacy format: "temperature:" (lowercase)
|
||||
if line.contains("temperature:") {
|
||||
if let Some(temp_part) = line.split("temperature:").nth(1) {
|
||||
if let Some(temp_str) = temp_part.split_whitespace().next() {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.129"
|
||||
version = "0.1.130"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -20,12 +20,13 @@ pub struct Dashboard {
|
||||
tui_app: Option<TuiApp>,
|
||||
terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>,
|
||||
headless: bool,
|
||||
raw_data: bool,
|
||||
initial_commands_sent: std::collections::HashSet<String>,
|
||||
config: DashboardConfig,
|
||||
}
|
||||
|
||||
impl Dashboard {
|
||||
pub async fn new(config_path: Option<String>, headless: bool) -> Result<Self> {
|
||||
pub async fn new(config_path: Option<String>, headless: bool, raw_data: bool) -> Result<Self> {
|
||||
info!("Initializing dashboard");
|
||||
|
||||
// Load configuration - try default path if not specified
|
||||
@ -119,6 +120,7 @@ impl Dashboard {
|
||||
tui_app,
|
||||
terminal,
|
||||
headless,
|
||||
raw_data,
|
||||
initial_commands_sent: std::collections::HashSet::new(),
|
||||
config,
|
||||
})
|
||||
@ -204,6 +206,18 @@ impl Dashboard {
|
||||
.insert(metric_message.hostname.clone());
|
||||
}
|
||||
|
||||
// Show raw data if requested (before processing)
|
||||
if self.raw_data {
|
||||
println!("RAW METRICS FROM {}: {} metrics", metric_message.hostname, metric_message.metrics.len());
|
||||
for metric in &metric_message.metrics {
|
||||
println!(" {}: {:?} ({:?})", metric.name, metric.value, metric.status);
|
||||
if let Some(desc) = &metric.description {
|
||||
println!(" └─ {}", desc);
|
||||
}
|
||||
}
|
||||
println!("{}", "─".repeat(80));
|
||||
}
|
||||
|
||||
// Update metric store
|
||||
self.metric_store
|
||||
.update_metrics(&metric_message.hostname, metric_message.metrics);
|
||||
|
||||
@ -51,6 +51,10 @@ struct Cli {
|
||||
/// Run in headless mode (no TUI, just logging)
|
||||
#[arg(long)]
|
||||
headless: bool,
|
||||
|
||||
/// Show raw agent data in headless mode
|
||||
#[arg(long)]
|
||||
raw_data: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@ -86,7 +90,7 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
// Create and run dashboard
|
||||
let mut dashboard = Dashboard::new(cli.config, cli.headless).await?;
|
||||
let mut dashboard = Dashboard::new(cli.config, cli.headless, cli.raw_data).await?;
|
||||
|
||||
// Setup graceful shutdown
|
||||
let ctrl_c = async {
|
||||
|
||||
@ -232,12 +232,16 @@ impl SystemWidget {
|
||||
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) {
|
||||
// Find existing drive or create new one
|
||||
// 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 {
|
||||
@ -252,6 +256,10 @@ impl SystemWidget {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -372,20 +380,23 @@ impl SystemWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle physical drive health metrics: disk_{drive}_health
|
||||
if metric_name.ends_with("_health") && !metric_name.contains("_pool_health") {
|
||||
// Count underscores to distinguish physical drive health (disk_{drive}_health)
|
||||
// from pool drive health (disk_{pool}_{drive}_health)
|
||||
// Handle physical drive metrics: disk_{drive}_health and disk_{drive}_wear_percent
|
||||
if (metric_name.ends_with("_health") && !metric_name.contains("_pool_health"))
|
||||
|| metric_name.ends_with("_wear_percent") {
|
||||
// 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();
|
||||
if underscore_count == 2 { // disk_{drive}_health
|
||||
if let Some(suffix_pos) = metric_name.rfind("_health") {
|
||||
// disk_nvme0n1_wear_percent has 3 underscores: disk_nvme0n1_wear_percent
|
||||
if underscore_count == 3 { // disk_{drive}_wear_percent (where drive has underscores)
|
||||
if let Some(suffix_pos) = metric_name.rfind("_health")
|
||||
.or_else(|| metric_name.rfind("_wear_percent")) {
|
||||
return Some(metric_name[5..suffix_pos].to_string()); // Skip "disk_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drive-specific metrics: disk_{pool}_{drive}_{metric}
|
||||
let drive_suffixes = ["_temperature", "_wear_percent", "_health"];
|
||||
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
|
||||
@ -428,9 +439,9 @@ impl SystemWidget {
|
||||
|
||||
/// 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}
|
||||
// Now using actual device names like: disk_srv_media_sdb_temperature
|
||||
// Since pool_name can contain underscores, work backwards from known metric suffixes
|
||||
// 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"))
|
||||
@ -440,6 +451,10 @@ impl SystemWidget {
|
||||
// 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_"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -453,8 +468,16 @@ impl SystemWidget {
|
||||
for pool in &self.storage_pools {
|
||||
// Pool header line with type and health
|
||||
let pool_label = if pool.pool_type.starts_with("drive (") {
|
||||
// For physical drives, show the drive name instead of mount point
|
||||
format!("{} ({}):", pool.name, pool.pool_type)
|
||||
// For physical drives, show the drive name with wear percentage if available
|
||||
// Look for any drive with wear data (physical drives may have drives named after the pool)
|
||||
let wear_opt = pool.drives.iter()
|
||||
.find_map(|d| d.wear_percent);
|
||||
|
||||
if let Some(wear) = wear_opt {
|
||||
format!("{} W: {:.0}%:", pool.name, wear)
|
||||
} else {
|
||||
format!("{}:", pool.name)
|
||||
}
|
||||
} else if pool.pool_type == "single" {
|
||||
format!("{}:", pool.mount_point)
|
||||
} else {
|
||||
@ -468,25 +491,27 @@ impl SystemWidget {
|
||||
|
||||
// Skip pool health line as discussed - removed
|
||||
|
||||
// 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 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()),
|
||||
Span::raw(" "),
|
||||
];
|
||||
usage_spans.extend(StatusIcons::create_status_spans(pool.status.clone(), &usage_text));
|
||||
lines.push(Line::from(usage_spans));
|
||||
// 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()),
|
||||
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.contains("mergerfs") && pool.drives.len() > 1 {
|
||||
@ -540,34 +565,7 @@ impl SystemWidget {
|
||||
self.render_drive_line(&mut lines, drive, tree_symbol);
|
||||
}
|
||||
} else if pool.pool_type.starts_with("drive (") {
|
||||
// Physical drive pools: show drive info + filesystem children
|
||||
// First show drive information
|
||||
for drive in &pool.drives {
|
||||
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() {
|
||||
format!("Drive: {}", drive.name)
|
||||
} else {
|
||||
format!("Drive: {}", drive_info.join(" "))
|
||||
};
|
||||
|
||||
let has_filesystems = !pool.filesystems.is_empty();
|
||||
let tree_symbol = if has_filesystems { "├─" } else { "└─" };
|
||||
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));
|
||||
}
|
||||
|
||||
// Then show filesystem children
|
||||
// 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 { "├─" };
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.129"
|
||||
version = "0.1.130"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user