// Package store persists SMART snapshots, alert state and self-test bookkeeping in SQLite. package store import ( "database/sql" "encoding/json" "time" _ "modernc.org/sqlite" "nasmon/internal/collect" ) type Store struct{ db *sql.DB } const schema = ` CREATE TABLE IF NOT EXISTS smart_snapshots ( id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, serial TEXT NOT NULL, device TEXT NOT NULL, temp_c INTEGER, healthy INTEGER, power_on_hours INTEGER, data TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS smart_snapshots_serial_ts ON smart_snapshots(serial, ts); CREATE TABLE IF NOT EXISTS alerts ( id INTEGER PRIMARY KEY, key TEXT NOT NULL, level TEXT NOT NULL, title TEXT NOT NULL, detail TEXT NOT NULL, opened_at INTEGER NOT NULL, resolved_at INTEGER, last_notified_at INTEGER, acked_at INTEGER ); CREATE INDEX IF NOT EXISTS alerts_key_open ON alerts(key, resolved_at); CREATE TABLE IF NOT EXISTS selftest_runs ( serial TEXT NOT NULL, kind TEXT NOT NULL, started_at INTEGER NOT NULL, PRIMARY KEY (serial, kind) ); CREATE TABLE IF NOT EXISTS disks_seen ( serial TEXT PRIMARY KEY, model TEXT NOT NULL, device TEXT NOT NULL, last_seen INTEGER NOT NULL, missing_acked INTEGER NOT NULL DEFAULT 0 ); ` func Open(path string) (*Store, error) { db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") if err != nil { return nil, err } db.SetMaxOpenConns(1) if _, err := db.Exec(schema); err != nil { db.Close() return nil, err } if err := migrate(db); err != nil { db.Close() return nil, err } return &Store{db: db}, nil } // migrate adds columns introduced after the first release. func migrate(db *sql.DB) error { rows, err := db.Query(`PRAGMA table_info(alerts)`) if err != nil { return err } defer rows.Close() have := map[string]bool{} for rows.Next() { var cid int var name, typ string var notnull, pk int var dflt sql.NullString if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { return err } have[name] = true } if err := rows.Err(); err != nil { return err } if !have["acked_at"] { if _, err := db.Exec(`ALTER TABLE alerts ADD COLUMN acked_at INTEGER`); err != nil { return err } } return nil } func (s *Store) Close() error { return s.db.Close() } // ---- SMART snapshots func (s *Store) AddSmart(sm collect.Smart) error { if sm.Serial == "" { return nil } b, err := json.Marshal(sm) if err != nil { return err } _, err = s.db.Exec(`INSERT INTO smart_snapshots(ts, serial, device, temp_c, healthy, power_on_hours, data) VALUES(?,?,?,?,?,?,?)`, sm.CheckedAt.Unix(), sm.Serial, sm.Device, sm.TempC, sm.Healthy, sm.PowerOnHrs, string(b)) return err } // LastSmart returns the most recent stored snapshot for a serial. func (s *Store) LastSmart(serial string) (*collect.Smart, error) { var data string err := s.db.QueryRow(`SELECT data FROM smart_snapshots WHERE serial=? ORDER BY ts DESC LIMIT 1`, serial).Scan(&data) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } var sm collect.Smart if err := json.Unmarshal([]byte(data), &sm); err != nil { return nil, err } return &sm, nil } type SmartPoint struct { T int64 `json:"t"` TempC int `json:"temp_c"` Healthy bool `json:"healthy"` Hours int64 `json:"hours"` Realloc int64 `json:"realloc"` // attr 5 raw or NVMe media errors Pending int64 `json:"pending"` // attr 197 raw CRC int64 `json:"crc"` // attr 199 raw } // SmartHistory returns compact per-snapshot points since t. func (s *Store) SmartHistory(serial string, since time.Time) ([]SmartPoint, error) { rows, err := s.db.Query(`SELECT ts, data FROM smart_snapshots WHERE serial=? AND ts>=? ORDER BY ts`, serial, since.Unix()) if err != nil { return nil, err } defer rows.Close() var out []SmartPoint for rows.Next() { var ts int64 var data string if err := rows.Scan(&ts, &data); err != nil { return nil, err } var sm collect.Smart if json.Unmarshal([]byte(data), &sm) != nil { continue } p := SmartPoint{T: ts, TempC: sm.TempC, Healthy: sm.Healthy, Hours: sm.PowerOnHrs} if a, ok := sm.Attr(5); ok { p.Realloc = a.Raw } else if sm.NVMe != nil { p.Realloc = sm.NVMe.MediaErrors } if a, ok := sm.Attr(197); ok { p.Pending = a.Raw } if a, ok := sm.Attr(199); ok { p.CRC = a.Raw } out = append(out, p) } return out, rows.Err() } func (s *Store) PruneSmart(olderThan time.Time) error { _, err := s.db.Exec(`DELETE FROM smart_snapshots WHERE ts=? ORDER BY opened_at DESC LIMIT ?`, resolvedSince.Unix(), limit) } // CountResolvedBefore counts resolved alerts hidden by the default alerts view. func (s *Store) CountResolvedBefore(t time.Time) (int, error) { var n int err := s.db.QueryRow(`SELECT COUNT(*) FROM alerts WHERE resolved_at IS NOT NULL AND resolved_at