Add the Sandbar mobile companion PWA

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

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
// Sandbar API client. Pure functions over fetch, so it runs in the page, the service worker and tests.
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
export function normalizeServer(input) {
let s = String(input || '').trim();
if (!s) throw new Error('Enter the address of your Sandbar server.');
if (!/^https?:\/\//i.test(s)) s = (/^(localhost|127\.|\[::1\])/.test(s) ? 'http://' : 'https://') + s;
const u = new URL(s);
return u.origin;
}
export class Client {
constructor(server, token, fetchImpl) {
this.server = server;
this.token = token;
this.fetch = fetchImpl || ((...a) => fetch(...a));
}
async req(method, path, body) {
const headers = {};
if (this.token) headers.Authorization = 'Bearer ' + this.token;
if (body !== undefined) headers['Content-Type'] = 'application/json';
let res;
try {
res = await this.fetch(this.server + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
} catch {
throw new ApiError(0, 'Could not reach ' + this.server + '. Check the address and your connection.');
}
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
if (!res.ok) throw new ApiError(res.status, (data && data.error) || 'The server answered ' + res.status + '.');
return data;
}
get(p) { return this.req('GET', p); }
post(p, b) { return this.req('POST', p, b === undefined ? {} : b); }
del(p) { return this.req('DELETE', p); }
static async login(server, email, password, device, fetchImpl) {
const c = new Client(server, '', fetchImpl);
const r = await c.post('/api/v1/mobile/login', { email, password, device });
return { token: r.token, keyId: r.keyId, user: r.user };
}
alerts(status) { return this.get('/api/v1/alerts?limit=100' + (status ? '&status=' + status : '')); }
ack(id) { return this.post('/api/v1/alerts/' + id + '/ack'); }
snooze(id, minutes) { return this.post('/api/v1/alerts/' + id + '/snooze', { minutes }); }
escalate(id) { return this.post('/api/v1/alerts/' + id + '/escalate'); }
resolve(id) { return this.post('/api/v1/alerts/' + id + '/resolve'); }
alert(id) { return this.get('/api/v1/alerts/' + id); }
onCall() { return this.get('/api/v1/oncall'); }
monitors() { return this.get('/api/v1/monitors'); }
monitorStates() { return this.get('/api/v1/monitor-states'); }
edges() { return this.get('/api/v1/hammerhead/edges'); }
messages(topic, since) { return this.get('/api/v1/topics/' + encodeURIComponent(topic) + '/messages?since=' + (since || 0)); }
publish(topic, message, title, priority) {
const headers = { 'Content-Type': 'text/plain' };
if (this.token) headers.Authorization = 'Bearer ' + this.token;
if (title) headers.Title = title;
if (priority) headers.Priority = String(priority);
return this.fetch(this.server + '/' + encodeURIComponent(topic), { method: 'POST', headers, body: message });
}
pushKey() { return this.get('/api/v1/push/key'); }
pushSubs() { return this.get('/api/v1/push/subscriptions'); }
savePush(sub, topics, device) {
const j = sub.toJSON ? sub.toJSON() : sub;
return this.post('/api/v1/push/subscriptions', { endpoint: j.endpoint, keys: j.keys, topics, device });
}
deletePush(id) { return this.del('/api/v1/push/subscriptions/' + id); }
testPush() { return this.post('/api/v1/push/test'); }
logout(keyId) { return keyId ? this.del('/api/v1/apikeys/' + keyId) : Promise.resolve(); }
// Streams a topic as newline-delimited JSON until the signal aborts, calling onMessage for each
// message. Resolves when the stream ends, so the caller decides whether to reconnect.
async stream(topic, since, onMessage, signal) {
const headers = this.token ? { Authorization: 'Bearer ' + this.token } : {};
const res = await this.fetch(this.server + '/' + encodeURIComponent(topic) + '/json?since=' + encodeURIComponent(since || 'all'), { headers, signal });
if (!res.ok) throw new ApiError(res.status, 'Could not follow ' + topic + '.');
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
for (;;) {
const { done, value } = await reader.read();
if (done) return;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line) continue;
try {
const m = JSON.parse(line);
if (m.event === 'message') onMessage(m);
} catch { /* skip a bad line */ }
}
}
}
}
export function urlBase64ToUint8Array(b64) {
const pad = '='.repeat((4 - (b64.length % 4)) % 4);
const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
export function ago(sec, now = Date.now() / 1000) {
const d = Math.max(0, Math.round(now - sec));
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d / 60) + ' min ago';
if (d < 86400) return Math.floor(d / 3600) + ' h ago';
return Math.floor(d / 86400) + ' d ago';
}
+65
View File
@@ -0,0 +1,65 @@
/* Tokens follow the Lightwell language Sandbar uses: quiet neutrals, one teal accent. */
:root {
--bg: #f6f7f7; --surface: #fff; --text: #1b2121; --muted: #5b6666; --line: #dde2e2;
--accent: #1f5c5c; --accent-text: #fff; --accent-soft: #e3efef;
--ok: #1d6b3a; --ok-soft: #e1f2e7; --warn: #8a5a00; --warn-soft: #fbefd5; --bad: #a3231f; --bad-soft: #fbe4e2;
--r: 6px; --r2: 10px; --shadow: 0 1px 2px rgb(0 0 0 / .08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #101515; --surface: #182020; --text: #e8eeee; --muted: #9db0b0; --line: #2a3636;
--accent: #5fb3b3; --accent-text: #0b1414; --accent-soft: #1d3434;
--ok: #6fd193; --ok-soft: #173424; --warn: #e6b957; --warn-soft: #3a2e12; --bad: #ff8d86; --bad-soft: #3d1d1b;
}
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
#app { min-height: 100dvh; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); }
header.top { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--accent); color: var(--accent-text); }
header.top h1 { font-size: 18px; margin: 0; font-weight: 600; }
header.top .sub { font-size: 12px; opacity: .85; }
main { flex: 1; width: 100%; max-width: 640px; margin: 0 auto; padding: 16px; }
nav.tabs { position: sticky; bottom: 0; display: grid; grid-template-columns: repeat(5, 1fr); background: var(--surface); border-top: 1px solid var(--line); padding-bottom: env(safe-area-inset-bottom); }
nav.tabs a { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 8px 2px; min-height: 52px; color: var(--muted); text-decoration: none; font-size: 12px; }
nav.tabs a[aria-current="page"] { color: var(--accent); font-weight: 600; box-shadow: inset 0 2px 0 var(--accent); }
nav.tabs svg { width: 22px; height: 22px; }
.badge-count { background: var(--bad); color: #fff; border-radius: 9px; font-size: 11px; padding: 0 6px; margin-left: 4px; }
h2 { font-size: 20px; margin: 4px 0 12px; }
h3 { font-size: 14px; margin: 20px 0 8px; color: var(--muted); font-weight: 600; text-transform: none; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r2); box-shadow: var(--shadow); padding: 14px; margin-bottom: 12px; }
.card.flush { padding: 0; }
.row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.row:last-child { border-bottom: 0; }
.row .grow { flex: 1; min-width: 0; }
.row .title { font-weight: 600; overflow-wrap: anywhere; }
.row .meta { font-size: 13px; color: var(--muted); overflow-wrap: anywhere; }
.badge { display: inline-block; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); white-space: nowrap; }
.badge.ok { background: var(--ok-soft); color: var(--ok); }
.badge.warn { background: var(--warn-soft); color: var(--warn); }
.badge.bad { background: var(--bad-soft); color: var(--bad); }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
button, .btn { font: inherit; min-height: 44px; padding: 0 16px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer; }
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); font-weight: 600; }
button.danger { color: var(--bad); }
button:disabled { opacity: .55; cursor: default; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
label { display: block; font-size: 14px; font-weight: 600; margin: 12px 0 4px; }
input, select, textarea { width: 100%; font: inherit; min-height: 44px; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--r); background: var(--surface); color: var(--text); }
.hint { font-size: 13px; color: var(--muted); margin-top: 4px; }
.empty { color: var(--muted); text-align: center; padding: 28px 12px; }
.error { background: var(--bad-soft); color: var(--bad); padding: 10px 12px; border-radius: var(--r); margin: 12px 0; }
.notice { background: var(--warn-soft); color: var(--warn); padding: 10px 12px; border-radius: var(--r); margin: 12px 0; }
.toast { position: fixed; left: 50%; bottom: 80px; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: 10px 16px; border-radius: var(--r); max-width: 90vw; z-index: 10; }
.login { max-width: 420px; margin: 0 auto; padding: 32px 16px; }
.login h1 { font-size: 28px; margin: 0 0 4px; }
.msg { padding: 10px 14px; border-bottom: 1px solid var(--line); }
.msg:last-child { border-bottom: 0; }
.msg .t { font-weight: 600; overflow-wrap: anywhere; }
.msg .b { white-space: pre-wrap; overflow-wrap: anywhere; }
.msg .m { font-size: 12px; color: var(--muted); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { display: inline-flex; align-items: center; gap: 6px; background: var(--accent-soft); color: var(--accent); border-radius: 999px; padding: 4px 10px; font-size: 14px; }
.chip button { min-height: 24px; padding: 0 4px; border: 0; background: none; color: inherit; }
a { color: var(--accent); }
@media (prefers-reduced-motion: no-preference) { .toast { animation: up .2s ease-out; } @keyframes up { from { opacity: 0; transform: translate(-50%, 8px); } } }
+387
View File
@@ -0,0 +1,387 @@
import { Client, ApiError, normalizeServer, urlBase64ToUint8Array, ago } from './api.js';
import { getSession, setSession, getTopics, setTopics } from './store.js';
const app = document.getElementById('app');
const TABS = [
['alerts', 'Alerts', 'M12 3a6 6 0 0 0-6 6c0 5-2 6-2 7h16c0-1-2-2-2-7a6 6 0 0 0-6-6zM10 20a2 2 0 0 0 4 0'],
['topics', 'Topics', 'M4 5h16v11H8l-4 4zM8 9h8M8 12h5'],
['monitors', 'Monitors', 'M3 12h4l3-7 4 14 3-7h4'],
['status', 'Status', 'M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6zM9 12l2 2 4-4'],
['settings', 'Settings', 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19 12l2-1-2-4-2 1-2-1-1-2h-4l-1 2-2 1-2-1-2 4 2 1v2l-2 1 2 4 2-1 2 1 1 2h4l1-2 2-1 2 1 2-4-2-1z'],
];
let session = null;
let client = null;
let route = 'alerts';
let topics = ['alerts'];
let streams = new Map();
let pollTimer = null;
let data = { alerts: null, monitors: null, states: null, onCall: null, edges: null, subs: null, messages: {}, error: '' };
const live = {}; // topic -> messages seen since the app opened
// ---------- tiny DOM helper ----------
function h(tag, attrs, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else el.setAttribute(k, v === true ? '' : v);
}
for (const kid of kids.flat()) if (kid != null && kid !== false) el.append(kid.nodeType ? kid : document.createTextNode(String(kid)));
return el;
}
const svg = (d) => {
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor');
s.setAttribute('stroke-width', '1.75'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round'); s.setAttribute('aria-hidden', 'true');
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p.setAttribute('d', d); s.append(p);
return s;
};
function toast(text) {
const t = h('div', { class: 'toast', role: 'status' }, text);
document.body.append(t);
setTimeout(() => t.remove(), 3500);
}
function deviceName() {
const ua = navigator.userAgent;
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad/.test(ua) ? 'iPhone' : /Windows/.test(ua) ? 'Windows' : /Mac/.test(ua) ? 'Mac' : /Linux/.test(ua) ? 'Linux' : 'device';
const br = /Firefox/.test(ua) ? 'Firefox' : /Edg/.test(ua) ? 'Edge' : /Chrome/.test(ua) ? 'Chrome' : /Safari/.test(ua) ? 'Safari' : 'browser';
return br + ' on ' + os;
}
// ---------- boot ----------
async function boot() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(() => {});
navigator.serviceWorker.addEventListener('message', (e) => onWorkerMessage(e.data));
}
try { session = await getSession(); } catch { session = null; }
if (session) {
client = new Client(session.server, session.token);
topics = await getTopics().catch(() => ['alerts']);
}
route = (location.hash.slice(1).split('/')[0]) || 'alerts';
window.addEventListener('hashchange', () => { route = location.hash.slice(1).split('/')[0] || 'alerts'; render(); load(); });
if (session) startStreams();
render();
if (session) load();
document.addEventListener('visibilitychange', () => { if (!document.hidden && session) load(); });
}
function onWorkerMessage(m) {
if (!m) return;
if (m.type === 'push') {
const p = m.payload || {};
if (document.visibilityState === 'visible') toast((p.title || 'Sandbar') + (p.message ? ': ' + p.message.slice(0, 80) : ''));
load();
} else if (m.type === 'open') {
location.hash = m.alert ? 'alerts' : 'topics';
}
}
// ---------- data ----------
async function load() {
if (!client) return;
clearTimeout(pollTimer);
data.error = '';
try {
if (route === 'alerts') data.alerts = await client.alerts();
else if (route === 'monitors') [data.monitors, data.states] = await Promise.all([client.monitors(), client.monitorStates()]);
else if (route === 'status') [data.onCall, data.edges] = await Promise.all([client.onCall(), client.edges().catch((e) => ({ configured: true, error: e.message, edges: [] }))]);
else if (route === 'settings') data.subs = await client.pushSubs();
else if (route === 'topic') {
const t = decodeURIComponent(location.hash.split('/')[1] || '');
data.messages[t] = await client.messages(t, Math.floor(Date.now() / 1000) - 7 * 86400);
}
if (route === 'alerts' && !data.alerts) data.alerts = [];
} catch (e) {
if (e instanceof ApiError && e.status === 401) return signOut(true);
data.error = e.message;
}
render();
pollTimer = setTimeout(load, 20000); // push and the streams are the fast path; this keeps the screen honest
}
function startStreams() {
for (const [, ctl] of streams) ctl.abort();
streams = new Map();
for (const t of topics) follow(t);
}
function follow(topic) {
const ctl = new AbortController();
streams.set(topic, ctl);
let wait = 1000;
const since = String(Math.floor(Date.now() / 1000));
(async () => {
while (!ctl.signal.aborted) {
try {
await client.stream(topic, since, (m) => {
(live[topic] = live[topic] || []).push(m);
wait = 1000;
if (route === 'topic' && decodeURIComponent(location.hash.split('/')[1] || '') === topic) {
(data.messages[topic] = data.messages[topic] || []).push(m);
}
if (m.alert && route === 'alerts') load();
render();
}, ctl.signal);
} catch (e) {
if (ctl.signal.aborted) return;
if (e instanceof ApiError && e.status === 401) return signOut(true);
}
await new Promise((r) => setTimeout(r, wait));
wait = Math.min(wait * 2, 30000);
}
})();
}
// ---------- actions ----------
async function act(label, fn) {
try {
await fn();
toast(label);
} catch (e) {
toast(e.message);
}
load();
}
async function signOut(expired) {
for (const [, ctl] of streams) ctl.abort();
streams = new Map();
clearTimeout(pollTimer);
if (client && !expired) {
try {
const reg = await navigator.serviceWorker?.ready;
const sub = await reg?.pushManager.getSubscription();
const mine = sub && (await client.pushSubs()).find((s) => s.endpoint === sub.endpoint);
if (mine) await client.deletePush(mine.id);
await sub?.unsubscribe();
await client.logout(session.keyId);
} catch { /* signing out must work even when the server is unreachable */ }
}
await setSession(undefined);
session = null; client = null; data = { alerts: null, monitors: null, states: null, onCall: null, edges: null, subs: null, messages: {}, error: '' };
render();
if (expired) toast('Your session ended. Sign in again.');
}
// Turns push on for this device: asks permission, subscribes, and tells the server which topics to send.
async function enablePush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) throw new Error('This browser cannot receive push notifications. On iPhone, add Sandbar to the Home Screen first.');
const perm = await Notification.requestPermission();
if (perm !== 'granted') throw new Error('Notifications are blocked. Allow them for this site in your browser settings, then try again.');
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
const { publicKey } = await client.pushKey();
sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });
}
await client.savePush(sub, topics, session.device);
}
async function pushState() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return 'unsupported';
if (Notification.permission === 'denied') return 'blocked';
const reg = await navigator.serviceWorker.ready;
return (await reg.pushManager.getSubscription()) ? 'on' : 'off';
}
async function syncTopics() {
await setTopics(topics);
startStreams();
if ((await pushState()) === 'on') {
const reg = await navigator.serviceWorker.ready;
await client.savePush(await reg.pushManager.getSubscription(), topics, session.device);
}
}
// ---------- views ----------
function loginView() {
const err = h('div', { class: 'error', role: 'alert', hidden: true });
const server = h('input', { id: 'server', type: 'url', autocomplete: 'url', inputmode: 'url', placeholder: 'sandbar.example.com', required: true,
value: location.pathname.startsWith('/app') ? location.origin : '' });
const email = h('input', { id: 'email', type: 'email', autocomplete: 'username', required: true });
const pw = h('input', { id: 'pw', type: 'password', autocomplete: 'current-password', required: true });
const btn = h('button', { class: 'primary', type: 'submit' }, 'Sign in');
const form = h('form', { class: 'login' },
h('h1', {}, 'Sandbar'),
h('p', { class: 'hint' }, 'Alerts, on-call and monitors from your own server.'),
err,
h('label', { for: 'server' }, 'Server address'), server,
h('label', { for: 'email' }, 'Email'), email,
h('label', { for: 'pw' }, 'Password'), pw,
h('div', { class: 'actions' }, btn));
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
err.hidden = true; btn.disabled = true; btn.textContent = 'Signing in';
try {
const base = normalizeServer(server.value);
const device = deviceName();
const r = await Client.login(base, email.value, pw.value, device);
session = { server: base, token: r.token, keyId: r.keyId, user: r.user, device };
await setSession(session);
client = new Client(base, r.token);
topics = ['alerts'];
await setTopics(topics);
startStreams();
location.hash = 'alerts';
render();
load();
} catch (e) {
err.textContent = e.message; err.hidden = false; btn.disabled = false; btn.textContent = 'Sign in';
}
});
return form;
}
const sevClass = (s) => (s === 'critical' ? 'bad' : s === 'warning' ? 'warn' : '');
const statusClass = (s) => (s === 'firing' || s === 'down' ? 'bad' : s === 'acked' || s === 'pending' ? 'warn' : 'ok');
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
function alertCard(a) {
const firing = a.status === 'firing';
const acts = [];
if (firing) {
acts.push(h('button', { class: 'primary', onclick: () => act('Acknowledged', () => client.ack(a.id)) }, 'Acknowledge'));
acts.push(h('button', { onclick: () => act('Snoozed for 30 minutes', () => client.snooze(a.id, 30)) }, 'Snooze 30 min'));
if (a.policyId) acts.push(h('button', { onclick: () => act('Escalated to the next step', () => client.escalate(a.id)) }, 'Escalate now'));
}
if (a.status !== 'resolved') {
acts.push(h('button', { class: 'danger', onclick: () => { if (confirm('Resolve "' + a.summary + '"? It will stop paging anyone.')) act('Resolved', () => client.resolve(a.id)); } }, 'Resolve'));
}
return h('div', { class: 'card' },
h('div', {}, h('span', { class: 'badge ' + statusClass(a.status) }, cap(a.status)), ' ', h('span', { class: 'badge ' + sevClass(a.severity) }, cap(a.severity))),
h('div', { class: 'title', style: 'margin-top:8px;font-weight:600' }, a.summary),
h('div', { class: 'meta hint' }, a.source + ' · ' + ago(a.created) + (a.count > 1 ? ' · fired ' + a.count + ' times' : '') +
(a.ackedBy ? ' · acknowledged by ' + a.ackedBy : '') +
(a.snoozeUntil * 1000 > Date.now() && firing ? ' · snoozed until ' + new Date(a.snoozeUntil * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '')),
a.detail ? h('div', { class: 'hint' }, a.detail) : null,
acts.length ? h('div', { class: 'actions' }, acts) : null);
}
function alertsView() {
if (!data.alerts) return h('p', { class: 'empty' }, 'Loading alerts');
const active = data.alerts.filter((a) => a.status !== 'resolved');
const done = data.alerts.filter((a) => a.status === 'resolved').slice(0, 10);
return h('div', {},
h('h2', {}, 'Alerts'),
active.length ? active.map(alertCard) : h('div', { class: 'card empty' }, 'No open alerts. New ones appear here and arrive as notifications.'),
done.length ? [h('h3', {}, 'Recently resolved'), done.map((a) => h('div', { class: 'card flush' }, h('div', { class: 'row' }, h('div', { class: 'grow' }, h('div', { class: 'title' }, a.summary), h('div', { class: 'meta' }, 'Resolved ' + ago(a.resolvedAt || a.updated)))))) ] : null);
}
function topicsView() {
const input = h('input', { id: 'newtopic', placeholder: 'topic name', autocapitalize: 'none', 'aria-label': 'Topic name' });
const add = async (ev) => {
ev.preventDefault();
const t = input.value.trim();
if (!/^[A-Za-z0-9_-]{1,64}$/.test(t)) return toast('A topic is 1 to 64 letters, digits, - or _.');
if (!topics.includes(t)) { topics = [...topics, t]; await syncTopics().catch((e) => toast(e.message)); }
input.value = ''; render();
};
return h('div', {},
h('h2', {}, 'Topics'),
h('div', { class: 'card flush' }, topics.map((t) =>
h('div', { class: 'row' },
h('a', { class: 'grow title', href: '#topic/' + encodeURIComponent(t) }, t, (live[t] || []).length ? h('span', { class: 'badge-count' }, String(live[t].length)) : null),
h('button', { 'aria-label': 'Stop following ' + t, onclick: async () => { topics = topics.filter((x) => x !== t); delete live[t]; await syncTopics().catch((e) => toast(e.message)); render(); } }, 'Unfollow')))),
h('form', { class: 'card', onsubmit: add }, h('label', { for: 'newtopic' }, 'Follow a topic'), input,
h('div', { class: 'hint' }, 'Followed topics send a notification to this device when push is on. Your personal on-call topic is always included.'),
h('div', { class: 'actions' }, h('button', { class: 'primary', type: 'submit' }, 'Follow topic'))));
}
function topicView() {
const t = decodeURIComponent(location.hash.split('/')[1] || '');
const msgs = (data.messages[t] || []).slice().reverse();
live[t] = [];
const body = h('input', { id: 'pub', placeholder: 'Message', 'aria-label': 'Message' });
const send = async (ev) => {
ev.preventDefault();
if (!body.value.trim()) return;
try { const r = await client.publish(t, body.value); if (!r.ok) throw new Error('The server refused the message.'); body.value = ''; toast('Sent'); } catch (e) { toast(e.message); }
};
return h('div', {},
h('p', {}, h('a', { href: '#topics' }, 'Back to topics')),
h('h2', {}, t),
h('div', { class: 'card flush' }, msgs.length ? msgs.map((m) => h('div', { class: 'msg' },
m.title ? h('div', { class: 't' }, m.title) : null, h('div', { class: 'b' }, m.message),
h('div', { class: 'm' }, ago(m.time) + (m.priority >= 4 ? ' · high priority' : '')))) : h('div', { class: 'empty' }, 'Nothing in the last 7 days.')),
h('form', { class: 'card', onsubmit: send }, h('label', { for: 'pub' }, 'Send a message'), body, h('div', { class: 'actions' }, h('button', { type: 'submit' }, 'Send'))));
}
function monitorsView() {
if (!data.monitors) return h('p', { class: 'empty' }, 'Loading monitors');
const st = data.states || {};
const rows = data.monitors.filter((m) => m.enabled !== false).map((m) => ({ m, s: st[m.id] || { status: 'pending' } }));
const order = { down: 0, pending: 1, up: 2 };
rows.sort((a, b) => (order[a.s.status] ?? 1) - (order[b.s.status] ?? 1) || a.m.name.localeCompare(b.m.name));
const down = rows.filter((r) => r.s.status === 'down').length;
return h('div', {},
h('h2', {}, 'Monitors'),
h('p', { class: 'hint' }, rows.length ? (down ? down + ' of ' + rows.length + ' are down.' : 'All ' + rows.length + ' are up.') : ''),
rows.length ? h('div', { class: 'card flush' }, rows.map(({ m, s }) => h('div', { class: 'row' },
h('div', { class: 'grow' }, h('div', { class: 'title' }, m.name), h('div', { class: 'meta' }, m.kind + ' · ' + m.target + (s.lastCheck ? ' · checked ' + ago(s.lastCheck) : '')),
s.status === 'down' && s.detail ? h('div', { class: 'meta' }, s.detail) : null),
h('div', {}, h('span', { class: 'badge ' + statusClass(s.status) }, cap(s.status)), s.uptime30 >= 0 && s.uptime30 != null ? h('div', { class: 'meta', style: 'text-align:right' }, s.uptime30.toFixed(2) + '% in 30 d') : null))))
: h('div', { class: 'card empty' }, 'No monitors yet. Add them in the Sandbar web app.'));
}
function statusView() {
const oc = data.onCall, hh = data.edges;
return h('div', {},
h('h2', {}, 'Status'),
h('h3', {}, 'On call now'),
oc ? (oc.length ? h('div', { class: 'card flush' }, oc.map((r) => h('div', { class: 'row' }, h('div', { class: 'grow' }, h('div', { class: 'title' }, r.user || 'Nobody'), h('div', { class: 'meta' }, r.schedule + (r.next ? ' · next: ' + r.next : '')))))) : h('div', { class: 'card empty' }, 'No schedules yet.')) : h('p', { class: 'empty' }, 'Loading'),
h('h3', {}, 'Hammerhead tunnels'),
!hh ? h('p', { class: 'empty' }, 'Loading')
: hh.error ? h('div', { class: 'error' }, hh.error)
: !hh.configured ? h('div', { class: 'card empty' }, 'Hammerhead is not linked. Set SANDBAR_HAMMERHEAD_URL on the server to see its edges here.')
: hh.edges.length ? h('div', { class: 'card flush' }, hh.edges.map((e) => h('div', { class: 'row' },
h('div', { class: 'grow' }, h('div', { class: 'title' }, e.name), h('div', { class: 'meta' }, (e.ips || []).join(', ') + (e.version ? ' · ' + e.version : ''))),
h('span', { class: 'badge ' + (e.online ? 'ok' : 'bad') }, e.online ? 'Online' : 'Offline'))))
: h('div', { class: 'card empty' }, 'Hammerhead has no edges.'));
}
function settingsView() {
const box = h('div', { id: 'pushbox' }, h('p', { class: 'hint' }, 'Checking'));
pushState().then(async (st) => {
const msg = { unsupported: 'This browser cannot receive push notifications. On iPhone and iPad, add Sandbar to the Home Screen (Share, then Add to Home Screen) and open it from there.',
blocked: 'Notifications are blocked for this site. Allow them in your browser settings, then come back.',
on: 'Push notifications are on for this device.', off: 'Push notifications are off for this device.' }[st];
box.replaceChildren(...[h('p', {}, msg),
st === 'off' ? h('div', { class: 'actions' }, h('button', { class: 'primary', onclick: () => act('Notifications turned on', enablePush) }, 'Turn on notifications')) : null,
st === 'on' ? h('div', { class: 'actions' },
h('button', { onclick: async () => { try { const r = await client.testPush(); const bad = r.find((x) => x.error); toast(bad ? 'Push failed: ' + bad.error : 'Test sent. It should arrive in a moment.'); } catch (e) { toast(e.message); } } }, 'Send a test'),
h('button', { onclick: () => act('Notifications turned off', async () => { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.getSubscription(); const mine = (await client.pushSubs()).find((x) => sub && x.endpoint === sub.endpoint); if (mine) await client.deletePush(mine.id); await sub?.unsubscribe(); }) }, 'Turn off')) : null].filter(Boolean));
});
const subs = data.subs;
return h('div', {},
h('h2', {}, 'Settings'),
h('div', { class: 'card' }, h('div', { class: 'title', style: 'font-weight:600' }, session.user.name), h('div', { class: 'meta hint' }, session.user.email + ' · ' + session.server)),
h('h3', {}, 'Notifications'), h('div', { class: 'card' }, box),
h('h3', {}, 'Your devices'),
subs ? (subs.length ? h('div', { class: 'card flush' }, subs.map((s) => h('div', { class: 'row' }, h('div', { class: 'grow' }, h('div', { class: 'title' }, s.device || 'Device'), h('div', { class: 'meta' }, s.topics.length + ' topics · added ' + ago(s.created))),
h('button', { class: 'danger', 'aria-label': 'Remove ' + (s.device || 'device'), onclick: () => act('Device removed', () => client.deletePush(s.id)) }, 'Remove')))) : h('div', { class: 'card empty' }, 'No devices are registered for push.')) : null,
h('div', { class: 'actions' }, h('button', { class: 'danger', onclick: () => signOut(false) }, 'Sign out')));
}
function render() {
if (!session) { app.replaceChildren(loginView()); return; }
const tab = route === 'topic' ? 'topics' : route;
const firing = (data.alerts || []).filter((a) => a.status === 'firing').length;
const view = { alerts: alertsView, topics: topicsView, topic: topicView, monitors: monitorsView, status: statusView, settings: settingsView }[route] || alertsView;
const focus = document.activeElement && document.activeElement.id;
const keep = focus && ['newtopic', 'pub'].includes(focus) ? document.activeElement.value : null;
app.replaceChildren(
h('header', { class: 'top' }, h('h1', {}, 'Sandbar'), h('span', { class: 'sub' }, session.server.replace(/^https?:\/\//, ''))),
h('main', {}, data.error ? h('div', { class: 'error', role: 'alert' }, data.error) : null, view()),
h('nav', { class: 'tabs', 'aria-label': 'Sections' }, TABS.map(([id, label, d]) =>
h('a', { href: '#' + id, 'aria-current': tab === id ? 'page' : null }, svg(d), h('span', {}, label, id === 'alerts' && firing ? h('span', { class: 'badge-count' }, String(firing)) : null)))));
if (keep !== null) { const el = document.getElementById(focus); if (el) { el.value = keep; el.focus(); } }
}
boot();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

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

After

Width:  |  Height:  |  Size: 337 B

+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#1f5c5c">
<meta name="color-scheme" content="light dark">
<title>Sandbar</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="icon-192.png">
<link rel="stylesheet" href="app.css">
</head>
<body>
<div id="app" aria-live="polite"></div>
<script type="module" src="app.js"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Sandbar",
"short_name": "Sandbar",
"description": "Alerts, on-call and monitors from your Sandbar server.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#101515",
"theme_color": "#1f5c5c",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}
+41
View File
@@ -0,0 +1,41 @@
// Settings shared by the page and the service worker. The service worker cannot read
// localStorage, so the sign-in lives in IndexedDB; the page keeps a copy in localStorage
// only for quick startup and falls back to IndexedDB.
const DB = 'sandbar-mobile';
function open() {
return new Promise((resolve, reject) => {
const r = indexedDB.open(DB, 1);
r.onupgradeneeded = () => r.result.createObjectStore('kv');
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
}
export async function kvGet(key) {
const db = await open();
return new Promise((resolve, reject) => {
const r = db.transaction('kv').objectStore('kv').get(key);
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
}
export async function kvSet(key, value) {
const db = await open();
return new Promise((resolve, reject) => {
const tx = db.transaction('kv', 'readwrite');
if (value === undefined) tx.objectStore('kv').delete(key);
else tx.objectStore('kv').put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// session: { server, token, keyId, user }
export const getSession = () => kvGet('session');
export const setSession = (s) => kvSet('session', s);
// topics: the topics this device follows
export async function getTopics() { return (await kvGet('topics')) || ['alerts']; }
export const setTopics = (t) => kvSet('topics', t);
+139
View File
@@ -0,0 +1,139 @@
// Service worker: shows push notifications and handles their buttons without opening the app.
// Classic script, not a module: Firefox does not run module service workers. It reads the
// sign-in that app.js saved in IndexedDB (see store.js) and calls the API directly.
function kvGet(key) {
return new Promise((resolve, reject) => {
const r = indexedDB.open('sandbar-mobile', 1);
r.onupgradeneeded = () => r.result.createObjectStore('kv');
r.onerror = () => reject(r.error);
r.onsuccess = () => {
const g = r.result.transaction('kv').objectStore('kv').get(key);
g.onsuccess = () => resolve(g.result);
g.onerror = () => reject(g.error);
};
});
}
async function api(session, method, path, body) {
const res = await fetch(session.server + path, {
method,
headers: { Authorization: 'Bearer ' + session.token, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error((data && data.error) || 'The server answered ' + res.status + '.');
return data;
}
function keyBytes(b64) {
const pad = '='.repeat((4 - (b64.length % 4)) % 4);
return Uint8Array.from(atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
}
const SHELL = 'sandbar-shell-v1';
const FILES = ['./', 'index.html', 'app.css', 'app.js', 'api.js', 'store.js', 'manifest.webmanifest', 'icon.svg'];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k)))).then(() => self.clients.claim()),
);
});
// The app shell loads from the cache first so it opens offline; API calls always go to the network.
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.origin !== self.location.origin || !url.pathname.startsWith(new URL(self.registration.scope).pathname)) return;
e.respondWith(
fetch(e.request)
.then((res) => {
if (res.ok) caches.open(SHELL).then((c) => c.put(e.request, res.clone()));
return res;
})
.catch(() => caches.match(e.request).then((r) => r || caches.match('./'))),
);
});
self.addEventListener('push', (e) => {
let p;
try { p = e.data.json(); } catch { p = { title: 'Sandbar', message: e.data ? e.data.text() : '' }; }
e.waitUntil(show(p));
});
async function show(p) {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const c of clients) c.postMessage({ type: 'push', payload: p });
const tag = p.alert || p.id || undefined;
if (p.status === 'resolved' || p.status === 'acked') {
// The alert moved on: take down its earlier notification, and say so quietly.
for (const n of await self.registration.getNotifications({ tag })) n.close();
}
if (clients.some((c) => c.visibilityState === 'visible')) return; // the open app shows it itself
const firing = !!p.alert && p.status === 'firing';
const options = {
body: p.message || '',
tag,
renotify: !!tag,
data: { topic: p.topic, alert: p.alert, id: p.id },
requireInteraction: (p.priority || 3) >= 4,
icon: 'icon-192.png',
badge: 'icon-192.png',
timestamp: (p.time || Date.now() / 1000) * 1000,
actions: firing ? [{ action: 'ack', title: 'Acknowledge' }, { action: 'snooze', title: 'Snooze 30 min' }] : [],
};
await self.registration.showNotification(p.title || 'Sandbar', options);
}
self.addEventListener('notificationclick', (e) => {
const n = e.notification;
const { alert } = n.data || {};
n.close();
e.waitUntil(
(async () => {
if (alert && (e.action === 'ack' || e.action === 'snooze')) {
const s = await kvGet('session');
if (s) {
try {
if (e.action === 'ack') await api(s, 'POST', '/api/v1/alerts/' + alert + '/ack', {});
else await api(s, 'POST', '/api/v1/alerts/' + alert + '/snooze', { minutes: 30 });
return;
} catch (err) {
await self.registration.showNotification('Could not ' + (e.action === 'ack' ? 'acknowledge' : 'snooze') + ' the alert', {
body: err.message + ' Open Sandbar to try again.', tag: 'err-' + alert, data: { alert },
});
return;
}
}
}
const target = new URL(alert ? '#alerts' : '#topics', self.registration.scope).href;
const wins = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const win = wins.find((w) => w.url.startsWith(self.registration.scope));
if (win) {
await win.focus();
win.postMessage({ type: 'open', alert, topic: (n.data || {}).topic });
} else {
await self.clients.openWindow(target);
}
})(),
);
});
// Browsers rotate push subscriptions now and then. Re-register the new one, or alerts silently stop.
self.addEventListener('pushsubscriptionchange', (e) => {
e.waitUntil(
(async () => {
const s = await kvGet('session');
if (!s) return;
const { publicKey } = await api(s, 'GET', '/api/v1/push/key');
const sub = await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(publicKey) });
const j = sub.toJSON();
await api(s, 'POST', '/api/v1/push/subscriptions', {
endpoint: j.endpoint, keys: j.keys, topics: (await kvGet('topics')) || ['alerts'], device: s.device || 'phone',
});
})(),
);
});