diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index 5790949604..2b6f741b2a 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -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>, + /// 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>, + /// Per-PID RSS history (raw f64 bits, KiB). Same shape. + pub rss_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, @@ -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` - /// 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` 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 = 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>, + 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>, + ) { + 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)); + } } \ 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 a4e4cbed56..07e52c08a4 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -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>, + pid: &u32, + width: usize, +) -> String { + history + .get(pid) + .map(|hist| { + let bytes: Vec = 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))); }