package webui import ( "database/sql" "encoding/csv" "io" "os" "path/filepath" "sort" "strconv" "strings" "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 } func (m *MetricsDB) Close() error { if m == nil || m.db == nil { return nil } return m.db.Close() } // 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, clock_mhz REAL, mem_clock_mhz 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) ); `) if err != nil { return err } if err := ensureMetricsColumn(db, "gpu_metrics", "clock_mhz", "REAL"); err != nil { return err } return ensureMetricsColumn(db, "gpu_metrics", "mem_clock_mhz", "REAL") } func ensureMetricsColumn(db *sql.DB, table, column, definition string) error { rows, err := db.Query("PRAGMA table_info(" + table + ")") if err != nil { return err } defer rows.Close() for rows.Next() { var cid int var name, ctype string var notNull, pk int var dflt sql.NullString if err := rows.Scan(&cid, &name, &ctype, ¬Null, &dflt, &pk); err != nil { return err } if strings.EqualFold(name, column) { return nil } } if err := rows.Err(); err != nil { return err } _, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition) 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,clock_mhz,mem_clock_mhz) VALUES(?,?,?,?,?,?,?,?)`, ts, g.GPUIndex, g.TempC, g.UsagePct, g.MemUsagePct, g.PowerW, g.ClockMHz, g.MemClockMHz, ) 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) } // LoadBetween returns samples in chronological order within the given time window. func (m *MetricsDB) LoadBetween(start, end time.Time) ([]platform.LiveMetricSample, error) { if m == nil { return nil, nil } if start.IsZero() || end.IsZero() { return nil, nil } if end.Before(start) { start, end = end, start } return m.loadSamples( `SELECT ts,cpu_load_pct,mem_load_pct,power_w FROM sys_metrics WHERE ts>=? AND ts<=? ORDER BY ts`, start.Unix(), end.Unix(), ) } // 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,IFNULL(clock_mhz,0),IFNULL(mem_clock_mhz,0) 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, &g.ClockMHz, &g.MemClockMHz); 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/temp names from loaded data. // Sort each list so that sample reconstruction is deterministic regardless // of Go's non-deterministic map iteration 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) } } sort.Ints(gpuIndices) seenFan := map[string]bool{} var fanNames []string for k := range fanData { if !seenFan[k.name] { seenFan[k.name] = true fanNames = append(fanNames, k.name) } } sort.Strings(fanNames) seenTemp := map[string]bool{} var tempNames []string for k := range tempData { if !seenTemp[k.name] { seenTemp[k.name] = true tempNames = append(tempNames, k.name) } } sort.Strings(tempNames) 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, g.clock_mhz, g.mem_clock_mhz 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", "gpu_clock_mhz", "gpu_mem_clock_mhz"}) for rows.Next() { var ts int64 var cpu, mem, pwr float64 var gpuIdx sql.NullInt64 var gpuTemp, gpuUse, gpuMem, gpuPow, gpuClock, gpuMemClock sql.NullFloat64 if err := rows.Scan(&ts, &cpu, &mem, &pwr, &gpuIdx, &gpuTemp, &gpuUse, &gpuMem, &gpuPow, &gpuClock, &gpuMemClock); 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), strconv.FormatFloat(gpuClock.Float64, 'f', 1, 64), strconv.FormatFloat(gpuMemClock.Float64, 'f', 1, 64), ) } else { row = append(row, "", "", "", "", "", "", "") } _ = cw.Write(row) } cw.Flush() return cw.Error() } func nullFloat(v float64) sql.NullFloat64 { return sql.NullFloat64{Float64: v, Valid: true} }