Add the Sandbar mobile companion PWA
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ad165ef6a5
18 files changed
+1143
No files matched your search
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.env
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
@@ -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
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
});
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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}`));
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user