aboutsummaryrefslogtreecommitdiffstats
path: root/sb_builder
diff options
context:
space:
mode:
Diffstat (limited to 'sb_builder')
-rw-r--r--sb_builder/__init__.py2
-rw-r--r--sb_builder/dashboard.py22
-rw-r--r--sb_builder/mainwindow.py25
-rw-r--r--sb_builder/results.py195
4 files changed, 238 insertions, 6 deletions
diff --git a/sb_builder/__init__.py b/sb_builder/__init__.py
index 563a7ce..539b874 100644
--- a/sb_builder/__init__.py
+++ b/sb_builder/__init__.py
@@ -16,4 +16,4 @@
"""sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM."""
-VERSION = "0.3.0" # must match remote/sbb-remote VERSION
+VERSION = "0.4.0" # must match remote/sbb-remote VERSION
diff --git a/sb_builder/dashboard.py b/sb_builder/dashboard.py
index 8c98040..a8de823 100644
--- a/sb_builder/dashboard.py
+++ b/sb_builder/dashboard.py
@@ -104,7 +104,9 @@ class Tile(Card):
class StackedBar(QWidget):
- """Horizontal part-to-whole bar; hover a segment for its label and count."""
+ """Horizontal part-to-whole bar; hover a segment for its count, click to open it."""
+
+ clicked = pyqtSignal(str)
def __init__(self):
super().__init__()
@@ -135,6 +137,14 @@ class StackedBar(QWidget):
p.setBrush(color)
p.drawRoundedRect(rect, 4, 4)
+ def label_at(self, pos):
+ return next((label for rect, label, _c, _col in self.rects() if rect.contains(pos)), None)
+
+ def mouseReleaseEvent(self, e):
+ label = self.label_at(e.position())
+ if label and e.button() == Qt.MouseButton.LeftButton:
+ self.clicked.emit(label)
+
def event(self, e):
if e.type() == QEvent.Type.ToolTip:
hit = next((f"{label}: {count:,}" for rect, label, count, _ in self.rects()
@@ -239,6 +249,8 @@ class Meter(QWidget):
class Dashboard(QWidget):
+ result_clicked = pyqtSignal(str) # a slackrepo result kind, from the bar or legend
+
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet("QFrame#card { background: palette(base);"
@@ -258,6 +270,9 @@ class Dashboard(QWidget):
self.legend = QLabel(NA)
self.legend.setTextFormat(Qt.TextFormat.RichText)
self.legend.setWordWrap(True)
+ self.legend.linkActivated.connect(self.result_clicked)
+ self.bar.clicked.connect(self.result_clicked)
+ self.bar.setCursor(Qt.CursorShape.PointingHandCursor)
self.results.body.addWidget(self.bar)
self.results.body.addWidget(self.legend)
self.results.body.addStretch()
@@ -341,11 +356,14 @@ class Dashboard(QWidget):
self.legend.setText(NA)
return
gray = self.palette().color(QPalette.ColorRole.PlaceholderText) # Mid can match the background
+ ink = self.palette().color(QPalette.ColorRole.WindowText).name() # links in text color
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)}'
+ f'<span style="color:{STATUS.get(k, gray.name())}">&#9632;</span> '
+ f'<a href="{html.escape(k)}" style="color:{ink}; text-decoration:none">'
+ f'{html.escape(k)} {num(v)}</a>'
for k, v in order))
def show_jobs(self, jobs):
diff --git a/sb_builder/mainwindow.py b/sb_builder/mainwindow.py
index bd2245d..247b0cf 100644
--- a/sb_builder/mainwindow.py
+++ b/sb_builder/mainwindow.py
@@ -29,6 +29,7 @@ from .dashboard import Dashboard
from .fmt import job_title
from .jobs import JobsTab
from .packages import PackagesTab
+from .results import ResultsTab
from .remote import DOWN, MISSING
NOTIFY = "org.freedesktop.Notifications"
@@ -36,6 +37,7 @@ NOTIFY_PATH = "/org/freedesktop/Notifications"
# ribbon entries: label, freedesktop icon name, Qt fallback icon
RIBBON = (("Dashboard", "go-home", QStyle.StandardPixmap.SP_DirHomeIcon),
("Packages", "package-x-generic", QStyle.StandardPixmap.SP_FileDialogDetailedView),
+ ("Results", "view-list-text", QStyle.StandardPixmap.SP_MessageBoxWarning),
("Jobs", "system-run", QStyle.StandardPixmap.SP_MediaPlay))
# tabs as a distinct, centered row; colors from the palette so the theme applies
TAB_STYLE = """
@@ -84,6 +86,7 @@ class MainWindow(QMainWindow):
self.notifier.clicked.connect(self.open_job)
self.jobs_tab = None
self.packages_tab = None
+ self.results_tab = None
self.seen = None # job id -> status at the last poll; None before the first
self.online = None # None until the first answer
self.overlays = []
@@ -100,6 +103,7 @@ class MainWindow(QMainWindow):
self.dashboard.refresh_btn.clicked.connect(self.poll_stats)
self.dashboard.tiles["Packages"].link(self.open_packages)
self.dashboard.jobs.link(self.open_jobs)
+ self.dashboard.result_clicked.connect(self.open_results)
self.tabs.addTab(self.dashboard, "Dashboard")
self.tabs.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None)
self.setCentralWidget(self.tabs)
@@ -115,7 +119,8 @@ class MainWindow(QMainWindow):
self.ribbon.setIconSize(QSize(32, 32))
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.ribbon)
slots = {"Dashboard": lambda: self.tabs.setCurrentWidget(self.dashboard),
- "Packages": self.open_packages, "Jobs": self.open_jobs}
+ "Packages": self.open_packages, "Results": lambda: self.open_results(),
+ "Jobs": self.open_jobs}
for label, name, fallback in RIBBON:
icon = QIcon.fromTheme(name, self.style().standardIcon(fallback))
self.ribbon.addAction(icon, label, slots[label])
@@ -275,8 +280,10 @@ class MainWindow(QMainWindow):
event = END_EVENT.get(j["status"])
if event and prev.get(j["id"], "running") != j["status"]:
self.notify(event, job_title(j), j["id"])
- if j["kind"] in ("slackrepo", "publish") and self.packages_tab:
- self.packages_tab.reload() # build state changed
+ if j["kind"] in ("slackrepo", "publish"): # build state changed
+ for tab in (self.packages_tab, self.results_tab):
+ if tab:
+ tab.reload()
# ── tabs ────────────────────────────────────────────────────────────────
@@ -302,6 +309,16 @@ class MainWindow(QMainWindow):
self.tabs.setCurrentWidget(self.packages_tab)
return self.packages_tab
+ def open_results(self, result=None):
+ if self.results_tab is None:
+ self.results_tab = ResultsTab(self.remote)
+ self.tabs.addTab(self.results_tab, "Results")
+ self.results_tab.reload()
+ if result:
+ self.results_tab.filter_to(result)
+ self.tabs.setCurrentWidget(self.results_tab)
+ return self.results_tab
+
def open_job(self, jid):
self.show()
self.raise_()
@@ -319,6 +336,8 @@ class MainWindow(QMainWindow):
w.stop()
if w is self.packages_tab:
self.packages_tab = None
+ if w is self.results_tab:
+ self.results_tab = None
w.deleteLater()
def reload_config(self):
diff --git a/sb_builder/results.py b/sb_builder/results.py
new file mode 100644
index 0000000..30a3adc
--- /dev/null
+++ b/sb_builder/results.py
@@ -0,0 +1,195 @@
+# sb-builder - Results tab
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Results tab: slackrepo's last build attempt per package, and its build log."""
+
+import html
+import re
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QColor, QFontDatabase, QTextCursor
+from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton,
+ QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget)
+
+from .dashboard import STATUS
+from .fmt import NA, fmt_time
+from .packages import chip
+
+KINDS = ("ok", "failed", "bad", "aborted", "removed")
+ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
+
+
+class TimeItem(QTableWidgetItem):
+ """Sorts by the timestamp it carries, not by its MM-DD HH:MM text."""
+
+ def __lt__(self, other):
+ return self.data(Qt.ItemDataRole.UserRole) < other.data(Qt.ItemDataRole.UserRole)
+
+
+class LogViewer(QDialog):
+ """Non-modal window with one package's build log, scrolled to the end."""
+
+ def __init__(self, path, log, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle(f"{path}: build log")
+ self.resize(1000, 700)
+ self.text = QPlainTextEdit(readOnly=True)
+ self.text.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
+ parts = [] # resource.log first: the end of build.log, where it failed, stays last
+ if log["resource"]:
+ parts.append("──── resource.log ────\n" + ANSI.sub("", log["resource"]) + "\n")
+ parts.append("──── build.log ────\n")
+ if log["truncated"]:
+ parts.append("[... start of build.log truncated, showing its end ...]\n")
+ parts.append(ANSI.sub("", log["build"]))
+ self.text.setPlainText("".join(parts))
+ self.text.moveCursor(QTextCursor.MoveOperation.End)
+ QVBoxLayout(self).addWidget(self.text)
+
+
+class ResultsTab(QWidget):
+ def __init__(self, remote, parent=None):
+ super().__init__(parent)
+ self.remote = remote
+ self.rows = []
+ self.viewers = []
+ self.chips = {k: chip(k) for k in KINDS}
+ bar = QHBoxLayout()
+ for c in self.chips.values():
+ c.toggled.connect(self.refilter)
+ bar.addWidget(c)
+ bar.addStretch()
+ self.count = QLabel()
+ bar.addWidget(self.count)
+ refresh = QPushButton("Refresh")
+ refresh.clicked.connect(self.reload)
+ bar.addWidget(refresh)
+
+ self.table = QTableWidget(0, 4, sortingEnabled=True)
+ self.table.setHorizontalHeaderLabels(["result", "package", "built version", "attempt"])
+ self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
+ self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
+ self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
+ self.table.verticalHeader().hide()
+ self.table.horizontalHeader().setStretchLastSection(True)
+ for col, width in enumerate((90, 320, 140)):
+ self.table.setColumnWidth(col, width)
+ self.table.itemSelectionChanged.connect(self.show_detail)
+ self.table.doubleClicked.connect(self.show_log)
+
+ self.detail = QLabel(textFormat=Qt.TextFormat.RichText, wordWrap=True,
+ alignment=Qt.AlignmentFlag.AlignTop)
+ self.detail.setContentsMargins(12, 8, 12, 8)
+ self.log_btn = QPushButton("Show build log")
+ self.log_btn.clicked.connect(self.show_log)
+ self.log_btn.setEnabled(False)
+ side = QWidget()
+ col = QVBoxLayout(side)
+ col.addWidget(self.detail)
+ col.addWidget(self.log_btn)
+ col.addStretch()
+ split = QSplitter()
+ split.addWidget(self.table)
+ split.addWidget(side)
+ split.setSizes([750, 350])
+
+ layout = QVBoxLayout(self)
+ layout.addLayout(bar)
+ layout.addWidget(split)
+
+ def reload(self):
+ self.count.setText("loading…")
+ self.remote.call(self._loaded, "results")
+
+ def _loaded(self, data, err):
+ if err:
+ self.count.setText(f"results failed: {err}")
+ return
+ self.rows = data["rows"]
+ self.table.setSortingEnabled(False)
+ self.table.setRowCount(len(self.rows))
+ for i, (path, result, when, version, has_log) in enumerate(self.rows):
+ items = [QTableWidgetItem(result), QTableWidgetItem(path),
+ QTableWidgetItem(version or ""), TimeItem(fmt_time(when))]
+ if result in STATUS:
+ items[0].setForeground(QColor(STATUS[result]))
+ items[3].setData(Qt.ItemDataRole.UserRole, when)
+ items[1].setData(Qt.ItemDataRole.UserRole, i)
+ for c, item in enumerate(items):
+ self.table.setItem(i, c, item)
+ self.table.setSortingEnabled(True)
+ self.table.sortItems(3, Qt.SortOrder.DescendingOrder) # newest attempt first
+ self.refilter()
+
+ def filter_to(self, result):
+ """Show only one result kind (from a Dashboard click)."""
+ for k, c in self.chips.items():
+ c.blockSignals(True)
+ c.setChecked(k == result)
+ c.blockSignals(False)
+ self.refilter()
+
+ def refilter(self):
+ on = {k for k, c in self.chips.items() if c.isChecked()}
+ shown = 0
+ for r in range(self.table.rowCount()):
+ hide = bool(on) and self.table.item(r, 0).text() not in on
+ self.table.setRowHidden(r, hide)
+ shown += not hide
+ if hide and self.table.item(r, 0).isSelected():
+ self.table.clearSelection() # no detail for a row the filter hides
+ self.count.setText(f"{shown} of {self.table.rowCount()}")
+
+ def visible_paths(self):
+ return [self.table.item(r, 1).text() for r in range(self.table.rowCount())
+ if not self.table.isRowHidden(r)]
+
+ def current(self):
+ rows = self.table.selectionModel().selectedRows()
+ if not rows:
+ return None
+ return self.rows[self.table.item(rows[0].row(), 1).data(Qt.ItemDataRole.UserRole)]
+
+ def show_detail(self):
+ row = self.current()
+ self.log_btn.setEnabled(bool(row and row[4]))
+ if not row:
+ self.detail.setText("")
+ return
+ path, result, when, version, has_log = row
+ color = STATUS.get(result)
+ res = f'<span style="color:{color}">{result}</span>' if color else html.escape(result)
+ self.detail.setText(
+ f"<h3>{html.escape(path.split('/', 1)[1])}</h3><p>{html.escape(path)}</p>"
+ f"<p>last attempt: {res}, {fmt_time(when)}<br>built version: "
+ f"{html.escape(version or NA)}</p>"
+ + ("" if has_log else "<p><i>no build log kept for this package</i></p>"))
+
+ def show_log(self, *_):
+ row = self.current()
+ if not row or not row[4]:
+ return
+ path = row[0]
+
+ def got(log, err):
+ if err:
+ self.count.setText(f"build log: {err}")
+ return
+ viewer = LogViewer(path, log, self)
+ viewer.finished.connect(lambda *_: self.viewers.remove(viewer))
+ self.viewers.append(viewer)
+ viewer.show()
+ self.remote.call(got, "build-log", path)