fester/ui/metrics.html

258 lines
9.1 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fester — Metrics</title>
<link rel="stylesheet" href="/style.css">
<style>
body { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
#wrap { flex: 1; padding: 16px; overflow: auto; }
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 14px 16px;
}
.stat .label { font-size: 11px; color: var(--fg-3); text-transform: uppercase; letter-spacing: 0.6px; }
.stat .value { font-family: var(--font-mono); font-size: 24px; color: var(--fg-0); margin-top: 4px; font-weight: 600; }
.stat .sub { font-size: 11px; color: var(--fg-2); margin-top: 2px; }
.stat .value.ok { color: var(--ok); }
.stat .value.warn { color: var(--warn); }
.stat .value.err { color: var(--err); }
.charts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.chart {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 14px;
}
.chart h3 { margin: 0 0 8px; }
canvas { width: 100%; height: 160px; display: block; }
.node-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 8px;
}
.node-card {
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px 12px;
}
.node-card .name { font-weight: 600; color: var(--fg-0); }
.node-card .row {
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-2);
margin-top: 2px;
}
.node-card .bar {
height: 4px;
background: var(--bg-0);
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
.node-card .bar > div { height: 100%; transition: width 0.4s, background 0.4s; }
@media (max-width: 900px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.charts { grid-template-columns: 1fr; }
}
</style>
</head>
<body data-fester-shell="metrics">
<div id="wrap">
<div class="stat-grid" id="stats">
<div class="stat">
<div class="label">Nodes Online</div>
<div class="value ok" id="s-nodes"></div>
<div class="sub" id="s-nodes-sub"></div>
</div>
<div class="stat">
<div class="label">Avg Heat</div>
<div class="value" id="s-heat"></div>
<div class="sub" id="s-heat-sub"></div>
</div>
<div class="stat">
<div class="label">Active Jobs</div>
<div class="value warn" id="s-jobs"></div>
<div class="sub" id="s-jobs-sub"></div>
</div>
<div class="stat">
<div class="label">Instability</div>
<div class="value" id="s-inst"></div>
<div class="sub" id="s-inst-sub"></div>
</div>
</div>
<div class="charts">
<div class="chart">
<h3>Heat Trend (last 60s)</h3>
<canvas id="heat-chart"></canvas>
</div>
<div class="chart">
<h3>Active Jobs Trend (last 60s)</h3>
<canvas id="jobs-chart"></canvas>
</div>
</div>
<div class="panel">
<div class="head">
<h3>Per-Node Breakdown</h3>
<button class="ghost" id="refresh">⟳ Refresh</button>
</div>
<div class="body">
<div class="node-grid" id="nodes"></div>
</div>
</div>
</div>
<script src="/ui/app.js"></script>
<script>
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
// Ring buffers for charts
const N = 60;
const heatHist = new Array(N).fill(0);
const jobsHist = new Array(N).fill(0);
function drawLine(canvas, data, color) {
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
// grid
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = (i / 4) * h;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
const max = Math.max(...data, 1);
const stepX = w / (data.length - 1);
// area
ctx.beginPath();
ctx.moveTo(0, h);
data.forEach((v, i) => {
const x = i * stepX;
const y = h - (v / max) * h * 0.9 - 4;
ctx.lineTo(x, y);
});
ctx.lineTo(w, h);
ctx.closePath();
ctx.fillStyle = color + '22';
ctx.fill();
// line
ctx.beginPath();
data.forEach((v, i) => {
const x = i * stepX;
const y = h - (v / max) * h * 0.9 - 4;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
// current value dot
const lastX = (data.length - 1) * stepX;
const lastY = h - (data[data.length - 1] / max) * h * 0.9 - 4;
ctx.beginPath();
ctx.arc(lastX, lastY, 3, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
async function tick() {
try {
const data = await Fester.api('/api/metrics/json');
const nodes = data.per_node || [];
const online = nodes.filter(n => n.temp > 0 || n.active_jobs > 0).length;
const avgHeat = nodes.length ? nodes.reduce((s, n) => s + n.temp, 0) / nodes.length : 0;
const totalJobs = nodes.reduce((s, n) => s + n.active_jobs, 0);
const avgInst = nodes.length ? nodes.reduce((s, n) => s + n.instability, 0) / nodes.length : 0;
$('s-nodes').textContent = `${online}/${nodes.length}`;
$('s-nodes-sub').textContent = `${nodes.length - online} offline`;
$('s-heat').textContent = Math.round(avgHeat) + '°';
$('s-heat').className = 'value ' + (avgHeat > 80 ? 'err' : avgHeat > 60 ? 'warn' : 'ok');
$('s-heat-sub').textContent = avgHeat > 80 ? 'critical' : avgHeat > 60 ? 'elevated' : 'nominal';
$('s-jobs').textContent = totalJobs;
$('s-jobs-sub').textContent = `across ${online} nodes`;
$('s-inst').textContent = avgInst.toFixed(2);
$('s-inst').className = 'value ' + (avgInst > 0.5 ? 'err' : avgInst > 0.2 ? 'warn' : 'ok');
$('s-inst-sub').textContent = avgInst > 0.5 ? 'unstable' : avgInst > 0.2 ? 'watch' : 'stable';
// push to history
heatHist.push(avgHeat); heatHist.shift();
jobsHist.push(totalJobs); jobsHist.shift();
drawLine($('heat-chart'), heatHist, '#e67e22');
drawLine($('jobs-chart'), jobsHist, '#42a5f5');
// node grid
const grid = $('nodes');
if (!nodes.length) {
grid.innerHTML = '<div class="dim center">No nodes reporting</div>';
return;
}
grid.innerHTML = nodes.map(n => {
const heatColor = Fester.heatColor(n.temp);
const instColor = n.instability > 0.5 ? 'var(--err)' : n.instability > 0.2 ? 'var(--warn)' : 'var(--ok)';
return `
<div class="node-card">
<div class="name">${n.name}</div>
<div class="row"><span>HEAT</span><span>${Math.round(n.temp)}°</span></div>
<div class="bar"><div style="width:${Math.min(100, n.temp)}%;background:${heatColor};"></div></div>
<div class="row" style="margin-top:6px;"><span>JOBS</span><span>${n.active_jobs}</span></div>
<div class="bar"><div style="width:${Math.min(100, n.active_jobs * 5)}%;background:var(--info);"></div></div>
<div class="row" style="margin-top:6px;"><span>CPU</span><span>${n.cpu_load.toFixed(1)}%</span></div>
<div class="bar"><div style="width:${Math.min(100, n.cpu_load)}%;background:var(--accent);"></div></div>
<div class="row" style="margin-top:6px;"><span>INSTABILITY</span><span>${n.instability.toFixed(2)}</span></div>
<div class="bar"><div style="width:${Math.min(100, n.instability * 100)}%;background:${instColor};"></div></div>
</div>
`;
}).join('');
} catch (e) {
console.error('metrics tick failed', e);
}
}
$('refresh').addEventListener('click', tick);
tick();
setInterval(tick, 2000);
})();
</script>
</body>
</html>