Notify a phone that has this installation installed
A `webpush` alert channel, and the PWA it needs to arrive. The payload is encrypted to the subscription (RFC 8291) and the request signed with this installation's own keypair (RFC 8292), both over `http-ece` — `pywebpush` does the same in one call but brings `requests` and `aiohttp` with it, two HTTP stacks beside httpx on a machine that may be a Raspberry Pi. The manifest and the worker are hand-written rather than `vite-plugin-pwa`: there is nothing worth precaching when the page carrying the credential is `no-store`, so the worker handles `push` and `notificationclick` and nothing else. `registration.scope` is the app's root in both places it runs, which is why the payload carries no URL. A run finishing in error is the first event worth waking someone for; `ok` and `cancelled` describe to nothing, so a nightly batch that works stays quiet. The events were already on the bus — only the filter changed. `WEBPUSH_FILE` is a derived path, so the keypair lands on the data volume with the alerts beside it. Off it, a rebuild would silently stop every phone being notified: the key they subscribed against would be gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014EbeFPm6WNC3YD9vrqqT3a
This commit is contained in:
@@ -36,6 +36,7 @@ ALERTING_EVENTS = {
|
||||
"engine_degraded",
|
||||
"cascade_dropped",
|
||||
"queue_unavailable",
|
||||
"run_finished",
|
||||
}
|
||||
|
||||
# The same fault repeating is the same alert.
|
||||
@@ -52,10 +53,11 @@ class Channel(BaseModel):
|
||||
"""Somewhere to send an alert."""
|
||||
|
||||
name: str
|
||||
kind: Literal["ntfy", "smtp", "webhook", "dashboard"]
|
||||
kind: Literal["ntfy", "smtp", "webhook", "dashboard", "webpush"]
|
||||
enabled: bool = True
|
||||
# ntfy: server + topic + optional token. smtp: to. webhook: url.
|
||||
# dashboard: message — the record a notification widget reads.
|
||||
# webpush: nothing — it goes to whichever browsers subscribed here.
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -144,6 +146,15 @@ def describe(event: dict[str, Any]) -> Alert | None:
|
||||
)
|
||||
if kind == "queue_unavailable":
|
||||
return Alert(title="The work queue is unreachable", body=error or "")
|
||||
if kind == "run_finished":
|
||||
# Every run ends; only the ones that ended badly are worth a phone.
|
||||
if event.get("status") != "error":
|
||||
return None
|
||||
return Alert(
|
||||
title=f"A run of '{flow}' failed",
|
||||
body=f"Run {event.get('run')} finished with errors.",
|
||||
flow=flow,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -304,6 +315,8 @@ class AlertManager:
|
||||
await self._send_webhook(config, alert)
|
||||
elif channel.kind == "dashboard":
|
||||
await self._send_dashboard(config, alert)
|
||||
elif channel.kind == "webpush":
|
||||
await self._send_webpush(alert)
|
||||
else:
|
||||
await self._send_email(config, alert)
|
||||
except Exception as exc:
|
||||
@@ -344,6 +357,18 @@ class AlertManager:
|
||||
# Blocking: it reads the store to find the declared message.
|
||||
await asyncio.to_thread(self.publish, message, alert.model_dump())
|
||||
|
||||
async def _send_webpush(self, alert: Alert) -> None:
|
||||
"""Wake every browser that subscribed to this installation.
|
||||
|
||||
No settings of its own: a browser subscribes by pressing a button on
|
||||
the alerts screen, and this channel goes to whichever ones did. Saying
|
||||
so when none have is the point of the test button.
|
||||
"""
|
||||
from fluksio.flow import webpush
|
||||
|
||||
if await webpush.send_to_all(alert.model_dump()) == 0:
|
||||
raise ValueError("No browsers are subscribed")
|
||||
|
||||
async def _send_webhook(self, config: dict[str, Any], alert: Alert) -> None:
|
||||
url = config.get("url")
|
||||
if not url:
|
||||
|
||||
Reference in New Issue
Block a user