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 || 'Remora') + (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 Remora 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', {}, 'Remora'), 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 Remora 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', {}, 'Remora'), 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();