aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-20 19:21:24 +0200
committerDanilo M. <danix@danix.xyz>2026-10-20 19:21:24 +0200
commit32201a3d505b6b78da7ba15c2bb77093d52ec735 (patch)
tree46a82794db61cc101c77d856790ad0a290121c6f
parent81604635b5a15ce36d14c04931ace058552ee2f3 (diff)
downloadsb-builder-32201a3d505b6b78da7ba15c2bb77093d52ec735.tar.gz
sb-builder-32201a3d505b6b78da7ba15c2bb77093d52ec735.zip
Notifications: icon per outcome, body says how the job went
Title is the job (kind and args), body the outcome: succeeded in, failed with exit code N after, killed after, lost. Icons are freedesktop names: emblem-default, dialog-error, process-stop, dialog-warning, computer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--README.md3
-rw-r--r--sb_builder/fmt.py10
-rw-r--r--sb_builder/mainwindow.py17
-rw-r--r--tests/test_gui.py11
4 files changed, 28 insertions, 13 deletions
diff --git a/README.md b/README.md
index 0d42aa6..a686197 100644
--- a/README.md
+++ b/README.md
@@ -98,7 +98,8 @@ GUI or losing the connection does not stop them; reopening the job resumes
the log. Job state and logs live in `/var/lib/sb-builder/jobs/` on the VM.
When ssh fails and `vm.domain` is set, the status bar shows the libvirt state
-of the VM and offers to start or resume it. Desktop notifications report
+finished, failed, killed and lost jobs, and the VM coming up: the job as title,
+how it went (exit code, duration) as body, an icon per outcome.
finished, failed, killed and lost jobs, and the VM coming up.
## License
diff --git a/sb_builder/fmt.py b/sb_builder/fmt.py
index 55a2d0d..5c50628 100644
--- a/sb_builder/fmt.py
+++ b/sb_builder/fmt.py
@@ -54,6 +54,16 @@ def job_title(j):
return " ".join([j["kind"], *j["args"]])
+def outcome(j):
+ """How an ended job went, as a sentence for its notification."""
+ took = fmt_duration(j["end"] - j["start"]) if "end" in j and "start" in j else None
+ after = f" after {took}" if took else ""
+ return {"ok": f"Succeeded in {took}" if took else "Succeeded",
+ "failed": f"Failed with exit code {j.get('exit', '?')}{after}",
+ "killed": f"Killed{after}",
+ "lost": "Lost: its runner died (killed, crashed or the VM rebooted)"}[j["status"]]
+
+
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"]
diff --git a/sb_builder/mainwindow.py b/sb_builder/mainwindow.py
index f63a4ba..ec2b87a 100644
--- a/sb_builder/mainwindow.py
+++ b/sb_builder/mainwindow.py
@@ -27,7 +27,7 @@ from PyQt6.QtWidgets import (QHeaderView, QLabel, QMainWindow, QMessageBox, QPus
from . import VERSION, config
from .dashboard import Dashboard
-from .fmt import job_title
+from .fmt import job_title, outcome
from .hints_tab import HINT_JOBS, HintResultDialog, HintsTab
from .jobs import JobsTab, NextStep, is_preview
from .packages import PackagesTab
@@ -51,6 +51,9 @@ QTabBar::tab:selected { background: palette(highlight); color: palette(highlight
QTabBar::tab:hover:!selected { background: palette(midlight); }
"""
END_EVENT = {"ok": "finished", "failed": "failed", "killed": "killed", "lost": "lost"}
+# freedesktop icon names per notification event
+NOTIFY_ICON = {"finished": "emblem-default", "failed": "dialog-error", "killed": "process-stop",
+ "lost": "dialog-warning", "vm-up": "computer"}
MAX_RETRY_MS = 30000
@@ -66,9 +69,9 @@ class Notifier(QObject):
bus.connect(NOTIFY, NOTIFY_PATH, NOTIFY, "ActionInvoked", self._action)
self.ids = {} # notification id -> job id
- def send(self, title, body, jid=""):
+ def send(self, title, body, icon="", jid=""):
reply = self.iface.call(
- "Notify", "sb-builder", QDBusArgument(0, QMetaType.Type.UInt.value), "", title, body,
+ "Notify", "sb-builder", QDBusArgument(0, QMetaType.Type.UInt.value), icon, title, body,
QDBusArgument(["default", "Open"], QMetaType.Type.QStringList.value), {}, -1)
if reply.type() == QDBusMessage.MessageType.ReplyMessage:
self.ids[reply.arguments()[0]] = jid
@@ -231,7 +234,7 @@ class MainWindow(QMainWindow):
self.poll_slow()
if self.vm_woken:
self.vm_woken = False
- self.notify("vm-up", f"{self.cfg['vm']['domain']} is up")
+ self.notify("vm-up", "VM up", f"{self.cfg['vm']['domain']} answers over ssh")
def _failed(self, err):
if err == MISSING:
@@ -290,10 +293,10 @@ class MainWindow(QMainWindow):
# ── notifications ───────────────────────────────────────────────────────
- def notify(self, event, body, jid=""):
+ def notify(self, event, title, body, jid=""):
n = self.cfg["notify"]
if n["enabled"] and event in n["events"]:
- self.notifier.send(f"sb-builder: {event}", body, jid)
+ self.notifier.send(title, body, NOTIFY_ICON[event], jid)
def _notify_ends(self, jobs):
prev, self.seen = self.seen, {j["id"]: j["status"] for j in jobs}
@@ -302,7 +305,7 @@ class MainWindow(QMainWindow):
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"])
+ self.notify(event, job_title(j), outcome(j), j["id"])
if self.tabs.currentWidget() is not self.jobs_tab:
self._badge("ok" if j["status"] == "ok" else "bad")
if j["kind"] in ("slackrepo", "publish"): # build state changed
diff --git a/tests/test_gui.py b/tests/test_gui.py
index a25be37..beec1fe 100644
--- a/tests/test_gui.py
+++ b/tests/test_gui.py
@@ -126,8 +126,8 @@ class FakeNotifier(QObject):
super().__init__()
self.sent = []
- def send(self, title, body, jid=""):
- self.sent.append((title, body, jid))
+ def send(self, title, body, icon="", jid=""):
+ self.sent.append((title, body, icon, jid))
STATS = {"load": [0.5, 0.4, 0.3], "uptime": 3600.0, "cpu": 85.0,
@@ -344,10 +344,11 @@ class SmokeTest(unittest.TestCase):
def test_end_notification_once(self):
w, remote = self.window(jobs=({"jobs": [JOB]}, None))
w.poll_jobs()
- remote.answers["jobs"] = ({"jobs": [dict(JOB, status="failed", end=5, exit=1)]}, None)
+ remote.answers["jobs"] = ({"jobs": [dict(JOB, status="failed", end=125, exit=1)]}, None)
w.poll_jobs()
w.poll_jobs()
- self.assertEqual(w.notifier.sent, [("sb-builder: failed", "sbshadow --no", JOB["id"])])
+ self.assertEqual(w.notifier.sent, [("sbshadow --no", "Failed with exit code 1 after 2m 5s",
+ "dialog-error", JOB["id"])])
def test_down_offers_vm_start(self):
w, remote = self.window(jobs=(None, DOWN))
@@ -465,7 +466,7 @@ class SmokeTest(unittest.TestCase):
remote.answers["jobs"] = ({"jobs": [PREVIEW]}, None)
w.poll_jobs()
self.assertEqual(w.next_steps, {})
- self.assertEqual(w.notifier.sent[0][0], "sb-builder: finished")
+ self.assertEqual(w.notifier.sent[0][2], "emblem-default")
def test_shadow_preview_dialog_only_with_a_plan(self):
shadow = dict(PREVIEW, id="20260101-000000-sbshadow", kind="sbshadow", args=["--no"])