Compare commits

..

2 Commits

Author SHA1 Message Date
jessey e33cc0ef3a fix(gui): high-contrast checkbox indicator
release / release (push) Successful in 13s
A checked checkbox was hard to distinguish from unchecked on the dark theme.
Style QCheckBox::indicator: accent-filled box with a checkmark when checked,
dark box with a visible border when not.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:25:33 +02:00
jessey e3b20089f0 feat: alerts (M8), notifications config page, and app icon (0.4.0)
release / release (push) Successful in 14s
- feat(alerts): desktop notifications (notify-send) for overheat (GPU/CPU past a
  configurable threshold), GPU-lost, and a new-version-available alert (once per
  version). Edge-triggered with cooldown so it doesn't spam (core/alerts.py)
- feat(gui): Notifications page to configure alerts (enable, GPU/CPU thresholds,
  Send test); changes apply live and persist via config.save_config/update_config
- feat(gui): ship a RigDoctor icon; the GUI self-registers the icon + .desktop on
  launch and sets the Wayland app-id, so the dock shows it after an update + relaunch
  (no installer re-run); installer/uninstaller updated to manage the icon
- config: alerts_enabled, gpu_temp_alert, cpu_temp_alert; flat-TOML writer
- tests for the alert monitor and config round-trip

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:22:13 +02:00
17 changed files with 447 additions and 7 deletions
+17
View File
@@ -5,6 +5,23 @@ 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.4.1] - 2026-05-21
### Fixed
- Checkbox contrast: a checked checkbox is now a clear accent-filled box with a checkmark
(was hard to tell checked from unchecked on the dark theme).
## [0.4.0] - 2026-05-21
### Added
- **Alerts (M8)**: desktop notifications (via `notify-send`) for **overheat** (GPU/CPU past a
threshold), **GPU-lost** (nvidia-smi timeout), and a **new version available** (fired once
per version). Edge-triggered with a cooldown so it doesn't spam. Degrades gracefully if
`notify-send` isn't installed.
- **Notifications page**: configure alerts (enable/disable, GPU/CPU temperature thresholds)
with a "Send test" button; changes apply live and persist to `config.toml`.
- **App icon**: ships a RigDoctor icon and shows it in the dock/launcher. The GUI
**self-registers** the icon + `.desktop` on launch (and sets the Wayland app-id), so a
self-update + relaunch picks it up — no need to re-run the installer.
## [0.3.2] - 2026-05-21 ## [0.3.2] - 2026-05-21
### Changed ### Changed
- Replaced the per-page "Run with admin" buttons with a **single password prompt at launch** - Replaced the per-page "Run with admin" buttons with a **single password prompt at launch**
+1 -1
View File
@@ -12,7 +12,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
| 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 | ⬜ |
| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | 🟨 | | M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | 🟨 |
+16 -2
View File
@@ -16,7 +16,8 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
uninstall() { uninstall() {
echo "Removing RigDoctor user-local install…" echo "Removing RigDoctor user-local install…"
rm -rf "$VENV" rm -rf "$VENV"
rm -f "$BIN_DIR/rigdoctor" "$BIN_DIR/rigdoctor-gui" "$DESKTOP_FILE" rm -f "$BIN_DIR/rigdoctor" "$BIN_DIR/rigdoctor-gui" "$DESKTOP_FILE" \
"$DATA_HOME/icons/hicolor/scalable/apps/rigdoctor.svg"
echo "Done. (Config and logs under ~/.config/rigdoctor and ~/.local/share/rigdoctor were kept.)" echo "Done. (Config and logs under ~/.config/rigdoctor and ~/.local/share/rigdoctor were kept.)"
} }
@@ -81,6 +82,17 @@ mkdir -p "$BIN_DIR"
ln -sf "$VENV/bin/rigdoctor" "$BIN_DIR/rigdoctor" ln -sf "$VENV/bin/rigdoctor" "$BIN_DIR/rigdoctor"
ln -sf "$VENV/bin/rigdoctor-gui" "$BIN_DIR/rigdoctor-gui" ln -sf "$VENV/bin/rigdoctor-gui" "$BIN_DIR/rigdoctor-gui"
# Install the app icon (for the dock/launcher); fall back to a stock icon.
ICON_NAME=utilities-system-monitor
ICON_SRC=$("$VENV/bin/python" -c "import os, rigdoctor.gui as g; print(os.path.join(os.path.dirname(g.__file__), 'assets', 'rigdoctor.svg'))" 2>/dev/null || true)
if [ -n "$ICON_SRC" ] && [ -f "$ICON_SRC" ]; then
ICON_DST="$DATA_HOME/icons/hicolor/scalable/apps/rigdoctor.svg"
mkdir -p "$(dirname "$ICON_DST")"
cp "$ICON_SRC" "$ICON_DST"
ICON_NAME=rigdoctor
command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache -qtf "$DATA_HOME/icons/hicolor" 2>/dev/null || true
fi
mkdir -p "$DESKTOP_DIR" mkdir -p "$DESKTOP_DIR"
cat > "$DESKTOP_FILE" <<EOF cat > "$DESKTOP_FILE" <<EOF
[Desktop Entry] [Desktop Entry]
@@ -88,10 +100,12 @@ Type=Application
Name=RigDoctor Name=RigDoctor
Comment=Hardware monitoring & crash diagnostics for Linux gamers Comment=Hardware monitoring & crash diagnostics for Linux gamers
Exec=$VENV/bin/rigdoctor-gui Exec=$VENV/bin/rigdoctor-gui
Icon=utilities-system-monitor Icon=$ICON_NAME
Terminal=false Terminal=false
Categories=System;Monitor;Utility; Categories=System;Monitor;Utility;
StartupWMClass=rigdoctor
EOF EOF
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
echo echo
echo "RigDoctor $("$VENV/bin/rigdoctor" --version 2>/dev/null | awk '{print $2}') installed." echo "RigDoctor $("$VENV/bin/rigdoctor" --version 2>/dev/null | awk '{print $2}') installed."
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.3.2" version = "0.4.1"
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"
@@ -21,3 +21,6 @@ rigdoctor-gui = "rigdoctor.gui.app:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.setuptools.package-data]
rigdoctor = ["gui/assets/*.svg"]
+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.3.2" __version__ = "0.4.1"
+27
View File
@@ -139,6 +139,9 @@ DEFAULTS: dict = {
"log_backups": 10, # keep this many rotated segments (bounds disk use) "log_backups": 10, # keep this many rotated segments (bounds disk use)
"update_check_minutes": 30, # re-check for updates this often while running (0 = off) "update_check_minutes": 30, # re-check for updates this often while running (0 = off)
"elevate_on_launch": True, # GUI asks for the password once at launch (SMART/dmidecode) "elevate_on_launch": True, # GUI asks for the password once at launch (SMART/dmidecode)
"alerts_enabled": True, # desktop notifications on overheat / GPU-lost / new version
"gpu_temp_alert": 90.0, # °C — alert when GPU reaches this
"cpu_temp_alert": 95.0, # °C — alert when CPU reaches this
} }
@@ -154,3 +157,27 @@ def load_config() -> dict:
except Exception: except Exception:
pass pass
return cfg return cfg
def _toml_value(value) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return repr(value)
return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
def save_config(values: dict) -> None:
"""Write a flat config.toml (stdlib has no TOML writer)."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
lines = ["# RigDoctor config — edit in the app (Notifications) or here."]
lines += [f"{key} = {_toml_value(value)}" for key, value in values.items()]
CONFIG_FILE.write_text("\n".join(lines) + "\n")
def update_config(**changes) -> dict:
"""Merge changes into the current effective config and persist them."""
cfg = load_config()
cfg.update(changes)
save_config(cfg)
return cfg
+91
View File
@@ -0,0 +1,91 @@
"""Desktop alerts (M8): notify on overheat / GPU-lost / new version via notify-send.
Edge-triggered: an alert fires when a condition becomes true (not every sample), and
can fire again only after it has cleared and a cooldown has passed — so a hot GPU or a
1-Hz sample loop doesn't spam notifications. Degrades to a no-op if notify-send is absent.
"""
from __future__ import annotations
import shutil
import subprocess
import time
from .sample import Sample
APP_NAME = "RigDoctor"
_ICON = "utilities-system-monitor"
def available() -> bool:
return shutil.which("notify-send") is not None
def notify(title: str, message: str, urgency: str = "normal") -> bool:
"""Send a desktop notification (best-effort). urgency: low|normal|critical."""
if not available():
return False
try:
subprocess.run(
["notify-send", "-a", APP_NAME, "-u", urgency, "-i", _ICON, title, message],
timeout=10,
check=False,
)
return True
except (subprocess.SubprocessError, OSError):
return False
class AlertMonitor:
"""Evaluate samples and raise edge-triggered desktop alerts."""
def __init__(self, gpu_temp: float = 90.0, cpu_temp: float = 95.0, cooldown: float = 300.0):
self.gpu_temp = gpu_temp
self.cpu_temp = cpu_temp
self.cooldown = cooldown
self.enabled = True
self._active: dict[str, bool] = {}
self._last: dict[str, float] = {}
def _fire(self, key: str, title: str, message: str, urgency: str = "critical") -> None:
if self._active.get(key):
return # already alerting; wait until it clears
now = time.time()
if now - self._last.get(key, 0.0) < self.cooldown:
return
self._active[key] = True
self._last[key] = now
notify(title, message, urgency)
def _clear(self, key: str) -> None:
self._active[key] = False
def check(self, sample: Sample) -> None:
if not self.enabled:
return
gpu_t = next(
(r.value for r in sample.readings
if r.source == "gpu" and r.metric == "temp" and r.label == "" and r.value is not None),
None,
)
if gpu_t is not None:
if gpu_t >= self.gpu_temp:
self._fire("gpu_temp", "GPU overheating", f"GPU at {gpu_t:.0f} °C")
else:
self._clear("gpu_temp")
cpu_temps = [r.value for r in sample.readings
if r.source == "cpu" and r.metric == "temp" and r.value is not None]
if cpu_temps:
cpu_t = max(cpu_temps)
if cpu_t >= self.cpu_temp:
self._fire("cpu_temp", "CPU overheating", f"CPU at {cpu_t:.0f} °C")
else:
self._clear("cpu_temp")
lost = any(r.source == "gpu" and r.metric == "status" and r.label == "query-timeout"
for r in sample.readings)
if lost:
self._fire("gpu_lost", "GPU not responding", "nvidia-smi query timed out — the GPU may have dropped")
else:
self._clear("gpu_lost")
+1
View File
@@ -23,6 +23,7 @@ def targets(purge: bool = False) -> list[Path]:
home / ".local" / "bin" / "rigdoctor", home / ".local" / "bin" / "rigdoctor",
home / ".local" / "bin" / "rigdoctor-gui", home / ".local" / "bin" / "rigdoctor-gui",
share / "applications" / "rigdoctor.desktop", share / "applications" / "rigdoctor.desktop",
share / "icons" / "hicolor" / "scalable" / "apps" / "rigdoctor.svg",
] ]
if purge: if purge:
items += [config.CONFIG_DIR, config.STATE_DIR, config.DATA_DIR] items += [config.CONFIG_DIR, config.STATE_DIR, config.DATA_DIR]
+10
View File
@@ -3,18 +3,28 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from pathlib import Path
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from ..config import load_config from ..config import load_config
from . import desktop
from .main_window import MainWindow from .main_window import MainWindow
from .theme import STYLESHEET from .theme import STYLESHEET
ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
desktop.ensure() # self-register icon + .desktop so updates show it without re-installing
app = QApplication(argv if argv is not None else sys.argv) app = QApplication(argv if argv is not None else sys.argv)
app.setApplicationName("RigDoctor") app.setApplicationName("RigDoctor")
app.setApplicationDisplayName("RigDoctor") app.setApplicationDisplayName("RigDoctor")
# Match the installed rigdoctor.desktop so the dock/launcher shows our icon (Wayland app-id).
app.setDesktopFileName("rigdoctor")
if ICON.exists():
app.setWindowIcon(QIcon(str(ICON)))
app.setStyle("Fusion") app.setStyle("Fusion")
app.setStyleSheet(STYLESHEET) app.setStyleSheet(STYLESHEET)
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M3.5 8.5 L6.5 11.5 L12.5 4.5" fill="none" stroke="#06222e"
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 237 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect x="8" y="8" width="240" height="240" rx="52" fill="#15181e"/>
<circle cx="128" cy="128" r="84" fill="none" stroke="#2a2f39" stroke-width="14"/>
<path d="M128 44 a84 84 0 1 1 -59.4 24.6" fill="none" stroke="#38bdf8"
stroke-width="14" stroke-linecap="round"/>
<path d="M60 132 H100 L116 96 L140 168 L156 132 H196" fill="none" stroke="#e6e8eb"
stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 533 B

+51
View File
@@ -0,0 +1,51 @@
"""Best-effort desktop integration: install our icon + .desktop so the dock shows it.
Runs at GUI launch (idempotent), so a self-update + relaunch refreshes the icon without
re-running install.sh. No-op for non-installed (dev) runs where the launcher is absent.
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
from .. import config
_ICON_SRC = Path(__file__).parent / "assets" / "rigdoctor.svg"
_DESKTOP = """[Desktop Entry]
Type=Application
Name=RigDoctor
Comment=Hardware monitoring & crash diagnostics for Linux gamers
Exec={exec}
Icon=rigdoctor
Terminal=false
Categories=System;Monitor;Utility;
StartupWMClass=rigdoctor
"""
def ensure() -> None:
share = config.DATA_DIR.parent # ~/.local/share
try:
if _ICON_SRC.exists():
icon_dst = share / "icons" / "hicolor" / "scalable" / "apps" / "rigdoctor.svg"
icon_dst.parent.mkdir(parents=True, exist_ok=True)
if not icon_dst.exists() or icon_dst.read_bytes() != _ICON_SRC.read_bytes():
shutil.copyfile(_ICON_SRC, icon_dst)
except OSError:
pass
gui_exec = Path(sys.executable).with_name("rigdoctor-gui")
if not gui_exec.exists(): # dev / not a normal install — don't fabricate a .desktop
return
try:
desktop = share / "applications" / "rigdoctor.desktop"
content = _DESKTOP.format(exec=gui_exec)
desktop.parent.mkdir(parents=True, exist_ok=True)
if not desktop.exists() or desktop.read_text() != content:
desktop.write_text(content)
except OSError:
pass
+24 -2
View File
@@ -26,16 +26,17 @@ from PySide6.QtWidgets import (
from .. import __version__ from .. import __version__
from ..config import load_config from ..config import load_config
from ..core import elevation, updates from ..core import alerts, elevation, updates
from .dashboard import Dashboard from .dashboard import Dashboard
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 .theme import ACCENT, GOOD, MUTED from .theme import ACCENT, GOOD, MUTED
from .worker import SamplerWorker from .worker import SamplerWorker
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Setup", "Inventory"] _NAV_ITEMS = ["Dashboard", "Logs", "Health", "Setup", "Inventory", "Notifications"]
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@@ -67,11 +68,14 @@ class MainWindow(QMainWindow):
self.health_page = HealthPage() self.health_page = HealthPage()
self.setup_page = SetupPage() self.setup_page = SetupPage()
self.inventory_page = InventoryPage() self.inventory_page = InventoryPage()
self.notifications_page = NotificationsPage()
self.notifications_page.changed.connect(self._apply_alert_settings)
self._stack.addWidget(self.dashboard) # 0 Dashboard self._stack.addWidget(self.dashboard) # 0 Dashboard
self._stack.addWidget(self.recorder_page) # 1 Logs self._stack.addWidget(self.recorder_page) # 1 Logs
self._stack.addWidget(self.health_page) # 2 Health self._stack.addWidget(self.health_page) # 2 Health
self._stack.addWidget(self.setup_page) # 3 Setup self._stack.addWidget(self.setup_page) # 3 Setup
self._stack.addWidget(self.inventory_page) # 4 Inventory self._stack.addWidget(self.inventory_page) # 4 Inventory
self._stack.addWidget(self.notifications_page) # 5 Notifications
content_layout.addWidget(self._stack) content_layout.addWidget(self._stack)
layout.addWidget(self._build_sidebar()) layout.addWidget(self._build_sidebar())
@@ -79,6 +83,15 @@ class MainWindow(QMainWindow):
self._worker = SamplerWorker(interval=interval) self._worker = SamplerWorker(interval=interval)
self._worker.sampled.connect(self.dashboard.update_sample) self._worker.sampled.connect(self.dashboard.update_sample)
# Desktop alerts (M8): overheat / GPU-lost from the sample stream, new-version below.
# Configurable on the Notifications page; gated by AlertMonitor.enabled.
self._notified_update_tag = None
self._alert_monitor = alerts.AlertMonitor(
gpu_temp=float(cfg.get("gpu_temp_alert", 90.0)),
cpu_temp=float(cfg.get("cpu_temp_alert", 95.0)),
)
self._alert_monitor.enabled = bool(cfg.get("alerts_enabled", True))
self._worker.sampled.connect(self._alert_monitor.check)
self._worker.start() self._worker.start()
# Ask for the password once at launch and collect root-only data (SMART + # Ask for the password once at launch and collect root-only data (SMART +
@@ -216,6 +229,12 @@ class MainWindow(QMainWindow):
self.health_page._run() self.health_page._run()
self.inventory_page._run() self.inventory_page._run()
def _apply_alert_settings(self) -> None:
cfg = load_config()
self._alert_monitor.enabled = bool(cfg.get("alerts_enabled", True))
self._alert_monitor.gpu_temp = float(cfg.get("gpu_temp_alert", 90.0))
self._alert_monitor.cpu_temp = float(cfg.get("cpu_temp_alert", 95.0))
def _manual_check(self) -> None: def _manual_check(self) -> None:
if self._applied: if self._applied:
return return
@@ -279,6 +298,9 @@ class MainWindow(QMainWindow):
self._update_label.setText(f'<span style="color:{GOOD};">{tag} available</span>') self._update_label.setText(f'<span style="color:{GOOD};">{tag} available</span>')
self._update_btn.setText(f"Update to {tag}") self._update_btn.setText(f"Update to {tag}")
self._update_btn.setVisible(True) self._update_btn.setVisible(True)
if self._alert_monitor.enabled and tag != self._notified_update_tag:
self._notified_update_tag = tag # once per version, not every poll
alerts.notify("Update available", f"RigDoctor {tag} is available — open RigDoctor to update.")
else: # UP_TO_DATE else: # UP_TO_DATE
self._update_label.setText("up-to-date") self._update_label.setText("up-to-date")
+108
View File
@@ -0,0 +1,108 @@
"""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).")
+14
View File
@@ -2,6 +2,10 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
_CHECK = (Path(__file__).parent / "assets" / "check.svg").as_posix()
# Palette (dark) # Palette (dark)
BG = "#101216" BG = "#101216"
SIDEBAR = "#15181e" SIDEBAR = "#15181e"
@@ -120,6 +124,16 @@ QPushButton#LinkButton {{
}} }}
QPushButton#LinkButton:hover {{ color: {TEXT}; }} QPushButton#LinkButton:hover {{ color: {TEXT}; }}
QCheckBox {{ spacing: 8px; background: transparent; }}
QCheckBox::indicator {{
width: 17px; height: 17px; border-radius: 4px;
border: 1px solid {MUTED}; background: #262b34;
}}
QCheckBox::indicator:hover {{ border-color: {ACCENT}; }}
QCheckBox::indicator:checked {{
background: {ACCENT}; border-color: {ACCENT}; image: url("{_CHECK}");
}}
/* Dialogs (update prompt, changelog) — match the dark theme so text is readable. */ /* Dialogs (update prompt, changelog) — match the dark theme so text is readable. */
QDialog {{ background: {BG}; }} QDialog {{ background: {BG}; }}
QMessageBox {{ background: {CARD}; }} QMessageBox {{ background: {CARD}; }}
+38
View File
@@ -0,0 +1,38 @@
"""Tests for the M8 alert monitor (edge-triggered; notify mocked)."""
import unittest
from unittest import mock
from rigdoctor.core import alerts
from rigdoctor.core.sample import Reading, Sample
def _gpu(temp):
return Sample(readings=[Reading("gpu", "temp", temp, "°C")])
class AlertTests(unittest.TestCase):
@mock.patch.object(alerts, "notify")
def test_edge_triggered_no_repeat(self, m):
mon = alerts.AlertMonitor(gpu_temp=90.0, cooldown=0.0)
mon.check(_gpu(95)) # fires
mon.check(_gpu(96)) # still hot — no repeat while active
self.assertEqual(m.call_count, 1)
mon.check(_gpu(50)) # clears
mon.check(_gpu(95)) # hot again — fires
self.assertEqual(m.call_count, 2)
@mock.patch.object(alerts, "notify")
def test_no_alert_below_threshold(self, m):
alerts.AlertMonitor(gpu_temp=90.0).check(_gpu(70))
m.assert_not_called()
@mock.patch.object(alerts, "notify")
def test_gpu_lost(self, m):
mon = alerts.AlertMonitor()
mon.check(Sample(readings=[Reading("gpu", "status", None, "", "query-timeout")]))
m.assert_called_once()
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -0,0 +1,32 @@
"""Tests for config save/load (flat TOML writer)."""
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from rigdoctor import config
class ConfigTests(unittest.TestCase):
def test_save_load_round_trip(self):
with tempfile.TemporaryDirectory() as d:
cf = Path(d) / "config.toml"
with mock.patch.object(config, "CONFIG_FILE", cf), mock.patch.object(config, "CONFIG_DIR", Path(d)):
config.save_config({"alerts_enabled": False, "gpu_temp_alert": 88.0, "update_check_minutes": 5})
loaded = config.load_config()
self.assertIs(loaded["alerts_enabled"], False)
self.assertEqual(loaded["gpu_temp_alert"], 88.0)
self.assertEqual(loaded["update_check_minutes"], 5)
def test_update_config_merges_and_keeps_defaults(self):
with tempfile.TemporaryDirectory() as d:
cf = Path(d) / "config.toml"
with mock.patch.object(config, "CONFIG_FILE", cf), mock.patch.object(config, "CONFIG_DIR", Path(d)):
config.update_config(cpu_temp_alert=70.0)
self.assertEqual(config.load_config()["cpu_temp_alert"], 70.0)
self.assertEqual(config.load_config()["gpu_temp_alert"], 90.0) # default preserved
if __name__ == "__main__":
unittest.main()