diff --git a/src/app.rs b/src/app.rs index 51c5b78..12112fa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,9 +6,26 @@ use eframe::egui::{self, Align, Layout, RichText, ScrollArea, Sense}; use vidya::{apply, body, dim_label, status_dot, title, title_2, Mode, Theme}; use crate::gauge::{arc_gauge, heat_color, mini_bar, PeakScale}; -use crate::io::{format_bps, format_hz, IoSampler, IoSnapshot}; +use crate::io::{ + format_bps, format_hz, format_local_hms, top_reader, top_writer, IoSampler, IoSnapshot, +}; const SAMPLE_EVERY: Duration = Duration::from_millis(500); +/// Gauge fill at/above this of the *previous* scale counts as abnormal. +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. +const ANOMALY_HOLD: Duration = Duration::from_secs(45); + +/// Last process that drove an abnormal disk gauge reading. +#[derive(Debug, Clone)] +struct AnomalyHit { + proc_name: String, + rate_bps: f64, + stamp: String, + at: Instant, +} pub struct UsageApp { mode: Mode, @@ -19,6 +36,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, } impl UsageApp { @@ -41,6 +62,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, } } @@ -58,10 +81,56 @@ impl UsageApp { let d = &self.snap.disk_total; let n = &self.snap.net_total; + + // Compare against previous scale *before* observing so a new peak + // still registers as abnormal relative to the old ceiling. + let dr_prev = self.disk_read_scale.current(); + let dw_prev = self.disk_write_scale.current(); + + 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(), + }); + } + } + 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(), + }); + } + } + self.disk_read_scale.observe(d.read_bps); self.disk_write_scale.observe(d.write_bps); 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); + } + } +} + +fn is_abnormal(rate: f64, prev_scale: f64) -> bool { + if rate < ABNORMAL_MIN_BPS || prev_scale <= 0.0 { + return false; + } + (rate / prev_scale) >= ABNORMAL_FRAC +} + +fn expire_anomaly(slot: &mut Option) { + if let Some(hit) = slot { + if hit.at.elapsed() > ANOMALY_HOLD { + *slot = None; } } } @@ -137,16 +206,29 @@ impl UsageApp { let nr_max = self.net_rx_scale.current(); let nw_max = self.net_tx_scale.current(); - let cards: [(&str, f64, f64, &str); 4] = [ - ("Disk read", disk_r, dr_max, "Disk Read"), - ("Disk write", disk_w, dw_max, "Disk Write"), - ("Network down", net_r, nr_max, "Net Down"), - ("Network up", net_w, nw_max, "Net Up"), + // caption, value, max, label, optional sticky anomaly under the gauge + let cards: [(&str, f64, f64, &str, Option<&AnomalyHit>); 4] = [ + ( + "Disk read", + disk_r, + dr_max, + "Disk Read", + self.disk_read_anomaly.as_ref(), + ), + ( + "Disk write", + disk_w, + dw_max, + "Disk Write", + self.disk_write_anomaly.as_ref(), + ), + ("Network down", net_r, nr_max, "Net Down", None), + ("Network up", net_w, nw_max, "Net Up", None), ]; ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = egui::vec2(gap, gap); - for (caption, value, max, label) in cards { + for (caption, value, max, label, anomaly) in cards { let frac = if max > 0.0 { (value / max) as f32 } else { @@ -169,6 +251,10 @@ 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 { + ui.add_space(th.spacing.sm); + anomaly_caption(ui, th, hit); + } }); }); } @@ -288,6 +374,25 @@ 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 header_row(ui: &mut egui::Ui, th: &Theme, row_h: f32) { let (rect, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), row_h), Sense::hover()); diff --git a/src/io.rs b/src/io.rs index 7772fd1..6a2d1ee 100644 --- a/src/io.rs +++ b/src/io.rs @@ -25,13 +25,15 @@ pub struct DeviceIo { pub rates: Rates, } -/// Per-process disk write activity (from `/proc/[pid]/io`). +/// Per-process disk I/O activity (from `/proc/[pid]/io`). #[derive(Debug, Clone)] pub struct ProcIo { pub pid: u32, pub name: String, /// Executable path (`/proc/[pid]/exe` or cmdline argv0). pub path: String, + /// Storage read rate (`read_bytes` delta / s). + pub read_bps: f64, /// Storage write rate (`write_bytes` delta / s). pub write_bps: f64, /// Write syscall rate (`syscw` delta / s) — write frequency. @@ -40,6 +42,7 @@ pub struct ProcIo { #[derive(Debug, Clone, Default)] struct ProcCounters { + read_bytes: u64, write_bytes: u64, syscw: u64, name: String, @@ -253,28 +256,30 @@ fn proc_rates( let mut out: Vec = current .iter() .map(|(&pid, c)| { - let (write_bps, write_freq) = if dt > 0.0 { + let (read_bps, write_bps, write_freq) = if dt > 0.0 { if let Some(p) = prev.get(&pid) { ( + delta_bps(p.read_bytes, c.read_bytes, dt), delta_bps(p.write_bytes, c.write_bytes, dt), delta_bps(p.syscw, c.syscw, dt), ) } else { - (0.0, 0.0) + (0.0, 0.0, 0.0) } } else { - (0.0, 0.0) + (0.0, 0.0, 0.0) }; ProcIo { pid, name: c.name.clone(), path: c.path.clone(), + read_bps, write_bps, write_freq, } }) .collect(); - // Highest write throughput first; break ties on write frequency. + // Highest write throughput first; break ties on write frequency / read. out.sort_by(|a, b| { b.write_bps .partial_cmp(&a.write_bps) @@ -284,11 +289,40 @@ fn proc_rates( .partial_cmp(&a.write_freq) .unwrap_or(std::cmp::Ordering::Equal) }) + .then_with(|| { + b.read_bps + .partial_cmp(&a.read_bps) + .unwrap_or(std::cmp::Ordering::Equal) + }) .then_with(|| a.pid.cmp(&b.pid)) }); out } +/// Process with the highest storage read rate this sample. +pub fn top_reader(procs: &[ProcIo]) -> Option<&ProcIo> { + procs + .iter() + .filter(|p| p.read_bps > 0.0) + .max_by(|a, b| { + a.read_bps + .partial_cmp(&b.read_bps) + .unwrap_or(std::cmp::Ordering::Equal) + }) +} + +/// Process with the highest storage write rate this sample. +pub fn top_writer(procs: &[ProcIo]) -> Option<&ProcIo> { + procs + .iter() + .filter(|p| p.write_bps > 0.0) + .max_by(|a, b| { + a.write_bps + .partial_cmp(&b.write_bps) + .unwrap_or(std::cmp::Ordering::Equal) + }) +} + /// Enumerate `/proc/[pid]/io` + command name. fn read_proc_counters() -> HashMap { let Ok(entries) = fs::read_dir("/proc") else { @@ -306,10 +340,13 @@ fn read_proc_counters() -> HashMap { // Other users' processes often deny access without elevated caps. continue; }; + let mut read_bytes = 0u64; let mut write_bytes = 0u64; let mut syscw = 0u64; for line in io_raw.lines() { - if let Some(v) = line.strip_prefix("write_bytes:") { + if let Some(v) = line.strip_prefix("read_bytes:") { + read_bytes = v.trim().parse().unwrap_or(0); + } else if let Some(v) = line.strip_prefix("write_bytes:") { write_bytes = v.trim().parse().unwrap_or(0); } else if let Some(v) = line.strip_prefix("syscw:") { syscw = v.trim().parse().unwrap_or(0); @@ -355,6 +392,7 @@ fn read_proc_counters() -> HashMap { map.insert( pid, ProcCounters { + read_bytes, write_bytes, syscw, name, @@ -399,3 +437,25 @@ pub fn format_hz(rate: f64) -> String { format!("{:.1}M/s", rate / 1_000_000.0) } } + +/// Local wall-clock `HH:MM:SS` for anomaly stamps. +pub fn format_local_hms() -> String { + if let Ok(out) = std::process::Command::new("date").arg("+%H:%M:%S").output() { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { + return s; + } + } + } + // Fallback: seconds since epoch mod day (UTC-ish). + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let h = (secs / 3600) % 24; + let m = (secs / 60) % 60; + let s = secs % 60; + format!("{h:02}:{m:02}:{s:02}") +}