Implement hysteresis for metric status changes to prevent flapping

Add comprehensive hysteresis support to prevent status oscillation near
threshold boundaries while maintaining responsive alerting.

Key Features:
- HysteresisThresholds with configurable upper/lower limits
- StatusTracker for per-metric status history
- Default gaps: CPU load 10%, memory 5%, disk temp 5°C

Updated Components:
- CPU load collector (5-minute average with hysteresis)
- Memory usage collector (percentage-based thresholds)
- Disk temperature collector (SMART data monitoring)
- All collectors updated to support StatusTracker interface

Cache Interval Adjustments:
- Service status: 60s → 10s (faster response)
- Disk usage: 300s → 60s (more frequent checks)
- Backup status: 900s → 60s (quicker updates)
- SMART data: moved to 600s tier (10 minutes)

Architecture:
- Individual metric status calculation in collectors
- Centralized StatusTracker in MetricCollectionManager
- Status aggregation preserved in dashboard widgets
This commit is contained in:
2025-10-20 18:45:41 +02:00
parent e998679901
commit 00a8ed3da2
34 changed files with 1037 additions and 770 deletions

View File

@@ -7,7 +7,7 @@ use ratatui::{
use tracing::debug;
use super::Widget;
use crate::ui::theme::{Typography, StatusIcons};
use crate::ui::theme::{StatusIcons, Typography};
/// CPU widget displaying load, temperature, and frequency
#[derive(Clone)]
@@ -38,7 +38,7 @@ impl CpuWidget {
has_data: false,
}
}
/// Format load average for display
fn format_load(&self) -> String {
match (self.load_1min, self.load_5min, self.load_15min) {
@@ -48,7 +48,7 @@ impl CpuWidget {
_ => "— — —".to_string(),
}
}
/// Format frequency for display
fn format_frequency(&self) -> String {
match self.frequency {
@@ -56,16 +56,15 @@ impl CpuWidget {
None => "— MHz".to_string(),
}
}
}
impl Widget for CpuWidget {
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
debug!("CPU widget updating with {} metrics", metrics.len());
// Reset status aggregation
let mut statuses = Vec::new();
for metric in metrics {
match metric.name.as_str() {
"cpu_load_1min" => {
@@ -101,33 +100,40 @@ impl Widget for CpuWidget {
_ => {}
}
}
// Aggregate status
self.status = if statuses.is_empty() {
Status::Unknown
} else {
Status::aggregate(&statuses)
};
self.has_data = !metrics.is_empty();
debug!("CPU widget updated: load={:?}, temp={:?}, freq={:?}, status={:?}",
self.load_1min, self.temperature, self.frequency, self.status);
debug!(
"CPU widget updated: load={:?}, temp={:?}, freq={:?}, status={:?}",
self.load_1min, self.temperature, self.frequency, self.status
);
}
fn render(&mut self, frame: &mut Frame, area: Rect) {
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Length(1)]).split(area);
let content_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(area);
let cpu_title = Paragraph::new("CPU:").style(Typography::widget_title());
frame.render_widget(cpu_title, content_chunks[0]);
let load_freq_spans = StatusIcons::create_status_spans(self.status, &format!("Load: {}{}", self.format_load(), self.format_frequency()));
let load_freq_spans = StatusIcons::create_status_spans(
self.status,
&format!("Load: {}{}", self.format_load(), self.format_frequency()),
);
let load_freq_para = Paragraph::new(ratatui::text::Line::from(load_freq_spans));
frame.render_widget(load_freq_para, content_chunks[1]);
}
}
impl Default for CpuWidget {
fn default() -> Self {
Self::new()
}
}
}

View File

@@ -7,7 +7,7 @@ use ratatui::{
use tracing::debug;
use super::Widget;
use crate::ui::theme::{Typography, StatusIcons};
use crate::ui::theme::{StatusIcons, Typography};
/// Memory widget displaying usage, totals, and swap information
#[derive(Clone)]
@@ -52,8 +52,7 @@ impl MemoryWidget {
has_data: false,
}
}
/// Get memory usage percentage for gauge
fn get_memory_percentage(&self) -> u16 {
match self.usage_percent {
@@ -108,10 +107,8 @@ impl MemoryWidget {
let total_str = Self::format_size_units(total_mb);
format!("{}/{}", used_str, total_str)
}
(None, Some(used_mb), None) => {
Self::format_size_units(used_mb)
}
_ => "".to_string()
(None, Some(used_mb), None) => Self::format_size_units(used_mb),
_ => "".to_string(),
}
}
@@ -129,16 +126,15 @@ impl MemoryWidget {
Status::Unknown
}
}
}
impl Widget for MemoryWidget {
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
debug!("Memory widget updating with {} metrics", metrics.len());
// Reset status aggregation
let mut statuses = Vec::new();
for metric in metrics {
match metric.name.as_str() {
"memory_usage_percent" => {
@@ -198,36 +194,53 @@ impl Widget for MemoryWidget {
_ => {}
}
}
// Aggregate status
self.status = if statuses.is_empty() {
Status::Unknown
} else {
Status::aggregate(&statuses)
};
self.has_data = !metrics.is_empty();
debug!("Memory widget updated: usage={:?}%, total={:?}GB, swap_total={:?}GB, tmp={:?}/{:?}MB, status={:?}",
self.usage_percent, self.total_gb, self.swap_total_gb, self.tmp_size_mb, self.tmp_total_mb, self.status);
}
fn render(&mut self, frame: &mut Frame, area: Rect) {
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1)]).split(area);
let content_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(area);
let mem_title = Paragraph::new("RAM:").style(Typography::widget_title());
frame.render_widget(mem_title, content_chunks[0]);
// Format used and total memory with smart units, percentage, and status icon
let used_str = self.used_gb.map_or("".to_string(), |v| Self::format_size_units(v * 1024.0)); // Convert GB to MB for formatting
let total_str = self.total_gb.map_or("".to_string(), |v| Self::format_size_units(v * 1024.0)); // Convert GB to MB for formatting
let used_str = self
.used_gb
.map_or("".to_string(), |v| Self::format_size_units(v * 1024.0)); // Convert GB to MB for formatting
let total_str = self
.total_gb
.map_or("".to_string(), |v| Self::format_size_units(v * 1024.0)); // Convert GB to MB for formatting
let percentage = self.get_memory_percentage();
let mem_details_spans = StatusIcons::create_status_spans(self.status, &format!("Used: {}% {}/{}", percentage, used_str, total_str));
let mem_details_spans = StatusIcons::create_status_spans(
self.status,
&format!("Used: {}% {}/{}", percentage, used_str, total_str),
);
let mem_details_para = Paragraph::new(ratatui::text::Line::from(mem_details_spans));
frame.render_widget(mem_details_para, content_chunks[1]);
// /tmp usage line with status icon
let tmp_status = self.get_tmp_status();
let tmp_spans = StatusIcons::create_status_spans(tmp_status, &format!("tmp: {}", self.format_tmp_usage()));
let tmp_spans = StatusIcons::create_status_spans(
tmp_status,
&format!("tmp: {}", self.format_tmp_usage()),
);
let tmp_para = Paragraph::new(ratatui::text::Line::from(tmp_spans));
frame.render_widget(tmp_para, content_chunks[2]);
}
@@ -237,4 +250,4 @@ impl Default for MemoryWidget {
fn default() -> Self {
Self::new()
}
}
}

View File

@@ -1,21 +1,21 @@
use cm_dashboard_shared::Metric;
use ratatui::{layout::Rect, Frame};
pub mod backup;
pub mod cpu;
pub mod memory;
pub mod services;
pub mod backup;
pub use backup::BackupWidget;
pub use cpu::CpuWidget;
pub use memory::MemoryWidget;
pub use services::ServicesWidget;
pub use backup::BackupWidget;
/// Widget trait for UI components that display metrics
pub trait Widget {
/// Update widget with new metrics data
fn update_from_metrics(&mut self, metrics: &[&Metric]);
/// Render the widget to a terminal frame
fn render(&mut self, frame: &mut Frame, area: Rect);
}
}