Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions internal/alerts/alerts.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ type Alerter struct {
wh *webhook.Notifier // puede ser nil (webhook desactivado)
mail *notifier.Mailer // puede ser nil (email desactivado)
ch *channels.Client // puede ser nil (ntfy/gotify/syslog desactivados)

// poolMissingAfter — ventana para alertar de pool conocido no importado
// (#136). 0 = default (5 min).
poolMissingAfter time.Duration
}

// New crea el Alerter.
Expand All @@ -50,6 +54,16 @@ func (a *Alerter) SetEmail(m *notifier.Mailer) { a.mail = m }
// SetChannels conecta los canales ntfy/gotify/syslog (opcional; nil = sin ellos).
func (a *Alerter) SetChannels(c *channels.Client) { a.ch = c }

// SetPoolMissingAfter fija la ventana de detección de pools no importados
// (#136); 0 o negativo restaura el default (5 min).
func (a *Alerter) SetPoolMissingAfter(d time.Duration) {
if d <= 0 {
a.poolMissingAfter = 5 * time.Minute
return
}
a.poolMissingAfter = d
}

// Raise inserta una alerta sin metadatos estructurados (kind "").
func (a *Alerter) Raise(ctx context.Context, level, source, target, message string) {
a.RaiseKind(ctx, level, source, target, message, "", nil)
Expand Down Expand Up @@ -243,6 +257,92 @@ func (a *Alerter) EvaluatePools(ctx context.Context, pools []model.Pool) {
"scrub_errors", map[string]any{"pool": p.Name, "errors": p.Scrub.Errors})
}
}
a.trackPools(ctx, pools)
}

// trackPools persiste los pools vistos en known_pools y alerta (crit,
// kind pool_missing) de los conocidos que llevan más de poolMissingAfter
// sin aparecer en zpool list (#136). Distingue "nunca visto" (instalación
// fresca: tabla vacía, sin alertas) de "visto antes y ahora ausente".
// Solo lectura: nunca se intenta importar.
func (a *Alerter) trackPools(ctx context.Context, pools []model.Pool) {
now := time.Now().UTC()
if a.poolMissingAfter <= 0 {
a.poolMissingAfter = 5 * time.Minute
}
seen := make(map[string]bool, len(pools))
for _, p := range pools {
seen[p.Name] = true
if _, err := a.db.ExecContext(ctx,
`INSERT INTO known_pools(name, first_seen_at, last_seen_at) VALUES(?,?,?)
ON CONFLICT(name) DO UPDATE SET last_seen_at=excluded.last_seen_at`,
p.Name, now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
log.Printf("alerts: known_pools upsert %s: %v", p.Name, err)
}
}
cutoff := now.Add(-a.poolMissingAfter).Format(time.RFC3339)
rows, err := a.db.QueryContext(ctx,
"SELECT name, last_seen_at FROM known_pools WHERE last_seen_at < ?", cutoff)
if err != nil {
log.Printf("alerts: known_pools listar: %v", err)
return
}
defer rows.Close()
type missing struct {
name string
lastSeen time.Time
}
var missingPools []missing
for rows.Next() {
var m missing
var last string
if err := rows.Scan(&m.name, &last); err != nil {
log.Printf("alerts: known_pools scan: %v", err)
return
}
if seen[m.name] {
continue // ya reapareció: last_seen está fresco, no debería pasar
}
m.lastSeen = parseTS(last)
missingPools = append(missingPools, m)
}
if err := rows.Err(); err != nil {
log.Printf("alerts: known_pools: %v", err)
return
}
for _, m := range missingPools {
mins := int(now.Sub(m.lastSeen).Minutes())
a.RaiseKind(ctx, "crit", "pool."+m.name, "pools:"+m.name,
fmt.Sprintf("Pool %s no importado (no aparece en zpool list; visto por última vez hace %d min)",
m.name, mins),
"pool_missing", map[string]any{"pool": m.name, "mins": mins})
}
}

// MissingPools devuelve los pools conocidos que no aparecen en zpool list
// desde hace más de poolMissingAfter (#136). Contrato de GET /api/pools/missing.
func (a *Alerter) MissingPools(ctx context.Context) ([]model.MissingPool, error) {
if a.poolMissingAfter <= 0 {
a.poolMissingAfter = 5 * time.Minute
}
cutoff := time.Now().UTC().Add(-a.poolMissingAfter).Format(time.RFC3339)
rows, err := a.db.QueryContext(ctx,
"SELECT name, last_seen_at FROM known_pools WHERE last_seen_at < ? ORDER BY name", cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.MissingPool{}
for rows.Next() {
var m model.MissingPool
var last string
if err := rows.Scan(&m.Name, &last); err != nil {
return nil, err
}
m.LastSeen = parseTS(last)
out = append(out, m)
}
return out, rows.Err()
}

// EvaluateDisks aplica umbrales de temperatura y estado SMART.
Expand Down
169 changes: 169 additions & 0 deletions internal/alerts/pool_missing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// pool_missing_test.go — detección de pools conocidos no importados (#136).
package alerts

import (
"context"
"strings"
"testing"
"time"

"easyzfs/internal/model"
)

// fresh install: tabla known_pools vacía, un pool presente → se registra y
// NO hay alerta (distingue "nunca visto" de "visto antes y ahora ausente").
func TestTrackPools_FreshInstallNoAlerta(t *testing.T) {
a, closeDB := newTestAlerter(t)
defer closeDB()
a.SetPoolMissingAfter(time.Minute)
ctx := context.Background()

a.EvaluatePools(ctx, []model.Pool{
{Name: "tank", TotalBytes: 100, UsedBytes: 10, Status: "ONLINE"},
})

var n int
if err := a.db.QueryRow("SELECT COUNT(*) FROM alerts").Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("alertas = %d, esperadas 0 en instalación fresca", n)
}
var lastSeen string
if err := a.db.QueryRow("SELECT last_seen_at FROM known_pools WHERE name='tank'").Scan(&lastSeen); err != nil {
t.Fatalf("tank no registrado en known_pools: %v", err)
}
}

// pool visto antes y ahora ausente de zpool list → alerta crítica pool_missing.
func TestTrackPools_PoolAusenteAlerta(t *testing.T) {
a, closeDB := newTestAlerter(t)
defer closeDB()
a.SetPoolMissingAfter(time.Minute)
ctx := context.Background()

stale := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339)
if _, err := a.db.Exec(
"INSERT INTO known_pools(name, first_seen_at, last_seen_at) VALUES('tank',?,?)",
stale, stale); err != nil {
t.Fatal(err)
}

a.EvaluatePools(ctx, nil)

var level, kind, msg string
err := a.db.QueryRow("SELECT level, kind, message FROM alerts WHERE source='pool.tank'").
Scan(&level, &kind, &msg)
if err != nil {
t.Fatalf("alerta pool_missing no creada: %v", err)
}
if level != "crit" || kind != "pool_missing" {
t.Errorf("level/kind = %s/%s, esperado crit/pool_missing", level, kind)
}
if !strings.Contains(msg, "tank") || !strings.Contains(msg, "no importado") {
t.Errorf("mensaje inesperado: %q", msg)
}
}

// ventana de gracia: ausente desde hace menos de PoolMissingAfter → sin alerta.
func TestTrackPools_DentroDeLaVentanaNoAlerta(t *testing.T) {
a, closeDB := newTestAlerter(t)
defer closeDB()
a.SetPoolMissingAfter(5 * time.Minute)
ctx := context.Background()

recent := time.Now().UTC().Add(-2 * time.Minute).Format(time.RFC3339)
if _, err := a.db.Exec(
"INSERT INTO known_pools(name, first_seen_at, last_seen_at) VALUES('tank',?,?)",
recent, recent); err != nil {
t.Fatal(err)
}

a.EvaluatePools(ctx, nil)

var n int
if err := a.db.QueryRow("SELECT COUNT(*) FROM alerts").Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("alertas = %d, esperadas 0 dentro de la ventana", n)
}
}

// el pool vuelve a importarse → desaparece de MissingPools y la alerta no se
// re-eleva (se refrescaría la existente, no crearía otra).
func TestTrackPools_PoolVuelveSinRealerta(t *testing.T) {
a, closeDB := newTestAlerter(t)
defer closeDB()
a.SetPoolMissingAfter(time.Minute)
ctx := context.Background()

stale := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339)
if _, err := a.db.Exec(
"INSERT INTO known_pools(name, first_seen_at, last_seen_at) VALUES('tank',?,?)",
stale, stale); err != nil {
t.Fatal(err)
}

a.EvaluatePools(ctx, nil) // ausente → alerta
a.EvaluatePools(ctx, []model.Pool{
{Name: "tank", TotalBytes: 100, UsedBytes: 10, Status: "ONLINE"},
}) // de vuelta

m, err := a.MissingPools(ctx)
if err != nil {
t.Fatal(err)
}
if len(m) != 0 {
t.Fatalf("MissingPools = %v, esperado vacío tras reimportar", m)
}
var n int
if err := a.db.QueryRow("SELECT COUNT(*) FROM alerts WHERE kind='pool_missing'").Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("alertas pool_missing = %d, esperada 1 (dedupe, no re-alerta)", n)
}
}

// MissingPools respeta el corte por last_seen (contrato de /api/pools/missing).
func TestMissingPools_Contrato(t *testing.T) {
a, closeDB := newTestAlerter(t)
defer closeDB()
a.SetPoolMissingAfter(time.Minute)
ctx := context.Background()

stale := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339)
recent := time.Now().UTC().Format(time.RFC3339)
seeds := []struct {
name string
lastSeen string
}{
{"tank", stale},
{"vault", stale},
{"data", recent},
}
for _, s := range seeds {
if _, err := a.db.Exec(
"INSERT INTO known_pools(name, first_seen_at, last_seen_at) VALUES(?,?,?)",
s.name, s.lastSeen, s.lastSeen); err != nil {
t.Fatal(err)
}
}

m, err := a.MissingPools(ctx)
if err != nil {
t.Fatal(err)
}
if len(m) != 2 {
t.Fatalf("MissingPools = %d, esperados 2 (tank, vault)", len(m))
}
for _, mp := range m {
if mp.Name == "data" {
t.Errorf("data no debería figurar (last_seen reciente)")
}
if mp.LastSeen.IsZero() {
t.Errorf("LastSeen vacío para %s", mp.Name)
}
}
}
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ type Config struct {
ZpoolInterval time.Duration // EASYZFS_ZPOOL_INTERVAL en segundos (def 10)
ZpoolAlertInterval time.Duration // EASYZFS_ZPOOL_ALERT_INTERVAL en segundos (def 60)
ZpoolIdleInterval time.Duration // EASYZFS_ZPOOL_IDLE_INTERVAL en segundos (def 300)

// PoolMissingAfter — un pool conocido que no se ve en zpool list durante
// este tiempo dispara alerta crítica pool_missing (#136). En segundos.
PoolMissingAfter time.Duration // EASYZFS_POOL_MISSING_AFTER (def 300)
}

// DataDir — directorio de datos del daemon (deriva de DB_PATH): ahí viven la
Expand Down Expand Up @@ -122,6 +126,8 @@ func Load() *Config {
ZpoolInterval: time.Duration(envInt("EASYZFS_ZPOOL_INTERVAL", 10)) * time.Second,
ZpoolAlertInterval: time.Duration(envInt("EASYZFS_ZPOOL_ALERT_INTERVAL", 60)) * time.Second,
ZpoolIdleInterval: time.Duration(envInt("EASYZFS_ZPOOL_IDLE_INTERVAL", 300)) * time.Second,

PoolMissingAfter: time.Duration(envInt("EASYZFS_POOL_MISSING_AFTER", 300)) * time.Second,
}
if cfg.Demo {
cfg.Mock = true // demo implica colectores mock
Expand Down
9 changes: 9 additions & 0 deletions internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,15 @@ var migrations = []string{
json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);`,
// v23: pools conocidos (#136). Se persiste qué pools ha visto el colector
// y cuándo, para alertar cuando un pool conocido deja de importarse
// (p. ej. fallo de auto-import tras un corte de luz). Solo lectura: la app
// nunca importa por su cuenta.
`CREATE TABLE IF NOT EXISTS known_pools (
name TEXT PRIMARY KEY,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL
);`,
}

// Open abre la BD con WAL, busy_timeout y una sola conexión escritora.
Expand Down
1 change: 1 addition & 0 deletions internal/httpapi/httpapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ func (s *Server) Handler() http.Handler {
a.HandleFunc("POST /api/system-timers/migrate", s.auth.RequireAdmin(s.sysTimerMigrate))
// pools (mutaciones: admin — son potencialmente destructivas)
a.HandleFunc("GET /api/pools", s.listPools)
a.HandleFunc("GET /api/pools/missing", s.missingPools)
a.HandleFunc("POST /api/pools", s.auth.RequireAdmin(s.createPool))
a.HandleFunc("POST /api/pools/import", s.auth.RequireAdmin(s.importPool))
a.HandleFunc("POST /api/pools/{name}/scrub", s.auth.RequireAdmin(s.scrubPool))
Expand Down
11 changes: 11 additions & 0 deletions internal/httpapi/pools.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import (
)

// listPools — GET /api/pools (caché; vdevs con temp cruzada con discos).
// missingPools — pools conocidos que no aparecen en zpool list desde hace
// más de la ventana configurada (#136). Banner informativo del dashboard.
func (s *Server) missingPools(w http.ResponseWriter, r *http.Request) {
m, err := s.alerter.MissingPools(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "db", err.Error())
return
}
writeJSON(w, http.StatusOK, m)
}

func (s *Server) listPools(w http.ResponseWriter, r *http.Request) {
pools := s.pools.Pools()
temps := map[string]float64{}
Expand Down
4 changes: 2 additions & 2 deletions internal/httpapi/push_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ func TestPreferencesEndpoints(t *testing.T) {
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("GET preferences: body no JSON: %v", err)
}
if len(resp.Preferences) != 5 {
t.Fatalf("tipos = %d, esperado 5", len(resp.Preferences))
if len(resp.Preferences) != 6 {
t.Fatalf("tipos = %d, esperado 6", len(resp.Preferences))
}
for _, p := range resp.Preferences {
if !p.Enabled {
Expand Down
7 changes: 7 additions & 0 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ type Pool struct {
RaidzVdevs []string `json:"raidz_vdevs,omitempty"`
}

// MissingPool — pool conocido (visto antes por el colector) que ya no aparece
// en zpool list: no está importado (#136). Solo lectura: la app no importa.
type MissingPool struct {
Name string `json:"name"`
LastSeen time.Time `json:"last_seen"`
}

// Capabilities — capacidades derivadas de la versión de OpenZFS del host
// (contrato: GET /api/version → capabilities).
type Capabilities struct {
Expand Down
2 changes: 2 additions & 0 deletions internal/push/i18n.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var catalogo = map[string]map[string]textos{
"es": {
"pool_capacity": {"Capacidad de pool", "El pool {pool} está al {pct}% de capacidad (umbral {threshold}%)."},
"pool_status": {"Estado de pool", "El pool {pool} está {status}."},
"pool_missing": {"Pool no importado", "El pool {pool} no está importado (no aparece en zpool list; visto hace {mins} min)."},
"scrub_errors": {"Scrub con errores", "El scrub de {pool} terminó con {errors} errores."},
"disk_temp": {"Disco caliente", "El disco {dev} está a {temp} °C (umbral {threshold} °C)."},
"smart_status": {"Aviso SMART", "{dev}: {detail}."},
Expand All @@ -35,6 +36,7 @@ var catalogo = map[string]map[string]textos{
"en": {
"pool_capacity": {"Pool capacity", "Pool {pool} is at {pct}% capacity (threshold {threshold}%)."},
"pool_status": {"Pool status", "Pool {pool} is {status}."},
"pool_missing": {"Pool not imported", "Pool {pool} is not imported (missing from zpool list; last seen {mins} min ago)."},
"scrub_errors": {"Scrub errors", "Scrub of {pool} finished with {errors} errors."},
"disk_temp": {"Hot disk", "Disk {dev} is at {temp} °C (threshold {threshold} °C)."},
"smart_status": {"SMART warning", "{dev}: {detail}."},
Expand Down
Loading
Loading