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
+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',
|
||||
});
|
||||
})(),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user