diff --git a/src/app.rs b/src/app.rs index 12112fa..3b1374d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,10 +15,12 @@ const SAMPLE_EVERY: Duration = Duration::from_millis(500); const ABNORMAL_FRAC: f64 = 0.80; /// Ignore tiny rates so idle noise never latches an anomaly. const ABNORMAL_MIN_BPS: f64 = 2.0 * 1024.0 * 1024.0; // 2 MiB/s -/// How long to keep the last anomaly label under the gauge. +/// How long to keep each anomaly entry under the gauge. const ANOMALY_HOLD: Duration = Duration::from_secs(45); +/// Cap list length so the gauge card stays usable. +const ANOMALY_MAX: usize = 12; -/// Last process that drove an abnormal disk gauge reading. +/// One process that drove an abnormal disk gauge reading. #[derive(Debug, Clone)] struct AnomalyHit { proc_name: String, @@ -36,10 +38,10 @@ pub struct UsageApp { disk_write_scale: PeakScale, net_rx_scale: PeakScale, net_tx_scale: PeakScale, - /// Sticky attribution under Disk Read when I/O spiked. - disk_read_anomaly: Option, - /// Sticky attribution under Disk Write when I/O spiked. - disk_write_anomaly: Option, + /// History of abnormal Disk Read attributions (newest last). + disk_read_anomalies: Vec, + /// History of abnormal Disk Write attributions (newest last). + disk_write_anomalies: Vec, } impl UsageApp { @@ -62,8 +64,8 @@ impl UsageApp { disk_write_scale: PeakScale::new(1024.0 * 1024.0), net_rx_scale: PeakScale::new(256.0 * 1024.0), net_tx_scale: PeakScale::new(256.0 * 1024.0), - disk_read_anomaly: None, - disk_write_anomaly: None, + disk_read_anomalies: Vec::new(), + disk_write_anomalies: Vec::new(), } } @@ -89,22 +91,28 @@ impl UsageApp { if is_abnormal(d.read_bps, dr_prev) { if let Some(p) = top_reader(&self.snap.processes) { - self.disk_read_anomaly = Some(AnomalyHit { - proc_name: p.name.clone(), - rate_bps: d.read_bps, - stamp: format_local_hms(), - at: Instant::now(), - }); + push_anomaly( + &mut self.disk_read_anomalies, + AnomalyHit { + proc_name: p.name.clone(), + rate_bps: d.read_bps, + stamp: format_local_hms(), + at: Instant::now(), + }, + ); } } if is_abnormal(d.write_bps, dw_prev) { if let Some(p) = top_writer(&self.snap.processes) { - self.disk_write_anomaly = Some(AnomalyHit { - proc_name: p.name.clone(), - rate_bps: d.write_bps, - stamp: format_local_hms(), - at: Instant::now(), - }); + push_anomaly( + &mut self.disk_write_anomalies, + AnomalyHit { + proc_name: p.name.clone(), + rate_bps: d.write_bps, + stamp: format_local_hms(), + at: Instant::now(), + }, + ); } } @@ -113,9 +121,8 @@ impl UsageApp { self.net_rx_scale.observe(n.read_bps); self.net_tx_scale.observe(n.write_bps); - // Drop stale attributions. - expire_anomaly(&mut self.disk_read_anomaly); - expire_anomaly(&mut self.disk_write_anomaly); + expire_anomalies(&mut self.disk_read_anomalies); + expire_anomalies(&mut self.disk_write_anomalies); } } } @@ -127,12 +134,25 @@ fn is_abnormal(rate: f64, prev_scale: f64) -> bool { (rate / prev_scale) >= ABNORMAL_FRAC } -fn expire_anomaly(slot: &mut Option) { - if let Some(hit) = slot { - if hit.at.elapsed() > ANOMALY_HOLD { - *slot = None; +/// Append a hit; skip duplicate of the same process within a short window. +fn push_anomaly(list: &mut Vec, hit: AnomalyHit) { + // Don't spam the same name on consecutive samples — refresh stamp/rate instead. + if let Some(last) = list.last_mut() { + if last.proc_name == hit.proc_name && last.at.elapsed() < Duration::from_secs(3) { + last.rate_bps = hit.rate_bps; + last.stamp = hit.stamp; + last.at = hit.at; + return; } } + list.push(hit); + while list.len() > ANOMALY_MAX { + list.remove(0); + } +} + +fn expire_anomalies(list: &mut Vec) { + list.retain(|hit| hit.at.elapsed() <= ANOMALY_HOLD); } impl eframe::App for UsageApp { @@ -206,29 +226,29 @@ impl UsageApp { let nr_max = self.net_rx_scale.current(); let nw_max = self.net_tx_scale.current(); - // caption, value, max, label, optional sticky anomaly under the gauge - let cards: [(&str, f64, f64, &str, Option<&AnomalyHit>); 4] = [ + // caption, value, max, label, anomaly history under the gauge + let cards: [(&str, f64, f64, &str, &[AnomalyHit]); 4] = [ ( "Disk read", disk_r, dr_max, "Disk Read", - self.disk_read_anomaly.as_ref(), + &self.disk_read_anomalies, ), ( "Disk write", disk_w, dw_max, "Disk Write", - self.disk_write_anomaly.as_ref(), + &self.disk_write_anomalies, ), - ("Network down", net_r, nr_max, "Net Down", None), - ("Network up", net_w, nw_max, "Net Up", None), + ("Network down", net_r, nr_max, "Net Down", &[]), + ("Network up", net_w, nw_max, "Net Up", &[]), ]; ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = egui::vec2(gap, gap); - for (caption, value, max, label, anomaly) in cards { + for (caption, value, max, label, anomalies) in cards { let frac = if max > 0.0 { (value / max) as f32 } else { @@ -251,9 +271,9 @@ impl UsageApp { ); ui.add_space(th.spacing.xs.max(2.0)); dim_label(ui, th, &format!("scale {}", format_bps(max))); - if let Some(hit) = anomaly { + if !anomalies.is_empty() { ui.add_space(th.spacing.sm); - anomaly_caption(ui, th, hit); + anomaly_list(ui, th, anomalies); } }); }); @@ -374,23 +394,23 @@ impl UsageApp { } } -fn anomaly_caption(ui: &mut egui::Ui, th: &Theme, hit: &AnomalyHit) { - // Proc that drove the spike + when it was seen. - ui.label( - RichText::new(&hit.proc_name) - .size(th.type_scale.body) - .strong() - .color(th.palette.warning), - ); - ui.label( - RichText::new(format!( - "{} · {}", - hit.stamp, - format_bps(hit.rate_bps) - )) - .size(th.type_scale.caption) - .color(th.palette.text_secondary), - ); +fn anomaly_list(ui: &mut egui::Ui, th: &Theme, hits: &[AnomalyHit]) { + // Newest first under the gauge. + for hit in hits.iter().rev() { + ui.horizontal(|ui| { + ui.label( + RichText::new(&hit.proc_name) + .size(th.type_scale.caption) + .strong() + .color(th.palette.warning), + ); + ui.label( + RichText::new(format!("{} · {}", hit.stamp, format_bps(hit.rate_bps))) + .size(th.type_scale.caption) + .color(th.palette.text_secondary), + ); + }); + } } fn header_row(ui: &mut egui::Ui, th: &Theme, row_h: f32) {