# 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, QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) from .dashboard import STATUS from .fmt import NA, fmt_time from .ui import SP, action, chip, enable, group_label 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) name = log.get("name", "build.log") self.setWindowTitle(f"{path}: {name}") 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(f"──── {name} ────\n") if log["truncated"]: parts.append(f"[... start of {name} 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, STATUS.get(k)) for k in KINDS} bar = QHBoxLayout() bar.addWidget(group_label("result")) for c in self.chips.values(): c.toggled.connect(self.refilter) bar.addWidget(c) bar.addStretch() self.count = QLabel() bar.addWidget(self.count) self.act_log = action(self, "Show build log", "text-x-generic", SP.SP_FileIcon, self.show_log) self.actions = [self.act_log] self.view_actions = [action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)] self.loaded = False 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) side = QWidget() col = QVBoxLayout(side) col.addWidget(self.detail) col.addStretch() split = QSplitter() split.addWidget(self.table) split.addWidget(side) split.setSizes([750, 350]) layout = QVBoxLayout(self) layout.addLayout(bar) layout.addWidget(split, 1) self.show_detail() def reload(self): self.loaded = True 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() enable(self.act_log, None if row and row[4] else "select a result with a log") 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)