From 3fda18f7088ee181e5f8011aee806269110f49b2 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sun, 29 Mar 2026 11:37:59 +0300 Subject: [PATCH] feat(metrics): SQLite persistence + chart fixes (no dots, peak label, min/avg/max in title) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add modernc.org/sqlite dependency; write every sample to /appdata/bee/metrics.db (WAL mode, prune to 24h on startup) - Pre-fill ring buffers from last 120 DB rows on startup so charts survive service restarts - Ticker changed 3s→1s; chart JS refresh will be set to 2s (lag ≤3s) - Add GET /api/metrics/export.csv for full history download - Chart rendering: SymbolNone (no dots), right padding=80px so peak mark line label is not clipped, min/avg/max appended to chart title Co-Authored-By: Claude Sonnet 4.6 --- audit/go.mod | 11 +- audit/go.sum | 19 ++ audit/internal/webui/api.go | 85 ++++---- audit/internal/webui/metricsdb.go | 320 ++++++++++++++++++++++++++++++ audit/internal/webui/server.go | 72 ++++++- 5 files changed, 462 insertions(+), 45 deletions(-) create mode 100644 audit/internal/webui/metricsdb.go diff --git a/audit/go.mod b/audit/go.mod index 7275246..26ad5f8 100644 --- a/audit/go.mod +++ b/audit/go.mod @@ -1,6 +1,6 @@ module bee/audit -go 1.24.0 +go 1.25.0 replace reanimator/chart => ../internal/chart @@ -13,5 +13,14 @@ 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 + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/image v0.24.0 // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.48.0 // indirect ) diff --git a/audit/go.sum b/audit/go.sum index 74ec432..66747ed 100644 --- a/audit/go.sum +++ b/audit/go.sum @@ -8,11 +8,30 @@ github.com/go-analyze/charts v0.5.26 h1:rSwZikLQuFX6cJzwI8OAgaWZneG1kDYxD857ms00 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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= +modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index c3033d7..7b295c7 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -592,7 +592,7 @@ func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request) if !sseStart(w) { return } - ticker := time.NewTicker(3 * time.Second) + ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { @@ -600,47 +600,10 @@ func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request) return case <-ticker.C: sample := platform.SampleLiveMetrics() - - // Feed server ring buffers - for _, t := range sample.Temps { - switch t.Group { - case "cpu": - h.pushNamedMetricRing(&h.cpuTempRings, t.Name, t.Celsius) - case "ambient": - h.pushNamedMetricRing(&h.ambientTempRings, t.Name, t.Celsius) - } + h.feedRings(sample) + if h.metricsDB != nil { + _ = h.metricsDB.Write(sample) } - h.ringPower.push(sample.PowerW) - h.ringCPULoad.push(sample.CPULoadPct) - h.ringMemLoad.push(sample.MemLoadPct) - - // Feed fan ring buffers (grow on first sight) - h.ringsMu.Lock() - for i, fan := range sample.Fans { - for len(h.ringFans) <= i { - h.ringFans = append(h.ringFans, newMetricsRing(120)) - h.fanNames = append(h.fanNames, fan.Name) - } - h.ringFans[i].push(float64(fan.RPM)) - } - // Feed per-GPU ring buffers (grow on first sight) - for _, gpu := range sample.GPUs { - idx := gpu.GPUIndex - for len(h.gpuRings) <= idx { - h.gpuRings = append(h.gpuRings, &gpuRings{ - Temp: newMetricsRing(120), - Util: newMetricsRing(120), - MemUtil: newMetricsRing(120), - Power: newMetricsRing(120), - }) - } - h.gpuRings[idx].Temp.push(gpu.TempC) - h.gpuRings[idx].Util.push(gpu.UsagePct) - h.gpuRings[idx].MemUtil.push(gpu.MemUsagePct) - h.gpuRings[idx].Power.push(gpu.PowerW) - } - h.ringsMu.Unlock() - b, err := json.Marshal(sample) if err != nil { continue @@ -652,6 +615,46 @@ func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request) } } +// feedRings pushes one sample into all in-memory ring buffers. +func (h *handler) feedRings(sample platform.LiveMetricSample) { + for _, t := range sample.Temps { + switch t.Group { + case "cpu": + h.pushNamedMetricRing(&h.cpuTempRings, t.Name, t.Celsius) + case "ambient": + h.pushNamedMetricRing(&h.ambientTempRings, t.Name, t.Celsius) + } + } + h.ringPower.push(sample.PowerW) + h.ringCPULoad.push(sample.CPULoadPct) + h.ringMemLoad.push(sample.MemLoadPct) + + h.ringsMu.Lock() + for i, fan := range sample.Fans { + for len(h.ringFans) <= i { + h.ringFans = append(h.ringFans, newMetricsRing(120)) + h.fanNames = append(h.fanNames, fan.Name) + } + h.ringFans[i].push(float64(fan.RPM)) + } + for _, gpu := range sample.GPUs { + idx := gpu.GPUIndex + for len(h.gpuRings) <= idx { + h.gpuRings = append(h.gpuRings, &gpuRings{ + Temp: newMetricsRing(120), + Util: newMetricsRing(120), + MemUtil: newMetricsRing(120), + Power: newMetricsRing(120), + }) + } + h.gpuRings[idx].Temp.push(gpu.TempC) + h.gpuRings[idx].Util.push(gpu.UsagePct) + h.gpuRings[idx].MemUtil.push(gpu.MemUsagePct) + h.gpuRings[idx].Power.push(gpu.PowerW) + } + h.ringsMu.Unlock() +} + func (h *handler) pushNamedMetricRing(dst *[]*namedMetricsRing, name string, value float64) { if name == "" { return diff --git a/audit/internal/webui/metricsdb.go b/audit/internal/webui/metricsdb.go new file mode 100644 index 0000000..90e4b37 --- /dev/null +++ b/audit/internal/webui/metricsdb.go @@ -0,0 +1,320 @@ +package webui + +import ( + "database/sql" + "encoding/csv" + "fmt" + "io" + "strconv" + "time" + + "bee/audit/internal/platform" + _ "modernc.org/sqlite" +) + +const metricsDBPath = "/appdata/bee/metrics.db" +const metricsKeepDuration = 24 * time.Hour + +// MetricsDB persists live metric samples to SQLite. +type MetricsDB struct { + db *sql.DB +} + +// openMetricsDB opens (or creates) the metrics database at the given path. +func openMetricsDB(path string) (*MetricsDB, error) { + db, err := sql.Open("sqlite", path+"?_journal=WAL&_busy_timeout=5000") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + if err := initMetricsSchema(db); err != nil { + _ = db.Close() + return nil, err + } + return &MetricsDB{db: db}, nil +} + +func initMetricsSchema(db *sql.DB) error { + _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS sys_metrics ( + ts INTEGER NOT NULL, + cpu_load_pct REAL, + mem_load_pct REAL, + power_w REAL, + PRIMARY KEY (ts) +); +CREATE TABLE IF NOT EXISTS gpu_metrics ( + ts INTEGER NOT NULL, + gpu_index INTEGER NOT NULL, + temp_c REAL, + usage_pct REAL, + mem_usage_pct REAL, + power_w REAL, + PRIMARY KEY (ts, gpu_index) +); +CREATE TABLE IF NOT EXISTS fan_metrics ( + ts INTEGER NOT NULL, + name TEXT NOT NULL, + rpm REAL, + PRIMARY KEY (ts, name) +); +CREATE TABLE IF NOT EXISTS temp_metrics ( + ts INTEGER NOT NULL, + name TEXT NOT NULL, + grp TEXT NOT NULL, + celsius REAL, + PRIMARY KEY (ts, name) +); +`) + return err +} + +// Write inserts one sample into all relevant tables. +func (m *MetricsDB) Write(s platform.LiveMetricSample) error { + ts := s.Timestamp.Unix() + tx, err := m.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + _, err = tx.Exec( + `INSERT OR REPLACE INTO sys_metrics(ts,cpu_load_pct,mem_load_pct,power_w) VALUES(?,?,?,?)`, + ts, s.CPULoadPct, s.MemLoadPct, s.PowerW, + ) + if err != nil { + return err + } + for _, g := range s.GPUs { + _, err = tx.Exec( + `INSERT OR REPLACE INTO gpu_metrics(ts,gpu_index,temp_c,usage_pct,mem_usage_pct,power_w) VALUES(?,?,?,?,?,?)`, + ts, g.GPUIndex, g.TempC, g.UsagePct, g.MemUsagePct, g.PowerW, + ) + if err != nil { + return err + } + } + for _, f := range s.Fans { + _, err = tx.Exec( + `INSERT OR REPLACE INTO fan_metrics(ts,name,rpm) VALUES(?,?,?)`, + ts, f.Name, f.RPM, + ) + if err != nil { + return err + } + } + for _, t := range s.Temps { + _, err = tx.Exec( + `INSERT OR REPLACE INTO temp_metrics(ts,name,grp,celsius) VALUES(?,?,?,?)`, + ts, t.Name, t.Group, t.Celsius, + ) + if err != nil { + return err + } + } + return tx.Commit() +} + +// LoadRecent returns up to n samples in chronological order (oldest first). +// It reconstructs LiveMetricSample from the normalized tables. +func (m *MetricsDB) LoadRecent(n int) ([]platform.LiveMetricSample, error) { + rows, err := m.db.Query( + `SELECT ts,cpu_load_pct,mem_load_pct,power_w FROM sys_metrics ORDER BY ts DESC LIMIT ?`, n, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + type sysRow struct { + ts int64 + cpu, mem, pwr float64 + } + var sysRows []sysRow + for rows.Next() { + var r sysRow + if err := rows.Scan(&r.ts, &r.cpu, &r.mem, &r.pwr); err != nil { + continue + } + sysRows = append(sysRows, r) + } + if len(sysRows) == 0 { + return nil, nil + } + // Reverse to chronological order + for i, j := 0, len(sysRows)-1; i < j; i, j = i+1, j-1 { + sysRows[i], sysRows[j] = sysRows[j], sysRows[i] + } + + // Collect min/max ts for range query + minTS := sysRows[0].ts + maxTS := sysRows[len(sysRows)-1].ts + + // Load GPU rows in range + type gpuKey struct{ ts int64; idx int } + gpuData := map[gpuKey]platform.GPUMetricRow{} + gRows, err := m.db.Query( + `SELECT ts,gpu_index,temp_c,usage_pct,mem_usage_pct,power_w FROM gpu_metrics WHERE ts>=? AND ts<=? ORDER BY ts,gpu_index`, + minTS, maxTS, + ) + if err == nil { + defer gRows.Close() + for gRows.Next() { + var ts int64 + var g platform.GPUMetricRow + if err := gRows.Scan(&ts, &g.GPUIndex, &g.TempC, &g.UsagePct, &g.MemUsagePct, &g.PowerW); err == nil { + gpuData[gpuKey{ts, g.GPUIndex}] = g + } + } + } + + // Load fan rows in range + type fanKey struct{ ts int64; name string } + fanData := map[fanKey]float64{} + fRows, err := m.db.Query( + `SELECT ts,name,rpm FROM fan_metrics WHERE ts>=? AND ts<=?`, minTS, maxTS, + ) + if err == nil { + defer fRows.Close() + for fRows.Next() { + var ts int64 + var name string + var rpm float64 + if err := fRows.Scan(&ts, &name, &rpm); err == nil { + fanData[fanKey{ts, name}] = rpm + } + } + } + + // Load temp rows in range + type tempKey struct{ ts int64; name string } + tempData := map[tempKey]platform.TempReading{} + tRows, err := m.db.Query( + `SELECT ts,name,grp,celsius FROM temp_metrics WHERE ts>=? AND ts<=?`, minTS, maxTS, + ) + if err == nil { + defer tRows.Close() + for tRows.Next() { + var ts int64 + var t platform.TempReading + if err := tRows.Scan(&ts, &t.Name, &t.Group, &t.Celsius); err == nil { + tempData[tempKey{ts, t.Name}] = t + } + } + } + + // Collect unique GPU indices and fan names from loaded data (preserve order) + seenGPU := map[int]bool{} + var gpuIndices []int + for k := range gpuData { + if !seenGPU[k.idx] { + seenGPU[k.idx] = true + gpuIndices = append(gpuIndices, k.idx) + } + } + seenFan := map[string]bool{} + var fanNames []string + for k := range fanData { + if !seenFan[k.name] { + seenFan[k.name] = true + fanNames = append(fanNames, k.name) + } + } + seenTemp := map[string]bool{} + var tempNames []string + for k := range tempData { + if !seenTemp[k.name] { + seenTemp[k.name] = true + tempNames = append(tempNames, k.name) + } + } + + samples := make([]platform.LiveMetricSample, len(sysRows)) + for i, r := range sysRows { + s := platform.LiveMetricSample{ + Timestamp: time.Unix(r.ts, 0).UTC(), + CPULoadPct: r.cpu, + MemLoadPct: r.mem, + PowerW: r.pwr, + } + for _, idx := range gpuIndices { + if g, ok := gpuData[gpuKey{r.ts, idx}]; ok { + s.GPUs = append(s.GPUs, g) + } + } + for _, name := range fanNames { + if rpm, ok := fanData[fanKey{r.ts, name}]; ok { + s.Fans = append(s.Fans, platform.FanReading{Name: name, RPM: rpm}) + } + } + for _, name := range tempNames { + if t, ok := tempData[tempKey{r.ts, name}]; ok { + s.Temps = append(s.Temps, t) + } + } + samples[i] = s + } + return samples, nil +} + +// Prune deletes samples older than keepDuration. +func (m *MetricsDB) Prune(keepDuration time.Duration) { + cutoff := time.Now().Add(-keepDuration).Unix() + for _, table := range []string{"sys_metrics", "gpu_metrics", "fan_metrics", "temp_metrics"} { + _, _ = m.db.Exec(fmt.Sprintf("DELETE FROM %s WHERE ts < ?", table), cutoff) + } +} + +// ExportCSV writes all sys+gpu data as CSV to w. +func (m *MetricsDB) ExportCSV(w io.Writer) error { + rows, err := m.db.Query(` + SELECT s.ts, s.cpu_load_pct, s.mem_load_pct, s.power_w, + g.gpu_index, g.temp_c, g.usage_pct, g.mem_usage_pct, g.power_w + FROM sys_metrics s + LEFT JOIN gpu_metrics g ON g.ts = s.ts + ORDER BY s.ts, g.gpu_index + `) + if err != nil { + return err + } + defer rows.Close() + + cw := csv.NewWriter(w) + _ = cw.Write([]string{"ts", "cpu_load_pct", "mem_load_pct", "sys_power_w", "gpu_index", "gpu_temp_c", "gpu_usage_pct", "gpu_mem_pct", "gpu_power_w"}) + for rows.Next() { + var ts int64 + var cpu, mem, pwr float64 + var gpuIdx sql.NullInt64 + var gpuTemp, gpuUse, gpuMem, gpuPow sql.NullFloat64 + if err := rows.Scan(&ts, &cpu, &mem, &pwr, &gpuIdx, &gpuTemp, &gpuUse, &gpuMem, &gpuPow); err != nil { + continue + } + row := []string{ + strconv.FormatInt(ts, 10), + strconv.FormatFloat(cpu, 'f', 2, 64), + strconv.FormatFloat(mem, 'f', 2, 64), + strconv.FormatFloat(pwr, 'f', 1, 64), + } + if gpuIdx.Valid { + row = append(row, + strconv.FormatInt(gpuIdx.Int64, 10), + strconv.FormatFloat(gpuTemp.Float64, 'f', 1, 64), + strconv.FormatFloat(gpuUse.Float64, 'f', 1, 64), + strconv.FormatFloat(gpuMem.Float64, 'f', 1, 64), + strconv.FormatFloat(gpuPow.Float64, 'f', 1, 64), + ) + } else { + row = append(row, "", "", "", "", "") + } + _ = cw.Write(row) + } + cw.Flush() + return cw.Error() +} + +// Close closes the database. +func (m *MetricsDB) Close() { _ = m.db.Close() } + +func nullFloat(v float64) sql.NullFloat64 { + return sql.NullFloat64{Float64: v, Valid: true} +} diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go index f78664b..3bd9d67 100644 --- a/audit/internal/webui/server.go +++ b/audit/internal/webui/server.go @@ -132,6 +132,8 @@ type handler struct { // per-GPU rings (index = GPU index) gpuRings []*gpuRings ringsMu sync.Mutex + // metrics persistence (nil if DB unavailable) + metricsDB *MetricsDB // install job (at most one at a time) installJob *jobState installMu sync.Mutex @@ -158,6 +160,18 @@ func NewHandler(opts HandlerOptions) http.Handler { ringMemLoad: newMetricsRing(120), ringPower: newMetricsRing(120), } + + // Open metrics DB and pre-fill ring buffers from history. + if db, err := openMetricsDB(metricsDBPath); err == nil { + h.metricsDB = db + db.Prune(metricsKeepDuration) + if samples, err := db.LoadRecent(120); err == nil { + for _, s := range samples { + h.feedRings(s) + } + } + } + globalQueue.startWorker(&opts) mux := http.NewServeMux() @@ -231,9 +245,10 @@ func NewHandler(opts HandlerOptions) http.Handler { mux.HandleFunc("POST /api/install/run", h.handleAPIInstallRun) mux.HandleFunc("GET /api/install/stream", h.handleAPIInstallStream) - // Metrics — SSE stream of live sensor data + server-side SVG charts + // Metrics — SSE stream of live sensor data + server-side SVG charts + CSV export mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream) mux.HandleFunc("GET /api/metrics/chart/", h.handleMetricsChartSVG) + mux.HandleFunc("GET /api/metrics/export.csv", h.handleAPIMetricsExportCSV) // Reanimator chart static assets (viewer template expects /static/*) mux.Handle("GET /static/", http.StripPrefix("/static/", web.Static())) @@ -618,6 +633,16 @@ func renderChartSVG(title string, datasets [][]float64, names []string, labels [ datasets[i] = make([]float64, n) } } + // Append global min/avg/max to title. + mn, avg, mx := globalStats(datasets) + if mx > 0 { + title = fmt.Sprintf("%s ↓%s ~%s ↑%s", + title, + chartLegendNumber(mn), + chartLegendNumber(avg), + chartLegendNumber(mx), + ) + } title = sanitizeChartText(title) names = sanitizeChartTexts(names) sparse := sanitizeChartTexts(sparseLabels(labels, 6)) @@ -626,6 +651,9 @@ func renderChartSVG(title string, datasets [][]float64, names []string, labels [ opt.Title = gocharts.TitleOption{Text: title} opt.XAxis.Labels = sparse opt.Legend = gocharts.LegendOption{SeriesNames: names} + opt.Symbol = gocharts.SymbolNone + // Right padding: reserve space for the MarkLine label (library recommendation). + opt.Padding = gocharts.NewBox(20, 80, 20, 20) if yMin != nil || yMax != nil { opt.YAxis = []gocharts.YAxisOption{{ Min: yMin, @@ -635,8 +663,8 @@ func renderChartSVG(title string, datasets [][]float64, names []string, labels [ } // Add a single peak mark line on the series that holds the global maximum. - peakIdx, peakVal := globalPeakSeries(datasets) - if peakIdx >= 0 && peakVal > 0 && peakIdx < len(opt.SeriesList) { + peakIdx, _ := globalPeakSeries(datasets) + if peakIdx >= 0 && peakIdx < len(opt.SeriesList) { opt.SeriesList[peakIdx].MarkLine = gocharts.NewMarkLine(gocharts.SeriesMarkTypeMax) } @@ -666,6 +694,33 @@ func globalPeakSeries(datasets [][]float64) (idx int, peak float64) { return idx, peak } +// globalStats returns min, average, and max across all values in all datasets. +func globalStats(datasets [][]float64) (mn, avg, mx float64) { + var sum float64 + var count int + first := true + for _, ds := range datasets { + for _, v := range ds { + if first { + mn, mx = v, v + first = false + } + if v < mn { + mn = v + } + if v > mx { + mx = v + } + sum += v + count++ + } + } + if count > 0 { + avg = sum / float64(count) + } + return mn, avg, mx +} + func sanitizeChartText(s string) string { if s == "" { return "" @@ -747,6 +802,17 @@ func sparseLabels(labels []string, n int) []string { return out } +func (h *handler) handleAPIMetricsExportCSV(w http.ResponseWriter, r *http.Request) { + if h.metricsDB == nil { + http.Error(w, "metrics database not available", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="bee-metrics.csv"`) + w.Header().Set("Cache-Control", "no-store") + _ = h.metricsDB.ExportCSV(w) +} + // ── Page handler ───────────────────────────────────────────────────────────── func (h *handler) handleReady(w http.ResponseWriter, r *http.Request) {