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:
1 parent
73f45fb3ae
commit
4ea4fe5289
7 files changed
+221
-14
No files matched your search
@@ -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.'));
|
||||
|
||||
Reference in new issue
Block a user