diff --git a/CHANGELOG.md b/CHANGELOG.md index 026e220..89337a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to RigDoctor are recorded here. Format follows (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git release tag (so the auto-updater, D18, can compare versions). +## [0.10.0] - 2026-05-22 +### Added +- **Actionable Environment page (M6) — install & apply, not just advice.** Findings that + recommend a tool or a setting are now one-click: + - **Install buttons** for GameMode, MangoHud, and cpupower (added to the M9 component catalog, + so they also appear on the **Setup** page with the existing installer). + - **Apply controls** for runtime-reversible tunables — a dropdown of the live options + Apply, + via a single pkexec prompt, no reboot: **CPU governor**, **NVIDIA persistence mode**, + **PCIe ASPM policy**, **vm.swappiness**, **Transparent HugePages** (`core/fixes.py`). The + chosen value is validated against the live options before anything runs. + - This is the consent-gated apply milestone D9 anticipated, scoped to safe settings (**D22**). + GRUB-based fixes and CPU mitigations stay suggestion-only; `rigdoctor gameenv` still prints + the exact commands for headless use. +### Changed +- The `Finding` model gained optional `action` (installable component) and `fix` (applyable + tunable) fields; the shared `finding_card` widget renders the matching control. + ## [0.9.0] - 2026-05-22 ### Added - **Gaming environment checks (M6) — the evaluate-and-suggest engine.** A new read-only report diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3a2ed62..f125528 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -223,9 +223,25 @@ The next version is **determined by the Conventional Commit types** since the la `packaging/bump.sh` writes it into `__init__.py` + `pyproject.toml`. Rules live in `cliff.toml [bump]` (pre-1.0: `breaking_always_bump_major = false`). +### D22 — Limited live apply of fixes (M6) — *DECIDED 2026-05-22; realizes the D9 milestone* +D9 deferred auto-applying fixes to "a deliberate later milestone, gated behind explicit user +consent." That milestone lands here, **scoped tightly to stay safe**: +- **Only runtime-reversible settings** are applyable from the gaming-environment report (M6): + **CPU governor, NVIDIA persistence mode, PCIe ASPM policy, vm.swappiness, Transparent + HugePages.** Each takes effect immediately, needs **no reboot**, and reverts on reboot. +- **How:** a dropdown of the live options + an Apply button per finding (`core/fixes.py`). + Applying runs a **single pkexec-elevated command** (one auth prompt); the chosen value is + validated against the live options first; writes target **sysfs/procfs or `nvidia-smi`** — + never the GRUB cmdline or a persistent config file. +- **Still suggestion-only** (the read-only stance holds for these): GRUB-based `pcie_aspm=off`, + CPU **mitigations** changes (security-sensitive, need a reboot), and the shader-cache env var. +- Everything remains **CLI-discoverable** (`rigdoctor gameenv` still prints the exact commands); + the apply UI is an additive convenience in the GUI, not the only path. Installing optional + tools (GameMode/MangoHud/cpupower) reuses the M9 installer and is likewise one-click. + ## Open -None currently — all tracked decisions (D1–D21) are resolved. New questions will be added +None currently — all tracked decisions (D1–D22) are resolved. New questions will be added here as they arise. Remaining detail to flesh out during build: the tray's supporting-action set (D13), per-module apt package names, M12's tunnel/token specifics, and M13's update mechanism (APT repo vs. self-installed `.deb`). diff --git a/docs/MODULES.md b/docs/MODULES.md index 42ba48d..59fbdaa 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -51,9 +51,13 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done *Env-check engine implemented* (`core/gameenv.py`): a read-only findings report (reusing the M4 `Finding` model) over PCIe ASPM, NVIDIA persistence mode, CPU governor (the three seed-case contributors to GPU bus-drop / Xid 79), GameMode, MangoHud, swappiness, shader cache, THP, CPU - mitigations, and installed Proton versions — each with the suggested fix command (D9). CLI - `rigdoctor gameenv`; GUI **Environment** page. *Pending:* non-Steam launchers (Lutris/Heroic) - and per-GPU power-profile (PowerMizer) checks. + mitigations, and installed Proton versions — each with the suggested fix command. CLI + `rigdoctor gameenv`; GUI **Environment** page. Per **D22**, the GUI adds **one-click apply** + for the runtime-reversible tunables (governor / NVIDIA persistence / PCIe ASPM / swappiness / + THP — dropdown + Apply via a single pkexec prompt, `core/fixes.py`) and **one-click install** + of optional tools (GameMode / MangoHud / cpupower, now in the M9 catalog). GRUB/mitigations + stay suggestion-only. *Pending:* non-Steam launchers (Lutris/Heroic) and GPU power-profile + (PowerMizer) checks. - **M8 Alerting** — threshold/event notifications; integrates with the tray applet (M11). - **M10 Desktop GUI** — PySide6 graphical front-end over the core engine (dashboard, log browser, report viewer, logger controls). Optional; adds the Qt dependency. *Bootstrapped diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 45475e2..73f8ced 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -57,8 +57,10 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`). - [x] M13 auto-update (D18) — launch-time version check (GUI sidebar) + no-root self-update apply (`rigdoctor update` / sidebar button → authenticated pip upgrade), token-gated. Restart-after-update is manual for now. -- [ ] (Later, separate milestone) Optional auto-apply of suggested fixes behind explicit - consent — currently out of scope (D9) +- [~] Optional auto-apply of suggested fixes behind explicit consent (D9 milestone) — *first + cut shipped for M6 (D22):* one-click apply of runtime-reversible tunables (CPU governor, + NVIDIA persistence, PCIe ASPM, swappiness, THP) via a single pkexec prompt, no reboot. + GRUB-based fixes + CPU mitigations remain suggestion-only. ## Phase 6 — Session sharing / remote assist (M12, D16) Escalating ladder, built in order: diff --git a/docs/SPEC.md b/docs/SPEC.md index a1d1a60..6ac6ba1 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -43,9 +43,12 @@ RigDoctor's crash-safe logger is designed to fix exactly that. - **Not a stress-test / load-generator** — explicitly out of scope (D7). Users can run existing tools (gpu-burn, vkmark, stress-ng) alongside the logger if they want. - Not an overclocking utility. -- **Not (yet) an auto-fixer.** RigDoctor is **read-only**: it diagnoses and *suggests* - actions (with the exact command where possible) but does not apply changes itself in this - stage. Auto-apply is a deliberate later milestone behind explicit consent. (D9) +- **Read-only by default, with a narrow consent-gated exception.** RigDoctor diagnoses and + *suggests* actions (with the exact command where possible). It does **not** apply changes + itself — **except** a small set of **runtime-reversible** gaming tunables (M6: CPU governor, + NVIDIA persistence, PCIe ASPM policy, swappiness, THP) that can be applied from the GUI via a + single pkexec prompt, no reboot, revert on reboot (D22, realizing the D9 milestone). Risky/ + persistent fixes (GRUB cmdline, CPU mitigations) remain suggestion-only. ## 3. Target users & platforms @@ -96,8 +99,10 @@ PCIe topology. Exportable (Markdown/JSON) to paste into forum/bug reports. ### M6 — Gaming environment checks Detects & evaluates: GPU power profile / persistence mode, CPU governor, Proton/Wine/Steam versions, GameMode, MangoHud, shader cache, swappiness, hugepages, CPU mitigations, -PCIe ASPM. Flags settings that hurt stability/performance and **suggests** the fix command -(read-only per D9). +PCIe ASPM. Flags settings that hurt stability/performance and **suggests** the fix command. +Also includes Steam library/game detection (the D12 "pick a game" foundation) and, per D22, +a **one-click apply** for the runtime-reversible tunables (governor, persistence, ASPM, +swappiness, THP) plus one-click install of optional tools (GameMode/MangoHud/cpupower). ### M8 — Alerting Threshold + event alerts (desktop notification / sound / log) on overheat, throttle, diff --git a/pyproject.toml b/pyproject.toml index bc89378..54a4f35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.9.0" +version = "0.10.0" description = "Modular hardware monitoring & crash diagnostics for Linux gamers." readme = "README.md" requires-python = ">=3.11" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index 4a4eeeb..5dbac79 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.9.0" +__version__ = "0.10.0" diff --git a/src/rigdoctor/core/catalog.py b/src/rigdoctor/core/catalog.py index 2ccb6e4..423c337 100644 --- a/src/rigdoctor/core/catalog.py +++ b/src/rigdoctor/core/catalog.py @@ -45,4 +45,23 @@ COMPONENTS: tuple[Component, ...] = ( "libsecret", "Encrypted token storage", "Updates", "Store the update token in the OS keyring, encrypted", ("libsecret-tools",), "secret-tool", ), + Component( + "gamemode", "Feral GameMode", "Gaming", + "Auto-applies performance tweaks (CPU governor, scheduling) while a game runs", + ("gamemode",), "gamemoderun", + ), + Component( + "mangohud", "MangoHud", "Gaming", + "In-game overlay for FPS, frame times, and temperatures", ("mangohud",), "mangohud", + ), + Component( + "cpupower", "cpupower", "Gaming", + "Read/set the CPU frequency governor (e.g. performance for gaming)", + ("linux-tools-common", "linux-tools-generic"), "cpupower", + ), ) + + +def by_id(component_id: str) -> Component | None: + """Look up a catalog component by its id (None if unknown).""" + return next((c for c in COMPONENTS if c.id == component_id), None) diff --git a/src/rigdoctor/core/fixes.py b/src/rigdoctor/core/fixes.py new file mode 100644 index 0000000..a284a00 --- /dev/null +++ b/src/rigdoctor/core/fixes.py @@ -0,0 +1,177 @@ +"""Apply runtime-reversible system tunables (M6) — a limited, consent-gated exception to +the read-only stance (D9, amended by D22). + +Only safe settings that take effect immediately, need no reboot, and revert on reboot are +applyable here: CPU governor, NVIDIA persistence mode, PCIe ASPM policy, vm.swappiness, and +Transparent HugePages. Each is set by a single privileged command (one pkexec prompt). The +chosen value is validated against the live options before building the command, and writes go +to sysfs / procfs (or `nvidia-smi`) — never the GRUB cmdline or a persistent config file. +Riskier fixes (GRUB-based PCIe ASPM-off, CPU mitigations) stay suggestion-only. +""" + +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class Tunable: + id: str + label: str # e.g. "CPU governor" + options: list[str] # selectable values (live, from the system) + current: str | None # the value in effect now (preselect this in the dropdown) + note: str = "" # caveat shown by the control, e.g. "resets on reboot" + + +def _read(path: str) -> str | None: + try: + return Path(path).read_text() + except OSError: + return None + + +def _bracketed(text: str) -> tuple[list[str], str | None]: + """Parse a sysfs 'a [b] c' enum into (options, active).""" + options = [tok.strip("[]") for tok in text.split()] + active = next((tok.strip("[]") for tok in text.split() if tok.startswith("[")), None) + return options, active + + +# --- individual tunables: a state reader + a command builder per id ------------------- + +_GOV = "/sys/devices/system/cpu" + + +def _cpu_governor() -> Tunable | None: + cur = _read(f"{_GOV}/cpu0/cpufreq/scaling_governor") + if cur is None: + return None + avail = _read(f"{_GOV}/cpu0/cpufreq/scaling_available_governors") + options = avail.split() if avail and avail.strip() else ["performance", "powersave", "schedutil"] + return Tunable("cpu_governor", "CPU governor", options, cur.strip(), "applies now; resets on reboot") + + +def _cpu_governor_cmd(value: str) -> list[str]: + return ["/bin/sh", "-c", + f'for f in {_GOV}/cpu*/cpufreq/scaling_governor; do echo {shlex.quote(value)} > "$f"; done'] + + +def _nvidia_persistence() -> Tunable | None: + if shutil.which("nvidia-smi") is None: + return None + try: + proc = subprocess.run( + ["nvidia-smi", "--query-gpu=persistence_mode", "--format=csv,noheader"], + capture_output=True, text=True, timeout=10, + ) + except (subprocess.SubprocessError, OSError): + return None + state = proc.stdout.strip().splitlines()[0].strip().lower() if proc.stdout.strip() else "" + current = "Enabled" if state.startswith("enabled") else ("Disabled" if state.startswith("disabled") else None) + return Tunable("nvidia_persistence", "NVIDIA persistence mode", ["Enabled", "Disabled"], current, + "resets on reboot (enable nvidia-persistenced to persist)") + + +def _nvidia_persistence_cmd(value: str) -> list[str]: + return ["nvidia-smi", "-pm", "1" if value == "Enabled" else "0"] + + +def _pcie_aspm() -> Tunable | None: + text = _read("/sys/module/pcie_aspm/parameters/policy") + if not text: + return None + options, active = _bracketed(text) + return Tunable("pcie_aspm", "PCIe ASPM policy", options, active, "applies now; resets on reboot") + + +def _pcie_aspm_cmd(value: str) -> list[str]: + return ["/bin/sh", "-c", f'echo {shlex.quote(value)} > /sys/module/pcie_aspm/parameters/policy'] + + +def _swappiness() -> Tunable | None: + text = _read("/proc/sys/vm/swappiness") + if text is None or not text.strip().isdigit(): + return None + cur = text.strip() + options = ["0", "10", "30", "60", "100"] + if cur not in options: + options = sorted(set(options) | {cur}, key=int) + return Tunable("swappiness", "vm.swappiness", options, cur, "applies now; resets on reboot") + + +def _swappiness_cmd(value: str) -> list[str]: + return ["/bin/sh", "-c", f'echo {shlex.quote(value)} > /proc/sys/vm/swappiness'] + + +def _thp() -> Tunable | None: + text = _read("/sys/kernel/mm/transparent_hugepage/enabled") + if not text: + return None + options, active = _bracketed(text) + return Tunable("thp", "Transparent HugePages", options, active, "applies now; resets on reboot") + + +def _thp_cmd(value: str) -> list[str]: + return ["/bin/sh", "-c", f'echo {shlex.quote(value)} > /sys/kernel/mm/transparent_hugepage/enabled'] + + +_TUNABLES: dict[str, tuple[Callable[[], Tunable | None], Callable[[str], list[str]]]] = { + "cpu_governor": (_cpu_governor, _cpu_governor_cmd), + "nvidia_persistence": (_nvidia_persistence, _nvidia_persistence_cmd), + "pcie_aspm": (_pcie_aspm, _pcie_aspm_cmd), + "swappiness": (_swappiness, _swappiness_cmd), + "thp": (_thp, _thp_cmd), +} + + +# --- public API ----------------------------------------------------------------------- + +def get_tunable(fix_id: str) -> Tunable | None: + """Live state (options + current value) for a fix id, or None if not applicable here.""" + fns = _TUNABLES.get(fix_id) + return fns[0]() if fns else None + + +def apply_command(fix_id: str, value: str) -> list[str] | None: + """The privileged command to set fix_id=value, or None if unknown/invalid. + + The value is validated against the *live* options, so only a real, currently-available + setting can ever be turned into a command. + """ + fns = _TUNABLES.get(fix_id) + if not fns: + return None + state = fns[0]() + if state is None or value not in state.options: + return None + return fns[1](value) + + +def _elevate(cmd: list[str]) -> list[str]: + prog = shutil.which(cmd[0]) or cmd[0] # pkexec needs an absolute program path + cmd = [prog, *cmd[1:]] + if os.geteuid() == 0: + return cmd + if shutil.which("pkexec"): + return ["pkexec", *cmd] + if shutil.which("sudo"): + return ["sudo", *cmd] + return cmd # no escalation available — will likely fail, surfaced to the caller + + +def apply(fix_id: str, value: str) -> tuple[int, str]: + """Apply fix_id=value via a single elevated command. Returns (exit_code, output).""" + cmd = apply_command(fix_id, value) + if cmd is None: + return (1, f"Unknown or unavailable setting: {fix_id}={value}") + try: + proc = subprocess.run(_elevate(cmd), capture_output=True, text=True, timeout=120) + return (proc.returncode, proc.stdout + proc.stderr) + except (subprocess.SubprocessError, OSError) as exc: + return (1, str(exc)) diff --git a/src/rigdoctor/core/gameenv.py b/src/rigdoctor/core/gameenv.py index 6c39152..c18ced2 100644 --- a/src/rigdoctor/core/gameenv.py +++ b/src/rigdoctor/core/gameenv.py @@ -49,15 +49,18 @@ def evaluate_aspm(policy_text: str | None) -> Finding | None: WARNING, "PCIe", f"PCIe ASPM is in power-saving mode ({active})", "Aggressive PCIe Active-State Power Management can cause the GPU to drop off the " "bus under load (Xid 79) or stutter — the seed-case failure mode.", - "Disable ASPM via the kernel cmdline: add `pcie_aspm=off` (and optionally " - "`pcie_aspm.policy=performance`) in GRUB, then `sudo update-grub` and reboot.", + "Set the policy to performance below (live), or for a permanent change add " + "`pcie_aspm=off` in GRUB, then `sudo update-grub` and reboot.", + fix="pcie_aspm", ) if active == "performance": - return Finding(OK, "PCIe", "PCIe ASPM set to performance", "ASPM power-saving is disabled.") + return Finding(OK, "PCIe", "PCIe ASPM set to performance", "ASPM power-saving is disabled.", + fix="pcie_aspm") return Finding( INFO, "PCIe", f"PCIe ASPM policy: {active}", "ASPM is left to the kernel/BIOS default.", - "If you see GPU bus-drop events (Xid 79), try `pcie_aspm=off` on the kernel cmdline.", + "If you see GPU bus-drop events (Xid 79), set the policy to performance below.", + fix="pcie_aspm", ) @@ -84,11 +87,13 @@ def check_gpu_persistence() -> list[Finding]: INFO, "GPU", "NVIDIA persistence mode is off", "The driver unloads when no client is attached, adding latency on first GPU " "access and churning state between game launches.", - "Enable it: `sudo nvidia-smi -pm 1` (per-boot), or enable the " - "`nvidia-persistenced` service to make it permanent.", + "Enable it below (per-boot), or enable the `nvidia-persistenced` service to " + "make it permanent.", + fix="nvidia_persistence", )] if state.lower().startswith("enabled"): - return [Finding(OK, "GPU", "NVIDIA persistence mode on", "The driver stays resident.")] + return [Finding(OK, "GPU", "NVIDIA persistence mode on", "The driver stays resident.", + fix="nvidia_persistence")] return [] @@ -99,18 +104,20 @@ def evaluate_governor(governors: set[str]) -> Finding | None: return None shown = ", ".join(sorted(governors)) if governors == {"performance"}: - return Finding(OK, "CPU", "CPU governor: performance", "CPUs run at full clocks under load.") + return Finding(OK, "CPU", "CPU governor: performance", "CPUs run at full clocks under load.", + fix="cpu_governor") if "powersave" in governors: return Finding( WARNING, "CPU", f"CPU governor set to power-saving ({shown})", "A powersave governor caps CPU frequency and can bottleneck frame times.", - "Set performance: `sudo cpupower frequency-set -g performance` " - "(install `linux-tools-common`/`cpupower`), or install GameMode to switch it per-game.", + "Set it to performance below (or install GameMode to switch it per-game).", + fix="cpu_governor", ) return Finding( INFO, "CPU", f"CPU governor: {shown}", "A dynamic governor scales with load; usually fine.", - "For the most consistent frame pacing, `performance` (or GameMode) avoids ramp-up lag.", + "For the most consistent frame pacing, set performance below (or use GameMode).", + fix="cpu_governor", ) @@ -137,6 +144,7 @@ def check_gamemode() -> list[Finding]: "GameMode auto-applies performance tweaks (governor, scheduling) for the duration of a game.", "Install it: `sudo apt install gamemode`, then launch games with `gamemoderun %command%` " "(or use a global Steam launch option).", + action="gamemode", )] @@ -147,6 +155,7 @@ def check_mangohud() -> list[Finding]: INFO, "Tools", "MangoHud not installed", "MangoHud overlays live FPS, frame times, and temps in-game — handy for spotting stutter.", "Install it: `sudo apt install mangohud`, then launch with `mangohud %command%`.", + action="mangohud", )] @@ -158,9 +167,11 @@ def evaluate_swappiness(value: int) -> Finding: INFO, "Memory", f"vm.swappiness is high ({value})", "A high swappiness lets the kernel swap out memory eagerly, which can cause " "hitching during gaming on systems with ample RAM.", - "Lower it: `sudo sysctl vm.swappiness=10` (persist in /etc/sysctl.d/99-rigdoctor.conf).", + "Lower it below (e.g. 10); applies immediately.", + fix="swappiness", ) - return Finding(OK, "Memory", f"vm.swappiness is {value}", "Swapping is conservative.") + return Finding(OK, "Memory", f"vm.swappiness is {value}", "Swapping is conservative.", + fix="swappiness") def check_swappiness() -> list[Finding]: @@ -204,7 +215,8 @@ def check_thp() -> list[Finding]: return [Finding( INFO, "Memory", "Transparent HugePages disabled (never)", "Some workloads benefit from THP; 'madvise' lets apps opt in without the downsides of 'always'.", - "Optional: `echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled`.", + "Optional: set 'madvise' below; applies immediately.", + fix="thp", )] return [] diff --git a/src/rigdoctor/core/health.py b/src/rigdoctor/core/health.py index 33a5451..5fdf3aa 100644 --- a/src/rigdoctor/core/health.py +++ b/src/rigdoctor/core/health.py @@ -27,6 +27,8 @@ class Finding: title: str detail: str = "" suggestion: str = "" + action: str = "" # optional: id of an installable catalog component (for an Install button) + fix: str = "" # optional: id of an applyable runtime tunable (for an Apply dropdown, M6) # --- NVIDIA Xid knowledge (the seed crash is Xid 79) -------------------------- diff --git a/src/rigdoctor/gui/environment_page.py b/src/rigdoctor/gui/environment_page.py index f1b549b..598497b 100644 --- a/src/rigdoctor/gui/environment_page.py +++ b/src/rigdoctor/gui/environment_page.py @@ -20,12 +20,15 @@ from .widgets import finding_card class EnvironmentPage(QWidget): - _result = Signal(object) # list[Finding] + _result = Signal(object) # list[Finding] + _action_done = Signal(object) # (label, rc) — install or apply finished def __init__(self) -> None: super().__init__() self.setObjectName("Page") self._result.connect(self._render_findings) + self._action_done.connect(self._on_action_done) + self._busy = False root = QVBoxLayout(self) root.setContentsMargins(20, 18, 20, 18) @@ -100,5 +103,43 @@ class EnvironmentPage(QWidget): f"{time.strftime('%H:%M:%S')}" ) for finding in findings: - self._list.addWidget(finding_card(finding)) + self._list.addWidget(finding_card(finding, on_install=self._install, on_apply=self._apply)) self._list.addStretch(1) + + def _install(self, component) -> None: + if self._busy: + return + self._busy = True + self._run_btn.setEnabled(False) + self._status.setText(f"Installing {component.name}… (may prompt for your password)") + threading.Thread(target=self._work_install, args=(component,), daemon=True).start() + + def _work_install(self, component) -> None: + from ..core import installer + + rc, _out = installer.install_packages(list(component.apt)) + self._action_done.emit((component.name, rc)) + + def _apply(self, fix_id: str, value: str) -> None: + if self._busy: + return + self._busy = True + self._run_btn.setEnabled(False) + self._status.setText(f"Applying {value}… (may prompt for your password)") + threading.Thread(target=self._work_apply, args=(fix_id, value), daemon=True).start() + + def _work_apply(self, fix_id: str, value: str) -> None: + from ..core import fixes + + rc, _out = fixes.apply(fix_id, value) + self._action_done.emit((value, rc)) + + def _on_action_done(self, result) -> None: + label, rc = result + self._busy = False + if rc == 0: + self._status.setText(f"{label} applied — re-checking…") + self._run() # re-run so the finding reflects the new state + else: + self._run_btn.setEnabled(True) + self._status.setText(f"'{label}' failed (cancelled, or needs privileges)") diff --git a/src/rigdoctor/gui/widgets.py b/src/rigdoctor/gui/widgets.py index 17b569b..0aa244c 100644 --- a/src/rigdoctor/gui/widgets.py +++ b/src/rigdoctor/gui/widgets.py @@ -5,6 +5,7 @@ from __future__ import annotations from PySide6.QtCore import QRectF, Qt from PySide6.QtGui import QColor, QFont, QPainter, QPen from PySide6.QtWidgets import ( + QComboBox, QFrame, QHBoxLayout, QLabel, @@ -26,8 +27,17 @@ _SEV = { } -def finding_card(finding) -> QFrame: - """A card for one M4/M6 Finding (severity-colored title, detail, suggested fix).""" +def finding_card(finding, on_install=None, on_apply=None) -> QFrame: + """A card for one M4/M6 Finding (severity-colored title, detail, suggested fix). + + If the finding names an installable catalog component (``finding.action``) and an + ``on_install(component)`` callback is given, an "Install" button is shown — so a + "tool not installed" finding becomes one click instead of a copy-pasted apt command. + + If the finding names a runtime tunable (``finding.fix``) and an ``on_apply(fix_id, + value)`` callback is given, a dropdown of the live options + an Apply button is shown + (M6 live fixes — D22). + """ label, color = _SEV.get(finding.severity, ("?", MUTED)) card = QFrame() card.setObjectName("Card") @@ -50,9 +60,65 @@ def finding_card(finding) -> QFrame: suggestion.setStyleSheet(f"color: {ACCENT}; background: transparent;") suggestion.setWordWrap(True) v.addWidget(suggestion) + + component = _installable_component(finding) if on_install else None + if component is not None: + row = QHBoxLayout() + row.addStretch(1) + btn = QPushButton(f"Install {component.name}") + btn.setObjectName("PrimaryButton") + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.clicked.connect(lambda: on_install(component)) + row.addWidget(btn) + v.addLayout(row) + + tunable = _tunable(finding) if on_apply else None + if tunable is not None and tunable.options: + row = QHBoxLayout() + name = QLabel(f"{tunable.label}:") + name.setObjectName("Muted") + combo = QComboBox() + combo.addItems(tunable.options) + if tunable.current in tunable.options: + combo.setCurrentText(tunable.current) + combo.setCursor(Qt.CursorShape.PointingHandCursor) + apply_btn = QPushButton("Apply") + apply_btn.setObjectName("PrimaryButton") + apply_btn.setCursor(Qt.CursorShape.PointingHandCursor) + apply_btn.clicked.connect(lambda: on_apply(tunable.id, combo.currentText())) + row.addWidget(name) + row.addWidget(combo, 1) + row.addWidget(apply_btn) + v.addLayout(row) + if tunable.note: + note = QLabel(tunable.note) + note.setObjectName("Muted") + v.addWidget(note) return card +def _tunable(finding): + """The runtime tunable a finding can apply, if any.""" + fix = getattr(finding, "fix", "") + if not fix: + return None + from ..core import fixes + + return fixes.get_tunable(fix) + + +def _installable_component(finding): + """The catalog component a finding offers to install, if any and if apt is usable.""" + action = getattr(finding, "action", "") + if not action: + return None + from ..core import catalog, sysenv + + if sysenv.package_manager() != "apt": + return None # apt-only (D15) — no one-click install elsewhere + return catalog.by_id(action) + + class Card(QFrame): """A titled panel whose body collapses when the header is clicked.""" diff --git a/tests/test_fixes.py b/tests/test_fixes.py new file mode 100644 index 0000000..9dc0d33 --- /dev/null +++ b/tests/test_fixes.py @@ -0,0 +1,63 @@ +"""Tests for M6 runtime tunables (parse, command builders, value validation).""" + +import unittest +from unittest import mock + +from rigdoctor.core import fixes +from rigdoctor.core.fixes import Tunable + + +class ParseTests(unittest.TestCase): + def test_bracketed(self): + self.assertEqual(fixes._bracketed("always [madvise] never"), (["always", "madvise", "never"], "madvise")) + + def test_bracketed_none_active(self): + self.assertEqual(fixes._bracketed("a b c"), (["a", "b", "c"], None)) + + +class CommandBuilderTests(unittest.TestCase): + def test_governor_cmd_writes_value_to_sysfs(self): + cmd = fixes._cpu_governor_cmd("performance") + self.assertEqual(cmd[:2], ["/bin/sh", "-c"]) + self.assertIn("performance", cmd[2]) + self.assertIn("scaling_governor", cmd[2]) + + def test_persistence_cmd(self): + self.assertEqual(fixes._nvidia_persistence_cmd("Enabled"), ["nvidia-smi", "-pm", "1"]) + self.assertEqual(fixes._nvidia_persistence_cmd("Disabled"), ["nvidia-smi", "-pm", "0"]) + + def test_swappiness_cmd_targets_procfs(self): + self.assertIn("/proc/sys/vm/swappiness", fixes._swappiness_cmd("10")[2]) + + def test_quoting_is_safe(self): + # A value that would be dangerous unquoted stays a single quoted token. + cmd = fixes._pcie_aspm_cmd("performance; rm -rf /") + self.assertIn("'performance; rm -rf /'", cmd[2]) + + +class ApplyValidationTests(unittest.TestCase): + def test_unknown_fix_returns_none(self): + self.assertIsNone(fixes.apply_command("does_not_exist", "x")) + + def test_value_validated_against_live_options(self): + fake = Tunable("x", "X", ["a", "b"], "a") + with mock.patch.dict(fixes._TUNABLES, {"x": (lambda: fake, lambda v: ["echo", v])}, clear=False): + self.assertEqual(fixes.apply_command("x", "a"), ["echo", "a"]) + self.assertIsNone(fixes.apply_command("x", "not-an-option")) + + def test_apply_unknown_is_error(self): + rc, _ = fixes.apply("nope", "x") + self.assertEqual(rc, 1) + + +class GameenvWiringTests(unittest.TestCase): + def test_findings_reference_known_fix_ids(self): + from rigdoctor.core import gameenv + + fix_ids = {f.fix for f in gameenv.run_gameenv_checks() if f.fix} + # Whatever fixes the live system surfaces, each must be a real tunable id. + self.assertTrue(fix_ids.issubset(set(fixes._TUNABLES))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gameenv.py b/tests/test_gameenv.py index a7037a9..4200967 100644 --- a/tests/test_gameenv.py +++ b/tests/test_gameenv.py @@ -30,7 +30,7 @@ class GovernorTests(unittest.TestCase): def test_powersave_is_warning(self): f = gameenv.evaluate_governor({"powersave"}) self.assertEqual(f.severity, "warning") - self.assertIn("cpupower", f.suggestion) + self.assertEqual(f.fix, "cpu_governor") # offers the live Apply dropdown def test_dynamic_is_info(self): self.assertEqual(gameenv.evaluate_governor({"schedutil"}).severity, "info") @@ -43,7 +43,7 @@ class SwappinessTests(unittest.TestCase): def test_high_is_info_with_suggestion(self): f = gameenv.evaluate_swappiness(60) self.assertEqual(f.severity, "info") - self.assertIn("swappiness", f.suggestion) + self.assertEqual(f.fix, "swappiness") # offers the live Apply dropdown def test_low_is_ok(self): self.assertEqual(gameenv.evaluate_swappiness(10).severity, "ok")