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:
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user