aboutsummaryrefslogtreecommitdiffstats
path: root/sb_builder
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-19 19:12:05 +0200
committerDanilo M. <danix@danix.xyz>2026-10-19 19:12:05 +0200
commit6fdb31d538269136415ce71cbf58de6017f95463 (patch)
treea37e51bdaf4783bce22cf121061d71e69ea2a862 /sb_builder
parentfb64c88bb8672ee9bdb8172eec0252966d67404a (diff)
downloadsb-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')
-rw-r--r--sb_builder/__init__.py19
-rw-r--r--sb_builder/__main__.py41
-rw-r--r--sb_builder/config.py57
-rw-r--r--sb_builder/dashboard.py81
-rw-r--r--sb_builder/fmt.py61
-rw-r--r--sb_builder/jobs.py196
-rw-r--r--sb_builder/mainwindow.py268
-rw-r--r--sb_builder/remote.py84
8 files changed, 807 insertions, 0 deletions
diff --git a/sb_builder/__init__.py b/sb_builder/__init__.py
new file mode 100644
index 0000000..6428da7
--- /dev/null
+++ b/sb_builder/__init__.py
@@ -0,0 +1,19 @@
+# sb-builder - GUI package
+# 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.
+
+"""sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM."""
+
+VERSION = "0.1.0" # must match remote/sbb-remote VERSION
diff --git a/sb_builder/__main__.py b/sb_builder/__main__.py
new file mode 100644
index 0000000..5a78b64
--- /dev/null
+++ b/sb_builder/__main__.py
@@ -0,0 +1,41 @@
+# sb-builder - entry point
+# 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.
+
+"""Entry point: python3 -m sb_builder."""
+
+import sys
+
+from PyQt6.QtWidgets import QApplication, QMessageBox
+
+from . import config
+from .mainwindow import MainWindow
+from .remote import Remote
+
+
+def main():
+ app = QApplication(sys.argv)
+ app.setApplicationName("sb-builder")
+ cfg, err = config.load()
+ w = MainWindow(cfg, Remote(cfg))
+ w.resize(1100, 700)
+ w.show()
+ if err:
+ QMessageBox.warning(w, "Config error", f"{err}\nUsing defaults.")
+ return app.exec()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/sb_builder/config.py b/sb_builder/config.py
new file mode 100644
index 0000000..4549b66
--- /dev/null
+++ b/sb_builder/config.py
@@ -0,0 +1,57 @@
+# sb-builder - GUI configuration
+# 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.
+
+"""GUI configuration: built-in defaults overlaid by the user's TOML file."""
+
+import copy
+import os
+import tomllib
+
+DEFAULTS = {
+ "remote": {"target": "buildsystem", "ssh_options": []},
+ "refresh": {"dashboard": 30, "jobs": 5},
+ "notify": {"enabled": True, "events": ["finished", "failed", "killed", "lost", "vm-up"]},
+ "vm": {"libvirt_uri": "qemu:///system", "domain": "buildsystem", "autostart": False},
+}
+
+
+def path():
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
+ return os.path.join(base, "sb-builder", "config.toml")
+
+
+def load(p=None):
+ """Return (config, error). On any problem: the defaults plus the error text."""
+ p = p or path()
+ try:
+ with open(p, "rb") as f:
+ data = tomllib.load(f)
+ except FileNotFoundError:
+ return copy.deepcopy(DEFAULTS), None
+ except (OSError, tomllib.TOMLDecodeError) as e:
+ return copy.deepcopy(DEFAULTS), f"{p}: {e}"
+ cfg = copy.deepcopy(DEFAULTS)
+ for sec, keys in data.items():
+ if sec not in DEFAULTS or not isinstance(keys, dict):
+ return copy.deepcopy(DEFAULTS), f"{p}: unknown section [{sec}]"
+ for k, v in keys.items():
+ if k not in DEFAULTS[sec]:
+ return copy.deepcopy(DEFAULTS), f"{p}: unknown key {sec}.{k}"
+ want = type(DEFAULTS[sec][k])
+ if type(v) is not want:
+ return copy.deepcopy(DEFAULTS), f"{p}: {sec}.{k}: expected {want.__name__}"
+ cfg[sec][k] = v
+ return cfg, None
diff --git a/sb_builder/dashboard.py b/sb_builder/dashboard.py
new file mode 100644
index 0000000..aac1b9f
--- /dev/null
+++ b/sb_builder/dashboard.py
@@ -0,0 +1,81 @@
+# sb-builder - Dashboard 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.
+
+"""Dashboard tab: tiles filled from sbb-remote stats and jobs."""
+
+import time
+
+from PyQt6.QtWidgets import (QGridLayout, QGroupBox, QHBoxLayout, QLabel,
+ QPushButton, QVBoxLayout, QWidget)
+
+from .fmt import NA, describe, fmt_age, fmt_bytes, fmt_duration
+
+TILES = ("VM", "Repo", "slackrepo", "Sync", "Jobs", "Orphans")
+
+
+class Dashboard(QWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.labels = {}
+ grid = QGridLayout()
+ for i, name in enumerate(TILES):
+ box = QGroupBox(name)
+ label = QLabel(NA)
+ label.setWordWrap(True)
+ QVBoxLayout(box).addWidget(label)
+ grid.addWidget(box, i // 3, i % 3)
+ self.labels[name] = label
+ self.labels["Orphans"].setText("coming in phase 5")
+ self.stale = QLabel()
+ self.refresh_btn = QPushButton("Refresh")
+ top = QHBoxLayout()
+ top.addWidget(self.stale)
+ top.addStretch()
+ top.addWidget(self.refresh_btn)
+ layout = QVBoxLayout(self)
+ layout.addLayout(top)
+ layout.addLayout(grid)
+ layout.addStretch()
+
+ def show_stats(self, s):
+ L = self.labels
+ load, disk = s.get("load"), s.get("disk_free") or {}
+ L["VM"].setText(
+ f"load: {NA if load is None else ' '.join(f'{x:.2f}' for x in load)}\n"
+ f"uptime: {fmt_duration(s.get('uptime'))}\n"
+ f"free: repo {fmt_bytes(disk.get('repo'))}, state {fmt_bytes(disk.get('state'))}")
+ p = s.get("packages")
+ L["Repo"].setText(NA if p is None else "\n".join(f"{k}: {v}" for k, v in p.items()))
+ sr = s.get("slackrepo")
+ if sr is None:
+ L["slackrepo"].setText(NA)
+ else:
+ never = NA if p is None else p["total"] - sr["built"]
+ L["slackrepo"].setText("\n".join(
+ [f"built: {sr['built']}", f"never built: {never}",
+ *(f"{k}: {v}" for k, v in sorted(sr["results"].items()))]))
+ sync = s.get("sync") or {}
+ L["Sync"].setText("\n".join(f"{o}: {fmt_age(t)}" for o, t in sync.items()) or NA)
+
+ def show_jobs(self, jobs):
+ self.labels["Jobs"].setText("\n".join(describe(j) for j in jobs[:10]) or "no jobs yet")
+
+ def set_stale(self, since):
+ """Grey the tiles when the VM stopped answering; None clears it."""
+ for label in self.labels.values():
+ label.setEnabled(since is None)
+ self.stale.setText("" if since is None else
+ f"stale since {time.strftime('%H:%M', time.localtime(since))}")
diff --git a/sb_builder/fmt.py b/sb_builder/fmt.py
new file mode 100644
index 0000000..d43da2e
--- /dev/null
+++ b/sb_builder/fmt.py
@@ -0,0 +1,61 @@
+# sb-builder - formatting helpers
+# 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.
+
+"""Text formatting shared by the tabs."""
+
+import time
+
+NA = "n/a"
+ICON = {"starting": "…", "running": "▶", "ok": "✔", "failed": "✘",
+ "killed": "■", "lost": "?"}
+
+
+def fmt_bytes(n):
+ if n is None:
+ return NA
+ for unit in ("B", "KiB", "MiB", "GiB"):
+ if n < 1024:
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
+ n /= 1024
+ return f"{n:.1f} TiB"
+
+
+def fmt_duration(secs):
+ if secs is None:
+ return NA
+ d, r = divmod(int(secs), 86400)
+ h, r = divmod(r, 3600)
+ m, s = divmod(r, 60)
+ return f"{d}d {h}h" if d else f"{h}h {m}m" if h else f"{m}m {s}s"
+
+
+def fmt_age(ts):
+ return NA if ts is None else fmt_duration(time.time() - ts) + " ago"
+
+
+def fmt_time(ts):
+ return time.strftime("%m-%d %H:%M", time.localtime(ts))
+
+
+def job_title(j):
+ return " ".join([j["kind"], *j["args"]])
+
+
+def describe(j):
+ """One-line job summary: icon, kind and args, start time, duration."""
+ dur = None if "start" not in j else j.get("end", time.time()) - j["start"]
+ return (f"{ICON.get(j['status'], '?')} {job_title(j)} "
+ f"{fmt_time(j.get('start', j['created']))} {fmt_duration(dur)}")
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())))
diff --git a/sb_builder/mainwindow.py b/sb_builder/mainwindow.py
new file mode 100644
index 0000000..734811b
--- /dev/null
+++ b/sb_builder/mainwindow.py
@@ -0,0 +1,268 @@
+# sb-builder - main window
+# 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.
+
+"""Main window: tabs, status bar, connection polling, VM control, notifications."""
+
+import time
+
+from PyQt6.QtCore import QMetaType, QObject, QTimer, pyqtSignal, pyqtSlot
+from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage
+from PyQt6.QtWidgets import QLabel, QMainWindow, QMessageBox, QPushButton, QTabBar, QTabWidget
+
+from . import VERSION, config
+from .dashboard import Dashboard
+from .fmt import job_title
+from .jobs import JobsTab
+from .remote import DOWN, MISSING
+
+NOTIFY = "org.freedesktop.Notifications"
+NOTIFY_PATH = "/org/freedesktop/Notifications"
+END_EVENT = {"ok": "finished", "failed": "failed", "killed": "killed", "lost": "lost"}
+MAX_RETRY_MS = 30000
+
+
+class Notifier(QObject):
+ """freedesktop notifications over D-Bus; clicked(job id) on the default action."""
+
+ clicked = pyqtSignal(str)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ bus = QDBusConnection.sessionBus()
+ self.iface = QDBusInterface(NOTIFY, NOTIFY_PATH, NOTIFY, bus)
+ bus.connect(NOTIFY, NOTIFY_PATH, NOTIFY, "ActionInvoked", self._action)
+ self.ids = {} # notification id -> job id
+
+ def send(self, title, body, jid=""):
+ reply = self.iface.call(
+ "Notify", "sb-builder", QDBusArgument(0, QMetaType.Type.UInt.value), "", title, body,
+ QDBusArgument(["default", "Open"], QMetaType.Type.QStringList.value), {}, -1)
+ if reply.type() == QDBusMessage.MessageType.ReplyMessage:
+ self.ids[reply.arguments()[0]] = jid
+
+ @pyqtSlot("uint", "QString")
+ def _action(self, nid, _key):
+ if nid in self.ids:
+ self.clicked.emit(self.ids.pop(nid))
+
+
+class MainWindow(QMainWindow):
+ def __init__(self, cfg, remote, notifier=None):
+ super().__init__()
+ self.cfg, self.remote = cfg, remote
+ self.notifier = notifier or Notifier(self)
+ self.notifier.clicked.connect(self.open_job)
+ self.jobs_tab = None
+ self.seen = None # job id -> status at the last poll; None before the first
+ self.online = None # None until the first answer
+ self.overlays = []
+ self.vm_action = None # "start" or "resume" offered on the VM button
+ self.vm_woken = False # we started the VM: notify once ssh answers
+ self.running_id = None
+ self.setWindowTitle("sb-builder")
+
+ self.tabs = QTabWidget(tabsClosable=True)
+ self.tabs.tabCloseRequested.connect(self._close_tab)
+ self.tabs.currentChanged.connect(lambda _i: self.poll_stats())
+ self.dashboard = Dashboard()
+ self.dashboard.refresh_btn.clicked.connect(self.poll_stats)
+ self.tabs.addTab(self.dashboard, "Dashboard")
+ self.tabs.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None)
+ self.setCentralWidget(self.tabs)
+
+ file_menu = self.menuBar().addMenu("&File")
+ file_menu.addAction("&Reload config", self.reload_config)
+ file_menu.addAction("&Quit", self.close).setShortcut("Ctrl+Q")
+ self.menuBar().addMenu("&Open").addAction("&Jobs", self.open_jobs)
+
+ self.conn = QLabel("● connecting")
+ self.vm_btn = QPushButton()
+ self.vm_btn.clicked.connect(lambda: self._vm_do(self.vm_action))
+ self.vm_btn.hide()
+ self.job_btn = QPushButton(flat=True)
+ self.job_btn.clicked.connect(lambda: self.open_job(self.running_id))
+ self.job_btn.hide()
+ for w in (self.job_btn, self.vm_btn, self.conn):
+ self.statusBar().addPermanentWidget(w)
+
+ self.jobs_timer = QTimer(self, timeout=self.poll_jobs)
+ self.stats_timer = QTimer(self, timeout=self.poll_stats)
+ self._start_timers()
+ QTimer.singleShot(0, self.poll_jobs)
+
+ # ── polling ─────────────────────────────────────────────────────────────
+
+ def _start_timers(self):
+ self.jobs_timer.start(self.cfg["refresh"]["jobs"] * 1000)
+ self.stats_timer.start(self.cfg["refresh"]["dashboard"] * 1000)
+
+ def poll_jobs(self):
+ self.remote.call(self._got_jobs, "jobs")
+
+ def poll_stats(self):
+ if self.online and self.tabs.currentWidget() is self.dashboard:
+ self.remote.call(self._got_stats, "stats")
+
+ def _got_jobs(self, data, err):
+ if err:
+ return self._failed(err)
+ self._up()
+ jobs = data["jobs"]
+ self._notify_ends(jobs)
+ self.dashboard.show_jobs(jobs)
+ if self.jobs_tab:
+ self.jobs_tab.set_jobs(jobs)
+ run = next((j for j in jobs if j["status"] == "running"), None)
+ self.running_id = run and run["id"]
+ self.job_btn.setVisible(run is not None)
+ if run:
+ self.job_btn.setText(f"▶ {job_title(run)}")
+
+ def _got_stats(self, data, err):
+ if err:
+ return self._failed(err)
+ self.dashboard.show_stats(data)
+
+ def _got_version(self, data, err):
+ if err:
+ return self._failed(err)
+ self.overlays = data.get("overlays", [])
+ if self.jobs_tab:
+ self.jobs_tab.set_overlays(self.overlays)
+ if data["version"] != VERSION:
+ self.conn.setText(f"● helper {data['version']}, GUI {VERSION}: run ./install.sh --remote")
+
+ def _up(self):
+ if self.online:
+ return
+ self.online = True
+ self.conn.setText("● connected")
+ self.vm_btn.hide()
+ self.dashboard.set_stale(None)
+ self.jobs_timer.setInterval(self.cfg["refresh"]["jobs"] * 1000)
+ self.remote.call(self._got_version, "version")
+ self.poll_stats()
+ if self.vm_woken:
+ self.vm_woken = False
+ self.notify("vm-up", f"{self.cfg['vm']['domain']} is up")
+
+ def _failed(self, err):
+ if err == MISSING:
+ self.conn.setText("● sbb-remote missing: run ./install.sh --remote")
+ return
+ if err != DOWN:
+ self.statusBar().showMessage(err, 10000)
+ return
+ if self.online is not False:
+ self.online = False
+ self.dashboard.set_stale(time.time())
+ base = self.cfg["refresh"]["jobs"] * 1000
+ self.jobs_timer.setInterval(min(self.jobs_timer.interval() * 2, max(MAX_RETRY_MS, base)))
+ self._check_vm()
+
+ # ── VM control ──────────────────────────────────────────────────────────
+
+ def _check_vm(self):
+ if not self.cfg["vm"]["domain"]:
+ self.conn.setText("● down")
+ return
+ self.remote.virsh(self._got_domstate, "domstate", self.cfg["vm"]["domain"])
+
+ def _got_domstate(self, rc, state):
+ if self.online:
+ return # ssh came back while virsh ran
+ self.vm_btn.hide()
+ if rc != 0:
+ self.conn.setText("● down")
+ elif state == "shut off":
+ self.conn.setText("● VM off")
+ if self.cfg["vm"]["autostart"]:
+ self._vm_do("start")
+ else:
+ self._offer("Start VM", "start")
+ elif state == "paused":
+ self.conn.setText("● VM paused")
+ self._offer("Resume VM", "resume")
+ elif state == "running":
+ self.conn.setText("● VM running, ssh unreachable")
+ else:
+ self.conn.setText(f"● VM {state}")
+
+ def _offer(self, label, action):
+ self.vm_action = action
+ self.vm_btn.setText(label)
+ self.vm_btn.show()
+
+ def _vm_do(self, action):
+ self.vm_btn.hide()
+ self.vm_woken = True
+ self.conn.setText(f"● VM {action} requested")
+ self.remote.virsh(
+ lambda rc, out: rc and self.statusBar().showMessage(f"virsh {action}: {out}", 10000),
+ action, self.cfg["vm"]["domain"])
+
+ # ── notifications ───────────────────────────────────────────────────────
+
+ def notify(self, event, body, jid=""):
+ n = self.cfg["notify"]
+ if n["enabled"] and event in n["events"]:
+ self.notifier.send(f"sb-builder: {event}", body, jid)
+
+ def _notify_ends(self, jobs):
+ prev, self.seen = self.seen, {j["id"]: j["status"] for j in jobs}
+ if prev is None:
+ return # first poll: old jobs are history, not news
+ for j in jobs:
+ event = END_EVENT.get(j["status"])
+ if event and prev.get(j["id"], "running") != j["status"]:
+ self.notify(event, job_title(j), j["id"])
+
+ # ── tabs ────────────────────────────────────────────────────────────────
+
+ def open_jobs(self):
+ if self.jobs_tab is None:
+ self.jobs_tab = JobsTab(self.remote)
+ self.jobs_tab.refresh_requested.connect(self.poll_jobs)
+ self.jobs_tab.set_overlays(self.overlays)
+ self.tabs.addTab(self.jobs_tab, "Jobs")
+ self.poll_jobs()
+ self.tabs.setCurrentWidget(self.jobs_tab)
+ return self.jobs_tab
+
+ def open_job(self, jid):
+ self.show()
+ self.raise_()
+ self.activateWindow()
+ if jid:
+ self.open_jobs().select(jid)
+
+ def _close_tab(self, i):
+ w = self.tabs.widget(i)
+ if w is self.dashboard:
+ return
+ self.tabs.removeTab(i)
+ if w is self.jobs_tab:
+ self.jobs_tab = None
+ w.stop()
+ w.deleteLater()
+
+ def reload_config(self):
+ cfg, err = config.load()
+ if err:
+ QMessageBox.warning(self, "Config error", f"{err}\nUsing defaults.")
+ self.cfg.clear()
+ self.cfg.update(cfg) # Remote holds the same dict
+ self._start_timers()
diff --git a/sb_builder/remote.py b/sb_builder/remote.py
new file mode 100644
index 0000000..5340f6c
--- /dev/null
+++ b/sb_builder/remote.py
@@ -0,0 +1,84 @@
+# sb-builder - ssh and virsh transport
+# 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.
+
+"""The only code that talks to the VM: ssh to sbb-remote, and local virsh."""
+
+import json
+import os
+import shlex
+
+from PyQt6.QtCore import QObject, QProcess, QStandardPaths
+
+DOWN = "ssh: connection failed"
+MISSING = "sbb-remote is not installed on the VM"
+
+
+def parse(rc, out, err):
+ """Map one sbb-remote call to (data, error)."""
+ if rc == 255:
+ return None, DOWN
+ if rc == 127:
+ return None, MISSING
+ try:
+ data = json.loads(out)
+ except ValueError:
+ return None, err.decode(errors="replace").strip() or f"exit {rc}"
+ if rc != 0 or "error" in data:
+ return None, data.get("error", f"exit {rc}")
+ return data, None
+
+
+class Remote(QObject):
+ def __init__(self, cfg, parent=None):
+ super().__init__(parent)
+ self.cfg = cfg # shared dict, updated in place on config reload
+ run = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.RuntimeLocation)
+ self.control = os.path.join(run or "/tmp", "sbb-%C")
+
+ def ssh_args(self, *args):
+ r = self.cfg["remote"]
+ # ssh joins its args into a remote shell command: quote every one
+ return ["-o", "ControlMaster=auto", "-o", f"ControlPath={self.control}",
+ "-o", "ControlPersist=10m", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
+ *r["ssh_options"], r["target"], "sbb-remote", *map(shlex.quote, args)]
+
+ def call(self, cb, *args):
+ """Run sbb-remote <args>, then cb(data, error)."""
+ self._run("ssh", self.ssh_args(*args), lambda rc, out, err: cb(*parse(rc, out, err)))
+
+ def virsh(self, cb, *args):
+ """Run local virsh against the configured libvirt URI, then cb(rc, text)."""
+ argv = ["-c", self.cfg["vm"]["libvirt_uri"], *args]
+ self._run("virsh", argv,
+ lambda rc, out, err: cb(rc, (out or err).decode(errors="replace").strip()))
+
+ def follow(self, jid, offset):
+ """Start job-follow; the caller wires readyReadStandardOutput and finished."""
+ p = QProcess(self)
+ p.start("ssh", self.ssh_args("job-follow", jid, str(offset)))
+ return p
+
+ def _run(self, prog, argv, cb):
+ p = QProcess(self)
+
+ def done(rc, _status=None):
+ cb(rc, bytes(p.readAllStandardOutput()), bytes(p.readAllStandardError()))
+ p.deleteLater()
+
+ p.finished.connect(done)
+ p.errorOccurred.connect(
+ lambda e: e == QProcess.ProcessError.FailedToStart and done(-1))
+ p.start(prog, argv)