From 6fdb31d538269136415ce71cbf58de6017f95463 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 19 Oct 2026 19:12:05 +0200 Subject: 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 --- sb_builder/__init__.py | 19 ++++ sb_builder/__main__.py | 41 ++++++++ sb_builder/config.py | 57 ++++++++++ sb_builder/dashboard.py | 81 ++++++++++++++ sb_builder/fmt.py | 61 +++++++++++ sb_builder/jobs.py | 196 ++++++++++++++++++++++++++++++++++ sb_builder/mainwindow.py | 268 +++++++++++++++++++++++++++++++++++++++++++++++ sb_builder/remote.py | 84 +++++++++++++++ 8 files changed, 807 insertions(+) create mode 100644 sb_builder/__init__.py create mode 100644 sb_builder/__main__.py create mode 100644 sb_builder/config.py create mode 100644 sb_builder/dashboard.py create mode 100644 sb_builder/fmt.py create mode 100644 sb_builder/jobs.py create mode 100644 sb_builder/mainwindow.py create mode 100644 sb_builder/remote.py (limited to 'sb_builder') 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. +# +# 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. +# +# 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. +# +# 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. +# +# 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. +# +# 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. +# +# 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. +# +# 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. +# +# 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 , 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) -- cgit v1.2.3