Files
a.gritsenkoandClaude Sonnet 5 09159a5f31 Initial import: nasmon health monitor, split out of home_nas
Go service (nasmon/) plus nfpm packaging (packaging/), carried over from
the home_nas monorepo. Adds rpm packaging alongside the existing deb
build (Fedora/RHEL/Rocky dependency names via nfpm overrides, per-format
systemd unit path, dpkg+rpm-compatible maintainer scripts) and a Gitea
Actions workflow that builds both formats on every push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:24:34 +03:00

404 lines
11 KiB
Go

// 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, &notnull, &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<?`, olderThan.Unix())
return err
}
// ---- alerts
type Alert struct {
ID int64 `json:"id"`
Key string `json:"key"`
Level string `json:"level"`
Title string `json:"title"`
Detail string `json:"detail"`
OpenedAt time.Time `json:"opened_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
LastNotifiedAt *time.Time `json:"last_notified_at,omitempty"`
AckedAt *time.Time `json:"acked_at,omitempty"`
}
// Acked reports whether the alert was acknowledged by the user.
func (a Alert) Acked() bool { return a.AckedAt != nil }
func scanAlert(sc interface{ Scan(...any) error }) (Alert, error) {
var a Alert
var opened int64
var resolved, notified, acked sql.NullInt64
err := sc.Scan(&a.ID, &a.Key, &a.Level, &a.Title, &a.Detail, &opened, &resolved, &notified, &acked)
if err != nil {
return a, err
}
if acked.Valid {
t := time.Unix(acked.Int64, 0)
a.AckedAt = &t
}
a.OpenedAt = time.Unix(opened, 0)
if resolved.Valid {
t := time.Unix(resolved.Int64, 0)
a.ResolvedAt = &t
}
if notified.Valid {
t := time.Unix(notified.Int64, 0)
a.LastNotifiedAt = &t
}
return a, nil
}
const alertCols = `id, key, level, title, detail, opened_at, resolved_at, last_notified_at, acked_at`
// OpenAlerts returns every unresolved alert, acknowledged ones included.
func (s *Store) OpenAlerts() ([]Alert, error) {
return s.queryAlerts(`SELECT ` + alertCols + ` FROM alerts WHERE resolved_at IS NULL ORDER BY opened_at DESC`)
}
// ActiveAlerts returns unresolved alerts that were not acknowledged — what the
// dashboard and the nav badge show.
func (s *Store) ActiveAlerts() ([]Alert, error) {
return s.queryAlerts(`SELECT ` + alertCols + ` FROM alerts WHERE resolved_at IS NULL AND acked_at IS NULL ORDER BY opened_at DESC`)
}
// RecentAlerts returns all open alerts plus those resolved at or after
// resolvedSince (zero time = every resolved alert), newest first.
func (s *Store) RecentAlerts(limit int, resolvedSince time.Time) ([]Alert, error) {
return s.queryAlerts(`SELECT `+alertCols+` FROM alerts WHERE resolved_at IS NULL OR resolved_at>=? 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<?`, t.Unix()).Scan(&n)
return n, err
}
func (s *Store) GetAlert(id int64) (*Alert, error) {
a, err := scanAlert(s.db.QueryRow(`SELECT `+alertCols+` FROM alerts WHERE id=?`, id))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &a, nil
}
func (s *Store) AckAlert(id int64, now time.Time) error {
_, err := s.db.Exec(`UPDATE alerts SET acked_at=? WHERE id=? AND resolved_at IS NULL`, now.Unix(), id)
return err
}
// UnackAlert shows an acknowledged alert again (user action, no new mail).
func (s *Store) UnackAlert(id int64) error {
_, err := s.db.Exec(`UPDATE alerts SET acked_at=NULL WHERE id=?`, id)
return err
}
// ReraiseAlert clears the acknowledgement and the delivery mark, so the alert
// is mailed again as new.
func (s *Store) ReraiseAlert(id int64) error {
_, err := s.db.Exec(`UPDATE alerts SET acked_at=NULL, last_notified_at=NULL WHERE id=?`, id)
return err
}
// DeleteAlert removes a resolved alert from the log; open alerts are kept.
func (s *Store) DeleteAlert(id int64) error {
_, err := s.db.Exec(`DELETE FROM alerts WHERE id=? AND resolved_at IS NOT NULL`, id)
return err
}
// PruneAlerts drops resolved alerts older than olderThan.
func (s *Store) PruneAlerts(olderThan time.Time) error {
_, err := s.db.Exec(`DELETE FROM alerts WHERE resolved_at IS NOT NULL AND resolved_at<?`, olderThan.Unix())
return err
}
func (s *Store) queryAlerts(q string, args ...any) ([]Alert, error) {
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Alert
for rows.Next() {
a, err := scanAlert(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Store) OpenAlert(key, level, title, detail string, now time.Time) (Alert, error) {
res, err := s.db.Exec(`INSERT INTO alerts(key, level, title, detail, opened_at) VALUES(?,?,?,?,?)`,
key, level, title, detail, now.Unix())
if err != nil {
return Alert{}, err
}
id, _ := res.LastInsertId()
return Alert{ID: id, Key: key, Level: level, Title: title, Detail: detail, OpenedAt: now}, nil
}
// UpdateAlertDetail refreshes level/detail of an open alert.
func (s *Store) UpdateAlertDetail(id int64, level, detail string) error {
_, err := s.db.Exec(`UPDATE alerts SET level=?, detail=? WHERE id=?`, level, detail, id)
return err
}
func (s *Store) ResolveAlert(id int64, now time.Time) error {
_, err := s.db.Exec(`UPDATE alerts SET resolved_at=? WHERE id=?`, now.Unix(), id)
return err
}
func (s *Store) MarkNotified(ids []int64, now time.Time) error {
for _, id := range ids {
if _, err := s.db.Exec(`UPDATE alerts SET last_notified_at=? WHERE id=?`, now.Unix(), id); err != nil {
return err
}
}
return nil
}
// ---- self-tests
func (s *Store) LastSelfTest(serial, kind string) (time.Time, error) {
var ts int64
err := s.db.QueryRow(`SELECT started_at FROM selftest_runs WHERE serial=? AND kind=?`, serial, kind).Scan(&ts)
if err == sql.ErrNoRows {
return time.Time{}, nil
}
return time.Unix(ts, 0), err
}
func (s *Store) SetSelfTest(serial, kind string, at time.Time) error {
_, err := s.db.Exec(`INSERT INTO selftest_runs(serial, kind, started_at) VALUES(?,?,?)
ON CONFLICT(serial, kind) DO UPDATE SET started_at=excluded.started_at`, serial, kind, at.Unix())
return err
}
// ---- known disks (for the "disk disappeared" rule)
type SeenDisk struct {
Serial string
Model string
Device string
LastSeen time.Time
}
func (s *Store) TouchDisk(serial, model, device string, now time.Time) error {
if serial == "" {
return nil
}
_, err := s.db.Exec(`INSERT INTO disks_seen(serial, model, device, last_seen) VALUES(?,?,?,?)
ON CONFLICT(serial) DO UPDATE SET model=excluded.model, device=excluded.device, last_seen=excluded.last_seen`,
serial, model, device, now.Unix())
return err
}
func (s *Store) SeenDisks() ([]SeenDisk, error) {
rows, err := s.db.Query(`SELECT serial, model, device, last_seen FROM disks_seen`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SeenDisk
for rows.Next() {
var d SeenDisk
var ts int64
if err := rows.Scan(&d.Serial, &d.Model, &d.Device, &ts); err != nil {
return nil, err
}
d.LastSeen = time.Unix(ts, 0)
out = append(out, d)
}
return out, rows.Err()
}
// ForgetDisk drops a disk from the known list (user removed it on purpose).
func (s *Store) ForgetDisk(serial string) error {
_, err := s.db.Exec(`DELETE FROM disks_seen WHERE serial=?`, serial)
return err
}