Show 90-day uptime bars on the status page

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>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 04:22:54 -06:00
1 parent 73f45fb3ae
commit 4ea4fe5289
7 files changed
+221 -14

No files matched your search

+55 -7
View File
@@ -45,6 +45,17 @@
.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); }
@@ -56,6 +67,7 @@
</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>
@@ -72,6 +84,48 @@ const label = { operational: 'All systems are operational', degraded: 'Some syst
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; }
@@ -80,13 +134,7 @@ async function load() {
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) {
const r = el('div', 'row');
r.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' : ''));
r.append(right); sv.append(r);
}
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.'));