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>
404 lines
11 KiB
Go
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, ¬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<?`, 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, ¬ified, &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
|
|
}
|