From ad165ef6a564c4c30f98e484584cea7ddef72c7a Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:02:39 -0600 Subject: [PATCH] Add the Sandbar mobile companion PWA Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 2 + README.md | 76 +++++++ docs/design.md | 69 +++++++ package.json | 13 ++ public/api.js | 119 +++++++++++ public/app.css | 65 ++++++ public/app.js | 387 ++++++++++++++++++++++++++++++++++++ public/icon-192.png | Bin 0 -> 1260 bytes public/icon-512.png | Bin 0 -> 4487 bytes public/icon.svg | 1 + public/index.html | 18 ++ public/manifest.webmanifest | 16 ++ public/store.js | 41 ++++ public/sw.js | 139 +++++++++++++ scripts/make-icons.mjs | 43 ++++ scripts/serve.mjs | 23 +++ test/api.test.mjs | 59 ++++++ test/sw.test.mjs | 72 +++++++ 18 files changed, 1143 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/design.md create mode 100644 package.json create mode 100644 public/api.js create mode 100644 public/app.css create mode 100644 public/app.js create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/icon.svg create mode 100644 public/index.html create mode 100644 public/manifest.webmanifest create mode 100644 public/store.js create mode 100644 public/sw.js create mode 100644 scripts/make-icons.mjs create mode 100644 scripts/serve.mjs create mode 100644 test/api.test.mjs create mode 100644 test/sw.test.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..713d500 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d3689f --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..6556e68 --- /dev/null +++ b/docs/design.md @@ -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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..811505e --- /dev/null +++ b/package.json @@ -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" +} diff --git a/public/api.js b/public/api.js new file mode 100644 index 0000000..0ad71a5 --- /dev/null +++ b/public/api.js @@ -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'; +} diff --git a/public/app.css b/public/app.css new file mode 100644 index 0000000..4f08b7d --- /dev/null +++ b/public/app.css @@ -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); } } } diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..858215d --- /dev/null +++ b/public/app.js @@ -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(); diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..7a06fbbe5c29234d56939614751c23711fc9415e GIT binary patch literal 1260 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;VA<^H;uumf=j}DeJ{flb=fJy1 z#4rC{pSXUDrpGq7cU);I@{Rl7dWL;q{`_|R`}_adqJB=RW0=swu%c}!A^At)p8LK3 z*S9n1%huf%i25!3;OXIfNvG_3CLX!}R|I{&`S1VB$;j4U|4CrkujTjuw=gsxx3A?9 z{(D(=oxcJ!_e9P^_$22e}8$w{4ETV<#V#; z1lK>GR=)S_M<$<|H%A`&OWpR&pPAOmkYD%X$wz;Qdz|?mo7@Gd626Fm;8QZfBqtqPoKiw;Qu_wxeW}P zO6>m@uMv1~R&Yu6%lWCj4Ea3@cisQntY-P~gyWL!7yIT?rkW&{m)~F4^8xL?thnU< z>i?B%#2%ElPpF&!?|D%xgT1u#FWF!3A6PLqtao4tacC`i zEylXKP6c&ZML-9zwGL7g(74e^LhFJi%Ho0|DBEaMtl*$V$O2ETSgV3o z2xBAdl4t@VF5CcWyrKrkWxM1ib7JS`Jo9UQ%rlRFzT`dMdcSkN z_dNF=3k?pMFn-#20KkORtCp_=U<~{_2K<2me~3dZBmkcUuU`J`hC_Mp9)<0Fc5}?~ zhi|`{E*!-&JWi=E$!-s>>RRK}pc$*Fnz6|LB-yRCQ6>ja{W6Q-0MM9E1K_YD04{$4 zKs|#F0K*jkzuN$$e+v^h&Icgo)JKz#efgg`4C(37IabUdRJA6%g5EL6T*_9{EKoLs zbkCHPIKOL+s&y>`M7?#ABd3|e;q|T{>GX1(|0-WpX4wVQ3w3e?5$>EVW{Bc}+H*k; z+d;Vy3hNG_9(P5`c2?Fx;Rp`Zv$EMUzgdvWo(@4SD~IhZDnq{(vt2-Mb%T_>SX7pn z!=4Fsx1aM650ySGm+kEchAY-{k(nyJiHA5dfJL`UuAPkGZ90Ai4J3Y}AlkbksV-J| z3{azUvf1&XGQ0*>?lC8aolu9i5+kud&6&zW{2Q>{q~knb@VzV7=HE|TD9&^Nr&Qy- zxhQ>5)k;Lffk4u|+>3I5Oa|+X$7t#$)Mip#ZI+d2r_j@5QyU0jY&UBy;>`d>M32sN z)Qswh_Bi0TDl114l1EjPXs6QC=cPvDLs#AsI^%Jg`j6srLKs;aN}iYpTne*tC4z)- zF2ZC0Ix(U%CEZwu*W3h#BxNPhu=M#B-24I55u;06<8bCJV2G)xu-M)<8u>PHelwWe zIc^`7Qn&w!S=3kIJ|dH(^wpnd3mwy^Nv5L!!dyo^vZ zkMcMPq-7r1J-ja}Eu(60pF(f4R8GYr?%J9bGXjs5c)W3&#p5&>(cA$+D}k0X0e68d z<|$kg-i;_mMtvGR&bxqFmP1vXdcIV$m;YBzhr{=d>7Pr~ZTam?9!FpvDMl0<|4t<9 z@53UGil}n|MFf*oG}L-;PXMX#EI!~mk=0zq<>T|e9`ipY)u{vp+=J~m%AXhLeYKl+G0;8$?aU96rjQV{p zM-*Z38S9ZS@|^z}T4>xz*Q#vEs~s10+ILZ#N;D&4S>s302D0&*ulMB~JRXD6cmCMZC8=(u4g zx*&U#m3icKR(8>et`bh`B1A&hV)>M zTyt1EA51)O3-U)WcFx7wZ4?b#8t@{kZL%Y z3EGeLek_%X939RIBuECi^9mH^U{UBeTj!HY2`aNzckV)oHZ2L-KRNCHD zF>Wf%u||@z7?>(vVZE2_MQyEp_*9NL*i)VD(ee~JENJqzt>iVkNs+=@o;{b6EqI{i zq0{XJFKI*0^vh9aYz_oBZn4mbuC$6;){~=s6@&0{v)A`i zC1?799BZbiY^xk|vS%vp(XJ57QBQ1l2tIVg9tJ0wVSLAv8+H#HI7Q{^^wZ6x{BT(N_T20@p* zocUhV1Ts82n%o)(S~gRKtFc_GySNngFW7ER8H$k{>mqSkl^3-QF3h1`7YA|EN>!~! zsiYp(K2;*p2_r`We59&msL!*|v)R8U4m9cnK8(PVkYmqidRj0n4re*j3s&+x1CDDn zf>hJ*>PPP_Xo5HP<{B4Mz*t}Hx=6^5(?Xa1BK^4Vu^%6g!~g9x0Kb2Hd}aTye5ZI| aqd6tVNIR-O&4nLW!0Hvj%PYT&`Qe`nH#;!^ literal 0 HcmV?d00001 diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..07e162c --- /dev/null +++ b/public/icon.svg @@ -0,0 +1 @@ + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5e9a7aa --- /dev/null +++ b/public/index.html @@ -0,0 +1,18 @@ + + + + + + + +Sandbar + + + + + + +
+ + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..9572cbb --- /dev/null +++ b/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/public/store.js b/public/store.js new file mode 100644 index 0000000..413f2af --- /dev/null +++ b/public/store.js @@ -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); diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..4f5b19d --- /dev/null +++ b/public/sw.js @@ -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', + }); + })(), + ); +}); diff --git a/scripts/make-icons.mjs b/scripts/make-icons.mjs new file mode 100644 index 0000000..d9a9ea9 --- /dev/null +++ b/scripts/make-icons.mjs @@ -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'); diff --git a/scripts/serve.mjs b/scripts/serve.mjs new file mode 100644 index 0000000..214149f --- /dev/null +++ b/scripts/serve.mjs @@ -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}`)); diff --git a/test/api.test.mjs b/test/api.test.mjs new file mode 100644 index 0000000..365746b --- /dev/null +++ b/test/api.test.mjs @@ -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]); +}); diff --git a/test/sw.test.mjs b/test/sw.test.mjs new file mode 100644 index 0000000..afddaa2 --- /dev/null +++ b/test/sw.test.mjs @@ -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'); +});