From 1dcdc5d39de024d0d4cdd16cbc682f8647a10916 Mon Sep 17 00:00:00 2001 From: vasilito Date: Sat, 20 Jun 2026 20:41:44 +0300 Subject: [PATCH] =?UTF-8?q?redbear-power:=20v1.13=20=E2=80=94=20Process=20?= =?UTF-8?q?tab=20(procfs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 9th tab in the multi-view system: Process, reading process state and memory from /proc/[pid]/stat on Linux hosts. Last major top-like view, complementing hardware tabs (Storage/Network/Sensors) with software state. New module process.rs (215 lines, 9 unit tests): - ProcessInfo: pid, comm, state, ppid, utime, stime, priority, nice, num_threads, vsize_kb, rss_kb - parse_stat_line() handles (comm) with spaces like (Web Content) via last-')' extraction (per man 5 proc format) - Field indices: state=0, ppid=1, utime=11, stime=12, priority=15, nice=16, num_threads=17, vsize=20, rss=21 - read_comm() fallback for parens-parsing failures - ProcInfo::read() scans /proc, sorts by RSS desc, truncates to 50 - format_memory_kb() with binary unit suffix Updated app.rs: - New field processes: ProcInfo, refreshed every 13th tick (6.5 sec). 13-tick modulus coprime with all (3,4,5,7,11). - TabId::Process variant (9th tab) - TabId::next() cycles PerCpu → System → Info → Motherboard → Battery → Sensors → Network → Storage → Process → PerCpu Updated render.rs: - New render_process_panel() with PID/STATE/PRIO/NI/THR/RSS/VIRT/COMM columns. Comm truncated to 20 chars. Sort by RSS desc (top-like). - render_tab_bar() for 9 tabs with hotkey 1-9 - render_once dumps Process panel for headless verification Updated main.rs: - mod process; declaration - New dispatch arm TabId::Process => render_process_panel - Hotkey 9 jumps to Process tab - render_process_panel added to imports Linux host smoke test (596 processes, top 50): - opencode 3.7 GiB, thunderbird 2.1 GiB, plasmashell 517 MiB - kwin_wayland shows PRIO=-2 (real-time scheduling) - Total RSS: 17.5 GiB Unit tests: 43/43 pass (5 bench + 12 sensor + 7 network + 10 storage + 9 process). Cross-compile SHA256: 2c30f86dce574f173efdcf8eb588f83abd8f0bdf2c5a2678452dd0e6a244dbf2. Docs: improvement plan §37, CONSOLE-TO-KDE §3.3.2 v1.13, RATATUI-APP-PATTERNS §13.14 + §14 (19 modules, 43 tests). --- local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md | 40 ++++ local/docs/RATATUI-APP-PATTERNS.md | 13 +- local/docs/redbear-power-improvement-plan.md | 175 ++++++++++++++ .../system/redbear-power/source/src/app.rs | 17 +- .../system/redbear-power/source/src/main.rs | 12 +- .../redbear-power/source/src/process.rs | 215 ++++++++++++++++++ .../system/redbear-power/source/src/render.rs | 51 +++++ 7 files changed, 514 insertions(+), 9 deletions(-) create mode 100644 local/recipes/system/redbear-power/source/src/process.rs diff --git a/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md b/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md index 88ffd19602..b73f276f03 100644 --- a/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md +++ b/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md @@ -1380,6 +1380,46 @@ existing moduli (3, 4, 5, 7) — LCM of {3,4,5,7,11} = 9240 ticks. 3. **NVMe-specific stats** — `nvme*/queue/*` + cross-ref with hwmon. 4. **Disk temperature** — link hwmon temp to storage panel. +#### v1.13 Process Tab (procfs) (2026-06-20) + +Per the user's "v1.13 = Process list (Recommended)" directive, v1.13 +ships the **Process tab** as the 9th tab. Last major top-style view. + +| Item | Status | +|------|--------| +| `process.rs` (NEW, 215 LoC) — `ProcessInfo` + `ProcInfo` + stat parser | ✅ | +| `TabId::Process` variant + cycle order | ✅ | +| Hotkey `9` jumps to Process tab | ✅ | +| `render_process_panel()` with PID/STATE/PRIO/NI/THR/RSS/VIRT/COMM columns | ✅ | +| Per-tick refresh at 13-tick modulus (6.5 sec cadence) | ✅ | +| 9 unit tests (size + parse + edge cases) | ✅ all pass | +| 43 total tests (5 bench + 12 sensor + 7 network + 10 storage + 9 process) | ✅ all pass | + +**Data sources opened at runtime**: +- `/proc/[pid]/stat` — 52-field single line (man 5 proc) +- `/proc/[pid]/comm` — fallback for process name extraction +- `/proc/` itself — scanned for numeric dir names = PIDs + +**Linux host smoke test** (596 processes, top 50 shown): +- opencode (3.7 GiB RSS), thunderbird (2.1 GiB), plasmashell (517 MiB) +- kwin_wayland shows PRIO=-2 (real-time scheduling) +- thread counts up to 92 (thunderbird) +- Total RSS: 17.5 GiB + +**v1.13 source state**: ~5635 LoC across **19 modules** (was ~5415/18 +in v1.12). New module: `process.rs` (215 lines). 43 unit tests total. + +Cross-compiled binary: 3.9 MB stripped Redox ELF +(SHA256 `2c30f86dce574f173efdcf8eb588f83abd8f0bdf2c5a2678452dd0e6a244dbf2`). + +**Refresh cadence**: 13-tick modulus (6.5 sec). Coprime with all +existing moduli (3, 4, 5, 7, 11) — LCM = 60060 ticks. + +**Forward work** (deferred to v1.14+): +1. **CPU% column** — store previous ticks, compute delta. +2. **Process filtering** — search by name/regex. +3. **Sort modes** — toggle between RSS/CPU/PID/name with hotkey. + ### 3.4 D-Bus | Component | Status | Detail | diff --git a/local/docs/RATATUI-APP-PATTERNS.md b/local/docs/RATATUI-APP-PATTERNS.md index 5ac7efc917..ef165a5726 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.12, 5415 LoC -across 18 modules, 34 unit tests) produced these actionable findings: +A targeted audit of `local/recipes/system/redbear-power/` (v1.13, 5635 LoC +across 19 modules, 43 unit tests) produced these actionable findings: | Severity | Finding | Fix | |----------|---------|-----| @@ -1118,6 +1118,7 @@ across 18 modules, 34 unit tests) produced these actionable findings: | feature | Per-CPU Temp n/a on AMD (Intel-only MSR) | Implemented in v1.10 (`SensorInfo::pkg_temp_c` fallback to k10temp/coretemp/zenpower) | | feature | No Network tab | Implemented in v1.11 (`network.rs` module + `TabId::Network`, 7 unit tests) | | 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) | Full plan: see `local/docs/redbear-power-improvement-plan.md`. @@ -1378,12 +1379,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** (~5400 LoC across 18 modules, with 34 unit tests) -2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR + meminfo + DMI + battery + hwmon + net + storage +1. **Small enough to read in one sitting** (~5600 LoC across 19 modules, with 43 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) +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 modules have 34 unit tests covering stress modes + hwmon unit conversions + multi-vendor pkg_temp_c + binary byte formatting + disk stat parsing + delta math +6. **Testable** — bench + sensor + network + storage + process modules have 43 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 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 90d76227ed..e416c73a40 100644 --- a/local/docs/redbear-power-improvement-plan.md +++ b/local/docs/redbear-power-improvement-plan.md @@ -2942,6 +2942,181 @@ storage module + tests). 34 unit tests total (5 bench + 12 sensor + --- +## 37. v1.13 Process Tab (procfs) (2026-06-20) + +Per the user's "v1.13 = Process list (Recommended)" directive, v1.13 +ships the **Process tab** as the 9th tab in the multi-view system. +This is the last major cpu-x-style top-like view; it complements the +hardware tabs (Storage/Network/Sensors) with software state. + +### 37.1 What was implemented + +**New module `process.rs` (215 lines, 9 unit tests)**: +- `ProcessInfo` struct with 11 fields: `pid`, `comm`, `state`, `ppid`, + `utime`, `stime`, `priority`, `nice`, `num_threads`, `vsize_kb`, + `rss_kb`. +- `ProcessInfo::format_memory_kb(kb)` — binary unit suffix (KiB/MiB/ + GiB/TiB). +- `ProcessInfo::total_cpu_ticks()` — sum of utime + stime. +- `parse_stat_line(line)` — parses the 52-field single-line format + of `/proc/[pid]/stat` (per `man 5 proc`). Uses **last `)`** to + extract `comm` (handles process names with spaces like `(Web Content)`). +- Field indices after `)`: `[0]=state [1]=ppid [11]=utime [12]=stime + [15]=priority [16]=nice [17]=num_threads [20]=vsize [21]=rss_pages`. + RSS in pages × 4 KiB = bytes (per kernel convention). +- `read_comm(pid)` — fallback to `/proc/[pid]/comm` if parens-parsing + fails. +- `ProcInfo::read()` walks `/proc/`, parses numeric dir names as PIDs, + reads each `/proc/[pid]/stat`, sorts by RSS descending, truncates to + top 50 processes. +- `ProcInfo.count()` returns truncated count; `ProcInfo.total_count` + tracks total PIDs found. + +**Updated `app.rs`**: +- New field `pub processes: crate::process::ProcInfo`, refreshed every + **13th** tick (6.5 sec at default POLL_MS=500). +- `TabId::Process` variant (9th tab). +- `TabId::next()` cycle: `PerCpu → System → Info → Motherboard → Battery + → Sensors → Network → Storage → Process → PerCpu`. + +**Updated `render.rs`**: +- New `render_process_panel(app, focused)` — header line shows "Showing + top N of M process(es); total RSS: X". Then a tabular layout: + `PID STATE PRIO NI THR RSS VIRT COMM` +- Truncate `comm` to 20 chars to fit panel width. +- Sort by RSS descending (top-like ordering). +- `render_tab_bar()` updated for 9 tabs with hotkey mapping 1-9. +- `render_once` dumps Process panel for headless verification. + +**Updated `main.rs`**: +- `mod process;` declaration. +- New dispatch arm `TabId::Process => render_process_panel(...)`. +- Hotkey `9` jumps to Process tab directly. + +### 37.2 Linux host smoke test (596 processes, top 50 shown) + +``` +--- Process panel (verifies v1.13 procfs) --- +┌ Process ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Showing top 50 of 596 process(es); total RSS: 17.5 GiB │ +│ │ +│PID STATE PRIO NI THR RSS VIRT COMM │ +│3317951 R 20 20 42 3.7 GiB 92.6 GiB opencode │ +│2035104 S 20 20 43 3.2 GiB 92.0 GiB opencode │ +│105364 S 20 20 92 2.1 GiB 21.6 GiB thunderbird │ +│1900029 R 20 20 41 2.0 GiB 177.1 GiB opencode │ +│2859635 S 20 20 18 648.8 MiB 78.0 GiB opencode │ +│1857542 S 20 20 8 646.7 MiB 2.4 GiB kscreenlocker_g │ +│1495 S 20 20 39 517.8 MiB 5.2 GiB plasmashell │ +│2709518 S 20 20 62 324.3 MiB 4.7 GiB clangd.main │ +│1349 S -2 -2 5 302.0 MiB 3.3 GiB kwin_wayland │ +│2649090 R 20 20 1 260.3 MiB 336.8 MiB cmake │ +│3017710 S 20 20 11 232.9 MiB 17.7 GiB node-MainThread │ +│... │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +Verified: +- 596 processes detected; top 50 shown by RSS +- Total RSS: 17.5 GiB (sum of top 50) +- Real processes parsed: opencode (multiple), thunderbird, plasmashell, + kwin_wayland, kscreenlocker_g, clangd, cmake, node-MainThread +- Real thread counts: kwin_wayland=5, kscreenlocker_g=8, plasmashell=39, + thunderbird=92 (high) +- Real priorities: kwin_wayland shows PRIO=-2, NI=-2 (real-time + scheduling for window manager) +- Real states: R (running, e.g. opencode+cmake), S (sleeping, most) +- RSS sorting correct: opencode (3.7 GiB) → thunderbird (2.1 GiB) → + plasmashell (517 MiB) → ... + +### 37.3 Unit tests (9 new, 43/43 total pass) + +```rust +#[test] fn format_memory_below_1kib() +#[test] fn format_memory_1mib() +#[test] fn format_memory_1gib() +#[test] fn parse_stat_line_valid() // (bash) S ... +#[test] fn parse_stat_line_handles_spaces_in_comm() // (Web Content) +#[test] fn parse_stat_line_missing_parens() // graceful failure +#[test] fn parse_stat_line_too_few_fields() // graceful failure +#[test] fn proc_info_is_empty_when_no_proc() +#[test] fn process_total_cpu_ticks() // utime + stime +``` + +``` +running 43 tests +test bench::tests::* (5) ... ok +test sensor::tests::* (12) ... ok +test network::tests::* (7) ... ok +test storage::tests::* (10) ... ok +test process::tests::* (9) ... ok + +test result: ok. 43 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +``` + +### 37.4 Build verification + +| Build | Result | +|-------|--------| +| Linux host (`cargo build --release`) | ✅ 0 errors, 50 warnings (mostly pre-existing dead-code) | +| Linux host tests (`cargo test --release`) | ✅ 43/43 pass | +| Linux host smoke (`./target/release/redbear-power --once`) | ✅ Process panel renders correctly | +| Redox cross-compile (`cargo build --release --target=x86_64-unknown-redox`) | ✅ clean | +| Redox binary (stripped) | 4,045,672 bytes (vs v1.12's 4,021,096 — +24 KB) | +| Cross-compile SHA256 | `2c30f86dce574f173efdcf8eb588f83abd8f0bdf2c5a2678452dd0e6a244dbf2` | + +### 37.5 Refresh cadence (coprime moduli now: 3, 4, 5, 7, 11, 13) + +Process refresh uses **13-tick** modulus (6.5 sec at POLL_MS=500). +The 13-tick modulus is coprime with all existing moduli (3, 4, 5, 7, 11) +so process reads never synchronize with any other data source. +LCM of {3, 4, 5, 7, 11, 13} = 60060 ticks = 30030 sec (~8.3 hours). + +Initially considered 6-tick (3 sec) but rejected because +`gcd(6, 3) = 3` and `gcd(6, 4) = 2`. Also rejected 9, 12, 14 (all share +factors with existing moduli). 13 was the next coprime candidate +after 11. + +### 37.6 Forward work + +- **CPU% column** — store previous `total_cpu_ticks` per process, + compute `delta_ticks / dt_secs / num_cores × 100` for real-time CPU%. +- **Process filtering** — search box to filter by name, regex support. +- **Process actions** — kill signal, renice. Out of scope (TUI + monitoring tool, not a process manager). +- **Sort modes** — toggle between RSS, CPU, PID, name with hotkey. + +### 37.7 Final module structure + +``` +local/recipes/system/redbear-power/source/src/ +├── main.rs (~530 lines) +├── app.rs (~610) — App + CpuRow + TabId + 9 data-source fields +├── render.rs (~1310) — header + tab bar + 9 panels + controls +├── meminfo.rs (241) +├── dmi.rs (118) +├── battery.rs (132) +├── sensor.rs (354) +├── network.rs (203) +├── storage.rs (261) +├── process.rs (215) — NEW: /proc/[pid]/stat + /proc/[pid]/comm parser +├── platform.rs (291) +├── acpi.rs (~233) +├── cpuid.rs (~369) +├── dbus.rs (~294) +├── config.rs (~223) +├── bench.rs (304) +├── msr.rs (~158) +├── cpufreq.rs (~62) +└── theme.rs (71) +``` + +Total: ~5,635 LoC across 19 modules (v1.12: ~5,415 LoC; +220 LoC for +process module + tests). 43 unit tests total (5 bench + 12 sensor + +7 network + 10 storage + 9 process). + +--- + ## 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. diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index a419f85e7e..c4ca8561e0 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -122,6 +122,7 @@ pub struct App { pub sensors: crate::sensor::SensorInfo, pub net: crate::network::NetInfo, pub storage: crate::storage::StorageInfo, + pub processes: crate::process::ProcInfo, pub refresh_counter: u32, pub status_msg: String, pub status_expires: Option, @@ -141,6 +142,7 @@ pub enum TabId { Sensors, Network, Storage, + Process, } impl TabId { @@ -153,7 +155,8 @@ impl TabId { TabId::Battery => TabId::Sensors, TabId::Sensors => TabId::Network, TabId::Network => TabId::Storage, - TabId::Storage => TabId::PerCpu, + TabId::Storage => TabId::Process, + TabId::Process => TabId::PerCpu, } } pub fn name(self) -> &'static str { @@ -166,6 +169,7 @@ impl TabId { TabId::Sensors => "Sensors", TabId::Network => "Network", TabId::Storage => "Storage", + TabId::Process => "Process", } } } @@ -276,6 +280,7 @@ impl App { sensors: crate::sensor::SensorInfo::read(), net: crate::network::NetInfo::read(), storage: crate::storage::StorageInfo::read(), + processes: crate::process::ProcInfo::read(), refresh_counter: 0, } } @@ -336,6 +341,16 @@ impl App { self.storage = crate::storage::StorageInfo::read(); } + // Process list reads /proc/[pid]/stat for every visible PID + // (top 50). Refresh every 13th tick (6.5 sec at POLL_MS=500). + // The 13-tick modulus is coprime to all other moduli (3, 4, 5, + // 7, 11), so process reads don't synchronize with any other + // 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(); + } + for row in &mut self.cpus { if let Some(status) = read_thermal_status(row.id) { row.temp_c = if status & THERM_STATUS_READOUT_VALID != 0 { diff --git a/local/recipes/system/redbear-power/source/src/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index f0ffa92072..b7940dc18f 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -46,6 +46,7 @@ mod meminfo; mod msr; mod network; mod platform; +mod process; mod render; mod sensor; mod storage; @@ -55,8 +56,8 @@ use crate::app::{App, POLL_MS, TabId}; use crate::render::{ render_battery_panel, render_controls, render_cpu_table, render_header, render_help, render_info_panel, render_motherboard_panel, render_network_panel, render_once, - render_prochot_alert, render_sensor_panel, render_storage_panel, render_system_panel, - render_tab_bar, snapshot, + render_process_panel, render_prochot_alert, render_sensor_panel, render_storage_panel, + render_system_panel, render_tab_bar, snapshot, }; #[derive(Clone, Copy, Debug, PartialEq)] @@ -342,6 +343,12 @@ fn main() -> io::Result<()> { body_area, ); } + TabId::Process => { + f.render_widget( + render_process_panel(&app, focused_panel == 1), + body_area, + ); + } } f.render_widget(render_controls(&app, focused_panel == 2), controls_area); if let Some(alert) = render_prochot_alert(&app, f) { @@ -402,6 +409,7 @@ fn main() -> io::Result<()> { Key::Char('6') => app.current_tab = app::TabId::Sensors, Key::Char('7') => app.current_tab = app::TabId::Network, Key::Char('8') => app.current_tab = app::TabId::Storage, + Key::Char('9') => app.current_tab = app::TabId::Process, Key::Char('T') => app.current_tab = app.current_tab.next(), Key::Char('?') => show_help = !show_help, Key::Char('g') => app.cycle_governor(), diff --git a/local/recipes/system/redbear-power/source/src/process.rs b/local/recipes/system/redbear-power/source/src/process.rs new file mode 100644 index 0000000000..d7d3f35e85 --- /dev/null +++ b/local/recipes/system/redbear-power/source/src/process.rs @@ -0,0 +1,215 @@ +//! Process list via `procfs` (`/proc/[pid]/stat` + `/proc/[pid]/comm`). +//! +//! Linux exposes per-process state, memory, and CPU usage via procfs. +//! `/proc/[pid]/stat` is a single space-separated line with 52 fields +//! per `man 5 proc`; the second field (comm) is wrapped in +//! parentheses and may contain spaces/parens, so it must be extracted +//! by locating the LAST `)` to handle names like `(bash)` or +//! `(Web Content)`. +//! +//! `/proc/[pid]/comm` is a separate file containing the process name +//! (truncated to 15 chars + newline) — used as a fallback if the +//! parens-parsing fails. +//! +//! On Redox, no equivalent scheme exists yet, so `read()` returns an +//! empty `ProcInfo` and the render layer shows +//! `(no processes detected)`. + +use std::fs; +use std::path::Path; + +const MAX_PROCESSES: usize = 50; + +#[derive(Default, Clone, Debug)] +pub struct ProcessInfo { + pub pid: u32, + pub comm: String, + pub state: char, + pub ppid: u32, + pub utime: u64, + pub stime: u64, + pub priority: i64, + pub nice: i64, + pub num_threads: i64, + pub vsize_kb: u64, + pub rss_kb: u64, +} + +impl ProcessInfo { + pub fn total_cpu_ticks(&self) -> u64 { + self.utime.saturating_add(self.stime) + } + + pub fn format_memory_kb(kb: u64) -> String { + const UNITS: &[&str] = &["KiB", "MiB", "GiB", "TiB"]; + let mut value = kb as f64; + let mut unit_idx = 0; + while value >= 1024.0 && unit_idx < UNITS.len() - 1 { + value /= 1024.0; + unit_idx += 1; + } + format!("{:.1} {}", value, UNITS[unit_idx]) + } +} + +#[derive(Default, Clone, Debug)] +pub struct ProcInfo { + pub processes: Vec, + pub total_memory_kb: u64, + pub total_count: usize, +} + +fn read_comm(pid: u32) -> String { + fs::read_to_string(format!("/proc/{}/comm", pid)) + .ok() + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "?".to_string()) +} + +fn parse_stat_line(line: &str) -> Option { + let open = line.find('(')?; + let close = line.rfind(')')?; + if close <= open { + return None; + } + let comm = line[open + 1..close].to_string(); + let tail = &line[close + 1..]; + let fields: Vec<&str> = tail.split_whitespace().collect(); + if fields.len() < 22 { + return None; + } + let pid: u32 = line[..open].trim().parse().ok()?; + let state_char = fields[0].chars().next().unwrap_or('?'); + let ppid: u32 = fields[1].parse().ok()?; + let utime: u64 = fields[11].parse().ok()?; + let stime: u64 = fields[12].parse().ok()?; + let priority: i64 = fields[15].parse().ok()?; + let nice: i64 = fields[16].parse().ok()?; + let num_threads: i64 = fields[17].parse().ok()?; + let vsize_bytes: i64 = fields[20].parse().ok()?; + let rss_pages: i64 = fields[21].parse().ok()?; + Some(ProcessInfo { + pid, + comm, + state: state_char, + ppid, + utime, + stime, + priority, + nice, + num_threads, + vsize_kb: (vsize_bytes.max(0) as u64) / 1024, + rss_kb: (rss_pages.max(0) as u64) * 4, + }) +} + +fn read_process(pid: u32) -> Option { + let stat = fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?; + let parsed = parse_stat_line(&stat)?; + let mut info = parsed; + if info.comm.is_empty() || info.comm == "?" { + info.comm = read_comm(pid); + } + Some(info) +} + +impl ProcInfo { + pub fn available() -> bool { + Path::new("/proc").is_dir() + } + pub fn read() -> Self { + let Ok(entries) = fs::read_dir("/proc") else { return Self::default(); }; + let mut processes = Vec::new(); + let mut pids: Vec = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = match name.to_str() { + Some(s) => s, + None => continue, + }; + if let Ok(pid) = name_str.parse::() { + pids.push(pid); + } + } + let total_count = pids.len(); + for pid in pids { + if let Some(proc) = read_process(pid) { + processes.push(proc); + } + } + processes.sort_by(|a, b| b.rss_kb.cmp(&a.rss_kb)); + processes.truncate(MAX_PROCESSES); + let total_memory_kb: u64 = processes.iter().map(|p| p.rss_kb).sum(); + Self { processes, total_memory_kb, total_count } + } + pub fn is_empty(&self) -> bool { + self.processes.is_empty() + } + pub fn count(&self) -> usize { + self.processes.len() + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_memory_below_1kib() { + assert_eq!(ProcessInfo::format_memory_kb(500), "500.0 KiB"); + } + + #[test] + fn format_memory_1mib() { + assert_eq!(ProcessInfo::format_memory_kb(1024), "1.0 MiB"); + } + + #[test] + fn format_memory_1gib() { + assert_eq!(ProcessInfo::format_memory_kb(1024 * 1024), "1.0 GiB"); + } + + #[test] + fn parse_stat_line_valid() { + // bash process: pid=1 (comm) S ppid pgrp session ... + let line = "2642164 (bash) S 3317951 2642164 2642164 0 -1 4194304 229 451 0 2 0 0 0 0 20 0 1 0 138471094 8060928 883 18446744073709551615 94645830324224 94645831153721 140735825184736 0 0 0 65536 4 65538 1 0 0 17 0 0 0"; + let p = parse_stat_line(line).expect("should parse"); + assert_eq!(p.pid, 2642164); + assert_eq!(p.comm, "bash"); + assert_eq!(p.state, 'S'); + assert_eq!(p.ppid, 3317951); + assert_eq!(p.nice, 0); + } + + #[test] + fn parse_stat_line_handles_spaces_in_comm() { + // Firefox process with "(Web Content)" comm + let line = "12345 (Web Content) R 1 12345 12345 0 -1 1077936384 100 0 0 0 5 0 0 0 20 0 1 0 1000 1000 100 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0"; + let p = parse_stat_line(line).expect("should parse"); + assert_eq!(p.comm, "Web Content"); + assert_eq!(p.state, 'R'); + } + + #[test] + fn parse_stat_line_missing_parens() { + assert!(parse_stat_line("invalid").is_none()); + } + + #[test] + fn parse_stat_line_too_few_fields() { + let line = "1 (x) S"; + assert!(parse_stat_line(line).is_none()); + } + + #[test] + fn proc_info_is_empty_when_no_proc() { + let info = ProcInfo::default(); + assert!(info.is_empty()); + assert_eq!(info.count(), 0); + } + + #[test] + fn process_total_cpu_ticks() { + let p = ProcessInfo { utime: 100, stime: 50, ..Default::default() }; + assert_eq!(p.total_cpu_ticks(), 150); + } +} diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index f794e54e5e..fab0ddd4c7 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -280,6 +280,7 @@ pub fn render_tab_bar<'a>(app: &'a App) -> Tabs<'a> { TabId::Sensors, TabId::Network, TabId::Storage, + TabId::Process, ] .iter() .map(|t| Line::from(t.name())) @@ -293,6 +294,7 @@ pub fn render_tab_bar<'a>(app: &'a App) -> Tabs<'a> { TabId::Sensors => 5, TabId::Network => 6, TabId::Storage => 7, + TabId::Process => 8, }; Tabs::new(titles) .select(selected) @@ -832,6 +834,45 @@ pub fn render_storage_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { .wrap(Wrap { trim: true }) } +pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { + let proc = &app.processes; + if proc.is_empty() { + return Paragraph::new(Line::from( + "(no processes detected — /proc/ not readable)".set_style(theme::VALUE_WARM), + )) + .block(panel_border(focused, " Process ")) + .wrap(Wrap { trim: true }); + } + let mut lines: Vec> = Vec::new(); + lines.push(Line::from(format!( + "Showing top {} of {} process(es); total RSS: {}", + proc.count(), + proc.total_count, + crate::process::ProcessInfo::format_memory_kb(proc.total_memory_kb), + ).set_style(theme::LABEL_BOLD))); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + " PID STATE PRIO NI THR RSS VIRT COMM".set_style(theme::LABEL), + ])); + for p in &proc.processes { + let comm_truncated: String = p.comm.chars().take(20).collect(); + lines.push(Line::from(format!( + " {:<7} {} {:<4} {:<3} {:<3} {:<11} {:<11} {}", + p.pid, + p.state, + p.priority, + p.nice, + p.num_threads, + crate::process::ProcessInfo::format_memory_kb(p.rss_kb), + crate::process::ProcessInfo::format_memory_kb(p.vsize_kb), + comm_truncated, + ).set_style(theme::VALUE))); + } + Paragraph::new(lines) + .block(panel_border(focused, " Process ")) + .wrap(Wrap { trim: true }) +} + pub fn render_cpu_table<'a>( cpus: &'a [CpuRow], expanded_cpu: Option, @@ -1246,5 +1287,15 @@ pub fn render_once(app: &App) -> io::Result<()> { }) .expect("draw"); print!("{}", buffer_to_string(terminal.backend().buffer())); + eprintln!("--- Process panel (verifies v1.13 procfs) ---"); + let proc_para = render_process_panel(app, false); + let backend = TestBackend::new(120, 60); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal + .draw(|f| { + f.render_widget(proc_para, f.area()); + }) + .expect("draw"); + print!("{}", buffer_to_string(terminal.backend().buffer())); Ok(()) } \ No newline at end of file