From ff0acc369842e3476095de6ae1098946a552b510 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Fri, 27 Mar 2026 23:07:47 +0300 Subject: [PATCH] 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 --- audit/go.mod | 14 ++++ audit/go.sum | 18 +++++ audit/internal/webui/api.go | 10 +++ audit/internal/webui/pages.go | 106 +++++---------------------- audit/internal/webui/server.go | 126 +++++++++++++++++++++++++++++++-- 5 files changed, 179 insertions(+), 95 deletions(-) create mode 100644 audit/go.sum diff --git a/audit/go.mod b/audit/go.mod index 3fcb566..7275246 100644 --- a/audit/go.mod +++ b/audit/go.mod @@ -1,3 +1,17 @@ module bee/audit 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 +) diff --git a/audit/go.sum b/audit/go.sum new file mode 100644 index 0000000..74ec432 --- /dev/null +++ b/audit/go.sum @@ -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= diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index 392e335..025aad1 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -423,6 +423,16 @@ func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request) return case <-ticker.C: 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) if err != nil { continue diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index 5a47a37..4b59643 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -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 `

Live server metrics updated every second.

+ return `

Live server metrics, charts updated every 2 seconds.

-
GPU Metrics
+
System
-
-
Temperature °C
-
-
Usage %
-
-
Power W
-
+ CPU Temp + Power +
-
System Metrics
+
GPU
-
-
CPU Temperature °C
-
-
Fan Speed RPM
-
-
System Power W
-
+

Waiting for data...

` } diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go index d39daef..9723863 100644 --- a/audit/internal/webui/server.go +++ b/audit/internal/webui/server.go @@ -8,9 +8,14 @@ import ( "os" "path/filepath" "strings" + "sync" + "time" "bee/audit/internal/app" "bee/audit/internal/runtimeenv" + gocharts "github.com/go-analyze/charts" + "reanimator/chart/viewer" + "reanimator/chart/web" ) const defaultTitle = "Bee Hardware Audit" @@ -24,10 +29,49 @@ type HandlerOptions struct { 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. type handler struct { - opts HandlerOptions - mux *http.ServeMux + opts HandlerOptions + mux *http.ServeMux + ringCPUTemp *metricsRing + ringPower *metricsRing + ringFans []*metricsRing + ringGPUTemp []*metricsRing + ringGPUUtil []*metricsRing + ringsMu sync.Mutex } // NewHandler creates the HTTP mux with all routes. @@ -42,7 +86,11 @@ func NewHandler(opts HandlerOptions) http.Handler { opts.RuntimeMode = runtimeenv.ModeAuto } - h := &handler{opts: opts} + h := &handler{ + opts: opts, + ringCPUTemp: newMetricsRing(120), + ringPower: newMetricsRing(120), + } mux := http.NewServeMux() // ── Infrastructure ────────────────────────────────────────────────────── @@ -87,8 +135,12 @@ func NewHandler(opts HandlerOptions) http.Handler { // Preflight 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/chart/", h.handleMetricsChartSVG) + + // Reanimator chart static assets + mux.Handle("GET /chart/static/", http.StripPrefix("/chart/static/", web.Static())) // ── Pages ──────────────────────────────────────────────────────────────── 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) { 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("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 ─────────────────────────────────────────────────────────────