Every check is also counted in a per-day table that outlives the raw checks, so the public page can draw one bar per UTC day with a tooltip, as most hosted status pages do. Existing databases are backfilled once from the checks they still have. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
153 lines
9.7 KiB
HTML
153 lines
9.7 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>Status</title>
|
|
<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; }
|
|
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); }
|
|
.dim { color:var(--text-muted); } .small { font-size:var(--text-sm); } .num { font-variant-numeric:tabular-nums; }
|
|
.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.outage { background:var(--danger-bg); color:var(--danger); border-color:var(--danger); }
|
|
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); overflow:hidden; }
|
|
.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; }
|
|
.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); }
|
|
.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); }
|
|
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
|
|
</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>
|
|
<script>
|
|
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 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' };
|
|
|
|
// 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 service(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' : ''));
|
|
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 b = 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);
|
|
});
|
|
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;
|
|
}
|
|
|
|
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 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));
|
|
|
|
const ic = $('incidents'); ic.replaceChildren();
|
|
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);
|
|
}
|
|
}
|
|
load(); setInterval(load, 30000);
|
|
</script>
|
|
</body>
|
|
</html>
|