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>
This commit is contained in:
1 parent
b4fd31a182
commit
cf4d5b3ce2
12 files changed
+2403
-147
No files matched your search
@@ -4,7 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Status</title>
|
||||
<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 {
|
||||
@@ -33,18 +34,36 @@
|
||||
/* 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 { 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); }
|
||||
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s4) var(--s8); }
|
||||
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 { background:var(--warning-bg); color:var(--warning); border-color:var(--warning); }
|
||||
.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; }
|
||||
@@ -60,29 +79,52 @@
|
||||
.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 ul { margin:var(--s2) 0 0; padding-left:var(--s5); }
|
||||
.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>
|
||||
<h1>Status</h1>
|
||||
<div id="banner" class="banner" role="status">Loading</div>
|
||||
<h2>Services</h2>
|
||||
<div class="card" id="services"></div>
|
||||
<h2>Incidents</h2>
|
||||
<div class="card" id="incidents"></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 label = { operational: 'All systems are operational', degraded: 'Some systems are affected', outage: 'There is a service outage' };
|
||||
const statusBadge = s => s === 'up' ? el('span', 'badge badge-success', 'Operational') : s === 'down' ? el('span', 'badge badge-danger', 'Down') : el('span', 'badge', 'Waiting for first check');
|
||||
const incStatus = { investigating: 'Investigating', identified: 'Identified', monitoring: 'Monitoring', resolved: 'Resolved' };
|
||||
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.
|
||||
@@ -101,11 +143,11 @@ function showTip(e, lines) {
|
||||
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 service(m, today) {
|
||||
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');
|
||||
right.append(statusBadge(m.status), document.createTextNode(m.uptime >= 0 ? ' ' + m.uptime.toFixed(2) + '% uptime' : ''));
|
||||
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;
|
||||
@@ -113,12 +155,12 @@ function service(m, today) {
|
||||
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 b = el('span', 'bar ' + dayClass(d));
|
||||
const s = el('span', 'bar ' + dayClass(d));
|
||||
const lines = dayText(d, i, days.length, today);
|
||||
b.addEventListener('pointerenter', e => showTip(e, lines));
|
||||
b.addEventListener('pointermove', e => showTip(e, lines));
|
||||
b.addEventListener('pointerleave', () => { tip.hidden = true; });
|
||||
bars.append(b);
|
||||
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'));
|
||||
@@ -126,27 +168,206 @@ function service(m, today) {
|
||||
return box;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
let d;
|
||||
try { d = await (await fetch('/status.json')).json(); } catch (e) { $('banner').textContent = 'The status could not be loaded. Try again in a moment.'; return; }
|
||||
$('banner').className = 'banner ' + d.overall;
|
||||
$('banner').textContent = label[d.overall] || d.overall;
|
||||
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;
|
||||
}
|
||||
|
||||
const sv = $('services'); sv.replaceChildren();
|
||||
if (!d.monitors.length) sv.append(el('div', 'row dim', 'No services are published yet.'));
|
||||
for (const m of d.monitors) sv.append(service(m, d.today));
|
||||
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;
|
||||
}
|
||||
|
||||
const ic = $('incidents'); ic.replaceChildren();
|
||||
// ---------- 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) {
|
||||
const b = el('div', 'inc');
|
||||
const head = el('div'); head.append(el('strong', '', i.title + ' '), el('span', 'badge ' + (i.status === 'resolved' ? 'badge-success' : 'badge-warning'), incStatus[i.status] || i.status));
|
||||
b.append(head, el('div', 'small dim', 'Started ' + when(i.created) + (i.resolved ? ', resolved ' + when(i.resolved) : '')));
|
||||
if (i.updates.length) { const ul = el('ul', 'small'); for (const u of i.updates) ul.append(el('li', '', when(u.time) + ': ' + u.text)); b.append(ul); }
|
||||
ic.append(b);
|
||||
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)); }
|
||||
});
|
||||
}
|
||||
}
|
||||
load(); setInterval(load, 30000);
|
||||
|
||||
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>
|
||||
Reference in new issue
Block a user