Compare commits

...

4 Commits

Author SHA1 Message Date
jessey 51b7ed69bd Merge pull request 'feat: live monitor TUI (M2) — 0.21.0' (#17) from feat/m11-tray into main
release / release (push) Successful in 15s
Reviewed-on: #17
2026-05-22 07:38:17 +00:00
jessey 6fca2c9aba feat: live monitor TUI (M2) — 0.21.0
Upgrade `rigdoctor monitor` from a basic redraw to a stdlib curses dashboard
(tui.py): current / session-min / session-max per sensor, grouped by subsystem,
with temperature & utilization color bands (GPU-lost flagged red). q quits,
r resets min/max. Plain full-screen redraw fallback on a non-TTY (--plain forces
it). Pure track()/band() helpers are unit-tested; curses path verified in a pty.

Completes the Monitoring bundle (M2 + M8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:37:57 +02:00
jessey 4c5a6547ec 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
Reviewed-on: #16
2026-05-22 07:31:06 +00:00
jessey 587568e574 refactor(gui): grouped navigation + clearer page names — 0.20.0
Reshape the IA so it reads by intent instead of a flat pile of pages.

- Grouped sidebar: Monitor / Diagnose / System / App (section headers).
- Renames: Health → System Health, Environment → Tuning, Logs → Recordings,
  Setup → Settings.
- Settings absorbs Notifications (alerts) as a section; Notifications dropped as a
  separate page (notifications_page.py removed; SetupPage gains the alerts card +
  `changed` signal wired to the live alert monitor).
- Recordings is now a hub: a source dropdown to view any captured log (always-on /
  last diagnostic / preserved crash) + Analyze-crash in place, plus the recorder
  controls; status line now shows the captured game.
- main_window nav is data-driven (_NAV groups → _PAGES order → stack); show_page,
  badges, and tray flows updated. GUI smoke test asserts the new page set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:30:35 +02:00
16 changed files with 461 additions and 190 deletions
+21
View File
@@ -5,6 +5,27 @@ 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.21.0] - 2026-05-22
### Added
- **Live monitor TUI (M2).** `rigdoctor monitor` is now a proper **curses** dashboard:
current / session-min / session-max per sensor, grouped by subsystem, with temperature and
utilization **color bands** (and GPU-lost flagged red). `q` quits, `r` resets the session
min/max. Falls back to a plain full-screen redraw on a non-TTY (`--plain` forces it). The
terminal face of the same live data the GUI dashboard graphs. Completes the Monitoring bundle.
## [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
+12 -8
View File
@@ -11,7 +11,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
| M1 | Sensor core | Essential | none (nvidia-smi, sysfs) | all (NVIDIA first) | P0 | ✅ | | M1 | Sensor core | Essential | none (nvidia-smi, sysfs) | all (NVIDIA first) | P0 | ✅ |
| M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | ✅ | | M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | ✅ |
| M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | ✅ | | M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | ✅ |
| M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | | | M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | |
| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | ✅ | | M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | ✅ |
| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | ✅ | | M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | ✅ |
| M6 | Gaming env checks | Diagnostics | none | all | P2 | 🟨 | | M6 | Gaming env checks | Diagnostics | none | all | P2 | 🟨 |
@@ -41,7 +41,10 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
findings (see SPEC §4). *Implemented:* journalctl scan (Xid/panic/OOM/MCE/AER/thermal/amdgpu), findings (see SPEC §4). *Implemented:* journalctl scan (Xid/panic/OOM/MCE/AER/thermal/amdgpu),
SMART, NVIDIA driver-mismatch, journald-persistence + live-temp checks; `rigdoctor report` SMART, NVIDIA driver-mismatch, journald-persistence + live-temp checks; `rigdoctor report`
(text/JSON) + GUI Health tab. GPU-firmware verification deferred. (text/JSON) + GUI Health tab. GPU-firmware verification deferred.
- **M2 Live monitor** — depends on M1; the terminal "HWMonitor for Linux" face. Stdlib-only. - **M2 Live monitor** — the terminal "HWMonitor for Linux" face. *Implemented (`tui.py`):*
`rigdoctor monitor` is a stdlib **curses** dashboard — current / session-min / session-max
per sensor, grouped by subsystem, with temperature & utilization color bands; `q` quits,
`r` resets the min/max. Falls back to a plain redraw on a non-TTY (`--plain` forces it).
- **M5 / M6 Diagnostics** — inventory export + gaming-env checks; M6 flags risky settings and - **M5 / M6 Diagnostics** — inventory export + gaming-env checks; M6 flags risky settings and
suggests the fix command but does not apply it (D9). *M6 implemented (Steam detection first — suggests the fix command but does not apply it (D9). *M6 implemented (Steam detection first —
the D12 "pick a game" foundation):* discovers Steam installs + all library folders the D12 "pick a game" foundation):* discovers Steam installs + all library folders
@@ -67,12 +70,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 →
+2 -1
View File
@@ -22,7 +22,8 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
last readings + a plausible cause. last readings + a plausible cause.
## Phase 2 — Live monitor (terminal) ## Phase 2 — Live monitor (terminal)
- [ ] M2 TUI dashboard (current/min/max, grouped, throttle highlighting) - [x] M2 TUI dashboard (`rigdoctor monitor`, `tui.py`): curses, current/min/max grouped by
subsystem with temp/usage color bands; q quit / r reset; plain-redraw fallback on non-TTY
- [ ] M8 basic alerting (overheat/throttle/GPU-lost notifications) - [ ] M8 basic alerting (overheat/throttle/GPU-lost notifications)
## Phase 3 — Diagnostics breadth ## Phase 3 — Diagnostics breadth
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.19.0" version = "0.21.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.19.0" __version__ = "0.21.0"
+5 -11
View File
@@ -44,17 +44,10 @@ def cmd_snapshot(args) -> int:
def cmd_monitor(args) -> int: def cmd_monitor(args) -> int:
from .tui import run
interval = args.interval or load_config()["interval"] interval = args.interval or load_config()["interval"]
try: return run(interval, plain=getattr(args, "plain", False))
for sample in _sampler().stream(interval=interval):
# Basic full-screen redraw; the rich TUI (M2) comes later.
print("\033[2J\033[H", end="")
print(f"RigDoctor — live (every {interval:g}s, Ctrl-C to quit)\n")
print(render_snapshot(sample))
sys.stdout.flush()
except KeyboardInterrupt:
print()
return 0
def cmd_gui(args) -> int: def cmd_gui(args) -> int:
@@ -516,8 +509,9 @@ def build_parser() -> argparse.ArgumentParser:
sp.add_argument("--json", action="store_true", help="output JSON instead of text") sp.add_argument("--json", action="store_true", help="output JSON instead of text")
sp.set_defaults(func=cmd_snapshot) sp.set_defaults(func=cmd_snapshot)
mp = sub.add_parser("monitor", help="live-refreshing sensor view") mp = sub.add_parser("monitor", help="live monitor TUI (current/min/max, M2)")
mp.add_argument("-n", "--interval", type=float, default=None, help="refresh interval (s)") mp.add_argument("-n", "--interval", type=float, default=None, help="refresh interval (s)")
mp.add_argument("--plain", action="store_true", help="plain redraw instead of the curses UI")
mp.set_defaults(func=cmd_monitor) mp.set_defaults(func=cmd_monitor)
sub.add_parser("gui", help="launch the desktop GUI (needs PySide6)").set_defaults(func=cmd_gui) sub.add_parser("gui", help="launch the desktop GUI (needs PySide6)").set_defaults(func=cmd_gui)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+40 -24
View File
@@ -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_()
-108
View File
@@ -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).")
+73 -28
View File
@@ -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))
+73 -5
View 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)
+1
View File
@@ -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 {{
+170
View File
@@ -0,0 +1,170 @@
"""Live monitor TUI (M2): a curses HWMonitor-style terminal dashboard.
Shows current / session-min / session-max per sensor, grouped by subsystem, with
temperature and utilization color bands. stdlib `curses` only; falls back to a plain
full-screen redraw when stdout isn't a TTY (piped/SSH-without-tty). Keys: q quit, r reset
the session min/max. The terminal face of the same live data the GUI dashboard graphs.
"""
from __future__ import annotations
import curses
import sys
import time
from .core.sample import Reading, Sample
from .core.sampler import Sampler
from .core.sources import available_sources
from .render import _GROUP_ORDER, _GROUP_TITLES, format_raw, metric_label, render_snapshot
# Color-band thresholds (mirror the GUI dashboard so both faces agree).
TEMP_COLD, TEMP_WARN, TEMP_CRIT = 50.0, 78.0, 88.0
USAGE_WARN, USAGE_CRIT = 85.0, 95.0
_USAGE_METRICS = {"util", "used_pct", "mem_util", "load"}
def band(r: Reading) -> str:
"""Color band for a reading: cold | good | warn | crit | normal | na."""
if r.source == "gpu" and r.metric == "status": # GPU-lost / query timeout
return "crit"
if r.value is None:
return "na"
if r.unit == "°C":
if r.value >= TEMP_CRIT:
return "crit"
if r.value >= TEMP_WARN:
return "warn"
if r.value >= TEMP_COLD:
return "good"
return "cold"
if r.unit == "%" and r.metric in _USAGE_METRICS:
if r.value >= USAGE_CRIT:
return "crit"
if r.value >= USAGE_WARN:
return "warn"
return "good"
return "normal"
def track(stats: dict[str, tuple[float, float]], sample: Sample) -> None:
"""Fold a sample's readings into {key: (min, max)} session extremes."""
for r in sample.readings:
if r.value is None:
continue
lo, hi = stats.get(r.key, (r.value, r.value))
stats[r.key] = (min(lo, r.value), max(hi, r.value))
# --- curses front-end -----------------------------------------------------------------
_BAND_PAIR = {"cold": 1, "good": 2, "warn": 3, "crit": 4}
def _init_colors() -> None:
try:
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_CYAN, -1)
curses.init_pair(2, curses.COLOR_GREEN, -1)
curses.init_pair(3, curses.COLOR_YELLOW, -1)
curses.init_pair(4, curses.COLOR_RED, -1)
except curses.error:
pass
def _attr(band_name: str) -> int:
pair = _BAND_PAIR.get(band_name)
if not pair:
return curses.A_NORMAL
attr = curses.color_pair(pair)
return attr | curses.A_BOLD if band_name == "crit" else attr
def _draw(stdscr, sample: Sample, stats: dict, interval: float) -> None:
stdscr.erase()
height, width = stdscr.getmaxyx()
def put(y: int, x: int, text: str, attr: int = curses.A_NORMAL) -> None:
if 0 <= y < height and 0 <= x < width:
try:
stdscr.addnstr(y, x, text, max(0, width - x - 1), attr)
except curses.error:
pass
put(0, 0, f"RigDoctor — live monitor every {interval:g}s", curses.A_BOLD)
put(1, 0, "q quit r reset min/max", curses.A_DIM)
groups = sample.by_source()
order = [k for k in _GROUP_ORDER if k in groups] + [k for k in groups if k not in _GROUP_ORDER]
name_w, col_w = 24, 11
y = 3
for key in order:
if y >= height:
break
put(y, 0, _GROUP_TITLES.get(key, key.title()), curses.A_BOLD)
y += 1
put(y, 2, f"{'sensor':<{name_w}}{'current':>{col_w}}{'min':>{col_w}}{'max':>{col_w}}", curses.A_DIM)
y += 1
for r in groups[key]:
if y >= height:
break
if r.metric == "name": # device identity line
put(y, 2, str(r.label), curses.A_DIM)
y += 1
continue
lo, hi = stats.get(r.key, (r.value, r.value))
put(y, 2, f"{metric_label(r):<{name_w}}")
put(y, 2 + name_w, f"{format_raw(r.value, r.unit):>{col_w}}", _attr(band(r)))
put(y, 2 + name_w + col_w, f"{format_raw(lo, r.unit):>{col_w}}", curses.A_DIM)
put(y, 2 + name_w + 2 * col_w, f"{format_raw(hi, r.unit):>{col_w}}", curses.A_DIM)
y += 1
y += 1
stdscr.refresh()
def _loop(stdscr, sampler: Sampler, interval: float) -> None:
curses.curs_set(0)
stdscr.nodelay(True)
_init_colors()
stats: dict[str, tuple[float, float]] = {}
latest = sampler.sample()
track(stats, latest)
next_sample = time.monotonic() + interval
while True:
ch = stdscr.getch()
if ch in (ord("q"), ord("Q")):
return
if ch in (ord("r"), ord("R")):
stats.clear()
track(stats, latest)
now = time.monotonic()
if now >= next_sample:
latest = sampler.sample()
track(stats, latest)
next_sample = now + interval
_draw(stdscr, latest, stats, interval)
time.sleep(0.05) # keep key handling responsive without busy-spinning
def _run_plain(sampler: Sampler, interval: float) -> int:
"""Fallback for non-TTY output: clear + reprint each tick (no curses)."""
try:
for sample in sampler.stream(interval=interval):
print("\033[2J\033[H", end="")
print(f"RigDoctor — live (every {interval:g}s, Ctrl-C to quit)\n")
print(render_snapshot(sample))
sys.stdout.flush()
except KeyboardInterrupt:
print()
return 0
def run(interval: float, plain: bool = False) -> int:
sampler = Sampler(available_sources())
if plain or not sys.stdout.isatty():
return _run_plain(sampler, interval)
try:
curses.wrapper(_loop, sampler, interval)
except curses.error: # terminal can't do curses — degrade gracefully
return _run_plain(sampler, interval)
return 0
+2 -1
View File
@@ -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()
+58
View File
@@ -0,0 +1,58 @@
"""Tests for the M2 live-monitor TUI logic (min/max tracking + color bands)."""
import unittest
from rigdoctor import tui
from rigdoctor.core.sample import Reading, Sample
def _temp(v):
return Reading("gpu", "temp", v, "°C", "")
class TrackTests(unittest.TestCase):
def test_tracks_min_and_max(self):
stats: dict = {}
for v in (60.0, 80.0, 70.0, 55.0):
tui.track(stats, Sample(0.0, [_temp(v)]))
self.assertEqual(stats["gpu.temp"], (55.0, 80.0))
def test_ignores_none_values(self):
stats: dict = {}
tui.track(stats, Sample(0.0, [_temp(None)]))
self.assertEqual(stats, {})
def test_keys_separate_by_label(self):
stats: dict = {}
tui.track(stats, Sample(0.0, [
Reading("cpu", "temp", 50.0, "°C", "Core 0"),
Reading("cpu", "temp", 70.0, "°C", "Core 1"),
]))
self.assertEqual(stats["cpu.temp.Core 0"], (50.0, 50.0))
self.assertEqual(stats["cpu.temp.Core 1"], (70.0, 70.0))
class BandTests(unittest.TestCase):
def test_temperature_bands(self):
self.assertEqual(tui.band(_temp(40.0)), "cold")
self.assertEqual(tui.band(_temp(60.0)), "good")
self.assertEqual(tui.band(_temp(80.0)), "warn")
self.assertEqual(tui.band(_temp(90.0)), "crit")
def test_usage_bands(self):
self.assertEqual(tui.band(Reading("gpu", "util", 50.0, "%")), "good")
self.assertEqual(tui.band(Reading("gpu", "util", 88.0, "%")), "warn")
self.assertEqual(tui.band(Reading("memory", "used_pct", 96.0, "%")), "crit")
def test_non_metric_percentage_is_normal(self):
self.assertEqual(tui.band(Reading("gpu", "fan", 100.0, "%")), "normal")
def test_gpu_lost_is_crit(self):
self.assertEqual(tui.band(Reading("gpu", "status", None, "", "query-timeout")), "crit")
def test_missing_value_is_na(self):
self.assertEqual(tui.band(Reading("gpu", "power", None, "W")), "na")
if __name__ == "__main__":
unittest.main()