From af636054addea566cf527c229743a9b2f5e9976f Mon Sep 17 00:00:00 2001 From: nandi Date: Fri, 31 Jul 2026 13:13:58 -0700 Subject: [PATCH] Add process table with name and disk write frequency Sample /proc/[pid]/io for write_bytes and syscw rates, sorted by write throughput. Table columns: Name, Write (B/s), Write freq (syscalls/s). --- README.md | 2 +- src/app.rs | 179 ++++++++++++++++++++++++++++++++++++++++++++++++++++- src/io.rs | 143 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 318 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9b1c508..f62591b 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ cargo run Each gauge fills against an auto-scaling peak (nice 1/2/5×10ⁿ ceiling) so idle and busy machines both stay readable. Heat colors shift accent → warning → destructive as the arc fills. -Below the gauges: cards listing individual disks and network interfaces with mini read/write bars. +Below the gauges: cards listing individual disks and network interfaces with mini read/write bars, plus a **process table** (name, disk write rate, write syscall frequency) from `/proc/[pid]/io`. ## Requirements diff --git a/src/app.rs b/src/app.rs index 2fe8e4e..7cef321 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,12 +1,12 @@ -//! Main window: four I/O gauges + per-device breakdown. +//! Main window: four I/O gauges + per-device breakdown + process table. use std::time::{Duration, Instant}; -use eframe::egui::{self, Align, Layout, RichText, ScrollArea}; +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, IoSampler, IoSnapshot}; +use crate::io::{format_bps, format_hz, IoSampler, IoSnapshot}; const SAMPLE_EVERY: Duration = Duration::from_millis(500); @@ -106,6 +106,8 @@ impl eframe::App for UsageApp { self.gauges_row(ui, &th); ui.add_space(th.spacing.lg); self.breakdown(ui, &th); + ui.add_space(th.spacing.lg); + self.proc_table(ui, &th); }); }); } @@ -193,6 +195,177 @@ impl UsageApp { ); }); } + + fn proc_table(&self, ui: &mut egui::Ui, th: &Theme) { + th.card_frame().show(ui, |ui| { + ui.horizontal(|ui| { + title_2(ui, th, "Processes"); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + dim_label( + ui, + th, + &format!("{} readable", self.snap.processes.len()), + ); + }); + }); + dim_label( + ui, + th, + "Disk write rate and write syscall frequency (from /proc/[pid]/io)", + ); + ui.add_space(th.spacing.sm); + + // Header + let row_h = th.spacing.control_height * 0.85; + header_row(ui, th, row_h); + + if self.snap.processes.is_empty() { + ui.add_space(th.spacing.sm); + dim_label(ui, th, "No process I/O readable (try running as root)"); + return; + } + + // Show processes with any write activity first; keep a floor of top N. + let active: Vec<_> = self + .snap + .processes + .iter() + .filter(|p| p.write_bps > 0.0 || p.write_freq > 0.0) + .collect(); + let rows: Vec<_> = if active.is_empty() { + self.snap.processes.iter().take(24).collect() + } else { + active.into_iter().take(40).collect() + }; + + let max_write = rows + .iter() + .map(|p| p.write_bps) + .fold(0.0_f64, f64::max) + .max(1.0); + + for (i, p) in rows.iter().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), |ui| { + ui.horizontal(|ui| { + ui.set_min_height(row_h); + ui.add_space(th.spacing.sm); + + // Name (flex) + let name_w = (ui.available_width() - 220.0).max(80.0); + let name_label = truncate_name(&p.name, 48); + ui.add_sized( + [name_w, row_h], + egui::Label::new( + RichText::new(name_label) + .size(th.type_scale.body) + .color(th.palette.text), + ) + .truncate(), + ); + + // Write rate + let write_txt = format_bps(p.write_bps); + ui.add_sized( + [100.0, row_h], + egui::Label::new( + RichText::new(write_txt) + .size(th.type_scale.body) + .color(th.palette.text), + ) + .halign(Align::RIGHT), + ); + + // Write frequency (syscalls/s) + let freq_txt = format_hz(p.write_freq); + ui.add_sized( + [90.0, row_h], + egui::Label::new( + RichText::new(freq_txt) + .size(th.type_scale.body) + .color(th.palette.text_secondary), + ) + .halign(Align::RIGHT), + ); + }); + }); + + // Subtle bar under write rate column area + let frac = (p.write_bps / max_write) as f32; + if frac > 0.01 { + let bar_h = 2.0; + let bar_w = rect.width() * frac.clamp(0.0, 1.0); + let bar_rect = egui::Rect::from_min_size( + egui::pos2(rect.left(), rect.bottom() - bar_h), + egui::vec2(bar_w, bar_h), + ); + ui.painter() + .rect_filled(bar_rect, 0.0, th.palette.accent.gamma_multiply(0.7)); + } + } + }); + } +} + +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()); + ui.painter() + .rect_filled(rect, th.spacing.radius_sm, th.palette.headerbar_bg); + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + ui.horizontal(|ui| { + ui.set_min_height(row_h); + ui.add_space(th.spacing.sm); + let name_w = (ui.available_width() - 220.0).max(80.0); + ui.add_sized( + [name_w, row_h], + egui::Label::new( + RichText::new("Name") + .size(th.type_scale.caption) + .strong() + .color(th.palette.text_secondary), + ), + ); + ui.add_sized( + [100.0, row_h], + egui::Label::new( + RichText::new("Write") + .size(th.type_scale.caption) + .strong() + .color(th.palette.text_secondary), + ) + .halign(Align::RIGHT), + ); + ui.add_sized( + [90.0, row_h], + egui::Label::new( + RichText::new("Write freq") + .size(th.type_scale.caption) + .strong() + .color(th.palette.text_secondary), + ) + .halign(Align::RIGHT), + ); + }); + }); +} + +fn truncate_name(name: &str, max_chars: usize) -> String { + let count = name.chars().count(); + if count <= max_chars { + name.to_string() + } else { + let t: String = name.chars().take(max_chars.saturating_sub(1)).collect(); + format!("{t}…") + } } fn device_card( diff --git a/src/io.rs b/src/io.rs index 6ed1be8..58107b1 100644 --- a/src/io.rs +++ b/src/io.rs @@ -1,6 +1,6 @@ -//! Sample Linux disk and network I/O counters from `/proc`. +//! Sample Linux disk, network, and per-process I/O counters from `/proc`. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::fs; use std::time::Instant; @@ -25,6 +25,24 @@ pub struct DeviceIo { pub rates: Rates, } +/// Per-process disk write activity (from `/proc/[pid]/io`). +#[derive(Debug, Clone)] +pub struct ProcIo { + pub pid: u32, + pub name: String, + /// Storage write rate (`write_bytes` delta / s). + pub write_bps: f64, + /// Write syscall rate (`syscw` delta / s) — write frequency. + pub write_freq: f64, +} + +#[derive(Debug, Clone, Default)] +struct ProcCounters { + write_bytes: u64, + syscw: u64, + name: String, +} + /// Full system snapshot of I/O rates. #[derive(Debug, Clone, Default)] pub struct IoSnapshot { @@ -32,12 +50,15 @@ pub struct IoSnapshot { pub net_total: Rates, pub disks: Vec, pub nets: Vec, + /// Processes sorted by write rate (highest first). + pub processes: Vec, } #[derive(Default)] pub struct IoSampler { prev_disks: BTreeMap, prev_nets: BTreeMap, + prev_procs: HashMap, prev_at: Option, } @@ -51,6 +72,7 @@ impl IoSampler { let now = Instant::now(); let disks = read_disk_counters(); let nets = read_net_counters(); + let procs = read_proc_counters(); let dt = self .prev_at @@ -60,12 +82,14 @@ impl IoSampler { let disk_devices = rates_for(&disks, &self.prev_disks, dt); let net_devices = rates_for(&nets, &self.prev_nets, dt); + let processes = proc_rates(&procs, &self.prev_procs, dt); let disk_total = sum_rates(&disk_devices); let net_total = sum_rates(&net_devices); self.prev_disks = disks; self.prev_nets = nets; + self.prev_procs = procs; self.prev_at = Some(now); IoSnapshot { @@ -73,6 +97,7 @@ impl IoSampler { net_total, disks: disk_devices, nets: net_devices, + processes, } } } @@ -217,6 +242,105 @@ fn read_net_counters() -> BTreeMap { map } +fn proc_rates( + current: &HashMap, + prev: &HashMap, + dt: f64, +) -> Vec { + let mut out: Vec = current + .iter() + .map(|(&pid, c)| { + let (write_bps, write_freq) = if dt > 0.0 { + if let Some(p) = prev.get(&pid) { + ( + delta_bps(p.write_bytes, c.write_bytes, dt), + delta_bps(p.syscw, c.syscw, dt), + ) + } else { + (0.0, 0.0) + } + } else { + (0.0, 0.0) + }; + ProcIo { + pid, + name: c.name.clone(), + write_bps, + write_freq, + } + }) + .collect(); + // Highest write throughput first; break ties on write frequency. + out.sort_by(|a, b| { + b.write_bps + .partial_cmp(&a.write_bps) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + b.write_freq + .partial_cmp(&a.write_freq) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .then_with(|| a.pid.cmp(&b.pid)) + }); + out +} + +/// Enumerate `/proc/[pid]/io` + command name. +fn read_proc_counters() -> HashMap { + let Ok(entries) = fs::read_dir("/proc") else { + return HashMap::new(); + }; + let mut map = HashMap::new(); + for ent in entries.flatten() { + let name = ent.file_name(); + let name = name.to_string_lossy(); + let Ok(pid) = name.parse::() else { + continue; + }; + let base = ent.path(); + let Ok(io_raw) = fs::read_to_string(base.join("io")) else { + // Other users' processes often deny access without elevated caps. + continue; + }; + let mut write_bytes = 0u64; + let mut syscw = 0u64; + for line in io_raw.lines() { + 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); + } + } + let comm = fs::read_to_string(base.join("comm")) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| format!("[{pid}]")); + // Prefer a short cmdline base when available (no null noise). + let name = match fs::read(base.join("cmdline")) { + Ok(bytes) if !bytes.is_empty() => { + let first = bytes.split(|&b| b == 0).next().unwrap_or(&bytes); + let s = String::from_utf8_lossy(first); + let leaf = s.rsplit('/').next().unwrap_or(&s); + if leaf.is_empty() { + comm + } else { + // Keep args off the table — name column only. + leaf.split_whitespace().next().unwrap_or(leaf).to_string() + } + } + _ => comm, + }; + map.insert( + pid, + ProcCounters { + write_bytes, + syscw, + name, + }, + ); + } + map +} + /// Human-readable throughput (B/s → KiB/s, MiB/s, …). pub fn format_bps(bps: f64) -> String { const UNITS: [&str; 5] = ["B/s", "KiB/s", "MiB/s", "GiB/s", "TiB/s"]; @@ -236,3 +360,18 @@ pub fn format_bps(bps: f64) -> String { format!("{v:.2} {}", UNITS[i]) } } + +/// Human-readable event rate (writes/s). +pub fn format_hz(rate: f64) -> String { + if rate < 0.05 { + "0/s".into() + } else if rate < 10.0 { + format!("{rate:.1}/s") + } else if rate < 1000.0 { + format!("{rate:.0}/s") + } else if rate < 1_000_000.0 { + format!("{:.1}k/s", rate / 1000.0) + } else { + format!("{:.1}M/s", rate / 1_000_000.0) + } +} -- 2.51.2