Compare commits

..

30 Commits

Author SHA1 Message Date
jessey fb468e83c2 Merge pull request 'feat(displays): monitors w/ resolution+refresh in Inventory; flag sub-max refresh in Health — 0.39.0' (#43) from feat/displays into main
release / test (push) Successful in 12s
release / release (push) Successful in 15s
Reviewed-on: #43
2026-05-22 14:56:15 +00:00
jessey b006fa6b8d feat(displays): monitors w/ resolution+refresh in Inventory; flag sub-max refresh in Health — 0.39.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
New core/displays.py reads connected monitors via GNOME Mutter DisplayConfig over
D-Bus (busctl --json; works on X11 + Wayland), falling back to xrandr on other X11
desktops. Inventory's Display section now lists each monitor's resolution + current
refresh (e.g. 'DP-1 · Samsung LC34G55T: 3440x1440 @ 165 Hz'). System Health
(check_displays) flags a monitor running below its max refresh AT THE CURRENT
resolution (e.g. 165 Hz panel set to 60 Hz) — never suggests lowering resolution.
+tests (Mutter JSON + xrandr parsers, health check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:55:33 +02:00
jessey b20e8dfc3a Merge pull request 'docs: public apt setup — one-line source with arch=all (no token, no notices)' (#42) from docs/public-registry into main
release / test (push) Successful in 11s
release / release (push) Successful in 16s
Reviewed-on: #42
2026-05-22 14:52:02 +00:00
jessey 9fe9a6576f Merge pull request 'feat(health): flag NVMe PCIe links below capability (lane-sharing) — 0.38.0' (#41) from feat/inventory-pcie into main
release / test (push) Successful in 12s
release / release (push) Successful in 15s
Reviewed-on: #41
2026-05-22 14:51:12 +00:00
jessey 07bc722209 feat(health): flag NVMe PCIe links below capability (lane-sharing) — 0.38.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
check_pcie_links() warns when an NVMe drive negotiates fewer lanes than it
supports — almost always motherboard lane-sharing (a GPU/second card or another
M.2 stealing lanes), the case the user asked about — and reports speed-only
reductions as info (slower slot / idle ASPM). GPU is excluded: NVIDIA drops its
PCIe gen+width at idle, so a snapshot would false-alarm. Reuses inventory
read_link/nvme_controllers (refactored to public). Wired into run_health_checks;
+tests. Folded into the 0.38.0 PCIe work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:49:47 +02:00
jessey d405bf7caf Merge pull request 'feat(inventory): show NVMe PCIe link gen/width, flag downtrains — 0.38.0' (#40) from feat/inventory-pcie into main
release / test (push) Successful in 13s
release / release (push) Successful in 15s
Reviewed-on: #40
2026-05-22 14:45:46 +00:00
jessey 9bb0f9a684 feat(inventory): show NVMe PCIe link gen/width, flag downtrains — 0.38.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
Each NVMe drive's Inventory entry now shows its negotiated PCIe link (e.g.
'· PCIe Gen4 x4') from sysfs (current/max link speed+width), and flags drives
running below their capability ('Gen3 x4 (capable of Gen4 x4)') — so you can
confirm a Gen4 SSD is in a Gen4 slot. SATA disks show no PCIe link. Renders in
the GUI Inventory, CLI, and the Markdown/JSON export automatically. +tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:45:08 +02:00
jessey 4bbc0fa97e Merge pull request 'docs: add Dashboard/Inventory/Share screenshots to the README' (#39) from docs/readme-screenshots into main
release / test (push) Successful in 12s
release / release (push) Successful in 15s
Reviewed-on: #39
2026-05-22 14:43:13 +00:00
jessey a0f8055328 docs: add Dashboard/Inventory/Share screenshots to the README
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 26s
Adds assets/screenshots/{dashboard,inventory,share}.png and a Screenshots section
(Dashboard + Inventory side by side, Share below).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:40:47 +02:00
jessey 323451428b Merge pull request 'fix(update): route the self-update by install kind (apt/pip/source) — 0.37.1' (#38) from fix/updater-by-install into main
release / test (push) Successful in 11s
release / release (push) Successful in 16s
Reviewed-on: #38
2026-05-22 14:40:19 +00:00
jessey 479189ee4e fix(update): route the self-update by install kind (apt/pip/source) — 0.37.1
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
rigdoctor update assumed a pip/venv install and ran 'python -m pip install', which
fails on a .deb (system python has no pip; you can't pip-upgrade a dpkg package).
Add updates.install_kind() (dpkg ownership / venv / source-checkout detection,
cached) and route apply_update: pip self-updates in place; apt and source installs
return guidance instead. CLI and the GUI Update button show the apt/git command.
Adds tests/test_updates.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:39:42 +02:00
jessey 51133e4042 Merge pull request 'feat(gui): scrollable pages + version footer — 0.37.0' (#37) from fix/scrollable-pages into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #37
2026-05-22 14:29:56 +00:00
jessey bcf6ac2656 feat(gui): scrollable pages + version footer — 0.37.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 31s
Wrap each page (except self-scrolling Dashboard/Health/Inventory and the Share
terminal) in a QScrollArea, so long pages scroll when too tall (Settings'
Uninstall is reachable again) and the window is no longer pinned to the tallest
page's height — min height drops from >screen to ~600px, so it can be resized
smaller. Add a bottom footer showing 'RigDoctor v<version>' bottom-right (moved
out of the sidebar); themed #Footer with a top border.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:29:14 +02:00
jessey 81c7757546 docs: public apt setup — one-line source with arch=all (no token, no notices)
tests / core (pull_request) Successful in 11s
tests / gui-smoke (pull_request) Successful in 27s
Registry is public now: drop the token/auth.conf.d, use the signed-by one-line
source with arch=all so apt doesn't emit 'doesn't support architecture amd64'
notices (our package is Architecture: all). Drop the curl|sh bootstrap idea.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:05:03 +02:00
jessey d59261f021 Merge pull request 'docs: registry is public now — drop the token/auth.conf.d from apt setup' (#36) from docs/public-registry into main
release / test (push) Successful in 13s
release / release (push) Successful in 15s
Reviewed-on: #36
2026-05-22 13:58:13 +00:00
jessey 44923b771a docs: registry is public now — drop the token/auth.conf.d from apt setup
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
REQUIRE_SIGNIN_VIEW is off and the repo is public, so anonymous apt works. The
apt instructions no longer need a read:package token or auth.conf.d — just the
signing key + a deb822 Signed-By source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:57:40 +02:00
jessey eaaf14c58a Merge pull request 'fix(cli): correct the missing-PySide6 hint to the real apt packages — 0.36.1' (#35) from docs/apt-proper into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #35
2026-05-22 13:49:28 +00:00
jessey 7779131cf9 Merge branch 'main' into docs/apt-proper
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
2026-05-22 13:48:36 +00:00
jessey 87fa678ccb fix(cli): correct the missing-PySide6 hint to the real apt packages — 0.36.1
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 26s
rigdoctor gui suggested 'apt install python3-pyside6' (no such package on
Debian/Ubuntu). Point to the split modules instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:48:20 +02:00
jessey c5e24b3984 Merge pull request 'docs: document the proper (GPG-verified, deb822) apt setup' (#34) from docs/apt-proper into main
release / test (push) Successful in 12s
release / release (push) Successful in 14s
Reviewed-on: #34
2026-05-22 13:46:10 +00:00
jessey 21cc6a4813 docs: document the proper (GPG-verified, deb822) apt setup
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 27s
Replace the trusted=yes apt instructions with the proper method: read:package
token, registry signing key dearmored into /etc/apt/keyrings, credentials in
auth.conf.d, and a modern deb822 .sources file with Signed-By + Architectures:
all. Keeps the trusted=yes one-liner as a noted fallback for unsigned registries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:44:41 +02:00
jessey ee73049248 Merge pull request 'fix(deb): auto-install all deps — correct PySide6 names + bundle tools — 0.36.0' (#33) from fix/deb-pyside6-deps into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #33
2026-05-22 13:39:01 +00:00
jessey 3a8ad5bd5d fix(deb): auto-install all deps — correct PySide6 names + bundle tools — 0.36.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 29s
The old Recommends named python3-pyside6 (no such package on Debian/Ubuntu —
PySide6 is split per module), so apt skipped it and the GUI couldn't start.
Now Recommends the real modules (python3-pyside6.qt{widgets,gui,websockets,svg}
+ python3-pyte) AND the optional diagnostic/gaming tools (smartmontools,
lm-sensors, dmidecode, pciutils, libnotify-bin, libsecret-tools, gamemode,
mangohud), so 'apt install rigdoctor' sets up the whole toolset automatically —
no manual installs. cpupower -> Suggests. Verified all candidates resolve in apt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:38:12 +02:00
jessey e8b84bf046 Merge pull request 'docs: rewrite README to be user-first (install + use)' (#32) from docs/readme-users into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #32
2026-05-22 13:32:41 +00:00
jessey 2342dd83aa docs: rewrite README to be user-first (install + use)
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 29s
Lead with what RigDoctor does, then install (.deb/apt incl. the private-registry
auth.conf.d + trusted=yes notes, and the .run), then usage (GUI/tray/CLI),
requirements, and privacy. Move the dev content (from-source, tests, docs links)
into a short Development section at the end. Drops the stale status/decisions/
repo-layout planning sections from the top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:31:36 +02:00
jessey a028fe6d38 Merge pull request 'ci: make apt registry upload idempotent (tolerate 409)' (#31) from fix/apt-409 into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #31
2026-05-22 13:26:47 +00:00
jessey a6453335e9 ci: make apt registry upload idempotent (tolerate 409)
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 28s
Gitea's Debian registry is immutable, so re-uploading an existing version returns
409. With --fail that aborted the release on any re-run / repeat push at the same
version. Now we capture the HTTP code: 2xx = uploaded, 409 = already published
(skip), anything else = fail with the body. Also fixed the stale skip message
(REGISTRY_TOKEN, not PACKAGES_TOKEN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:21:27 +02:00
jessey baec47dd4e Merge pull request 'assets: project avatar (gauge + heartbeat) for Gitea' (#30) from chore/avatar into main
release / test (push) Successful in 12s
release / release (push) Failing after 15s
Reviewed-on: #30
2026-05-22 13:18:59 +00:00
jessey 47ecb702e7 Merge branch 'main' into chore/avatar
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 28s
2026-05-22 13:17:28 +00:00
jessey dc719f6a89 assets: project avatar (gauge + heartbeat) for Gitea
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 27s
512x512 PNG (assets/avatar.png) rendered from assets/avatar.svg, matching the app
icon's gauge-ring + heartbeat motif on a dark gradient. Upload as the repo avatar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:16:58 +02:00
22 changed files with 785 additions and 136 deletions
+8 -2
View File
@@ -113,13 +113,19 @@ jobs:
run: |
set -euo pipefail
if [ -z "${PKG_TOKEN:-}" ]; then
echo "PACKAGES_TOKEN not set — skipping apt publish (the .deb is still a release asset)."
echo "REGISTRY_TOKEN not set — skipping apt publish (the .deb is still a release asset)."
exit 0
fi
OWNER="${{ github.repository_owner }}"
URL="${{ github.server_url }}/api/packages/${OWNER}/debian/pool/stable/main/upload"
for f in dist/*.deb; do
echo "Uploading $(basename "$f") to the apt registry…"
curl -sS --fail --user "${OWNER}:${PKG_TOKEN}" --upload-file "$f" "$URL"
code=$(curl -sS -o /tmp/apt_upload.txt -w '%{http_code}' \
--user "${OWNER}:${PKG_TOKEN}" --upload-file "$f" "$URL" || true)
case "$code" in
2*) echo " uploaded ($code)";;
409) echo " already published ($code) — skipping (registry versions are immutable)";;
*) echo " upload failed ($code):"; cat /tmp/apt_upload.txt || true; exit 1;;
esac
done
echo "apt source: deb ${{ github.server_url }}/api/packages/${OWNER}/debian stable main"
+61
View File
@@ -5,6 +5,67 @@ 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.39.0] - 2026-05-22
### Added
- **Displays in the Inventory.** A new `core/displays.py` lists each connected monitor with its
resolution and current/max refresh — e.g. `DP-1 · Samsung LC34G55T → 3440x1440 @ 165 Hz`. Reads
GNOME's Mutter `DisplayConfig` over D-Bus (works on X11 *and* Wayland), falling back to `xrandr`
on other X11 desktops.
- **System Health flags monitors below their max refresh.** If a monitor supports a higher refresh
at its current resolution (e.g. a 165 Hz panel set to 60 Hz — an easily-missed gaming setting),
Health reports it with the fix (raise it in Display settings). Max is computed at the *current*
resolution, so it never suggests dropping resolution.
## [0.38.0] - 2026-05-22
### Added
- **PCIe link in the Inventory.** Each NVMe drive now shows its negotiated PCIe link next to the
model — e.g. `Samsung SSD 980 PRO 1TB (931.5G) · PCIe Gen4 x4` — read from sysfs
(`current/max_link_speed` + width). If a drive negotiates below its capability (a slower M.2
slot, lane-sharing, or a downtrain) it's flagged: `PCIe Gen3 x4 (capable of Gen4 x4)`. So you
can confirm a Gen4 SSD is actually in a Gen4 slot. (SATA disks show no PCIe link.)
- **System Health flags downtrained NVMe links.** A new check warns when an NVMe drive negotiates
fewer PCIe lanes than it supports (almost always motherboard **lane-sharing** — a GPU/second
card or another M.2 stealing lanes) and notes speed-only reductions as info (a slower slot or
idle ASPM). The GPU is deliberately excluded — NVIDIA drops its PCIe gen/width at idle, so a
snapshot would false-alarm.
## [0.37.1] - 2026-05-22
### Fixed
- **`rigdoctor update` now uses the right method for how RigDoctor was installed.** It detects
apt (`.deb`), pip (venv/`.run`), or source installs (`updates.install_kind()`); only pip
installs self-update in place. An apt install no longer fails with "No module named pip" —
it (and the GUI Update button) shows `sudo apt update && sudo apt install --only-upgrade
rigdoctor`; a source checkout points to `git pull`.
## [0.37.0] - 2026-05-22
### Added
- **Version footer** — a footer across the bottom of the window shows `RigDoctor v<version>` in
the bottom-right (moved out of the sidebar).
### Fixed
- **Pages scroll when content doesn't fit, and the window is no longer pinned to the tallest
page's height.** Long pages (Settings, Tuning, …) get a scrollbar when too tall — so controls
like Uninstall are always reachable — and the window can now be resized smaller than the screen
(min height dropped from "taller than the screen" to ~600px). Pages that manage their own
scroll/fill (Dashboard, System Health, Inventory, Share) are unchanged.
## [0.36.1] - 2026-05-22
### Fixed
- `rigdoctor gui` printed the wrong fix when PySide6 is missing — it suggested the non-existent
`python3-pyside6` package. Now it names the real split modules
(`python3-pyside6.qt{widgets,gui,websockets,svg}` + `python3-pyte`).
## [0.36.0] - 2026-05-22
### Fixed
- **`.deb` now installs all dependencies automatically — no manual tool install.** The previous
`Recommends: python3-pyside6` named a package that doesn't exist on Debian/Ubuntu (PySide6 is
split per module), so apt silently skipped it and the GUI wouldn't start. Now it Recommends the
actual modules the GUI imports — `python3-pyside6.qt{widgets,gui,websockets,svg}` + `python3-pyte`.
### Changed
- **`apt install rigdoctor` sets up the whole toolset.** The `.deb` also Recommends the optional
diagnostic/gaming tools (smartmontools, lm-sensors, dmidecode, pciutils, libnotify-bin,
libsecret-tools, gamemode, mangohud) so they install by default — users never hand-install
tools. `cpupower` is a Suggests (kernel-tied); `--no-install-recommends` still gives CLI-only.
## [0.35.0] - 2026-05-22
### Added
- **`.deb` package (M9 / D8)** — `packaging/make_deb.py` builds a `rigdoctor_<version>_all.deb`
+97 -113
View File
@@ -1,152 +1,136 @@
# RigDoctor
A **modular diagnostics, monitoring, and health-check toolkit for Linux gamers.**
**Hardware monitoring & crash diagnostics for Linux gamers.** Live sensors, crash-safe
logging, plain-language health reports, per-game diagnostics, and optional AI explanations —
in a desktop app, a tray applet, or the terminal. Ubuntu/Debian + NVIDIA first.
> **Status:** 🟢 Phase 1 (MVP) complete. The **sensor core (M1)**, **crash-capture logger
> (M3)**, and **health report (M4)** all work — live `snapshot`/`monitor`, crash-safe `record`
> with a post-crash report, and `report` to scan logs/SMART/driver for likely causes. A
> desktop GUI (M10) ties them together (dashboard, recording, health). See `docs/ROADMAP.md`.
Linux gaming faults are hard to pin down — GPUs falling off the PCIe bus, black screens
mid-game, silent thermal/VRAM throttling, driver/Proton mismatches. The useful data is
scattered across `nvidia-smi`, `/sys`, `journalctl`, and SMART, and the readings right before a
freeze are usually lost. RigDoctor pulls it together and keeps the evidence.
## Why this exists
## Features
Linux gaming hardware faults are hard to diagnose: GPUs falling off the PCIe bus, the screen
suddenly going black mid-game, silent thermal/VRAM throttling, power transients,
driver/library mismatches, Proton quirks, and CPU governor / power-profile misconfiguration.
The data needed to diagnose them is scattered across `nvidia-smi`, `/sys/class/hwmon`,
`journalctl`, SMART, and more — and the most useful readings (the ones right before a hard
freeze) are usually lost because nothing flushed them to disk.
- **Live monitoring** — a dark desktop **dashboard** (history graphs + per-subsystem cards), a
**tray applet** with at-a-glance status, and a terminal view (`rigdoctor monitor`).
- **Crash-safe recording** — background logger that `fsync`s every sample, so the state right
before a hard freeze survives. Manual, always-on, or auto-start when a game launches.
- **Health report** — scans `journalctl`/SMART/driver for likely causes (Xid, OOM, disk
errors, throttling…) and explains them with suggested fixes.
- **Per-game diagnostics** — pick a game, capture while you play, get a focused report; hard
crashes are detected and analysed on next launch.
- **Gaming tune-ups** — flags risky settings (CPU governor, PCIe ASPM, persistence mode…) with
**one-click, reversible fixes**.
- **Proactive alerts** — desktop notifications on overheating and critical kernel events
(GPU-lost, Xid, out-of-memory, disk I/O).
- **AI explanations** *(optional, opt-in)* — explain a diagnostic in plain language with a
**local model (Ollama)** or **Claude**. Never automatic; only when you press the button.
- **Shareable reports** — zip a diagnostic (logs, inventory, AI transcript) to hand to someone,
or share a live **terminal session** for remote help.
- **Self-updating** — `apt upgrade`, or the in-app updater.
RigDoctor pulls all of that into one modular tool: live monitoring, crash-safe logging, a
one-shot health report, and an interactive installer that only sets up the modules a given
user actually needs for their hardware.
## Screenshots
**Seed use cases:** an RTX 3070 that intermittently "falls off the bus" under heavy GPU load
(Path of Exile on Linux, Escape from Tarkov on Windows), and a monitor going black mid-game.
See `docs/SPEC.md` §1.
| Dashboard | Inventory |
|---|---|
| ![Dashboard — live sensors](assets/screenshots/dashboard.png) | ![Inventory — hardware/OS](assets/screenshots/inventory.png) |
## How you run it
**Share** — a read-only or interactive terminal session over the relay, for remote help:
RigDoctor is **GUI-first** — the desktop app is the primary way in — but every feature is
also available headless:
- **Desktop GUI** — graphical dashboard, recording controls, log browser, reports. The
default interface for most users.
- **Tray applet** — a small top-menu-bar applet with quick actions and at-a-glance status.
- **CLI** — full functionality from the terminal; works over SSH and in scripts.
![Share — shared terminal session](assets/screenshots/share.png)
The GUI/tray are optional modules; a headless (CLI-only) install loses no capability.
## Install
## Key decisions (settled)
### Debian / Ubuntu — `.deb`
| Topic | Decision |
|-------|----------|
| Name | **RigDoctor** |
| Language / stack | **Python 3 + Qt (PySide6)** — core/CLI/daemon stdlib-only; Qt only for GUI/tray |
| Primary distro | **Ubuntu** (Debian via apt); others best-effort later |
| Primary GPU | **NVIDIA** first; AMD, then Intel later |
| MVP | **Sensor core + crash logger + health report** (NVIDIA-only, CLI-first) |
| Distribution | **User-local install** (self-updating from the public repo, no root); **`.deb`** optional |
| Scope of action | **Read-only + suggestions** (no auto-apply yet) |
| Stress tests | **Out of scope** |
Full rationale and the still-open questions are in `docs/DECISIONS.md`.
## Repo layout
| Path | Purpose |
|------|---------|
| `docs/SPEC.md` | Product specification — vision, requirements, modules (the main planning doc) |
| `docs/ARCHITECTURE.md` | Technical design — core engine, front-ends, daemon, installer |
| `docs/MODULES.md` | Catalog of modules with scope, dependencies, status |
| `docs/ROADMAP.md` | Phased milestones |
| `docs/DECISIONS.md` | Decision log + remaining open questions |
| `src/rigdoctor/` | Source code — `core/` engine + sources, `cli.py`, `render.py` |
| `installer/` | Installer / `.deb` packaging (empty until Phase 4) |
| `tests/` | Tests (stdlib `unittest`) |
## Install (user-local, no root)
RigDoctor installs into a private venv under `~/.local` — no root, self-updating:
The simplest path: grab the latest **`rigdoctor_<version>_all.deb`** from the
[releases page](https://git.jesseyvanofferen.com/jessey/rigdoctor/releases) and install it —
apt pulls the GUI dependencies (PySide6, pyte) automatically:
```bash
./install.sh # from a source checkout or the self-extracting .run
./install.sh --ref v0.0.6 # install a specific released tag (needs a token)
./install.sh --uninstall # remove it
sudo apt install ./rigdoctor_*_all.deb # CLI only: add --no-install-recommends
```
This adds `rigdoctor` / `rigdoctor-gui` to `~/.local/bin` and a desktop entry. Each release
also ships a one-file **`.run`** installer (download, `chmod +x`, run). Updates are gated to
accounts on the Git server (a Personal Access Token); save one via the GUI **Setup → Update
access** panel or `rigdoctor login`, then `rigdoctor update` (or the sidebar button).
## Install (`.deb`, system-wide)
Each release also ships a **`.deb`** (`Architecture: all`, M9/D8). Download it from the release
and install with apt (pulls the GUI deps — PySide6/pyte — via Recommends):
**Or add the apt repository** for `apt install` + automatic updates (the registry is public and
GPG-signed — no token needed):
```bash
sudo apt install ./rigdoctor_<version>_all.deb # CLI-only: add --no-install-recommends
sudo curl https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key -o /etc/apt/keyrings/gitea-jessey.asc
echo "deb [arch=all signed-by=/etc/apt/keyrings/gitea-jessey.asc] https://git.jesseyvanofferen.com/api/packages/jessey/debian stable main" | sudo tee /etc/apt/sources.list.d/gitea.list
sudo apt update
sudo apt install rigdoctor
```
When the apt registry is enabled on the server, you can instead add it as a source and
`sudo apt update && sudo apt install rigdoctor` (with `apt upgrade` for updates):
Then `sudo apt upgrade` keeps it current.
Then `sudo apt upgrade` keeps it current.
### Any distro — self-extracting `.run` (no root)
Download **`rigdoctor-<version>-installer.run`** from the releases page and run it. It installs
into a private virtualenv under `~/.local` (no root), adds the launchers + desktop entry, and
opens the first-run setup wizard:
```bash
curl -fsSL https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key \
| sudo tee /etc/apt/keyrings/gitea-rigdoctor.asc > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/gitea-rigdoctor.asc] \
https://git.jesseyvanofferen.com/api/packages/jessey/debian stable main" \
| sudo tee /etc/apt/sources.list.d/rigdoctor.list
sh rigdoctor-*-installer.run
```
## Run it (dev)
### Updating & removing
Stdlib-only, no install needed (target is Python ≥ 3.11; tested on 3.14):
- **`.deb`:** `sudo apt upgrade` (or reinstall a newer `.deb`).
- **`.run` / user-local:** the in-app **Update** button, or `rigdoctor update`.
- **Remove:** `sudo apt remove rigdoctor`, or `rigdoctor uninstall` for the user-local install.
## Using it
Launch **RigDoctor** from your app menu, or:
```bash
PYTHONPATH=src python3 -m rigdoctor snapshot # one-shot sensor read
PYTHONPATH=src python3 -m rigdoctor snapshot --json
PYTHONPATH=src python3 -m rigdoctor monitor -n 1 # live view (Ctrl-C to quit)
PYTHONPATH=src python3 -m rigdoctor sources # list detected sensor sources
PYTHONPATH=src python3 -m unittest discover -s tests
rigdoctor-gui # desktop app (+ tray)
rigdoctor --help # everything from the terminal (works over SSH)
```
### Crash-capture logger (M3)
A crash-safe background logger (JSONL, `fsync` per sample, bounded by rotation) for catching
the state right before a freeze:
Handy CLI commands:
```bash
rigdoctor record start # start logging in the background
rigdoctor record status # is it running? latest readings, sample count
rigdoctor record stop # stop it
rigdoctor record report # post-crash summary: peaks, events, last samples
rigdoctor record run # run in the foreground (the systemd-ready entrypoint)
rigdoctor snapshot # one-shot reading of every sensor
rigdoctor monitor # live terminal dashboard
rigdoctor report # health report (logs / SMART / driver)
rigdoctor diagnose start|finish # capture while gaming, then analyse
rigdoctor gameenv # flag risky gaming settings + fixes
rigdoctor inventory # hardware/OS inventory
rigdoctor ai explain # AI explanation of the current findings (opt-in)
rigdoctor bundle # zip the latest diagnostic into a shareable report
```
Logs live in `~/.local/share/rigdoctor/logs/`. It detects GPU "lost"/hang (nvidia-smi query
timeout) and writes an event marker. Trigger modes (always-on / game-launch) and the
`systemd --user` service arrive in Phase 4.
## Requirements
### Desktop GUI (M10)
- **Linux** — Ubuntu/Debian first-class (the `.deb`); the `.run` works on any distro with
Python ≥ 3.11.
- **GPU** — NVIDIA fully supported (via `nvidia-smi`); AMD/Intel sensors are best-effort.
- **CLI/daemon** need only Python 3 (stdlib). The **GUI/tray** add **PySide6** (`python3-pyside6`).
- Optional tools unlock more: `smartmontools`, `lm-sensors`, `gamemode`, `mangohud`. The setup
wizard offers to install them.
The GUI uses PySide6 (Qt) — the only part of RigDoctor that needs a non-stdlib dep:
## Privacy
Everything stays on your machine — no telemetry, no phone-home. The AI assistant is **off by
default** and runs only when you explicitly trigger it; with Ollama nothing leaves the machine,
and the Claude option asks before sending. Reports are local files; they leave only if you share
the zip.
## Development
RigDoctor's core is stdlib-only Python; the GUI/tray use PySide6.
```bash
pip install -e '.[gui]' # core + PySide6, gives `rigdoctor` and `rigdoctor-gui`
rigdoctor gui # or: rigdoctor-gui
git clone https://git.jesseyvanofferen.com/jessey/rigdoctor && cd rigdoctor
pip install -e ".[gui]" # core + GUI; omit [gui] for CLI-only
python -m unittest discover -s tests # run the test suite
PYTHONPATH=src python3 -m rigdoctor snapshot # run without installing
```
It opens a dark-themed window with sidebar navigation and a **live dashboard** over the
same sensor core — circular gauges for the headline metrics plus collapsible per-subsystem
cards (GPU/CPU/memory/storage) with temperature-colored values (icey-blue → green → red).
The **Logs** and **Health** sections are full pages (recording controls + post-crash report;
and the kernel-log / SMART / driver scan). **Inventory** is a placeholder until M5 lands.
Without the GUI extra, `pip install -e .` gives just the stdlib-only CLI.
## Start here
1. Read `docs/SPEC.md` for what we're building.
2. Read `docs/ROADMAP.md` for the build order (Phase 1 = the MVP).
3. Read `docs/DECISIONS.md` for the settled decisions (D1D15).
</content>
Design docs live in `docs/``SPEC.md` (vision/requirements), `ARCHITECTURE.md`,
`MODULES.md` (module catalog), `ROADMAP.md`, and `DECISIONS.md` (the decision log).
Contributions: branch off `main`, keep tests green (CI runs them on PRs), and bump the version
+ `CHANGELOG.md` for shipped changes.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<radialGradient id="bg" cx="50%" cy="42%" r="78%">
<stop offset="0%" stop-color="#1b2230"/>
<stop offset="100%" stop-color="#0d0f13"/>
</radialGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<!-- gauge ring -->
<circle cx="256" cy="256" r="168" fill="none" stroke="#2a2f39" stroke-width="28"/>
<!-- accent sweep -->
<path d="M256 88 a168 168 0 1 1 -118.8 49.2" fill="none" stroke="#38bdf8"
stroke-width="28" stroke-linecap="round"/>
<!-- heartbeat / monitoring trace -->
<path d="M120 264 H200 L232 192 L280 336 L312 264 H392" fill="none" stroke="#e6e8eb"
stroke-width="28" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+9 -4
View File
@@ -2,9 +2,13 @@
Pure-Python app, so it's `Architecture: all`: we stage the package into dist-packages, drop the
two launchers in /usr/bin, install the desktop entry + icon, write a DEBIAN/control, and call
`dpkg-deb`. The core is stdlib (`Depends: python3`); the GUI/tray deps are **Recommends**
(`python3-pyside6`, `python3-pyte`) so `apt install rigdoctor` gives the full app by default,
while `--no-install-recommends` yields a CLI-only install.
`dpkg-deb`. The core is stdlib (`Depends: python3`); everything else is **Recommends** so a
plain `apt install rigdoctor` sets up the whole toolset automatically (users never hand-install
deps) — the GUI modules (Debian/Ubuntu split PySide6 per module, so we name
`python3-pyside6.qt{widgets,gui,websockets,svg}`) + `python3-pyte`, plus the diagnostic/gaming
tools (smartmontools, lm-sensors, dmidecode, pciutils, libnotify-bin, libsecret-tools, gamemode,
mangohud). `--no-install-recommends` still yields a CLI-only install; `cpupower` is a Suggests
(kernel-tied/heavy).
Run: `python packaging/make_deb.py` → `dist/rigdoctor_<version>_all.deb`.
"""
@@ -57,7 +61,8 @@ Maintainer: {maintainer}
Section: utils
Priority: optional
Depends: python3 (>= 3.11)
Recommends: python3-pyside6, python3-pyte
Recommends: python3-pyside6.qtwidgets, python3-pyside6.qtgui, python3-pyside6.qtwebsockets, python3-pyside6.qtsvg, python3-pyte, smartmontools, lm-sensors, dmidecode, pciutils, libnotify-bin, libsecret-tools, gamemode, mangohud
Suggests: linux-tools-generic
Homepage: {homepage}
Description: Hardware monitoring & crash diagnostics for Linux gamers
RigDoctor monitors GPU/CPU temperatures, load, and sensors, captures crash
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rigdoctor"
version = "0.35.0"
version = "0.39.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.35.0"
__version__ = "0.39.0"
+7 -2
View File
@@ -55,8 +55,9 @@ def cmd_gui(args) -> int:
from .gui.app import main as gui_main
except ImportError as exc:
print("The GUI needs PySide6, which isn't installed.")
print(" Install it with: pip install 'rigdoctor[gui]'")
print(" or on Ubuntu: sudo apt install python3-pyside6")
print(" Ubuntu/Debian: sudo apt install python3-pyside6.qtwidgets "
"python3-pyside6.qtgui python3-pyside6.qtwebsockets python3-pyside6.qtsvg python3-pyte")
print(" pip: pip install 'rigdoctor[gui]'")
print(f" ({exc})")
return 2
return gui_main([sys.argv[0]])
@@ -262,6 +263,10 @@ def cmd_update(args) -> int:
print("\nWhat's new:\n" + "\n".join(" " + ln for ln in notes.splitlines()) + "\n")
if args.check:
return 0
kind = updates.install_kind()
if kind != "pip": # apt/source installs aren't pip-updatable — show the right command
print(updates.update_hint(kind))
return 0
print(f"Installing {tag}")
rc, out = updates.apply_update(tag)
print(out[-2000:])
+148
View File
@@ -0,0 +1,148 @@
"""Connected displays (M5): resolution + current/max refresh per monitor.
GNOME exposes the authoritative data over D-Bus (Mutter `DisplayConfig.GetCurrentState`),
which works on both X11 and Wayland — read via `busctl --json`. Plain X11 desktops fall back
to `xrandr`. Other Wayland compositors (sway/KDE) aren't covered yet and degrade to empty.
Stdlib only; every probe fails soft. Max refresh is computed at the *current* resolution, so
"can go faster" never suggests dropping resolution.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from dataclasses import dataclass
# A few common PNP monitor-vendor IDs → friendly names (best-effort; unknown codes pass through).
_PNP = {
"SAM": "Samsung", "DEL": "Dell", "GSM": "LG", "LGD": "LG", "AUS": "ASUS", "ACR": "Acer",
"BNQ": "BenQ", "MSI": "MSI", "AOC": "AOC", "VSC": "ViewSonic", "HWP": "HP", "HPN": "HP",
"PHL": "Philips", "GBT": "Gigabyte", "APP": "Apple", "DGC": "Dell",
}
@dataclass
class Monitor:
connector: str # e.g. "DP-1"
name: str # e.g. "Samsung LC34G55T" ("" if unknown, e.g. xrandr)
width: int
height: int
refresh: float # current Hz
max_refresh: float # max Hz available at the current resolution
@property
def can_go_faster(self) -> bool:
"""True if a meaningfully higher refresh is available at the current resolution."""
return self.max_refresh - self.refresh > 1.0
def label(self) -> str:
return f"{self.connector} · {self.name}".rstrip(" ·") if self.name else self.connector
def _run(cmd: list[str], timeout: float = 8.0) -> str:
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if proc.returncode == 0:
return proc.stdout
except (subprocess.SubprocessError, OSError):
pass
return ""
def _parse_mutter(out: str) -> list[Monitor]:
"""Parse `busctl --json` output of Mutter DisplayConfig.GetCurrentState.
data = [serial, monitors, logical_monitors, props]; each monitor is
[[connector, vendor, product, serial], [modes], props]; each mode is
[id, width, height, refresh, scale, [scales], {props}] where props may hold is-current.
"""
try:
data = json.loads(out)["data"]
raw_monitors = data[1]
except (json.JSONDecodeError, KeyError, IndexError, TypeError):
return []
monitors: list[Monitor] = []
for mon in raw_monitors:
try:
connector, vendor, product = mon[0][0], mon[0][1], mon[0][2]
modes = mon[1]
except (IndexError, TypeError):
continue
current = None
for m in modes:
props = m[6] if len(m) > 6 and isinstance(m[6], dict) else {}
if (props.get("is-current") or {}).get("data"):
current = m
break
if current is None:
continue
w, h, r = int(current[1]), int(current[2]), float(current[3])
max_r = max((float(m[3]) for m in modes if int(m[1]) == w and int(m[2]) == h), default=r)
name = f"{_PNP.get(vendor, vendor)} {product}".strip()
monitors.append(Monitor(connector, name, w, h, r, max_r))
return monitors
def _parse_xrandr(out: str) -> list[Monitor]:
"""Parse `xrandr --query`: an output line with the active WxH+x+y, then indented mode lines
whose rates carry `*` for the current one."""
monitors: list[Monitor] = []
out_re = re.compile(r"^(\S+) connected.*?(\d+)x(\d+)\+\d+\+\d+")
mode_re = re.compile(r"^\s+(\d+)x(\d+)\s+(.+)$")
name = ""
cw = ch = 0
cur_r = max_r = 0.0
def flush() -> None:
if name and cw and cur_r:
monitors.append(Monitor(name, "", cw, ch, cur_r, max_r or cur_r))
for line in out.splitlines():
mo = out_re.match(line)
if mo:
flush()
name, cw, ch = mo.group(1), int(mo.group(2)), int(mo.group(3))
cur_r = max_r = 0.0
continue
mm = mode_re.match(line)
if mm and name and int(mm.group(1)) == cw and int(mm.group(2)) == ch:
for tok in mm.group(3).split():
try:
rate = float(tok.rstrip("*+"))
except ValueError:
continue
max_r = max(max_r, rate)
if "*" in tok:
cur_r = rate
flush()
return monitors
def _mutter() -> list[Monitor]:
exe = shutil.which("busctl")
if not exe:
return []
out = _run([exe, "--user", "--json=short", "call", "org.gnome.Mutter.DisplayConfig",
"/org/gnome/Mutter/DisplayConfig", "org.gnome.Mutter.DisplayConfig",
"GetCurrentState"])
return _parse_mutter(out) if out.strip() else []
def _xrandr() -> list[Monitor]:
if not shutil.which("xrandr"):
return []
return _parse_xrandr(_run(["xrandr", "--query"]))
def collect() -> list[Monitor]:
"""Connected monitors, via the first backend that returns any (Mutter, then xrandr)."""
for backend in (_mutter, _xrandr):
try:
monitors = backend()
except Exception:
monitors = []
if monitors:
return monitors
return []
+51
View File
@@ -251,6 +251,55 @@ def check_live_temps() -> list[Finding]:
)]
def check_pcie_links() -> list[Finding]:
"""Flag NVMe drives linked below their PCIe capability — a slower slot or, most often,
motherboard lane-sharing where a GPU/second card or another M.2 steals lanes from the slot.
Width reductions are reliable (reported as warnings); speed-only reductions are info (they can
also be normal link power management at idle). The GPU is intentionally not checked here:
NVIDIA drops its PCIe gen *and* width at idle, so a point-in-time snapshot is misleading.
"""
from . import inventory
findings: list[Finding] = []
for name, dev in inventory.nvme_controllers():
cur_g, cur_w, max_g, max_w = inventory.read_link(dev)
if not cur_g or not max_g:
continue
if max_w and cur_w and cur_w != max_w: # fewer lanes → almost always lane-sharing
findings.append(Finding(
WARNING, "PCIe", f"{name} linked at x{cur_w} (supports x{max_w})",
f"{name} negotiated PCIe Gen{cur_g} x{cur_w}, but the drive supports "
f"Gen{max_g} x{max_w}. Fewer lanes is usually motherboard lane-sharing — a GPU or a "
"second card in a PCIe slot, or another populated M.2, can steal lanes from this slot.",
"Check your board manual's lane-sharing table; move the drive to a full-x4 "
"(often CPU-attached) M.2 slot."))
elif cur_g < max_g: # full width but a lower generation → slower slot or idle ASPM
findings.append(Finding(
INFO, "PCIe", f"{name} linked at Gen{cur_g} (supports Gen{max_g})",
f"{name} negotiated PCIe Gen{cur_g} but supports Gen{max_g}. This can be a slower "
"(chipset or older) M.2 slot, or normal link power management (ASPM) at idle.",
"If you expect full speed, check the slot and the BIOS PCIe/ASPM settings."))
return findings
def check_displays() -> list[Finding]:
"""Flag monitors running below their max refresh rate at the current resolution — e.g. a
165 Hz panel set to 60 Hz, a common and easily-missed gaming setting (read-only suggestion)."""
from . import displays
findings: list[Finding] = []
for m in displays.collect():
if m.can_go_faster:
findings.append(Finding(
INFO, "Display",
f"{m.connector} at {round(m.refresh)} Hz (supports {round(m.max_refresh)} Hz)",
f"{m.name or m.connector} is running at {round(m.refresh)} Hz at "
f"{m.width}x{m.height}, but supports {round(m.max_refresh)} Hz at that resolution.",
"Raise the refresh rate in your desktop's Display settings (GNOME: Settings → Displays)."))
return findings
def run_health_checks(include_journal: bool = True) -> list[Finding]:
"""Run all checks and return findings sorted by severity (worst first).
@@ -273,5 +322,7 @@ def run_health_checks(include_journal: bool = True) -> list[Finding]:
else:
findings += check_smart()
findings += check_live_temps()
findings += check_pcie_links()
findings += check_displays()
findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
return findings
+74 -3
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import json
import os
import platform
import re
import shutil
import subprocess
from dataclasses import dataclass
@@ -123,6 +124,64 @@ def _gpu() -> Section:
return Section("GPU", [("Device", g) for g in gpus] or [("Device", "unknown")])
# PCIe link speed (GT/s) → generation.
_PCIE_GEN = {"2.5": 1, "5": 2, "5.0": 2, "8": 3, "8.0": 3, "16": 4, "16.0": 4, "32": 5, "32.0": 5}
def _gen(speed: str) -> int | None:
"""Map a sysfs link speed like '16.0 GT/s PCIe' to its PCIe generation (4)."""
tok = speed.strip().split()[0] if speed.strip() else ""
return _PCIE_GEN.get(tok)
def read_link(dev: Path) -> tuple[int | None, str, int | None, str]:
"""Negotiated/max PCIe link for a PCI device dir: (cur_gen, cur_width, max_gen, max_width).
Widths are the raw sysfs strings (e.g. '4'); gens are ints (4) or None when unreadable.
"""
def rd(name: str) -> str:
try:
return (dev / name).read_text().strip()
except OSError:
return ""
return (_gen(rd("current_link_speed")), rd("current_link_width"),
_gen(rd("max_link_speed")), rd("max_link_width"))
def _link_desc(dev: Path) -> str:
"""Describe a PCI device's negotiated PCIe link, noting if it's below its max.
e.g. 'PCIe Gen4 x4', or 'PCIe Gen3 x4 (capable of Gen4 x4)' when downtrained / in a
slower slot.
"""
cur_g, cur_w, max_g, max_w = read_link(dev)
if not cur_g or not cur_w:
return ""
desc = f"PCIe Gen{cur_g} x{cur_w}"
if max_g and (cur_g < max_g or (max_w and cur_w != max_w)):
desc += f" (capable of Gen{max_g} x{max_w})"
return desc
def nvme_controllers() -> list[tuple[str, Path]]:
"""Each NVMe controller as (name, pci-device-dir), e.g. ('nvme0', /sys/.../device)."""
base = Path("/sys/class/nvme")
try:
entries = [p for p in base.iterdir() if re.fullmatch(r"nvme\d+", p.name)]
except OSError:
return []
return sorted((p.name, p / "device") for p in entries)
def _nvme_link(block_name: str) -> str:
"""PCIe link for an NVMe block device (nvme0n1 → controller nvme0); '' for non-NVMe."""
m = re.match(r"(nvme\d+)", block_name)
if not m:
return ""
return _link_desc(Path("/sys/class/nvme") / m.group(1) / "device")
def _storage() -> Section:
items: list[tuple[str, str]] = []
# TYPE first so MODEL (which can contain spaces) is the trailing field.
@@ -133,15 +192,27 @@ def _storage() -> Section:
continue
name, size = parts[1], parts[2]
model = parts[3] if len(parts) > 3 else ""
items.append((name, f"{model} ({size})".strip()))
desc = f"{model} ({size})".strip()
link = _nvme_link(name) # NVMe PCIe gen/width (e.g. Gen4 x4), flags downtrains
if link:
desc += f" · {link}"
items.append((name, desc))
return Section("Storage", items or [("Disks", "unknown")])
def _display() -> Section:
return Section("Display", [
from . import displays
items = [
("Session", os.environ.get("XDG_SESSION_TYPE", "unknown")),
("Desktop", os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION", "unknown")),
])
]
for m in displays.collect():
val = f"{m.width}x{m.height} @ {round(m.refresh)} Hz"
if m.can_go_faster:
val += f" (supports {round(m.max_refresh)} Hz)"
items.append((m.label(), val))
return Section("Display", items)
def _dmidecode() -> dict:
+55 -3
View File
@@ -8,11 +8,14 @@ state for the UI; `apply_update` performs the no-root self-update.
from __future__ import annotations
import functools
import json
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
from .. import __version__
from ..config import load_token
@@ -31,6 +34,50 @@ UP_TO_DATE = "up-to-date"
AVAILABLE = "available"
APT_PACKAGE = "rigdoctor"
def _dpkg_owns(path: Path) -> bool:
"""True if dpkg reports `path` belongs to a package (i.e. an apt/.deb install)."""
if not shutil.which("dpkg"):
return False
try:
r = subprocess.run(["dpkg", "-S", str(path)], capture_output=True, text=True, timeout=5)
except (subprocess.SubprocessError, OSError):
return False
return r.returncode == 0 and APT_PACKAGE in r.stdout
@functools.lru_cache(maxsize=1)
def install_kind() -> str:
"""How RigDoctor was installed: 'apt' (.deb), 'pip' (venv/.run), or 'dev' (source checkout).
Decides which updater to use: only 'pip' can self-update in place; apt is root/dpkg-managed
and source is VCS-managed, so those are guided rather than auto-applied.
"""
pkg = Path(__file__).resolve().parents[1] # .../rigdoctor
if _dpkg_owns(pkg / "__init__.py"):
return "apt"
if sys.prefix != sys.base_prefix: # inside a venv → the pip/.run install
return "pip"
if (pkg.parents[1] / "pyproject.toml").exists(): # repo checkout
return "dev"
if str(pkg).startswith("/usr/") or "/dist-packages/" in str(pkg):
return "apt" # system-managed but no dpkg record — still don't pip
return "pip"
def update_hint(kind: str | None = None) -> str:
"""Human guidance for installs that can't self-update via pip (apt / source)."""
kind = kind or install_kind()
if kind == "apt":
return ("Installed via apt — update with:\n"
f" sudo apt update && sudo apt install --only-upgrade {APT_PACKAGE}")
if kind == "dev":
return "Running from a source checkout — update with `git pull`."
return ""
def _parse(version: str) -> tuple[int, ...]:
return tuple(int(p) for p in version.lstrip("vV").split(".") if p.isdigit())
@@ -100,11 +147,16 @@ def list_releases(limit: int = 15, timeout: float = 6.0) -> tuple[list[tuple[str
def apply_update(tag: str) -> tuple[int, str]:
"""Self-update the current (user-local) install to `tag` via authenticated pip.
"""Update to `tag` using the method matching how RigDoctor was installed.
Installs `rigdoctor[gui] @ git+https://oauth2:<token>@/rigdoctor.git@<tag>` into
the running environment. Returns (exit_code, output) with the token scrubbed.
Only pip/venv installs are upgraded in place (authenticated pip install of
`rigdoctor[gui] @ git+https://oauth2:<token>@/rigdoctor.git@<tag>`). apt and source
installs can't be (root/dpkg- or VCS-managed), so they return guidance instead of
attempting pip. Returns (exit_code, output) with the token scrubbed.
"""
kind = install_kind()
if kind != "pip":
return (1, update_hint(kind))
token = load_token()
if not token:
return (1, "No update token configured. Run `rigdoctor login`.")
+39 -6
View File
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
QMainWindow,
QMessageBox,
QPushButton,
QScrollArea,
QStackedWidget,
QSystemTrayIcon,
QTextEdit,
@@ -51,6 +52,10 @@ _NAV = [
("App", ["Settings", "Share"]),
]
_PAGES = [name for _section, names in _NAV for name in names]
# Pages that manage their own scrolling (pinned header + inner scroll) or must fill the
# viewport (the Share terminal) — these are added to the stack as-is; every other page is
# wrapped in a QScrollArea so it scrolls when too tall and doesn't pin the window's height.
_NO_WRAP = {"Dashboard", "System Health", "Inventory", "Share"}
_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
@@ -68,7 +73,11 @@ class MainWindow(QMainWindow):
central = QWidget()
self.setCentralWidget(central)
layout = QHBoxLayout(central)
outer = QVBoxLayout(central)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
body = QWidget()
layout = QHBoxLayout(body)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
@@ -100,11 +109,14 @@ class MainWindow(QMainWindow):
"Share": self.share_page,
}
for name in _PAGES:
self._stack.addWidget(self._pages[name])
page = self._pages[name]
self._stack.addWidget(page if name in _NO_WRAP else self._scrollable(page))
content_layout.addWidget(self._stack)
layout.addWidget(self._build_sidebar())
layout.addWidget(content, 1)
outer.addWidget(body, 1)
outer.addWidget(self._build_footer())
self._worker = SamplerWorker(interval=interval)
self._worker.sampled.connect(self.dashboard.update_sample)
@@ -216,9 +228,6 @@ class MainWindow(QMainWindow):
v.addStretch(1)
live = QLabel(f'<span style="color:{ACCENT};">●</span> <span style="color:{MUTED};">Live</span>')
v.addWidget(live)
version = QLabel(f"v{__version__}")
version.setObjectName("Muted")
v.addWidget(version)
changelog_btn = QPushButton("Changelog")
changelog_btn.setObjectName("LinkButton")
changelog_btn.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -248,6 +257,27 @@ class MainWindow(QMainWindow):
v.addWidget(self._restart_btn)
return bar
def _scrollable(self, page: QWidget) -> QScrollArea:
"""Wrap a page so it scrolls when taller than the window — and so the window can shrink
below the page's natural height instead of being pinned to it."""
area = QScrollArea()
area.setWidget(page)
area.setWidgetResizable(True)
area.setFrameShape(QFrame.Shape.NoFrame)
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
return area
def _build_footer(self) -> QFrame:
bar = QFrame()
bar.setObjectName("Footer")
h = QHBoxLayout(bar)
h.setContentsMargins(14, 5, 16, 5)
h.addStretch(1)
version = QLabel(f"RigDoctor v{__version__}")
version.setObjectName("Muted")
h.addWidget(version)
return bar
def _restart(self) -> None:
gui = os.path.join(os.path.dirname(sys.executable), "rigdoctor-gui")
if os.path.exists(gui):
@@ -259,6 +289,9 @@ class MainWindow(QMainWindow):
def _apply_update(self) -> None:
if not self._latest_tag:
return
if updates.install_kind() != "pip": # apt/source: can't pip-update — show the command
QMessageBox.information(self, "Update RigDoctor", updates.update_hint())
return
box = QMessageBox(self)
box.setWindowTitle(f"Update to {self._latest_tag}")
box.setText(f"Update RigDoctor to {self._latest_tag}?")
@@ -424,7 +457,7 @@ class MainWindow(QMainWindow):
self._update_label.setText("update check unavailable")
elif state == updates.AVAILABLE:
self._update_label.setText(f'<span style="color:{GOOD};">{tag} available</span>')
self._update_btn.setText(f"Update to {tag}")
self._update_btn.setText(f"Update to {tag}" if updates.install_kind() == "pip" else "How to update")
self._update_btn.setVisible(True)
if self._alert_monitor.enabled and tag != self._notified_update_tag:
self._notified_update_tag = tag # once per version, not every poll
+2
View File
@@ -68,6 +68,8 @@ QMainWindow, #ContentArea, #Page {{ background: {BG}; }}
QLabel {{ background: transparent; }}
#Sidebar {{ background: {SIDEBAR}; border-right: 1px solid {CARD_BORDER}; }}
#Footer {{ background: {SIDEBAR}; border-top: 1px solid {CARD_BORDER}; }}
#Footer QLabel {{ font-size: 11px; }}
#AppTitle {{ font-size: 17px; font-weight: 800; }}
#AppSubtitle {{ color: {MUTED}; font-size: 11px; }}
+67
View File
@@ -0,0 +1,67 @@
"""Tests for display detection (Mutter D-Bus JSON + xrandr parsers)."""
import unittest
from rigdoctor.core import displays
# Minimal Mutter GetCurrentState (busctl --json) shape: current mode is 60 Hz, panel max 165 Hz.
_MUTTER_60 = (
'{"type":"x","data":[1,[[["DP-1","SAM","LC34G55T","S"],['
'["3440x1440@60",3440,1440,60.0,1.0,[1.0],{"is-current":{"type":"b","data":true}}],'
'["3440x1440@165",3440,1440,165.0,1.0,[1.0],{"is-preferred":{"type":"b","data":true}}]'
'],{}]],[],{}]}'
)
_MUTTER_MAX = (
'{"type":"x","data":[1,[[["DP-1","SAM","LC34G55T","S"],['
'["3440x1440@165",3440,1440,165.0,1.0,[1.0],{"is-current":{"type":"b","data":true}}],'
'["3440x1440@60",3440,1440,60.0,1.0,[1.0],{}]'
'],{}]],[],{}]}'
)
_XRANDR_60 = """Screen 0: minimum 8 x 8, current 3440 x 1440, maximum 16384 x 16384
DP-1 connected primary 3440x1440+0+0 (normal left inverted right x axis y axis) 800mm x 335mm
3440x1440 60.00*+ 165.00 100.00
2560x1440 165.00 60.00
HDMI-1 disconnected (normal left inverted right x axis y axis)
"""
class MutterParseTests(unittest.TestCase):
def test_parses_and_flags_higher_refresh(self):
mons = displays._parse_mutter(_MUTTER_60)
self.assertEqual(len(mons), 1)
m = mons[0]
self.assertEqual(m.connector, "DP-1")
self.assertEqual(m.name, "Samsung LC34G55T") # PNP code SAM mapped
self.assertEqual((m.width, m.height), (3440, 1440))
self.assertEqual(round(m.refresh), 60)
self.assertEqual(round(m.max_refresh), 165)
self.assertTrue(m.can_go_faster)
def test_at_max_is_not_flagged(self):
m = displays._parse_mutter(_MUTTER_MAX)[0]
self.assertEqual(round(m.refresh), 165)
self.assertFalse(m.can_go_faster)
def test_garbage_returns_empty(self):
self.assertEqual(displays._parse_mutter("not json"), [])
self.assertEqual(displays._parse_mutter("{}"), [])
class XrandrParseTests(unittest.TestCase):
def test_current_and_max_refresh(self):
mons = displays._parse_xrandr(_XRANDR_60)
self.assertEqual(len(mons), 1) # disconnected output ignored
m = mons[0]
self.assertEqual(m.connector, "DP-1")
self.assertEqual((m.width, m.height), (3440, 1440))
self.assertEqual(round(m.refresh), 60)
self.assertEqual(round(m.max_refresh), 165)
self.assertTrue(m.can_go_faster)
def test_empty_returns_empty(self):
self.assertEqual(displays._parse_xrandr(""), [])
if __name__ == "__main__":
unittest.main()
+56 -1
View File
@@ -1,8 +1,19 @@
"""Tests for the M4 health report's log scanner (synthetic input)."""
import unittest
from pathlib import Path
from unittest import mock
from rigdoctor.core.health import CRITICAL, WARNING, run_health_checks, scan_journal_text
from rigdoctor.core import displays, health
from rigdoctor.core.health import (
CRITICAL,
INFO,
WARNING,
check_displays,
check_pcie_links,
run_health_checks,
scan_journal_text,
)
class HealthScanTests(unittest.TestCase):
@@ -42,5 +53,49 @@ class HealthScanTests(unittest.TestCase):
self.assertEqual(ranks, sorted(ranks))
class PcieLinkCheckTests(unittest.TestCase):
def _with_link(self, cur_g, cur_w, max_g, max_w):
# one fake NVMe controller returning the given link tuple
return (mock.patch("rigdoctor.core.inventory.nvme_controllers",
return_value=[("nvme0", Path("/x"))]),
mock.patch("rigdoctor.core.inventory.read_link",
return_value=(cur_g, cur_w, max_g, max_w)))
def test_reduced_width_is_a_warning_about_lane_sharing(self):
ctrls, link = self._with_link(4, "2", 4, "4") # Gen4 x2 but supports x4
with ctrls, link:
findings = check_pcie_links()
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0].severity, WARNING)
self.assertIn("lane-sharing", findings[0].detail)
def test_reduced_speed_only_is_info(self):
ctrls, link = self._with_link(3, "4", 4, "4") # Gen3 x4 but supports Gen4
with ctrls, link:
findings = check_pcie_links()
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0].severity, INFO)
def test_full_speed_no_finding(self):
ctrls, link = self._with_link(4, "4", 4, "4")
with ctrls, link:
self.assertEqual(check_pcie_links(), [])
class DisplayCheckTests(unittest.TestCase):
def test_lower_than_max_refresh_is_flagged(self):
mon = displays.Monitor("DP-1", "Samsung LC34G55T", 3440, 1440, 60.0, 165.0)
with mock.patch("rigdoctor.core.displays.collect", return_value=[mon]):
findings = check_displays()
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0].severity, INFO)
self.assertIn("165", findings[0].title)
def test_at_max_refresh_no_finding(self):
mon = displays.Monitor("DP-1", "Samsung LC34G55T", 3440, 1440, 165.0, 165.0)
with mock.patch("rigdoctor.core.displays.collect", return_value=[mon]):
self.assertEqual(check_displays(), [])
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -1,6 +1,8 @@
"""Tests for the M5 system inventory (render + dict round-trip; collect on real system)."""
import tempfile
import unittest
from pathlib import Path
from rigdoctor.core import inventory
from rigdoctor.core.inventory import Section
@@ -26,5 +28,31 @@ class InventoryTests(unittest.TestCase):
self.assertIn("- **Model:** Test CPU", md)
class PcieLinkTests(unittest.TestCase):
def test_gen_mapping(self):
self.assertEqual(inventory._gen("16.0 GT/s PCIe"), 4)
self.assertEqual(inventory._gen("8.0 GT/s PCIe"), 3)
self.assertIsNone(inventory._gen(""))
def _fake_dev(self, cur_s, cur_w, max_s, max_w) -> Path:
d = Path(tempfile.mkdtemp())
(d / "current_link_speed").write_text(cur_s)
(d / "current_link_width").write_text(cur_w)
(d / "max_link_speed").write_text(max_s)
(d / "max_link_width").write_text(max_w)
return d
def test_link_at_full_speed(self):
dev = self._fake_dev("16.0 GT/s PCIe", "4", "16.0 GT/s PCIe", "4")
self.assertEqual(inventory._link_desc(dev), "PCIe Gen4 x4")
def test_link_downtrained_flags_capability(self):
dev = self._fake_dev("8.0 GT/s PCIe", "4", "16.0 GT/s PCIe", "4")
self.assertEqual(inventory._link_desc(dev), "PCIe Gen3 x4 (capable of Gen4 x4)")
def test_non_nvme_has_no_link(self):
self.assertEqual(inventory._nvme_link("sda"), "")
if __name__ == "__main__":
unittest.main()
+64
View File
@@ -0,0 +1,64 @@
"""Tests for the M13 updater: install detection + routing the update to the right method."""
import unittest
from unittest import mock
from rigdoctor.core import updates
class InstallKindTests(unittest.TestCase):
def setUp(self):
updates.install_kind.cache_clear()
def tearDown(self):
updates.install_kind.cache_clear()
def test_apt_when_dpkg_owns_the_package(self):
with mock.patch.object(updates, "_dpkg_owns", return_value=True):
self.assertEqual(updates.install_kind(), "apt")
def test_pip_when_running_in_a_venv(self):
with mock.patch.object(updates, "_dpkg_owns", return_value=False), \
mock.patch.object(updates.sys, "prefix", "/opt/venv"), \
mock.patch.object(updates.sys, "base_prefix", "/usr"):
self.assertEqual(updates.install_kind(), "pip")
class ApplyUpdateRoutingTests(unittest.TestCase):
def test_apt_returns_guidance_and_never_runs_pip(self):
with mock.patch.object(updates, "install_kind", return_value="apt"), \
mock.patch("subprocess.run") as run:
rc, out = updates.apply_update("v9.9.9")
self.assertEqual(rc, 1)
self.assertIn("apt install --only-upgrade", out)
run.assert_not_called()
def test_dev_returns_guidance_and_never_runs_pip(self):
with mock.patch.object(updates, "install_kind", return_value="dev"), \
mock.patch("subprocess.run") as run:
rc, out = updates.apply_update("v9.9.9")
self.assertIn("git pull", out)
run.assert_not_called()
def test_pip_install_runs_pip(self):
proc = mock.Mock(returncode=0, stdout="Successfully installed", stderr="")
with mock.patch.object(updates, "install_kind", return_value="pip"), \
mock.patch.object(updates, "load_token", return_value="TOK"), \
mock.patch("subprocess.run", return_value=proc) as run:
rc, _out = updates.apply_update("v1.2.3")
self.assertEqual(rc, 0)
cmd = run.call_args[0][0]
self.assertIn("pip", cmd)
self.assertIn("install", cmd)
class UpdateHintTests(unittest.TestCase):
def test_apt_hint_names_the_apt_command(self):
self.assertIn("apt install --only-upgrade rigdoctor", updates.update_hint("apt"))
def test_dev_hint_says_git_pull(self):
self.assertIn("git pull", updates.update_hint("dev"))
if __name__ == "__main__":
unittest.main()