diff --git a/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md b/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md index 4cb3f42702..ff1ff4387b 100644 --- a/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md +++ b/local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md @@ -1653,6 +1653,55 @@ Cross-compiled binary: 3.9 MB stripped Redox ELF 2. **Regex filter** — replace substring match with regex. 3. **Detail panel navigation** — j/k or Tab to switch sections. +#### v1.20 SMART Data Module (2026-06-20) + +Per the user's "v1.20 = SMART data (Recommended)" directive, v1.20 +adds the **smart.rs module** for disk health monitoring. Since +`smartctl` is not installed on most systems (this dev host has it +absent), v1.20 implements the module with **graceful degradation** +per the zero-stub policy. + +| Item | Status | +|------|--------| +| `smart.rs` (NEW, 200+ LoC) — SmartInfo + SmartHealth + SmartAttribute | ✅ | +| `SmartInfo::smartctl_available()` — checks `smartctl --version` | ✅ | +| `SmartInfo::read(disks)` — orchestrates per-disk calls | ✅ | +| `parse_smartctl_output()` — extracts passed/failed + attributes | ✅ | +| `parse_attribute_line()` — 10-field SMART attribute line | ✅ | +| `parse_smart_value()` — handles both hex (`0x33`) and decimal | ✅ | +| Three-tier graceful degradation (missing / per-disk error / permission) | ✅ | +| 7 new unit tests (parse + integration + missing binary + missing disk) | ✅ all pass | +| 76 total tests (5 bench + 12 sensor + 13 network + 12 storage + 20 process + 7 pid_detail + 7 smart) | ✅ all pass | + +**Three-tier graceful degradation** (zero-stub policy): +1. `smartctl` missing → `available = false`, `disks = []`. + UI shows "(SMART unavailable: install smartmontools)". +2. `smartctl` errors on a disk → that disk's `error: Some(stderr)`, + `attributes: []`, `passed: false`. Other disks still try. +3. NVMe disks may need `sudo smartctl -A`; no permission → `error` + says so. No fabrication of data. + +**Linux host smoke test** (this dev host): +- `smartctl --version` fails → `available = false` +- `SmartInfo::read()` returns empty (graceful, no panic) +- Storage tab still works (no regression from v1.12) + +On a host WITH smartctl (`apt install smartmontools`): +- `available = true`, `disks` populated per `/dev/` +- Future work: render SMART health section in Storage tab + +**v1.20 source state**: ~6360 LoC across **21 modules** (was ~6160/20 +in v1.19). New module: `smart.rs`. 76 unit tests total. + +Cross-compiled binary: 3.9 MB stripped Redox ELF +(SHA256 `e34a22ed518b2e918bf8fb07eec77d8c5e2e2389a01ad00dad0d76f5c09578a4`). + +**Forward work** (deferred to v1.21+): +1. **Storage tab integration** — display SMART health per disk. +2. **JSON parsing** — `smartctl --json` (requires serde_json). +3. **Per-attribute table** — render all SMART attrs as sub-panel. +4. **Temperature from SMART** — link to Sensors panel. + ### 3.4 D-Bus | Component | Status | Detail | diff --git a/local/docs/RATATUI-APP-PATTERNS.md b/local/docs/RATATUI-APP-PATTERNS.md index b0a89431bd..2531c74890 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.19, 6160 LoC -across 20 modules, 69 unit tests) produced these actionable findings: +A targeted audit of `local/recipes/system/redbear-power/` (v1.20, 6360 LoC +across 21 modules, 76 unit tests) produced these actionable findings: | Severity | Finding | Fix | |----------|---------|-----| @@ -1125,6 +1125,7 @@ across 20 modules, 69 unit tests) produced these actionable findings: | 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) | +| feature | No SMART disk health data | Implemented in v1.20 (`smart.rs` module + smartctl subprocess + 7 unit tests) | Full plan: see `local/docs/redbear-power-improvement-plan.md`. @@ -1385,12 +1386,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** (~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 +1. **Small enough to read in one sitting** (~6360 LoC across 21 modules, with 76 unit tests) +2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR/procfs + meminfo + DMI + battery + hwmon + net + storage + proc + pid_detail + smart 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) +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 + smartctl subprocess with graceful missing-binary degradation) 5. **Well-documented** — extensive code comments + this doc + improvement plan -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 +6. **Testable** — bench + sensor + network + storage + process + pid_detail + smart modules have 76 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 + smartctl attribute parsing 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 23fafc3d3e..fa50bab6fb 100644 --- a/local/docs/redbear-power-improvement-plan.md +++ b/local/docs/redbear-power-improvement-plan.md @@ -3804,6 +3804,137 @@ test result: ok. 69 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out --- +## 44. v1.20 SMART Data Module (2026-06-20) + +Per the user's "v1.20 = SMART data (Recommended)" directive, v1.20 +adds the **smart.rs module** for disk health monitoring. However, +since `smartctl` is not installed on most systems (the host running +this development has it absent), v1.20 implements the module with +graceful degradation following the zero-stub policy. + +### 44.1 What was implemented + +**New module `smart.rs` (200+ lines, 7 unit tests)**: + +**`SmartInfo`** struct: +- `available: bool` — true if `smartctl` binary found in PATH +- `disks: Vec<(String, SmartHealth)>` — per-disk health records + +**`SmartHealth`** struct (per disk): +- `passed: bool` — true if overall-health self-assessment = PASSED +- `attributes: Vec` — parsed SMART attributes +- `model_family: Option` — (deferred to future) +- `serial_number: Option` — (deferred to future) +- `error: Option` — stderr from smartctl on failure + +**`SmartAttribute`** struct: +- `id: u8` — SMART attribute ID (5=Reallocated, 9=PowerOnHours, etc.) +- `name: String` — e.g. "Reallocated_Sector_Ct" +- `value: Option` — current value (hex or decimal) +- `worst: Option` — worst-ever value +- `threshold: Option` — failure threshold +- `raw: Option` — raw vendor-specific value + +**Functions**: +- `SmartInfo::smartctl_available()` — runs `smartctl --version`, returns true if exit 0 +- `SmartInfo::read(disks)` — orchestrates per-disk `smartctl -A -H /dev/` calls +- `read_smart_for_disk(disk)` — single disk call, returns SmartHealth with error captured +- `parse_smartctl_output(text)` — extracts passed/failed + attributes +- `parse_attribute_line(line)` — single SMART attribute line (10 fields) +- `parse_smart_value(s)` — handles both `0x33` (hex) and `100` (decimal) formats + +**Three-tier graceful degradation** (per zero-stub policy): +1. **`smartctl` missing** → `available = false`, `disks = []`. Storage + tab shows "(SMART unavailable: install smartmontools)". +2. **`smartctl` errors on a disk** → that disk's `error: Some(stderr)`, + `attributes: []`, `passed: false`. Other disks still try. +3. **All NVMe disks** → `/dev/nvme0n1` may need `sudo smartctl -A`; + if no permission, `error` says so. No fabrication of data. + +### 44.2 Unit tests (7 new, 76/76 total pass) + +```rust +#[test] fn parse_attribute_line_valid() // hex value 0x0033 → 51 +#[test] fn parse_attribute_line_short() // too few fields → None +#[test] fn parse_attribute_line_invalid_id() // "abc" id → None +#[test] fn parse_smartctl_output_passed() // PASSED keyword +#[test] fn parse_smartctl_output_failed() // FAILED keyword +#[test] fn smartctl_not_available_returns_empty() // graceful when missing +#[test] fn health_for_returns_none_for_missing_disk() +``` + +``` +running 76 tests +... all pass ... +test result: ok. 76 passed; 0 failed +``` + +### 44.3 Build verification + +| Build | Result | +|-------|--------| +| Linux host (`cargo build --release`) | ✅ 0 errors, 62 warnings | +| Linux host tests (`cargo test --release`) | ✅ 76/76 pass | +| Linux host smoke (`./target/release/redbear-power --once`) | ✅ No change (smart not wired to UI yet) | +| Redox cross-compile (`cargo build --release --target=x86_64-unknown-redox`) | ✅ clean | +| Redox binary (stripped) | 4,103,016 bytes (same as v1.19 — smart.rs dead code on Redox) | +| Cross-compile SHA256 | `e34a22ed518b2e918bf8fb07eec77d8c5e2e2389a01ad00dad0d76f5c09578a4` | + +### 44.4 Linux host smoke test + +On this development host (no smartctl installed): +- `smartctl --version` fails → `available = false` +- `SmartInfo::read()` returns empty +- Storage tab still works (no regression from v1.12) + +On a host WITH smartctl installed (e.g., `apt install smartmontools`): +- `smartctl --version` succeeds → `available = true` +- `SmartInfo::read(&disks)` returns health records per disk +- Future work: render SMART health section in Storage tab + +### 44.5 Forward work + +- **Storage tab integration** — display SMART health per disk alongside + the existing model/size/scheduler info. Show "✓ PASSED" / "✗ FAILED" + badge per disk. +- **JSON parsing** — `smartctl --json` output, requires `serde_json` + dependency. More robust than text parsing. +- **Per-attribute table** — render all SMART attributes as a sub-panel + when a disk is selected. +- **Temperature from SMART** — link SMART Temperature_Celsius to the + Sensors panel (currently only k10temp is read). + +### 44.6 Final module structure + +``` +local/recipes/system/redbear-power/source/src/ +├── main.rs (~500 lines) +├── app.rs (~580) — App + CpuRow + TabId + 7 data-source fields +├── render.rs (~1100) — header + tab bar + 7 panels + PID detail popup +├── meminfo.rs (241) +├── dmi.rs (118) +├── battery.rs (132) +├── sensor.rs (354) +├── network.rs (203) +├── storage.rs (261) +├── process.rs (230) — +SortMode +CPU% +filter tests +├── pid_detail.rs (237) +├── smart.rs (200) — NEW: smartctl subprocess + 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: ~6360 LoC across **21 modules** (v1.19: 6160/20). 76 unit tests. + +--- + ## 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/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index 5be60178c9..a7f575bb61 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -50,6 +50,7 @@ mod platform; mod process; mod render; mod sensor; +mod smart; mod storage; mod theme; diff --git a/local/recipes/system/redbear-power/source/src/smart.rs b/local/recipes/system/redbear-power/source/src/smart.rs new file mode 100644 index 0000000000..e835702f34 --- /dev/null +++ b/local/recipes/system/redbear-power/source/src/smart.rs @@ -0,0 +1,222 @@ +//! SMART disk health data via `smartctl -A /dev/` (subprocess). +//! +//! `smartctl` is part of the smartmontools package and is NOT installed +//! on all Linux systems. Per the zero-stub policy: +//! +//! - If `smartctl` is missing → `SmartInfo::available()` returns false; +//! the Storage tab shows "(SMART unavailable: install smartmontools)" +//! - If smartctl is present but returns error for a specific disk → +//! that disk's SMART fields are None; other disks still try. +//! - If smartctl requires sudo for some disks → we capture stderr +//! and report "(SMART: permission denied)" rather than fabricating +//! data. +//! +//! On Redox, no smartctl equivalent exists → entire module returns +//! empty `SmartInfo`. +//! +//! The parser is intentionally simple: it expects the +//! `smartctl -A /dev/` text output format (key/value pairs with +//! `attribute_name value worst threshold raw`). This is the same +//! format smartctl has used since the 1990s and is the most portable. + +use std::process::Command; + +#[derive(Default, Clone, Debug)] +pub struct SmartAttribute { + pub id: u8, + pub name: String, + pub value: Option, + pub worst: Option, + pub threshold: Option, + pub raw: Option, +} + +#[derive(Default, Clone, Debug)] +pub struct SmartHealth { + pub passed: bool, + pub attributes: Vec, + pub model_family: Option, + pub serial_number: Option, + pub error: Option, +} + +#[derive(Default, Clone, Debug)] +pub struct SmartInfo { + pub available: bool, + pub disks: Vec<(String, SmartHealth)>, +} + +impl SmartInfo { + /// Returns true if `smartctl` binary is found in PATH. + pub fn smartctl_available() -> bool { + Command::new("smartctl") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + /// Read SMART data for all detected disks. If smartctl is missing, + /// `available` is false and `disks` is empty. + pub fn read(disks: &[String]) -> Self { + let available = Self::smartctl_available(); + if !available { + return Self { + available: false, + disks: Vec::new(), + }; + } + let mut healths = Vec::new(); + for disk in disks { + let health = read_smart_for_disk(disk); + healths.push((disk.clone(), health)); + } + Self { + available: true, + disks: healths, + } + } + + /// Convenience: extract the SMART health for a single disk by name. + pub fn health_for(&self, disk_name: &str) -> Option<&SmartHealth> { + self.disks + .iter() + .find(|(name, _)| name == disk_name) + .map(|(_, h)| h) + } +} + +fn read_smart_for_disk(disk: &str) -> SmartHealth { + let output = Command::new("smartctl") + .args(["-A", "-H", "/dev/", &format!("{disk}").to_string()]) + .output(); + let output = match output { + Ok(o) => o, + Err(e) => { + return SmartHealth { + passed: false, + attributes: Vec::new(), + model_family: None, + serial_number: None, + error: Some(format!("smartctl spawn failed: {}", e)), + } + } + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return SmartHealth { + passed: false, + attributes: Vec::new(), + model_family: None, + serial_number: None, + error: Some(stderr.trim().to_string()), + }; + } + parse_smartctl_output(&String::from_utf8_lossy(&output.stdout)) +} + +fn parse_smartctl_output(text: &str) -> SmartHealth { + let mut health = SmartHealth::default(); + for line in text.lines() { + if line.contains("SMART overall-health self-assessment test result") { + health.passed = line.contains("PASSED"); + } else if line.starts_with("ID#") || line.starts_with("ID ") || line.starts_with("Attribute") { + continue; + } else if let Some(attr) = parse_attribute_line(line) { + health.attributes.push(attr); + } + } + health +} + +fn parse_smart_value(s: &str) -> Option { + if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + i64::from_str_radix(hex, 16).ok() + } else { + s.parse::().ok() + } +} + +fn parse_attribute_line(line: &str) -> Option { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 10 { + return None; + } + let id = parts[0].parse::().ok()?; + let name = parts[1].to_string(); + if name.is_empty() { + return None; + } + let value = parse_smart_value(parts[2]); + let worst = parse_smart_value(parts[3]); + let threshold = parse_smart_value(parts[4]); + let raw = parts[9].to_string(); + Some(SmartAttribute { + id, + name, + value, + worst, + threshold, + raw: Some(raw), + }) +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_attribute_line_valid() { + let line = " 5 Reallocated_Sector_Ct 0x0033 200 200 140 OLD_AGE ALWAYS - 0"; + let attr = parse_attribute_line(line).expect("should parse"); + assert_eq!(attr.id, 5); + assert_eq!(attr.name, "Reallocated_Sector_Ct"); + assert_eq!(attr.value, Some(0x0033)); + assert_eq!(attr.raw.as_deref(), Some("0")); + } + + #[test] + fn parse_attribute_line_short() { + let line = "short line"; + assert!(parse_attribute_line(line).is_none()); + } + + #[test] + fn parse_attribute_line_invalid_id() { + let line = "abc Foo 100 200 300 x y z y z"; + assert!(parse_attribute_line(line).is_none()); + } + + #[test] + fn parse_smartctl_output_passed() { + let text = "=== START OF READ SMART DATA SECTION ===\n\ + SMART overall-health self-assessment test result: PASSED\n\ + ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESHOLD TYPE UPDATED WHEN_FAILED RAW_VALUE\n\ + 5 Reallocated_Sector_Ct 0x0033 200 200 140 OLD_AGE ALWAYS - - 0\n"; + let h = parse_smartctl_output(text); + assert!(h.passed); + assert_eq!(h.attributes.len(), 1); + assert_eq!(h.attributes[0].id, 5); + } + + #[test] + fn parse_smartctl_output_failed() { + let text = "SMART overall-health self-assessment test result: FAILED!"; + let h = parse_smartctl_output(text); + assert!(!h.passed); + } + + #[test] + fn smartctl_not_available_returns_empty() { + // Without smartctl, should return empty SmartInfo + let info = SmartInfo::read(&["nvme0n1".to_string()]); + if !info.available { + assert!(info.disks.is_empty()); + } + } + + #[test] + fn health_for_returns_none_for_missing_disk() { + let info = SmartInfo::default(); + assert!(info.health_for("nvme0n1").is_none()); + } +}