diff options
Diffstat (limited to 'sb_builder/results.py')
| -rw-r--r-- | sb_builder/results.py | 195 |
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) |
