clavitor/clavitor.ai/templates/noc.tmpl

163 lines
10 KiB
Cheetah

{{define "noc"}}
<div class="hero container" style="padding-bottom:0">
<p class="label accent mb-4">Network Operations</p>
<h1 class="mb-4">Clavitor NOC</h1>
<p class="lead" style="margin-bottom:0">Real-time telemetry from {{len .Pops}} points of presence.</p>
</div>
<div class="section container" style="padding-top:24px">
<div id="noc-summary" class="glass-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:24px">
<div class="glass-pop"><div class="glass-header"><span class="glass-city">Nodes reporting</span></div><div class="glass-val" style="font-size:1.5rem;font-weight:700" id="s-nodes">—</div></div>
<div class="glass-pop"><div class="glass-header"><span class="glass-city">Avg CPU</span></div><div class="glass-val" style="font-size:1.5rem;font-weight:700" id="s-cpu">—</div></div>
<div class="glass-pop"><div class="glass-header"><span class="glass-city">Avg Mem</span></div><div class="glass-val" style="font-size:1.5rem;font-weight:700" id="s-mem">—</div></div>
<div class="glass-pop"><div class="glass-header"><span class="glass-city">Total Vaults</span></div><div class="glass-val" style="font-size:1.5rem;font-weight:700" id="s-vaults">—</div></div>
</div>
<div id="noc-error" style="display:none;color:var(--brand-red);font-size:0.85rem;margin-bottom:16px"></div>
<div id="noc-cards" class="glass-grid" style="grid-template-columns:repeat(3,1fr)"></div>
<p class="mt-4 text-sm text-tertiary" id="noc-updated">Loading...</p>
</div>
{{end}}
{{define "noc-script"}}
<script>
(function() {
const pin = new URLSearchParams(window.location.search).get('pin') || '';
const API = '/noc';
const P = '?pin=' + encodeURIComponent(pin);
function apiUrl(path, extra) { return API + path + P + (extra ? '&' + extra : ''); }
const REFRESH_MS = 30000;
const history = {};
function colorClass(pct, w=60, c=85) { return pct >= c ? 'glass-slow' : pct >= w ? 'glass-ok' : 'glass-fast'; }
function barColor(pct, w=60, c=85) { return pct >= c ? 'var(--brand-red)' : pct >= w ? '#ca8a04' : '#16a34a'; }
function fmtUptime(s) {
if (!s) return '—';
const d=Math.floor(s/86400), h=Math.floor((s%86400)/3600), m=Math.floor((s%3600)/60);
return d > 0 ? d+'d '+h+'h' : h > 0 ? h+'h '+m+'m' : m+'m';
}
function fmtAgo(ts) {
const s = Math.round(Date.now()/1000 - ts);
if (s < 5) return 'just now';
if (s < 60) return s+'s ago';
if (s < 3600) return Math.floor(s/60)+'m ago';
return Math.floor(s/3600)+'h ago';
}
function drawSpark(canvas, points, key, color) {
const W = canvas.clientWidth || 320, H = 40;
canvas.width = W * devicePixelRatio; canvas.height = H * devicePixelRatio;
const ctx = canvas.getContext('2d');
ctx.scale(devicePixelRatio, devicePixelRatio);
if (!points || points.length < 2) return;
const vals = points.map(p => p[key]);
const dataMax = Math.max(...vals);
// Snap ceiling to nice round number above data
const max = dataMax <= 5 ? 5 : dataMax <= 10 ? 10 : dataMax <= 25 ? 25 : dataMax <= 50 ? 50 : 100;
const xs = i => (i / (points.length-1)) * W;
const ys = v => H - 2 - (v/max) * (H-4);
// Guide lines at 25%, 50%, 75% of ceiling
ctx.strokeStyle = 'rgba(0,0,0,0.06)'; ctx.lineWidth = 1;
for (const f of [0.25, 0.5, 0.75]) {
const y = ys(max * f);
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 1.5;
ctx.moveTo(xs(0), ys(vals[0]));
for (let i=1;i<vals.length;i++) ctx.lineTo(xs(i), ys(vals[i]));
ctx.stroke();
ctx.lineTo(xs(vals.length-1), H); ctx.lineTo(xs(0), H); ctx.closePath();
ctx.fillStyle = color.replace(')', ',0.08)').replace('rgb','rgba');
ctx.fill();
}
function renderCard(t, hist) {
if (t._pending) return `
<div class="glass-pop" style="opacity:.5">
<div class="glass-header"><div><span class="pop-city">${t._city || t.node_id}</span><span class="pop-country">${t._country || ''}</span></div><span class="glass-status glass-status-planned">PENDING</span></div>
<div style="color:var(--muted);font-size:0.8rem;text-align:center;padding:20px 0">Awaiting telemetry</div>
</div>`;
const ageS = Math.round(Date.now()/1000 - t.received_at);
const stale = ageS > 150, offline = ageS > 300;
const memPct = t.memory_total_mb ? Math.round(t.memory_used_mb/t.memory_total_mb*100) : 0;
const diskPct = t.disk_total_mb ? Math.round(t.disk_used_mb/t.disk_total_mb*100) : 0;
const statusClass = offline ? 'glass-status-planned' : stale ? 'glass-status-planned' : 'glass-status-live';
const statusText = offline ? 'OFFLINE' : stale ? 'STALE' : 'LIVE';
const borderColor = offline ? 'var(--brand-red)' : stale ? '#ca8a04' : '#16a34a';
return `
<div class="glass-pop" style="border-left:3px solid ${borderColor}">
<div class="glass-header">
<div><span class="pop-city">${t._city || t.node_id}</span><span class="pop-country">${t._country || ''}</span></div>
<span class="glass-status ${statusClass}">${statusText}</span>
</div>
<div style="font-size:0.72rem;color:var(--muted);margin-bottom:10px">${t.hostname || ''} · v${t.version || ''}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px">
<div><div style="font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em">CPU</div><div class="${colorClass(t.cpu_percent)}" style="font-size:1.3rem;font-weight:700">${t.cpu_percent.toFixed(1)}%</div><div style="font-size:0.7rem;color:var(--muted)">load ${t.load_1m.toFixed(2)}</div></div>
<div style="text-align:right"><div style="font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em">Memory</div><div class="${colorClass(memPct)}" style="font-size:1.3rem;font-weight:700">${memPct}%</div><div style="font-size:0.7rem;color:var(--muted)">${t.memory_used_mb} / ${t.memory_total_mb} MB</div></div>
</div>
<div style="margin-bottom:8px"><div style="display:flex;justify-content:space-between;font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;margin-bottom:3px"><span>Disk</span><span>${diskPct}% · ${t.disk_used_mb} / ${t.disk_total_mb} MB</span></div><div style="background:var(--border);border-radius:2px;height:4px"><div style="height:4px;border-radius:2px;width:${diskPct}%;background:${barColor(diskPct,70,90)}"></div></div></div>
<div style="border-top:1px solid var(--border);padding-top:10px;margin-top:8px"><div style="font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px">CPU % — last 30 min</div><canvas class="noc-spark" id="spark-cpu-${t.node_id}" style="width:100%;height:40px;display:block"></canvas></div>
<div style="display:flex;justify-content:space-between;margin-top:10px;padding-top:8px;border-top:1px solid var(--border);font-size:0.7rem;color:var(--muted)">
<span>↑ ${fmtUptime(t.uptime_seconds)}</span>
<span style="color:var(--brand-red);font-weight:600">◈ ${t.vault_count} vaults · ${t.vault_size_mb.toFixed(1)} MB</span>
<span title="${new Date(t.received_at*1000).toISOString()}">⏱ ${fmtAgo(t.received_at)}</span>
</div>
</div>`;
}
async function fetchHistory(nodeId) {
try {
const r = await fetch(apiUrl('/api/telemetry/history', 'node='+nodeId+'&limit=60'), {cache:'no-cache'});
const d = await r.json();
if (d.history) history[nodeId] = d.history.map(h => ({ts:h.ts, cpu:h.cpu, mem_pct: h.mem_total_mb ? Math.round(h.mem_used_mb/h.mem_total_mb*100) : 0}));
} catch(e) {}
}
async function refresh() {
try {
const [tRes, nRes] = await Promise.all([
fetch(apiUrl('/api/telemetry'), {cache:'no-cache'}),
fetch(apiUrl('/api/nodes'), {cache:'no-cache'}),
]);
const tData = await tRes.json(), nData = await nRes.json();
const tMap = {};
for (const t of (tData.telemetry || [])) tMap[t.node_id] = t;
const liveNodes = (nData.nodes || []).filter(n => n.Status === 'live');
const nodes = liveNodes.map(n => {
const t = tMap[n.ID] || {node_id:n.ID, _pending:true};
t._city = n.City || n.ID;
t._country = n.Country || '';
return t;
});
document.getElementById('noc-error').style.display = 'none';
document.getElementById('s-nodes').textContent = nodes.length;
if (nodes.length) {
const live = nodes.filter(n => !n._pending);
const avgCPU = live.reduce((a,n)=>a+n.cpu_percent,0)/live.length;
const avgMem = live.reduce((a,n)=>a+(n.memory_total_mb?n.memory_used_mb/n.memory_total_mb*100:0),0)/live.length;
const totalVaults = live.reduce((a,n)=>a+n.vault_count,0);
document.getElementById('s-cpu').textContent = avgCPU.toFixed(1)+'%';
document.getElementById('s-mem').textContent = avgMem.toFixed(1)+'%';
document.getElementById('s-vaults').textContent = totalVaults;
}
await Promise.all(nodes.map(n => fetchHistory(n.node_id)));
document.getElementById('noc-cards').innerHTML = nodes.map(t => renderCard(t, history[t.node_id])).join('');
nodes.forEach(t => {
const hist = history[t.node_id] || [];
const cpu = document.getElementById('spark-cpu-'+t.node_id);
if (cpu) drawSpark(cpu, hist, 'cpu', 'rgb(220,38,38)');
});
document.getElementById('noc-updated').textContent = 'Updated ' + new Date().toLocaleTimeString();
} catch(e) {
const err = document.getElementById('noc-error');
err.textContent = 'Fetch error: ' + e.message;
err.style.display = 'block';
}
}
refresh();
setInterval(refresh, REFRESH_MS);
})();
</script>
{{end}}