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>
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user