158 lines
10 KiB
Cheetah
158 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 max = Math.max(...vals, 5);
|
|
const xs = i => (i / (points.length-1)) * W;
|
|
const ys = v => H - 2 - (v/max) * (H-4);
|
|
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"><span class="glass-city">${t._city || t.node_id}</span><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">
|
|
<span class="glass-city">${t._city || t.node_id}</span>
|
|
<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><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>Mem</span><span>${memPct}%</span></div><div style="background:var(--border);border-radius:2px;height:4px"><div style="height:4px;border-radius:2px;width:${memPct}%;background:${barColor(memPct)}"></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 ${hist ? hist.length : 0} samples</div><canvas class="noc-spark" id="spark-cpu-${t.node_id}" style="width:100%;height:40px;display:block"></canvas></div>
|
|
<div style="margin-top:8px"><div style="font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px">Mem % — last ${hist ? hist.length : 0} samples</div><canvas class="noc-spark" id="spark-mem-${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;
|
|
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);
|
|
const mem = document.getElementById('spark-mem-'+t.node_id);
|
|
if (cpu) drawSpark(cpu, hist, 'cpu', 'rgb(220,38,38)');
|
|
if (mem) drawSpark(mem, hist, 'mem_pct', 'rgb(10,10,10)');
|
|
});
|
|
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}}
|