237 lines
11 KiB
Cheetah
237 lines
11 KiB
Cheetah
{{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">✔</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 { }
|
||
.st-region { }
|
||
.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-health-maintenance { color:#6366f1; }
|
||
.st-health-planned { 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';
|
||
const isMaint = data.overall === 'Scheduled Maintenance';
|
||
if (isMaint) {
|
||
banner.style.background = '#eef2ff'; banner.style.color = '#4338ca'; banner.style.borderColor = '#c7d2fe';
|
||
banner.querySelector('span:first-child').innerHTML = '🔧';
|
||
} else if (allOp) {
|
||
banner.style.background = '#f0fdf4'; banner.style.color = '#15803d'; banner.style.borderColor = '#bbf7d0';
|
||
banner.querySelector('span:first-child').innerHTML = '✔';
|
||
} else {
|
||
banner.style.background = '#fefce8'; banner.style.color = '#854d0e'; banner.style.borderColor = '#fde68a';
|
||
banner.querySelector('span:first-child').innerHTML = '⚠';
|
||
}
|
||
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="pop-city">${n.city}</span><span class="pop-country">${n.country}</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="padding:8px 0"><span class="pop-city" style="color:var(--text-tertiary)">${n.city}</span><span class="pop-country">${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} · ${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' ? '●' : '○';
|
||
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}}
|