diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b66ca..026e220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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.9.0] - 2026-05-22 +### Added +- **Gaming environment checks (M6) — the evaluate-and-suggest engine.** A new read-only report + (D9) that flags system settings which hurt gaming stability/performance and gives the exact fix + command. Checks: **PCIe ASPM**, **NVIDIA persistence mode**, **CPU governor** (the three that + map to the seed-case GPU bus-drop / Xid 79), GameMode, MangoHud, `vm.swappiness`, shader disk + cache, Transparent HugePages, CPU mitigations, and installed Proton versions. + - **CLI:** `rigdoctor gameenv` (text or `--json`). + - **GUI:** a new **Environment** page (findings cards, auto-runs on open), reusing the M4 + health-report card style via a shared `finding_card` widget. +### Fixed +- **Notification icon** now uses the RigDoctor icon (matching the app/dock) instead of a generic + stock icon — resolved from the installed icon theme, the bundled asset, then a stock fallback. + ## [0.8.0] - 2026-05-22 ### Added - **Gaming environment checks (M6) — Steam game detection.** RigDoctor now finds your Steam diff --git a/docs/MODULES.md b/docs/MODULES.md index 17767f1..42ba48d 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -48,8 +48,12 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done config); the GUI **Games** page lists them with per-library counts and rescans in the background on every launch, badging games installed since the last scan (cached in `state/games.json`). CLI: `rigdoctor games` / `games libraries [--enable|--disable|--all]`. - *Pending:* the env-check probes (CPU governor, GPU persistence, GameMode/MangoHud, swappiness, - hugepages, mitigations, PCIe ASPM) and non-Steam launchers (Lutris/Heroic). + *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. - **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 2308806..45475e2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,8 +30,10 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`). - [~] M6 gaming environment checks (suggest-only) — *Steam game/library detection done* (multi-library `libraryfolders.vdf` discovery + `appmanifest` scan, opt-in libraries, launch-time background rescan with new-game badge; CLI `rigdoctor games`, GUI Games page). - This is also the D12 "pick a game" foundation. *Pending:* the env-check probes (governor, - GPU persistence, GameMode/MangoHud, swappiness, hugepages, mitigations, PCIe ASPM). + This is also the D12 "pick a game" foundation. *Env-check engine done* (`rigdoctor gameenv` + + GUI Environment page): PCIe ASPM, NVIDIA persistence, CPU governor, GameMode, MangoHud, + swappiness, shader cache, THP, mitigations, Proton versions — read-only with fix commands. + *Pending:* non-Steam launchers (Lutris/Heroic) + GPU power-profile (PowerMizer) checks. - [ ] SMART integration (smartmontools if present) ## Phase 4 — Desktop UI & installer diff --git a/pyproject.toml b/pyproject.toml index 5edaee3..bc89378 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.8.0" +version = "0.9.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 731a974..4a4eeeb 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.8.0" +__version__ = "0.9.0" diff --git a/src/rigdoctor/cli.py b/src/rigdoctor/cli.py index d1a1194..98a527b 100644 --- a/src/rigdoctor/cli.py +++ b/src/rigdoctor/cli.py @@ -345,6 +345,20 @@ def cmd_report(args) -> int: return 0 +def cmd_gameenv(args) -> int: + from dataclasses import asdict + + from .core.gameenv import run_gameenv_checks + from .render import render_health + + findings = run_gameenv_checks() + if args.json: + print(json.dumps([asdict(f) for f in findings], indent=2, ensure_ascii=False)) + else: + print(render_health(findings, title="Gaming environment")) + return 0 + + def cmd_games(args) -> int: from .core import steam @@ -501,6 +515,10 @@ def build_parser() -> argparse.ArgumentParser: lib_p.add_argument("--all", action="store_true", help="scan all detected libraries") lib_p.add_argument("--json", action="store_true", help="output JSON") lib_p.set_defaults(func=cmd_games_libraries) + + env_p = sub.add_parser("gameenv", help="gaming environment checks (M6): flag stability/perf settings") + env_p.add_argument("--json", action="store_true", help="output JSON instead of text") + env_p.set_defaults(func=cmd_gameenv) return p diff --git a/src/rigdoctor/core/alerts.py b/src/rigdoctor/core/alerts.py index 47cbfd3..c976951 100644 --- a/src/rigdoctor/core/alerts.py +++ b/src/rigdoctor/core/alerts.py @@ -10,24 +10,42 @@ from __future__ import annotations import shutil import subprocess import time +from pathlib import Path +from ..config import DATA_DIR from .sample import Sample APP_NAME = "RigDoctor" -_ICON = "utilities-system-monitor" +_STOCK_ICON = "utilities-system-monitor" +# The RigDoctor icon, so notifications match the app/dock icon. Prefer the copy that +# desktop integration installs into the icon theme (~/.local/share/icons/...); fall back to +# the bundled asset for source/dev runs, then to a stock icon if neither is present. +_INSTALLED_ICON = DATA_DIR.parent / "icons" / "hicolor" / "scalable" / "apps" / "rigdoctor.svg" +_BUNDLED_ICON = Path(__file__).parents[1] / "gui" / "assets" / "rigdoctor.svg" def available() -> bool: return shutil.which("notify-send") is not None +def _icon() -> str: + """Resolve the notification icon at call time (the themed copy may be installed late).""" + for path in (_INSTALLED_ICON, _BUNDLED_ICON): + try: + if path.exists(): + return str(path) + except OSError: + pass + return _STOCK_ICON + + def notify(title: str, message: str, urgency: str = "normal") -> bool: """Send a desktop notification (best-effort). urgency: low|normal|critical.""" if not available(): return False try: subprocess.run( - ["notify-send", "-a", APP_NAME, "-u", urgency, "-i", _ICON, title, message], + ["notify-send", "-a", APP_NAME, "-u", urgency, "-i", _icon(), title, message], timeout=10, check=False, ) diff --git a/src/rigdoctor/core/gameenv.py b/src/rigdoctor/core/gameenv.py new file mode 100644 index 0000000..6c39152 --- /dev/null +++ b/src/rigdoctor/core/gameenv.py @@ -0,0 +1,259 @@ +"""Gaming environment checks (M6): evaluate system settings that affect gaming +stability/performance and suggest the fix command — read-only (D9). + +Stdlib-only. Each check degrades gracefully (a missing file/tool yields no finding or an +info finding, never an exception). The pure ``evaluate_*`` helpers are split from the IO +that reads sysfs / runs tools, so they're unit-testable. + +Several checks target the seed case directly: an RTX 3070 falling off the PCIe bus under +load (Xid 79). PCIe ASPM power-saving, NVIDIA persistence mode, and a power-saving CPU +governor are the usual contributors to that class of drop-off / stutter. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from pathlib import Path + +from .health import INFO, OK, WARNING, Finding + +_ORDER = {"critical": 0, WARNING: 1, INFO: 2, OK: 3} + + +def _read(path: str) -> str | None: + try: + return Path(path).read_text() + except OSError: + return None + + +# --- PCIe ASPM (seed-case relevant) --------------------------------------------------- + +def _active_aspm(policy_text: str) -> str | None: + """The active ASPM policy is the bracketed token, e.g. '[default] performance ...'.""" + m = re.search(r"\[(\w+)\]", policy_text) + return m.group(1) if m else None + + +def evaluate_aspm(policy_text: str | None) -> Finding | None: + if not policy_text: + return None + active = _active_aspm(policy_text) + if active is None: + return None + if active in ("powersave", "powersupersave"): + return Finding( + 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.", + ) + if active == "performance": + return Finding(OK, "PCIe", "PCIe ASPM set to performance", "ASPM power-saving is disabled.") + 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.", + ) + + +def check_pcie_aspm() -> list[Finding]: + f = evaluate_aspm(_read("/sys/module/pcie_aspm/parameters/policy")) + return [f] if f else [] + + +# --- NVIDIA persistence mode (seed-case relevant) ------------------------------------- + +def check_gpu_persistence() -> list[Finding]: + if shutil.which("nvidia-smi") is None: + return [] + 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 [] + state = proc.stdout.strip().splitlines()[0].strip() if proc.stdout.strip() else "" + if state.lower().startswith("disabled"): + return [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.", + )] + if state.lower().startswith("enabled"): + return [Finding(OK, "GPU", "NVIDIA persistence mode on", "The driver stays resident.")] + return [] + + +# --- CPU governor --------------------------------------------------------------------- + +def evaluate_governor(governors: set[str]) -> Finding | None: + if not governors: + return None + shown = ", ".join(sorted(governors)) + if governors == {"performance"}: + return Finding(OK, "CPU", "CPU governor: performance", "CPUs run at full clocks under load.") + 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.", + ) + 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.", + ) + + +def check_cpu_governor() -> list[Finding]: + govs: set[str] = set() + for p in Path("/sys/devices/system/cpu").glob("cpu*/cpufreq/scaling_governor"): + text = _read(str(p)) + if text and text.strip(): + govs.add(text.strip()) + f = evaluate_governor(govs) + return [f] if f else [] + + +# --- GameMode / MangoHud -------------------------------------------------------------- + +def check_gamemode() -> list[Finding]: + if shutil.which("gamemoderun") or shutil.which("gamemoded"): + return [Finding( + OK, "Tools", "Feral GameMode installed", + "GameMode can apply the performance governor and other tweaks while a game runs.", + )] + return [Finding( + INFO, "Tools", "GameMode not installed", + "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).", + )] + + +def check_mangohud() -> list[Finding]: + if shutil.which("mangohud"): + return [Finding(OK, "Tools", "MangoHud available", "In-game FPS/temps/frametime overlay is installed.")] + return [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%`.", + )] + + +# --- vm.swappiness -------------------------------------------------------------------- + +def evaluate_swappiness(value: int) -> Finding: + if value > 10: + return 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).", + ) + return Finding(OK, "Memory", f"vm.swappiness is {value}", "Swapping is conservative.") + + +def check_swappiness() -> list[Finding]: + text = _read("/proc/sys/vm/swappiness") + if text is None or not text.strip().isdigit(): + return [] + return [evaluate_swappiness(int(text.strip()))] + + +# --- shader cache --------------------------------------------------------------------- + +def evaluate_shader_cache(env: dict) -> Finding: + disabled = ( + env.get("__GL_SHADER_DISK_CACHE") == "0" + or env.get("MESA_SHADER_CACHE_DISABLE", "").lower() in ("1", "true") + or env.get("MESA_GLSL_CACHE_DISABLE", "").lower() in ("1", "true") + ) + if disabled: + return Finding( + WARNING, "GPU", "Shader disk cache is disabled", + "With the shader cache off, shaders recompile every run — a common cause of " + "in-game stutter, especially on first encounters.", + "Unset the disabling variable (e.g. remove `__GL_SHADER_DISK_CACHE=0` / " + "`MESA_SHADER_CACHE_DISABLE`) from your environment / launch options.", + ) + return Finding(OK, "GPU", "Shader disk cache enabled", "Compiled shaders are cached between runs (default).") + + +def check_shader_cache() -> list[Finding]: + return [evaluate_shader_cache(os.environ)] + + +# --- transparent hugepages / CPU mitigations (only when notable) ---------------------- + +def check_thp() -> list[Finding]: + text = _read("/sys/kernel/mm/transparent_hugepage/enabled") + if not text: + return [] + active = _active_aspm(text) # same '[token]' format + if active == "never": + 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`.", + )] + return [] + + +def check_mitigations() -> list[Finding]: + cmdline = _read("/proc/cmdline") or "" + if "mitigations=off" in cmdline: + return [Finding( + INFO, "CPU", "CPU security mitigations are disabled", + "`mitigations=off` recovers some CPU performance at the cost of CPU-vulnerability " + "protections — a deliberate trade-off, noted here for awareness.", + "Remove `mitigations=off` from the kernel cmdline to restore protections.", + )] + return [] + + +# --- Proton versions (informational) -------------------------------------------------- + +def check_proton() -> list[Finding]: + from . import steam + + try: + versions = steam.proton_versions() + except Exception: + versions = [] + if not versions: + return [] + return [Finding( + INFO, "Tools", f"Proton: {len(versions)} version(s) installed", + ", ".join(versions), + "Steam picks the Proton version per game (Properties → Compatibility); " + "Proton Experimental often has the latest fixes.", + )] + + +# --- aggregate ------------------------------------------------------------------------ + +def run_gameenv_checks() -> list[Finding]: + """Run all environment checks, sorted by severity (worst first).""" + findings: list[Finding] = [] + findings += check_pcie_aspm() + findings += check_gpu_persistence() + findings += check_cpu_governor() + findings += check_gamemode() + findings += check_mangohud() + findings += check_swappiness() + findings += check_shader_cache() + findings += check_thp() + findings += check_mitigations() + findings += check_proton() + findings.sort(key=lambda f: _ORDER.get(f.severity, 9)) + return findings diff --git a/src/rigdoctor/core/steam.py b/src/rigdoctor/core/steam.py index 282b916..d9bb4f5 100644 --- a/src/rigdoctor/core/steam.py +++ b/src/rigdoctor/core/steam.py @@ -248,6 +248,28 @@ def _int(value) -> int: return 0 +def proton_versions() -> list[str]: + """Installed Proton compatibility-tool versions across all discovered libraries. + + Proton builds are the appmanifests we filter out of game scans; here we surface them + for the M6 environment report. Returns unique names, newest-looking last. + """ + names: set[str] = set() + for lib in discover_libraries(): + try: + manifests = sorted((Path(lib.path) / "steamapps").glob("appmanifest_*.acf")) + except OSError: + continue + for manifest in manifests: + state = _read_vdf(manifest).get("AppState", {}) + if isinstance(state, dict): + state = {k.lower(): v for k, v in state.items()} + name = state.get("name", "").strip() + if name.startswith("Proton"): + names.add(name) + return sorted(names) + + # --- config-driven selection ---------------------------------------------------------- def selected_library_paths(cfg: dict | None = None) -> list[str]: diff --git a/src/rigdoctor/gui/environment_page.py b/src/rigdoctor/gui/environment_page.py new file mode 100644 index 0000000..f1b549b --- /dev/null +++ b/src/rigdoctor/gui/environment_page.py @@ -0,0 +1,104 @@ +"""Environment page (M6 in the GUI): runs the gaming-environment checks as findings cards.""" + +from __future__ import annotations + +import threading +import time + +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from .widgets import finding_card + + +class EnvironmentPage(QWidget): + _result = Signal(object) # list[Finding] + + def __init__(self) -> None: + super().__init__() + self.setObjectName("Page") + self._result.connect(self._render_findings) + + root = QVBoxLayout(self) + root.setContentsMargins(20, 18, 20, 18) + root.setSpacing(16) + + header = QHBoxLayout() + title = QLabel("Environment") + title.setObjectName("PageTitle") + header.addWidget(title) + header.addStretch(1) + self._status = QLabel("") + self._status.setObjectName("Muted") + header.addWidget(self._status) + self._run_btn = QPushButton("Run checks") + self._run_btn.setObjectName("PrimaryButton") + self._run_btn.clicked.connect(self._run) + header.addWidget(self._run_btn) + root.addLayout(header) + + intro = QLabel( + "System settings that affect gaming stability and performance, with the suggested " + "fix command. RigDoctor only reports — it never changes anything." + ) + intro.setObjectName("Muted") + intro.setWordWrap(True) + root.addWidget(intro) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setStyleSheet("background: transparent;") + self._container = QWidget() + self._list = QVBoxLayout(self._container) + self._list.setContentsMargins(0, 0, 0, 0) + self._list.setSpacing(10) + self._list.setAlignment(Qt.AlignmentFlag.AlignTop) + scroll.setWidget(self._container) + root.addWidget(scroll, 1) + + QTimer.singleShot(350, self._run) # auto-run shortly after the window opens + + def _run(self) -> None: + self._run_btn.setEnabled(False) + self._status.setText("Checking environment…") + threading.Thread(target=self._work, daemon=True).start() + + def _work(self) -> None: + from ..core.gameenv import run_gameenv_checks + + try: + findings = run_gameenv_checks() + except Exception: + findings = None + self._result.emit(findings) + + def _render_findings(self, findings) -> None: + self._run_btn.setEnabled(True) + if findings is None: # check failed — keep current results + self._status.setText("check failed") + return + + while self._list.count(): + item = self._list.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + + crit = sum(1 for f in findings if f.severity == "critical") + warn = sum(1 for f in findings if f.severity == "warning") + self._status.setText( + f"{crit} critical · {warn} warning · {len(findings)} checks · " + f"{time.strftime('%H:%M:%S')}" + ) + for finding in findings: + self._list.addWidget(finding_card(finding)) + self._list.addStretch(1) diff --git a/src/rigdoctor/gui/health_page.py b/src/rigdoctor/gui/health_page.py index 633ffab..13a6d42 100644 --- a/src/rigdoctor/gui/health_page.py +++ b/src/rigdoctor/gui/health_page.py @@ -16,40 +16,7 @@ from PySide6.QtWidgets import ( QWidget, ) -from .theme import ACCENT, CRIT, GOOD, MUTED, WARN - -_SEV = { - "critical": ("CRITICAL", CRIT), - "warning": ("WARNING", WARN), - "info": ("INFO", MUTED), - "ok": ("OK", GOOD), -} - - -def _finding_widget(finding) -> QFrame: - label, color = _SEV.get(finding.severity, ("?", MUTED)) - card = QFrame() - card.setObjectName("Card") - v = QVBoxLayout(card) - v.setContentsMargins(16, 12, 16, 12) - v.setSpacing(4) - - head = QLabel(f"{label} · {finding.category}: {finding.title}") - head.setStyleSheet(f"color: {color}; font-weight: 700; background: transparent;") - head.setWordWrap(True) - v.addWidget(head) - - if finding.detail: - detail = QLabel(finding.detail) - detail.setObjectName("Muted") - detail.setWordWrap(True) - v.addWidget(detail) - if finding.suggestion: - suggestion = QLabel(f"→ {finding.suggestion}") - suggestion.setStyleSheet(f"color: {ACCENT}; background: transparent;") - suggestion.setWordWrap(True) - v.addWidget(suggestion) - return card +from .widgets import finding_card class HealthPage(QWidget): @@ -125,5 +92,5 @@ class HealthPage(QWidget): f"{time.strftime('%H:%M:%S')}" ) for finding in findings: - self._list.addWidget(_finding_widget(finding)) + self._list.addWidget(finding_card(finding)) self._list.addStretch(1) diff --git a/src/rigdoctor/gui/main_window.py b/src/rigdoctor/gui/main_window.py index eed23ee..725f228 100644 --- a/src/rigdoctor/gui/main_window.py +++ b/src/rigdoctor/gui/main_window.py @@ -28,6 +28,7 @@ from .. import __version__ from ..config import load_config from ..core import alerts, elevation, updates from .dashboard import Dashboard +from .environment_page import EnvironmentPage from .games_page import GamesPage from .health_page import HealthPage from .notifications_page import NotificationsPage @@ -37,7 +38,7 @@ from .share_page import SharePage from .theme import ACCENT, GOOD, MUTED from .worker import SamplerWorker -_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Games", "Setup", "Notifications", "Share"] +_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Games", "Environment", "Setup", "Notifications", "Share"] class MainWindow(QMainWindow): @@ -69,6 +70,7 @@ class MainWindow(QMainWindow): self.health_page = HealthPage() self.games_page = GamesPage() self.games_page.new_count_changed.connect(self._set_games_badge) + self.environment_page = EnvironmentPage() self.setup_page = SetupPage() self.notifications_page = NotificationsPage() self.notifications_page.changed.connect(self._apply_alert_settings) @@ -77,9 +79,10 @@ class MainWindow(QMainWindow): self._stack.addWidget(self.recorder_page) # 1 Logs self._stack.addWidget(self.health_page) # 2 Health self._stack.addWidget(self.games_page) # 3 Games - self._stack.addWidget(self.setup_page) # 4 Setup - self._stack.addWidget(self.notifications_page) # 5 Notifications - self._stack.addWidget(self.share_page) # 6 Share + self._stack.addWidget(self.environment_page) # 4 Environment + self._stack.addWidget(self.setup_page) # 5 Setup + self._stack.addWidget(self.notifications_page) # 6 Notifications + self._stack.addWidget(self.share_page) # 7 Share content_layout.addWidget(self._stack) layout.addWidget(self._build_sidebar()) diff --git a/src/rigdoctor/gui/widgets.py b/src/rigdoctor/gui/widgets.py index 6923e71..17b569b 100644 --- a/src/rigdoctor/gui/widgets.py +++ b/src/rigdoctor/gui/widgets.py @@ -16,7 +16,41 @@ from PySide6.QtWidgets import ( from ..core.sample import Reading from ..render import format_value -from .theme import MUTED, TEXT, TRACK, gauge_color, temp_color +from .theme import ACCENT, CRIT, GOOD, MUTED, TEXT, TRACK, WARN, gauge_color, temp_color + +_SEV = { + "critical": ("CRITICAL", CRIT), + "warning": ("WARNING", WARN), + "info": ("INFO", MUTED), + "ok": ("OK", GOOD), +} + + +def finding_card(finding) -> QFrame: + """A card for one M4/M6 Finding (severity-colored title, detail, suggested fix).""" + label, color = _SEV.get(finding.severity, ("?", MUTED)) + card = QFrame() + card.setObjectName("Card") + v = QVBoxLayout(card) + v.setContentsMargins(16, 12, 16, 12) + v.setSpacing(4) + + head = QLabel(f"{label} · {finding.category}: {finding.title}") + head.setStyleSheet(f"color: {color}; font-weight: 700; background: transparent;") + head.setWordWrap(True) + v.addWidget(head) + + if finding.detail: + detail = QLabel(finding.detail) + detail.setObjectName("Muted") + detail.setWordWrap(True) + v.addWidget(detail) + if finding.suggestion: + suggestion = QLabel(f"→ {finding.suggestion}") + suggestion.setStyleSheet(f"color: {ACCENT}; background: transparent;") + suggestion.setWordWrap(True) + v.addWidget(suggestion) + return card class Card(QFrame): diff --git a/src/rigdoctor/render.py b/src/rigdoctor/render.py index 691e5cf..9496589 100644 --- a/src/rigdoctor/render.py +++ b/src/rigdoctor/render.py @@ -102,12 +102,12 @@ def _aggregate_peaks(maxima: dict) -> list[tuple[str, str, float, str, float, st _SEV_LABEL = {"critical": "CRITICAL", "warning": "WARNING", "info": "INFO", "ok": "OK"} -def render_health(findings: list) -> str: +def render_health(findings: list, title: str = "Health report") -> str: if not findings: - return "Health report: no findings." + return f"{title}: no findings." crit = sum(1 for f in findings if f.severity == "critical") warn = sum(1 for f in findings if f.severity == "warning") - lines = ["Health report", "", f" {crit} critical · {warn} warning · {len(findings)} checks", ""] + lines = [title, "", f" {crit} critical · {warn} warning · {len(findings)} checks", ""] for f in findings: lines.append(f"[{_SEV_LABEL.get(f.severity, '?')}] {f.category}: {f.title}") if f.detail: diff --git a/tests/test_gameenv.py b/tests/test_gameenv.py new file mode 100644 index 0000000..a7037a9 --- /dev/null +++ b/tests/test_gameenv.py @@ -0,0 +1,73 @@ +"""Tests for M6 gaming-environment checks (pure evaluators + aggregate smoke test).""" + +import unittest + +from rigdoctor.core import gameenv +from rigdoctor.core.health import Finding + + +class AspmTests(unittest.TestCase): + def test_powersave_is_warning(self): + f = gameenv.evaluate_aspm("[powersave] performance powersupersave\n") + self.assertEqual(f.severity, "warning") + self.assertEqual(f.category, "PCIe") + + def test_performance_is_ok(self): + self.assertEqual(gameenv.evaluate_aspm("[performance] powersave powersupersave").severity, "ok") + + def test_default_is_info(self): + self.assertEqual(gameenv.evaluate_aspm("[default] performance powersave").severity, "info") + + def test_missing_is_none(self): + self.assertIsNone(gameenv.evaluate_aspm(None)) + self.assertIsNone(gameenv.evaluate_aspm("no brackets here")) + + +class GovernorTests(unittest.TestCase): + def test_performance_only_is_ok(self): + self.assertEqual(gameenv.evaluate_governor({"performance"}).severity, "ok") + + def test_powersave_is_warning(self): + f = gameenv.evaluate_governor({"powersave"}) + self.assertEqual(f.severity, "warning") + self.assertIn("cpupower", f.suggestion) + + def test_dynamic_is_info(self): + self.assertEqual(gameenv.evaluate_governor({"schedutil"}).severity, "info") + + def test_empty_is_none(self): + self.assertIsNone(gameenv.evaluate_governor(set())) + + +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) + + def test_low_is_ok(self): + self.assertEqual(gameenv.evaluate_swappiness(10).severity, "ok") + + +class ShaderCacheTests(unittest.TestCase): + def test_disabled_nvidia_is_warning(self): + self.assertEqual(gameenv.evaluate_shader_cache({"__GL_SHADER_DISK_CACHE": "0"}).severity, "warning") + + def test_disabled_mesa_is_warning(self): + self.assertEqual(gameenv.evaluate_shader_cache({"MESA_SHADER_CACHE_DISABLE": "true"}).severity, "warning") + + def test_default_is_ok(self): + self.assertEqual(gameenv.evaluate_shader_cache({}).severity, "ok") + + +class AggregateTests(unittest.TestCase): + def test_run_returns_sorted_findings(self): + findings = gameenv.run_gameenv_checks() + self.assertTrue(all(isinstance(f, Finding) for f in findings)) + order = {"critical": 0, "warning": 1, "info": 2, "ok": 3} + sevs = [order.get(f.severity, 9) for f in findings] + self.assertEqual(sevs, sorted(sevs)) # worst-first + + +if __name__ == "__main__": + unittest.main()