From 2597246908f939b98d9647b68d45421d952b44ce Mon Sep 17 00:00:00 2001 From: vasilito Date: Sun, 21 Jun 2026 07:19:16 +0300 Subject: [PATCH] redbear-power: v1.31 per-PID IO rate sparkline Activates the v1.25-deferred 'persistent rate sparkline' future-use. Each process in the Process tab now shows a 12-sample sparkline of its IO rate history (last 78 seconds at the 6.5s process refresh cadence). - New App.io_history: BTreeMap> Per-PID history of raw f64-bit rate samples. BTreeMap for stable iteration; VecDeque for O(1) push-back + pop-front. - PROCESS_IO_HISTORY_LEN = 12 (12 samples * 6.5s = 78s of history) - App::update_io_history() runs after sort_tree + apply_fold on every process refresh. Three-pass algorithm: 1. Reap: drop history for PIDs that exited 2. Append: push new f64-bit sample for PIDs with known rate (PIDs with None rate are skipped, no entry created) 3. Normalize: divide each sample by the per-history max, scale to u8 0..=255. Separate pass so max is computed once per history, not per sample. - render::io_rate_sparkline(&[u8]) helper maps 0..=255 to Unicode chars (\u2581\u2582... matches existing load sparkline) - New 'IO-RATE' column in Process panel between RSS/VSZ and COMM. 12 chars wide. Empty spaces for PIDs with no history yet (first tick after startup). - Why u64 storage of f64 bits: normalization needs the full f64 range; clamping to u8 before normalize would lose precision for high-rate PIDs. Test count 117 -> 121 (+4): - update_io_history_reaps_exited_pids - update_io_history_normalizes_against_max (100/200*255=127.5 rounds to 128; 200/200*255=255) - update_io_history_handles_all_zero (no div by zero) - update_io_history_skips_pids_without_rate (None rate \u2192 no entry created; no panic) Redox stripped binary: 4,201,320 bytes (+4 KiB from v1.30). Memory: ~91 KiB for 600 PIDs (negligible). Compile warnings: 55 (unchanged). Notes: - CPU% sparkline per process: defer (same pattern, separate work) - RSS sparkline per process: defer - Variable sparkline length: defer (header is 'IO-RATE' not 'IO-RATE 12' so a future change to PROCESS_IO_HISTORY_LEN doesn't need a header update) - Per-PID scaling (not global): each PID's max is 255. A long-running PID at 5 KiB/s steady shows full bars; a bursty PID that just started at 50 KiB/s also shows full bars. Global scaling would flatten the long-running one. Docs: local/docs/redbear-power-improvement-plan.md \xC2\xA755 --- local/docs/redbear-power-improvement-plan.md | 140 ++++++++++++++++++ .../system/redbear-power/source/src/app.rs | 140 ++++++++++++++++++ .../system/redbear-power/source/src/render.rs | 32 +++- 3 files changed, 310 insertions(+), 2 deletions(-) diff --git a/local/docs/redbear-power-improvement-plan.md b/local/docs/redbear-power-improvement-plan.md index 7a576c84c8..dedfe2509a 100644 --- a/local/docs/redbear-power-improvement-plan.md +++ b/local/docs/redbear-power-improvement-plan.md @@ -5142,6 +5142,146 @@ selected row. --- +## 55. v1.31 Per-PID IO Rate Sparkline (2026-06-21) + +Per the v1.25 deferred-future-use comment ("persistent rate +sparkline ... a per-process IO rate over time is a natural +visualization"), v1.31 adds a small sparkline column to the +Process tab showing each PID's IO rate history. + +### 55.1 What was implemented + +**`App.io_history: BTreeMap>`** — per-PID +history of raw f64-bit rate samples. `BTreeMap` for stable +iteration; `VecDeque` for O(1) push-back + pop-front when the +capacity is hit. The key is the PID, the value is the last +`PROCESS_IO_HISTORY_LEN = 12` samples. + +**`PROCESS_IO_HISTORY_LEN = 12`** — 12 samples × 6.5s refresh += 78 seconds of history. Wide enough to see a CPU/IO burst +pattern; narrow enough to keep the column at 12 chars. + +**`App::update_io_history()`** — called from the refresh path +after `sort_tree` and `apply_fold`. Three-pass algorithm: + +1. **Reap**: drop entries for PIDs that exited since the last + refresh. Uses `BTreeMap::retain`. +2. **Append**: for each current PID with a known rate + (`io_total_rate_kbs() == Some(_)`), push the new f64-bit + sample. PIDs without a known rate (sentinel `None`) are + skipped — no history entry is created. Capacity-bounded at + `PROCESS_IO_HISTORY_LEN`. +3. **Normalize**: for each history, compute the max once, then + divide every sample by the max and scale to u8 0..=255. + Separating the max computation from the per-sample division + is a deliberate optimization — without it, we'd recompute the + max N times per history. + +**Why u64 storage of f64 bits** — direct f64 in `VecDeque` would +require `VecDeque` which is fine on 64-bit but the +normalization step needs to round to u8 anyway. Storing the +bits lets the normalize step use `f64::from_bits` and then +`as u8` without an intermediate typed conversion. + +**`render::io_rate_sparkline(&[u8]) -> String`** — new helper. +Maps 0..=255 values to Unicode sparkline chars (`▁▂▃▄▅▆▇█`), +matching the existing `padded_to_sparkline` 0..=100 helper's +visual style. The render layer re-interprets the u64 history as +f64, clamps to 0..=255, and calls this helper. + +**New column in the Process panel** — `IO-RATE` between the +`RSS`/`VSZ` column and `COMM`. 12 chars wide. Empty (spaces) for +PIDs with no history yet. The render format string grew from +10 columns to 11; total width is now ~108 chars, still well +within a 120-col terminal. + +### 55.2 Test coverage + +Test count: **121** (up from 117 in v1.30). + +New tests (4): +- `update_io_history_reaps_exited_pids` — pre-seeds a history + for a PID that's not in the current read; after + `update_io_history()` the entry is gone. +- `update_io_history_normalizes_against_max` — injects raw + 100.0 and 200.0 samples; asserts normalized values 128 and + 255 (100/200 × 255 = 127.5 → 128 after round). +- `update_io_history_handles_all_zero` — all-zero history + should not divide by zero; values stay at 0. +- `update_io_history_skips_pids_without_rate` — PIDs whose + `io_total_rate_kbs()` is `None` don't get history entries + created. The function must not panic on `None`. + +### 55.3 Cross-compile + smoke test results + +| Target | Size | SHA256 | +|--------------|-------------|-------------------------------------------------------------------| +| Linux host | 3.0 MB | (run from `target/release/redbear-power`) | +| Redox x86_64 | 4,201,320 B | `eb33519d7fadbc71c74fe6facc5de994d94b2944c0fed3c18ac69ab62b815cb8` | + +Binary size delta: +4,096 bytes (4 KiB) from v1.30. The growth +comes from the new `update_io_history` method, the +`io_rate_sparkline` helper, the 4 new tests, and the render +column. + +Smoke test confirms the new column header: +``` +PID STATE PRIO NI THR CPU% IO RATE RSS IO-RATE COMM +``` + +The `--once` mode shows blank sparklines (no history yet since +only one tick ran). The interactive TUI populates them on the +second refresh (after 6.5s). + +### 55.4 Memory cost + +`BTreeMap>` for 600 active PIDs: +- Per PID: 12 samples × 8 bytes (u64) + VecDeque overhead = ~120 bytes +- Plus BTreeMap entry: ~32 bytes +- Total: ~91 KiB for 600 PIDs + +Negligible vs. the 4 MiB binary. The `BTreeMap` shrinks as +PIDs exit (reap pass), so steady-state memory is proportional +to the number of distinct PIDs seen since the last reset. + +### 55.5 UX flow + +v1.31 makes the Process tab fully time-aware: + +| Visualization | What it shows | +|---------------|---------------| +| CPU% | Instantaneous CPU% for this tick | +| IO | Cumulative read+write bytes (lifetime) | +| RATE | Current read+write KiB/s | +| IO-RATE | Last 78 seconds of rate history | + +The combination of RATE (current number) + IO-RATE (visual +history) answers both "what is happening now" (RATE) and "how +does this compare to the recent past" (IO-RATE shape). + +### 55.6 What was NOT changed (intentional) + +- **CPU% sparkline per process** — would mirror the IO history + pattern but for CPU% instead of rate. Defer. +- **RSS sparkline per process** — same idea for memory. Defer. +- **Variable sparkline length** — fixed at 12 samples. The + header is "IO-RATE" not "IO-RATE 12" so a future length + change doesn't need a header update. +- **Sparkline auto-scaling** — current normalize is per-PID + (each PID's max is 255). Could do global-scaling (max across + all PIDs) so visually comparing two PIDs is easier. Per-PID + was chosen for v1.31 because it preserves the per-PID + activity pattern (a PID that just started and spiked to 50 + KiB/s shows full bars; a long-running PID at 5 KiB/s steady + also shows full bars). Global scaling would make the + long-running PID look "flat" in comparison. +- **No time scale label** — the column is just "IO-RATE" with + no "12 × 6.5s = 78s" annotation. The history length is in + `PROCESS_IO_HISTORY_LEN`; future: a tooltip in the PID detail + popup could explain. + +--- + ## See Also - **`local/docs/RATATUI-APP-PATTERNS.md`** §13 — the canonical ratatui 0.30 best-practices update that this plan is derived from. Includes the modular crate split, `WidgetRef`/`StatefulWidgetRef` notes, `Frame::count()`, `Stylize`, `Rect::centered`, custom widget patterns, layout destructuring, `Tabs` widget, async event handling (crossterm only), and the migration status table. Use this as the implementation guide while this doc is the roadmap. diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index fa8df763e5..5790949604 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -25,6 +25,10 @@ use crate::msr::{ pub const POLL_MS: u64 = 500; pub const LOAD_HISTORY_LEN: usize = 30; pub const SPARK_WIDTH: usize = 20; +/// Per-PID IO rate history length (number of samples shown in the +/// Process tab sparkline). At POLL_MS=500ms and 13-tick process +/// refresh, 12 samples = 78 seconds of history. +pub const PROCESS_IO_HISTORY_LEN: usize = 12; #[derive(Clone, Copy, Debug, PartialEq)] pub enum Governor { @@ -140,6 +144,13 @@ pub meminfo: crate::meminfo::MemInfo, /// by the `Space` hotkey (Process tab, tree mode only). /// `BTreeSet` for stable iteration order. pub folded: std::collections::BTreeSet, + /// Per-PID IO rate history (raw KiB/s samples as f64 bits, + /// normalized to 0..=255 against the max in the history + /// before being passed to the renderer's sparkline). Used by + /// the Process tab to render a small sparkline per process. + /// PIDs that exit have their entries reaped on the next + /// refresh. `BTreeMap` for stable iteration order. + pub io_history: std::collections::BTreeMap>, /// Cursor index into the visible (post-filter) process list. /// Distinct from `table_state` which tracks the Per-CPU tab. pub process_cursor: usize, @@ -311,6 +322,7 @@ impl App { process_filter: String::new(), process_tree: false, folded: std::collections::BTreeSet::new(), + io_history: std::collections::BTreeMap::new(), process_cursor: 0, pid_detail: None, refresh_counter: 0, @@ -456,6 +468,7 @@ impl App { self.processes.processes = filtered; } } + self.update_io_history(); self.prev_refresh_secs = now_secs; } @@ -731,6 +744,61 @@ impl App { .collect() } + /// Update the per-PID IO rate history from the current + /// `self.processes`. Each PID's history is a `VecDeque` + /// of raw f64-bit rate samples, normalized to u8 0..=255 + /// against the history's max before being passed to the + /// renderer. PIDs that exited since the last refresh are + /// reaped. + pub fn update_io_history(&mut self) { + // 1. Reap: drop history for PIDs no longer present. + let current_pids: std::collections::BTreeSet = self + .processes + .processes + .iter() + .map(|p| p.pid) + .collect(); + self.io_history + .retain(|pid, _| current_pids.contains(pid)); + + // 2. Append a new raw f64-bit sample for each PID with a + // known rate. Storing the raw bits (not a pre-clamped + // u8) preserves precision for the normalization step. + for p in &self.processes.processes { + let rate_kbs = match p.io_total_rate_kbs() { + Some(r) => r.max(0.0), + None => continue, // skip: no data this tick + }; + let history = self + .io_history + .entry(p.pid) + .or_insert_with(|| std::collections::VecDeque::with_capacity(PROCESS_IO_HISTORY_LEN)); + if history.len() >= PROCESS_IO_HISTORY_LEN { + history.pop_front(); + } + history.push_back(rate_kbs.to_bits()); + } + + // 3. Normalize each history in place to u8 0..=255 against + // its max. A separate pass lets the max be computed + // once per history rather than per sample. + for history in self.io_history.values_mut() { + let max_bits = history.iter().copied().max().unwrap_or(0); + if max_bits == 0 { + continue; + } + let max_f = f64::from_bits(max_bits).max(0.0); + if max_f == 0.0 { + continue; + } + for sample in history.iter_mut() { + let raw = f64::from_bits(*sample); + let normalized = ((raw / max_f) * 255.0).round(); + *sample = normalized.clamp(0.0, 255.0) as u64; + } + } + } + /// Page-scroll the selection by `pages` rows. PageDown moves /// down (positive pages); PageUp moves up (negative pages). /// The per-row offset for "one page" is a UX convention — 8 @@ -839,4 +907,76 @@ mod tests { app.process_filter = "nope".to_string(); assert_eq!(app.selected_pid(), None); } + + #[test] + fn update_io_history_reaps_exited_pids() { + let mut app = make_app_with_processes(3); + // Seed history for a PID that will not be in the next read. + app.io_history.insert( + 9999, + std::collections::VecDeque::from(vec![1.0f64.to_bits()]), + ); + app.update_io_history(); + assert!(!app.io_history.contains_key(&9999)); + } + + #[test] + fn update_io_history_normalizes_against_max() { + let mut app = make_app_with_processes(2); + // Manually inject rates: 100.0 then 200.0 (max=200). + // After normalize: 100/200*255 = 127, 200/200*255 = 255. + let hist = app + .io_history + .entry(100) + .or_insert_with(|| std::collections::VecDeque::with_capacity(PROCESS_IO_HISTORY_LEN)); + hist.push_back(100.0f64.to_bits()); + hist.push_back(200.0f64.to_bits()); + // Add a sample for pid 101 (separate history). + let hist2 = app + .io_history + .entry(101) + .or_insert_with(|| std::collections::VecDeque::with_capacity(PROCESS_IO_HISTORY_LEN)); + hist2.push_back(50.0f64.to_bits()); + app.update_io_history(); + let p100 = &app.io_history[&100]; + assert_eq!(p100[0], 128); // 100/200*255 = 127.5 -> rounds to 128 + assert_eq!(p100[1], 255); // 200/200*255 = 255 + let p101 = &app.io_history[&101]; + assert_eq!(p101[0], 255); // only one sample, max=50, normalized to 255 + } + + #[test] + fn update_io_history_handles_all_zero() { + let mut app = make_app_with_processes(1); + let hist = app + .io_history + .entry(100) + .or_insert_with(|| std::collections::VecDeque::with_capacity(PROCESS_IO_HISTORY_LEN)); + hist.push_back(0.0f64.to_bits()); + hist.push_back(0.0f64.to_bits()); + app.update_io_history(); + // No division by zero; values stay as-is (0). + let p100 = &app.io_history[&100]; + assert_eq!(p100[0], 0); + assert_eq!(p100[1], 0); + } + + #[test] + fn update_io_history_skips_pids_without_rate() { + // procs without io_read_kb/io_write_kb (None) should NOT + // get a history entry created. Seed: empty app (n=2 but + // their io_total_rate_kbs() will be None because no + // io_read_rate_kbs is set in make_app_with_processes). + let mut app = make_app_with_processes(2); + app.update_io_history(); + // No PIDs in the process list have a rate, so io_history + // remains empty (or all entries are 0 if seeded). + // The function must not panic on None rates. + // Pids 100 and 101 have no rate; the loop `continue`s. + for entry in app.io_history.values() { + for &bits in entry { + let _ = f64::from_bits(bits); // no panic + } + } + } } \ No newline at end of file diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index d4a22917e0..a4e4cbed56 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -44,6 +44,18 @@ pub fn padded_to_sparkline(values: &[u8]) -> String { out } +/// Map a 0..=255 value to a sparkline char. Used for IO rate +/// sparklines (already normalized against the per-PID max). +pub fn io_rate_sparkline(values: &[u8]) -> String { + const BARS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let mut out = String::with_capacity(values.len()); + for &v in values { + let idx = ((v as usize).min(255) * 8 / 255).min(8); + out.push(BARS[idx]); + } + out +} + /// Build a fixed-width horizontal bar that visualizes a 0..=100 /// value. The bar grows from the left, with the rightmost cells /// remaining as light filler so the user can read the proportion @@ -889,7 +901,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { "RSS" }; let header_str = format!( - " PID STATE PRIO NI THR CPU% IO RATE {:<11} COMM", + " PID STATE PRIO NI THR CPU% IO RATE {:<11} IO-RATE COMM", mem_header ); lines.push(Line::from(vec![ @@ -921,6 +933,21 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { } else { crate::process::ProcessInfo::format_memory_kb(p.rss_kb) }; + // Per-PID IO rate sparkline (12 samples, normalized to + // 0..=255 against the history's max). Empty for PIDs + // with no history yet. The history stores raw f64 bits + // as u64; we re-interpret and convert to u8 here. + let sparkline = app + .io_history + .get(&p.pid) + .map(|hist| { + let bytes: Vec = hist + .iter() + .map(|bits| f64::from_bits(*bits).max(0.0).min(255.0) as u8) + .collect(); + io_rate_sparkline(&bytes) + }) + .unwrap_or_else(|| " ".repeat(crate::app::PROCESS_IO_HISTORY_LEN)); let prefix = if app.process_tree { tree_prefix(p.pid, p.ppid, &proc.processes, &app.folded) } else { @@ -932,7 +959,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { theme::VALUE }; lines.push(Line::from(format!( - " {}{:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {}", + " {}{:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {:<12} {}", prefix, p.pid, p.state, @@ -943,6 +970,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { io_str, rate_str, mem_str, + sparkline, comm_truncated, ).set_style(row_style))); }