feat(metrics): SQLite persistence + chart fixes (no dots, peak label, min/avg/max in title)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user