Merge pull request 'feat(gui): dashboard history graphs for headline metrics — 0.14.0' (#9) from feat/m6-steam-detection into main
release / release (push) Successful in 14s
release / release (push) Successful in 14s
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
@@ -5,6 +5,13 @@ 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.14.0] - 2026-05-22
|
||||||
|
### Changed
|
||||||
|
- **Dashboard headline tiles are now history trend graphs** instead of single-value gauges —
|
||||||
|
GPU temp, GPU load, CPU temp, and memory each plot their recent history (with the current
|
||||||
|
value, window min/max, and a dashed warning-threshold line), so you can see changes over time
|
||||||
|
rather than only the instantaneous reading. New `HistoryGraph` widget (QPainter, no new deps).
|
||||||
|
|
||||||
## [0.13.0] - 2026-05-22
|
## [0.13.0] - 2026-05-22
|
||||||
### Added
|
### Added
|
||||||
- **Run Diagnostic now explains itself and can launch the game.** Clicking Run Diagnostic shows
|
- **Run Diagnostic now explains itself and can launch the game.** Clicking Run Diagnostic shows
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rigdoctor"
|
name = "rigdoctor"
|
||||||
version = "0.13.0"
|
version = "0.14.0"
|
||||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||||
|
|
||||||
__version__ = "0.13.0"
|
__version__ = "0.14.0"
|
||||||
|
|||||||
@@ -17,19 +17,19 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from ..core.sample import Sample
|
from ..core.sample import Sample
|
||||||
from ..render import metric_label
|
from ..render import metric_label
|
||||||
from .widgets import Card, MetricBar, MetricRow, StatGauge
|
from .widgets import Card, HistoryGraph, MetricBar, MetricRow
|
||||||
|
|
||||||
_GROUP_ORDER = ["gpu", "cpu", "memory", "storage"]
|
_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"}
|
||||||
_BAR_METRICS = {"util", "mem_util", "fan", "used_pct"}
|
_BAR_METRICS = {"util", "mem_util", "fan", "used_pct"}
|
||||||
|
|
||||||
|
|
||||||
def _gauge_card(gauge: StatGauge) -> QFrame:
|
def _tile_card(widget: QWidget) -> QFrame:
|
||||||
card = QFrame()
|
card = QFrame()
|
||||||
card.setObjectName("Card")
|
card.setObjectName("Card")
|
||||||
layout = QVBoxLayout(card)
|
layout = QVBoxLayout(card)
|
||||||
layout.setContentsMargins(6, 14, 6, 8)
|
layout.setContentsMargins(6, 10, 6, 8)
|
||||||
layout.addWidget(gauge)
|
layout.addWidget(widget)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
@@ -54,16 +54,16 @@ class Dashboard(QWidget):
|
|||||||
header.addWidget(self._updated)
|
header.addWidget(self._updated)
|
||||||
root.addLayout(header)
|
root.addLayout(header)
|
||||||
|
|
||||||
# Headline gauges
|
# Headline trend graphs (history over the session, not just the live value)
|
||||||
self._g_gpu_temp = StatGauge("GPU Temp", "°C", 100, "temp")
|
self._g_gpu_temp = HistoryGraph("GPU Temp", "°C", 30, 100, "temp")
|
||||||
self._g_gpu_load = StatGauge("GPU Load", "%", 100, "accent")
|
self._g_gpu_load = HistoryGraph("GPU Load", "%", 0, 100, "accent")
|
||||||
self._g_cpu_temp = StatGauge("CPU Temp", "°C", 100, "temp")
|
self._g_cpu_temp = HistoryGraph("CPU Temp", "°C", 30, 100, "temp")
|
||||||
self._g_mem = StatGauge("Memory", "%", 100, "usage")
|
self._g_mem = HistoryGraph("Memory", "%", 0, 100, "usage")
|
||||||
gauges = QHBoxLayout()
|
graphs = QHBoxLayout()
|
||||||
gauges.setSpacing(14)
|
graphs.setSpacing(14)
|
||||||
for g in (self._g_gpu_temp, self._g_gpu_load, self._g_cpu_temp, self._g_mem):
|
for g in (self._g_gpu_temp, self._g_gpu_load, self._g_cpu_temp, self._g_mem):
|
||||||
gauges.addWidget(_gauge_card(g))
|
graphs.addWidget(_tile_card(g))
|
||||||
root.addLayout(gauges)
|
root.addLayout(graphs)
|
||||||
|
|
||||||
# Per-subsystem cards (scrollable, 2-column grid)
|
# Per-subsystem cards (scrollable, 2-column grid)
|
||||||
scroll = QScrollArea()
|
scroll = QScrollArea()
|
||||||
@@ -81,10 +81,10 @@ class Dashboard(QWidget):
|
|||||||
root.addWidget(scroll, 1)
|
root.addWidget(scroll, 1)
|
||||||
|
|
||||||
def update_sample(self, sample: Sample) -> None:
|
def update_sample(self, sample: Sample) -> None:
|
||||||
self._g_gpu_temp.set_value(self._val(sample, "gpu", "temp", ""))
|
self._g_gpu_temp.add_value(self._val(sample, "gpu", "temp", ""))
|
||||||
self._g_gpu_load.set_value(self._val(sample, "gpu", "util"))
|
self._g_gpu_load.add_value(self._val(sample, "gpu", "util"))
|
||||||
self._g_cpu_temp.set_value(self._cpu_temp(sample))
|
self._g_cpu_temp.add_value(self._cpu_temp(sample))
|
||||||
self._g_mem.set_value(self._val(sample, "memory", "used_pct"))
|
self._g_mem.add_value(self._val(sample, "memory", "used_pct"))
|
||||||
|
|
||||||
keys = [r.key for r in sample.readings]
|
keys = [r.key for r in sample.readings]
|
||||||
if keys != self._built_keys: # sources appeared/disappeared
|
if keys != self._built_keys: # sources appeared/disappeared
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import QRectF, Qt
|
from collections import deque
|
||||||
from PySide6.QtGui import QColor, QFont, QPainter, QPen
|
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, Qt
|
||||||
|
from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
@@ -17,7 +19,19 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from ..core.sample import Reading
|
from ..core.sample import Reading
|
||||||
from ..render import format_value
|
from ..render import format_value
|
||||||
from .theme import ACCENT, CRIT, GOOD, MUTED, TEXT, TRACK, WARN, gauge_color, temp_color
|
from .theme import (
|
||||||
|
ACCENT,
|
||||||
|
CRIT,
|
||||||
|
GOOD,
|
||||||
|
MUTED,
|
||||||
|
TEMP_WARN,
|
||||||
|
TEXT,
|
||||||
|
TRACK,
|
||||||
|
USAGE_WARN,
|
||||||
|
WARN,
|
||||||
|
gauge_color,
|
||||||
|
temp_color,
|
||||||
|
)
|
||||||
|
|
||||||
_SEV = {
|
_SEV = {
|
||||||
"critical": ("CRITICAL", CRIT),
|
"critical": ("CRITICAL", CRIT),
|
||||||
@@ -248,6 +262,117 @@ class StatGauge(QWidget):
|
|||||||
p.end()
|
p.end()
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryGraph(QWidget):
|
||||||
|
"""A headline metric as a trend: current value + window min/max + a history line.
|
||||||
|
|
||||||
|
Replaces the at-a-glance gauge with changes-over-time. `kind` drives the color
|
||||||
|
(temp band / usage / accent), matching StatGauge so the dashboard stays consistent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, title: str, unit: str = "", vmin: float = 0.0, vmax: float = 100.0,
|
||||||
|
kind: str = "accent", history: int = 180) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._title = title
|
||||||
|
self._unit = unit
|
||||||
|
self._min = vmin
|
||||||
|
self._max = vmax
|
||||||
|
self._kind = kind # "temp" | "usage" | "accent"
|
||||||
|
self._values: deque[float | None] = deque(maxlen=history)
|
||||||
|
self.setMinimumSize(160, 132)
|
||||||
|
|
||||||
|
def add_value(self, value: float | None) -> None:
|
||||||
|
self._values.append(value)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _fmt(self, value: float | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
if self._unit == "°C":
|
||||||
|
return f"{value:.0f}°"
|
||||||
|
if self._unit == "%":
|
||||||
|
return f"{value:.0f}%"
|
||||||
|
return f"{value:.0f}{self._unit}"
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None: # noqa: N802 (Qt override)
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
w, h = self.width(), self.height()
|
||||||
|
pad = 10.0
|
||||||
|
present = [v for v in self._values if v is not None]
|
||||||
|
current = next((v for v in reversed(self._values) if v is not None), None)
|
||||||
|
color = QColor(gauge_color(self._kind, current))
|
||||||
|
|
||||||
|
ftitle = QFont()
|
||||||
|
ftitle.setPointSizeF(10.0)
|
||||||
|
ftitle.setBold(True)
|
||||||
|
p.setFont(ftitle)
|
||||||
|
p.setPen(QColor(MUTED))
|
||||||
|
p.drawText(QRectF(pad, 6, w - 2 * pad, 18),
|
||||||
|
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._title)
|
||||||
|
|
||||||
|
fval = QFont()
|
||||||
|
fval.setPointSizeF(21.0)
|
||||||
|
fval.setBold(True)
|
||||||
|
p.setFont(fval)
|
||||||
|
p.setPen(color if current is not None else QColor(MUTED))
|
||||||
|
p.drawText(QRectF(pad, 2, w - 2 * pad, 28),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop, self._fmt(current))
|
||||||
|
|
||||||
|
if present:
|
||||||
|
fsm = QFont()
|
||||||
|
fsm.setPointSizeF(8.5)
|
||||||
|
p.setFont(fsm)
|
||||||
|
p.setPen(QColor(MUTED))
|
||||||
|
p.drawText(QRectF(pad, 27, w - 2 * pad, 14), Qt.AlignmentFlag.AlignLeft,
|
||||||
|
f"min {self._fmt(min(present))} max {self._fmt(max(present))}")
|
||||||
|
|
||||||
|
g_top, g_bot = 48.0, h - pad
|
||||||
|
g_left, g_right = pad, w - pad
|
||||||
|
span = self._max - self._min
|
||||||
|
if g_bot - g_top < 12 or g_right - g_left < 12 or span <= 0:
|
||||||
|
p.end()
|
||||||
|
return
|
||||||
|
|
||||||
|
def y_of(v: float) -> float:
|
||||||
|
frac = (max(self._min, min(self._max, v)) - self._min) / span
|
||||||
|
return g_bot - frac * (g_bot - g_top)
|
||||||
|
|
||||||
|
warn = TEMP_WARN if self._kind == "temp" else (USAGE_WARN if self._kind == "usage" else None)
|
||||||
|
if warn is not None and self._min <= warn <= self._max:
|
||||||
|
pen = QPen(QColor(TRACK))
|
||||||
|
pen.setWidthF(1.0)
|
||||||
|
pen.setStyle(Qt.PenStyle.DashLine)
|
||||||
|
p.setPen(pen)
|
||||||
|
yw = y_of(warn)
|
||||||
|
p.drawLine(QPointF(g_left, yw), QPointF(g_right, yw))
|
||||||
|
|
||||||
|
maxlen = self._values.maxlen or 1
|
||||||
|
step = (g_right - g_left) / max(1, maxlen - 1)
|
||||||
|
n = len(self._values)
|
||||||
|
# Build the line newest-at-right; break it where readings are missing.
|
||||||
|
path = QPainterPath()
|
||||||
|
drawing = False
|
||||||
|
for i, v in enumerate(self._values):
|
||||||
|
if v is None:
|
||||||
|
drawing = False
|
||||||
|
continue
|
||||||
|
x = g_right - (n - 1 - i) * step
|
||||||
|
y = y_of(v)
|
||||||
|
if drawing:
|
||||||
|
path.lineTo(x, y)
|
||||||
|
else:
|
||||||
|
path.moveTo(x, y)
|
||||||
|
drawing = True
|
||||||
|
if not path.isEmpty():
|
||||||
|
pen = QPen(color)
|
||||||
|
pen.setWidthF(2.0)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
|
p.setPen(pen)
|
||||||
|
p.drawPath(path)
|
||||||
|
p.end()
|
||||||
|
|
||||||
|
|
||||||
class MetricBar(QWidget):
|
class MetricBar(QWidget):
|
||||||
"""A label + value with a thin progress bar (for 0–100% metrics)."""
|
"""A label + value with a thin progress bar (for 0–100% metrics)."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user