Add desktop GUI (M10): modern dark dashboard
PySide6/Qt front-end over the stdlib sensor core (only gui/ imports Qt). - sidebar navigation + stacked pages (Dashboard live; Logs/Health/Inventory placeholders for M3-M5) - live dashboard: circular gauges (GPU temp/load, CPU temp, memory) plus collapsible per-subsystem cards with progress bars and metric rows - background sampling thread -> Qt signal so the UI stays responsive - temperature colors: icey-blue (cold) -> green -> orange -> red (hot) - dark theme via QSS + Fusion Supporting changes: - cpu source: order temps as package, then cores numerically (fixes CLI too) - render: expose format_value/metric_label, shared by CLI and GUI - cli: `rigdoctor gui` (lazy import; prints install hint if PySide6 missing) - pyproject: rigdoctor-gui script + [gui] extra (PySide6) - gitignore: *.egg-info/, build/, dist/ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,9 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
# RigDoctor runtime output
|
# RigDoctor runtime output
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
@@ -73,7 +73,21 @@ PYTHONPATH=src python3 -m rigdoctor sources # list detected sensor sources
|
|||||||
PYTHONPATH=src python3 -m unittest discover -s tests
|
PYTHONPATH=src python3 -m unittest discover -s tests
|
||||||
```
|
```
|
||||||
|
|
||||||
Or `pip install -e .` to get a `rigdoctor` command on your PATH.
|
### Desktop GUI (M10)
|
||||||
|
|
||||||
|
The GUI uses PySide6 (Qt) — the only part of RigDoctor that needs a non-stdlib dep:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e '.[gui]' # core + PySide6, gives `rigdoctor` and `rigdoctor-gui`
|
||||||
|
rigdoctor gui # or: rigdoctor-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
It opens a dark-themed window with sidebar navigation and a **live dashboard** over the
|
||||||
|
same sensor core — circular gauges for the headline metrics plus collapsible per-subsystem
|
||||||
|
cards (GPU/CPU/memory/storage) with temperature-colored values (icey-blue → green → red).
|
||||||
|
The Logs / Health / Inventory sections are placeholders until M3–M5 land.
|
||||||
|
|
||||||
|
Without the GUI extra, `pip install -e .` gives just the stdlib-only CLI.
|
||||||
|
|
||||||
## Start here
|
## Start here
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -15,7 +15,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
|||||||
| 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 | 🟨 |
|
||||||
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ⬜ |
|
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ⬜ |
|
||||||
| M9 | Installer | (meta) | none | all | P1 | ⬜ |
|
| M9 | Installer | (meta) | none | all | P1 | ⬜ |
|
||||||
| ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) |
|
| ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) |
|
||||||
@@ -35,7 +35,10 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
|||||||
suggests the fix command but does not apply it (D9).
|
suggests the fix command but does not apply it (D9).
|
||||||
- **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 (dashboard, log
|
||||||
browser, report viewer, logger controls). Optional; adds the Qt dependency.
|
browser, report viewer, logger controls). Optional; adds the Qt dependency. *Bootstrapped
|
||||||
|
early (ahead of its Phase 4 slot) at the user's request:* dark-themed window with sidebar
|
||||||
|
nav and a live dashboard (circular gauges + collapsible per-subsystem cards, temperature-
|
||||||
|
colored values); Logs/Health/Inventory are placeholders until M3–M5.
|
||||||
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. Dropdown shows live M1 readouts
|
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. Dropdown shows live M1 readouts
|
||||||
(CPU temp, GPU temp, memory used/total, status dot) and is led by a **Run Diagnostic**
|
(CPU temp, GPU temp, memory used/total, status dot) and is led by a **Run Diagnostic**
|
||||||
action (the guided diagnostic session), plus Open dashboard / Start-Stop recording /
|
action (the guided diagnostic session), plus Open dashboard / Start-Stop recording /
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ gui = ["PySide6"]
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
rigdoctor = "rigdoctor.cli:main"
|
rigdoctor = "rigdoctor.cli:main"
|
||||||
|
rigdoctor-gui = "rigdoctor.gui.app:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ def cmd_monitor(args) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_gui(args) -> int:
|
||||||
|
try:
|
||||||
|
from .gui.app import main as gui_main
|
||||||
|
except ImportError as exc:
|
||||||
|
print("The GUI needs PySide6, which isn't installed.")
|
||||||
|
print(" Install it with: pip install 'rigdoctor[gui]'")
|
||||||
|
print(" or on Ubuntu: sudo apt install python3-pyside6")
|
||||||
|
print(f" ({exc})")
|
||||||
|
return 2
|
||||||
|
return gui_main([sys.argv[0]])
|
||||||
|
|
||||||
|
|
||||||
def cmd_record(args) -> int:
|
def cmd_record(args) -> int:
|
||||||
print("`record` (M3 crash-capture logger) is not implemented yet — next on the roadmap.")
|
print("`record` (M3 crash-capture logger) is not implemented yet — next on the roadmap.")
|
||||||
return 2
|
return 2
|
||||||
@@ -78,6 +90,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
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.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("sources", help="list detected sensor sources").set_defaults(func=cmd_sources)
|
sub.add_parser("sources", help="list detected sensor sources").set_defaults(func=cmd_sources)
|
||||||
sub.add_parser("record", help="crash-capture logger (coming soon)").set_defaults(func=cmd_record)
|
sub.add_parser("record", help="crash-capture logger (coming soon)").set_defaults(func=cmd_record)
|
||||||
sub.add_parser("report", help="health report (coming soon)").set_defaults(func=cmd_report)
|
sub.add_parser("report", help="health report (coming soon)").set_defaults(func=cmd_report)
|
||||||
|
|||||||
@@ -3,12 +3,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from ..hwmon import find_by_name, read_temps
|
from ..hwmon import find_by_name, read_temps
|
||||||
from ..sample import Reading
|
from ..sample import Reading
|
||||||
from .base import Source
|
from .base import Source
|
||||||
|
|
||||||
|
|
||||||
|
def _temp_sort_key(label: str) -> tuple[int, int]:
|
||||||
|
"""Order temps: package first, then cores by ascending number, others last."""
|
||||||
|
low = label.lower()
|
||||||
|
if low.startswith("package") or "tctl" in low or "tdie" in low:
|
||||||
|
return (0, 0)
|
||||||
|
match = re.search(r"\d+", label)
|
||||||
|
if low.startswith("core") and match:
|
||||||
|
return (1, int(match.group()))
|
||||||
|
return (2, 0)
|
||||||
|
|
||||||
|
|
||||||
class CpuSource(Source):
|
class CpuSource(Source):
|
||||||
name = "cpu"
|
name = "cpu"
|
||||||
|
|
||||||
@@ -16,17 +28,19 @@ class CpuSource(Source):
|
|||||||
# Intel exposes 'coretemp'; AMD exposes 'k10temp'.
|
# Intel exposes 'coretemp'; AMD exposes 'k10temp'.
|
||||||
return find_by_name("coretemp") or find_by_name("k10temp")
|
return find_by_name("coretemp") or find_by_name("k10temp")
|
||||||
|
|
||||||
def probe(self) -> bool:
|
|
||||||
return bool(self._hwmons())
|
|
||||||
|
|
||||||
def read(self) -> list[Reading]:
|
def read(self) -> list[Reading]:
|
||||||
readings: list[Reading] = []
|
temps: list[tuple[str, float]] = []
|
||||||
for d in self._hwmons():
|
for d in self._hwmons():
|
||||||
for label, celsius in read_temps(d):
|
temps.extend(read_temps(d))
|
||||||
readings.append(Reading("cpu", "temp", round(celsius, 1), "°C", label))
|
temps.sort(key=lambda t: _temp_sort_key(t[0]))
|
||||||
|
|
||||||
|
readings = [Reading("cpu", "temp", round(c, 1), "°C", label) for label, c in temps]
|
||||||
try:
|
try:
|
||||||
load1 = os.getloadavg()[0]
|
load1 = os.getloadavg()[0]
|
||||||
readings.append(Reading("cpu", "load", round(load1, 2), "", "loadavg-1m"))
|
readings.append(Reading("cpu", "load", round(load1, 2), "", "loadavg-1m"))
|
||||||
except (OSError, AttributeError):
|
except (OSError, AttributeError):
|
||||||
pass
|
pass
|
||||||
return readings
|
return readings
|
||||||
|
|
||||||
|
def probe(self) -> bool:
|
||||||
|
return bool(self._hwmons())
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""RigDoctor desktop GUI (M10) — PySide6/Qt.
|
||||||
|
|
||||||
|
This is the only part of RigDoctor that depends on Qt; the core engine, CLI, and
|
||||||
|
daemon stay stdlib-only (D2). Import this package lazily so a headless install
|
||||||
|
never needs PySide6.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""GUI entry point."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from ..config import load_config
|
||||||
|
from .main_window import MainWindow
|
||||||
|
from .theme import STYLESHEET
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
app = QApplication(argv if argv is not None else sys.argv)
|
||||||
|
app.setApplicationName("RigDoctor")
|
||||||
|
app.setApplicationDisplayName("RigDoctor")
|
||||||
|
app.setStyle("Fusion")
|
||||||
|
app.setStyleSheet(STYLESHEET)
|
||||||
|
|
||||||
|
interval = float(load_config().get("interval", 1.0))
|
||||||
|
window = MainWindow(interval=interval)
|
||||||
|
window.show()
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Live sensor dashboard: headline gauges + collapsible per-subsystem cards."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFrame,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QScrollArea,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..core.sample import Sample
|
||||||
|
from ..render import metric_label
|
||||||
|
from .widgets import Card, MetricBar, MetricRow, StatGauge
|
||||||
|
|
||||||
|
_GROUP_ORDER = ["gpu", "cpu", "memory", "storage"]
|
||||||
|
_GROUP_TITLES = {"gpu": "GPU", "cpu": "CPU", "memory": "Memory", "storage": "Storage"}
|
||||||
|
_BAR_METRICS = {"util", "mem_util", "fan", "used_pct"}
|
||||||
|
|
||||||
|
|
||||||
|
def _gauge_card(gauge: StatGauge) -> QFrame:
|
||||||
|
card = QFrame()
|
||||||
|
card.setObjectName("Card")
|
||||||
|
layout = QVBoxLayout(card)
|
||||||
|
layout.setContentsMargins(6, 14, 6, 8)
|
||||||
|
layout.addWidget(gauge)
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
class Dashboard(QWidget):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.setObjectName("Page")
|
||||||
|
self._metric_widgets: dict[str, MetricBar | MetricRow] = {}
|
||||||
|
self._built_keys: list[str] | None = None
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(20, 18, 20, 18)
|
||||||
|
root.setSpacing(16)
|
||||||
|
|
||||||
|
header = QHBoxLayout()
|
||||||
|
title = QLabel("Dashboard")
|
||||||
|
title.setObjectName("PageTitle")
|
||||||
|
self._updated = QLabel("starting…")
|
||||||
|
self._updated.setObjectName("Muted")
|
||||||
|
header.addWidget(title)
|
||||||
|
header.addStretch(1)
|
||||||
|
header.addWidget(self._updated)
|
||||||
|
root.addLayout(header)
|
||||||
|
|
||||||
|
# Headline gauges
|
||||||
|
self._g_gpu_temp = StatGauge("GPU Temp", "°C", 100, "temp")
|
||||||
|
self._g_gpu_load = StatGauge("GPU Load", "%", 100, "accent")
|
||||||
|
self._g_cpu_temp = StatGauge("CPU Temp", "°C", 100, "temp")
|
||||||
|
self._g_mem = StatGauge("Memory", "%", 100, "usage")
|
||||||
|
gauges = QHBoxLayout()
|
||||||
|
gauges.setSpacing(14)
|
||||||
|
for g in (self._g_gpu_temp, self._g_gpu_load, self._g_cpu_temp, self._g_mem):
|
||||||
|
gauges.addWidget(_gauge_card(g))
|
||||||
|
root.addLayout(gauges)
|
||||||
|
|
||||||
|
# Per-subsystem cards (scrollable, 2-column grid)
|
||||||
|
scroll = QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
scroll.setStyleSheet("background: transparent;")
|
||||||
|
container = QWidget()
|
||||||
|
self._grid = QGridLayout(container)
|
||||||
|
self._grid.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self._grid.setSpacing(14)
|
||||||
|
self._grid.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
self._grid.setColumnStretch(0, 1)
|
||||||
|
self._grid.setColumnStretch(1, 1)
|
||||||
|
scroll.setWidget(container)
|
||||||
|
root.addWidget(scroll, 1)
|
||||||
|
|
||||||
|
def update_sample(self, sample: Sample) -> None:
|
||||||
|
self._g_gpu_temp.set_value(self._val(sample, "gpu", "temp", ""))
|
||||||
|
self._g_gpu_load.set_value(self._val(sample, "gpu", "util"))
|
||||||
|
self._g_cpu_temp.set_value(self._cpu_temp(sample))
|
||||||
|
self._g_mem.set_value(self._val(sample, "memory", "used_pct"))
|
||||||
|
|
||||||
|
keys = [r.key for r in sample.readings]
|
||||||
|
if keys != self._built_keys: # sources appeared/disappeared
|
||||||
|
self._rebuild(sample)
|
||||||
|
self._built_keys = keys
|
||||||
|
for r in sample.readings:
|
||||||
|
widget = self._metric_widgets.get(r.key)
|
||||||
|
if widget is not None:
|
||||||
|
widget.set_reading(r)
|
||||||
|
|
||||||
|
stamp = time.strftime("%H:%M:%S", time.localtime(sample.ts))
|
||||||
|
self._updated.setText(f"Updated {stamp} · {len(sample.readings)} sensors")
|
||||||
|
|
||||||
|
def _rebuild(self, sample: Sample) -> None:
|
||||||
|
while self._grid.count():
|
||||||
|
item = self._grid.takeAt(0)
|
||||||
|
widget = item.widget()
|
||||||
|
if widget is not None:
|
||||||
|
widget.deleteLater()
|
||||||
|
self._metric_widgets.clear()
|
||||||
|
|
||||||
|
groups = sample.by_source()
|
||||||
|
ordered = [k for k in _GROUP_ORDER if k in groups]
|
||||||
|
ordered += [k for k in groups if k not in _GROUP_ORDER]
|
||||||
|
|
||||||
|
for index, key in enumerate(ordered):
|
||||||
|
readings = groups[key]
|
||||||
|
subtitle = next((r.label for r in readings if r.metric == "name"), None)
|
||||||
|
card = Card(_GROUP_TITLES.get(key, key.title()), subtitle)
|
||||||
|
for r in readings:
|
||||||
|
if r.metric == "name":
|
||||||
|
continue
|
||||||
|
if r.metric in _BAR_METRICS and r.unit == "%":
|
||||||
|
mode = "usage" if r.metric == "used_pct" else "load"
|
||||||
|
widget: MetricBar | MetricRow = MetricBar(metric_label(r), mode)
|
||||||
|
else:
|
||||||
|
widget = MetricRow(metric_label(r))
|
||||||
|
widget.set_reading(r)
|
||||||
|
card.add(widget)
|
||||||
|
self._metric_widgets[r.key] = widget
|
||||||
|
row, col = divmod(index, 2)
|
||||||
|
self._grid.addWidget(card, row, col, Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _val(sample: Sample, source: str, metric: str, label: str | None = None) -> float | None:
|
||||||
|
for r in sample.readings:
|
||||||
|
if r.source == source and r.metric == metric and (label is None or r.label == label):
|
||||||
|
return r.value
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cpu_temp(sample: Sample) -> float | None:
|
||||||
|
temps = [r for r in sample.readings if r.source == "cpu" and r.metric == "temp" and r.value is not None]
|
||||||
|
for r in temps:
|
||||||
|
low = r.label.lower()
|
||||||
|
if low.startswith("package") or "tctl" in low or "tdie" in low:
|
||||||
|
return r.value
|
||||||
|
return max((r.value for r in temps), default=None)
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""RigDoctor main window — sidebar navigation over a stacked content area."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QButtonGroup,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QMainWindow,
|
||||||
|
QPushButton,
|
||||||
|
QStackedWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .dashboard import Dashboard
|
||||||
|
from .theme import ACCENT, MUTED
|
||||||
|
from .worker import SamplerWorker
|
||||||
|
|
||||||
|
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Inventory"]
|
||||||
|
_PLACEHOLDERS = {
|
||||||
|
"Logs": "Captured crash logs will appear here once the logger (M3) lands.",
|
||||||
|
"Health": "The health report (M4) — log scan + plain-language findings — lands here.",
|
||||||
|
"Inventory": "System inventory (M5) — CPU/GPU/board/RAM/drivers — lands here.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self, interval: float = 1.0) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("RigDoctor")
|
||||||
|
self.resize(1000, 680)
|
||||||
|
|
||||||
|
central = QWidget()
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
layout = QHBoxLayout(central)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Content stack
|
||||||
|
content = QWidget()
|
||||||
|
content.setObjectName("ContentArea")
|
||||||
|
content_layout = QVBoxLayout(content)
|
||||||
|
content_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self._stack = QStackedWidget()
|
||||||
|
self.dashboard = Dashboard()
|
||||||
|
self._stack.addWidget(self.dashboard)
|
||||||
|
for name in _NAV_ITEMS[1:]:
|
||||||
|
self._stack.addWidget(self._placeholder_page(name, _PLACEHOLDERS[name]))
|
||||||
|
content_layout.addWidget(self._stack)
|
||||||
|
|
||||||
|
layout.addWidget(self._build_sidebar())
|
||||||
|
layout.addWidget(content, 1)
|
||||||
|
|
||||||
|
self._worker = SamplerWorker(interval=interval)
|
||||||
|
self._worker.sampled.connect(self.dashboard.update_sample)
|
||||||
|
self._worker.start()
|
||||||
|
|
||||||
|
def _build_sidebar(self) -> QFrame:
|
||||||
|
bar = QFrame()
|
||||||
|
bar.setObjectName("Sidebar")
|
||||||
|
bar.setFixedWidth(208)
|
||||||
|
v = QVBoxLayout(bar)
|
||||||
|
v.setContentsMargins(16, 18, 16, 16)
|
||||||
|
v.setSpacing(4)
|
||||||
|
|
||||||
|
title = QLabel("RigDoctor")
|
||||||
|
title.setObjectName("AppTitle")
|
||||||
|
subtitle = QLabel("Hardware monitor")
|
||||||
|
subtitle.setObjectName("AppSubtitle")
|
||||||
|
v.addWidget(title)
|
||||||
|
v.addWidget(subtitle)
|
||||||
|
v.addSpacing(18)
|
||||||
|
|
||||||
|
group = QButtonGroup(self)
|
||||||
|
group.setExclusive(True)
|
||||||
|
for i, name in enumerate(_NAV_ITEMS):
|
||||||
|
btn = QPushButton(name)
|
||||||
|
btn.setObjectName("NavButton")
|
||||||
|
btn.setCheckable(True)
|
||||||
|
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
btn.setChecked(i == 0)
|
||||||
|
btn.clicked.connect(lambda _checked, idx=i: self._stack.setCurrentIndex(idx))
|
||||||
|
group.addButton(btn, i)
|
||||||
|
v.addWidget(btn)
|
||||||
|
|
||||||
|
v.addStretch(1)
|
||||||
|
live = QLabel(f'<span style="color:{ACCENT};">●</span> <span style="color:{MUTED};">Live</span>')
|
||||||
|
v.addWidget(live)
|
||||||
|
return bar
|
||||||
|
|
||||||
|
def _placeholder_page(self, title: str, description: str) -> QWidget:
|
||||||
|
page = QWidget()
|
||||||
|
page.setObjectName("Page")
|
||||||
|
v = QVBoxLayout(page)
|
||||||
|
v.setContentsMargins(20, 18, 20, 18)
|
||||||
|
v.setSpacing(16)
|
||||||
|
head = QLabel(title)
|
||||||
|
head.setObjectName("PageTitle")
|
||||||
|
v.addWidget(head)
|
||||||
|
|
||||||
|
card = QFrame()
|
||||||
|
card.setObjectName("Card")
|
||||||
|
cv = QVBoxLayout(card)
|
||||||
|
cv.setContentsMargins(24, 48, 24, 48)
|
||||||
|
msg = QLabel(description)
|
||||||
|
msg.setObjectName("Muted")
|
||||||
|
msg.setWordWrap(True)
|
||||||
|
msg.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
cv.addWidget(msg)
|
||||||
|
v.addWidget(card)
|
||||||
|
v.addStretch(1)
|
||||||
|
return page
|
||||||
|
|
||||||
|
def closeEvent(self, event) -> None: # noqa: N802 (Qt override)
|
||||||
|
self._worker.stop()
|
||||||
|
super().closeEvent(event)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Visual theme: color palette, thresholds, and the app stylesheet (QSS)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Palette (dark)
|
||||||
|
BG = "#101216"
|
||||||
|
SIDEBAR = "#15181e"
|
||||||
|
CARD = "#1b1f26"
|
||||||
|
CARD_BORDER = "#2a2f39"
|
||||||
|
TRACK = "#2a2f39"
|
||||||
|
TEXT = "#e6e8eb"
|
||||||
|
MUTED = "#8b929c"
|
||||||
|
|
||||||
|
ACCENT = "#38bdf8"
|
||||||
|
COLD = "#7dd3fc" # icey-blue
|
||||||
|
GOOD = "#4ade80" # green
|
||||||
|
WARN = "#fb923c" # orange (warm transition)
|
||||||
|
CRIT = "#f87171" # red
|
||||||
|
|
||||||
|
# Temperature bands (°C): < COLD = icey-blue, < WARN = green, < CRIT = orange, else red.
|
||||||
|
TEMP_COLD = 50.0
|
||||||
|
TEMP_WARN = 78.0
|
||||||
|
TEMP_CRIT = 88.0
|
||||||
|
USAGE_WARN = 85.0
|
||||||
|
USAGE_CRIT = 95.0
|
||||||
|
|
||||||
|
|
||||||
|
def temp_color(celsius: float) -> str:
|
||||||
|
if celsius >= TEMP_CRIT:
|
||||||
|
return CRIT
|
||||||
|
if celsius >= TEMP_WARN:
|
||||||
|
return WARN
|
||||||
|
if celsius >= TEMP_COLD:
|
||||||
|
return GOOD
|
||||||
|
return COLD
|
||||||
|
|
||||||
|
|
||||||
|
def usage_color(pct: float) -> str:
|
||||||
|
if pct >= USAGE_CRIT:
|
||||||
|
return CRIT
|
||||||
|
if pct >= USAGE_WARN:
|
||||||
|
return WARN
|
||||||
|
return ACCENT
|
||||||
|
|
||||||
|
|
||||||
|
def gauge_color(kind: str, value: float | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return MUTED
|
||||||
|
if kind == "temp":
|
||||||
|
return temp_color(value)
|
||||||
|
if kind == "usage":
|
||||||
|
return usage_color(value)
|
||||||
|
return ACCENT
|
||||||
|
|
||||||
|
|
||||||
|
STYLESHEET = f"""
|
||||||
|
QWidget {{
|
||||||
|
color: {TEXT};
|
||||||
|
font-family: "Inter", "Cantarell", "Ubuntu", "Noto Sans", "DejaVu Sans", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
QMainWindow, #ContentArea, #Page {{ background: {BG}; }}
|
||||||
|
QLabel {{ background: transparent; }}
|
||||||
|
|
||||||
|
#Sidebar {{ background: {SIDEBAR}; border-right: 1px solid {CARD_BORDER}; }}
|
||||||
|
#AppTitle {{ font-size: 17px; font-weight: 800; }}
|
||||||
|
#AppSubtitle {{ color: {MUTED}; font-size: 11px; }}
|
||||||
|
|
||||||
|
QPushButton#NavButton {{
|
||||||
|
text-align: left; padding: 9px 12px; border: none; border-radius: 8px;
|
||||||
|
color: {MUTED}; background: transparent;
|
||||||
|
}}
|
||||||
|
QPushButton#NavButton:hover {{ background: {CARD}; color: {TEXT}; }}
|
||||||
|
QPushButton#NavButton:checked {{ background: {CARD}; color: #ffffff; font-weight: 600; }}
|
||||||
|
|
||||||
|
#Card {{ background: {CARD}; border: 1px solid {CARD_BORDER}; border-radius: 12px; }}
|
||||||
|
QPushButton#CardHeader {{
|
||||||
|
background: transparent; border: none; text-align: left;
|
||||||
|
font-size: 13px; font-weight: 700; color: {TEXT}; padding: 2px 0;
|
||||||
|
}}
|
||||||
|
QPushButton#CardHeader:hover {{ color: #ffffff; }}
|
||||||
|
#PageTitle {{ font-size: 22px; font-weight: 800; }}
|
||||||
|
#Muted {{ color: {MUTED}; }}
|
||||||
|
|
||||||
|
QScrollArea {{ border: none; background: transparent; }}
|
||||||
|
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }}
|
||||||
|
QScrollBar::handle:vertical {{ background: {CARD_BORDER}; border-radius: 5px; min-height: 28px; }}
|
||||||
|
QScrollBar::handle:vertical:hover {{ background: #3a414d; }}
|
||||||
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||||
|
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: transparent; }}
|
||||||
|
"""
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""Reusable GUI widgets: collapsible card, circular gauge, metric bar/row."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QRectF, Qt
|
||||||
|
from PySide6.QtGui import QColor, QFont, QPainter, QPen
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..core.sample import Reading
|
||||||
|
from ..render import format_value
|
||||||
|
from .theme import MUTED, TEXT, TRACK, gauge_color, temp_color
|
||||||
|
|
||||||
|
|
||||||
|
class Card(QFrame):
|
||||||
|
"""A titled panel whose body collapses when the header is clicked."""
|
||||||
|
|
||||||
|
def __init__(self, title: str, subtitle: str | None = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.setObjectName("Card")
|
||||||
|
self._title = title
|
||||||
|
self._open = True
|
||||||
|
|
||||||
|
outer = QVBoxLayout(self)
|
||||||
|
outer.setContentsMargins(16, 12, 16, 12)
|
||||||
|
outer.setSpacing(8)
|
||||||
|
|
||||||
|
self._header = QPushButton()
|
||||||
|
self._header.setObjectName("CardHeader")
|
||||||
|
self._header.setFlat(True)
|
||||||
|
self._header.setCheckable(True)
|
||||||
|
self._header.setChecked(True)
|
||||||
|
self._header.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self._header.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
|
self._header.clicked.connect(self._toggle)
|
||||||
|
outer.addWidget(self._header)
|
||||||
|
|
||||||
|
self._body = QWidget()
|
||||||
|
self._body_layout = QVBoxLayout(self._body)
|
||||||
|
self._body_layout.setContentsMargins(0, 2, 0, 0)
|
||||||
|
self._body_layout.setSpacing(7)
|
||||||
|
if subtitle:
|
||||||
|
sub = QLabel(subtitle)
|
||||||
|
sub.setObjectName("Muted")
|
||||||
|
self._body_layout.addWidget(sub)
|
||||||
|
outer.addWidget(self._body)
|
||||||
|
|
||||||
|
self._refresh_header()
|
||||||
|
|
||||||
|
def add(self, widget: QWidget) -> None:
|
||||||
|
self._body_layout.addWidget(widget)
|
||||||
|
|
||||||
|
def _toggle(self) -> None:
|
||||||
|
self._open = self._header.isChecked()
|
||||||
|
self._body.setVisible(self._open)
|
||||||
|
self._refresh_header()
|
||||||
|
|
||||||
|
def _refresh_header(self) -> None:
|
||||||
|
chevron = "▾" if self._open else "▸"
|
||||||
|
self._header.setText(f"{chevron} {self._title}")
|
||||||
|
|
||||||
|
|
||||||
|
class StatGauge(QWidget):
|
||||||
|
"""A circular gauge for a single headline metric."""
|
||||||
|
|
||||||
|
def __init__(self, title: str, unit: str = "", vmax: float = 100.0, kind: str = "accent") -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._title = title
|
||||||
|
self._unit = unit
|
||||||
|
self._max = vmax
|
||||||
|
self._kind = kind # "temp" | "usage" | "accent"
|
||||||
|
self._value: float | None = None
|
||||||
|
self.setMinimumSize(118, 140)
|
||||||
|
|
||||||
|
def set_value(self, value: float | None) -> None:
|
||||||
|
self._value = value
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None: # noqa: N802 (Qt override)
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
w, h = self.width(), self.height()
|
||||||
|
title_h = 20
|
||||||
|
side = min(w, h - title_h)
|
||||||
|
if side < 20:
|
||||||
|
p.end()
|
||||||
|
return
|
||||||
|
|
||||||
|
pen_w = max(7.0, side * 0.11)
|
||||||
|
inset = pen_w / 2 + 1
|
||||||
|
x = (w - side) / 2.0
|
||||||
|
arc = QRectF(x + inset, inset, side - 2 * inset, side - 2 * inset)
|
||||||
|
start = 225 * 16
|
||||||
|
full = -270 * 16
|
||||||
|
color = QColor(gauge_color(self._kind, self._value))
|
||||||
|
|
||||||
|
pen = QPen(QColor(TRACK))
|
||||||
|
pen.setWidthF(pen_w)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
p.setPen(pen)
|
||||||
|
p.drawArc(arc, start, full)
|
||||||
|
|
||||||
|
frac = 0.0 if self._value is None else max(0.0, min(1.0, self._value / self._max))
|
||||||
|
if frac > 0:
|
||||||
|
pen.setColor(color)
|
||||||
|
p.setPen(pen)
|
||||||
|
p.drawArc(arc, start, int(full * frac))
|
||||||
|
|
||||||
|
# Value (colored to match the arc)
|
||||||
|
value_text = "—" if self._value is None else f"{self._value:.0f}"
|
||||||
|
fval = QFont()
|
||||||
|
fval.setPointSizeF(max(12.0, side * 0.21))
|
||||||
|
fval.setBold(True)
|
||||||
|
p.setFont(fval)
|
||||||
|
p.setPen(color if self._value is not None else QColor(MUTED))
|
||||||
|
p.drawText(QRectF(x, side * 0.20, side, side * 0.42), Qt.AlignmentFlag.AlignCenter, value_text)
|
||||||
|
|
||||||
|
# Unit
|
||||||
|
if self._unit and self._value is not None:
|
||||||
|
funit = QFont()
|
||||||
|
funit.setPointSizeF(max(8.0, side * 0.095))
|
||||||
|
p.setFont(funit)
|
||||||
|
p.setPen(QColor(MUTED))
|
||||||
|
p.drawText(
|
||||||
|
QRectF(x, side * 0.56, side, side * 0.18),
|
||||||
|
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop,
|
||||||
|
self._unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Title under the arc
|
||||||
|
ftitle = QFont()
|
||||||
|
ftitle.setPointSizeF(10.0)
|
||||||
|
ftitle.setBold(True)
|
||||||
|
p.setFont(ftitle)
|
||||||
|
p.setPen(QColor(MUTED))
|
||||||
|
p.drawText(
|
||||||
|
QRectF(0, side - 2, w, title_h + 4),
|
||||||
|
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
self._title,
|
||||||
|
)
|
||||||
|
p.end()
|
||||||
|
|
||||||
|
|
||||||
|
class MetricBar(QWidget):
|
||||||
|
"""A label + value with a thin progress bar (for 0–100% metrics)."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, mode: str = "load") -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._name = name
|
||||||
|
self._mode = mode # "load" (accent) | "usage" (threshold-colored)
|
||||||
|
self._text = "N/A"
|
||||||
|
self._frac = 0.0
|
||||||
|
self._color = MUTED
|
||||||
|
self.setMinimumHeight(36)
|
||||||
|
|
||||||
|
def set_reading(self, r: Reading) -> None:
|
||||||
|
self._text = format_value(r)
|
||||||
|
if r.value is None:
|
||||||
|
self._frac = 0.0
|
||||||
|
self._color = MUTED
|
||||||
|
else:
|
||||||
|
self._frac = max(0.0, min(1.0, r.value / 100.0))
|
||||||
|
self._color = gauge_color("usage" if self._mode == "usage" else "accent", r.value)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None: # noqa: N802
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
w = self.width()
|
||||||
|
|
||||||
|
fname = QFont()
|
||||||
|
fname.setPointSizeF(10.5)
|
||||||
|
p.setFont(fname)
|
||||||
|
p.setPen(QColor(MUTED))
|
||||||
|
p.drawText(QRectF(0, 0, w * 0.6, 18), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._name)
|
||||||
|
|
||||||
|
fval = QFont()
|
||||||
|
fval.setPointSizeF(11.0)
|
||||||
|
fval.setBold(True)
|
||||||
|
p.setFont(fval)
|
||||||
|
p.setPen(QColor(self._color))
|
||||||
|
p.drawText(QRectF(w * 0.4, 0, w * 0.6, 18), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, self._text)
|
||||||
|
|
||||||
|
bar_y, bar_h = 23.0, 6.0
|
||||||
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
|
p.setBrush(QColor(TRACK))
|
||||||
|
p.drawRoundedRect(QRectF(0, bar_y, w, bar_h), 3, 3)
|
||||||
|
if self._frac > 0:
|
||||||
|
p.setBrush(QColor(self._color))
|
||||||
|
p.drawRoundedRect(QRectF(0, bar_y, w * self._frac, bar_h), 3, 3)
|
||||||
|
p.end()
|
||||||
|
|
||||||
|
|
||||||
|
class MetricRow(QWidget):
|
||||||
|
"""A simple label/value row; values in °C are colored by temperature."""
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
name_label = QLabel(name)
|
||||||
|
name_label.setObjectName("Muted")
|
||||||
|
self._value = QLabel("N/A")
|
||||||
|
self._value.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
layout.addWidget(name_label)
|
||||||
|
layout.addStretch(1)
|
||||||
|
layout.addWidget(self._value)
|
||||||
|
|
||||||
|
def set_reading(self, r: Reading) -> None:
|
||||||
|
self._value.setText(format_value(r))
|
||||||
|
if r.unit == "°C" and r.value is not None:
|
||||||
|
self._value.setStyleSheet(f"color: {temp_color(r.value)}; font-weight: 600; background: transparent;")
|
||||||
|
else:
|
||||||
|
self._value.setStyleSheet(f"color: {TEXT}; font-weight: 600; background: transparent;")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Background sampling for the GUI.
|
||||||
|
|
||||||
|
Sampling shells out to nvidia-smi etc., so it runs on a worker thread to keep the
|
||||||
|
UI responsive. Samples are delivered to the GUI via a Qt signal (thread-safe).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
|
from ..core.sampler import Sampler
|
||||||
|
from ..core.sources import available_sources
|
||||||
|
|
||||||
|
|
||||||
|
class SamplerWorker(QObject):
|
||||||
|
sampled = Signal(object) # emits a core.sample.Sample
|
||||||
|
|
||||||
|
def __init__(self, interval: float = 1.0):
|
||||||
|
super().__init__()
|
||||||
|
self._interval = interval
|
||||||
|
self._sampler = Sampler(available_sources())
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True, name="rigdoctor-sampler")
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
for sample in self._sampler.stream(interval=self._interval):
|
||||||
|
if self._stop.is_set():
|
||||||
|
break
|
||||||
|
self.sampled.emit(sample)
|
||||||
@@ -8,7 +8,8 @@ _GROUP_ORDER = ["gpu", "cpu", "memory", "storage"]
|
|||||||
_GROUP_TITLES = {"gpu": "GPU", "cpu": "CPU", "memory": "Memory", "storage": "Storage"}
|
_GROUP_TITLES = {"gpu": "GPU", "cpu": "CPU", "memory": "Memory", "storage": "Storage"}
|
||||||
|
|
||||||
|
|
||||||
def _fmt_value(r: Reading) -> str:
|
def format_value(r: Reading) -> str:
|
||||||
|
"""Format a reading's value + unit for display (shared by CLI and GUI)."""
|
||||||
if r.value is None:
|
if r.value is None:
|
||||||
return "N/A"
|
return "N/A"
|
||||||
if r.unit == "°C":
|
if r.unit == "°C":
|
||||||
@@ -18,11 +19,15 @@ def _fmt_value(r: Reading) -> str:
|
|||||||
return f"{r.value:g}"
|
return f"{r.value:g}"
|
||||||
|
|
||||||
|
|
||||||
|
def metric_label(r: Reading) -> str:
|
||||||
|
"""Human label for a reading's metric (e.g. 'temp memory')."""
|
||||||
|
return f"{r.metric} {r.label}".strip()
|
||||||
|
|
||||||
|
|
||||||
def _fmt(r: Reading) -> str:
|
def _fmt(r: Reading) -> str:
|
||||||
if r.metric == "name": # GPU/device identity line
|
if r.metric == "name": # GPU/device identity line
|
||||||
return f" {r.label}"
|
return f" {r.label}"
|
||||||
name = f"{r.metric} {r.label}".strip()
|
return f" {metric_label(r):<22} {format_value(r)}"
|
||||||
return f" {name:<22} {_fmt_value(r)}"
|
|
||||||
|
|
||||||
|
|
||||||
def render_snapshot(sample: Sample) -> str:
|
def render_snapshot(sample: Sample) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user