Implement comprehensive dashboard improvements and maintenance mode
- 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
This commit is contained in:
parent
bb69f0f31b
commit
cd4764596f
@ -357,6 +357,8 @@ struct SmartDeviceData {
|
||||
health_status: String,
|
||||
capacity_gb: Option<f32>,
|
||||
used_gb: Option<f32>,
|
||||
#[serde(default)]
|
||||
description: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -21,6 +21,8 @@ pub struct DriveInfo {
|
||||
pub available_spare: f32,
|
||||
pub capacity_gb: Option<f32>,
|
||||
pub used_gb: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub description: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@ -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<f32>) -> String {
|
||||
match value {
|
||||
Some(gb) if gb > 0.0 => format!("{:.0}G", gb),
|
||||
_ => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_usage(used: Option<f32>, capacity: Option<f32>) -> 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(),
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user