redbear-power: v1.13 — Process tab (procfs)

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).
This commit is contained in:
2026-06-20 20:41:44 +03:00
parent 833509a979
commit 1dcdc5d39d
7 changed files with 514 additions and 9 deletions
+40
View File
@@ -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 |
+7 -6
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.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:
@@ -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.
@@ -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<Instant>,
@@ -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 {
@@ -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(),
@@ -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<ProcessInfo>,
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<ProcessInfo> {
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<ProcessInfo> {
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<u32> = 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::<u32>() {
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);
}
}
@@ -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<Line<'a>> = 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<u32>,
@@ -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(())
}