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:
@@ -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
|
(`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.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
|
## [0.11.0] - 2026-05-22
|
||||||
### Added
|
### Added
|
||||||
- **Guided diagnostic session (CLI) — the seed use case, end to end.** `rigdoctor diagnose
|
- **Guided diagnostic session (CLI) — the seed use case, end to end.** `rigdoctor diagnose
|
||||||
|
|||||||
+6
-4
@@ -41,10 +41,12 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
|
|||||||
- [ ] M11 tray / menu-bar applet (QSystemTrayIcon: live M1 readouts + Run Diagnostic +
|
- [ ] M11 tray / menu-bar applet (QSystemTrayIcon: live M1 readouts + Run Diagnostic +
|
||||||
supporting actions — D13)
|
supporting actions — D13)
|
||||||
- [~] Guided diagnostic session (pick game → focused M3 capture → M4 scan → findings),
|
- [~] Guided diagnostic session (pick game → focused M3 capture → M4 scan → findings),
|
||||||
shared by tray/GUI/CLI — *core + CLI done* (`core/diagnostic.py`, `rigdoctor diagnose
|
shared by tray/GUI/CLI — *core + CLI + GUI done* (`core/diagnostic.py`, `rigdoctor
|
||||||
start/status/finish`): tags a focused capture with the chosen game (own diagnostic log,
|
diagnose start/status/finish`, and a **Run Diagnostic** button per game on the GUI Games
|
||||||
window-scoped report) and combines the capture summary with the M4 findings. *Pending:*
|
page → recording banner → results dialog with the capture summary + findings). Tags a
|
||||||
the GUI/tray "Run Diagnostic" button, and auto start/stop via the D12 wrapper/watcher.
|
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:
|
- [ ] Logger trigger modes: always-on + game-launch (D12 — wrapper first:
|
||||||
`rigdoctor wrap %command%` + global Steam compat-tool; zero-config watcher
|
`rigdoctor wrap %command%` + global Steam compat-tool; zero-config watcher
|
||||||
(Steam RunningAppID + /proc) and GameMode hook follow)
|
(Steam RunningAppID + /proc) and GameMode hook follow)
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rigdoctor"
|
name = "rigdoctor"
|
||||||
version = "0.11.0"
|
version = "0.12.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,3 +1,3 @@
|
|||||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||||
|
|
||||||
__version__ = "0.11.0"
|
__version__ = "0.12.0"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
|
|||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QMessageBox,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QScrollArea,
|
QScrollArea,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
@@ -24,10 +25,11 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..config import load_config, update_config
|
from ..config import load_config, update_config
|
||||||
|
from .diagnostic_dialog import DiagnosticDialog
|
||||||
from .theme import ACCENT, GOOD, MUTED
|
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 = QFrame()
|
||||||
card.setObjectName("Card")
|
card.setObjectName("Card")
|
||||||
h = QHBoxLayout(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.setMinimumWidth(80)
|
||||||
size_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
size_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||||
h.addWidget(size_label, 0)
|
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
|
return card
|
||||||
|
|
||||||
|
|
||||||
@@ -66,14 +75,17 @@ class GamesPage(QWidget):
|
|||||||
_libraries_ready = Signal(object) # list[dict(path, label, count, selected)]
|
_libraries_ready = Signal(object) # list[dict(path, label, count, selected)]
|
||||||
_scanned = Signal(object) # steam.ScanResult
|
_scanned = Signal(object) # steam.ScanResult
|
||||||
new_count_changed = Signal(int) # newly-installed game count (for the nav badge)
|
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:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setObjectName("Page")
|
self.setObjectName("Page")
|
||||||
self._libraries_ready.connect(self._render_libraries)
|
self._libraries_ready.connect(self._render_libraries)
|
||||||
self._scanned.connect(self._render_games)
|
self._scanned.connect(self._render_games)
|
||||||
|
self._diag_done.connect(self._on_diag_done)
|
||||||
self._busy = False
|
self._busy = False
|
||||||
self._new_appids: set[str] = set()
|
self._new_appids: set[str] = set()
|
||||||
|
self._diag_game: str | None = None
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
root.setContentsMargins(20, 18, 20, 18)
|
root.setContentsMargins(20, 18, 20, 18)
|
||||||
@@ -93,6 +105,30 @@ class GamesPage(QWidget):
|
|||||||
header.addWidget(self._rescan_btn)
|
header.addWidget(self._rescan_btn)
|
||||||
root.addLayout(header)
|
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)
|
# Libraries (opt-in checkboxes)
|
||||||
lib_card = QFrame()
|
lib_card = QFrame()
|
||||||
lib_card.setObjectName("Card")
|
lib_card.setObjectName("Card")
|
||||||
@@ -233,9 +269,74 @@ class GamesPage(QWidget):
|
|||||||
os.path.basename(g.library.rstrip("/")) or g.library,
|
os.path.basename(g.library.rstrip("/")) or g.library,
|
||||||
steam.human_size(g.size_bytes),
|
steam.human_size(g.size_bytes),
|
||||||
g.appid in new_appids,
|
g.appid in new_appids,
|
||||||
|
on_diagnose=self._start_diagnostic,
|
||||||
))
|
))
|
||||||
self._list.addStretch(1)
|
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 --------------------------------------------------------
|
# --- nav badge integration --------------------------------------------------------
|
||||||
|
|
||||||
def showEvent(self, event) -> None: # noqa: N802 (Qt override)
|
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()
|
threading.Thread(target=steam.acknowledge_new, daemon=True).start()
|
||||||
self.new_count_changed.emit(0)
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user