332 lines
8.2 KiB
Go
332 lines
8.2 KiB
Go
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 sys_metrics ORDER BY ts DESC LIMIT ?`, 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
|
|
}
|
|
// 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
|
|
}
|
|
|
|
// 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}
|
|
}
|