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:
14
audit/go.mod
14
audit/go.mod
@@ -1,3 +1,17 @@
|
|||||||
module bee/audit
|
module bee/audit
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
|
replace reanimator/chart => ../internal/chart
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-analyze/charts v0.5.26
|
||||||
|
reanimator/chart v0.0.0-00010101000000-000000000000
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-analyze/bulk v0.1.3 // indirect
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
|
golang.org/x/image v0.24.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
18
audit/go.sum
Normal file
18
audit/go.sum
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-analyze/bulk v0.1.3 h1:pzRdBqzHDAT9PyROt0SlWE0YqPtdmTcEpIJY0C3vF0c=
|
||||||
|
github.com/go-analyze/bulk v0.1.3/go.mod h1:afon/KtFJYnekIyN20H/+XUvcLFjE8sKR1CfpqfClgM=
|
||||||
|
github.com/go-analyze/charts v0.5.26 h1:rSwZikLQuFX6cJzwI8OAgaWZneG1kDYxD857ms00ZxY=
|
||||||
|
github.com/go-analyze/charts v0.5.26/go.mod h1:s1YvQhjiSwtLx1f2dOKfiV9x2TT49nVSL6v2rlRpTbY=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -423,6 +423,16 @@ func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
sample := platform.SampleLiveMetrics()
|
sample := platform.SampleLiveMetrics()
|
||||||
|
|
||||||
|
// Feed ring buffers for server-side SVG charts
|
||||||
|
for _, t := range sample.Temps {
|
||||||
|
if t.Name == "CPU" {
|
||||||
|
h.ringCPUTemp.push(t.Celsius)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.ringPower.push(sample.PowerW)
|
||||||
|
|
||||||
b, err := json.Marshal(sample)
|
b, err := json.Marshal(sample)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -66,9 +66,6 @@ tr:hover td{background:#1a2030}
|
|||||||
.form-row label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}
|
.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,.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}
|
.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}
|
.chart-legend{font-size:11px;color:#64748b;padding:4px 0}
|
||||||
/* Grid */
|
/* Grid */
|
||||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||||
@@ -245,94 +242,36 @@ func renderHealthCard(opts HandlerOptions) string {
|
|||||||
// ── Metrics ───────────────────────────────────────────────────────────────────
|
// ── Metrics ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderMetrics() string {
|
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="grid2">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-head">GPU Metrics</div>
|
<div class="card-head">System</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="chart-wrap"><canvas id="chart-gpu-temp" class="chart"></canvas></div>
|
<img id="chart-cpu-temp" src="/api/metrics/chart/cpu-temp.svg" style="width:100%;border-radius:6px" alt="CPU Temp">
|
||||||
<div class="chart-legend">Temperature °C</div>
|
<img id="chart-power" src="/api/metrics/chart/power.svg" style="width:100%;border-radius:6px;margin-top:8px" alt="Power">
|
||||||
<div class="chart-wrap"><canvas id="chart-gpu-usage" class="chart"></canvas></div>
|
<div id="sys-table" style="margin-top:8px"></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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-head">System Metrics</div>
|
<div class="card-head">GPU</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="chart-wrap"><canvas id="chart-cpu-temp" class="chart"></canvas></div>
|
<div id="gpu-table"><p style="color:#64748b;font-size:12px">Waiting for data...</p></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const WINDOW = 120;
|
function refreshCharts() {
|
||||||
const history = {gpuTemp:[],gpuUsage:[],gpuPower:[],cpuTemp:[],fans:[],power:[]};
|
const t = '?t=' + Date.now();
|
||||||
const colors = ['#60a5fa','#34d399','#f87171','#fbbf24','#a78bfa','#fb7185'];
|
['chart-cpu-temp','chart-power'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
function push(arr, val) { arr.push(val); if (arr.length > WINDOW) arr.shift(); }
|
if (el) el.src = el.src.split('?')[0] + t;
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setInterval(refreshCharts, 2000);
|
||||||
|
|
||||||
const es = new EventSource('/api/metrics/stream');
|
const es = new EventSource('/api/metrics/stream');
|
||||||
es.addEventListener('metrics', e => {
|
es.addEventListener('metrics', e => {
|
||||||
const d = JSON.parse(e.data);
|
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 =>
|
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>'
|
'<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('');
|
).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>' :
|
'<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>';
|
'<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 = '';
|
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>';
|
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>');
|
(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>';
|
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 ?
|
document.getElementById('sys-table').innerHTML = sysHTML ?
|
||||||
'<table style="margin-top:8px">'+sysHTML+'</table>' :
|
'<table>'+sysHTML+'</table>' :
|
||||||
'<p style="color:#64748b;font-size:12px">No sensor data (requires ipmitool/sensors)</p>';
|
'<p style="color:#64748b;font-size:12px">No sensor data (ipmitool/sensors required)</p>';
|
||||||
});
|
});
|
||||||
es.onerror = () => { /* reconnect automatically */ };
|
es.onerror = () => {};
|
||||||
</script>`
|
</script>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"bee/audit/internal/app"
|
"bee/audit/internal/app"
|
||||||
"bee/audit/internal/runtimeenv"
|
"bee/audit/internal/runtimeenv"
|
||||||
|
gocharts "github.com/go-analyze/charts"
|
||||||
|
"reanimator/chart/viewer"
|
||||||
|
"reanimator/chart/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultTitle = "Bee Hardware Audit"
|
const defaultTitle = "Bee Hardware Audit"
|
||||||
@@ -24,10 +29,49 @@ type HandlerOptions struct {
|
|||||||
RuntimeMode runtimeenv.Mode
|
RuntimeMode runtimeenv.Mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metricsRing holds a rolling window of live metric samples.
|
||||||
|
type metricsRing struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
vals []float64
|
||||||
|
labels []string
|
||||||
|
size int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMetricsRing(size int) *metricsRing {
|
||||||
|
return &metricsRing{size: size, vals: make([]float64, 0, size), labels: make([]string, 0, size)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *metricsRing) push(v float64) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if len(r.vals) >= r.size {
|
||||||
|
r.vals = r.vals[1:]
|
||||||
|
r.labels = r.labels[1:]
|
||||||
|
}
|
||||||
|
r.vals = append(r.vals, v)
|
||||||
|
r.labels = append(r.labels, time.Now().Format("15:04"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *metricsRing) snapshot() ([]float64, []string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
v := make([]float64, len(r.vals))
|
||||||
|
l := make([]string, len(r.labels))
|
||||||
|
copy(v, r.vals)
|
||||||
|
copy(l, r.labels)
|
||||||
|
return v, l
|
||||||
|
}
|
||||||
|
|
||||||
// handler is the HTTP handler for the web UI.
|
// handler is the HTTP handler for the web UI.
|
||||||
type handler struct {
|
type handler struct {
|
||||||
opts HandlerOptions
|
opts HandlerOptions
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
|
ringCPUTemp *metricsRing
|
||||||
|
ringPower *metricsRing
|
||||||
|
ringFans []*metricsRing
|
||||||
|
ringGPUTemp []*metricsRing
|
||||||
|
ringGPUUtil []*metricsRing
|
||||||
|
ringsMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates the HTTP mux with all routes.
|
// NewHandler creates the HTTP mux with all routes.
|
||||||
@@ -42,7 +86,11 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
opts.RuntimeMode = runtimeenv.ModeAuto
|
opts.RuntimeMode = runtimeenv.ModeAuto
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &handler{opts: opts}
|
h := &handler{
|
||||||
|
opts: opts,
|
||||||
|
ringCPUTemp: newMetricsRing(120),
|
||||||
|
ringPower: newMetricsRing(120),
|
||||||
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// ── Infrastructure ──────────────────────────────────────────────────────
|
// ── Infrastructure ──────────────────────────────────────────────────────
|
||||||
@@ -87,8 +135,12 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
// Preflight
|
// Preflight
|
||||||
mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight)
|
mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight)
|
||||||
|
|
||||||
// Metrics — SSE stream of live sensor data
|
// Metrics — SSE stream of live sensor data + server-side SVG charts
|
||||||
mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream)
|
mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream)
|
||||||
|
mux.HandleFunc("GET /api/metrics/chart/", h.handleMetricsChartSVG)
|
||||||
|
|
||||||
|
// Reanimator chart static assets
|
||||||
|
mux.Handle("GET /chart/static/", http.StripPrefix("/chart/static/", web.Static()))
|
||||||
|
|
||||||
// ── Pages ────────────────────────────────────────────────────────────────
|
// ── Pages ────────────────────────────────────────────────────────────────
|
||||||
mux.HandleFunc("GET /", h.handlePage)
|
mux.HandleFunc("GET /", h.handlePage)
|
||||||
@@ -181,10 +233,72 @@ func (h *handler) handleExportIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) {
|
||||||
snapshot, _ := loadSnapshot(h.opts.AuditPath)
|
snapshot, _ := loadSnapshot(h.opts.AuditPath)
|
||||||
body := renderViewerPage(h.opts.Title, snapshot)
|
body, err := viewer.RenderHTML(snapshot, h.opts.Title)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_, _ = w.Write([]byte(body))
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/api/metrics/chart/")
|
||||||
|
name = strings.TrimSuffix(name, ".svg")
|
||||||
|
|
||||||
|
var ring *metricsRing
|
||||||
|
var title, unit string
|
||||||
|
switch name {
|
||||||
|
case "cpu-temp":
|
||||||
|
ring, title, unit = h.ringCPUTemp, "CPU Temperature", "°C"
|
||||||
|
case "power":
|
||||||
|
ring, title, unit = h.ringPower, "System Power", "W"
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vals, labels := ring.snapshot()
|
||||||
|
if len(vals) == 0 {
|
||||||
|
vals = []float64{0}
|
||||||
|
labels = []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sparse x-axis labels
|
||||||
|
sparse := make([]string, len(labels))
|
||||||
|
step := len(labels) / 6
|
||||||
|
if step < 1 {
|
||||||
|
step = 1
|
||||||
|
}
|
||||||
|
for i := range labels {
|
||||||
|
if i%step == 0 {
|
||||||
|
sparse[i] = labels[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opt := gocharts.NewLineChartOptionWithData([][]float64{vals})
|
||||||
|
opt.Title = gocharts.TitleOption{Text: title + " (" + unit + ")"}
|
||||||
|
opt.XAxis.Labels = sparse
|
||||||
|
opt.Legend = gocharts.LegendOption{Show: gocharts.Ptr(false)}
|
||||||
|
|
||||||
|
p := gocharts.NewPainter(gocharts.PainterOptions{
|
||||||
|
OutputFormat: gocharts.ChartOutputSVG,
|
||||||
|
Width: 600,
|
||||||
|
Height: 180,
|
||||||
|
}, gocharts.PainterThemeOption(gocharts.GetTheme("grafana")))
|
||||||
|
if err := p.LineChart(opt); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "image/svg+xml")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page handler ─────────────────────────────────────────────────────────────
|
// ── Page handler ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user