diff --git a/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md b/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md index 9b139e6a18..85131503ac 100644 --- a/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md +++ b/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md @@ -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 | diff --git a/local/docs/RATATUI-APP-PATTERNS.md b/local/docs/RATATUI-APP-PATTERNS.md index 08f1286f09..ff7936d876 100644 --- a/local/docs/RATATUI-APP-PATTERNS.md +++ b/local/docs/RATATUI-APP-PATTERNS.md @@ -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: diff --git a/local/docs/redbear-power-improvement-plan.md b/local/docs/redbear-power-improvement-plan.md index 6619b27be8..bb254ef430 100644 --- a/local/docs/redbear-power-improvement-plan.md +++ b/local/docs/redbear-power-improvement-plan.md @@ -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, @@ -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 { diff --git a/local/recipes/system/redbear-power/source/src/process.rs b/local/recipes/system/redbear-power/source/src/process.rs index de53b42cf3..5389534656 100644 --- a/local/recipes/system/redbear-power/source/src/process.rs +++ b/local/recipes/system/redbear-power/source/src/process.rs @@ -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); + } +} diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index fab0ddd4c7..debd389536 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -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![ diff --git a/local/recipes/system/redbear-power/source/src/storage.rs b/local/recipes/system/redbear-power/source/src/storage.rs index f17943b14d..f35a962763 100644 --- a/local/recipes/system/redbear-power/source/src/storage.rs +++ b/local/recipes/system/redbear-power/source/src/storage.rs @@ -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); + } + } +}