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:
@@ -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)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user