From 3865fd7d10d76cd265800fd8d5915a5792be4619 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 19 Oct 2026 21:20:00 +0200 Subject: 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 --- sb_builder/dashboard.py | 146 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 136 insertions(+), 10 deletions(-) (limited to 'sb_builder/dashboard.py') 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): -- cgit v1.2.3