From f91000808c9dfa1aa332bca558d53153e46d7de7 Mon Sep 17 00:00:00 2001 From: nandi Date: Fri, 31 Jul 2026 14:34:00 -0700 Subject: [PATCH] Move anomaly tables full-width under gauges; fix cell wrap Gauge cards stay equal-height without cramped in-card tables. Abnormal Process/Time/Usage lives in full-width cards with single-line truncate cells and fixed time/usage columns. --- src/app.rs | 228 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 158 insertions(+), 70 deletions(-) diff --git a/src/app.rs b/src/app.rs index 58cdd8c..5dbd75b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -185,6 +185,12 @@ impl eframe::App for UsageApp { .auto_shrink([false, false]) .show(ui, |ui| { self.gauges_row(ui, &th); + if !self.disk_read_anomalies.is_empty() + || !self.disk_write_anomalies.is_empty() + { + ui.add_space(th.spacing.md); + self.anomaly_section(ui, &th); + } ui.add_space(th.spacing.lg); self.proc_table(ui, &th); ui.add_space(th.spacing.lg); @@ -218,29 +224,17 @@ impl UsageApp { let nr_max = self.net_rx_scale.current(); let nw_max = self.net_tx_scale.current(); - // 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_anomalies, - ), - ( - "Disk write", - disk_w, - dw_max, - "Disk Write", - &self.disk_write_anomalies, - ), - ("Network down", net_r, nr_max, "Net Down", &[]), - ("Network up", net_w, nw_max, "Net Up", &[]), + // Gauges only — anomaly tables are full-width below (too narrow in-card). + 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"), ]; ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = egui::vec2(gap, gap); - for (caption, value, max, label, anomalies) in cards { + for (caption, value, max, label) in cards { let frac = if max > 0.0 { (value / max) as f32 } else { @@ -248,10 +242,9 @@ impl UsageApp { }; let color = heat_color(th, frac); th.card_frame().show(ui, |ui| { - // Non-justified: sibling cards in a wrap row can pass extra - // height; justified layout would stretch gaps in the table. ui.with_layout(Layout::top_down(Align::Center), |ui| { ui.set_width(cell_w - 1.0); + ui.set_min_height(gauge_size + 72.0); ui.spacing_mut().item_spacing.y = th.spacing.xs.max(2.0); dim_label(ui, th, caption); @@ -265,20 +258,36 @@ impl UsageApp { color, ); dim_label(ui, th, &format!("scale {}", format_bps(max))); - - if !anomalies.is_empty() { - ui.add_space(th.spacing.sm); - ui.with_layout(Layout::top_down(Align::Min), |ui| { - ui.set_width(ui.available_width()); - anomaly_table(ui, th, anomalies); - }); - } }); }); } }); } + /// Full-width Process | Time | Usage tables for abnormal disk I/O. + fn anomaly_section(&self, ui: &mut egui::Ui, th: &Theme) { + let read = &self.disk_read_anomalies; + let write = &self.disk_write_anomalies; + let both = !read.is_empty() && !write.is_empty(); + + if both && ui.available_width() >= 560.0 { + ui.columns(2, |cols| { + anomaly_card(&mut cols[0], th, "Abnormal disk read", read, "anomaly_read"); + anomaly_card(&mut cols[1], th, "Abnormal disk write", write, "anomaly_write"); + }); + } else { + if !read.is_empty() { + anomaly_card(ui, th, "Abnormal disk read", read, "anomaly_read"); + } + if !write.is_empty() { + if !read.is_empty() { + ui.add_space(th.spacing.sm); + } + anomaly_card(ui, th, "Abnormal disk write", write, "anomaly_write"); + } + } + } + fn breakdown(&self, ui: &mut egui::Ui, th: &Theme) { ui.columns(2, |cols| { device_card( @@ -392,48 +401,127 @@ impl UsageApp { } } -/// Compact table under a disk gauge: Process | Time | Usage. -fn anomaly_table(ui: &mut egui::Ui, th: &Theme, hits: &[AnomalyHit]) { - // Tight grid — no justified gaps when the parent card is stretched tall. - ui.spacing_mut().item_spacing = egui::vec2(th.spacing.sm, 2.0); - - egui::Grid::new(ui.id().with("anomaly_hits")) - .num_columns(3) - .min_col_width(40.0) - .spacing([th.spacing.sm, 2.0]) - .striped(true) - .show(ui, |ui| { - let head = |s: &str| { - RichText::new(s) - .size(th.type_scale.caption) - .strong() - .color(th.palette.text_secondary) - }; - ui.label(head("Process")); - ui.label(head("Time")); - ui.label(head("Usage")); - ui.end_row(); - - for hit in hits.iter().rev() { - ui.label( - RichText::new(truncate_chars(&hit.proc_name, 20)) - .size(th.type_scale.caption) - .strong() - .color(th.palette.warning), - ); - ui.label( - RichText::new(&hit.stamp) - .size(th.type_scale.caption) - .color(th.palette.text_secondary), - ); - ui.label( - RichText::new(format_bps(hit.rate_bps)) - .size(th.type_scale.caption) - .color(th.palette.text_secondary), - ); - ui.end_row(); - } +fn anomaly_card(ui: &mut egui::Ui, th: &Theme, title: &str, hits: &[AnomalyHit], id: &str) { + th.card_frame().show(ui, |ui| { + title_2(ui, th, title); + ui.add_space(th.spacing.xs.max(2.0)); + anomaly_table(ui, th, hits, id); + }); +} + +/// Process | Time | Usage — single-line cells, fixed time/usage widths. +fn anomaly_table(ui: &mut egui::Ui, th: &Theme, hits: &[AnomalyHit], id: &str) { + const TIME_W: f32 = 104.0; + const USAGE_W: f32 = 88.0; + let row_h = th.type_scale.caption + 10.0; + let name_w = (ui.available_width() - TIME_W - USAGE_W - th.spacing.md * 2.0).max(96.0); + + // Header + { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), row_h), Sense::hover()); + ui.painter() + .rect_filled(rect, th.spacing.radius_sm, th.palette.headerbar_bg); + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + anomaly_cells( + ui, + th, + row_h, + true, + "Process", + "Time", + "Usage", + name_w, + TIME_W, + USAGE_W, + ); }); + } + + for (i, hit) in hits.iter().rev().enumerate() { + let zebra = if i % 2 == 0 { + th.palette.view_bg + } else { + th.palette.card_bg + }; + let (rect, _) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), row_h), Sense::hover()); + ui.painter().rect_filled(rect, 0.0, zebra); + ui.scope_builder(egui::UiBuilder::new().max_rect(rect).id_salt((id, i)), |ui| { + anomaly_cells( + ui, + th, + row_h, + false, + &hit.proc_name, + &hit.stamp, + &format_bps(hit.rate_bps), + name_w, + TIME_W, + USAGE_W, + ); + }); + } +} + +fn anomaly_cells( + ui: &mut egui::Ui, + th: &Theme, + row_h: f32, + is_header: bool, + process: &str, + time: &str, + usage: &str, + name_w: f32, + time_w: f32, + usage_w: f32, +) { + let head = |s: &str| { + RichText::new(s) + .size(th.type_scale.caption) + .strong() + .color(th.palette.text_secondary) + }; + let warn = |s: &str| { + RichText::new(s) + .size(th.type_scale.caption) + .strong() + .color(th.palette.warning) + }; + let dim = |s: &str| { + RichText::new(s) + .size(th.type_scale.caption) + .color(th.palette.text_secondary) + }; + + ui.horizontal(|ui| { + ui.set_min_height(row_h); + ui.set_max_height(row_h); + ui.spacing_mut().item_spacing.x = th.spacing.sm; + ui.add_space(th.spacing.sm); + + let name = if is_header { + process.to_string() + } else { + truncate_chars(process, 36) + }; + // `truncate()` keeps a single line — no wrap of "chromium" / time. + ui.add_sized( + [name_w, row_h], + egui::Label::new(if is_header { head(&name) } else { warn(&name) }) + .truncate(), + ); + ui.add_sized( + [time_w, row_h], + egui::Label::new(if is_header { head(time) } else { dim(time) }).truncate(), + ); + ui.add_sized( + [usage_w, row_h], + egui::Label::new(if is_header { head(usage) } else { dim(usage) }) + .halign(Align::RIGHT) + .truncate(), + ); + }); } fn header_row(ui: &mut egui::Ui, th: &Theme, row_h: f32) { -- 2.51.2