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:
11
audit/go.mod
11
audit/go.mod
@@ -1,6 +1,6 @@
|
|||||||
module bee/audit
|
module bee/audit
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.0
|
||||||
|
|
||||||
replace reanimator/chart => ../internal/chart
|
replace reanimator/chart => ../internal/chart
|
||||||
|
|
||||||
@@ -13,5 +13,14 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-analyze/bulk v0.1.3 // indirect
|
github.com/go-analyze/bulk v0.1.3 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // 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/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
|
||||||
)
|
)
|
||||||
|
|||||||
19
audit/go.sum
19
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/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 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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=
|
||||||
|
|||||||
@@ -592,7 +592,7 @@ func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request)
|
|||||||
if !sseStart(w) {
|
if !sseStart(w) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ticker := time.NewTicker(3 * time.Second)
|
ticker := time.NewTicker(1 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -600,47 +600,10 @@ func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
sample := platform.SampleLiveMetrics()
|
sample := platform.SampleLiveMetrics()
|
||||||
|
h.feedRings(sample)
|
||||||
// Feed server ring buffers
|
if h.metricsDB != nil {
|
||||||
for _, t := range sample.Temps {
|
_ = h.metricsDB.Write(sample)
|
||||||
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)
|
|
||||||
|
|
||||||
// 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)
|
b, err := json.Marshal(sample)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
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) {
|
func (h *handler) pushNamedMetricRing(dst *[]*namedMetricsRing, name string, value float64) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
320
audit/internal/webui/metricsdb.go
Normal file
320
audit/internal/webui/metricsdb.go
Normal file
@@ -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}
|
||||||
|
}
|
||||||
@@ -132,6 +132,8 @@ type handler struct {
|
|||||||
// per-GPU rings (index = GPU index)
|
// per-GPU rings (index = GPU index)
|
||||||
gpuRings []*gpuRings
|
gpuRings []*gpuRings
|
||||||
ringsMu sync.Mutex
|
ringsMu sync.Mutex
|
||||||
|
// metrics persistence (nil if DB unavailable)
|
||||||
|
metricsDB *MetricsDB
|
||||||
// install job (at most one at a time)
|
// install job (at most one at a time)
|
||||||
installJob *jobState
|
installJob *jobState
|
||||||
installMu sync.Mutex
|
installMu sync.Mutex
|
||||||
@@ -158,6 +160,18 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
ringMemLoad: newMetricsRing(120),
|
ringMemLoad: newMetricsRing(120),
|
||||||
ringPower: 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)
|
globalQueue.startWorker(&opts)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
@@ -231,9 +245,10 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
mux.HandleFunc("POST /api/install/run", h.handleAPIInstallRun)
|
mux.HandleFunc("POST /api/install/run", h.handleAPIInstallRun)
|
||||||
mux.HandleFunc("GET /api/install/stream", h.handleAPIInstallStream)
|
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/stream", h.handleAPIMetricsStream)
|
||||||
mux.HandleFunc("GET /api/metrics/chart/", h.handleMetricsChartSVG)
|
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/*)
|
// Reanimator chart static assets (viewer template expects /static/*)
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", web.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)
|
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)
|
title = sanitizeChartText(title)
|
||||||
names = sanitizeChartTexts(names)
|
names = sanitizeChartTexts(names)
|
||||||
sparse := sanitizeChartTexts(sparseLabels(labels, 6))
|
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.Title = gocharts.TitleOption{Text: title}
|
||||||
opt.XAxis.Labels = sparse
|
opt.XAxis.Labels = sparse
|
||||||
opt.Legend = gocharts.LegendOption{SeriesNames: names}
|
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 {
|
if yMin != nil || yMax != nil {
|
||||||
opt.YAxis = []gocharts.YAxisOption{{
|
opt.YAxis = []gocharts.YAxisOption{{
|
||||||
Min: yMin,
|
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.
|
// Add a single peak mark line on the series that holds the global maximum.
|
||||||
peakIdx, peakVal := globalPeakSeries(datasets)
|
peakIdx, _ := globalPeakSeries(datasets)
|
||||||
if peakIdx >= 0 && peakVal > 0 && peakIdx < len(opt.SeriesList) {
|
if peakIdx >= 0 && peakIdx < len(opt.SeriesList) {
|
||||||
opt.SeriesList[peakIdx].MarkLine = gocharts.NewMarkLine(gocharts.SeriesMarkTypeMax)
|
opt.SeriesList[peakIdx].MarkLine = gocharts.NewMarkLine(gocharts.SeriesMarkTypeMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,6 +694,33 @@ func globalPeakSeries(datasets [][]float64) (idx int, peak float64) {
|
|||||||
return idx, peak
|
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 {
|
func sanitizeChartText(s string) string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -747,6 +802,17 @@ func sparseLabels(labels []string, n int) []string {
|
|||||||
return out
|
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 ─────────────────────────────────────────────────────────────
|
// ── Page handler ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *handler) handleReady(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
Reference in New Issue
Block a user