Add the Sandbar mobile companion PWA

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 17:02:39 -06:00
commit ad165ef6a5
18 files changed
+1143

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules/
.env
+76
View File
@@ -0,0 +1,76 @@
# Sandbar mobile
An installable companion for [Sandbar](https://git.veillax.com/Veillax/sandbar): push notifications, on-call actions
(acknowledge, snooze, escalate, resolve), topics, monitors and Hammerhead tunnel status.
It is a PWA with plain ES modules and no build step. See [docs/design.md](docs/design.md) for why it is not a native app.
## What you need
- A Sandbar server with the mobile endpoints. They are on the `feature/mobile-push` branch of Sandbar and not released yet.
- HTTPS for the server (or `localhost` while developing). Browsers only allow service workers and push on secure origins.
## Run it against a local Sandbar
Start Sandbar and let it serve this folder:
```
cd ../sandbar
SANDBAR_ADMIN_EMAIL=dev@example.com SANDBAR_ADMIN_PASSWORD=choose-a-password \
SANDBAR_APP_DIR=../sandbar-mobile/public SANDBAR_PUSH_DEV=1 \
go run ./cmd/sandbar -listen 127.0.0.1:8090 -url http://127.0.0.1:8090
```
Open `http://127.0.0.1:8090/app/`, sign in, then Settings, Turn on notifications.
`SANDBAR_PUSH_DEV=1` is only for development: it allows `http` push endpoints and private addresses.
To work on the app files with their own server, run `npm run serve` (port 5173) and start Sandbar with
`SANDBAR_APP_ORIGIN=http://localhost:5173`. Enter the Sandbar address on the sign-in screen.
## Install it for real
1. Put Sandbar behind HTTPS and set `SANDBAR_URL` and `SANDBAR_APP_DIR` (or host `public/` anywhere and set `SANDBAR_APP_ORIGIN`).
Set `SANDBAR_VAPID_SUBJECT=mailto:you@example.com` so push services can reach you. The VAPID key is created on first use
and stored in the database; moving to a new database means people must turn notifications on again.
2. On Android, open `/app/` in Chrome, sign in, turn on notifications, then Install app.
3. On iPhone (iOS 16.4 or later), open `/app/` in Safari, Share, Add to Home Screen, open it from the Home Screen, then turn on notifications.
To see Hammerhead in the Status tab, set `SANDBAR_HAMMERHEAD_URL`, `SANDBAR_HAMMERHEAD_EMAIL` and
`SANDBAR_HAMMERHEAD_PASSWORD` on Sandbar, using an account without two-factor sign-in.
## What is in it
| Tab | What it does |
| --- | --- |
| Alerts | Open and recent alerts. Acknowledge, Snooze 30 min, Escalate now, Resolve. Updates live and by push. |
| Topics | Follow topics (they become push subscriptions), read the last 7 days, send a message. |
| Monitors | Every monitor with up, down or pending, last check and 30 day uptime. Read-only. |
| Status | Who is on call, and Hammerhead edges (online or offline) when linked. |
| Settings | Turn push on or off, send a test, list and remove devices, sign out (deletes this phone's API key). |
Notifications for a firing alert carry Acknowledge and Snooze buttons that work without opening the app (Android and desktop).
## Layout
```
public/ the app, served as is
index.html app.css app.js the page
api.js Sandbar API client (also used by tests)
store.js sign-in and topics in IndexedDB, shared with the worker
sw.js push, notification buttons, offline shell
scripts/ make-icons.mjs (regenerates the PNG icons), serve.mjs (dev server)
test/ node --test: API client and service worker
docs/design.md why a PWA
```
```
npm test
```
## Not done
- Push delivery was verified with Sandbar's Go tests (the payload is decrypted and the VAPID signature checked), but not
through a real Google, Apple or Mozilla push service. The in-app browser used for development refuses service workers,
so the service worker is tested in a simulated scope, not in a browser.
- No offline data beyond the app shell, no incident screens, no editing of monitors or schedules.
- Hammerhead sign-in with two-factor accounts is not supported.
+69
View File
@@ -0,0 +1,69 @@
# Design note: PWA with Web Push
## Decision
The companion is an installable web app (PWA) that gets notifications through Web Push. It is plain ES modules with
no build step, served by Sandbar itself at `/app/` or from any static host.
## What it has to do
1. Sign in to a Sandbar server.
2. Follow topics and get push notifications while the app is closed.
3. Acknowledge, snooze, escalate and resolve on-call alerts, ideally from the notification.
4. Show monitors and Hammerhead tunnel status, read-only.
## Options
| | PWA + Web Push | Flutter or React Native | Native (Kotlin, Swift) |
| --- | --- | --- | --- |
| Android push | Yes, through the browser's push service | FCM, needs a Firebase project | FCM, same |
| iPhone push | Yes since iOS 16.4, once added to the Home Screen | APNs, needs a paid developer account | Same |
| Server needs | Standard Web Push (VAPID), no vendor account | A Firebase or APNs sender, or a relay | Same |
| Self-hosting fit | Good: nothing leaves your server except the encrypted push | Poor: FCM project keys, or a third-party relay | Poor |
| Distribution | A URL, "Add to Home Screen" | Play Store, App Store, signing, review | Same |
| Notification buttons | Yes (Acknowledge, Snooze) on Android and desktop; limited on iOS | Yes | Yes |
| Background connection | Push only, no always-on socket | Same | Same |
| Maintenance | One small code base, one language | A framework and two store pipelines | Two code bases |
## Why a PWA
- **Sandbar is self-hosted and one binary.** Native push needs either a Firebase project baked into a store app or a relay
that sees every notification. Web Push needs neither: each browser vendor runs the push service, Sandbar signs with its own
VAPID key, and the payload is encrypted end to end (RFC 8291), so the push service sees only ciphertext.
- **Nothing to publish.** An admin points people at `https://sandbar.example.com/app/`. A store app would have to be a
public, multi-tenant client with a server address field, built and signed by someone.
- **The sharks stack is web already.** The Sandbar and Hammerhead UIs use the same tokens, so this reuses them.
- **The work is mostly API calls.** Sandbar already has everything except push, so a thin client is enough.
## What we give up
- **iPhone** only gets push after Add to Home Screen, and notification action buttons are not shown there. Tapping the
notification opens the app, where the buttons are one tap away.
- **Delivery is best effort**, through Google, Apple or Mozilla. For a pager that is not enough on its own. Sandbar's
escalation policies still email and repeat, and the existing ntfy-app path keeps working for people who want the
ntfy app's persistent connection and Do Not Disturb override. The PWA is a companion, not a replacement.
- **No critical-alert bypass** of Do Not Disturb. A native app could ask for that entitlement.
- **Background fetch is limited**, so when the app is closed it only knows what pushes told it. It refreshes on open.
If those limits ever outweigh the benefit of skipping the stores, the same API (`docs/mobile-api.md` in Sandbar) serves a
native client unchanged: the server side is one table of subscriptions and one push sender. A Flutter app would add FCM
as a second sender.
## How it fits together
```
phone (PWA) --login--> Sandbar /api/v1/mobile/login -> API key (bearer token)
phone --subscribe-> Sandbar /api/v1/push/subscriptions {endpoint, keys, topics}
Sandbar Publish() --> push service (Google/Apple/Mozilla) --> service worker --> notification
notification button --> service worker --> POST /api/v1/alerts/{id}/ack (bearer token from IndexedDB)
```
- **Sign-in** returns an API key named after the device, not a cookie. It works from any origin, and signing out of one
phone deletes only that key.
- **Service worker** is a classic script (Firefox does not run module workers). It cannot read `localStorage`, so the sign-in
is kept in IndexedDB.
- **Open app**: while visible it streams each followed topic over `/{topic}/json` and suppresses the system notification.
- **Subscription rotation** (`pushsubscriptionchange`) re-registers itself, and a 404 or 410 from the push service
removes the server-side row.
- **Hammerhead** status goes through Sandbar (`/api/v1/hammerhead/edges`) because Hammerhead lists edges only for
signed-in people, not API keys. Sandbar holds that sign-in so the phone never gets Hammerhead credentials, and edge tokens are stripped.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "sandbar-mobile",
"version": "0.1.0",
"description": "Installable companion app for Sandbar: push notifications, on-call actions, monitors and tunnel status.",
"private": true,
"type": "module",
"scripts": {
"test": "node --test test/*.test.mjs",
"icons": "node scripts/make-icons.mjs",
"serve": "node scripts/serve.mjs"
},
"license": "MIT"
}
+119
View File
@@ -0,0 +1,119 @@
// Sandbar API client. Pure functions over fetch, so it runs in the page, the service worker and tests.
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
export function normalizeServer(input) {
let s = String(input || '').trim();
if (!s) throw new Error('Enter the address of your Sandbar server.');
if (!/^https?:\/\//i.test(s)) s = (/^(localhost|127\.|\[::1\])/.test(s) ? 'http://' : 'https://') + s;
const u = new URL(s);
return u.origin;
}
export class Client {
constructor(server, token, fetchImpl) {
this.server = server;
this.token = token;
this.fetch = fetchImpl || ((...a) => fetch(...a));
}
async req(method, path, body) {
const headers = {};
if (this.token) headers.Authorization = 'Bearer ' + this.token;
if (body !== undefined) headers['Content-Type'] = 'application/json';
let res;
try {
res = await this.fetch(this.server + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
} catch {
throw new ApiError(0, 'Could not reach ' + this.server + '. Check the address and your connection.');
}
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
if (!res.ok) throw new ApiError(res.status, (data && data.error) || 'The server answered ' + res.status + '.');
return data;
}
get(p) { return this.req('GET', p); }
post(p, b) { return this.req('POST', p, b === undefined ? {} : b); }
del(p) { return this.req('DELETE', p); }
static async login(server, email, password, device, fetchImpl) {
const c = new Client(server, '', fetchImpl);
const r = await c.post('/api/v1/mobile/login', { email, password, device });
return { token: r.token, keyId: r.keyId, user: r.user };
}
alerts(status) { return this.get('/api/v1/alerts?limit=100' + (status ? '&status=' + status : '')); }
ack(id) { return this.post('/api/v1/alerts/' + id + '/ack'); }
snooze(id, minutes) { return this.post('/api/v1/alerts/' + id + '/snooze', { minutes }); }
escalate(id) { return this.post('/api/v1/alerts/' + id + '/escalate'); }
resolve(id) { return this.post('/api/v1/alerts/' + id + '/resolve'); }
alert(id) { return this.get('/api/v1/alerts/' + id); }
onCall() { return this.get('/api/v1/oncall'); }
monitors() { return this.get('/api/v1/monitors'); }
monitorStates() { return this.get('/api/v1/monitor-states'); }
edges() { return this.get('/api/v1/hammerhead/edges'); }
messages(topic, since) { return this.get('/api/v1/topics/' + encodeURIComponent(topic) + '/messages?since=' + (since || 0)); }
publish(topic, message, title, priority) {
const headers = { 'Content-Type': 'text/plain' };
if (this.token) headers.Authorization = 'Bearer ' + this.token;
if (title) headers.Title = title;
if (priority) headers.Priority = String(priority);
return this.fetch(this.server + '/' + encodeURIComponent(topic), { method: 'POST', headers, body: message });
}
pushKey() { return this.get('/api/v1/push/key'); }
pushSubs() { return this.get('/api/v1/push/subscriptions'); }
savePush(sub, topics, device) {
const j = sub.toJSON ? sub.toJSON() : sub;
return this.post('/api/v1/push/subscriptions', { endpoint: j.endpoint, keys: j.keys, topics, device });
}
deletePush(id) { return this.del('/api/v1/push/subscriptions/' + id); }
testPush() { return this.post('/api/v1/push/test'); }
logout(keyId) { return keyId ? this.del('/api/v1/apikeys/' + keyId) : Promise.resolve(); }
// Streams a topic as newline-delimited JSON until the signal aborts, calling onMessage for each
// message. Resolves when the stream ends, so the caller decides whether to reconnect.
async stream(topic, since, onMessage, signal) {
const headers = this.token ? { Authorization: 'Bearer ' + this.token } : {};
const res = await this.fetch(this.server + '/' + encodeURIComponent(topic) + '/json?since=' + encodeURIComponent(since || 'all'), { headers, signal });
if (!res.ok) throw new ApiError(res.status, 'Could not follow ' + topic + '.');
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
for (;;) {
const { done, value } = await reader.read();
if (done) return;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line) continue;
try {
const m = JSON.parse(line);
if (m.event === 'message') onMessage(m);
} catch { /* skip a bad line */ }
}
}
}
}
export function urlBase64ToUint8Array(b64) {
const pad = '='.repeat((4 - (b64.length % 4)) % 4);
const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
export function ago(sec, now = Date.now() / 1000) {
const d = Math.max(0, Math.round(now - sec));
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d / 60) + ' min ago';
if (d < 86400) return Math.floor(d / 3600) + ' h ago';
return Math.floor(d / 86400) + ' d ago';
}
+65
View File
@@ -0,0 +1,65 @@
/* Tokens follow the Lightwell language Sandbar uses: quiet neutrals, one teal accent. */
:root {
--bg: #f6f7f7; --surface: #fff; --text: #1b2121; --muted: #5b6666; --line: #dde2e2;
--accent: #1f5c5c; --accent-text: #fff; --accent-soft: #e3efef;
--ok: #1d6b3a; --ok-soft: #e1f2e7; --warn: #8a5a00; --warn-soft: #fbefd5; --bad: #a3231f; --bad-soft: #fbe4e2;
--r: 6px; --r2: 10px; --shadow: 0 1px 2px rgb(0 0 0 / .08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #101515; --surface: #182020; --text: #e8eeee; --muted: #9db0b0; --line: #2a3636;
--accent: #5fb3b3; --accent-text: #0b1414; --accent-soft: #1d3434;
--ok: #6fd193; --ok-soft: #173424; --warn: #e6b957; --warn-soft: #3a2e12; --bad: #ff8d86; --bad-soft: #3d1d1b;
}
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
#app { min-height: 100dvh; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); }
header.top { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--accent); color: var(--accent-text); }
header.top h1 { font-size: 18px; margin: 0; font-weight: 600; }
header.top .sub { font-size: 12px; opacity: .85; }
main { flex: 1; width: 100%; max-width: 640px; margin: 0 auto; padding: 16px; }
nav.tabs { position: sticky; bottom: 0; display: grid; grid-template-columns: repeat(5, 1fr); background: var(--surface); border-top: 1px solid var(--line); padding-bottom: env(safe-area-inset-bottom); }
nav.tabs a { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 8px 2px; min-height: 52px; color: var(--muted); text-decoration: none; font-size: 12px; }
nav.tabs a[aria-current="page"] { color: var(--accent); font-weight: 600; box-shadow: inset 0 2px 0 var(--accent); }
nav.tabs svg { width: 22px; height: 22px; }
.badge-count { background: var(--bad); color: #fff; border-radius: 9px; font-size: 11px; padding: 0 6px; margin-left: 4px; }
h2 { font-size: 20px; margin: 4px 0 12px; }
h3 { font-size: 14px; margin: 20px 0 8px; color: var(--muted); font-weight: 600; text-transform: none; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r2); box-shadow: var(--shadow); padding: 14px; margin-bottom: 12px; }
.card.flush { padding: 0; }
.row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.row:last-child { border-bottom: 0; }
.row .grow { flex: 1; min-width: 0; }
.row .title { font-weight: 600; overflow-wrap: anywhere; }
.row .meta { font-size: 13px; color: var(--muted); overflow-wrap: anywhere; }
.badge { display: inline-block; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); white-space: nowrap; }
.badge.ok { background: var(--ok-soft); color: var(--ok); }
.badge.warn { background: var(--warn-soft); color: var(--warn); }
.badge.bad { background: var(--bad-soft); color: var(--bad); }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
button, .btn { font: inherit; min-height: 44px; padding: 0 16px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer; }
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); font-weight: 600; }
button.danger { color: var(--bad); }
button:disabled { opacity: .55; cursor: default; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
label { display: block; font-size: 14px; font-weight: 600; margin: 12px 0 4px; }
input, select, textarea { width: 100%; font: inherit; min-height: 44px; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--r); background: var(--surface); color: var(--text); }
.hint { font-size: 13px; color: var(--muted); margin-top: 4px; }
.empty { color: var(--muted); text-align: center; padding: 28px 12px; }
.error { background: var(--bad-soft); color: var(--bad); padding: 10px 12px; border-radius: var(--r); margin: 12px 0; }
.notice { background: var(--warn-soft); color: var(--warn); padding: 10px 12px; border-radius: var(--r); margin: 12px 0; }
.toast { position: fixed; left: 50%; bottom: 80px; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: 10px 16px; border-radius: var(--r); max-width: 90vw; z-index: 10; }
.login { max-width: 420px; margin: 0 auto; padding: 32px 16px; }
.login h1 { font-size: 28px; margin: 0 0 4px; }
.msg { padding: 10px 14px; border-bottom: 1px solid var(--line); }
.msg:last-child { border-bottom: 0; }
.msg .t { font-weight: 600; overflow-wrap: anywhere; }
.msg .b { white-space: pre-wrap; overflow-wrap: anywhere; }
.msg .m { font-size: 12px; color: var(--muted); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { display: inline-flex; align-items: center; gap: 6px; background: var(--accent-soft); color: var(--accent); border-radius: 999px; padding: 4px 10px; font-size: 14px; }
.chip button { min-height: 24px; padding: 0 4px; border: 0; background: none; color: inherit; }
a { color: var(--accent); }
@media (prefers-reduced-motion: no-preference) { .toast { animation: up .2s ease-out; } @keyframes up { from { opacity: 0; transform: translate(-50%, 8px); } } }
+387
View File
@@ -0,0 +1,387 @@
import { Client, ApiError, normalizeServer, urlBase64ToUint8Array, ago } from './api.js';
import { getSession, setSession, getTopics, setTopics } from './store.js';
const app = document.getElementById('app');
const TABS = [
['alerts', 'Alerts', 'M12 3a6 6 0 0 0-6 6c0 5-2 6-2 7h16c0-1-2-2-2-7a6 6 0 0 0-6-6zM10 20a2 2 0 0 0 4 0'],
['topics', 'Topics', 'M4 5h16v11H8l-4 4zM8 9h8M8 12h5'],
['monitors', 'Monitors', 'M3 12h4l3-7 4 14 3-7h4'],
['status', 'Status', 'M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6zM9 12l2 2 4-4'],
['settings', 'Settings', 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19 12l2-1-2-4-2 1-2-1-1-2h-4l-1 2-2 1-2-1-2 4 2 1v2l-2 1 2 4 2-1 2 1 1 2h4l1-2 2-1 2 1 2-4-2-1z'],
];
let session = null;
let client = null;
let route = 'alerts';
let topics = ['alerts'];
let streams = new Map();
let pollTimer = null;
let data = { alerts: null, monitors: null, states: null, onCall: null, edges: null, subs: null, messages: {}, error: '' };
const live = {}; // topic -> messages seen since the app opened
// ---------- tiny DOM helper ----------
function h(tag, attrs, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else el.setAttribute(k, v === true ? '' : v);
}
for (const kid of kids.flat()) if (kid != null && kid !== false) el.append(kid.nodeType ? kid : document.createTextNode(String(kid)));
return el;
}
const svg = (d) => {
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor');
s.setAttribute('stroke-width', '1.75'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round'); s.setAttribute('aria-hidden', 'true');
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p.setAttribute('d', d); s.append(p);
return s;
};
function toast(text) {
const t = h('div', { class: 'toast', role: 'status' }, text);
document.body.append(t);
setTimeout(() => t.remove(), 3500);
}
function deviceName() {
const ua = navigator.userAgent;
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad/.test(ua) ? 'iPhone' : /Windows/.test(ua) ? 'Windows' : /Mac/.test(ua) ? 'Mac' : /Linux/.test(ua) ? 'Linux' : 'device';
const br = /Firefox/.test(ua) ? 'Firefox' : /Edg/.test(ua) ? 'Edge' : /Chrome/.test(ua) ? 'Chrome' : /Safari/.test(ua) ? 'Safari' : 'browser';
return br + ' on ' + os;
}
// ---------- boot ----------
async function boot() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(() => {});
navigator.serviceWorker.addEventListener('message', (e) => onWorkerMessage(e.data));
}
try { session = await getSession(); } catch { session = null; }
if (session) {
client = new Client(session.server, session.token);
topics = await getTopics().catch(() => ['alerts']);
}
route = (location.hash.slice(1).split('/')[0]) || 'alerts';
window.addEventListener('hashchange', () => { route = location.hash.slice(1).split('/')[0] || 'alerts'; render(); load(); });
if (session) startStreams();
render();
if (session) load();
document.addEventListener('visibilitychange', () => { if (!document.hidden && session) load(); });
}
function onWorkerMessage(m) {
if (!m) return;
if (m.type === 'push') {
const p = m.payload || {};
if (document.visibilityState === 'visible') toast((p.title || 'Sandbar') + (p.message ? ': ' + p.message.slice(0, 80) : ''));
load();
} else if (m.type === 'open') {
location.hash = m.alert ? 'alerts' : 'topics';
}
}
// ---------- data ----------
async function load() {
if (!client) return;
clearTimeout(pollTimer);
data.error = '';
try {
if (route === 'alerts') data.alerts = await client.alerts();
else if (route === 'monitors') [data.monitors, data.states] = await Promise.all([client.monitors(), client.monitorStates()]);
else if (route === 'status') [data.onCall, data.edges] = await Promise.all([client.onCall(), client.edges().catch((e) => ({ configured: true, error: e.message, edges: [] }))]);
else if (route === 'settings') data.subs = await client.pushSubs();
else if (route === 'topic') {
const t = decodeURIComponent(location.hash.split('/')[1] || '');
data.messages[t] = await client.messages(t, Math.floor(Date.now() / 1000) - 7 * 86400);
}
if (route === 'alerts' && !data.alerts) data.alerts = [];
} catch (e) {
if (e instanceof ApiError && e.status === 401) return signOut(true);
data.error = e.message;
}
render();
pollTimer = setTimeout(load, 20000); // push and the streams are the fast path; this keeps the screen honest
}
function startStreams() {
for (const [, ctl] of streams) ctl.abort();
streams = new Map();
for (const t of topics) follow(t);
}
function follow(topic) {
const ctl = new AbortController();
streams.set(topic, ctl);
let wait = 1000;
const since = String(Math.floor(Date.now() / 1000));
(async () => {
while (!ctl.signal.aborted) {
try {
await client.stream(topic, since, (m) => {
(live[topic] = live[topic] || []).push(m);
wait = 1000;
if (route === 'topic' && decodeURIComponent(location.hash.split('/')[1] || '') === topic) {
(data.messages[topic] = data.messages[topic] || []).push(m);
}
if (m.alert && route === 'alerts') load();
render();
}, ctl.signal);
} catch (e) {
if (ctl.signal.aborted) return;
if (e instanceof ApiError && e.status === 401) return signOut(true);
}
await new Promise((r) => setTimeout(r, wait));
wait = Math.min(wait * 2, 30000);
}
})();
}
// ---------- actions ----------
async function act(label, fn) {
try {
await fn();
toast(label);
} catch (e) {
toast(e.message);
}
load();
}
async function signOut(expired) {
for (const [, ctl] of streams) ctl.abort();
streams = new Map();
clearTimeout(pollTimer);
if (client && !expired) {
try {
const reg = await navigator.serviceWorker?.ready;
const sub = await reg?.pushManager.getSubscription();
const mine = sub && (await client.pushSubs()).find((s) => s.endpoint === sub.endpoint);
if (mine) await client.deletePush(mine.id);
await sub?.unsubscribe();
await client.logout(session.keyId);
} catch { /* signing out must work even when the server is unreachable */ }
}
await setSession(undefined);
session = null; client = null; data = { alerts: null, monitors: null, states: null, onCall: null, edges: null, subs: null, messages: {}, error: '' };
render();
if (expired) toast('Your session ended. Sign in again.');
}
// Turns push on for this device: asks permission, subscribes, and tells the server which topics to send.
async function enablePush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) throw new Error('This browser cannot receive push notifications. On iPhone, add Sandbar to the Home Screen first.');
const perm = await Notification.requestPermission();
if (perm !== 'granted') throw new Error('Notifications are blocked. Allow them for this site in your browser settings, then try again.');
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
const { publicKey } = await client.pushKey();
sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });
}
await client.savePush(sub, topics, session.device);
}
async function pushState() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return 'unsupported';
if (Notification.permission === 'denied') return 'blocked';
const reg = await navigator.serviceWorker.ready;
return (await reg.pushManager.getSubscription()) ? 'on' : 'off';
}
async function syncTopics() {
await setTopics(topics);
startStreams();
if ((await pushState()) === 'on') {
const reg = await navigator.serviceWorker.ready;
await client.savePush(await reg.pushManager.getSubscription(), topics, session.device);
}
}
// ---------- views ----------
function loginView() {
const err = h('div', { class: 'error', role: 'alert', hidden: true });
const server = h('input', { id: 'server', type: 'url', autocomplete: 'url', inputmode: 'url', placeholder: 'sandbar.example.com', required: true,
value: location.pathname.startsWith('/app') ? location.origin : '' });
const email = h('input', { id: 'email', type: 'email', autocomplete: 'username', required: true });
const pw = h('input', { id: 'pw', type: 'password', autocomplete: 'current-password', required: true });
const btn = h('button', { class: 'primary', type: 'submit' }, 'Sign in');
const form = h('form', { class: 'login' },
h('h1', {}, 'Sandbar'),
h('p', { class: 'hint' }, 'Alerts, on-call and monitors from your own server.'),
err,
h('label', { for: 'server' }, 'Server address'), server,
h('label', { for: 'email' }, 'Email'), email,
h('label', { for: 'pw' }, 'Password'), pw,
h('div', { class: 'actions' }, btn));
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
err.hidden = true; btn.disabled = true; btn.textContent = 'Signing in';
try {
const base = normalizeServer(server.value);
const device = deviceName();
const r = await Client.login(base, email.value, pw.value, device);
session = { server: base, token: r.token, keyId: r.keyId, user: r.user, device };
await setSession(session);
client = new Client(base, r.token);
topics = ['alerts'];
await setTopics(topics);
startStreams();
location.hash = 'alerts';
render();
load();
} catch (e) {
err.textContent = e.message; err.hidden = false; btn.disabled = false; btn.textContent = 'Sign in';
}
});
return form;
}
const sevClass = (s) => (s === 'critical' ? 'bad' : s === 'warning' ? 'warn' : '');
const statusClass = (s) => (s === 'firing' || s === 'down' ? 'bad' : s === 'acked' || s === 'pending' ? 'warn' : 'ok');
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
function alertCard(a) {
const firing = a.status === 'firing';
const acts = [];
if (firing) {
acts.push(h('button', { class: 'primary', onclick: () => act('Acknowledged', () => client.ack(a.id)) }, 'Acknowledge'));
acts.push(h('button', { onclick: () => act('Snoozed for 30 minutes', () => client.snooze(a.id, 30)) }, 'Snooze 30 min'));
if (a.policyId) acts.push(h('button', { onclick: () => act('Escalated to the next step', () => client.escalate(a.id)) }, 'Escalate now'));
}
if (a.status !== 'resolved') {
acts.push(h('button', { class: 'danger', onclick: () => { if (confirm('Resolve "' + a.summary + '"? It will stop paging anyone.')) act('Resolved', () => client.resolve(a.id)); } }, 'Resolve'));
}
return h('div', { class: 'card' },
h('div', {}, h('span', { class: 'badge ' + statusClass(a.status) }, cap(a.status)), ' ', h('span', { class: 'badge ' + sevClass(a.severity) }, cap(a.severity))),
h('div', { class: 'title', style: 'margin-top:8px;font-weight:600' }, a.summary),
h('div', { class: 'meta hint' }, a.source + ' · ' + ago(a.created) + (a.count > 1 ? ' · fired ' + a.count + ' times' : '') +
(a.ackedBy ? ' · acknowledged by ' + a.ackedBy : '') +
(a.snoozeUntil * 1000 > Date.now() && firing ? ' · snoozed until ' + new Date(a.snoozeUntil * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '')),
a.detail ? h('div', { class: 'hint' }, a.detail) : null,
acts.length ? h('div', { class: 'actions' }, acts) : null);
}
function alertsView() {
if (!data.alerts) return h('p', { class: 'empty' }, 'Loading alerts');
const active = data.alerts.filter((a) => a.status !== 'resolved');
const done = data.alerts.filter((a) => a.status === 'resolved').slice(0, 10);
return h('div', {},
h('h2', {}, 'Alerts'),
active.length ? active.map(alertCard) : h('div', { class: 'card empty' }, 'No open alerts. New ones appear here and arrive as notifications.'),
done.length ? [h('h3', {}, 'Recently resolved'), done.map((a) => h('div', { class: 'card flush' }, h('div', { class: 'row' }, h('div', { class: 'grow' }, h('div', { class: 'title' }, a.summary), h('div', { class: 'meta' }, 'Resolved ' + ago(a.resolvedAt || a.updated)))))) ] : null);
}
function topicsView() {
const input = h('input', { id: 'newtopic', placeholder: 'topic name', autocapitalize: 'none', 'aria-label': 'Topic name' });
const add = async (ev) => {
ev.preventDefault();
const t = input.value.trim();
if (!/^[A-Za-z0-9_-]{1,64}$/.test(t)) return toast('A topic is 1 to 64 letters, digits, - or _.');
if (!topics.includes(t)) { topics = [...topics, t]; await syncTopics().catch((e) => toast(e.message)); }
input.value = ''; render();
};
return h('div', {},
h('h2', {}, 'Topics'),
h('div', { class: 'card flush' }, topics.map((t) =>
h('div', { class: 'row' },
h('a', { class: 'grow title', href: '#topic/' + encodeURIComponent(t) }, t, (live[t] || []).length ? h('span', { class: 'badge-count' }, String(live[t].length)) : null),
h('button', { 'aria-label': 'Stop following ' + t, onclick: async () => { topics = topics.filter((x) => x !== t); delete live[t]; await syncTopics().catch((e) => toast(e.message)); render(); } }, 'Unfollow')))),
h('form', { class: 'card', onsubmit: add }, h('label', { for: 'newtopic' }, 'Follow a topic'), input,
h('div', { class: 'hint' }, 'Followed topics send a notification to this device when push is on. Your personal on-call topic is always included.'),
h('div', { class: 'actions' }, h('button', { class: 'primary', type: 'submit' }, 'Follow topic'))));
}
function topicView() {
const t = decodeURIComponent(location.hash.split('/')[1] || '');
const msgs = (data.messages[t] || []).slice().reverse();
live[t] = [];
const body = h('input', { id: 'pub', placeholder: 'Message', 'aria-label': 'Message' });
const send = async (ev) => {
ev.preventDefault();
if (!body.value.trim()) return;
try { const r = await client.publish(t, body.value); if (!r.ok) throw new Error('The server refused the message.'); body.value = ''; toast('Sent'); } catch (e) { toast(e.message); }
};
return h('div', {},
h('p', {}, h('a', { href: '#topics' }, 'Back to topics')),
h('h2', {}, t),
h('div', { class: 'card flush' }, msgs.length ? msgs.map((m) => h('div', { class: 'msg' },
m.title ? h('div', { class: 't' }, m.title) : null, h('div', { class: 'b' }, m.message),
h('div', { class: 'm' }, ago(m.time) + (m.priority >= 4 ? ' · high priority' : '')))) : h('div', { class: 'empty' }, 'Nothing in the last 7 days.')),
h('form', { class: 'card', onsubmit: send }, h('label', { for: 'pub' }, 'Send a message'), body, h('div', { class: 'actions' }, h('button', { type: 'submit' }, 'Send'))));
}
function monitorsView() {
if (!data.monitors) return h('p', { class: 'empty' }, 'Loading monitors');
const st = data.states || {};
const rows = data.monitors.filter((m) => m.enabled !== false).map((m) => ({ m, s: st[m.id] || { status: 'pending' } }));
const order = { down: 0, pending: 1, up: 2 };
rows.sort((a, b) => (order[a.s.status] ?? 1) - (order[b.s.status] ?? 1) || a.m.name.localeCompare(b.m.name));
const down = rows.filter((r) => r.s.status === 'down').length;
return h('div', {},
h('h2', {}, 'Monitors'),
h('p', { class: 'hint' }, rows.length ? (down ? down + ' of ' + rows.length + ' are down.' : 'All ' + rows.length + ' are up.') : ''),
rows.length ? h('div', { class: 'card flush' }, rows.map(({ m, s }) => h('div', { class: 'row' },
h('div', { class: 'grow' }, h('div', { class: 'title' }, m.name), h('div', { class: 'meta' }, m.kind + ' · ' + m.target + (s.lastCheck ? ' · checked ' + ago(s.lastCheck) : '')),
s.status === 'down' && s.detail ? h('div', { class: 'meta' }, s.detail) : null),
h('div', {}, h('span', { class: 'badge ' + statusClass(s.status) }, cap(s.status)), s.uptime30 >= 0 && s.uptime30 != null ? h('div', { class: 'meta', style: 'text-align:right' }, s.uptime30.toFixed(2) + '% in 30 d') : null))))
: h('div', { class: 'card empty' }, 'No monitors yet. Add them in the Sandbar web app.'));
}
function statusView() {
const oc = data.onCall, hh = data.edges;
return h('div', {},
h('h2', {}, 'Status'),
h('h3', {}, 'On call now'),
oc ? (oc.length ? h('div', { class: 'card flush' }, oc.map((r) => h('div', { class: 'row' }, h('div', { class: 'grow' }, h('div', { class: 'title' }, r.user || 'Nobody'), h('div', { class: 'meta' }, r.schedule + (r.next ? ' · next: ' + r.next : '')))))) : h('div', { class: 'card empty' }, 'No schedules yet.')) : h('p', { class: 'empty' }, 'Loading'),
h('h3', {}, 'Hammerhead tunnels'),
!hh ? h('p', { class: 'empty' }, 'Loading')
: hh.error ? h('div', { class: 'error' }, hh.error)
: !hh.configured ? h('div', { class: 'card empty' }, 'Hammerhead is not linked. Set SANDBAR_HAMMERHEAD_URL on the server to see its edges here.')
: hh.edges.length ? h('div', { class: 'card flush' }, hh.edges.map((e) => h('div', { class: 'row' },
h('div', { class: 'grow' }, h('div', { class: 'title' }, e.name), h('div', { class: 'meta' }, (e.ips || []).join(', ') + (e.version ? ' · ' + e.version : ''))),
h('span', { class: 'badge ' + (e.online ? 'ok' : 'bad') }, e.online ? 'Online' : 'Offline'))))
: h('div', { class: 'card empty' }, 'Hammerhead has no edges.'));
}
function settingsView() {
const box = h('div', { id: 'pushbox' }, h('p', { class: 'hint' }, 'Checking'));
pushState().then(async (st) => {
const msg = { unsupported: 'This browser cannot receive push notifications. On iPhone and iPad, add Sandbar to the Home Screen (Share, then Add to Home Screen) and open it from there.',
blocked: 'Notifications are blocked for this site. Allow them in your browser settings, then come back.',
on: 'Push notifications are on for this device.', off: 'Push notifications are off for this device.' }[st];
box.replaceChildren(...[h('p', {}, msg),
st === 'off' ? h('div', { class: 'actions' }, h('button', { class: 'primary', onclick: () => act('Notifications turned on', enablePush) }, 'Turn on notifications')) : null,
st === 'on' ? h('div', { class: 'actions' },
h('button', { onclick: async () => { try { const r = await client.testPush(); const bad = r.find((x) => x.error); toast(bad ? 'Push failed: ' + bad.error : 'Test sent. It should arrive in a moment.'); } catch (e) { toast(e.message); } } }, 'Send a test'),
h('button', { onclick: () => act('Notifications turned off', async () => { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.getSubscription(); const mine = (await client.pushSubs()).find((x) => sub && x.endpoint === sub.endpoint); if (mine) await client.deletePush(mine.id); await sub?.unsubscribe(); }) }, 'Turn off')) : null].filter(Boolean));
});
const subs = data.subs;
return h('div', {},
h('h2', {}, 'Settings'),
h('div', { class: 'card' }, h('div', { class: 'title', style: 'font-weight:600' }, session.user.name), h('div', { class: 'meta hint' }, session.user.email + ' · ' + session.server)),
h('h3', {}, 'Notifications'), h('div', { class: 'card' }, box),
h('h3', {}, 'Your devices'),
subs ? (subs.length ? h('div', { class: 'card flush' }, subs.map((s) => h('div', { class: 'row' }, h('div', { class: 'grow' }, h('div', { class: 'title' }, s.device || 'Device'), h('div', { class: 'meta' }, s.topics.length + ' topics · added ' + ago(s.created))),
h('button', { class: 'danger', 'aria-label': 'Remove ' + (s.device || 'device'), onclick: () => act('Device removed', () => client.deletePush(s.id)) }, 'Remove')))) : h('div', { class: 'card empty' }, 'No devices are registered for push.')) : null,
h('div', { class: 'actions' }, h('button', { class: 'danger', onclick: () => signOut(false) }, 'Sign out')));
}
function render() {
if (!session) { app.replaceChildren(loginView()); return; }
const tab = route === 'topic' ? 'topics' : route;
const firing = (data.alerts || []).filter((a) => a.status === 'firing').length;
const view = { alerts: alertsView, topics: topicsView, topic: topicView, monitors: monitorsView, status: statusView, settings: settingsView }[route] || alertsView;
const focus = document.activeElement && document.activeElement.id;
const keep = focus && ['newtopic', 'pub'].includes(focus) ? document.activeElement.value : null;
app.replaceChildren(
h('header', { class: 'top' }, h('h1', {}, 'Sandbar'), h('span', { class: 'sub' }, session.server.replace(/^https?:\/\//, ''))),
h('main', {}, data.error ? h('div', { class: 'error', role: 'alert' }, data.error) : null, view()),
h('nav', { class: 'tabs', 'aria-label': 'Sections' }, TABS.map(([id, label, d]) =>
h('a', { href: '#' + id, 'aria-current': tab === id ? 'page' : null }, svg(d), h('span', {}, label, id === 'alerts' && firing ? h('span', { class: 'badge-count' }, String(firing)) : null)))));
if (keep !== null) { const el = document.getElementById(focus); if (el) { el.value = keep; el.focus(); } }
}
boot();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" fill="#1f5c5c"/><path d="M96 330c40-48 80-48 120 0s80 48 120 0 56-32 80-16v78H96z" fill="#fff"/><path d="M96 250c40-48 80-48 120 0s80 48 120 0 56-32 80-16" fill="none" stroke="#fff" stroke-width="28" stroke-linecap="round" opacity=".6"/></svg>

After

Width:  |  Height:  |  Size: 337 B

+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#1f5c5c">
<meta name="color-scheme" content="light dark">
<title>Sandbar</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="icon-192.png">
<link rel="stylesheet" href="app.css">
</head>
<body>
<div id="app" aria-live="polite"></div>
<script type="module" src="app.js"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Sandbar",
"short_name": "Sandbar",
"description": "Alerts, on-call and monitors from your Sandbar server.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#101515",
"theme_color": "#1f5c5c",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}
+41
View File
@@ -0,0 +1,41 @@
// Settings shared by the page and the service worker. The service worker cannot read
// localStorage, so the sign-in lives in IndexedDB; the page keeps a copy in localStorage
// only for quick startup and falls back to IndexedDB.
const DB = 'sandbar-mobile';
function open() {
return new Promise((resolve, reject) => {
const r = indexedDB.open(DB, 1);
r.onupgradeneeded = () => r.result.createObjectStore('kv');
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
}
export async function kvGet(key) {
const db = await open();
return new Promise((resolve, reject) => {
const r = db.transaction('kv').objectStore('kv').get(key);
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
}
export async function kvSet(key, value) {
const db = await open();
return new Promise((resolve, reject) => {
const tx = db.transaction('kv', 'readwrite');
if (value === undefined) tx.objectStore('kv').delete(key);
else tx.objectStore('kv').put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// session: { server, token, keyId, user }
export const getSession = () => kvGet('session');
export const setSession = (s) => kvSet('session', s);
// topics: the topics this device follows
export async function getTopics() { return (await kvGet('topics')) || ['alerts']; }
export const setTopics = (t) => kvSet('topics', t);
+139
View File
@@ -0,0 +1,139 @@
// Service worker: shows push notifications and handles their buttons without opening the app.
// Classic script, not a module: Firefox does not run module service workers. It reads the
// sign-in that app.js saved in IndexedDB (see store.js) and calls the API directly.
function kvGet(key) {
return new Promise((resolve, reject) => {
const r = indexedDB.open('sandbar-mobile', 1);
r.onupgradeneeded = () => r.result.createObjectStore('kv');
r.onerror = () => reject(r.error);
r.onsuccess = () => {
const g = r.result.transaction('kv').objectStore('kv').get(key);
g.onsuccess = () => resolve(g.result);
g.onerror = () => reject(g.error);
};
});
}
async function api(session, method, path, body) {
const res = await fetch(session.server + path, {
method,
headers: { Authorization: 'Bearer ' + session.token, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error((data && data.error) || 'The server answered ' + res.status + '.');
return data;
}
function keyBytes(b64) {
const pad = '='.repeat((4 - (b64.length % 4)) % 4);
return Uint8Array.from(atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
}
const SHELL = 'sandbar-shell-v1';
const FILES = ['./', 'index.html', 'app.css', 'app.js', 'api.js', 'store.js', 'manifest.webmanifest', 'icon.svg'];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k)))).then(() => self.clients.claim()),
);
});
// The app shell loads from the cache first so it opens offline; API calls always go to the network.
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.origin !== self.location.origin || !url.pathname.startsWith(new URL(self.registration.scope).pathname)) return;
e.respondWith(
fetch(e.request)
.then((res) => {
if (res.ok) caches.open(SHELL).then((c) => c.put(e.request, res.clone()));
return res;
})
.catch(() => caches.match(e.request).then((r) => r || caches.match('./'))),
);
});
self.addEventListener('push', (e) => {
let p;
try { p = e.data.json(); } catch { p = { title: 'Sandbar', message: e.data ? e.data.text() : '' }; }
e.waitUntil(show(p));
});
async function show(p) {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const c of clients) c.postMessage({ type: 'push', payload: p });
const tag = p.alert || p.id || undefined;
if (p.status === 'resolved' || p.status === 'acked') {
// The alert moved on: take down its earlier notification, and say so quietly.
for (const n of await self.registration.getNotifications({ tag })) n.close();
}
if (clients.some((c) => c.visibilityState === 'visible')) return; // the open app shows it itself
const firing = !!p.alert && p.status === 'firing';
const options = {
body: p.message || '',
tag,
renotify: !!tag,
data: { topic: p.topic, alert: p.alert, id: p.id },
requireInteraction: (p.priority || 3) >= 4,
icon: 'icon-192.png',
badge: 'icon-192.png',
timestamp: (p.time || Date.now() / 1000) * 1000,
actions: firing ? [{ action: 'ack', title: 'Acknowledge' }, { action: 'snooze', title: 'Snooze 30 min' }] : [],
};
await self.registration.showNotification(p.title || 'Sandbar', options);
}
self.addEventListener('notificationclick', (e) => {
const n = e.notification;
const { alert } = n.data || {};
n.close();
e.waitUntil(
(async () => {
if (alert && (e.action === 'ack' || e.action === 'snooze')) {
const s = await kvGet('session');
if (s) {
try {
if (e.action === 'ack') await api(s, 'POST', '/api/v1/alerts/' + alert + '/ack', {});
else await api(s, 'POST', '/api/v1/alerts/' + alert + '/snooze', { minutes: 30 });
return;
} catch (err) {
await self.registration.showNotification('Could not ' + (e.action === 'ack' ? 'acknowledge' : 'snooze') + ' the alert', {
body: err.message + ' Open Sandbar to try again.', tag: 'err-' + alert, data: { alert },
});
return;
}
}
}
const target = new URL(alert ? '#alerts' : '#topics', self.registration.scope).href;
const wins = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const win = wins.find((w) => w.url.startsWith(self.registration.scope));
if (win) {
await win.focus();
win.postMessage({ type: 'open', alert, topic: (n.data || {}).topic });
} else {
await self.clients.openWindow(target);
}
})(),
);
});
// Browsers rotate push subscriptions now and then. Re-register the new one, or alerts silently stop.
self.addEventListener('pushsubscriptionchange', (e) => {
e.waitUntil(
(async () => {
const s = await kvGet('session');
if (!s) return;
const { publicKey } = await api(s, 'GET', '/api/v1/push/key');
const sub = await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(publicKey) });
const j = sub.toJSON();
await api(s, 'POST', '/api/v1/push/subscriptions', {
endpoint: j.endpoint, keys: j.keys, topics: (await kvGet('topics')) || ['alerts'], device: s.device || 'phone',
});
})(),
);
});
+43
View File
@@ -0,0 +1,43 @@
// Writes public/icon-192.png and icon-512.png (teal square with two white waves) with no dependencies.
import { deflateSync } from 'node:zlib';
import { writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
const crc = (b) => {
let c = 0xffffffff;
for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type, data) => {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type), data]);
const c = Buffer.alloc(4); c.writeUInt32BE(crc(td));
return Buffer.concat([len, td, c]);
};
function png(size) {
const raw = Buffer.alloc((size * 4 + 1) * size);
for (let y = 0; y < size; y++) {
raw[y * (size * 4 + 1)] = 0;
for (let x = 0; x < size; x++) {
const u = x / size, v = y / size; // 0..1
const wave = (off) => 0.5 + off + 0.06 * Math.sin((u - 0.19) * Math.PI * 6);
const body = v > wave(0.0) + 0.06 && v < 0.85 && u > 0.19 && u < 0.81; // lower filled wave
const line = Math.abs(v - wave(-0.1)) < 0.03 && u > 0.19 && u < 0.81;
const i = y * (size * 4 + 1) + 1 + x * 4;
const on = body || line;
raw[i] = on ? 255 : 0x1f; raw[i + 1] = on ? 255 : 0x5c; raw[i + 2] = on ? 255 : 0x5c; raw[i + 3] = 255;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; ihdr[9] = 6;
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
}
for (const s of [192, 512]) writeFileSync(fileURLToPath(new URL(`../public/icon-${s}.png`, import.meta.url)), png(s));
console.log('icons written');
+23
View File
@@ -0,0 +1,23 @@
// Serves public/ on http://localhost:5173 for working on the app without a Sandbar-hosted copy.
// The app then needs the server's address, and the server must allow this origin:
// SANDBAR_APP_ORIGIN=http://localhost:5173
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../public/', import.meta.url));
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.webmanifest': 'application/manifest+json' };
const port = Number(process.env.PORT || 5173);
createServer(async (req, res) => {
let p = normalize(decodeURIComponent(new URL(req.url, 'http://x').pathname)).replace(/^([/\])+/, '');
if (p === '' || p.endsWith('/')) p += 'index.html';
if (p.includes('..')) { res.writeHead(400).end(); return; }
try {
const body = await readFile(join(root, p));
res.writeHead(200, { 'Content-Type': types[extname(p)] || 'application/octet-stream', 'Cache-Control': 'no-cache' }).end(body);
} catch {
res.writeHead(404).end('not found');
}
}).listen(port, () => console.log(`http://localhost:${port}`));
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Client, ApiError, normalizeServer, ago, urlBase64ToUint8Array } from '../public/api.js';
const jsonRes = (status, body) => ({ ok: status < 300, status, text: async () => JSON.stringify(body) });
test('normalizeServer adds a scheme and drops paths', () => {
assert.equal(normalizeServer('sandbar.example.com/app/'), 'https://sandbar.example.com');
assert.equal(normalizeServer('localhost:8090'), 'http://localhost:8090');
assert.equal(normalizeServer(' https://x.test:8443/y '), 'https://x.test:8443');
assert.throws(() => normalizeServer(''), /address/);
});
test('login posts credentials and returns the token', async () => {
let seen;
const f = async (url, init) => { seen = { url, init }; return jsonRes(200, { token: 'sb_abc', keyId: 'k1', user: { name: 'Ana' } }); };
const r = await Client.login('http://s', 'a@b.c', 'pw', 'Pixel', f);
assert.equal(r.token, 'sb_abc');
assert.equal(seen.url, 'http://s/api/v1/mobile/login');
assert.deepEqual(JSON.parse(seen.init.body), { email: 'a@b.c', password: 'pw', device: 'Pixel' });
assert.equal(seen.init.headers.Authorization, undefined);
});
test('errors carry the server message and status', async () => {
const c = new Client('http://s', 't', async () => jsonRes(401, { error: 'Wrong email or password' }));
await assert.rejects(c.alerts(), (e) => e instanceof ApiError && e.status === 401 && e.message === 'Wrong email or password');
const down = new Client('http://s', 't', async () => { throw new TypeError('fetch failed'); });
await assert.rejects(down.alerts(), (e) => e.status === 0 && /Could not reach/.test(e.message));
});
test('alert actions hit the right endpoints with a bearer token', async () => {
const calls = [];
const c = new Client('http://s', 'tok', async (url, init) => { calls.push([init.method, url, init.headers.Authorization, init.body]); return jsonRes(200, { ok: true }); });
await c.ack('a1'); await c.snooze('a1', 30); await c.escalate('a1'); await c.resolve('a1');
assert.deepEqual(calls.map((x) => x.slice(0, 3)), [
['POST', 'http://s/api/v1/alerts/a1/ack', 'Bearer tok'],
['POST', 'http://s/api/v1/alerts/a1/snooze', 'Bearer tok'],
['POST', 'http://s/api/v1/alerts/a1/escalate', 'Bearer tok'],
['POST', 'http://s/api/v1/alerts/a1/resolve', 'Bearer tok'],
]);
assert.equal(calls[1][3], '{"minutes":30}');
});
test('stream parses newline-delimited messages split across chunks', async () => {
const enc = new TextEncoder();
const chunks = ['{"event":"open"}\n{"event":"message","id":"1","mess', 'age":"hi"}\n\n{"event":"message","id":"2","message":"yo"}\n'];
const body = new ReadableStream({ start(c) { chunks.forEach((x) => c.enqueue(enc.encode(x))); c.close(); } });
const got = [];
const c = new Client('http://s', 't', async () => ({ ok: true, status: 200, body }));
await c.stream('alerts', 'all', (m) => got.push(m.message), new AbortController().signal);
assert.deepEqual(got, ['hi', 'yo']);
});
test('ago and key decoding', () => {
assert.equal(ago(1000, 1030), 'just now');
assert.equal(ago(1000, 1000 + 5 * 60), '5 min ago');
assert.equal(ago(1000, 1000 + 3 * 3600), '3 h ago');
assert.deepEqual([...urlBase64ToUint8Array('AQID')], [1, 2, 3]);
});
+72
View File
@@ -0,0 +1,72 @@
// Runs sw.js in a stand-in service worker scope and checks what it shows and calls.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
function load({ visible = false, session = { server: 'http://s', token: 'tok', device: 'Pixel' } } = {}) {
const handlers = {}, shown = [], closed = [], fetches = [], opened = [], posted = [];
const win = { url: 'http://s/app/', visibilityState: visible ? 'visible' : 'hidden', focus: async () => {}, postMessage: (m) => posted.push(m) };
const self = {
location: { origin: 'http://s' },
addEventListener: (n, f) => { handlers[n] = f; },
skipWaiting: () => {},
clients: { claim: async () => {}, matchAll: async () => (visible ? [win] : []), openWindow: async (u) => opened.push(u) },
registration: {
scope: 'http://s/app/',
showNotification: async (title, o) => shown.push({ title, ...o }),
getNotifications: async ({ tag }) => [{ tag, close: () => closed.push(tag) }],
pushManager: {},
},
};
const idb = { open: () => { const r = {}; setTimeout(() => { r.result = { createObjectStore() {}, transaction: () => ({ objectStore: () => ({ get: () => { const g = {}; setTimeout(() => { g.result = session; g.onsuccess(); }); return g; } }) }) }; r.onsuccess(); }); return r; } };
const ctx = vm.createContext({ self, indexedDB: idb, fetch: async (u, i) => { fetches.push([u, i]); return { ok: true, status: 200, json: async () => ({ ok: true }) }; }, caches: {}, URL, console, setTimeout, Uint8Array, atob });
vm.runInContext(readFileSync(new URL('../public/sw.js', import.meta.url), 'utf8'), ctx);
const run = async (name, ev) => { let p; await handlers[name]({ ...ev, waitUntil: (x) => { p = x; } }); await p; };
return { run, shown, closed, fetches, opened, posted };
}
const push = (o) => ({ data: { json: () => o } });
test('a firing alert shows a notification with Acknowledge and Snooze', async () => {
const w = load();
await w.run('push', push({ id: 'm1', topic: 'alerts', title: 'Disk full', message: 'db1 98%', priority: 5, alert: 'a1', status: 'firing', time: 1 }));
assert.equal(w.shown.length, 1);
assert.equal(w.shown[0].title, 'Disk full');
assert.equal(w.shown[0].tag, 'a1');
assert.equal(w.shown[0].requireInteraction, true);
assert.equal(JSON.stringify(w.shown[0].actions.map((a) => a.action)), '["ack","snooze"]');
});
test('a plain message has no alert buttons, and an open app shows its own toast instead', async () => {
const w = load();
await w.run('push', push({ id: 'm2', topic: 'news', title: 'Hi', message: 'x', priority: 3 }));
assert.equal(w.shown[0].actions.length, 0);
const v = load({ visible: true });
await v.run('push', push({ id: 'm3', topic: 'news', title: 'Hi', message: 'x' }));
assert.equal(v.shown.length, 0);
assert.equal(v.posted[0].type, 'push');
});
test('a resolved alert closes its earlier notification', async () => {
const w = load();
await w.run('push', push({ id: 'm4', topic: 'alerts', title: 'Resolved', message: 'ok', alert: 'a1', status: 'resolved' }));
assert.equal(w.closed.join(), 'a1');
});
test('Acknowledge on the notification calls the API without opening the app', async () => {
const w = load();
await w.run('notificationclick', { action: 'ack', notification: { data: { alert: 'a1' }, close() {} } });
assert.equal(w.fetches.length, 1);
assert.equal(w.fetches[0][0], 'http://s/api/v1/alerts/a1/ack');
assert.equal(w.fetches[0][1].headers.Authorization, 'Bearer tok');
assert.equal(w.opened.length, 0);
});
test('Snooze sends 30 minutes, and tapping the body opens the alerts view', async () => {
const w = load();
await w.run('notificationclick', { action: 'snooze', notification: { data: { alert: 'a1' }, close() {} } });
assert.equal(w.fetches[0][1].body, '{"minutes":30}');
await w.run('notificationclick', { action: '', notification: { data: { alert: 'a1' }, close() {} } });
assert.equal(w.opened[0], 'http://s/app/#alerts');
});