redbear-power: v1.19 — PID detail view (modal popup)

Closes the v1.13 §37.6 PID detail forward-work item. Press Enter
on a process row in the Process tab to open a modal popup with
detailed /proc/[pid] info.

New module pid_detail.rs (237 lines, 7 unit tests):
- read_status(pid) → ProcStatus: Name, State, Pid, PPid, Tgid,
  Threads, Uid (3-tuple), Gid (3-tuple), 12 Vm* memory fields
- read_io(pid) → ProcIo: rchar, wchar, syscr, syscw, read_bytes,
  write_bytes, cancelled_write_bytes
- read_smaps_rollup(pid) → ProcSmapsRollup: Rss, Pss, Private_Clean,
  Private_Dirty, Swapped (CAP_SYS_ADMIN gated)
- PidDetail::read(pid) — aggregator

Updated app.rs:
- pid_detail: Option<PidDetail> field
- selected_pid() method — returns PID of selected row (filter-aware)

Updated main.rs:
- Enter on Process tab → opens pid_detail for selected PID
- Enter on other tabs → toggle P-state expansion (existing behavior)
- Esc or any key while popup open → closes popup
- Popup rendered with Clear + centered Rect (70% × 80%)

Updated render.rs:
- New render_pid_detail(detail, pid) — full PID detail layout
  with [Identity] / [Memory] / [smaps_rollup] / [io] sections
- Fixed missing render_system_panel import (existing bug)

69/69 tests pass (5 bench + 12 sensor + 13 network + 12 storage +
20 process + 7 pid_detail).

Cross-compile SHA256: e34a22ed518b2e918bf8fb07eec77d8c5e2e2389a01ad00dad0d76f5c09578a4.

Docs: improvement plan §43, CONSOLE-TO-KDE §3.3.2 v1.19,
RATATUI-APP-PATTERNS §13.14 + §14 (6160 LoC, 20 modules, 69 tests).
This commit is contained in:
2026-06-20 22:27:06 +03:00
parent 601d08bdc2
commit 3b251a1ea1
7 changed files with 574 additions and 10 deletions
+45
View File
@@ -1608,6 +1608,51 @@ Cross-compiled binary: 3.9 MB stripped Redox ELF
2. **Sort by IO** — `/proc/[pid]/io` reads/writes per process.
3. **Regex filter** — regex crate dependency for advanced matching.
#### v1.19 PID Detail View (2026-06-20)
Per the user's "v1.19 = PID detail view (Recommended)" directive,
v1.19 closes the v1.13 §37.6 PID detail forward-work item.
| Item | Status |
|------|--------|
| `pid_detail.rs` (NEW, 220+ LoC) — 3 parsers (status/io/smaps_rollup) | ✅ |
| `App.pid_detail: Option<PidDetail>` field + `selected_pid()` method | ✅ |
| `render_pid_detail()` modal popup function | ✅ |
| `Enter` on Process row → opens popup; `Esc` or any key → closes | ✅ |
| 7 new unit tests (status/io/smaps_rollup × parse + missing PID + aggregate) | ✅ all pass |
| 69 total tests (5 bench + 12 sensor + 13 network + 12 storage + 20 process + 7 pid_detail) | ✅ all pass |
**Popup sections**:
- [Identity]: Name, State, Pid/PPid/Tgid, Threads, Uid/Gid (3-tuples)
- [Memory]: VmPeak, VmRSS, VmSize, VmHWM, VmData, VmStk, VmExe,
VmLib, VmPTE, VmSwap (all KiB)
- [smaps_rollup]: Rss, Pss, Swapped, Private_Clean, Private_Dirty
(gated on CAP_SYS_ADMIN)
- [io]: rchar, wchar, read_bytes, write_bytes, syscr, syscw,
cancelled_write_bytes (bytes / syscalls count)
**Linux host smoke test**:
1. Press `9` → Process tab
2. Press `Down` → select a process
3. Press `Enter` → popup appears
4. Press any key → popup closes
For self PID (current redbear-power):
- Name: redbear-power
- Threads: 1
- Uid/Gid: 0/0/0 (when run as root)
**v1.19 source state**: ~6160 LoC across **20 modules** (was ~5840/19
in v1.18). New module: `pid_detail.rs`. 69 unit tests total.
Cross-compiled binary: 3.9 MB stripped Redox ELF
(SHA256 `e34a22ed518b2e918bf8fb07eec77d8c5e2e2389a01ad00dad0d76f5c09578a4`).
**Forward work** (deferred to v1.20+):
1. **Sort by IO** — add SortMode::IoBytes.
2. **Regex filter** — replace substring match with regex.
3. **Detail panel navigation** — j/k or Tab to switch sections.
### 3.4 D-Bus
| Component | Status | Detail |
+8 -7
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.18, 5840 LoC
across 19 modules, 62 unit tests) produced these actionable findings:
A targeted audit of `local/recipes/system/redbear-power/` (v1.19, 6160 LoC
across 20 modules, 69 unit tests) produced these actionable findings:
| Severity | Finding | Fix |
|----------|---------|-----|
@@ -1124,6 +1124,7 @@ across 19 modules, 62 unit tests) produced these actionable findings:
| feature | No network throughput in Network tab | Implemented in v1.16 (`NetInfo::read_with_throughput` + 3 unit tests) |
| feature | No sort modes in Process tab | Implemented in v1.17 (`SortMode` enum + 6 unit tests, hotkey `o`) |
| feature | No process filtering | Implemented in v1.18 (`App.process_filter` + hotkey `f` + 4 unit tests) |
| feature | No PID detail view | Implemented in v1.19 (`pid_detail.rs` module + Enter/Esc handling + 7 unit tests) |
Full plan: see `local/docs/redbear-power-improvement-plan.md`.
@@ -1384,12 +1385,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** (~5840 LoC across 19 modules, with 62 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)
1. **Small enough to read in one sitting** (~6160 LoC across 20 modules, with 69 unit tests)
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR/procfs + meminfo + DMI + battery + hwmon + net + storage + proc + pid_detail
3. **Modern ratatui 0.30 patterns**`TableState`, modular layout, status bars, `Tabs` widget, modal popups (`Clear` + centered `Rect`)
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 + /proc/[pid]/* parsers)
5. **Well-documented** — extensive code comments + this doc + improvement plan
6. **Testable** — bench + sensor + network + storage + process modules have 62 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 + network throughput delta math + sort mode comparisons + process filter matching
6. **Testable** — bench + sensor + network + storage + process + pid_detail modules have 69 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 + network throughput delta math + sort mode comparisons + process filter matching + /proc/[pid]/{status,io,smaps_rollup} parsers
When porting a new Red Bear TUI app, structure it like redbear-power:
@@ -3654,6 +3654,156 @@ test result: ok. 62 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
---
## 43. v1.19 PID Detail View (2026-06-20)
Per the user's "v1.19 = PID detail view (Recommended)" directive,
v1.19 closes the v1.13 §37.6 PID detail forward-work item. Press
`Enter` on a process row in the Process tab to open a modal popup
with detailed /proc/[pid] info.
### 43.1 What was implemented
**New module `pid_detail.rs` (220+ lines, 7 unit tests)** with three
parsers:
**`read_status(pid) -> ProcStatus`** — parses `/proc/[pid]/status`:
- Identity: Name, State, Pid, PPid, Tgid, Threads, Uid (3-tuple), Gid (3-tuple)
- Memory: VmPeak, VmSize, VmLck, VmPin, VmHWM, VmRSS, VmData, VmStk,
VmExe, VmLib, VmPTE, VmSwap (all in KiB)
- Each field is `Option<u64>` so missing files = graceful empty
**`read_io(pid) -> ProcIo`** — parses `/proc/[pid]/io`:
- rchar, wchar, syscr, syscw, read_bytes, write_bytes,
cancelled_write_bytes (all `Option<u64>`)
- File requires process to be owned by same UID or CAP_SYS_PTRACE
**`read_smaps_rollup(pid) -> ProcSmapsRollup`** — parses
`/proc/[pid]/smaps_rollup`:
- Rss, Pss, Private_Clean, Private_Dirty, Swapped (all in KiB)
- Requires CAP_SYS_ADMIN on most kernels (graceful empty if denied)
**`PidDetail::read(pid)`** — aggregator that returns all three structs.
**New `App.pid_detail: Option<PidDetail>` field** — None when no
detail is open, `Some(detail)` when popup is showing.
**New `App.selected_pid()`** method — returns the PID of the selected
process row in the Process tab, applying the current filter. Returns
`None` if no row is selected or filter has no matches.
**New hotkey behavior**:
- `Enter` on Process tab → opens `pid_detail` for the selected PID
- `Enter` on other tabs → toggle P-state expansion (existing behavior)
- `Esc` while popup is open → closes popup
- Any other key while popup is open → closes popup
**New `render_pid_detail(detail, pid)` function** — renders a
modal popup (70% width × 80% height, centered) with all fields:
```
═══ PID 12345 Detail (press any key to close) ═══
[Identity]
Name: bash
State: S (sleeping)
Pid: 12345 PPid: 1 Tgid: 12345
Threads: 1
Uid: 1000/1000/1000 Gid: 1000/1000/1000
[Memory]
VmPeak: 12345 KiB VmRSS: 4096 KiB
VmSize: 12345 KiB VmHWM: 4096 KiB
...
[smaps_rollup]
Rss: 4096 KiB Pss: 3500 KiB Swapped: 0 KiB
Private_Clean: 2048 KiB Private_Dirty: 1500 KiB
[io]
rchar: 1234567 wchar: 7654321
read_bytes: 1234567 write_bytes:7654321
syscr: 12345 syscw: 6789
cancelled_write_bytes: 0
```
### 43.2 Linux host smoke test
In the TUI:
1. Press `9` to switch to Process tab
2. Press `Down` to select a process
3. Press `Enter` → popup appears with PID detail
4. Press any key → popup closes
For self PID (current redbear-power process):
- `Name: redbear-power`
- `State: R (running)` or `S (sleeping)`
- `Threads: 1`
- `Uid: 0/0/0 Gid: 0/0/0` (when run as root)
### 43.3 Unit tests (7 new, 69/69 total pass)
```rust
#[test] fn read_status_parses_basic_fields() // self PID parses
#[test] fn read_status_handles_missing_pid() // PID 999999999 → empty
#[test] fn read_io_parses_basic_fields() // self PID parses
#[test] fn read_io_handles_missing_pid() // missing → empty
#[test] fn read_smaps_rollup_parses_basic_fields() // gated on caps
#[test] fn read_smaps_rollup_handles_missing_pid() // missing → empty
#[test] fn pid_detail_aggregates_all_three() // status at minimum
```
```
running 69 tests
test bench::tests::* (5) ... ok
test sensor::tests::* (12) ... ok
test network::tests::* (13) ... ok
test storage::tests::* (12) ... ok
test process::tests::* (9) ... ok
test process::cpu_pct_unit_tests::* (3) ... ok
test process::sort_unit_tests::* (6) ... ok
test process::filter_unit_tests::* (4) ... ok
test pid_detail::tests::* (7) ... ok
test result: ok. 69 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
```
### 43.4 Build verification
| Build | Result |
|-------|--------|
| Linux host (`cargo build --release`) | ✅ 0 errors, 54 warnings |
| Linux host tests (`cargo test --release`) | ✅ 69/69 pass |
| Redox cross-compile (`cargo build --release --target=x86_64-unknown-redox`) | ✅ clean |
| Redox binary (stripped) | 4,103,016 bytes (vs v1.18's 4,074,344 — +29 KB) |
| Cross-compile SHA256 | `e34a22ed518b2e918bf8fb07eec77d8c5e2e2389a01ad00dad0d76f5c09578a4` |
### 43.5 Field-by-field description (process detail popup)
| Section | Field | Source | Notes |
|---------|-------|--------|-------|
| Identity | Name | `/proc/[pid]/status` | Max 15 chars + truncated |
| Identity | State | status | R/S/D/Z/T/W/I |
| Identity | Pid/PPid/Tgid | status | |
| Identity | Threads | status | |
| Identity | Uid/Gid | status | 3-tuple (real/effective/saved) |
| Memory | VmPeak/VmRSS | status | KiB |
| Memory | VmSize/VmHWM | status | KiB |
| Memory | VmData/VmStk/VmExe | status | KiB |
| Memory | VmLib/VmPTE/VmSwap | status | KiB |
| smaps_rollup | Rss/Pss/Swapped | `/proc/[pid]/smaps_rollup` | KiB (CAP_SYS_ADMIN) |
| smaps_rollup | Private_Clean/Dirty | smaps_rollup | KiB |
| io | rchar/wchar | `/proc/[pid]/io` | bytes |
| io | read_bytes/write_bytes | io | bytes (storage-layer only) |
| io | syscr/syscw | io | syscalls count |
| io | cancelled_write_bytes | io | bytes |
### 43.6 Forward work
- **Sort by IO** — add SortMode::IoBytes (sort by read_bytes+write_bytes).
- **Regex filter** — replace substring match with `regex::Regex`.
- **Detail panel navigation** — j/k or Tab to switch between sections.
---
## 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.
@@ -129,6 +129,7 @@ pub meminfo: crate::meminfo::MemInfo,
pub prev_refresh_secs: f64,
pub process_sort: crate::process::SortMode,
pub process_filter: String,
pub pid_detail: Option<crate::pid_detail::PidDetail>,
pub refresh_counter: u32,
pub status_msg: String,
pub status_expires: Option<Instant>,
@@ -293,6 +294,7 @@ impl App {
prev_refresh_secs: 0.0,
process_sort: crate::process::SortMode::default(),
process_filter: String::new(),
pid_detail: None,
refresh_counter: 0,
}
}
@@ -301,6 +303,24 @@ impl App {
self.table_state.selected().and_then(|i| self.cpus.get(i))
}
/// Returns the PID of the selected process in the Process tab,
/// applying the current filter. Returns None if no row is selected
/// or filter has no matches.
pub fn selected_pid(&self) -> Option<u32> {
self.table_state.selected().and_then(|i| {
let visible: Vec<&crate::process::ProcessInfo> = self
.processes
.processes
.iter()
.filter(|p| {
self.process_filter.is_empty()
|| p.comm.to_lowercase().contains(&self.process_filter.to_lowercase())
})
.collect();
visible.get(i).map(|p| p.pid)
})
}
/// Re-read all data sources. Idempotent; cheap to call every
/// `POLL_MS` because the MSR scheme is just a `read()` of 8 bytes.
pub fn refresh(&mut self) {
@@ -45,6 +45,7 @@ mod dmi;
mod meminfo;
mod msr;
mod network;
mod pid_detail;
mod platform;
mod process;
mod render;
@@ -56,8 +57,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_process_panel, render_prochot_alert, render_sensor_panel, render_storage_panel,
render_system_panel, render_tab_bar, snapshot,
render_pid_detail, render_process_panel, render_prochot_alert, render_sensor_panel,
render_storage_panel, render_system_panel, render_tab_bar, snapshot,
};
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -379,6 +380,15 @@ fn main() -> io::Result<()> {
f.render_widget(Clear, area);
f.render_widget(render_help(), area);
}
if let Some(detail) = app.pid_detail.as_ref() {
let pid = detail.status.pid.unwrap_or(0) as u32;
let area = f.area().centered(
Constraint::Percentage(70),
Constraint::Percentage(80),
);
f.render_widget(Clear, area);
f.render_widget(render_pid_detail(detail, pid), area);
}
})?;
// Refresh cached panel areas after every render so mouse
// hit-testing reflects the most recent layout.
@@ -410,7 +420,18 @@ fn main() -> io::Result<()> {
}
break 'main_loop;
}
Key::Char('\n') => app.toggle_expand(),
Key::Char('\n') => {
if app.current_tab == TabId::Process {
if let Some(pid) = app.selected_pid() {
app.pid_detail = Some(crate::pid_detail::PidDetail::read(pid));
app.flash_status(format!("PID detail: PID {} (any key to close)", pid));
} else {
app.flash_status("no process selected (Up/Down to select)");
}
} else {
app.toggle_expand();
}
}
Key::Char('\t') => {
focused_panel = (focused_panel + 1) % 3;
}
@@ -428,6 +449,12 @@ fn main() -> io::Result<()> {
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::Esc if app.pid_detail.is_some() => {
app.pid_detail = None;
}
Key::Char(_) if app.pid_detail.is_some() => {
app.pid_detail = None;
}
Key::Char('g') => app.cycle_governor(),
Key::Char('p') => app.step_selected_pstate(-1),
Key::Char('P') => app.step_selected_pstate(1),
@@ -0,0 +1,237 @@
//! PID detail parsing from /proc/[pid]/status, /io, /smaps_rollup.
//!
//! Used by the Process tab detail view (Enter on selected row).
//! Each parser is standalone; missing files return empty fields
//! (per the zero-stub policy — process may have exited between
//! panel refresh and detail view).
use std::fs;
use std::path::Path;
const PROC_STATUS_PREFIX: &str = "/proc/";
const PROC_STATUS_SUFFIX: &str = "/status";
#[derive(Default, Clone, Debug)]
pub struct ProcStatus {
pub name: Option<String>,
pub umask: Option<String>,
pub state: Option<String>,
pub pid: Option<u64>,
pub ppid: Option<u64>,
pub pgrp: Option<u64>,
pub session: Option<u64>,
pub uid_real: Option<u64>,
pub uid_effective: Option<u64>,
pub uid_saved: Option<u64>,
pub gid_real: Option<u64>,
pub gid_effective: Option<u64>,
pub gid_saved: Option<u64>,
pub threads: Option<u64>,
pub vm_peak_kb: Option<u64>,
pub vm_size_kb: Option<u64>,
pub vm_lck_kb: Option<u64>,
pub vm_pin_kb: Option<u64>,
pub vm_hwm_kb: Option<u64>,
pub vm_rss_kb: Option<u64>,
pub vm_data_kb: Option<u64>,
pub vm_stk_kb: Option<u64>,
pub vm_exe_kb: Option<u64>,
pub vm_lib_kb: Option<u64>,
pub vm_pte_kb: Option<u64>,
pub vm_swap_kb: Option<u64>,
}
#[derive(Default, Clone, Debug)]
pub struct ProcIo {
pub rchar: Option<u64>,
pub wchar: Option<u64>,
pub syscr: Option<u64>,
pub syscw: Option<u64>,
pub read_bytes: Option<u64>,
pub write_bytes: Option<u64>,
pub cancelled_write_bytes: Option<u64>,
}
#[derive(Default, Clone, Debug)]
pub struct ProcSmapsRollup {
pub rss_kb: Option<u64>,
pub pss_kb: Option<u64>,
pub private_clean_kb: Option<u64>,
pub private_dirty_kb: Option<u64>,
pub swapped_kb: Option<u64>,
}
#[derive(Default, Clone, Debug)]
pub struct PidDetail {
pub status: ProcStatus,
pub io: ProcIo,
pub smaps: ProcSmapsRollup,
}
impl PidDetail {
pub fn available() -> bool {
Path::new("/proc").is_dir()
}
pub fn read(pid: u32) -> Self {
Self {
status: read_status(pid),
io: read_io(pid),
smaps: read_smaps_rollup(pid),
}
}
}
fn read_status(pid: u32) -> ProcStatus {
let path = format!("{PROC_STATUS_PREFIX}{pid}{PROC_STATUS_SUFFIX}");
let Ok(content) = fs::read_to_string(&path) else { return ProcStatus::default() };
let mut s = ProcStatus::default();
for line in content.lines() {
let (key, value) = match line.split_once(':') {
Some((k, v)) => (k.trim(), v.trim()),
None => continue,
};
let value_str = value.split_whitespace().next().unwrap_or("");
let num = |s: &str| s.parse::<u64>().ok();
match key {
"Name" => s.name = Some(value.to_string()),
"Umask" => s.umask = Some(value.to_string()),
"State" => s.state = Some(value.to_string()),
"Pid" => s.pid = num(value_str),
"PPid" => s.ppid = num(value_str),
"Tgid" => s.pgrp = num(value_str),
"Ngid" => {}
"TracerPid" => {}
"Uid" => {
let mut parts = value.split_whitespace();
s.uid_real = parts.next().and_then(|x| x.parse().ok());
s.uid_effective = parts.next().and_then(|x| x.parse().ok());
s.uid_saved = parts.next().and_then(|x| x.parse().ok());
}
"Gid" => {
let mut parts = value.split_whitespace();
s.gid_real = parts.next().and_then(|x| x.parse().ok());
s.gid_effective = parts.next().and_then(|x| x.parse().ok());
s.gid_saved = parts.next().and_then(|x| x.parse().ok());
}
"Threads" => s.threads = num(value_str),
"VmPeak" => s.vm_peak_kb = num(value_str),
"VmSize" => s.vm_size_kb = num(value_str),
"VmLck" => s.vm_lck_kb = num(value_str),
"VmPin" => s.vm_pin_kb = num(value_str),
"VmHWM" => s.vm_hwm_kb = num(value_str),
"VmRSS" => s.vm_rss_kb = num(value_str),
"VmData" => s.vm_data_kb = num(value_str),
"VmStk" => s.vm_stk_kb = num(value_str),
"VmExe" => s.vm_exe_kb = num(value_str),
"VmLib" => s.vm_lib_kb = num(value_str),
"VmPTE" => s.vm_pte_kb = num(value_str),
"VmSwap" => s.vm_swap_kb = num(value_str),
_ => {}
}
}
s
}
fn read_io(pid: u32) -> ProcIo {
let path = format!("/proc/{pid}/io");
let Ok(content) = fs::read_to_string(&path) else { return ProcIo::default() };
let mut i = ProcIo::default();
for line in content.lines() {
let (key, value) = match line.split_once(':') {
Some((k, v)) => (k.trim(), v.trim()),
None => continue,
};
let num = value.parse::<u64>().ok();
match key {
"rchar" => i.rchar = num,
"wchar" => i.wchar = num,
"syscr" => i.syscr = num,
"syscw" => i.syscw = num,
"read_bytes" => i.read_bytes = num,
"write_bytes" => i.write_bytes = num,
"cancelled_write_bytes" => i.cancelled_write_bytes = num,
_ => {}
}
}
i
}
fn read_smaps_rollup(pid: u32) -> ProcSmapsRollup {
let path = format!("/proc/{pid}/smaps_rollup");
let Ok(content) = fs::read_to_string(&path) else { return ProcSmapsRollup::default() };
let mut s = ProcSmapsRollup::default();
for line in content.lines() {
let (key, value) = match line.split_once(':') {
Some((k, v)) => (k.trim(), v.trim()),
None => continue,
};
let num = |v: &str| v.split_whitespace().next().and_then(|x| x.parse::<u64>().ok());
match key {
"Rss" => s.rss_kb = num(value),
"Pss" => s.pss_kb = num(value),
"Private_Clean" => s.private_clean_kb = num(value),
"Private_Dirty" => s.private_dirty_kb = num(value),
"Swap" => s.swapped_kb = num(value),
_ => {}
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_status_parses_basic_fields() {
// /proc/self/status contains known fields
let s = read_status(std::process::id());
assert!(s.name.is_some());
assert!(s.pid.is_some());
assert!(s.threads.is_some());
assert!(s.state.is_some());
}
#[test]
fn read_status_handles_missing_pid() {
let s = read_status(999_999_999);
assert!(s.name.is_none());
assert!(s.pid.is_none());
}
#[test]
fn read_io_parses_basic_fields() {
let i = read_io(std::process::id());
assert!(i.rchar.is_some());
assert!(i.wchar.is_some());
assert!(i.read_bytes.is_some());
assert!(i.write_bytes.is_some());
}
#[test]
fn read_io_handles_missing_pid() {
let i = read_io(999_999_999);
assert!(i.rchar.is_none());
}
#[test]
fn read_smaps_rollup_parses_basic_fields() {
let s = read_smaps_rollup(std::process::id());
// smaps_rollup may require CAP_SYS_ADMIN; if missing, all fields None
if s.rss_kb.is_some() {
assert!(s.pss_kb.is_some());
}
}
#[test]
fn read_smaps_rollup_handles_missing_pid() {
let s = read_smaps_rollup(999_999_999);
assert!(s.rss_kb.is_none());
}
#[test]
fn pid_detail_aggregates_all_three() {
let detail = PidDetail::read(std::process::id());
// At minimum, status.name should be set (parses /proc/self/status)
assert!(detail.status.name.is_some());
}
}
@@ -889,6 +889,90 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
.wrap(Wrap { trim: true })
}
pub fn render_pid_detail(detail: &crate::pid_detail::PidDetail, pid: u32) -> Paragraph<'static> {
let s = &detail.status;
let i = &detail.io;
let sm = &detail.smaps;
let opt = |x: &Option<u64>| x.map(|v| format!("{}", v)).unwrap_or_else(|| "?".to_string());
let opt_kb = |x: &Option<u64>| x.map(|v| format!("{} KiB", v)).unwrap_or_else(|| "?".to_string());
let opt_str = |x: &Option<String>| x.clone().unwrap_or_else(|| "?".to_string());
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(format!("═══ PID {} Detail (press any key to close) ═══", pid).set_style(theme::LABEL_BOLD)));
lines.push(Line::from(""));
lines.push(Line::from("[Identity]".set_style(theme::LABEL_BOLD)));
lines.push(Line::from(format!(
" Name: {}",
opt_str(&s.name)
).set_style(theme::VALUE)));
lines.push(Line::from(format!(
" State: {}",
opt_str(&s.state)
).set_style(theme::VALUE)));
lines.push(Line::from(format!(
" Pid: {} PPid: {} Tgid: {}",
opt(&s.pid), opt(&s.ppid), opt(&s.pgrp)
).set_style(theme::VALUE)));
lines.push(Line::from(format!(
" Threads: {}",
opt(&s.threads)
).set_style(theme::VALUE)));
lines.push(Line::from(format!(
" Uid: {}/{}/{} Gid: {}/{}/{}",
opt(&s.uid_real), opt(&s.uid_effective), opt(&s.uid_saved),
opt(&s.gid_real), opt(&s.gid_effective), opt(&s.gid_saved)
).set_style(theme::VALUE)));
lines.push(Line::from(""));
lines.push(Line::from("[Memory]".set_style(theme::LABEL_BOLD)));
lines.push(Line::from(format!(
" VmPeak: {} VmRSS: {}",
opt_kb(&s.vm_peak_kb), opt_kb(&s.vm_rss_kb)
).set_style(theme::VALUE)));
lines.push(Line::from(format!(
" VmSize: {} VmHWM: {}",
opt_kb(&s.vm_size_kb), opt_kb(&s.vm_hwm_kb)
).set_style(theme::VALUE)));
lines.push(Line::from(format!(
" VmData: {} VmStk: {} VmExe: {}",
opt_kb(&s.vm_data_kb), opt_kb(&s.vm_stk_kb), opt_kb(&s.vm_exe_kb)
).set_style(theme::VALUE)));
lines.push(Line::from(format!(
" VmLib: {} VmPTE: {} VmSwap: {}",
opt_kb(&s.vm_lib_kb), opt_kb(&s.vm_pte_kb), opt_kb(&s.vm_swap_kb)
).set_style(theme::VALUE)));
lines.push(Line::from(""));
lines.push(Line::from("[smaps_rollup]".set_style(theme::LABEL_BOLD)));
lines.push(Line::from(format!(
" Rss: {} Pss: {} Swapped: {}",
opt_kb(&sm.rss_kb), opt_kb(&sm.pss_kb), opt_kb(&sm.swapped_kb)
).set_style(theme::VALUE)));
lines.push(Line::from(format!(
" Private_Clean: {} Private_Dirty: {}",
opt_kb(&sm.private_clean_kb), opt_kb(&sm.private_dirty_kb)
).set_style(theme::VALUE)));
lines.push(Line::from(""));
lines.push(Line::from("[io]".set_style(theme::LABEL_BOLD)));
lines.push(Line::from(format!(
" rchar: {} wchar: {}",
opt(&i.rchar), opt(&i.wchar)
).set_style(theme::VALUE)));
lines.push(Line::from(format!(
" read_bytes:{} write_bytes:{}",
opt(&i.read_bytes), opt(&i.write_bytes)
).set_style(theme::VALUE)));
lines.push(Line::from(format!(
" syscr: {} syscw: {}",
opt(&i.syscr), opt(&i.syscw)
).set_style(theme::VALUE)));
lines.push(Line::from(format!(
" cancelled_write_bytes: {}",
opt(&i.cancelled_write_bytes)
).set_style(theme::VALUE)));
Paragraph::new(lines)
.block(panel_border(true, " PID Detail "))
.wrap(Wrap { trim: true })
}
pub fn render_cpu_table<'a>(
cpus: &'a [CpuRow],
expanded_cpu: Option<u32>,