aboutsummaryrefslogtreecommitdiffstats
path: root/sb_builder/results.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-19 21:28:54 +0200
committerDanilo M. <danix@danix.xyz>2026-10-19 21:28:54 +0200
commit993950b7733ba9300915955ec6e51e1f21dad806 (patch)
treed8800305748d96581f3bea06eeb9b1a1da979a49 /sb_builder/results.py
parent5609a744ef5e89c4d57a5239199a6afe5497590e (diff)
downloadsb-builder-993950b7733ba9300915955ec6e51e1f21dad806.tar.gz
sb-builder-993950b7733ba9300915955ec6e51e1f21dad806.zip
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 <noreply@anthropic.com>
Diffstat (limited to 'sb_builder/results.py')
-rw-r--r--sb_builder/results.py195
1 files changed, 195 insertions, 0 deletions
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)