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:
@@ -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 |
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user