Files
VeillaxandClaude Sonnet 5.5 cf4d5b3ce2 Turn the status page into a full status page generator
Components can be put in groups and given public names. Public incidents carry
posts written for customers (the internal timeline stays internal), the
components they affect, and a page for each incident plus paged history.
Scheduled maintenance has its own window, shows affected components as under
maintenance, and is announced when scheduled, started, ended or canceled.

Branding (title, logo, accent checked for contrast, contact link, footer) and a
custom domain that serves only the status page. Visitors follow by RSS, by
confirmed email, or by webhook that must answer a test request. Admin UI for
all of it, tests, README and design notes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 17:05:38 -06:00

374 lines
23 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>__TITLE__</title>
<link rel="alternate" type="application/rss+xml" title="__TITLE__" href="__BASE__/feed.rss">
<style>
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root {
--accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:6px; --radius-lg:10px; --border-w:1px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--content-max:720px;
color-scheme:light;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
color-scheme:dark;
} }
/* tokens:end */
*, *::before, *::after { box-sizing:border-box; }
[hidden] { display:none !important; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; }
h1, h2, h3 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
h1 { font-size:var(--text-2xl); } h2 { font-size:var(--text-lg); margin:var(--s6) 0 var(--s3); } h3 { font-size:var(--text-base); }
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s4) var(--s5); }
footer { max-width:var(--content-max); margin:0 auto; padding:0 var(--s4) var(--s8); color:var(--text-muted); font-size:var(--text-sm); }
footer .links { display:flex; gap:var(--s4); flex-wrap:wrap; margin-bottom:var(--s2); }
a { color:var(--accent-text); }
p { margin:0; }
.dim { color:var(--text-muted); } .small { font-size:var(--text-sm); } .num { font-variant-numeric:tabular-nums; }
.head { display:flex; align-items:center; justify-content:space-between; gap:var(--s4); flex-wrap:wrap; }
.brand { display:flex; align-items:center; gap:var(--s3); min-width:0; }
.brand img { max-height:40px; max-width:160px; object-fit:contain; }
.brand a { color:inherit; text-decoration:none; }
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
.btn, .ghost { display:inline-flex; align-items:center; justify-content:center; min-height:36px; padding:var(--s2) var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; text-decoration:none; cursor:pointer; border:var(--border-w) solid transparent; }
.btn { background:var(--accent); color:var(--accent-contrast); }
.ghost { background:var(--surface); color:var(--text); border-color:var(--border-strong); }
.btn:hover { filter:brightness(1.1); } .ghost:hover { background:var(--surface-2); }
.desc { margin-top:var(--s2); color:var(--text-muted); }
.banner { margin-top:var(--s4); padding:var(--s4) var(--s5); border-radius:var(--radius-lg); border:var(--border-w) solid var(--border); font-weight:600; background:var(--surface-2); }
.banner.operational { background:var(--success-bg); color:var(--success); border-color:var(--success); }
.banner.degraded, .banner.maintenance { background:var(--warning-bg); color:var(--warning); border-color:var(--warning); }
.banner.outage { background:var(--danger-bg); color:var(--danger); border-color:var(--danger); }
.notice { margin-top:var(--s4); padding:var(--s3) var(--s4); border-radius:var(--radius); border:var(--border-w) solid var(--border-strong); background:var(--surface); display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; }
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); overflow:hidden; }
.card + .card { margin-top:var(--s3); }
.row { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); }
.row:last-child { border-bottom:0; }
.ghead { display:flex; align-items:baseline; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; padding:var(--s3) var(--s4); background:var(--surface-2); border-bottom:var(--border-w) solid var(--border); }
.svc { padding:var(--s3) var(--s4) var(--s4); border-bottom:var(--border-w) solid var(--border); }
.svc:last-child { border-bottom:0; }
.svc .top { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; }
.bars { display:flex; gap:2px; height:32px; margin-top:var(--s3); }
.bar { flex:1; min-width:0; border-radius:2px; background:var(--neutral-bg); }
.bar.ok { background:var(--success); } .bar.warn { background:var(--warning); } .bar.bad { background:var(--danger); }
.bar:hover { opacity:.7; }
.axis { display:flex; justify-content:space-between; margin-top:var(--s1); font-size:var(--text-xs); color:var(--text-faint); }
.tip { position:fixed; z-index:10; pointer-events:none; max-width:240px; padding:var(--s2) var(--s3); border-radius:var(--radius); background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); font-size:var(--text-sm); }
.tip strong { display:block; }
@media (max-width:480px) { .bars { gap:1px; } }
.badge { display:inline-flex; align-items:center; padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; }
.badge-success { background:var(--success-bg); color:var(--success); }
.badge-warning { background:var(--warning-bg); color:var(--warning); }
.badge-danger { background:var(--danger-bg); color:var(--danger); }
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
.inc { padding:var(--s4); border-bottom:var(--border-w) solid var(--border); }
.inc:last-child { border-bottom:0; }
.inc h3 a { color:inherit; }
.posts { list-style:none; margin:var(--s3) 0 0; padding:0 0 0 var(--s4); border-left:2px solid var(--border); display:flex; flex-direction:column; gap:var(--s3); }
.posts p { white-space:pre-wrap; }
.month { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); margin:var(--s5) 0 var(--s2); }
.sub { padding:var(--s4); display:grid; gap:var(--s4); }
.sub form { display:flex; gap:var(--s2); flex-wrap:wrap; align-items:flex-end; }
.sub label { display:flex; flex-direction:column; gap:var(--s1); flex:1; min-width:200px; font-size:var(--text-sm); font-weight:500; }
input { font:inherit; min-height:36px; padding:var(--s2) var(--s3); border-radius:var(--radius); border:var(--border-w) solid var(--border-strong); background:var(--surface); color:var(--text); }
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:var(--text-sm); word-break:break-all; }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
@media (pointer:coarse) { .btn, .ghost, input { min-height:44px; } input { font-size:16px; } }
@media (prefers-reduced-motion:no-preference) { .btn, .ghost { transition:background-color .12s, filter .12s; } }
</style>
</head>
<body>
<div class="tip" id="tip" role="presentation" hidden></div>
<main id="main" tabindex="-1">
<div class="head">
<div class="brand" id="brand"></div>
<div class="actions" id="actions"></div>
</div>
<p class="desc" id="desc" hidden></p>
<div id="notice"></div>
<div id="view" aria-live="polite"><div class="banner" role="status">Loading</div></div>
</main>
<footer id="foot"></footer>
<script>
const BASE = '__BASE__';
const $ = id => document.getElementById(id);
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
const link = (href, text, cls) => { const a = el('a', cls, text); a.href = href; return a; };
const home = BASE || '/';
const when = t => new Date(t * 1000).toLocaleString(undefined, {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'});
const monthOf = t => new Date(t * 1000).toLocaleDateString(undefined, {month:'long', year:'numeric'});
const overallLabel = { operational: 'All systems are operational', degraded: 'Some systems are affected', outage: 'There is a service outage', maintenance: 'Scheduled maintenance is in progress' };
const stateBadge = {
operational: ['Operational', 'success'], degraded: ['Degraded performance', 'warning'], partial_outage: ['Partial outage', 'warning'],
major_outage: ['Major outage', 'danger'], maintenance: ['Under maintenance', 'accent'], pending: ['Waiting for first check', ''],
};
const badge = (text, tone) => el('span', 'badge' + (tone ? ' badge-' + tone : ''), text);
const incLabel = { investigating: 'Investigating', identified: 'Identified', monitoring: 'Monitoring', resolved: 'Resolved' };
const incTone = s => s === 'resolved' ? 'success' : s === 'monitoring' ? 'accent' : 'warning';
// One bar per UTC day, oldest first. A day is green with no failed checks, amber when at least 99% passed,
// and red below that. Color is never the only signal: the tooltip and the label say it in words.
const dayFmt = new Intl.DateTimeFormat(undefined, {month:'short', day:'numeric', year:'numeric', timeZone:'UTC'});
const dayClass = ([c, f]) => !c ? '' : !f ? 'ok' : (c - f) / c >= 0.99 ? 'warn' : 'bad';
function dayText([c, f], i, n, today) {
const d = new Date(today + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() - (n - 1 - i));
if (!c) return [dayFmt.format(d), 'No data'];
const up = ((c - f) * 100 / c).toFixed(2) + '% uptime';
return [dayFmt.format(d), f ? up + ', ' + f.toLocaleString() + ' failed of ' + c.toLocaleString() + ' checks' : up];
}
const tip = $('tip');
function showTip(e, lines) {
tip.replaceChildren(el('strong', '', lines[0]), document.createTextNode(lines[1]));
tip.hidden = false;
const w = tip.offsetWidth, x = Math.min(Math.max(8, e.clientX - w / 2), innerWidth - w - 8);
tip.style.left = x + 'px'; tip.style.top = Math.max(8, e.clientY - tip.offsetHeight - 14) + 'px';
}
function component(m, today) {
const box = el('div', 'svc'), top = el('div', 'top');
top.append(el('span', '', m.name));
const right = el('span', 'small dim num'), b = stateBadge[m.state] || stateBadge.pending;
right.append(badge(b[0], b[1]), document.createTextNode(m.uptime >= 0 ? ' ' + m.uptime.toFixed(2) + '% uptime' : ''));
top.append(right); box.append(top);
const days = m.days || [], bars = el('div', 'bars');
const bad = days.filter(d => dayClass(d) === 'bad').length, warn = days.filter(d => dayClass(d) === 'warn').length;
bars.setAttribute('role', 'img');
bars.setAttribute('aria-label', m.name + ', the last ' + days.length + ' days: ' + (m.uptime >= 0 ? m.uptime.toFixed(2) + '% uptime' : 'no checks yet') +
(bad || warn ? ', ' + bad + ' days with an outage and ' + warn + ' with short failures' : ', no failures'));
days.forEach((d, i) => {
const s = el('span', 'bar ' + dayClass(d));
const lines = dayText(d, i, days.length, today);
s.addEventListener('pointerenter', e => showTip(e, lines));
s.addEventListener('pointermove', e => showTip(e, lines));
s.addEventListener('pointerleave', () => { tip.hidden = true; });
bars.append(s);
});
const axis = el('div', 'axis'); axis.setAttribute('aria-hidden', 'true');
axis.append(el('span', '', days.length + ' days ago'), el('span', '', 'Today'));
if (days.length) box.append(bars, axis);
return box;
}
const worst = ['operational', 'pending', 'maintenance', 'degraded', 'partial_outage', 'major_outage'];
function groups(d) {
const out = [], byId = {};
const open = (id, g) => { byId[id] = { g, items: [] }; out.push(byId[id]); };
open('', null);
for (const g of d.groups) open(g.id, g);
for (const m of d.monitors) (byId[m.group] || byId['']).items.push(m);
return out.filter(x => x.items.length);
}
function services(d) {
const wrap = el('div');
if (!d.monitors.length) { const c = el('div', 'card'); c.append(el('div', 'row dim', 'No services are published yet.')); wrap.append(c); return wrap; }
for (const { g, items } of groups(d)) {
const card = el('div', 'card');
if (g) {
const head = el('div', 'ghead'), left = el('div');
left.append(el('h3', '', g.name));
if (g.description) left.append(el('div', 'small dim', g.description));
const w = items.map(i => i.state).reduce((a, s) => worst.indexOf(s) > worst.indexOf(a) ? s : a, 'operational'), b = stateBadge[w];
head.append(left, badge(b[0], b[1])); card.append(head);
}
for (const m of items) card.append(component(m, d.today));
wrap.append(card);
}
return wrap;
}
function posts(list) {
const ul = el('ul', 'posts');
for (const u of list) {
const li = el('li'), head = el('div', 'small');
head.append(el('strong', '', incLabel[u.status] || 'Update'), el('span', 'dim num', ' · ' + when(u.time)));
li.append(head, el('p', '', u.text)); ul.append(li);
}
return ul;
}
function incident(i, full) {
const b = el('div', 'inc'), head = el('h3');
head.append(full ? document.createTextNode(i.title) : link(BASE + '/incidents/' + i.id, i.title), document.createTextNode(' '), badge(incLabel[i.status] || i.status, incTone(i.status)));
b.append(head);
b.append(el('div', 'small dim', 'Started ' + when(i.created) + (i.resolved ? ', resolved ' + when(i.resolved) : '') + (i.components.length ? ' · Affects ' + i.components.join(', ') : '')));
b.append(posts(i.updates));
return b;
}
function maintenance(m) {
const b = el('div', 'inc'), head = el('h3');
head.append(document.createTextNode(m.title + ' '), m.state === 'in_progress' ? badge('In progress', 'accent') : m.state === 'scheduled' ? badge('Scheduled', '') : badge('Completed', 'success'));
b.append(head, el('div', 'small dim', when(m.start) + ' to ' + when(m.end) + (m.components.length ? ' · Affects ' + m.components.join(', ') : '')));
if (m.message) { const p = el('p', '', m.message); p.style.whiteSpace = 'pre-wrap'; p.style.marginTop = 'var(--s2)'; b.append(p); }
return b;
}
// ---------- branding, header, footer ----------
function brand(d) {
const s = d.settings;
if (s.accent) { document.documentElement.style.setProperty('--accent', s.accent); }
const b = $('brand'); b.replaceChildren();
if (s.logoUrl) { const img = el('img'); img.src = s.logoUrl; img.alt = ''; img.referrerPolicy = 'no-referrer'; img.addEventListener('error', () => img.remove()); b.append(img); }
const h = el('h1'); h.append(link(home, s.title)); b.append(h);
const desc = $('desc'); desc.textContent = s.description || ''; desc.hidden = !s.description;
const a = $('actions'); a.replaceChildren();
const sub = el('button', 'btn', 'Subscribe to updates'); sub.type = 'button'; sub.setAttribute('aria-expanded', 'false'); sub.setAttribute('aria-controls', 'subpanel');
sub.addEventListener('click', () => { const p = $('subpanel'); if (!p) return location.assign(home + '#subscribe'); p.hidden = !p.hidden; sub.setAttribute('aria-expanded', String(!p.hidden)); if (!p.hidden) p.querySelector('input').focus(); });
a.append(sub);
if (s.supportUrl) a.append(link(s.supportUrl, 'Contact us', 'ghost'));
const f = $('foot'); f.replaceChildren();
const links = el('div', 'links');
links.append(link(BASE + '/history', 'Incident history'), link(BASE + '/feed.rss', 'RSS feed'));
f.append(links);
if (s.footer) f.append(el('p', '', s.footer));
}
// ---------- subscribe ----------
async function post(path, body) {
const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || 'Something went wrong. Try again in a moment.');
return j;
}
function subscribePanel(d) {
const p = el('div', 'card sub'); p.id = 'subpanel'; p.hidden = location.hash !== '#subscribe';
p.append(el('h3', '', 'Get notified when something changes'));
const msg = el('p', 'small'); msg.setAttribute('role', 'status');
const form = (kind, label, type, hint, button) => {
const f = el('form'), l = el('label', '', label), i = el('input');
i.type = type; i.required = true; i.name = kind; i.autocomplete = kind === 'email' ? 'email' : 'off';
if (kind === 'webhook') i.placeholder = 'https://example.com/hooks/status';
l.append(i); f.append(l);
const b = el('button', 'btn', button); b.type = 'submit'; f.append(b);
f.addEventListener('submit', async e => {
e.preventDefault(); b.disabled = true; msg.textContent = '';
try { const j = await post(BASE + '/subscribe', { [kind]: i.value.trim() }); msg.textContent = j.message; i.value = ''; }
catch (err) { msg.textContent = err.message; }
b.disabled = false;
});
const w = el('div'); w.append(f); if (hint) w.append(el('p', 'small dim', hint)); w.lastChild.style.marginTop = 'var(--s2)';
return w;
};
if (d.subscribe.email) p.append(form('email', 'Email address', 'email', 'We send a link to confirm. Every update comes with a link to unsubscribe.', 'Subscribe by email'));
if (d.subscribe.webhook) p.append(form('webhook', 'Webhook address', 'url', 'We send a JSON request for every update. Your server has to answer a test request first.', 'Subscribe with a webhook'));
const rss = el('div'); rss.append(el('p', 'small', 'Or follow the RSS feed:'));
const a = link(BASE + '/feed.rss', location.origin + BASE + '/feed.rss', 'mono'); rss.append(a);
p.append(rss, msg);
return p;
}
// ---------- pages ----------
async function getJSON(path) {
const r = await fetch(path);
if (!r.ok) throw new Error(String(r.status));
return r.json();
}
async function showHome() {
const d = await getJSON('/status.json');
document.title = d.settings.title;
brand(d);
const v = el('div');
const ban = el('div', 'banner ' + d.overall, overallLabel[d.overall] || d.overall); ban.setAttribute('role', 'status');
v.append(ban);
if (d.subscribe.email || d.subscribe.webhook) { const sp = subscribePanel(d); sp.style.marginTop = 'var(--s4)'; v.append(sp); }
if (d.maintenance.length) {
v.append(el('h2', '', 'Scheduled maintenance'));
const c = el('div', 'card'); for (const m of d.maintenance) c.append(maintenance(m)); v.append(c);
}
v.append(el('h2', '', 'Services'), services(d));
v.append(el('h2', '', 'Incidents'));
const ic = el('div', 'card');
if (!d.incidents.length) ic.append(el('div', 'row dim', 'No incidents in the last 14 days.'));
for (const i of d.incidents) ic.append(incident(i, false));
v.append(ic);
const more = el('p', 'small'); more.style.marginTop = 'var(--s3)'; more.append(link(BASE + '/history', 'See all past incidents'));
v.append(more);
const old = $('subpanel'), open = old && !old.hidden;
$('view').replaceChildren(v);
if (open) { $('subpanel').hidden = false; }
}
async function showHistory() {
const first = await getJSON('/status.json');
document.title = 'History - ' + first.settings.title;
brand(first);
const v = el('div'), list = el('div'), btn = el('button', 'ghost', 'Show older'); btn.type = 'button'; btn.style.marginTop = 'var(--s4)';
v.append(el('h2', '', 'Incident history'), list, btn);
$('view').replaceChildren(v);
let next = 0, last = '';
const load = async () => {
const h = await getJSON('/status/api/history' + (next ? '?before=' + next : ''));
let card = null;
for (const e of h.entries) {
const m = monthOf(e.time);
if (m !== last) { last = m; v.insertBefore(el('div', 'month', m), btn); card = el('div', 'card'); v.insertBefore(card, btn); }
card.append(e.type === 'incident' ? incident(e.incident, false) : maintenance(e.maintenance));
}
if (!list.children.length && !h.entries.length && !next) list.append(el('div', 'card')), list.firstChild.append(el('div', 'row dim', 'Nothing has been posted yet.'));
next = h.next; btn.hidden = !next;
};
btn.addEventListener('click', () => load().catch(fail));
await load();
}
async function showIncident(id) {
const [d, i] = await Promise.all([getJSON('/status.json'), getJSON('/status/api/incidents/' + encodeURIComponent(id))]);
document.title = i.title + ' - ' + d.settings.title;
brand(d);
const v = el('div'), back = el('p', 'small'); back.style.marginTop = 'var(--s5)'; back.append(link(BASE + '/history', 'Back to incident history'));
const c = el('div', 'card'); c.style.marginTop = 'var(--s3)'; c.append(incident(i, true));
v.append(back, c);
$('view').replaceChildren(v);
}
function fail(e) {
const v = $('view');
if (e && e.message === '404') { v.replaceChildren(el('div', 'banner', 'That page does not exist. Go back to the ')); v.firstChild.append(link(home, 'status page'), document.createTextNode('.')); return; }
if (!v.querySelector('.card')) v.replaceChildren(el('div', 'banner', 'The status could not be loaded. Try again in a moment.'));
}
// ---------- notices from confirm and unsubscribe links ----------
function notices() {
const q = new URLSearchParams(location.search), n = $('notice');
const note = (text, ...extra) => { const d = el('div', 'notice'); d.setAttribute('role', 'status'); d.append(el('span', '', text), ...extra); n.replaceChildren(d); return d; };
if (q.get('notice') === 'confirmed') note('You are subscribed. You will get an email for each update.');
else if (q.get('notice') === 'invalid') note('That link is not valid. Subscribe again to get a new one.');
const token = q.get('unsubscribe');
if (token) {
const b = el('button', 'btn', 'Unsubscribe'); b.type = 'button';
const d = note('Stop getting updates from this page?', b);
b.addEventListener('click', async () => {
b.disabled = true;
try { const j = await post(BASE + '/unsubscribe', { token }); d.replaceChildren(el('span', '', j.message)); history.replaceState(null, '', location.pathname); }
catch (e) { d.replaceChildren(el('span', '', e.message)); }
});
}
}
const rel = location.pathname.slice(BASE.length).replace(/\/+$/, '');
let run;
if (rel === '/history') run = showHistory;
else if (rel.startsWith('/incidents/')) run = () => showIncident(decodeURIComponent(rel.slice(11)));
else run = showHome;
notices();
run().catch(fail);
if (run === showHome) setInterval(() => { if (!document.hidden && !document.activeElement.matches('input')) showHome().catch(() => {}); }, 30000);
</script>
</body>
</html>