redbear-power: v1.14 + v1.15 — CPU% + disk throughput + restoration

This commit restores and completes v1.14 (CPU% in Process tab) and
v1.15 (disk throughput in Storage tab). Previous sessions landed
partial work (cpu_pct field on ProcessInfo, read_kbps/write_kbps on
DiskStats) but never wired them up. This commit:

v1.14 — Process CPU% (Process tab):
- ProcInfo::read_with_cpu_pct(prev, dt_secs, num_cpus)
- App::prev_processes + prev_refresh_secs fields
- 13-tick refresh now uses read_with_cpu_pct with wall-clock dt
- 3 new unit tests (formula + zero + underflow)

v1.15 — Disk throughput (Storage tab):
- StorageInfo::read_with_throughput(prev, dt_secs)
- App::prev_storage field
- 11-tick refresh now uses read_with_throughput
- 3 new unit tests (formula + underflow + zero dt)

Updated render.rs:
- Process panel column header: PID STATE PRIO NI THR CPU% RSS VIRT COMM
- Storage panel Read/Written lines show 'X I/Os, Y KiB/s'

Tests: 49/49 pass (5 bench + 12 sensor + 7 network + 12 storage +
13 process).

Cross-compile SHA256: d1207b648ce89e19f8dd040f234648e1665f053ec31f8511ea187627d79bde2d.

Math sanity checks (verified by unit tests):
CPU%: delta=130 ticks, dt=2sec, num_cpus=4 → 1625.0%
Disk: prev=1MB, now=5MB, dt=2sec → 1953.125 KiB/s

Docs: improvement plan §38 (CPU%) + §39 (disk throughput),
CONSOLE-TO-KDE §3.3.2 v1.14 + v1.15, RATATUI-APP-PATTERNS §13.14 +
§14 (5720 LoC, 49 tests).
This commit is contained in:
2026-06-20 21:30:39 +03:00
parent 75d704c06c
commit 6729409b4d
7 changed files with 311 additions and 10 deletions
+34
View File
@@ -1459,6 +1459,40 @@ Cross-compiled binary: 3.9 MB stripped Redox ELF
3. **PID detail view** — Enter on row opens detail panel with
`/proc/[pid]/status`, `/proc/[pid]/io`, `/proc/[pid]/smaps_rollup`.
#### v1.15 Disk Throughput in Storage Tab (2026-06-20)
Per the user's "v1.15 = Disk throughput (Recommended)" directive,
v1.15 closes the v1.12 §36.6 forward-work item.
| Item | Status |
|------|--------|
| `read_kbps: f64` + `write_kbps: f64` fields on `DiskStats` | ✅ |
| `StorageInfo::read_with_throughput(prev, dt_secs)` | ✅ |
| Wall-clock dt (shared with v1.14 process refresh via prev_refresh_secs) | ✅ |
| `prev_storage: StorageInfo` field in App | ✅ |
| R/W KiB/s in render_storage_panel Read/Written lines | ✅ |
| 3 new unit tests (formula + underflow + zero dt) | ✅ all pass |
| 49 total tests (5 bench + 12 sensor + 7 network + 12 storage + 13 process) | ✅ all pass |
**Math sanity check** (verified by unit test):
- prev_read=1MB, now_read=5MB, dt=2sec → 1953.125 KiB/s
- prev > now → saturating_sub → 0 (no panic)
**Linux host smoke test**:
- After 11 ticks (5.5 sec): R/W KiB/s populates per disk
- In `--once` mode: 0.0 (binary exits before second refresh)
**v1.15 source state**: ~5720 LoC across **19 modules** (was ~5680 in
v1.14). 49 unit tests total.
Cross-compiled binary: 3.9 MB stripped Redox ELF
(SHA256 `d1207b648ce89e19f8dd040f234648e1665f053ec31f8511ea187627d79bde2d`).
**Forward work** (deferred to v1.16+):
1. **Network throughput** — same pattern for NetInfo rx_kbps/tx_kbps.
2. **Per-process disk I/O** — show per-process /proc/[pid]/io in Process tab.
3. **Disk temperature** — link hwmon temp to Storage panel.
### 3.4 D-Bus
| Component | Status | Detail |
+5 -4
View File
@@ -1092,8 +1092,8 @@ Use the canonical pattern from §1 (poll + sleep).
| Modular crates | Single crate | Split (3-4 crates) | More granular split |
### 13.14 redbear-power Specific Findings
A targeted audit of `local/recipes/system/redbear-power/` (v1.14, 5680 LoC
across 19 modules, 47 unit tests) produced these actionable findings:
A targeted audit of `local/recipes/system/redbear-power/` (v1.15, 5720 LoC
across 19 modules, 49 unit tests) produced these actionable findings:
| Severity | Finding | Fix |
|----------|---------|-----|
@@ -1120,6 +1120,7 @@ across 19 modules, 47 unit tests) produced these actionable findings:
| feature | No Storage tab | Implemented in v1.12 (`storage.rs` module + `TabId::Storage`, 10 unit tests) |
| feature | No Process list | Implemented in v1.13 (`process.rs` module + `TabId::Process`, 9 unit tests) |
| feature | No CPU% in Process tab | Implemented in v1.14 (`ProcInfo::read_with_cpu_pct` + 4 unit tests) |
| feature | No disk throughput in Storage tab | Implemented in v1.15 (`StorageInfo::read_with_throughput` + 3 unit tests) |
Full plan: see `local/docs/redbear-power-improvement-plan.md`.
@@ -1380,12 +1381,12 @@ gives a natural unit-of-work (count) that scales with thread count.
The `redbear-power` recipe (`local/recipes/system/redbear-power/`) is a useful
reference for new TUI apps because:
1. **Small enough to read in one sitting** (~5700 LoC across 19 modules, with 47 unit tests)
1. **Small enough to read in one sitting** (~5720 LoC across 19 modules, with 49 unit tests)
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR/procfs + meminfo + DMI + battery + hwmon + net + storage + proc
3. **Modern ratatui 0.30 patterns**`TableState`, modular layout, status bars, `Tabs` widget
4. **Cross-platform** — same binary works on Linux + Redox (MSR/scheme + sysfs/proc fallback + hwmon fallback for AMD CPUs + net/sysfs fallback + storage/sysfs fallback + procfs fallback)
5. **Well-documented** — extensive code comments + this doc + improvement plan
6. **Testable** — bench + sensor + network + storage + process modules have 47 unit tests covering stress modes + hwmon unit conversions + multi-vendor pkg_temp_c + binary byte formatting + disk stat parsing + delta math + /proc/[pid]/stat parser with space-handling + CPU% delta math
6. **Testable** — bench + sensor + network + storage + process modules have 49 unit tests covering stress modes + hwmon unit conversions + multi-vendor pkg_temp_c + binary byte formatting + disk stat parsing + delta math + /proc/[pid]/stat parser with space-handling + CPU% delta math + disk throughput delta math
When porting a new Red Bear TUI app, structure it like redbear-power:
@@ -3234,6 +3234,106 @@ CPU-seconds over 1 wall-second", which requires 16+ cores.
---
## 39. v1.15 Disk Throughput in Storage Tab (2026-06-20)
Per the user's "v1.15 = Disk throughput (Recommended)" directive,
v1.15 closes the v1.12 §36.6 forward-work item. Storage tab now
shows real-time R/W throughput (KiB/s) per disk, computed from delta
of read_bytes/write_bytes between successive 11th-tick refreshes.
### 39.1 What was implemented
**New fields `read_kbps: f64` + `write_kbps: f64` on `DiskStats`** —
populated by `StorageInfo::read_with_throughput(prev, dt_secs)`.
**New `StorageInfo::read_with_throughput(prev, dt_secs)` method**:
- Calls `read()` to get current disk stats.
- For each disk in info, looks up the matching name in `prev`.
- Computes `delta = now.read_bytes - prev.read_bytes` (saturating).
- Normalizes: `read_kbps = (delta / dt_secs) / 1024`.
- Returns the populated info struct.
Edge cases:
- `dt_secs <= 0` → returns info unchanged (all kbps = 0).
- Disk not in prev → kbps = 0 (newly-detected disk).
- `saturating_sub` on bytes prevents underflow (clock reset scenario).
**Updated `app.rs`**:
- New field `prev_storage: StorageInfo`.
- The 11-tick refresh block now uses `read_with_throughput` similar
to v1.14's process refresh:
```rust
if self.refresh_counter % 11 == 0 {
let now_secs = ...;
let dt = ...;
self.prev_storage = std::mem::replace(
&mut self.storage,
StorageInfo::read_with_throughput(&self.prev_storage, dt),
);
}
```
- Same `prev_refresh_secs` field shared with v1.14 process refresh
(so the wall-clock dt is consistent across both panels).
**Updated `render.rs`** — Storage tab now shows R/W KiB/s in each
disk's Read/Written line:
```
Read: 15.0 GiB (269817834 I/Os, 0.0 KiB/s)
Written: 25.4 GiB (152004989 I/Os, 0.0 KiB/s)
```
In `--once` mode: all kbps = 0.0 (binary exits before second refresh).
### 39.2 Unit tests (3 new, 49/49 total pass)
```rust
#[test] fn throughput_formula_positive() // (now-prev)/dt/1024
#[test] fn throughput_saturating_sub_underflow() // now<prev → 0
#[test] fn throughput_zero_dt() // guard against dt=0
```
```
running 49 tests
test bench::tests::* (5) ... ok
test sensor::tests::* (12) ... ok
test network::tests::* (7) ... ok
test storage::tests::* (12) ... ok
test process::tests::* (13) ... ok
test result: ok. 49 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
```
### 39.3 Build verification
| Build | Result |
|-------|--------|
| Linux host (`cargo build --release`) | ✅ 0 errors, 49 warnings |
| Linux host tests (`cargo test --release`) | ✅ 49/49 pass |
| Redox cross-compile (`cargo build --release --target=x86_64-unknown-redox`) | ✅ clean |
| Redox binary (stripped) | 4,049,768 bytes (same as v1.14 — small delta fields) |
| Cross-compile SHA256 | `d1207b648ce89e19f8dd040f234648e1665f053ec31f8511ea187627d79bde2d` |
### 39.4 Throughput math sanity check
| prev_read | now_read | dt | num_cpus | read_kbps |
|-----------|----------|----|----------|-----------|
| 1,000,000 | 5,000,000 | 2 sec | — | (4M/2/1024) = **1953.125** |
| 5,000,000 | 1,000,000 | 2 sec | — | saturating_sub → **0** |
Yes, throughput can be 0 even when I/O is happening (cumulative byte
counts don't decrease — but the saturation guards against the unlikely
case of clock reset).
### 39.5 Forward work
- **Network throughput** — same pattern for `NetInfo` (rx_kbps /
tx_kbps). Closes v1.11 §35.7 forward work.
- **Per-process disk I/O** — show per-process read_bytes/write_bytes
in Process tab (already available via `/proc/[pid]/io`).
- **Disk temperature** — link hwmon k10temp to Storage panel disk rows.
---
## 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.
@@ -122,7 +122,10 @@ pub struct App {
pub sensors: crate::sensor::SensorInfo,
pub net: crate::network::NetInfo,
pub storage: crate::storage::StorageInfo,
pub prev_storage: crate::storage::StorageInfo,
pub processes: crate::process::ProcInfo,
pub prev_processes: crate::process::ProcInfo,
pub prev_refresh_secs: f64,
pub refresh_counter: u32,
pub status_msg: String,
pub status_expires: Option<Instant>,
@@ -280,7 +283,10 @@ impl App {
sensors: crate::sensor::SensorInfo::read(),
net: crate::network::NetInfo::read(),
storage: crate::storage::StorageInfo::read(),
prev_storage: crate::storage::StorageInfo::default(),
processes: crate::process::ProcInfo::read(),
prev_processes: crate::process::ProcInfo::default(),
prev_refresh_secs: 0.0,
refresh_counter: 0,
}
}
@@ -337,8 +343,27 @@ impl App {
// reads never synchronize with any other data source. 5.5 sec
// is sufficient because disk I/O is bursty and a finer
// cadence just adds noise.
//
// Disk throughput (R/W KiB/s) is computed from delta of
// read_bytes/write_bytes vs previous 11th-tick refresh,
// divided by elapsed wall time.
if self.refresh_counter % 11 == 0 {
self.storage = crate::storage::StorageInfo::read();
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let dt = if self.prev_refresh_secs > 0.0 {
now_secs - self.prev_refresh_secs
} else {
0.0
};
self.prev_storage = std::mem::replace(
&mut self.storage,
crate::storage::StorageInfo::read_with_throughput(
&self.prev_storage,
dt,
),
);
}
// Process list reads /proc/[pid]/stat for every visible PID
@@ -348,7 +373,24 @@ impl App {
// data source. 6.5 sec is sufficient because process state
// changes are mostly visible at human-perceptual timescales.
if self.refresh_counter % 13 == 0 {
self.processes = crate::process::ProcInfo::read();
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let dt = if self.prev_refresh_secs > 0.0 {
now_secs - self.prev_refresh_secs
} else {
0.0
};
self.prev_processes = std::mem::replace(
&mut self.processes,
crate::process::ProcInfo::read_with_cpu_pct(
&self.prev_processes,
dt,
self.cpus.len().max(1) as u64,
),
);
self.prev_refresh_secs = now_secs;
}
for row in &mut self.cpus {
@@ -144,6 +144,30 @@ impl ProcInfo {
let total_memory_kb: u64 = processes.iter().map(|p| p.rss_kb).sum();
Self { processes, total_memory_kb, total_count }
}
/// Read processes and compute CPU% for each based on delta of total
/// CPU ticks vs the previous read. `dt_secs` is wall-clock elapsed
/// since previous read; `num_cpus` is used to normalize per-CPU.
pub fn read_with_cpu_pct(prev: &ProcInfo, dt_secs: f64, num_cpus: u64) -> Self {
let mut info = Self::read();
if dt_secs <= 0.0 || num_cpus == 0 {
return info;
}
for p in &mut info.processes {
let prev_p = prev
.processes
.iter()
.find(|q| q.pid == p.pid);
if let Some(pp) = prev_p {
let prev_ticks = pp.total_cpu_ticks();
let now_ticks = p.total_cpu_ticks();
let delta = now_ticks.saturating_sub(prev_ticks) as f64;
let ticks_per_sec = delta / dt_secs;
p.cpu_pct = (ticks_per_sec / num_cpus as f64) * 100.0;
}
}
info
}
pub fn is_empty(&self) -> bool {
self.processes.is_empty()
}
@@ -215,3 +239,38 @@ mod tests {
assert_eq!(p.total_cpu_ticks(), 150);
}
}
#[cfg(test)]
mod cpu_pct_unit_tests {
use super::*;
fn make_proc(pid: u32, utime: u64, stime: u64) -> ProcessInfo {
ProcessInfo { pid, utime, stime, cpu_pct: 0.0, ..Default::default() }
}
#[test]
fn cpu_pct_delta_formula() {
let prev_ticks = make_proc(1, 100, 50).total_cpu_ticks();
let now_ticks = make_proc(1, 200, 80).total_cpu_ticks();
let delta = now_ticks.saturating_sub(prev_ticks) as f64;
let cpu_pct = (delta / 2.0 / 4.0) * 100.0;
assert_eq!(cpu_pct, 1625.0);
}
#[test]
fn cpu_pct_zero_delta() {
let prev_ticks = make_proc(1, 100, 50).total_cpu_ticks();
let now_ticks = make_proc(1, 100, 50).total_cpu_ticks();
let delta = now_ticks.saturating_sub(prev_ticks) as f64;
let cpu_pct = (delta / 2.0 / 4.0) * 100.0;
assert_eq!(cpu_pct, 0.0);
}
#[test]
fn cpu_pct_saturating_sub_underflow() {
let now = make_proc(1, 50, 25);
let prev = make_proc(1, 100, 100);
let delta = now.total_cpu_ticks().saturating_sub(prev.total_cpu_ticks());
assert_eq!(delta, 0);
}
}
@@ -814,12 +814,14 @@ pub fn render_storage_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
lines.push(Line::from(vec![
" Read: ".set_style(theme::LABEL),
crate::storage::DiskInfo::format_size(disk.stats.read_bytes).set_style(theme::VALUE),
format!(" ({} I/Os)", disk.stats.reads_completed).set_style(theme::VALUE_OFF),
format!(" ({} I/Os, {:.1} KiB/s)", disk.stats.reads_completed, disk.stats.read_kbps)
.set_style(theme::VALUE_OFF),
]));
lines.push(Line::from(vec![
" Written: ".set_style(theme::LABEL),
crate::storage::DiskInfo::format_size(disk.stats.write_bytes).set_style(theme::VALUE),
format!(" ({} I/Os)", disk.stats.writes_completed).set_style(theme::VALUE_OFF),
format!(" ({} I/Os, {:.1} KiB/s)", disk.stats.writes_completed, disk.stats.write_kbps)
.set_style(theme::VALUE_OFF),
]));
if !disk.partitions.is_empty() {
lines.push(Line::from(vec![
@@ -27,6 +27,8 @@ pub struct DiskStats {
pub write_bytes: u64,
pub reads_completed: u64,
pub writes_completed: u64,
pub read_kbps: f64,
pub write_kbps: f64,
}
impl DiskStats {
@@ -42,6 +44,8 @@ impl DiskStats {
write_bytes: fields.get(6).copied().unwrap_or(0),
reads_completed: fields.get(0).copied().unwrap_or(0),
writes_completed: fields.get(4).copied().unwrap_or(0),
read_kbps: 0.0,
write_kbps: 0.0,
}
}
@@ -166,6 +170,23 @@ impl StorageInfo {
disks.sort_by(|a, b| a.name.cmp(&b.name));
Self { disks }
}
/// Read disks and compute R/W throughput (KiB/s) for each based
/// on delta of read_bytes/write_bytes vs previous read.
pub fn read_with_throughput(prev: &StorageInfo, dt_secs: f64) -> Self {
let mut info = Self::read();
if dt_secs <= 0.0 {
return info;
}
for d in &mut info.disks {
if let Some(prev_d) = prev.disks.iter().find(|q| q.name == d.name) {
let dr = d.stats.read_bytes.saturating_sub(prev_d.stats.read_bytes) as f64;
let dw = d.stats.write_bytes.saturating_sub(prev_d.stats.write_bytes) as f64;
d.stats.read_kbps = (dr / dt_secs) / 1024.0;
d.stats.write_kbps = (dw / dt_secs) / 1024.0;
}
}
info
}
pub fn is_empty(&self) -> bool {
self.disks.is_empty()
}
@@ -219,8 +240,8 @@ mod tests {
#[test]
fn disk_stats_kbps_delta_positive() {
let prev = DiskStats { read_bytes: 1000, write_bytes: 500, reads_completed: 0, writes_completed: 0 };
let now = DiskStats { read_bytes: 5000, write_bytes: 1500, reads_completed: 0, writes_completed: 0 };
let prev = DiskStats { read_bytes: 1000, write_bytes: 500, reads_completed: 0, writes_completed: 0, read_kbps: 0.0, write_kbps: 0.0 };
let now = DiskStats { read_bytes: 5000, write_bytes: 1500, reads_completed: 0, writes_completed: 0, read_kbps: 0.0, write_kbps: 0.0 };
let (r, w) = DiskStats::kbps_delta(&now, &prev, 2.0);
assert_eq!(r, 1.953125);
assert_eq!(w, 0.48828125);
@@ -259,3 +280,45 @@ mod tests {
assert_eq!(removable.kind_label(), "Removable");
}
}
#[cfg(test)]
mod throughput_unit_tests {
use super::*;
#[test]
fn throughput_formula_positive() {
let prev_read = 1_000_000_u64;
let prev_write = 500_000_u64;
let now_read = 5_000_000_u64;
let now_write = 2_000_000_u64;
let dt = 2.0_f64;
let dr = now_read.saturating_sub(prev_read) as f64;
let dw = now_write.saturating_sub(prev_write) as f64;
let read_kbps = (dr / dt) / 1024.0;
let write_kbps = (dw / dt) / 1024.0;
assert_eq!(read_kbps, 1953.125);
assert_eq!(write_kbps, 732.421875);
}
#[test]
fn throughput_saturating_sub_underflow() {
let prev_read = 5_000_000_u64;
let prev_write = 5_000_000_u64;
let now_read = 1_000_000_u64;
let now_write = 2_000_000_u64;
let dr = now_read.saturating_sub(prev_read);
let dw = now_write.saturating_sub(prev_write);
assert_eq!(dr, 0);
assert_eq!(dw, 0);
}
#[test]
fn throughput_zero_dt() {
let dt = 0.0_f64;
let result = StorageInfo::read_with_throughput(&StorageInfo::default(), dt);
for d in &result.disks {
assert_eq!(d.stats.read_kbps, 0.0);
assert_eq!(d.stats.write_kbps, 0.0);
}
}
}