aboutsummaryrefslogtreecommitdiffstats
path: root/sb_builder/dashboard.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-19 21:20:00 +0200
committerDanilo M. <danix@danix.xyz>2026-10-19 21:20:00 +0200
commit3865fd7d10d76cd265800fd8d5915a5792be4619 (patch)
tree28c4c160ad0668699d4a5d43a01bcc198369e6a0 /sb_builder/dashboard.py
parent57df6adb767e1691599f894e1e3925870b1b3b3f (diff)
downloadsb-builder-3865fd7d10d76cd265800fd8d5915a5792be4619.tar.gz
sb-builder-3865fd7d10d76cd265800fd8d5915a5792be4619.zip
Dashboard: VM meters and sparklines, slackpkg tile
sbb-remote 0.3.0 adds CPU busy % (two /proc/stat samples), RAM total and available, disk / total and free, and the last slackpkg update (LASTUPDATE) and package change (/var/log/packages), replacing disk_free. The Dashboard shows a VM card with meters for CPU, RAM and disk / (accent, warning from 80%, critical from 90%, value always printed) and CPU/RAM sparklines from this session's polls, and a slackpkg tile in place of VM load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'sb_builder/dashboard.py')
-rw-r--r--sb_builder/dashboard.py146
1 files changed, 136 insertions, 10 deletions
diff --git a/sb_builder/dashboard.py b/sb_builder/dashboard.py
index 85c19f4..474af66 100644
--- a/sb_builder/dashboard.py
+++ b/sb_builder/dashboard.py
@@ -14,12 +14,13 @@
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-"""Dashboard tab: KPI tiles, slackrepo results bar, recent jobs, from stats and jobs."""
+"""Dashboard tab: KPI tiles, VM meters, slackrepo results bar, recent jobs."""
+import collections
import html
import time
-from PyQt6.QtCore import QEvent, QRectF, Qt, pyqtSignal
+from PyQt6.QtCore import QEvent, QPointF, QRectF, Qt, pyqtSignal
from PyQt6.QtGui import QColor, QFont, QPainter, QPalette
from PyQt6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton,
QToolTip, QVBoxLayout, QWidget)
@@ -30,6 +31,16 @@ from .fmt import ICON, NA, fmt_age, fmt_bytes, fmt_duration, fmt_time, job_title
STATUS = {"ok": "#0ca30c", "aborted": "#fab219", "bad": "#ec835a", "failed": "#d03b3b",
"killed": "#fab219", "lost": "#ec835a"}
GAP = 2 # px of surface between bar segments
+SPARK_POINTS = 60 # ~30 min at the default 30 s dashboard refresh
+
+
+def meter_color(pct, accent):
+ """Fill color by severity: accent, then warning from 80%, critical from 90%."""
+ if pct >= 90:
+ return QColor(STATUS["failed"])
+ if pct >= 80:
+ return QColor(STATUS["aborted"])
+ return accent
def num(n):
@@ -128,12 +139,111 @@ class StackedBar(QWidget):
return super().event(e)
+class MeterBar(QWidget):
+ """Horizontal meter; the track is a faint step of the fill's own color."""
+
+ def __init__(self):
+ super().__init__()
+ self.pct = None
+ self.setFixedHeight(10)
+
+ def paintEvent(self, _event):
+ p = QPainter(self)
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
+ p.setPen(Qt.PenStyle.NoPen)
+ fill = meter_color(self.pct or 0, self.palette().color(QPalette.ColorRole.Highlight))
+ track = QColor(fill)
+ track.setAlphaF(0.22)
+ p.setBrush(track)
+ p.drawRoundedRect(QRectF(self.rect()), 4, 4)
+ if self.pct:
+ p.setBrush(fill)
+ p.drawRoundedRect(QRectF(0, 0, self.width() * min(self.pct, 100) / 100, self.height()), 4, 4)
+
+
+class Sparkline(QWidget):
+ """Recent values as a thin line, newest point marked; this session's polls only."""
+
+ def __init__(self):
+ super().__init__()
+ # ponytail: in-memory, filled only while the dashboard is polled (visible)
+ self.points = collections.deque(maxlen=SPARK_POINTS)
+ self.setFixedHeight(30)
+
+ def add(self, value):
+ self.points.append(value)
+ self.update()
+
+ def paintEvent(self, _event):
+ if len(self.points) < 2:
+ return
+ p = QPainter(self)
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
+ w, h = self.width() - 4, self.height() - 4
+ step = w / (SPARK_POINTS - 1)
+ x0 = 2 + w - step * (len(self.points) - 1) # newest at the right edge
+ pts = [QPointF(x0 + i * step, 2 + h - h * min(v, 100) / 100)
+ for i, v in enumerate(self.points)]
+ pen = p.pen()
+ pen.setColor(self.palette().color(QPalette.ColorRole.PlaceholderText))
+ pen.setWidthF(2)
+ p.setPen(pen)
+ p.drawPolyline(pts)
+ p.setPen(Qt.PenStyle.NoPen)
+ p.setBrush(self.palette().color(QPalette.ColorRole.Highlight))
+ p.drawEllipse(pts[-1], 3, 3)
+
+
+class Meter(QWidget):
+ """Label and percentage, meter bar, muted detail, optional sparkline."""
+
+ def __init__(self, label, spark=False):
+ super().__init__()
+ self.value = QLabel(NA)
+ f = self.value.font()
+ f.setWeight(QFont.Weight.DemiBold)
+ self.value.setFont(f)
+ self.bar = MeterBar()
+ self.detail = muted(QLabel())
+ self.spark = Sparkline() if spark else None
+ head = QHBoxLayout()
+ head.addWidget(QLabel(label))
+ head.addStretch()
+ head.addWidget(self.value)
+ col = QVBoxLayout()
+ col.setSpacing(4)
+ col.addLayout(head)
+ col.addWidget(self.bar)
+ col.addWidget(self.detail)
+ if self.spark:
+ col.addWidget(self.spark)
+ col.addStretch()
+ col.setContentsMargins(0, 0, 0, 0)
+ self.setLayout(col)
+
+ def set(self, pct, detail):
+ self.bar.pct = pct
+ self.bar.update()
+ self.value.setText(NA if pct is None else f"{pct:.0f}%")
+ self.detail.setText(detail)
+ if self.spark and pct is not None:
+ self.spark.add(pct)
+
+
class Dashboard(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet("QFrame#card { background: palette(base);"
" border: 1px solid palette(mid); border-radius: 10px; }")
- self.tiles = {name: Tile(name) for name in ("Packages", "Built", "Last pull", "VM load")}
+ self.tiles = {name: Tile(name) for name in ("Packages", "Built", "Last pull", "slackpkg")}
+
+ self.vm = Card("VM")
+ self.cpu, self.ram, self.disk = Meter("CPU", spark=True), Meter("RAM", spark=True), Meter("Disk /")
+ meters = QHBoxLayout()
+ meters.setSpacing(28)
+ for m in (self.cpu, self.ram, self.disk):
+ meters.addWidget(m, 1)
+ self.vm.body.addLayout(meters)
self.results = Card("slackrepo results")
self.bar = StackedBar()
@@ -165,17 +275,18 @@ class Dashboard(QWidget):
grid.setSpacing(12)
for i, tile in enumerate(self.tiles.values()):
grid.addWidget(tile, 0, i)
- grid.addWidget(self.results, 1, 0, 1, 3)
+ grid.addWidget(self.vm, 1, 0, 1, 3)
grid.addWidget(self.orphans, 1, 3)
- grid.addWidget(self.jobs, 2, 0, 1, 4)
- grid.setRowStretch(2, 1)
+ grid.addWidget(self.results, 2, 0, 1, 4)
+ grid.addWidget(self.jobs, 3, 0, 1, 4)
+ grid.setRowStretch(3, 1)
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 12, 16, 16)
layout.addLayout(top)
layout.addLayout(grid)
def cards(self):
- return [*self.tiles.values(), self.results, self.orphans, self.jobs]
+ return [*self.tiles.values(), self.vm, self.results, self.orphans, self.jobs]
def show_stats(self, s):
p, sr, sync = s.get("packages"), s.get("slackrepo"), s.get("sync") or {}
@@ -194,9 +305,24 @@ class Dashboard(QWidget):
t["Last pull"].set(fmt_age(min(known)) if known else NA,
" · ".join(f"{o} {fmt_duration(None if ts is None else time.time() - ts)}"
for o, ts in sync.items()))
- load, disk = s.get("load"), s.get("disk_free") or {}
- t["VM load"].set(NA if load is None else f"{load[0]:.2f}",
- f"up {fmt_duration(s.get('uptime'))} · {fmt_bytes(disk.get('repo'))} free")
+ pkg = s.get("slackpkg") or {}
+ t["slackpkg"].set(fmt_age(pkg.get("upgrade")) if pkg.get("upgrade") else NA,
+ "last upgrade · lists updated " + fmt_duration(
+ None if pkg.get("update") is None else time.time() - pkg["update"]))
+ load, mem, disk = s.get("load"), s.get("mem"), s.get("disk") or {}
+ self.cpu.set(s.get("cpu"), f"load {NA if load is None else f'{load[0]:.2f}'}"
+ f" · up {fmt_duration(s.get('uptime'))}")
+ if mem:
+ used = mem["total"] - mem["available"]
+ self.ram.set(100 * used / mem["total"],
+ f"{fmt_bytes(used)} used of {fmt_bytes(mem['total'])}")
+ else:
+ self.ram.set(None, "")
+ if disk.get("total"):
+ self.disk.set(100 * (disk["total"] - disk["free"]) / disk["total"],
+ f"{fmt_bytes(disk['free'])} free of {fmt_bytes(disk['total'])}")
+ else:
+ self.disk.set(None, "")
self._show_results(sr)
def _show_results(self, sr):