redbear-power: v1.32 CPU% and RSS per-PID history (3 sparklines)

Generalizes the v1.31 io_history pattern to two more metrics:
cpu_history and rss_history. Each Process tab row now shows
three sparklines:
  IO-RATE  (12 samples, full 78s of IO rate history)
  CPU%    (6 samples, last 39s of CPU usage)
  RSS     (6 samples, last 39s of memory)

The two new sparklines are 6 chars wide (vs 12 for IO-RATE) to
keep the panel within a 120-col terminal.

Implementation:
- Two new BTreeMap<u32, VecDeque<u64>> fields on App
- update_io_history() now updates all three maps in a single
  3-pass sweep (reap, append, normalize) for all metrics
- Extracted private helpers push_sample() and normalize_history()
  for the per-metric work; both operate on the map type
  generically
- New render::sparkline_short() helper: renders the last
  'width' samples of a history, padding with leading spaces
  for short histories

Test count 121 -> 123 (+2):
- update_io_history_populates_cpu_and_rss_for_each_pid
  (every PID gets cpu/rss entries, not just PIDs with non-zero
  values)
- update_io_history_reaps_all_three_maps (phantom-PID reap
  spans all three maps)

Redox stripped binary: 4,213,608 bytes (+12 KiB from v1.31).
Compile warnings: 55 (unchanged).
This commit is contained in:
2026-06-21 07:23:27 +03:00
parent 2597246908
commit bdec5061ef
2 changed files with 129 additions and 38 deletions
@@ -151,6 +151,12 @@ pub meminfo: crate::meminfo::MemInfo,
/// 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>>,
/// Per-PID CPU% history (raw f64 bits, 0..=200+ — values above
/// 100% are valid for multi-threaded procs aggregated as a
/// single ratio). Same shape as `io_history`.
pub cpu_history: std::collections::BTreeMap<u32, std::collections::VecDeque<u64>>,
/// Per-PID RSS history (raw f64 bits, KiB). Same shape.
pub rss_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,
@@ -323,6 +329,8 @@ impl App {
process_tree: false,
folded: std::collections::BTreeSet::new(),
io_history: std::collections::BTreeMap::new(),
cpu_history: std::collections::BTreeMap::new(),
rss_history: std::collections::BTreeMap::new(),
process_cursor: 0,
pid_detail: None,
refresh_counter: 0,
@@ -744,45 +752,65 @@ 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.
/// Update all per-PID history maps (IO rate, CPU%, RSS) from
/// the current `self.processes`. Each map is a
/// `VecDeque<u64>` of raw f64-bit samples, normalized to u8
/// 0..=255 against the per-history max before being passed to
/// the renderer. PIDs that exited since the last refresh are
/// reaped from all three maps.
pub fn update_io_history(&mut self) {
// 1. Reap: drop history for PIDs no longer present.
// Compute the set of current PIDs once; reuse for all three
// reaps.
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());
for map in [&mut self.io_history, &mut self.cpu_history, &mut self.rss_history] {
map.retain(|pid, _| current_pids.contains(pid));
}
// 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() {
// Append one sample per PID per metric.
for p in &self.processes.processes {
if let Some(rate) = p.io_total_rate_kbs() {
Self::push_sample(&mut self.io_history, p.pid, rate);
}
Self::push_sample(&mut self.cpu_history, p.pid, p.cpu_pct);
Self::push_sample(&mut self.rss_history, p.pid, p.rss_kb as f64);
}
// Normalize each map in place against its per-history max.
for map in [&mut self.io_history, &mut self.cpu_history, &mut self.rss_history] {
Self::normalize_history(map);
}
}
/// Push a new f64 sample into the per-PID history map for
/// `pid`. The capacity is bounded at `PROCESS_IO_HISTORY_LEN`.
fn push_sample(
map: &mut std::collections::BTreeMap<u32, std::collections::VecDeque<u64>>,
pid: u32,
value: f64,
) {
let history = map
.entry(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(value.to_bits());
}
/// Normalize a per-PID history map in place: each value
/// becomes (raw / max) * 255, rounded to u8. A separate pass
/// for each history means the max is computed once per
/// history, not per sample. All-zero histories are left
/// as-is (rendered as empty bars by the sparkline helper).
fn normalize_history(
map: &mut std::collections::BTreeMap<u32, std::collections::VecDeque<u64>>,
) {
for history in map.values_mut() {
let max_bits = history.iter().copied().max().unwrap_or(0);
if max_bits == 0 {
continue;
@@ -979,4 +1007,35 @@ mod tests {
}
}
}
#[test]
fn update_io_history_populates_cpu_and_rss_for_each_pid() {
// CPU% is on every ProcessInfo; should populate for all
// PIDs. RSS is also on every ProcessInfo.
let mut app = make_app_with_processes(3);
// make_app_with_processes leaves cpu_pct=0 and rss_kb=0
// (the Default). After update, all 3 PIDs should appear
// in both cpu_history and rss_history (even if values
// are zero, an entry is created).
app.update_io_history();
for pid in [100u32, 101, 102] {
assert!(app.cpu_history.contains_key(&pid),
"cpu_history missing pid {pid}");
assert!(app.rss_history.contains_key(&pid),
"rss_history missing pid {pid}");
}
}
#[test]
fn update_io_history_reaps_all_three_maps() {
let mut app = make_app_with_processes(2);
// Seed a phantom PID across all three maps.
for map in [&mut app.io_history, &mut app.cpu_history, &mut app.rss_history] {
map.insert(9999, std::collections::VecDeque::from(vec![1.0f64.to_bits()]));
}
app.update_io_history();
assert!(!app.io_history.contains_key(&9999));
assert!(!app.cpu_history.contains_key(&9999));
assert!(!app.rss_history.contains_key(&9999));
}
}
@@ -56,6 +56,34 @@ pub fn io_rate_sparkline(values: &[u8]) -> String {
out
}
/// Render a short sparkline (CPU% or RSS history) at a fixed
/// width. Takes the LAST `width` samples of the history; if the
/// history is shorter, pads with leading spaces. Returns `width`
/// chars total. Used for the compact 6-char CPU% and RSS
/// sparklines next to the 12-char IO-RATE sparkline.
fn sparkline_short(
history: &std::collections::BTreeMap<u32, std::collections::VecDeque<u64>>,
pid: &u32,
width: usize,
) -> String {
history
.get(pid)
.map(|hist| {
let bytes: Vec<u8> = hist
.iter()
.map(|bits| f64::from_bits(*bits).max(0.0).min(255.0) as u8)
.collect();
let start = bytes.len().saturating_sub(width);
let slice = &bytes[start..];
let mut out = io_rate_sparkline(slice);
while out.chars().count() < width {
out.insert(0, ' ');
}
out
})
.unwrap_or_else(|| " ".repeat(width))
}
/// 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
@@ -901,7 +929,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} IO-RATE COMM",
" PID STATE PRIO NI THR CPU% IO RATE {:<11} IO-RATE CPU% RSS COMM",
mem_header
);
lines.push(Line::from(vec![
@@ -933,11 +961,11 @@ 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
// Three per-PID sparklines: IO-RATE (12 samples),
// CPU% (6 samples), RSS (6 samples). The smaller
// CPU/RSS sparklines are 6 chars wide to keep the
// panel within a 120-col terminal.
let io_spark = app
.io_history
.get(&p.pid)
.map(|hist| {
@@ -948,6 +976,8 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
io_rate_sparkline(&bytes)
})
.unwrap_or_else(|| " ".repeat(crate::app::PROCESS_IO_HISTORY_LEN));
let cpu_spark = sparkline_short(&app.cpu_history, &p.pid, 6);
let rss_spark = sparkline_short(&app.rss_history, &p.pid, 6);
let prefix = if app.process_tree {
tree_prefix(p.pid, p.ppid, &proc.processes, &app.folded)
} else {
@@ -959,7 +989,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} {:<12} {}",
" {}{:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {:<12} {:<6} {:<6} {}",
prefix,
p.pid,
p.state,
@@ -970,7 +1000,9 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
io_str,
rate_str,
mem_str,
sparkline,
io_spark,
cpu_spark,
rss_spark,
comm_truncated,
).set_style(row_style)));
}