feat(gui): Run Diagnostic flow on the Games page — 0.12.0

Brings the guided diagnostic (0.11.0 core/CLI) into the GUI:
- Each game row gets a "Run Diagnostic" button → starts a focused, game-tagged
  capture and shows a recording banner (live sample count + GPU-lost indicator)
  with Finish & analyze / Discard.
- Finishing runs core.diagnostic.finish() off the UI thread and opens a results
  dialog (gui/diagnostic_dialog.py): window-scoped capture summary + findings
  cards (reusing render_summary + finding_card).
- Banner restores on showEvent if a capture is still running (navigate away/back).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 08:32:04 +02:00
parent 5682878f22
commit 934b489fec
6 changed files with 210 additions and 7 deletions
+9
View File
@@ -5,6 +5,15 @@ 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.12.0] - 2026-05-22
### Added
- **Guided diagnostic in the GUI.** Each game on the **Games** page now has a **Run Diagnostic**
button → a focused, game-tagged capture starts and a recording banner appears (live sample
count, GPU-lost indicator) with **Finish & analyze** / **Discard**. Finishing opens a results
dialog: the window-scoped capture summary (peak temps/power, events, last samples) plus the
health findings as cards. The banner persists/restores if you navigate away and back while a
capture is running. Shares `core/diagnostic.py` with the CLI (one flow, three front-ends).
## [0.11.0] - 2026-05-22
### Added
- **Guided diagnostic session (CLI) — the seed use case, end to end.** `rigdoctor diagnose
+6 -4
View File
@@ -41,10 +41,12 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
- [ ] M11 tray / menu-bar applet (QSystemTrayIcon: live M1 readouts + Run Diagnostic +
supporting actions — D13)
- [~] Guided diagnostic session (pick game → focused M3 capture → M4 scan → findings),
shared by tray/GUI/CLI — *core + CLI done* (`core/diagnostic.py`, `rigdoctor diagnose
start/status/finish`): tags a focused capture with the chosen game (own diagnostic log,
window-scoped report) and combines the capture summary with the M4 findings. *Pending:*
the GUI/tray "Run Diagnostic" button, and auto start/stop via the D12 wrapper/watcher.
shared by tray/GUI/CLI — *core + CLI + GUI done* (`core/diagnostic.py`, `rigdoctor
diagnose start/status/finish`, and a **Run Diagnostic** button per game on the GUI Games
page → recording banner → results dialog with the capture summary + findings). Tags a
focused capture with the chosen game (own diagnostic log, window-scoped report) and
combines the capture summary with the M4 findings. *Pending:* the tray (M11) entry point,
and auto start/stop via the D12 wrapper/watcher.
- [ ] Logger trigger modes: always-on + game-launch (D12 — wrapper first:
`rigdoctor wrap %command%` + global Steam compat-tool; zero-config watcher
(Steam RunningAppID + /proc) and GameMode hook follow)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rigdoctor"
version = "0.11.0"
version = "0.12.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.11.0"
__version__ = "0.12.0"
+81
View File
@@ -0,0 +1,81 @@
"""Results view for a guided diagnostic session (M6/D12): capture summary + findings."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from ..render import render_summary
from .widgets import finding_card
class DiagnosticDialog(QDialog):
def __init__(self, result, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle(f"Diagnostic — {result.game}" if result.game else "Diagnostic")
self.resize(660, 680)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 16)
root.setSpacing(14)
title = QLabel(f"Diagnostic — {result.game}" if result.game else "Diagnostic")
title.setObjectName("PageTitle")
root.addWidget(title)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("background: transparent;")
body = QWidget()
col = QVBoxLayout(body)
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(10)
col.setAlignment(Qt.AlignmentFlag.AlignTop)
# Capture window summary (peaks / events / last samples) — monospace for the columns.
cap_head = QLabel("Capture")
cap_head.setStyleSheet("font-weight: 700; background: transparent;")
col.addWidget(cap_head)
summary = QLabel(render_summary(result.summary))
summary.setObjectName("Report")
summary.setFont(QFont("monospace"))
summary.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
summary.setWordWrap(False)
summary.setStyleSheet(
"background: #0d0f13; color: #cfd3da; border: 1px solid #2a2f39; "
"border-radius: 8px; padding: 10px;"
)
col.addWidget(summary)
find_head = QLabel(f"Findings ({len(result.findings)})")
find_head.setStyleSheet("font-weight: 700; background: transparent;")
col.addWidget(find_head)
if result.findings:
for finding in result.findings:
col.addWidget(finding_card(finding))
else:
none = QLabel("No findings.")
none.setObjectName("Muted")
col.addWidget(none)
scroll.setWidget(body)
root.addWidget(scroll, 1)
buttons = QHBoxLayout()
buttons.addStretch(1)
close = QPushButton("Close")
close.setObjectName("PrimaryButton")
close.clicked.connect(self.accept)
buttons.addWidget(close)
root.addLayout(buttons)
+112 -1
View File
@@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QScrollArea,
QVBoxLayout,
@@ -24,10 +25,11 @@ from PySide6.QtWidgets import (
)
from ..config import load_config, update_config
from .diagnostic_dialog import DiagnosticDialog
from .theme import ACCENT, GOOD, MUTED
def _game_row(name: str, sublabel: str, size: str, is_new: bool) -> QFrame:
def _game_row(name: str, sublabel: str, size: str, is_new: bool, on_diagnose=None) -> QFrame:
card = QFrame()
card.setObjectName("Card")
h = QHBoxLayout(card)
@@ -59,6 +61,13 @@ def _game_row(name: str, sublabel: str, size: str, is_new: bool) -> QFrame:
size_label.setMinimumWidth(80)
size_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
h.addWidget(size_label, 0)
if on_diagnose is not None:
diag_btn = QPushButton("Run Diagnostic")
diag_btn.setObjectName("ActionButton")
diag_btn.setCursor(Qt.CursorShape.PointingHandCursor)
diag_btn.clicked.connect(lambda: on_diagnose(name))
h.addWidget(diag_btn, 0)
return card
@@ -66,14 +75,17 @@ class GamesPage(QWidget):
_libraries_ready = Signal(object) # list[dict(path, label, count, selected)]
_scanned = Signal(object) # steam.ScanResult
new_count_changed = Signal(int) # newly-installed game count (for the nav badge)
_diag_done = Signal(object) # DiagnosticResult — focused capture analyzed
def __init__(self) -> None:
super().__init__()
self.setObjectName("Page")
self._libraries_ready.connect(self._render_libraries)
self._scanned.connect(self._render_games)
self._diag_done.connect(self._on_diag_done)
self._busy = False
self._new_appids: set[str] = set()
self._diag_game: str | None = None
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
@@ -93,6 +105,30 @@ class GamesPage(QWidget):
header.addWidget(self._rescan_btn)
root.addLayout(header)
# In-progress diagnostic banner (hidden until a focused capture is running).
self._banner = QFrame()
self._banner.setObjectName("Card")
self._banner.setStyleSheet(f"#Card {{ border: 1px solid {ACCENT}; }}")
banner_h = QHBoxLayout(self._banner)
banner_h.setContentsMargins(16, 10, 16, 10)
banner_h.setSpacing(10)
self._banner_label = QLabel("")
self._banner_label.setStyleSheet(f"color: {ACCENT}; font-weight: 700; background: transparent;")
banner_h.addWidget(self._banner_label, 1)
self._finish_btn = QPushButton("Finish & analyze")
self._finish_btn.setObjectName("ActionButton")
self._finish_btn.clicked.connect(self._finish_diagnostic)
banner_h.addWidget(self._finish_btn)
self._discard_btn = QPushButton("Discard")
self._discard_btn.clicked.connect(self._discard_diagnostic)
banner_h.addWidget(self._discard_btn)
self._banner.hide()
root.addWidget(self._banner)
self._diag_timer = QTimer(self)
self._diag_timer.setInterval(1000)
self._diag_timer.timeout.connect(self._poll_diag)
# Libraries (opt-in checkboxes)
lib_card = QFrame()
lib_card.setObjectName("Card")
@@ -233,9 +269,74 @@ class GamesPage(QWidget):
os.path.basename(g.library.rstrip("/")) or g.library,
steam.human_size(g.size_bytes),
g.appid in new_appids,
on_diagnose=self._start_diagnostic,
))
self._list.addStretch(1)
# --- guided diagnostic (M6/D12) ---------------------------------------------------
def _start_diagnostic(self, name: str) -> None:
from ..core import diagnostic
if diagnostic.is_running():
QMessageBox.information(
self, "RigDoctor",
"A capture is already running — finish or discard it first.")
return
if diagnostic.start(game=name) is None:
QMessageBox.warning(self, "RigDoctor", "Couldn't start the capture.")
return
self._diag_game = name
self._banner_label.setText(f"● Recording — {name} · starting…")
self._finish_btn.setEnabled(True)
self._discard_btn.setEnabled(True)
self._banner.show()
self._diag_timer.start()
def _poll_diag(self) -> None:
from ..core import diagnostic
status = diagnostic.active()
if not status:
self._diag_timer.stop() # recorder exited on its own
return
samples = status.get("samples", 0)
lost = " · GPU-lost seen" if status.get("gpu_lost") else ""
game = status.get("game") or self._diag_game or ""
self._banner_label.setText(f"● Recording — {game} · {samples} samples{lost}")
def _finish_diagnostic(self) -> None:
self._diag_timer.stop()
self._finish_btn.setEnabled(False)
self._discard_btn.setEnabled(False)
self._banner_label.setText("Analyzing… (running the health report)")
threading.Thread(target=self._work_finish, daemon=True).start()
def _work_finish(self) -> None:
from ..core import diagnostic
try:
result = diagnostic.finish()
except Exception:
result = None
self._diag_done.emit(result)
def _on_diag_done(self, result) -> None:
self._banner.hide()
self._finish_btn.setEnabled(True)
self._discard_btn.setEnabled(True)
if result is None:
QMessageBox.warning(self, "RigDoctor", "The diagnostic couldn't be analyzed.")
return
DiagnosticDialog(result, self).exec()
def _discard_diagnostic(self) -> None:
from ..core import reccontrol
self._diag_timer.stop()
reccontrol.stop_background()
self._banner.hide()
# --- nav badge integration --------------------------------------------------------
def showEvent(self, event) -> None: # noqa: N802 (Qt override)
@@ -247,3 +348,13 @@ class GamesPage(QWidget):
threading.Thread(target=steam.acknowledge_new, daemon=True).start()
self.new_count_changed.emit(0)
# Reflect a capture that's still running (e.g. started earlier, navigated back).
from ..core import diagnostic
if diagnostic.is_running():
status = diagnostic.active() or {}
self._diag_game = status.get("game") or self._diag_game
self._banner.show()
if not self._diag_timer.isActive():
self._diag_timer.start()