aboutsummaryrefslogtreecommitdiffstats
path: root/sb_builder/dashboard.py
diff options
context:
space:
mode:
Diffstat (limited to 'sb_builder/dashboard.py')
-rw-r--r--sb_builder/dashboard.py215
1 files changed, 178 insertions, 37 deletions
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'<span style="color:{STATUS.get(k, gray.name())}">&#9632;</span> {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'<span style="color:{color}">{icon}</span>'
+ dur = None if "start" not in j else j.get("end", time.time()) - j["start"]
+ rows.append(f"<tr><td>{icon}&nbsp;</td><td>{html.escape(job_title(j))}&nbsp;&nbsp;</td>"
+ f"<td>{j['status']}&nbsp;&nbsp;</td>"
+ f"<td>{fmt_time(j.get('start', j['created']))}&nbsp;&nbsp;</td>"
+ f"<td>{fmt_duration(dur)}</td></tr>")
+ self.jobs_list.setText(f"<table>{''.join(rows)}</table>" 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))}")