258 lines
9.1 KiB
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>
|