Files
felhom-controller/controller/internal/metrics/store.go
T

360 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package metrics
import (
"database/sql"
"fmt"
"log"
"time"
_ "modernc.org/sqlite"
)
// MetricsStore manages SQLite storage for system and container metrics.
type MetricsStore struct {
db *sql.DB
logger *log.Logger
}
// NewMetricsStore opens (or creates) a SQLite database at dbPath and initializes the schema.
func NewMetricsStore(dbPath string, logger *log.Logger) (*MetricsStore, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// Enable WAL mode and verify it took effect
var walMode string
if err := db.QueryRow("PRAGMA journal_mode=WAL").Scan(&walMode); err != nil {
db.Close()
return nil, fmt.Errorf("set WAL mode: %w", err)
}
if walMode != "wal" {
db.Close()
return nil, fmt.Errorf("WAL mode not supported on this filesystem (got %q)", walMode)
}
// Set remaining pragmas for performance and concurrency
pragmas := []string{
"PRAGMA synchronous=NORMAL",
"PRAGMA busy_timeout=5000",
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
db.Close()
return nil, fmt.Errorf("pragma %q: %w", p, err)
}
}
// Create tables
schema := []string{
`CREATE TABLE IF NOT EXISTS system_metrics (
ts INTEGER NOT NULL,
cpu_percent REAL NOT NULL,
mem_used_mb INTEGER NOT NULL,
mem_total_mb INTEGER NOT NULL,
temp_celsius REAL,
load_avg_1 REAL,
load_avg_5 REAL,
load_avg_15 REAL,
disk_used_gb REAL,
disk_total_gb REAL,
hdd_used_gb REAL,
hdd_total_gb REAL
)`,
`CREATE INDEX IF NOT EXISTS idx_system_ts ON system_metrics(ts)`,
`CREATE TABLE IF NOT EXISTS container_metrics (
ts INTEGER NOT NULL,
container_name TEXT NOT NULL,
cpu_percent REAL NOT NULL,
mem_usage_mb REAL NOT NULL,
mem_limit_mb REAL,
net_rx_bytes INTEGER,
net_tx_bytes INTEGER,
block_read_bytes INTEGER,
block_write_bytes INTEGER
)`,
`CREATE INDEX IF NOT EXISTS idx_container_ts ON container_metrics(ts)`,
`CREATE INDEX IF NOT EXISTS idx_container_name ON container_metrics(container_name, ts)`,
}
for _, s := range schema {
if _, err := db.Exec(s); err != nil {
db.Close()
return nil, fmt.Errorf("schema: %w", err)
}
}
return &MetricsStore{db: db, logger: logger}, nil
}
// Close closes the underlying database connection.
func (s *MetricsStore) Close() error {
return s.db.Close()
}
// InsertSystemMetrics inserts a single system metrics sample.
func (s *MetricsStore) InsertSystemMetrics(m SystemSample) error {
_, err := s.db.Exec(
`INSERT INTO system_metrics (ts, cpu_percent, mem_used_mb, mem_total_mb, temp_celsius,
load_avg_1, load_avg_5, load_avg_15, disk_used_gb, disk_total_gb, hdd_used_gb, hdd_total_gb)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.Timestamp, m.CPUPercent, m.MemUsedMB, m.MemTotalMB, m.TempCelsius,
m.LoadAvg1, m.LoadAvg5, m.LoadAvg15, m.DiskUsedGB, m.DiskTotalGB, m.HDDUsedGB, m.HDDTotalGB,
)
return err
}
// InsertContainerMetrics inserts a batch of container metrics samples.
func (s *MetricsStore) InsertContainerMetrics(samples []ContainerSample) error {
if len(samples) == 0 {
return nil
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(
`INSERT INTO container_metrics (ts, container_name, cpu_percent, mem_usage_mb, mem_limit_mb,
net_rx_bytes, net_tx_bytes, block_read_bytes, block_write_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
if err != nil {
return err
}
defer stmt.Close()
for _, c := range samples {
if _, err := stmt.Exec(c.Timestamp, c.ContainerName, c.CPUPercent, c.MemUsageMB, c.MemLimitMB,
c.NetRxBytes, c.NetTxBytes, c.BlockReadBytes, c.BlockWriteBytes); err != nil {
return err
}
}
return tx.Commit()
}
// QuerySystemMetrics returns downsampled system metrics between from and to.
// resolution controls the approximate number of data points returned.
func (s *MetricsStore) QuerySystemMetrics(from, to time.Time, resolution int) ([]SystemSample, error) {
fromTS := from.Unix()
toTS := to.Unix()
if resolution <= 0 {
resolution = 200
}
rangeSeconds := toTS - fromTS
if rangeSeconds <= 0 {
return nil, nil
}
bucketSeconds := rangeSeconds / int64(resolution)
if bucketSeconds < 1 {
bucketSeconds = 1
}
rows, err := s.db.Query(`
SELECT
(ts / ?) * ? AS bucket_ts,
AVG(cpu_percent),
ROUND(AVG(mem_used_mb)),
ROUND(AVG(mem_total_mb)),
AVG(temp_celsius),
AVG(load_avg_1),
AVG(load_avg_5),
AVG(load_avg_15),
AVG(disk_used_gb),
AVG(disk_total_gb),
AVG(hdd_used_gb),
AVG(hdd_total_gb)
FROM system_metrics
WHERE ts >= ? AND ts <= ?
GROUP BY ts / ?
ORDER BY bucket_ts ASC`,
bucketSeconds, bucketSeconds, fromTS, toTS, bucketSeconds,
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []SystemSample
for rows.Next() {
var m SystemSample
var tempC, load1, load5, load15, diskUsed, diskTotal, hddUsed, hddTotal sql.NullFloat64
if err := rows.Scan(&m.Timestamp, &m.CPUPercent, &m.MemUsedMB, &m.MemTotalMB,
&tempC, &load1, &load5, &load15, &diskUsed, &diskTotal, &hddUsed, &hddTotal); err != nil {
return nil, err
}
if tempC.Valid {
m.TempCelsius = tempC.Float64
}
if load1.Valid {
m.LoadAvg1 = load1.Float64
}
if load5.Valid {
m.LoadAvg5 = load5.Float64
}
if load15.Valid {
m.LoadAvg15 = load15.Float64
}
if diskUsed.Valid {
m.DiskUsedGB = diskUsed.Float64
}
if diskTotal.Valid {
m.DiskTotalGB = diskTotal.Float64
}
if hddUsed.Valid {
m.HDDUsedGB = hddUsed.Float64
}
if hddTotal.Valid {
m.HDDTotalGB = hddTotal.Float64
}
result = append(result, m)
}
return result, rows.Err()
}
// QueryContainerMetrics returns downsampled metrics for a specific container.
func (s *MetricsStore) QueryContainerMetrics(name string, from, to time.Time, resolution int) ([]ContainerSample, error) {
fromTS := from.Unix()
toTS := to.Unix()
if resolution <= 0 {
resolution = 200
}
rangeSeconds := toTS - fromTS
if rangeSeconds <= 0 {
return nil, nil
}
bucketSeconds := rangeSeconds / int64(resolution)
if bucketSeconds < 1 {
bucketSeconds = 1
}
rows, err := s.db.Query(`
SELECT
(ts / ?) * ? AS bucket_ts,
container_name,
AVG(cpu_percent),
AVG(mem_usage_mb),
AVG(mem_limit_mb),
AVG(net_rx_bytes),
AVG(net_tx_bytes),
AVG(block_read_bytes),
AVG(block_write_bytes)
FROM container_metrics
WHERE container_name = ? AND ts >= ? AND ts <= ?
GROUP BY ts / ?
ORDER BY bucket_ts ASC`,
bucketSeconds, bucketSeconds, name, fromTS, toTS, bucketSeconds,
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ContainerSample
for rows.Next() {
var c ContainerSample
var memLimit, netRx, netTx, blkRead, blkWrite sql.NullFloat64
if err := rows.Scan(&c.Timestamp, &c.ContainerName, &c.CPUPercent, &c.MemUsageMB,
&memLimit, &netRx, &netTx, &blkRead, &blkWrite); err != nil {
return nil, err
}
if memLimit.Valid {
c.MemLimitMB = memLimit.Float64
}
if netRx.Valid {
c.NetRxBytes = int64(netRx.Float64)
}
if netTx.Valid {
c.NetTxBytes = int64(netTx.Float64)
}
if blkRead.Valid {
c.BlockReadBytes = int64(blkRead.Float64)
}
if blkWrite.Valid {
c.BlockWriteBytes = int64(blkWrite.Float64)
}
result = append(result, c)
}
return result, rows.Err()
}
// QueryContainerSummary returns the latest metrics for all containers.
func (s *MetricsStore) QueryContainerSummary() ([]ContainerCurrentStats, error) {
rows, err := s.db.Query(`
SELECT container_name, cpu_percent, mem_usage_mb, COALESCE(mem_limit_mb, 0)
FROM container_metrics
WHERE ts = (SELECT MAX(ts) FROM container_metrics)
ORDER BY cpu_percent DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ContainerCurrentStats
for rows.Next() {
var c ContainerCurrentStats
if err := rows.Scan(&c.ContainerName, &c.CPUPercent, &c.MemUsageMB, &c.MemLimitMB); err != nil {
return nil, err
}
result = append(result, c)
}
return result, rows.Err()
}
// Prune deletes rows older than the given duration. Returns the number of deleted rows.
func (s *MetricsStore) Prune(olderThan time.Duration) (int64, error) {
cutoff := time.Now().Add(-olderThan).Unix()
var total int64
res, err := s.db.Exec("DELETE FROM system_metrics WHERE ts < ?", cutoff)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
total += n
res, err = s.db.Exec("DELETE FROM container_metrics WHERE ts < ?", cutoff)
if err != nil {
return total, err
}
n, _ = res.RowsAffected()
total += n
return total, nil
}
// SampleCount is how many system samples (one a minute) exist in [from, to) — the box's own record of whether it
// was on (R-871 banner: "the box was off at 02:30").
func (s *MetricsStore) SampleCount(from, to time.Time) (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM system_metrics WHERE ts >= ? AND ts < ?`, from.Unix(), to.Unix()).Scan(&n)
return n, err
}
// HourSampleCounts returns the number of system samples per UTC hour start (unix seconds) in [from, to) — the
// banner's "when is this box usually on" pattern (R-871). At most 24×7 rows for a week.
func (s *MetricsStore) HourSampleCounts(from, to time.Time) (map[int64]int, error) {
rows, err := s.db.Query(`SELECT (ts / 3600) * 3600 AS h, COUNT(*) FROM system_metrics WHERE ts >= ? AND ts < ? GROUP BY h`, from.Unix(), to.Unix())
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]int{}
for rows.Next() {
var h int64
var n int
if err := rows.Scan(&h, &n); err != nil {
return nil, err
}
out[h] = n
}
return out, rows.Err()
}