From 14180a2c200c4b7aba23a6d34be8b3af9f682f70 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 19 Oct 2026 19:25:01 +0200 Subject: Redesign the Dashboard as cards with stat tiles The tiles were plain group boxes of key: value text and read as text scattered on a page. Now a KPI row (packages, built, last pull, VM load) with large values and muted details, a slackrepo results stacked bar in the reserved status colors with hover counts and a labelled legend, and a recent jobs card. Colors come from the Qt palette, so the user's theme applies. Co-Authored-By: Claude Opus 5.5 --- sb_builder/dashboard.py | 215 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 178 insertions(+), 37 deletions(-) (limited to 'sb_builder') diff --git a/sb_builder/dashboard.py b/sb_builder/dashboard.py index aac1b9f..20d23e2 100644 --- a/sb_builder/dashboard.py +++ b/sb_builder/dashboard.py @@ -14,68 +14,209 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -"""Dashboard tab: tiles filled from sbb-remote stats and jobs.""" +"""Dashboard tab: KPI tiles, slackrepo results bar, recent jobs, from stats and jobs.""" +import html import time -from PyQt6.QtWidgets import (QGridLayout, QGroupBox, QHBoxLayout, QLabel, - QPushButton, QVBoxLayout, QWidget) +from PyQt6.QtCore import QEvent, QRectF, Qt +from PyQt6.QtGui import QColor, QFont, QPainter, QPalette +from PyQt6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton, + QToolTip, QVBoxLayout, QWidget) -from .fmt import NA, describe, fmt_age, fmt_bytes, fmt_duration +from .fmt import ICON, NA, fmt_age, fmt_bytes, fmt_duration, fmt_time, job_title -TILES = ("VM", "Repo", "slackrepo", "Sync", "Jobs", "Orphans") +# reserved status colors (dataviz reference palette); always shown with a text label +STATUS = {"ok": "#0ca30c", "aborted": "#fab219", "bad": "#ec835a", "failed": "#d03b3b", + "killed": "#fab219", "lost": "#ec835a"} +GAP = 2 # px of surface between bar segments + + +def num(n): + return NA if n is None else f"{n:,}" + + +def muted(label): + label.setForegroundRole(QPalette.ColorRole.PlaceholderText) + return label + + +class Card(QFrame): + """Rounded panel with a muted title; content goes into self.body.""" + + def __init__(self, title): + super().__init__(objectName="card") + self.body = QVBoxLayout(self) + self.body.setContentsMargins(16, 12, 16, 14) + self.body.addWidget(muted(QLabel(title))) + + +class Tile(Card): + """Stat tile: title, one large value, a muted detail line.""" + + def __init__(self, title): + super().__init__(title) + self.value = QLabel(NA) + f = self.value.font() + f.setPointSizeF(f.pointSizeF() * 2.2) + f.setWeight(QFont.Weight.DemiBold) + self.value.setFont(f) + self.detail = muted(QLabel()) + self.detail.setWordWrap(True) + self.body.addWidget(self.value) + self.body.addWidget(self.detail) + self.body.addStretch() + + def set(self, value, detail=""): + self.value.setText(value) + self.detail.setText(detail) + + +class StackedBar(QWidget): + """Horizontal part-to-whole bar; hover a segment for its label and count.""" + + def __init__(self): + super().__init__() + self.parts = [] # (label, count, QColor) + self.setFixedHeight(14) + + def set_parts(self, parts): + self.parts = [p for p in parts if p[1]] + self.update() + + def rects(self): + total = sum(c for _, c, _ in self.parts) + if not total: + return [] + room = self.width() - GAP * (len(self.parts) - 1) + out, x = [], 0.0 + for label, count, color in self.parts: + w = max(2.0, room * count / total) + out.append((QRectF(x, 0, w, self.height()), label, count, color)) + x += w + GAP + return out + + def paintEvent(self, _event): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.setPen(Qt.PenStyle.NoPen) + for rect, _label, _count, color in self.rects(): + p.setBrush(color) + p.drawRoundedRect(rect, 4, 4) + + def event(self, e): + if e.type() == QEvent.Type.ToolTip: + hit = next((f"{label}: {count:,}" for rect, label, count, _ in self.rects() + if rect.contains(e.position())), None) + if hit: + QToolTip.showText(e.globalPosition().toPoint(), hit, self) + else: + QToolTip.hideText() + return True + return super().event(e) class Dashboard(QWidget): def __init__(self, parent=None): super().__init__(parent) - self.labels = {} - grid = QGridLayout() - for i, name in enumerate(TILES): - box = QGroupBox(name) - label = QLabel(NA) - label.setWordWrap(True) - QVBoxLayout(box).addWidget(label) - grid.addWidget(box, i // 3, i % 3) - self.labels[name] = label - self.labels["Orphans"].setText("coming in phase 5") - self.stale = QLabel() + 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.results = Card("slackrepo results") + self.bar = StackedBar() + self.legend = QLabel(NA) + self.legend.setTextFormat(Qt.TextFormat.RichText) + self.legend.setWordWrap(True) + self.results.body.addWidget(self.bar) + self.results.body.addWidget(self.legend) + self.results.body.addStretch() + + self.orphans = Card("SBo orphans") + self.orphans.body.addWidget(muted(QLabel("coming in phase 5"))) + self.orphans.body.addStretch() + + self.jobs = Card("Recent jobs") + self.jobs_list = QLabel("no jobs yet") + self.jobs_list.setTextFormat(Qt.TextFormat.RichText) + self.jobs.body.addWidget(self.jobs_list) + self.jobs.body.addStretch() + + self.stale = muted(QLabel()) self.refresh_btn = QPushButton("Refresh") top = QHBoxLayout() top.addWidget(self.stale) top.addStretch() top.addWidget(self.refresh_btn) + + grid = QGridLayout() + 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.orphans, 1, 3) + grid.addWidget(self.jobs, 2, 0, 1, 4) + grid.setRowStretch(2, 1) layout = QVBoxLayout(self) + layout.setContentsMargins(16, 12, 16, 16) layout.addLayout(top) layout.addLayout(grid) - layout.addStretch() + + def cards(self): + return [*self.tiles.values(), self.results, self.orphans, self.jobs] def show_stats(self, s): - L = self.labels - load, disk = s.get("load"), s.get("disk_free") or {} - L["VM"].setText( - f"load: {NA if load is None else ' '.join(f'{x:.2f}' for x in load)}\n" - f"uptime: {fmt_duration(s.get('uptime'))}\n" - f"free: repo {fmt_bytes(disk.get('repo'))}, state {fmt_bytes(disk.get('state'))}") - p = s.get("packages") - L["Repo"].setText(NA if p is None else "\n".join(f"{k}: {v}" for k, v in p.items())) - sr = s.get("slackrepo") + p, sr, sync = s.get("packages"), s.get("slackrepo"), s.get("sync") or {} + t = self.tiles + if p is None: + t["Packages"].set(NA) + else: + extra = " · ".join(f"{num(v)}\u00a0{k}" for k, v in p.items() if k != "total") + t["Packages"].set(num(p["total"]), extra) if sr is None: - L["slackrepo"].setText(NA) + t["Built"].set(NA, "no slackrepo database") else: - never = NA if p is None else p["total"] - sr["built"] - L["slackrepo"].setText("\n".join( - [f"built: {sr['built']}", f"never built: {never}", - *(f"{k}: {v}" for k, v in sorted(sr["results"].items()))])) - sync = s.get("sync") or {} - L["Sync"].setText("\n".join(f"{o}: {fmt_age(t)}" for o, t in sync.items()) or NA) + never = "" if p is None else f" · {num(p['total'] - sr['built'])} never built" + t["Built"].set(num(sr["built"]), f"of {num(p and p['total'])}{never}") + known = [ts for ts in sync.values() if ts is not None] + 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") + self._show_results(sr) + + def _show_results(self, sr): + if sr is None: + self.bar.set_parts([]) + self.legend.setText(NA) + return + gray = self.palette().color(QPalette.ColorRole.Mid) + order = sorted(sr["results"].items(), + key=lambda kv: (list(STATUS).index(kv[0]) if kv[0] in STATUS else 99, kv[0])) + self.bar.set_parts([(k, v, QColor(STATUS.get(k)) if k in STATUS else gray) for k, v in order]) + self.legend.setText("   ".join( + f'■ {html.escape(k)} {num(v)}' + for k, v in order)) def show_jobs(self, jobs): - self.labels["Jobs"].setText("\n".join(describe(j) for j in jobs[:10]) or "no jobs yet") + rows = [] + for j in jobs[:10]: + color = STATUS.get(j["status"]) + icon = ICON.get(j["status"], "?") + if color: + icon = f'{icon}' + dur = None if "start" not in j else j.get("end", time.time()) - j["start"] + rows.append(f"{icon} {html.escape(job_title(j))}  " + f"{j['status']}  " + f"{fmt_time(j.get('start', j['created']))}  " + f"{fmt_duration(dur)}") + self.jobs_list.setText(f"{''.join(rows)}
" if rows else "no jobs yet") def set_stale(self, since): - """Grey the tiles when the VM stopped answering; None clears it.""" - for label in self.labels.values(): - label.setEnabled(since is None) + """Grey the cards when the VM stopped answering; None clears it.""" + for card in self.cards(): + card.setEnabled(since is None) self.stale.setText("" if since is None else f"stale since {time.strftime('%H:%M', time.localtime(since))}") -- cgit v1.2.3