Merge pull request 'refactor(gui): grouped navigation + clearer page names — 0.20.0' (#16) from feat/m11-tray into main
release / release (push) Successful in 15s
release / release (push) Successful in 15s
Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
@@ -5,6 +5,19 @@ 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.20.0] - 2026-05-22
|
||||||
|
### Changed
|
||||||
|
- **Reorganized navigation** into grouped sidebar sections — **Monitor** (Dashboard) ·
|
||||||
|
**Diagnose** (Games, Recordings, System Health, Tuning) · **System** (Inventory) · **App**
|
||||||
|
(Settings, Share) — so it's clear where to go.
|
||||||
|
- **Renames for clarity:** *Health → System Health* (it's the overall 7-day system scan, not
|
||||||
|
per-game), *Environment → Tuning* (gaming tunables + fixes), *Logs → Recordings*,
|
||||||
|
*Setup → Settings*.
|
||||||
|
- **Settings** absorbed **Notifications** (alerts) — app configuration (components/deps, alerts,
|
||||||
|
account access, uninstall) now lives in one page; Notifications is no longer a separate item.
|
||||||
|
- **Recordings** is now a hub: pick which captured log to view (always-on capture, last
|
||||||
|
diagnostic, or a preserved crash), **Analyze crash** in place, alongside the recorder controls.
|
||||||
|
|
||||||
## [0.19.0] - 2026-05-22
|
## [0.19.0] - 2026-05-22
|
||||||
### Added
|
### Added
|
||||||
- **System-tray applet (M11, D13).** A tray icon whose menu shows live **CPU / GPU temp** and
|
- **System-tray applet (M11, D13).** A tray icon whose menu shows live **CPU / GPU temp** and
|
||||||
|
|||||||
+7
-6
@@ -67,12 +67,13 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
|||||||
(`pending_crash`/`analyze_crash` + `health.check_previous_boot`). *Pending:* non-Steam
|
(`pending_crash`/`analyze_crash` + `health.check_previous_boot`). *Pending:* non-Steam
|
||||||
launchers (Lutris/Heroic), GPU power-profile (PowerMizer) checks, and the zero-config watcher.
|
launchers (Lutris/Heroic), GPU power-profile (PowerMizer) checks, and the zero-config watcher.
|
||||||
- **M8 Alerting** — threshold/event notifications; integrates with the tray applet (M11).
|
- **M8 Alerting** — threshold/event notifications; integrates with the tray applet (M11).
|
||||||
- **M10 Desktop GUI** — PySide6 graphical front-end over the core engine (dashboard, log
|
- **M10 Desktop GUI** — PySide6 graphical front-end over the core engine. Optional; adds the
|
||||||
browser, report viewer, logger controls). Optional; adds the Qt dependency. *Bootstrapped
|
Qt dependency. Dark-themed window with a **grouped sidebar** (Monitor / Diagnose / System /
|
||||||
early (ahead of its Phase 4 slot) at the user's request:* dark-themed window with sidebar
|
App) over: **Dashboard** (live history graphs + per-subsystem cards), **Games** (M6 detection
|
||||||
nav, a live dashboard (circular gauges + collapsible per-subsystem cards, temperature-
|
+ Run Diagnostic), **Recordings** (recorder controls + view/report any captured log + analyze
|
||||||
colored values), and a **Recording/Logs page** with full M3 controls (start/stop/status +
|
a crash), **System Health** (M4 scan), **Tuning** (M6 gaming tunables + fixes), **Inventory**
|
||||||
post-crash report). Health/Inventory remain placeholders until M4/M5. GUI-first per D17.
|
(M5), **Settings** (components/deps + alerts + account + uninstall), and **Share** (M12). A
|
||||||
|
global recording badge shows on every page. GUI-first per D17.
|
||||||
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. *Implemented (`gui/tray.py`, D13):*
|
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. *Implemented (`gui/tray.py`, D13):*
|
||||||
the menu shows live M1 readouts (CPU temp, GPU temp, memory used/total) + a status line
|
the menu shows live M1 readouts (CPU temp, GPU temp, memory used/total) + a status line
|
||||||
(Normal / Hot / GPU not responding), led by a **Run Diagnostic** submenu (per detected game →
|
(Normal / Hot / GPU not responding), led by a **Run Diagnostic** submenu (per detected game →
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rigdoctor"
|
name = "rigdoctor"
|
||||||
version = "0.19.0"
|
version = "0.20.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.19.0"
|
__version__ = "0.20.0"
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class EnvironmentPage(QWidget):
|
|||||||
root.setSpacing(16)
|
root.setSpacing(16)
|
||||||
|
|
||||||
header = QHBoxLayout()
|
header = QHBoxLayout()
|
||||||
title = QLabel("Environment")
|
title = QLabel("Tuning")
|
||||||
title.setObjectName("PageTitle")
|
title.setObjectName("PageTitle")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch(1)
|
header.addStretch(1)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class HealthPage(QWidget):
|
|||||||
root.setSpacing(16)
|
root.setSpacing(16)
|
||||||
|
|
||||||
header = QHBoxLayout()
|
header = QHBoxLayout()
|
||||||
title = QLabel("Health")
|
title = QLabel("System Health")
|
||||||
title.setObjectName("PageTitle")
|
title.setObjectName("PageTitle")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch(1)
|
header.addStretch(1)
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ from .environment_page import EnvironmentPage
|
|||||||
from .games_page import GamesPage
|
from .games_page import GamesPage
|
||||||
from .health_page import HealthPage
|
from .health_page import HealthPage
|
||||||
from .inventory_page import InventoryPage
|
from .inventory_page import InventoryPage
|
||||||
from .notifications_page import NotificationsPage
|
|
||||||
from .recorder_page import RecorderPage
|
from .recorder_page import RecorderPage
|
||||||
from .setup_page import SetupPage
|
from .setup_page import SetupPage
|
||||||
from .share_page import SharePage
|
from .share_page import SharePage
|
||||||
@@ -43,7 +42,15 @@ from .theme import ACCENT, CRIT, GOOD, MUTED, TEXT
|
|||||||
from .tray import TrayIcon
|
from .tray import TrayIcon
|
||||||
from .worker import SamplerWorker
|
from .worker import SamplerWorker
|
||||||
|
|
||||||
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Games", "Environment", "Inventory", "Setup", "Notifications", "Share"]
|
# Sidebar grouped by intent. Each page name maps to a widget built in __init__; the stack is
|
||||||
|
# filled in this order, so _PAGES.index(name) is the stack index.
|
||||||
|
_NAV = [
|
||||||
|
("Monitor", ["Dashboard"]),
|
||||||
|
("Diagnose", ["Games", "Recordings", "System Health", "Tuning"]),
|
||||||
|
("System", ["Inventory"]),
|
||||||
|
("App", ["Settings", "Share"]),
|
||||||
|
]
|
||||||
|
_PAGES = [name for _section, names in _NAV for name in names]
|
||||||
_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
|
_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
|
||||||
|
|
||||||
|
|
||||||
@@ -79,18 +86,21 @@ class MainWindow(QMainWindow):
|
|||||||
self.environment_page = EnvironmentPage()
|
self.environment_page = EnvironmentPage()
|
||||||
self.inventory_page = InventoryPage()
|
self.inventory_page = InventoryPage()
|
||||||
self.setup_page = SetupPage()
|
self.setup_page = SetupPage()
|
||||||
self.notifications_page = NotificationsPage()
|
self.setup_page.changed.connect(self._apply_alert_settings)
|
||||||
self.notifications_page.changed.connect(self._apply_alert_settings)
|
|
||||||
self.share_page = SharePage()
|
self.share_page = SharePage()
|
||||||
self._stack.addWidget(self.dashboard) # 0 Dashboard
|
# Page name → widget; the stack is filled in _PAGES order so indices line up.
|
||||||
self._stack.addWidget(self.recorder_page) # 1 Logs
|
self._pages = {
|
||||||
self._stack.addWidget(self.health_page) # 2 Health
|
"Dashboard": self.dashboard,
|
||||||
self._stack.addWidget(self.games_page) # 3 Games
|
"Games": self.games_page,
|
||||||
self._stack.addWidget(self.environment_page) # 4 Environment
|
"Recordings": self.recorder_page,
|
||||||
self._stack.addWidget(self.inventory_page) # 5 Inventory
|
"System Health": self.health_page,
|
||||||
self._stack.addWidget(self.setup_page) # 6 Setup
|
"Tuning": self.environment_page,
|
||||||
self._stack.addWidget(self.notifications_page) # 7 Notifications
|
"Inventory": self.inventory_page,
|
||||||
self._stack.addWidget(self.share_page) # 8 Share
|
"Settings": self.setup_page,
|
||||||
|
"Share": self.share_page,
|
||||||
|
}
|
||||||
|
for name in _PAGES:
|
||||||
|
self._stack.addWidget(self._pages[name])
|
||||||
content_layout.addWidget(self._stack)
|
content_layout.addWidget(self._stack)
|
||||||
|
|
||||||
layout.addWidget(self._build_sidebar())
|
layout.addWidget(self._build_sidebar())
|
||||||
@@ -186,16 +196,22 @@ class MainWindow(QMainWindow):
|
|||||||
group = QButtonGroup(self)
|
group = QButtonGroup(self)
|
||||||
group.setExclusive(True)
|
group.setExclusive(True)
|
||||||
self._nav_buttons: dict[str, QPushButton] = {}
|
self._nav_buttons: dict[str, QPushButton] = {}
|
||||||
for i, name in enumerate(_NAV_ITEMS):
|
for section, names in _NAV:
|
||||||
btn = QPushButton(name)
|
header = QLabel(section.upper())
|
||||||
btn.setObjectName("NavButton")
|
header.setObjectName("NavSection")
|
||||||
btn.setCheckable(True)
|
v.addSpacing(8)
|
||||||
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
v.addWidget(header)
|
||||||
btn.setChecked(i == 0)
|
for name in names:
|
||||||
btn.clicked.connect(lambda _checked, idx=i: self._stack.setCurrentIndex(idx))
|
idx = _PAGES.index(name)
|
||||||
group.addButton(btn, i)
|
btn = QPushButton(name)
|
||||||
v.addWidget(btn)
|
btn.setObjectName("NavButton")
|
||||||
self._nav_buttons[name] = btn
|
btn.setCheckable(True)
|
||||||
|
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
btn.setChecked(idx == 0)
|
||||||
|
btn.clicked.connect(lambda _checked, i=idx: self._stack.setCurrentIndex(i))
|
||||||
|
group.addButton(btn, idx)
|
||||||
|
v.addWidget(btn)
|
||||||
|
self._nav_buttons[name] = btn
|
||||||
|
|
||||||
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>')
|
||||||
@@ -287,7 +303,7 @@ class MainWindow(QMainWindow):
|
|||||||
def show_page(self, name: str) -> None:
|
def show_page(self, name: str) -> None:
|
||||||
"""Bring the window forward on a given page (used by the tray)."""
|
"""Bring the window forward on a given page (used by the tray)."""
|
||||||
if name in self._nav_buttons:
|
if name in self._nav_buttons:
|
||||||
self._stack.setCurrentIndex(_NAV_ITEMS.index(name))
|
self._stack.setCurrentIndex(_PAGES.index(name))
|
||||||
self._nav_buttons[name].setChecked(True)
|
self._nav_buttons[name].setChecked(True)
|
||||||
self.showNormal()
|
self.showNormal()
|
||||||
self.raise_()
|
self.raise_()
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
"""Notifications page (M8 config): user-configurable alert settings."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Signal
|
|
||||||
from PySide6.QtWidgets import (
|
|
||||||
QCheckBox,
|
|
||||||
QDoubleSpinBox,
|
|
||||||
QFrame,
|
|
||||||
QGridLayout,
|
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
|
||||||
QPushButton,
|
|
||||||
QVBoxLayout,
|
|
||||||
QWidget,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ..config import load_config, update_config
|
|
||||||
from ..core import alerts
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationsPage(QWidget):
|
|
||||||
changed = Signal() # settings saved — main window re-applies them live
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.setObjectName("Page")
|
|
||||||
root = QVBoxLayout(self)
|
|
||||||
root.setContentsMargins(20, 18, 20, 18)
|
|
||||||
root.setSpacing(16)
|
|
||||||
|
|
||||||
title = QLabel("Notifications")
|
|
||||||
title.setObjectName("PageTitle")
|
|
||||||
root.addWidget(title)
|
|
||||||
|
|
||||||
card = QFrame()
|
|
||||||
card.setObjectName("Card")
|
|
||||||
v = QVBoxLayout(card)
|
|
||||||
v.setContentsMargins(16, 14, 16, 14)
|
|
||||||
v.setSpacing(10)
|
|
||||||
head = QLabel("Alerts")
|
|
||||||
head.setStyleSheet("font-weight: 700; background: transparent;")
|
|
||||||
v.addWidget(head)
|
|
||||||
|
|
||||||
self._enabled = QCheckBox("Enable desktop notifications")
|
|
||||||
v.addWidget(self._enabled)
|
|
||||||
|
|
||||||
grid = QGridLayout()
|
|
||||||
grid.setHorizontalSpacing(12)
|
|
||||||
grid.setColumnStretch(2, 1)
|
|
||||||
self._gpu = self._spin()
|
|
||||||
self._cpu = self._spin()
|
|
||||||
grid.addWidget(QLabel("GPU temperature alert"), 0, 0)
|
|
||||||
grid.addWidget(self._gpu, 0, 1)
|
|
||||||
grid.addWidget(QLabel("CPU temperature alert"), 1, 0)
|
|
||||||
grid.addWidget(self._cpu, 1, 1)
|
|
||||||
v.addLayout(grid)
|
|
||||||
|
|
||||||
note = QLabel("GPU-lost and new-version alerts are included whenever notifications are enabled.")
|
|
||||||
note.setObjectName("Muted")
|
|
||||||
note.setWordWrap(True)
|
|
||||||
v.addWidget(note)
|
|
||||||
|
|
||||||
buttons = QHBoxLayout()
|
|
||||||
save = QPushButton("Save")
|
|
||||||
save.setObjectName("PrimaryButton")
|
|
||||||
save.clicked.connect(self._save)
|
|
||||||
test = QPushButton("Send test")
|
|
||||||
test.clicked.connect(self._test)
|
|
||||||
buttons.addWidget(save)
|
|
||||||
buttons.addWidget(test)
|
|
||||||
buttons.addStretch(1)
|
|
||||||
v.addLayout(buttons)
|
|
||||||
self._status = QLabel("")
|
|
||||||
self._status.setObjectName("Muted")
|
|
||||||
v.addWidget(self._status)
|
|
||||||
|
|
||||||
root.addWidget(card)
|
|
||||||
root.addStretch(1)
|
|
||||||
self._load()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _spin() -> QDoubleSpinBox:
|
|
||||||
spin = QDoubleSpinBox()
|
|
||||||
spin.setRange(40, 110)
|
|
||||||
spin.setDecimals(0)
|
|
||||||
spin.setSingleStep(1)
|
|
||||||
spin.setSuffix(" °C")
|
|
||||||
return spin
|
|
||||||
|
|
||||||
def _load(self) -> None:
|
|
||||||
cfg = load_config()
|
|
||||||
self._enabled.setChecked(bool(cfg.get("alerts_enabled", True)))
|
|
||||||
self._gpu.setValue(float(cfg.get("gpu_temp_alert", 90.0)))
|
|
||||||
self._cpu.setValue(float(cfg.get("cpu_temp_alert", 95.0)))
|
|
||||||
|
|
||||||
def _save(self) -> None:
|
|
||||||
update_config(
|
|
||||||
alerts_enabled=self._enabled.isChecked(),
|
|
||||||
gpu_temp_alert=self._gpu.value(),
|
|
||||||
cpu_temp_alert=self._cpu.value(),
|
|
||||||
)
|
|
||||||
self.changed.emit()
|
|
||||||
self._status.setText("Saved.")
|
|
||||||
|
|
||||||
def _test(self) -> None:
|
|
||||||
ok = alerts.notify("RigDoctor", "Test notification — alerts are working.")
|
|
||||||
self._status.setText("Test notification sent." if ok else "notify-send not found — install libnotify-bin (Setup).")
|
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
"""Recording & Logs page (M3 in the GUI): start/stop/status + post-crash report.
|
"""Recordings page (M3 in the GUI): recorder controls + view/report any captured log.
|
||||||
|
|
||||||
Drives the same background recorder as the CLI via core.reccontrol, so the GUI and
|
Drives the same background recorder as the CLI via core.reccontrol, and surfaces the
|
||||||
`rigdoctor record …` are interchangeable.
|
captured data — the always-on log, the last guided diagnostic, and a preserved hard-crash
|
||||||
|
(which can be analyzed in place). One place to see what was captured and what it means.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QTimer, QUrl
|
from PySide6.QtCore import Qt, QTimer, QUrl, Signal
|
||||||
from PySide6.QtGui import QDesktopServices, QFont
|
from PySide6.QtGui import QDesktopServices, QFont
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -25,6 +28,7 @@ from .. import config
|
|||||||
from ..core import reccontrol
|
from ..core import reccontrol
|
||||||
from ..core.crashlog import summarize
|
from ..core.crashlog import summarize
|
||||||
from ..render import format_headline, render_summary
|
from ..render import format_headline, render_summary
|
||||||
|
from .diagnostic_dialog import DiagnosticDialog
|
||||||
from .theme import GOOD, MUTED, WARN
|
from .theme import GOOD, MUTED, WARN
|
||||||
|
|
||||||
|
|
||||||
@@ -45,31 +49,30 @@ def _fmt_time(value, fmt="%Y-%m-%d %H:%M:%S") -> str:
|
|||||||
|
|
||||||
|
|
||||||
class RecorderPage(QWidget):
|
class RecorderPage(QWidget):
|
||||||
|
_analyzed = Signal(object) # DiagnosticResult from a crash analysis
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setObjectName("Page")
|
self.setObjectName("Page")
|
||||||
|
self._analyzed.connect(self._show_analysis)
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
root.setContentsMargins(20, 18, 20, 18)
|
root.setContentsMargins(20, 18, 20, 18)
|
||||||
root.setSpacing(16)
|
root.setSpacing(16)
|
||||||
|
|
||||||
title = QLabel("Recording")
|
title = QLabel("Recordings")
|
||||||
title.setObjectName("PageTitle")
|
title.setObjectName("PageTitle")
|
||||||
root.addWidget(title)
|
root.addWidget(title)
|
||||||
|
|
||||||
# --- Status + controls -------------------------------------------------
|
# --- Status + controls -------------------------------------------------
|
||||||
status_card, status_layout = _panel("Status")
|
status_card, status_layout = _panel("Status")
|
||||||
|
|
||||||
self._state = QLabel("○ Not recording")
|
self._state = QLabel("○ Not recording")
|
||||||
self._state.setStyleSheet(f"color: {MUTED}; font-weight: 700; background: transparent;")
|
self._state.setStyleSheet(f"color: {MUTED}; font-weight: 700; background: transparent;")
|
||||||
status_layout.addWidget(self._state)
|
status_layout.addWidget(self._state)
|
||||||
|
|
||||||
self._info = QLabel("")
|
self._info = QLabel("")
|
||||||
self._info.setObjectName("Muted")
|
self._info.setObjectName("Muted")
|
||||||
status_layout.addWidget(self._info)
|
status_layout.addWidget(self._info)
|
||||||
|
|
||||||
self._latest = QLabel("")
|
self._latest = QLabel("")
|
||||||
status_layout.addWidget(self._latest)
|
status_layout.addWidget(self._latest)
|
||||||
|
|
||||||
self._warn = QLabel("")
|
self._warn = QLabel("")
|
||||||
self._warn.setStyleSheet(f"color: {WARN}; font-weight: 600; background: transparent;")
|
self._warn.setStyleSheet(f"color: {WARN}; font-weight: 600; background: transparent;")
|
||||||
self._warn.setVisible(False)
|
self._warn.setVisible(False)
|
||||||
@@ -97,19 +100,20 @@ class RecorderPage(QWidget):
|
|||||||
status_layout.addLayout(controls)
|
status_layout.addLayout(controls)
|
||||||
root.addWidget(status_card)
|
root.addWidget(status_card)
|
||||||
|
|
||||||
# --- Report ------------------------------------------------------------
|
# --- Captured logs -----------------------------------------------------
|
||||||
report_card = QFrame()
|
report_card, report_layout = _panel("Captured logs")
|
||||||
report_card.setObjectName("Card")
|
|
||||||
report_layout = QVBoxLayout(report_card)
|
|
||||||
report_layout.setContentsMargins(16, 14, 16, 14)
|
|
||||||
report_layout.setSpacing(10)
|
|
||||||
header = QHBoxLayout()
|
header = QHBoxLayout()
|
||||||
report_title = QLabel("Post-crash report")
|
header.addWidget(QLabel("Show:"))
|
||||||
report_title.setStyleSheet("font-weight: 700; background: transparent;")
|
self._source = QComboBox()
|
||||||
header.addWidget(report_title)
|
self._source.currentIndexChanged.connect(self._load_report)
|
||||||
header.addStretch(1)
|
header.addWidget(self._source, 1)
|
||||||
|
self._analyze_btn = QPushButton("Analyze crash")
|
||||||
|
self._analyze_btn.setObjectName("ActionButton")
|
||||||
|
self._analyze_btn.clicked.connect(self._analyze_crash)
|
||||||
|
self._analyze_btn.setVisible(False)
|
||||||
|
header.addWidget(self._analyze_btn)
|
||||||
refresh_btn = QPushButton("Refresh")
|
refresh_btn = QPushButton("Refresh")
|
||||||
refresh_btn.clicked.connect(self._load_report)
|
refresh_btn.clicked.connect(self._refresh_sources)
|
||||||
header.addWidget(refresh_btn)
|
header.addWidget(refresh_btn)
|
||||||
report_layout.addLayout(header)
|
report_layout.addLayout(header)
|
||||||
|
|
||||||
@@ -121,13 +125,12 @@ class RecorderPage(QWidget):
|
|||||||
report_layout.addWidget(self._report)
|
report_layout.addWidget(self._report)
|
||||||
root.addWidget(report_card, 1)
|
root.addWidget(report_card, 1)
|
||||||
|
|
||||||
# Poll recorder status once a second (reflects CLI-driven sessions too).
|
|
||||||
self._timer = QTimer(self)
|
self._timer = QTimer(self)
|
||||||
self._timer.setInterval(1000)
|
self._timer.setInterval(1000)
|
||||||
self._timer.timeout.connect(self._refresh_status)
|
self._timer.timeout.connect(self._refresh_status)
|
||||||
self._timer.start()
|
self._timer.start()
|
||||||
self._refresh_status()
|
self._refresh_status()
|
||||||
self._load_report()
|
self._refresh_sources()
|
||||||
|
|
||||||
# --- actions ---------------------------------------------------------------
|
# --- actions ---------------------------------------------------------------
|
||||||
def _on_start(self) -> None:
|
def _on_start(self) -> None:
|
||||||
@@ -139,12 +142,56 @@ class RecorderPage(QWidget):
|
|||||||
self._stop_btn.setEnabled(False)
|
self._stop_btn.setEnabled(False)
|
||||||
reccontrol.stop_background()
|
reccontrol.stop_background()
|
||||||
QTimer.singleShot(600, self._refresh_status)
|
QTimer.singleShot(600, self._refresh_status)
|
||||||
QTimer.singleShot(900, self._load_report)
|
QTimer.singleShot(900, self._refresh_sources)
|
||||||
|
|
||||||
def _open_folder(self) -> None:
|
def _open_folder(self) -> None:
|
||||||
config.LOG_DIR.mkdir(parents=True, exist_ok=True)
|
config.LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(config.LOG_DIR)))
|
QDesktopServices.openUrl(QUrl.fromLocalFile(str(config.LOG_DIR)))
|
||||||
|
|
||||||
|
# --- captured logs ---------------------------------------------------------
|
||||||
|
def _refresh_sources(self) -> None:
|
||||||
|
from ..core import diagnostic
|
||||||
|
|
||||||
|
current = self._source.currentData()
|
||||||
|
self._source.blockSignals(True)
|
||||||
|
self._source.clear()
|
||||||
|
self._source.addItem("Always-on capture", str(config.LOG_FILE))
|
||||||
|
if config.DIAG_LOG.exists():
|
||||||
|
self._source.addItem("Last diagnostic", str(config.DIAG_LOG))
|
||||||
|
if config.DIAG_CRASH.exists():
|
||||||
|
self._source.addItem("Crash (unanalyzed)", str(config.DIAG_CRASH))
|
||||||
|
# keep the previous selection if it's still present
|
||||||
|
idx = self._source.findData(current) if current else -1
|
||||||
|
self._source.setCurrentIndex(idx if idx >= 0 else 0)
|
||||||
|
self._source.blockSignals(False)
|
||||||
|
self._analyze_btn.setVisible(diagnostic.pending_crash() is not None)
|
||||||
|
self._load_report()
|
||||||
|
|
||||||
|
def _load_report(self) -> None:
|
||||||
|
path = self._source.currentData() or str(config.LOG_FILE)
|
||||||
|
summary = summarize(path, last_n=10)
|
||||||
|
self._report.setPlainText(render_summary(summary, log_path=path))
|
||||||
|
|
||||||
|
def _analyze_crash(self) -> None:
|
||||||
|
self._analyze_btn.setEnabled(False)
|
||||||
|
self._report.setPlainText("Analyzing the crash (final readings + system logs)…")
|
||||||
|
threading.Thread(target=self._work_analyze, daemon=True).start()
|
||||||
|
|
||||||
|
def _work_analyze(self) -> None:
|
||||||
|
from ..core import diagnostic
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = diagnostic.analyze_crash()
|
||||||
|
except Exception:
|
||||||
|
result = None
|
||||||
|
self._analyzed.emit(result)
|
||||||
|
|
||||||
|
def _show_analysis(self, result) -> None:
|
||||||
|
self._analyze_btn.setEnabled(True)
|
||||||
|
if result is not None:
|
||||||
|
DiagnosticDialog(result, self).exec()
|
||||||
|
self._refresh_sources()
|
||||||
|
|
||||||
# --- refresh ---------------------------------------------------------------
|
# --- refresh ---------------------------------------------------------------
|
||||||
def _refresh_status(self) -> None:
|
def _refresh_status(self) -> None:
|
||||||
pid = reccontrol.running_pid()
|
pid = reccontrol.running_pid()
|
||||||
@@ -162,8 +209,10 @@ class RecorderPage(QWidget):
|
|||||||
self._interval.setEnabled(not running)
|
self._interval.setEnabled(not running)
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
|
game = status.get("game")
|
||||||
|
game_line = f"Game: {game} " if game else ""
|
||||||
self._info.setText(
|
self._info.setText(
|
||||||
f"Samples: {status.get('samples', 0)} "
|
f"{game_line}Samples: {status.get('samples', 0)} "
|
||||||
f"Started: {_fmt_time(status.get('started'))} "
|
f"Started: {_fmt_time(status.get('started'))} "
|
||||||
f"Updated: {_fmt_time(status.get('updated'), '%H:%M:%S')}\n"
|
f"Updated: {_fmt_time(status.get('updated'), '%H:%M:%S')}\n"
|
||||||
f"Log: {status.get('log', config.LOG_FILE)}"
|
f"Log: {status.get('log', config.LOG_FILE)}"
|
||||||
@@ -179,7 +228,3 @@ class RecorderPage(QWidget):
|
|||||||
self._info.setText("No recording yet. Press “Start recording”.")
|
self._info.setText("No recording yet. Press “Start recording”.")
|
||||||
self._latest.setText("")
|
self._latest.setText("")
|
||||||
self._warn.setVisible(False)
|
self._warn.setVisible(False)
|
||||||
|
|
||||||
def _load_report(self) -> None:
|
|
||||||
summary = summarize(config.LOG_FILE, last_n=10)
|
|
||||||
self._report.setPlainText(render_summary(summary, log_path=config.LOG_FILE))
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Setup page (M9 in the GUI): show environment + optional components, install missing."""
|
"""Settings page: components/deps, alerts (M8), account access (token), and uninstall."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,7 +8,10 @@ from PySide6.QtCore import Qt, QUrl, Signal
|
|||||||
from PySide6.QtGui import QDesktopServices
|
from PySide6.QtGui import QDesktopServices
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
|
QCheckBox,
|
||||||
|
QDoubleSpinBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
|
QGridLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
@@ -21,7 +24,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .. import config
|
from .. import config
|
||||||
from ..core import installer, sysenv, uninstall, updates
|
from ..core import alerts, installer, sysenv, uninstall, updates
|
||||||
from .theme import GOOD, MUTED, WARN
|
from .theme import GOOD, MUTED, WARN
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ _BACKEND_DESC = {
|
|||||||
class SetupPage(QWidget):
|
class SetupPage(QWidget):
|
||||||
_installed = Signal(int, str)
|
_installed = Signal(int, str)
|
||||||
_upd_state = Signal(object)
|
_upd_state = Signal(object)
|
||||||
|
changed = Signal() # alert settings saved — main window re-applies them live
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -60,7 +64,7 @@ class SetupPage(QWidget):
|
|||||||
root.setContentsMargins(20, 18, 20, 18)
|
root.setContentsMargins(20, 18, 20, 18)
|
||||||
root.setSpacing(16)
|
root.setSpacing(16)
|
||||||
|
|
||||||
title = QLabel("Setup")
|
title = QLabel("Settings")
|
||||||
title.setObjectName("PageTitle")
|
title.setObjectName("PageTitle")
|
||||||
root.addWidget(title)
|
root.addWidget(title)
|
||||||
|
|
||||||
@@ -70,7 +74,7 @@ class SetupPage(QWidget):
|
|||||||
env_layout.addWidget(self._env)
|
env_layout.addWidget(self._env)
|
||||||
root.addWidget(env_card)
|
root.addWidget(env_card)
|
||||||
|
|
||||||
comp_card, comp_layout = _panel("Optional components")
|
comp_card, comp_layout = _panel("Components & dependencies")
|
||||||
self._components = QVBoxLayout()
|
self._components = QVBoxLayout()
|
||||||
self._components.setSpacing(6)
|
self._components.setSpacing(6)
|
||||||
comp_layout.addLayout(self._components)
|
comp_layout.addLayout(self._components)
|
||||||
@@ -86,6 +90,39 @@ class SetupPage(QWidget):
|
|||||||
comp_layout.addLayout(controls)
|
comp_layout.addLayout(controls)
|
||||||
root.addWidget(comp_card)
|
root.addWidget(comp_card)
|
||||||
|
|
||||||
|
# Alerts (M8) — folded in from the old Notifications page.
|
||||||
|
alerts_card, alerts_layout = _panel("Notifications")
|
||||||
|
self._alerts_enabled = QCheckBox("Enable desktop notifications")
|
||||||
|
alerts_layout.addWidget(self._alerts_enabled)
|
||||||
|
grid = QGridLayout()
|
||||||
|
grid.setHorizontalSpacing(12)
|
||||||
|
grid.setColumnStretch(2, 1)
|
||||||
|
self._gpu_alert = self._spin()
|
||||||
|
self._cpu_alert = self._spin()
|
||||||
|
grid.addWidget(QLabel("GPU temperature alert"), 0, 0)
|
||||||
|
grid.addWidget(self._gpu_alert, 0, 1)
|
||||||
|
grid.addWidget(QLabel("CPU temperature alert"), 1, 0)
|
||||||
|
grid.addWidget(self._cpu_alert, 1, 1)
|
||||||
|
alerts_layout.addLayout(grid)
|
||||||
|
alerts_note = QLabel("GPU-lost and new-version alerts are included whenever notifications are enabled.")
|
||||||
|
alerts_note.setObjectName("Muted")
|
||||||
|
alerts_note.setWordWrap(True)
|
||||||
|
alerts_layout.addWidget(alerts_note)
|
||||||
|
alerts_buttons = QHBoxLayout()
|
||||||
|
save_alerts = QPushButton("Save")
|
||||||
|
save_alerts.setObjectName("PrimaryButton")
|
||||||
|
save_alerts.clicked.connect(self._save_alerts)
|
||||||
|
test_alerts = QPushButton("Send test")
|
||||||
|
test_alerts.clicked.connect(self._test_alerts)
|
||||||
|
alerts_buttons.addWidget(save_alerts)
|
||||||
|
alerts_buttons.addWidget(test_alerts)
|
||||||
|
alerts_buttons.addStretch(1)
|
||||||
|
self._alerts_status = QLabel("")
|
||||||
|
self._alerts_status.setObjectName("Muted")
|
||||||
|
alerts_buttons.addWidget(self._alerts_status)
|
||||||
|
alerts_layout.addLayout(alerts_buttons)
|
||||||
|
root.addWidget(alerts_card)
|
||||||
|
|
||||||
# Account access (M13/M12): one Gitea token gates updates and session sharing.
|
# Account access (M13/M12): one Gitea token gates updates and session sharing.
|
||||||
upd_card, upd_layout = _panel("Account access")
|
upd_card, upd_layout = _panel("Account access")
|
||||||
hint = QLabel("A Gitea access token unlocks updates and session sharing. "
|
hint = QLabel("A Gitea access token unlocks updates and session sharing. "
|
||||||
@@ -115,7 +152,7 @@ class SetupPage(QWidget):
|
|||||||
self._output = QTextEdit()
|
self._output = QTextEdit()
|
||||||
self._output.setObjectName("Report")
|
self._output.setObjectName("Report")
|
||||||
self._output.setReadOnly(True)
|
self._output.setReadOnly(True)
|
||||||
self._output.setMinimumHeight(180)
|
self._output.setMinimumHeight(160)
|
||||||
self._output.setVisible(False)
|
self._output.setVisible(False)
|
||||||
root.addWidget(self._output)
|
root.addWidget(self._output)
|
||||||
root.addStretch(1)
|
root.addStretch(1)
|
||||||
@@ -129,8 +166,39 @@ class SetupPage(QWidget):
|
|||||||
root.addLayout(danger)
|
root.addLayout(danger)
|
||||||
|
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
self._load_alerts()
|
||||||
self._refresh_update_status()
|
self._refresh_update_status()
|
||||||
|
|
||||||
|
# --- alerts (M8) ----------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _spin() -> QDoubleSpinBox:
|
||||||
|
spin = QDoubleSpinBox()
|
||||||
|
spin.setRange(40, 110)
|
||||||
|
spin.setDecimals(0)
|
||||||
|
spin.setSingleStep(1)
|
||||||
|
spin.setSuffix(" °C")
|
||||||
|
return spin
|
||||||
|
|
||||||
|
def _load_alerts(self) -> None:
|
||||||
|
cfg = config.load_config()
|
||||||
|
self._alerts_enabled.setChecked(bool(cfg.get("alerts_enabled", True)))
|
||||||
|
self._gpu_alert.setValue(float(cfg.get("gpu_temp_alert", 90.0)))
|
||||||
|
self._cpu_alert.setValue(float(cfg.get("cpu_temp_alert", 95.0)))
|
||||||
|
|
||||||
|
def _save_alerts(self) -> None:
|
||||||
|
config.update_config(
|
||||||
|
alerts_enabled=self._alerts_enabled.isChecked(),
|
||||||
|
gpu_temp_alert=self._gpu_alert.value(),
|
||||||
|
cpu_temp_alert=self._cpu_alert.value(),
|
||||||
|
)
|
||||||
|
self.changed.emit()
|
||||||
|
self._alerts_status.setText("Saved.")
|
||||||
|
|
||||||
|
def _test_alerts(self) -> None:
|
||||||
|
ok = alerts.notify("RigDoctor", "Test notification — alerts are working.")
|
||||||
|
self._alerts_status.setText(
|
||||||
|
"Test sent." if ok else "notify-send not found — install libnotify-bin above.")
|
||||||
|
|
||||||
def _uninstall(self) -> None:
|
def _uninstall(self) -> None:
|
||||||
box = QMessageBox(self)
|
box = QMessageBox(self)
|
||||||
box.setIcon(QMessageBox.Icon.Warning)
|
box.setIcon(QMessageBox.Icon.Warning)
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ QPushButton#NavButton {{
|
|||||||
}}
|
}}
|
||||||
QPushButton#NavButton:hover {{ background: {CARD}; color: {TEXT}; }}
|
QPushButton#NavButton:hover {{ background: {CARD}; color: {TEXT}; }}
|
||||||
QPushButton#NavButton:checked {{ background: {CARD}; color: #ffffff; font-weight: 600; }}
|
QPushButton#NavButton:checked {{ background: {CARD}; color: #ffffff; font-weight: 600; }}
|
||||||
|
QLabel#NavSection {{ color: {MUTED}; font-size: 10px; font-weight: 800; letter-spacing: 1px; padding: 2px 12px 0; }}
|
||||||
|
|
||||||
#Card {{ background: {CARD}; border: 1px solid {CARD_BORDER}; border-radius: 12px; }}
|
#Card {{ background: {CARD}; border: 1px solid {CARD_BORDER}; border-radius: 12px; }}
|
||||||
QPushButton#CardHeader {{
|
QPushButton#CardHeader {{
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ class GuiSmokeTests(unittest.TestCase):
|
|||||||
mock.patch.object(updates, "update_state", return_value=(updates.UP_TO_DATE, None, "")):
|
mock.patch.object(updates, "update_state", return_value=(updates.UP_TO_DATE, None, "")):
|
||||||
window = mw.MainWindow()
|
window = mw.MainWindow()
|
||||||
try:
|
try:
|
||||||
self.assertEqual(len(window._nav_buttons), len(mw._NAV_ITEMS))
|
self.assertEqual(len(window._nav_buttons), len(mw._PAGES))
|
||||||
|
self.assertEqual(set(window._nav_buttons), set(mw._PAGES))
|
||||||
finally:
|
finally:
|
||||||
window._worker.stop()
|
window._worker.stop()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user