diff options
Diffstat (limited to 'sb_builder')
| -rw-r--r-- | sb_builder/dashboard.py | 215 |
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())}">■</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} </td><td>{html.escape(job_title(j))} </td>" + f"<td>{j['status']} </td>" + f"<td>{fmt_time(j.get('start', j['created']))} </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))}") |
