diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-19 19:12:05 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-19 19:12:05 +0200 |
| commit | 6fdb31d538269136415ce71cbf58de6017f95463 (patch) | |
| tree | a37e51bdaf4783bce22cf121061d71e69ea2a862 /sb_builder/jobs.py | |
| parent | fb64c88bb8672ee9bdb8172eec0252966d67404a (diff) | |
| download | sb-builder-6fdb31d538269136415ce71cbf58de6017f95463.tar.gz sb-builder-6fdb31d538269136415ce71cbf58de6017f95463.zip | |
Add the PyQt6 GUI: Dashboard, Jobs, VM control, notifications
All VM access goes through remote.py (ssh with a shared ControlMaster,
local virsh). The main window polls jobs, backs off while ssh is down,
checks the libvirt domain and offers Start or Resume, and sends
freedesktop notifications when a job ends or the VM comes up. The Jobs
tab streams a job's log with job-follow and resumes at the last offset
after a drop. Config is TOML, validated against built-in defaults.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'sb_builder/jobs.py')
| -rw-r--r-- | sb_builder/jobs.py | 196 |
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()))) |
