clavitor/clavitor.com/templates/status.tmpl

228 lines
11 KiB
Cheetah
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{define "status"}}
<div class="hero container" style="padding-bottom:0">
<p class="label accent mb-4">System Status</p>
<h1 class="mb-4">Clavitor Status</h1>
</div>
<div class="section container" style="padding-top:24px">
<div id="status-banner" style="padding:16px 20px;border-radius:var(--radius-sm);margin-bottom:32px;font-weight:600;font-size:0.95rem;display:flex;align-items:center;gap:10px;background:#f0fdf4;color:#15803d;border:1px solid #bbf7d0">
<span style="font-size:1.2rem">&#x2714;</span>
<span id="status-text">Loading...</span>
</div>
<p class="text-sm text-tertiary" style="margin-bottom:16px;display:flex;justify-content:space-between">
<span>Uptime over the past 90 days. All times UTC. Last heartbeat: <span id="last-beat">—</span></span>
<span id="utc-clock"></span>
</p>
<div id="status-nodes"></div>
<div id="status-incidents" style="margin-top:48px"></div>
<p class="mt-4 text-sm text-tertiary" id="status-updated"></p>
</div>
{{end}}
{{define "status-script"}}
<style>
.st-node { margin-bottom:28px; }
.st-header { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:6px; }
.st-name { font-weight:600; font-size:0.95rem; }
.st-region { font-size:0.75rem; color:var(--text-tertiary); margin-left:8px; }
.st-health { font-size:0.75rem; font-weight:600; text-transform:uppercase; letter-spacing:0.06em; }
.st-health-operational { color:#16a34a; }
.st-health-degraded { color:#ca8a04; }
.st-health-down { color:#dc2626; }
.st-health-unknown { color:var(--text-tertiary); }
.st-bars { display:flex; gap:1px; height:28px; align-items:flex-end; }
.st-bar { flex:1; border-radius:2px; min-width:2px; cursor:default; }
.st-bar-operational { background:#22c55e; }
.st-bar-down { background:#ef4444; }
.st-bar-degraded { background:#eab308; }
.st-bar-unknown { background:#e5e7eb; }
.st-range { display:flex; justify-content:space-between; font-size:0.7rem; color:var(--text-tertiary); margin-top:4px; }
.st-uptime-pct { font-size:0.7rem; color:var(--text-tertiary); text-align:center; margin-top:2px; }
.st-incident { border-left:3px solid var(--border); padding:12px 16px; margin-bottom:12px; }
.st-incident-title { font-weight:600; font-size:0.9rem; }
.st-incident-meta { font-size:0.75rem; color:var(--text-tertiary); margin-top:2px; }
.st-tooltip { display:none; position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%); background:#fff; border:1px solid var(--border); border-radius:6px; padding:10px 14px; min-width:220px; box-shadow:0 4px 12px rgba(0,0,0,0.1); z-index:10; font-size:0.75rem; }
.st-tooltip-date { font-weight:600; margin-bottom:6px; }
.st-tooltip-bar { display:flex; height:8px; border-radius:2px; overflow:hidden; margin-bottom:6px; }
.st-tooltip-bar .up { background:#22c55e; }
.st-tooltip-bar .down { background:#ef4444; }
.st-tooltip-spans { color:var(--text-tertiary); line-height:1.6; }
.st-bar { position:relative; }
.st-bar:hover .st-tooltip { display:block; }
</style>
<script>
(function() {
async function refresh() {
try {
const res = await fetch('/status/api', {cache:'no-cache'});
const data = await res.json();
// Banner
const banner = document.getElementById('status-banner');
const allOp = data.overall === 'All Systems Operational';
banner.style.background = allOp ? '#f0fdf4' : '#fefce8';
banner.style.color = allOp ? '#15803d' : '#854d0e';
banner.style.borderColor = allOp ? '#bbf7d0' : '#fde68a';
banner.querySelector('span:first-child').innerHTML = allOp ? '&#x2714;' : '&#x26A0;';
document.getElementById('status-text').textContent = data.overall;
if (data.last_heartbeat) {
const d = new Date(data.last_heartbeat * 1000);
document.getElementById('last-beat').textContent = d.toISOString().slice(0, 19).replace('T', ' ') + ' UTC';
}
// Nodes
const nodes = data.nodes || [];
const dates = data.dates || [];
let html = '';
// Separate live and planned
const live = nodes.filter(n => n.status === 'live');
const planned = nodes.filter(n => n.status !== 'live');
for (const n of live) {
const days = n.uptime_90 || [];
const withData = days.filter(d => d.pct >= 0);
const avgPct = withData.length > 0
? (withData.reduce((a, d) => a + d.pct, 0) / withData.length).toFixed(2)
: '--';
const healthClass = 'st-health-' + n.health;
const healthLabel = n.health.charAt(0).toUpperCase() + n.health.slice(1);
html += `<div class="st-node">
<div class="st-header">
<div><span class="st-name">${n.city}</span><span class="st-region">${n.region}</span></div>
<span class="st-health ${healthClass}">${healthLabel}</span>
</div>
<div class="st-bars">`;
const today = new Date().toISOString().slice(0, 10);
for (let i = 0; i < days.length; i++) {
const d = days[i];
const isToday = d.date === today;
let cls, h;
if (d.pct < 0) { cls = 'unknown'; h = '40%'; }
else if (d.pct === 100) { cls = 'operational'; h = '100%'; }
else if (d.pct >= 99) { cls = 'degraded'; h = '100%'; }
else if (d.pct > 0) { cls = 'down'; h = '100%'; }
else { cls = 'down'; h = '100%'; }
html += `<div class="st-bar st-bar-${cls}" data-node="${n.id}" data-date="${d.date}" data-pct="${d.pct}" style="height:${h}" onmouseenter="showDayTooltip(this)"><div class="st-tooltip"></div></div>`;
}
html += `</div>
<div class="st-range"><span>90 days ago</span><span>${avgPct}% uptime</span><span>Today</span></div>
</div>`;
}
if (planned.length > 0) {
html += `<div style="margin-top:32px;margin-bottom:16px;font-size:0.7rem;font-weight:500;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary)">Planned</div>`;
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px">`;
for (const n of planned) {
html += `<div style="font-size:0.85rem;padding:8px 0;color:var(--text-tertiary)">${n.city}<span style="font-size:0.7rem;margin-left:6px;opacity:0.6">${n.country}</span></div>`;
}
html += `</div>`;
}
document.getElementById('status-nodes').innerHTML = html;
// Incidents
const incidents = data.incidents || [];
if (incidents.length > 0) {
let ihtml = '<h3 style="font-size:1rem;font-weight:600;margin-bottom:16px">Recent Incidents</h3>';
for (const inc of incidents) {
ihtml += `<div class="st-incident">
<div class="st-incident-title">${inc.title}</div>
<div class="st-incident-meta">${inc.date} &middot; ${inc.status}</div>
</div>`;
}
document.getElementById('status-incidents').innerHTML = ihtml;
} else {
document.getElementById('status-incidents').innerHTML =
'<p class="text-sm text-tertiary" style="margin-top:32px">No recent incidents.</p>';
}
document.getElementById('status-updated').textContent = 'Updated ' + new Date().toLocaleTimeString();
} catch(e) {
console.error('status refresh error:', e);
}
}
function updateClock() {
const now = new Date();
document.getElementById('utc-clock').textContent = 'now: ' + now.toISOString().slice(0, 10) + ' ' + now.toISOString().slice(11, 19) + ' UTC';
}
updateClock();
setInterval(updateClock, 1000);
refresh();
// Align refresh to :00 and :30 of each minute
function scheduleRefresh() {
const now = new Date();
const s = now.getSeconds();
const next = s < 30 ? 30 - s : 60 - s;
setTimeout(() => { refresh(); scheduleRefresh(); }, next * 1000);
}
scheduleRefresh();
const spanCache = {};
window.showDayTooltip = async function(bar) {
const tip = bar.querySelector('.st-tooltip');
const node = bar.dataset.node;
const date = bar.dataset.date;
const pct = parseFloat(bar.dataset.pct);
const key = node + ':' + date;
if (pct < 0) {
tip.innerHTML = `<div class="st-tooltip-date">${date}</div><div style="color:var(--text-tertiary)">No data</div>`;
return;
}
tip.innerHTML = `<div class="st-tooltip-date">${date}</div><div style="color:var(--text-tertiary)">Loading...</div>`;
const today = new Date().toISOString().slice(0, 10);
if (!spanCache[key] || date === today) {
try {
const r = await fetch('/status/api/spans?node=' + encodeURIComponent(node) + '&date=' + date);
spanCache[key] = await r.json();
} catch(e) {
tip.innerHTML = `<div class="st-tooltip-date">${date}</div><div style="color:var(--text-tertiary)">Error loading</div>`;
return;
}
}
const data = spanCache[key];
const spans = data.spans || [];
const total = data.day_end - data.day_start;
if (!total || !spans.length) {
tip.innerHTML = `<div class="st-tooltip-date">${date}${pct.toFixed(1)}% uptime</div><div style="color:var(--text-tertiary)">No spans</div>`;
return;
}
// Build visual bar
let barHtml = '<div class="st-tooltip-bar">';
for (const s of spans) {
const w = ((s.end - s.start) / total * 100).toFixed(1);
barHtml += `<div class="${s.type}" style="width:${w}%"></div>`;
}
barHtml += '</div>';
// Build text list
let listHtml = '<div class="st-tooltip-spans">';
for (const s of spans) {
const start = new Date(s.start * 1000).toISOString().slice(11, 16);
const end = new Date(s.end * 1000).toISOString().slice(11, 16);
const dur = Math.round((s.end - s.start) / 60);
const icon = s.type === 'up' ? '&#9679;' : '&#9675;';
const color = s.type === 'up' ? '#16a34a' : '#dc2626';
listHtml += `<div><span style="color:${color}">${icon}</span> ${start}${end} UTC (${dur}m ${s.type})</div>`;
}
listHtml += '</div>';
tip.innerHTML = `<div class="st-tooltip-date">${date}${pct.toFixed(1)}% uptime</div>${barHtml}${listHtml}`;
};
})();
</script>
{{end}}