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
-
+

+

+
-
System Metrics
+
GPU
-
-
CPU Temperature °C
-
-
Fan Speed RPM
-
-
System Power W
-
+
`
}
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 ─────────────────────────────────────────────────────────────