Compare commits

..

14 Commits

Author SHA1 Message Date
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
16 changed files with 382 additions and 42 deletions
+32
View File
@@ -5,6 +5,38 @@ 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.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
+19 -29
View File
@@ -29,6 +29,16 @@ freeze are usually lost. RigDoctor pulls it together and keeps the evidence.
or share a live **terminal session** for remote help.
- **Self-updating** — `apt upgrade`, or the in-app updater.
## Screenshots
| Dashboard | Inventory |
|---|---|
| ![Dashboard — live sensors](assets/screenshots/dashboard.png) | ![Inventory — hardware/OS](assets/screenshots/inventory.png) |
**Share** — a read-only or interactive terminal session over the relay, for remote help:
![Share — shared terminal session](assets/screenshots/share.png)
## Install
### Debian / Ubuntu — `.deb`
@@ -41,39 +51,19 @@ apt pulls the GUI dependencies (PySide6, pyte) automatically:
sudo apt install ./rigdoctor_*_all.deb # CLI only: add --no-install-recommends
```
**Or add the apt repository** for `apt install` + automatic updates. The registry is private and
GPG-signed, so you need a Gitea token with **`read:package`**, the signing key, and the deb822
source (`read -rsp` keeps the token out of your shell history):
**Or add the apt repository** for `apt install` + automatic updates (the registry is public and
GPG-signed — no token needed):
```bash
read -rsp 'Gitea read:package token: ' TOKEN; echo
# signing key → dearmored into the keyring (the key endpoint requires the token too)
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL --user <user>:"$TOKEN" \
https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key \
| sudo gpg --dearmor -o /etc/apt/keyrings/gitea-jessey.gpg
# download credentials, kept out of the sources file
printf 'machine git.jesseyvanofferen.com login <user> password %s\n' "$TOKEN" \
| sudo tee /etc/apt/auth.conf.d/rigdoctor.conf >/dev/null
sudo chmod 0600 /etc/apt/auth.conf.d/rigdoctor.conf
# the source (modern deb822 format, GPG-verified, all-arch)
sudo tee /etc/apt/sources.list.d/rigdoctor.sources >/dev/null <<'EOF'
Types: deb
URIs: https://git.jesseyvanofferen.com/api/packages/jessey/debian
Suites: stable
Components: main
Architectures: all
Signed-By: /etc/apt/keyrings/gitea-jessey.gpg
EOF
sudo apt update && sudo apt install rigdoctor
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
```
Then `sudo apt upgrade` keeps it current. *(Quick-and-dirty alternative if the registry isn't
signed: skip the key and use a one-line `deb [arch=all trusted=yes] …/debian stable main` source.)*
Then `sudo apt upgrade` keeps it current.
Then `sudo apt upgrade` keeps it current.
### Any distro — self-extracting `.run` (no root)
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

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rigdoctor"
version = "0.36.1"
version = "0.38.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.36.1"
__version__ = "0.38.0"
+4
View File
@@ -263,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:])
+33
View File
@@ -251,6 +251,38 @@ 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 run_health_checks(include_journal: bool = True) -> list[Finding]:
"""Run all checks and return findings sorted by severity (worst first).
@@ -273,5 +305,6 @@ def run_health_checks(include_journal: bool = True) -> list[Finding]:
else:
findings += check_smart()
findings += check_live_temps()
findings += check_pcie_links()
findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
return findings
+64 -1
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,7 +192,11 @@ 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")])
+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; }}
+40 -1
View File
@@ -1,8 +1,18 @@
"""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 health
from rigdoctor.core.health import (
CRITICAL,
INFO,
WARNING,
check_pcie_links,
run_health_checks,
scan_journal_text,
)
class HealthScanTests(unittest.TestCase):
@@ -42,5 +52,34 @@ 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(), [])
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()