From cd4764596ff9882bb7b61089cf21ccd71b31d3ec Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Mon, 13 Oct 2025 11:18:23 +0200 Subject: [PATCH] Implement comprehensive dashboard improvements and maintenance mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Storage widget: Restructure with Name/Temp/Wear/Usage columns, SMART details as descriptions - Host navigation: Only cycle through connected hosts, no disconnected hosts - Auto-discovery: Skip config files, use predefined CMTEC host list - Maintenance mode: Suppress notifications during backup via /tmp/cm-maintenance file - CPU thresholds: Update to warning ≥9.0, critical ≥10.0 for production use - Agent-dashboard separation: Agent provides descriptions, dashboard displays only --- agent/src/collectors/smart.rs | 18 ++++++++++ agent/src/notifications.rs | 10 ++++++ dashboard/src/app.rs | 64 ++++++++++++++++++++++++++++++----- dashboard/src/data/metrics.rs | 2 ++ dashboard/src/ui/storage.rs | 50 ++++++++++----------------- 5 files changed, 103 insertions(+), 41 deletions(-) diff --git a/agent/src/collectors/smart.rs b/agent/src/collectors/smart.rs index 4f38e5c..5476336 100644 --- a/agent/src/collectors/smart.rs +++ b/agent/src/collectors/smart.rs @@ -357,6 +357,8 @@ struct SmartDeviceData { health_status: String, capacity_gb: Option, used_gb: Option, + #[serde(default)] + description: Option>, } impl SmartDeviceData { @@ -389,6 +391,21 @@ impl SmartDeviceData { }) .unwrap_or_else(|| "UNKNOWN".to_string()); + // Build SMART description with key metrics + let mut smart_details = Vec::new(); + if available_spare > 0.0 { + smart_details.push(format!("Spare: {}%", available_spare as u32)); + } + if power_on_hours > 0 { + smart_details.push(format!("Hours: {}", power_on_hours)); + } + + let description = if smart_details.is_empty() { + None + } else { + Some(vec![smart_details.join(", ")]) + }; + Self { name: device.to_string(), temperature_c, @@ -398,6 +415,7 @@ impl SmartDeviceData { health_status, capacity_gb: None, // Will be set later by the collector used_gb: None, // Will be set later by the collector + description, } } } diff --git a/agent/src/notifications.rs b/agent/src/notifications.rs index 7c00e68..597e6f3 100644 --- a/agent/src/notifications.rs +++ b/agent/src/notifications.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::Path; use chrono::{DateTime, Utc}; use chrono_tz::Europe::Stockholm; use lettre::{Message, SmtpTransport, Transport}; @@ -150,11 +151,20 @@ impl NotificationManager { false } + fn is_maintenance_mode() -> bool { + Path::new("/tmp/cm-maintenance").exists() + } + pub async fn send_notification(&mut self, change: StatusChange) { if !self.config.enabled { return; } + if Self::is_maintenance_mode() { + info!("Suppressing notification for {}.{} (maintenance mode active)", change.component, change.metric); + return; + } + if self.is_rate_limited(&change) { warn!("Rate limiting notification for {}.{}", change.component, change.metric); return; diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs index 25689ca..06f9815 100644 --- a/dashboard/src/app.rs +++ b/dashboard/src/app.rs @@ -469,16 +469,46 @@ impl App { usize::try_from(samples.max(1)).unwrap_or(DEFAULT_CAPACITY) } + fn connected_hosts(&self) -> Vec<&HostTarget> { + self.hosts + .iter() + .filter(|host| { + self.host_states + .get(&host.name) + .map(|state| state.last_success.is_some()) + .unwrap_or(false) + }) + .collect() + } + fn select_previous_host(&mut self) { - if self.hosts.is_empty() { + let connected = self.connected_hosts(); + if connected.is_empty() { return; } - self.active_host_index = if self.active_host_index == 0 { - self.hosts.len().saturating_sub(1) - } else { - self.active_host_index - 1 - }; + // Find current host in connected list + let current_host = self.hosts.get(self.active_host_index); + if let Some(current) = current_host { + if let Some(current_pos) = connected.iter().position(|h| h.name == current.name) { + let new_pos = if current_pos == 0 { + connected.len().saturating_sub(1) + } else { + current_pos - 1 + }; + let new_host = connected[new_pos]; + // Find this host's index in the full hosts list + if let Some(new_index) = self.hosts.iter().position(|h| h.name == new_host.name) { + self.active_host_index = new_index; + } + } else { + // Current host not connected, switch to first connected host + if let Some(new_index) = self.hosts.iter().position(|h| h.name == connected[0].name) { + self.active_host_index = new_index; + } + } + } + self.status = format!( "Active host switched to {} ({}/{})", self.hosts[self.active_host_index].name, @@ -488,11 +518,29 @@ impl App { } fn select_next_host(&mut self) { - if self.hosts.is_empty() { + let connected = self.connected_hosts(); + if connected.is_empty() { return; } - self.active_host_index = (self.active_host_index + 1) % self.hosts.len(); + // Find current host in connected list + let current_host = self.hosts.get(self.active_host_index); + if let Some(current) = current_host { + if let Some(current_pos) = connected.iter().position(|h| h.name == current.name) { + let new_pos = (current_pos + 1) % connected.len(); + let new_host = connected[new_pos]; + // Find this host's index in the full hosts list + if let Some(new_index) = self.hosts.iter().position(|h| h.name == new_host.name) { + self.active_host_index = new_index; + } + } else { + // Current host not connected, switch to first connected host + if let Some(new_index) = self.hosts.iter().position(|h| h.name == connected[0].name) { + self.active_host_index = new_index; + } + } + } + self.status = format!( "Active host switched to {} ({}/{})", self.hosts[self.active_host_index].name, diff --git a/dashboard/src/data/metrics.rs b/dashboard/src/data/metrics.rs index 7e35e88..c0c37b4 100644 --- a/dashboard/src/data/metrics.rs +++ b/dashboard/src/data/metrics.rs @@ -21,6 +21,8 @@ pub struct DriveInfo { pub available_spare: f32, pub capacity_gb: Option, pub used_gb: Option, + #[serde(default)] + pub description: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/dashboard/src/ui/storage.rs b/dashboard/src/ui/storage.rs index b34e402..0b40fe0 100644 --- a/dashboard/src/ui/storage.rs +++ b/dashboard/src/ui/storage.rs @@ -43,10 +43,9 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &SmartMet let mut data = WidgetData::new( title, Some(WidgetStatus::new(widget_status)), - vec!["Drive".to_string(), "Temp".to_string(), "Wear".to_string(), "Spare".to_string(), "Hours".to_string(), "Capacity".to_string(), "Usage".to_string()] + vec!["Name".to_string(), "Temp".to_string(), "Wear".to_string(), "Usage".to_string()] ); - if metrics.drives.is_empty() { data.add_row( None, @@ -56,44 +55,33 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &SmartMet "".to_string(), "".to_string(), "".to_string(), - "".to_string(), - "".to_string(), - "".to_string(), ], ); } else { for drive in &metrics.drives { let status_level = drive_status_level(metrics, &drive.name); + + // Use agent-provided descriptions (agent is source of truth) + let mut description = drive.description.clone().unwrap_or_default(); + + // Add drive-specific issues as additional description lines + for issue in &metrics.issues { + if issue.to_lowercase().contains(&drive.name.to_lowercase()) { + description.push(format!("Issue: {}", issue)); + } + } + data.add_row( Some(WidgetStatus::new(status_level)), - vec![], + description, vec![ drive.name.clone(), format_temperature(drive.temperature_c), format_percent(drive.wear_level), - format_percent(drive.available_spare), - drive.power_on_hours.to_string(), - format_capacity(drive.capacity_gb), format_usage(drive.used_gb, drive.capacity_gb), ], ); } - - if let Some(issue) = metrics.issues.first() { - data.add_row( - Some(WidgetStatus::new(StatusLevel::Warning)), - vec![], - vec![ - format!("Issue: {}", issue), - "".to_string(), - "".to_string(), - "".to_string(), - "".to_string(), - "".to_string(), - "".to_string(), - ], - ); - } } render_widget_data(frame, area, data); @@ -117,22 +105,18 @@ fn format_percent(value: f32) -> String { } -fn format_capacity(value: Option) -> String { - match value { - Some(gb) if gb > 0.0 => format!("{:.0}G", gb), - _ => "—".to_string(), - } -} fn format_usage(used: Option, capacity: Option) -> String { match (used, capacity) { (Some(used_gb), Some(total_gb)) if used_gb > 0.0 && total_gb > 0.0 => { - let percent = (used_gb / total_gb) * 100.0; - format!("{:.0}G ({:.0}%)", used_gb, percent) + format!("{:.0}G/{:.0}G", used_gb, total_gb) } (Some(used_gb), None) if used_gb > 0.0 => { format!("{:.0}G", used_gb) } + (None, Some(total_gb)) if total_gb > 0.0 => { + format!("—/{:.0}G", total_gb) + } _ => "—".to_string(), } }