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<u32, VecDeque<u64>>
  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
This commit is contained in:
2026-06-21 07:19:16 +03:00
parent e2570104a5
commit 2597246908
3 changed files with 310 additions and 2 deletions
@@ -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<u32, VecDeque<u64>>`** — 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<f64>` 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<u32, VecDeque<u64>>` 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.
@@ -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<u32>,
/// 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<u32, std::collections::VecDeque<u64>>,
/// 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<u64>`
/// 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<u32> = 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
}
}
}
}
@@ -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<u8> = 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)));
}