aboutsummaryrefslogtreecommitdiffstats
path: root/sb_builder/jobs.py
diff options
context:
space:
mode:
Diffstat (limited to 'sb_builder/jobs.py')
-rw-r--r--sb_builder/jobs.py196
1 files changed, 196 insertions, 0 deletions
diff --git a/sb_builder/jobs.py b/sb_builder/jobs.py
new file mode 100644
index 0000000..e0f906b
--- /dev/null
+++ b/sb_builder/jobs.py
@@ -0,0 +1,196 @@
+# sb-builder - Jobs 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.
+
+"""Jobs tab: job list, live log through job-follow, start and kill."""
+
+import codecs
+import re
+
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtGui import QFontDatabase, QTextCursor
+from PyQt6.QtWidgets import (QHBoxLayout, QListWidget, QListWidgetItem, QMenu,
+ QMessageBox, QPlainTextEdit, QPushButton, QSplitter,
+ QToolButton, QVBoxLayout, QWidget)
+
+from .fmt import describe
+
+ANSI = re.compile(rb"\x1b\[[0-9;?]*[A-Za-z]")
+SHADOW_PLAN = re.compile(r"^(un)?shadow: ", re.M)
+ACTIVE = ("starting", "running")
+BAD = ("failed", "killed", "lost")
+
+
+class JobsTab(QWidget):
+ refresh_requested = pyqtSignal()
+
+ def __init__(self, remote, parent=None):
+ super().__init__(parent)
+ self.remote = remote
+ self.jobs = {}
+ self.current = None
+ self.offset = 0
+ self.proc = None
+ self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
+
+ self.start_menu = QMenu(self)
+ start = QToolButton(text="Start", popupMode=QToolButton.ToolButtonPopupMode.InstantPopup)
+ start.setMenu(self.start_menu)
+ self.apply_btn = QPushButton("Apply shadowing")
+ self.apply_btn.clicked.connect(lambda: self.confirm_start(
+ "Apply the shadowing plan to the repo (sbshadow --yes)?", "sbshadow", "--yes"))
+ self.apply_btn.hide()
+ self.kill_btn = QPushButton("Kill")
+ self.kill_btn.clicked.connect(self.kill)
+ bar = QHBoxLayout()
+ for w in (start, self.apply_btn):
+ bar.addWidget(w)
+ bar.addStretch()
+ bar.addWidget(self.kill_btn)
+
+ self.list = QListWidget()
+ self.list.currentItemChanged.connect(self._on_select)
+ self.log = QPlainTextEdit(readOnly=True)
+ self.log.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
+ self.log.setMaximumBlockCount(50000) # ponytail: older lines drop off; the full log stays on the VM
+ split = QSplitter()
+ split.addWidget(self.list)
+ split.addWidget(self.log)
+ split.setSizes([300, 700])
+ layout = QVBoxLayout(self)
+ layout.addLayout(bar)
+ layout.addWidget(split)
+ self.set_overlays([])
+
+ def set_overlays(self, overlays):
+ m = self.start_menu
+ m.clear()
+ for o in overlays:
+ m.addAction(f"sbpull {o} (preview)", lambda o=o: self.start("sbpull", o, "--no"))
+ m.addSeparator()
+ m.addAction("sbshadow (preview)", lambda: self.start("sbshadow", "--no"))
+ m.addAction("sbshadow (apply)…", lambda: self.confirm_start(
+ "Run sbshadow --yes on the repo?", "sbshadow", "--yes"))
+
+ def set_jobs(self, jobs):
+ self.jobs = {j["id"]: j for j in jobs}
+ self.list.blockSignals(True)
+ self.list.clear()
+ for j in jobs:
+ item = QListWidgetItem(describe(j))
+ item.setData(Qt.ItemDataRole.UserRole, j["id"])
+ if j["status"] in BAD:
+ item.setForeground(Qt.GlobalColor.red)
+ self.list.addItem(item)
+ if j["id"] == self.current:
+ self.list.setCurrentItem(item)
+ self.list.blockSignals(False)
+ j = self.jobs.get(self.current)
+ if j and j["status"] in ACTIVE and self.proc is None:
+ self._follow() # stream dropped while the job kept running: resume at offset
+ self._update_buttons()
+
+ def select(self, jid):
+ for i in range(self.list.count()):
+ if self.list.item(i).data(Qt.ItemDataRole.UserRole) == jid:
+ self.list.setCurrentItem(self.list.item(i))
+ return
+ self._show(jid) # not listed yet: just started
+
+ def append_log(self, text):
+ cur = self.log.textCursor()
+ cur.movePosition(QTextCursor.MoveOperation.End)
+ # ponytail: a \r split across two chunks is printed as-is
+ for i, part in enumerate(text.replace("\r\n", "\n").split("\r")):
+ if i: # carriage return: the next text replaces the current line
+ cur.movePosition(QTextCursor.MoveOperation.StartOfBlock,
+ QTextCursor.MoveMode.KeepAnchor)
+ cur.removeSelectedText()
+ cur.insertText(part)
+ self.log.setTextCursor(cur)
+
+ def stop(self):
+ if self.proc:
+ p, self.proc = self.proc, None
+ p.kill()
+
+ def start(self, *args):
+ self.remote.call(self._started, "job-start", *args)
+
+ def confirm_start(self, question, *args):
+ if QMessageBox.question(self, "Confirm", question) == QMessageBox.StandardButton.Yes:
+ self.start(*args)
+
+ def kill(self):
+ if QMessageBox.question(self, "Kill job", f"Send SIGTERM to {self.current}?") \
+ == QMessageBox.StandardButton.Yes:
+ self.remote.call(lambda _d, err: err and QMessageBox.warning(self, "Kill failed", err),
+ "job-kill", self.current)
+
+ def _started(self, data, err):
+ if err:
+ QMessageBox.warning(self, "Start failed", err)
+ return
+ if "busy" in data:
+ box = QMessageBox(QMessageBox.Icon.Information, "Busy",
+ f"busy: {data['busy']}", parent=self)
+ show = box.addButton("Show job", QMessageBox.ButtonRole.AcceptRole)
+ box.addButton(QMessageBox.StandardButton.Close)
+ box.exec()
+ if box.clickedButton() is show and data["busy"]:
+ self.select(data["busy"])
+ return
+ self.select(data["id"])
+ self.refresh_requested.emit()
+
+ def _on_select(self, item, _prev):
+ jid = item.data(Qt.ItemDataRole.UserRole) if item else None
+ if jid != self.current:
+ self._show(jid)
+
+ def _show(self, jid):
+ self.stop()
+ self.current, self.offset = jid, 0
+ self.decoder.reset()
+ self.log.clear()
+ if jid:
+ self._follow()
+ self._update_buttons()
+
+ def _follow(self):
+ jid = self.current
+ p = self.proc = self.remote.follow(jid, self.offset)
+ p.readyReadStandardOutput.connect(lambda: self._chunk(p, jid))
+ p.finished.connect(lambda *_: self._ended(p))
+
+ def _chunk(self, p, jid):
+ data = bytes(p.readAllStandardOutput())
+ if jid != self.current:
+ return
+ self.offset += len(data)
+ self.append_log(self.decoder.decode(ANSI.sub(b"", data)))
+
+ def _ended(self, p):
+ if p is self.proc:
+ self.proc = None
+ p.deleteLater()
+ self._update_buttons()
+
+ def _update_buttons(self):
+ j = self.jobs.get(self.current)
+ self.kill_btn.setEnabled(bool(j) and j["status"] == "running")
+ self.apply_btn.setVisible(
+ bool(j) and j["status"] == "ok" and "--no" in j["args"]
+ and bool(SHADOW_PLAN.search(self.log.toPlainText())))