388 lines
21 KiB
JavaScript
388 lines
21 KiB
JavaScript
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();
|