feat(webui): server-side SVG charts + reanimator-chart viewer

Metrics:
- Replace canvas JS charts with server-side SVG via go-analyze/charts
- Add ring buffers (120 samples) for CPU temp and power
- /api/metrics/chart/{name}.svg endpoint serves live SVG, polled every 2s

Dashboard:
- Replace custom renderViewerPage with viewer.RenderHTML() from reanimator/chart submodule
- Mount chart static assets at /chart/static/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 23:07:47 +03:00
parent d50760e7c6
commit ff0acc3698
5 changed files with 179 additions and 95 deletions

View File

@@ -66,9 +66,6 @@ tr:hover td{background:#1a2030}
.form-row label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}
.form-row input,.form-row select{width:100%;padding:8px 10px;background:#0f1117;border:1px solid #252d3d;border-radius:6px;color:#e2e8f0;font-size:13px;outline:none}
.form-row input:focus,.form-row select:focus{border-color:#3b82f6}
/* Metrics chart */
.chart-wrap{position:relative;background:#0a0d14;border-radius:8px;overflow:hidden;margin-bottom:8px;line-height:0}
canvas.chart{display:block}
.chart-legend{font-size:11px;color:#64748b;padding:4px 0}
/* Grid */
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@@ -245,94 +242,36 @@ func renderHealthCard(opts HandlerOptions) string {
// ── Metrics ───────────────────────────────────────────────────────────────────
func renderMetrics() string {
return `<p style="color:#64748b;font-size:13px;margin-bottom:16px">Live server metrics updated every second.</p>
return `<p style="color:#64748b;font-size:13px;margin-bottom:16px">Live server metrics, charts updated every 2 seconds.</p>
<div class="grid2">
<div class="card">
<div class="card-head">GPU Metrics</div>
<div class="card-head">System</div>
<div class="card-body">
<div class="chart-wrap"><canvas id="chart-gpu-temp" class="chart"></canvas></div>
<div class="chart-legend">Temperature °C</div>
<div class="chart-wrap"><canvas id="chart-gpu-usage" class="chart"></canvas></div>
<div class="chart-legend">Usage %</div>
<div class="chart-wrap"><canvas id="chart-gpu-power" class="chart"></canvas></div>
<div class="chart-legend">Power W</div>
<div id="gpu-table"></div>
<img id="chart-cpu-temp" src="/api/metrics/chart/cpu-temp.svg" style="width:100%;border-radius:6px" alt="CPU Temp">
<img id="chart-power" src="/api/metrics/chart/power.svg" style="width:100%;border-radius:6px;margin-top:8px" alt="Power">
<div id="sys-table" style="margin-top:8px"></div>
</div>
</div>
<div class="card">
<div class="card-head">System Metrics</div>
<div class="card-head">GPU</div>
<div class="card-body">
<div class="chart-wrap"><canvas id="chart-cpu-temp" class="chart"></canvas></div>
<div class="chart-legend">CPU Temperature °C</div>
<div class="chart-wrap"><canvas id="chart-fans" class="chart"></canvas></div>
<div class="chart-legend">Fan Speed RPM</div>
<div class="chart-wrap"><canvas id="chart-power" class="chart"></canvas></div>
<div class="chart-legend">System Power W</div>
<div id="sys-table"></div>
<div id="gpu-table"><p style="color:#64748b;font-size:12px">Waiting for data...</p></div>
</div>
</div>
</div>
<script>
const WINDOW = 120;
const history = {gpuTemp:[],gpuUsage:[],gpuPower:[],cpuTemp:[],fans:[],power:[]};
const colors = ['#60a5fa','#34d399','#f87171','#fbbf24','#a78bfa','#fb7185'];
function push(arr, val) { arr.push(val); if (arr.length > WINDOW) arr.shift(); }
function drawChart(canvasId, datasets, maxY) {
const c = document.getElementById(canvasId);
if (!c) return;
const rect = c.parentElement.getBoundingClientRect();
const W = rect.width || 400;
const H = 120;
c.width = W; c.height = H; c.style.width=W+'px'; c.style.height=H+'px';
const ctx = c.getContext('2d');
ctx.clearRect(0,0,W,H);
ctx.fillStyle = '#0a0d14';
ctx.fillRect(0,0,W,H);
// grid
ctx.strokeStyle = '#1e2535';
ctx.lineWidth = 1;
for (let y = 0; y <= 4; y++) {
const py = H * y / 4;
ctx.beginPath(); ctx.moveTo(0,py); ctx.lineTo(W,py); ctx.stroke();
}
const max = maxY || datasets.reduce((m,d) => Math.max(m,...d.map(v=>v||0)), 1) || 1;
datasets.forEach((data, i) => {
if (!data.length) return;
ctx.strokeStyle = colors[i % colors.length];
ctx.lineWidth = 1.5;
ctx.beginPath();
data.forEach((v, j) => {
const x = W * j / Math.max(data.length - 1, 1);
const y = H - H * (v || 0) / max;
j === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
});
ctx.stroke();
function refreshCharts() {
const t = '?t=' + Date.now();
['chart-cpu-temp','chart-power'].forEach(id => {
const el = document.getElementById(id);
if (el) el.src = el.src.split('?')[0] + t;
});
}
setInterval(refreshCharts, 2000);
const es = new EventSource('/api/metrics/stream');
es.addEventListener('metrics', e => {
const d = JSON.parse(e.data);
// GPU
const gpuTemps = (d.gpus||[]).map(g => g.temp_c||0);
const gpuUsages = (d.gpus||[]).map(g => g.usage_pct||0);
const gpuPowers = (d.gpus||[]).map(g => g.power_w||0);
if (!history.gpuTemp.length && gpuTemps.length) {
history.gpuTemp = gpuTemps.map(() => []);
history.gpuUsage = gpuUsages.map(() => []);
history.gpuPower = gpuPowers.map(() => []);
}
gpuTemps.forEach((v,i) => push(history.gpuTemp[i]||[], v));
gpuUsages.forEach((v,i) => push(history.gpuUsage[i]||[], v));
gpuPowers.forEach((v,i) => push(history.gpuPower[i]||[], v));
drawChart('chart-gpu-temp', history.gpuTemp.length ? history.gpuTemp : [history.cpuTemp]);
drawChart('chart-gpu-usage', history.gpuUsage, 100);
drawChart('chart-gpu-power', history.gpuPower);
// GPU table
const gpuRows = (d.gpus||[]).map(g =>
'<tr><td>GPU '+g.index+'</td><td>'+g.temp_c+'°C</td><td>'+g.usage_pct+'%</td><td>'+g.power_w+'W</td><td>'+g.clock_mhz+'MHz</td></tr>'
).join('');
@@ -340,27 +279,16 @@ es.addEventListener('metrics', e => {
'<table><tr><th>GPU</th><th>Temp</th><th>Usage</th><th>Power</th><th>Clock</th></tr>'+gpuRows+'</table>' :
'<p style="color:#64748b;font-size:12px">No NVIDIA GPU detected</p>';
// System
const cpuTemp = (d.temps||[]).find(t => t.name==='CPU');
push(history.cpuTemp, cpuTemp ? cpuTemp.celsius : 0);
const fanRPMs = (d.fans||[]).map(f => f.rpm||0);
if (!history.fans.length && fanRPMs.length) history.fans = fanRPMs.map(() => []);
fanRPMs.forEach((v,i) => push(history.fans[i]||[], v));
push(history.power, d.power_w||0);
drawChart('chart-cpu-temp', [history.cpuTemp]);
drawChart('chart-fans', history.fans.length ? history.fans : [[]]);
drawChart('chart-power', [history.power]);
// Sys table
let sysHTML = '';
const cpuTemp = (d.temps||[]).find(t => t.name==='CPU');
if (cpuTemp) sysHTML += '<tr><td>CPU Temp</td><td>'+cpuTemp.celsius.toFixed(1)+'°C</td></tr>';
(d.fans||[]).forEach(f => sysHTML += '<tr><td>'+f.name+'</td><td>'+f.rpm+' RPM</td></tr>');
if (d.power_w) sysHTML += '<tr><td>System Power</td><td>'+d.power_w.toFixed(0)+'W</td></tr>';
document.getElementById('sys-table').innerHTML = sysHTML ?
'<table style="margin-top:8px">'+sysHTML+'</table>' :
'<p style="color:#64748b;font-size:12px">No sensor data (requires ipmitool/sensors)</p>';
'<table>'+sysHTML+'</table>' :
'<p style="color:#64748b;font-size:12px">No sensor data (ipmitool/sensors required)</p>';
});
es.onerror = () => { /* reconnect automatically */ };
es.onerror = () => {};
</script>`
}