package webui import ( "database/sql" "encoding/csv" "io" "os" "path/filepath" "strconv" "time" "bee/audit/internal/platform" _ "modernc.org/sqlite" ) const metricsDBPath = "/appdata/bee/metrics.db" // 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) { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return nil, err } 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). func (m *MetricsDB) LoadRecent(n int) ([]platform.LiveMetricSample, error) { return m.loadSamples(`SELECT ts,cpu_load_pct,mem_load_pct,power_w FROM (SELECT ts,cpu_load_pct,mem_load_pct,power_w FROM sys_metrics ORDER BY ts DESC LIMIT ?) ORDER BY ts`, n) } // LoadAll returns all persisted samples in chronological order (oldest first). func (m *MetricsDB) LoadAll() ([]platform.LiveMetricSample, error) { return m.loadSamples(`SELECT ts,cpu_load_pct,mem_load_pct,power_w FROM sys_metrics ORDER BY ts`, nil) } // loadSamples reconstructs LiveMetricSample rows from the normalized tables. func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetricSample, error) { rows, err := m.db.Query(query, args...) 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 } // 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 } // 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} }