From 993950b7733ba9300915955ec6e51e1f21dad806 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 19 Oct 2026 21:28:54 +0200 Subject: Results tab with build logs; count only real packages sbb-remote 0.4.0: results lists slackrepo's last attempt per package (newest first, with built version and whether a log dir exists), and build-log returns the end of a package's build.log (512 KB) plus its resource.log, accepting only category/pkg paths. Result counts skip the legacy buildresults rows keyed by package file name: the 213 "bad" on the Dashboard matched no package. GUI: a Results tab (chips per result, table sorted by attempt time, detail pane, log viewer window that keeps the failure at the end), opened from the ribbon or by clicking a result in the Dashboard bar or legend, filtered to it. Co-Authored-By: Claude Opus 5.5 --- sb_builder/__init__.py | 2 +- sb_builder/dashboard.py | 22 +++++- sb_builder/mainwindow.py | 25 +++++- sb_builder/results.py | 195 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 6 deletions(-) create mode 100644 sb_builder/results.py (limited to 'sb_builder') 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'■ {html.escape(k)} {num(v)}' + f'■ ' + f'' + f'{html.escape(k)} {num(v)}' 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. +# +# 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'{result}' if color else html.escape(result) + self.detail.setText( + f"

{html.escape(path.split('/', 1)[1])}

{html.escape(path)}

" + f"

last attempt: {res}, {fmt_time(when)}
built version: " + f"{html.escape(version or NA)}

" + + ("" if has_log else "

no build log kept for this package

")) + + 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) -- cgit v1.2.3