Compare commits

...

13 Commits

Author SHA1 Message Date
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 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 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
14 changed files with 282 additions and 27 deletions
+27
View File
@@ -5,6 +5,33 @@ All notable changes to RigDoctor are recorded here. Format follows
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
release tag (so the auto-updater, D18, can compare versions). 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.)
## [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 ## [0.36.1] - 2026-05-22
### Fixed ### Fixed
- `rigdoctor gui` printed the wrong fix when PySide6 is missing — it suggested the non-existent - `rigdoctor gui` printed the wrong fix when PySide6 is missing — it suggested the non-existent
+15 -15
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. or share a live **terminal session** for remote help.
- **Self-updating** — `apt upgrade`, or the in-app updater. - **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 ## Install
### Debian / Ubuntu — `.deb` ### Debian / Ubuntu — `.deb`
@@ -41,24 +51,15 @@ apt pulls the GUI dependencies (PySide6, pyte) automatically:
sudo apt install ./rigdoctor_*_all.deb # CLI only: add --no-install-recommends 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 **Or add the apt repository** for `apt install` + automatic updates. The registry is public and
GPG-signed, so you need a Gitea token with **`read:package`**, the signing key, and the deb822 GPG-signed — no token needed; just add the signing key and a deb822 source:
source (`read -rsp` keeps the token out of your shell history):
```bash ```bash
read -rsp 'Gitea read:package token: ' TOKEN; echo # signing key → dearmored into the keyring
# signing key → dearmored into the keyring (the key endpoint requires the token too)
sudo install -d -m 0755 /etc/apt/keyrings sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL --user <user>:"$TOKEN" \ curl -fsSL https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key \
https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key \
| sudo gpg --dearmor -o /etc/apt/keyrings/gitea-jessey.gpg | 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) # the source (modern deb822 format, GPG-verified, all-arch)
sudo tee /etc/apt/sources.list.d/rigdoctor.sources >/dev/null <<'EOF' sudo tee /etc/apt/sources.list.d/rigdoctor.sources >/dev/null <<'EOF'
Types: deb Types: deb
@@ -72,8 +73,7 @@ EOF
sudo apt update && sudo apt install rigdoctor sudo apt update && sudo apt install rigdoctor
``` ```
Then `sudo apt upgrade` keeps it current. *(Quick-and-dirty alternative if the registry isn't Then `sudo apt upgrade` keeps it current.
signed: skip the key and use a one-line `deb [arch=all trusted=yes] …/debian stable main` source.)*
### Any distro — self-extracting `.run` (no root) ### 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] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.36.1" version = "0.38.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers." description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" """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") print("\nWhat's new:\n" + "\n".join(" " + ln for ln in notes.splitlines()) + "\n")
if args.check: if args.check:
return 0 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}") print(f"Installing {tag}")
rc, out = updates.apply_update(tag) rc, out = updates.apply_update(tag)
print(out[-2000:]) print(out[-2000:])
+46 -1
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import json import json
import os import os
import platform import platform
import re
import shutil import shutil
import subprocess import subprocess
from dataclasses import dataclass from dataclasses import dataclass
@@ -123,6 +124,46 @@ def _gpu() -> Section:
return Section("GPU", [("Device", g) for g in gpus] or [("Device", "unknown")]) 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 _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.
"""
def rd(name: str) -> str:
try:
return (dev / name).read_text().strip()
except OSError:
return ""
cur_g, cur_w = _gen(rd("current_link_speed")), rd("current_link_width")
max_g, max_w = _gen(rd("max_link_speed")), rd("max_link_width")
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_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: def _storage() -> Section:
items: list[tuple[str, str]] = [] items: list[tuple[str, str]] = []
# TYPE first so MODEL (which can contain spaces) is the trailing field. # TYPE first so MODEL (which can contain spaces) is the trailing field.
@@ -133,7 +174,11 @@ def _storage() -> Section:
continue continue
name, size = parts[1], parts[2] name, size = parts[1], parts[2]
model = parts[3] if len(parts) > 3 else "" 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")]) 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 from __future__ import annotations
import functools
import json import json
import shutil
import subprocess import subprocess
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
from pathlib import Path
from .. import __version__ from .. import __version__
from ..config import load_token from ..config import load_token
@@ -31,6 +34,50 @@ UP_TO_DATE = "up-to-date"
AVAILABLE = "available" 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, ...]: def _parse(version: str) -> tuple[int, ...]:
return tuple(int(p) for p in version.lstrip("vV").split(".") if p.isdigit()) 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]: 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 Only pip/venv installs are upgraded in place (authenticated pip install of
the running environment. Returns (exit_code, output) with the token scrubbed. `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() token = load_token()
if not token: if not token:
return (1, "No update token configured. Run `rigdoctor login`.") return (1, "No update token configured. Run `rigdoctor login`.")
+39 -6
View File
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QScrollArea,
QStackedWidget, QStackedWidget,
QSystemTrayIcon, QSystemTrayIcon,
QTextEdit, QTextEdit,
@@ -51,6 +52,10 @@ _NAV = [
("App", ["Settings", "Share"]), ("App", ["Settings", "Share"]),
] ]
_PAGES = [name for _section, names in _NAV for name in names] _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" _ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
@@ -68,7 +73,11 @@ class MainWindow(QMainWindow):
central = QWidget() central = QWidget()
self.setCentralWidget(central) 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.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) layout.setSpacing(0)
@@ -100,11 +109,14 @@ class MainWindow(QMainWindow):
"Share": self.share_page, "Share": self.share_page,
} }
for name in _PAGES: 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) content_layout.addWidget(self._stack)
layout.addWidget(self._build_sidebar()) layout.addWidget(self._build_sidebar())
layout.addWidget(content, 1) layout.addWidget(content, 1)
outer.addWidget(body, 1)
outer.addWidget(self._build_footer())
self._worker = SamplerWorker(interval=interval) self._worker = SamplerWorker(interval=interval)
self._worker.sampled.connect(self.dashboard.update_sample) self._worker.sampled.connect(self.dashboard.update_sample)
@@ -216,9 +228,6 @@ class MainWindow(QMainWindow):
v.addStretch(1) v.addStretch(1)
live = QLabel(f'<span style="color:{ACCENT};">●</span> <span style="color:{MUTED};">Live</span>') live = QLabel(f'<span style="color:{ACCENT};">●</span> <span style="color:{MUTED};">Live</span>')
v.addWidget(live) v.addWidget(live)
version = QLabel(f"v{__version__}")
version.setObjectName("Muted")
v.addWidget(version)
changelog_btn = QPushButton("Changelog") changelog_btn = QPushButton("Changelog")
changelog_btn.setObjectName("LinkButton") changelog_btn.setObjectName("LinkButton")
changelog_btn.setCursor(Qt.CursorShape.PointingHandCursor) changelog_btn.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -248,6 +257,27 @@ class MainWindow(QMainWindow):
v.addWidget(self._restart_btn) v.addWidget(self._restart_btn)
return bar 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: def _restart(self) -> None:
gui = os.path.join(os.path.dirname(sys.executable), "rigdoctor-gui") gui = os.path.join(os.path.dirname(sys.executable), "rigdoctor-gui")
if os.path.exists(gui): if os.path.exists(gui):
@@ -259,6 +289,9 @@ class MainWindow(QMainWindow):
def _apply_update(self) -> None: def _apply_update(self) -> None:
if not self._latest_tag: if not self._latest_tag:
return 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 = QMessageBox(self)
box.setWindowTitle(f"Update to {self._latest_tag}") box.setWindowTitle(f"Update to {self._latest_tag}")
box.setText(f"Update RigDoctor 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") self._update_label.setText("update check unavailable")
elif state == updates.AVAILABLE: elif state == updates.AVAILABLE:
self._update_label.setText(f'<span style="color:{GOOD};">{tag} available</span>') 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) self._update_btn.setVisible(True)
if self._alert_monitor.enabled and tag != self._notified_update_tag: if self._alert_monitor.enabled and tag != self._notified_update_tag:
self._notified_update_tag = tag # once per version, not every poll 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; }} QLabel {{ background: transparent; }}
#Sidebar {{ background: {SIDEBAR}; border-right: 1px solid {CARD_BORDER}; }} #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; }} #AppTitle {{ font-size: 17px; font-weight: 800; }}
#AppSubtitle {{ color: {MUTED}; font-size: 11px; }} #AppSubtitle {{ color: {MUTED}; font-size: 11px; }}
+28
View File
@@ -1,6 +1,8 @@
"""Tests for the M5 system inventory (render + dict round-trip; collect on real system).""" """Tests for the M5 system inventory (render + dict round-trip; collect on real system)."""
import tempfile
import unittest import unittest
from pathlib import Path
from rigdoctor.core import inventory from rigdoctor.core import inventory
from rigdoctor.core.inventory import Section from rigdoctor.core.inventory import Section
@@ -26,5 +28,31 @@ class InventoryTests(unittest.TestCase):
self.assertIn("- **Model:** Test CPU", md) 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__": if __name__ == "__main__":
unittest.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()