From d7f36ab7aedac7f4204f0147e49ee0f62fafde51 Mon Sep 17 00:00:00 2001 From: Nacho Date: Mon, 21 Sep 2026 20:16:50 +0200 Subject: [PATCH] feat(alerts): alert when a known pool is not imported (#136) --- internal/alerts/alerts.go | 100 +++++++++++++++ internal/alerts/pool_missing_test.go | 169 +++++++++++++++++++++++++ internal/config/config.go | 6 + internal/db/db.go | 9 ++ internal/httpapi/httpapi.go | 1 + internal/httpapi/pools.go | 11 ++ internal/httpapi/push_handlers_test.go | 4 +- internal/model/model.go | 7 + internal/push/i18n.go | 2 + internal/push/prefs.go | 2 +- main.go | 1 + web/src/data/http.ts | 3 +- web/src/data/mock.ts | 1 + web/src/data/provider.ts | 3 +- web/src/data/types.ts | 6 + web/src/ui/i18n.ts | 4 + web/src/views/Dashboard.tsx | 28 +++- 17 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 internal/alerts/pool_missing_test.go diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index a5bead9..58d2e46 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -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. @@ -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) @@ -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. diff --git a/internal/alerts/pool_missing_test.go b/internal/alerts/pool_missing_test.go new file mode 100644 index 0000000..97e8c06 --- /dev/null +++ b/internal/alerts/pool_missing_test.go @@ -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) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index abf3bec..d68476e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -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 diff --git a/internal/db/db.go b/internal/db/db.go index 14ca2ce..6336f17 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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. diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 1845abe..40221b2 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -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)) diff --git a/internal/httpapi/pools.go b/internal/httpapi/pools.go index 8dcccb5..966d30a 100644 --- a/internal/httpapi/pools.go +++ b/internal/httpapi/pools.go @@ -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{} diff --git a/internal/httpapi/push_handlers_test.go b/internal/httpapi/push_handlers_test.go index f218ee2..335fa76 100644 --- a/internal/httpapi/push_handlers_test.go +++ b/internal/httpapi/push_handlers_test.go @@ -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 { diff --git a/internal/model/model.go b/internal/model/model.go index 4480d95..93248c6 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -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 { diff --git a/internal/push/i18n.go b/internal/push/i18n.go index 3d0781d..90175fb 100644 --- a/internal/push/i18n.go +++ b/internal/push/i18n.go @@ -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}."}, @@ -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}."}, diff --git a/internal/push/prefs.go b/internal/push/prefs.go index c9fceec..c20d83e 100644 --- a/internal/push/prefs.go +++ b/internal/push/prefs.go @@ -21,7 +21,7 @@ import ( // Tipos — catálogo de tipos de alerta configurables por el usuario (casan con // notification_preferences.tipo y con las claves del catálogo i18n). -var Tipos = []string{"pool_capacity", "pool_status", "scrub_errors", "disk_temp", "smart_status"} +var Tipos = []string{"pool_capacity", "pool_status", "pool_missing", "scrub_errors", "disk_temp", "smart_status"} // TipoValido — ¿tipo de alerta conocido? func TipoValido(tipo string) bool { diff --git a/main.go b/main.go index 12bbce0..d5cb0f9 100644 --- a/main.go +++ b/main.go @@ -92,6 +92,7 @@ func main() { h := hub.NewHub() alerter := alerts.New(database, h, stStore) + alerter.SetPoolMissingAfter(cfg.PoolMissingAfter) // Webhook saliente (issue #18): worker async con cola acotada + DLQ. La URL // se resuelve de settings en cada envío (dinámica, editable por UI); secret, diff --git a/web/src/data/http.ts b/web/src/data/http.ts index 595e770..901fd24 100644 --- a/web/src/data/http.ts +++ b/web/src/data/http.ts @@ -4,7 +4,7 @@ import { ApiError } from './types'; import { notifyAuthExpired } from './events'; import type { ActivityItem, Alert, APIKeyCreated, APIKeyInfo, BackupFile, BackupStatus, ChannelName, ChannelPatch, ChannelsStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateReplicationReq, CreateSnapshotReq, CreateUserReq, - Dataset, DatasetProp, DatasetPropsResp, DiffEntry, Disk, DiskSmartLogResp, DiskSmartResp, Job, JobHistoryItem, Lang, LoginResult, LongOp, Overview, Performance, Pool, PoolHistoryEntry, PushAlertTipo, PushPreference, PushQuietHours, PushSubscriptionJSON, + Dataset, DatasetProp, DatasetPropsResp, DiffEntry, Disk, DiskSmartLogResp, DiskSmartResp, Job, JobHistoryItem, Lang, LoginResult, LongOp, MissingPool, Overview, Performance, Pool, PoolHistoryEntry, PushAlertTipo, PushPreference, PushQuietHours, PushSubscriptionJSON, Recommendation, ReplicationJob, ReplicationSSHKey, ReplicationTestResult, SessionUser, Settings, SeriesResp, SnapshotGroup, SystemTimer, SystemTimersResp, TwoFARecovery, TwoFASetup, TwoFAStatus, UpdateJobReq, UpdateReplicationReq, UpdateStatus, UserInfo, VersionInfo, } from './types'; @@ -154,6 +154,7 @@ export class HttpProvider implements DataProvider { deleteAPIKey = (id: number) => del(`/keys/${id}`); getPools = () => get('/pools'); + getMissingPools = () => get('/pools/missing'); createPool = (r: CreatePoolReq) => post('/pools', r); importPool = async (name?: string): Promise => { const r = await post<{ importable?: string[] } | string[]>('/pools/import', name ? { name } : {}); diff --git a/web/src/data/mock.ts b/web/src/data/mock.ts index c99791c..5733e67 100644 --- a/web/src/data/mock.ts +++ b/web/src/data/mock.ts @@ -438,6 +438,7 @@ export class MockProvider implements DataProvider { // ---- Pools ---- getPools = async () => { await delay(); return this.pools.map((p) => ({ ...p, vdevs: p.vdevs.map((v) => ({ ...v })), scrub: { ...p.scrub } })); }; + getMissingPools = async () => { await delay(); return []; }; createPool = async (r: CreatePoolReq) => { await delay(400); if (r.confirm !== r.name) throw new ApiError(400, 'confirm_required', `Escribe "${r.name}" para confirmar`); diff --git a/web/src/data/provider.ts b/web/src/data/provider.ts index 744b41f..8332a89 100644 --- a/web/src/data/provider.ts +++ b/web/src/data/provider.ts @@ -1,7 +1,7 @@ // Interfaz DataProvider: abstrae el origen de datos (HTTP real o mock demo). import type { ActivityItem, Alert, APIKeyCreated, APIKeyInfo, BackupFile, BackupStatus, ChannelName, ChannelPatch, ChannelsStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateReplicationReq, CreateSnapshotReq, CreateUserReq, - Dataset, DatasetProp, DatasetPropsResp, DiffEntry, Disk, DiskSmartLogResp, DiskSmartResp, Job, JobHistoryItem, Lang, LoginResult, LongOp, Overview, Performance, Pool, PoolHistoryEntry, PushAlertTipo, PushPreference, PushQuietHours, PushSubscriptionJSON, SeriesResp, + Dataset, DatasetProp, DatasetPropsResp, DiffEntry, Disk, DiskSmartLogResp, DiskSmartResp, Job, JobHistoryItem, Lang, LoginResult, LongOp, MissingPool, Overview, Performance, Pool, PoolHistoryEntry, PushAlertTipo, PushPreference, PushQuietHours, PushSubscriptionJSON, SeriesResp, Recommendation, ReplicationJob, ReplicationSSHKey, ReplicationTestResult, SessionUser, Settings, SnapshotGroup, SystemTimer, SystemTimersResp, TwoFARecovery, TwoFASetup, TwoFAStatus, UpdateJobReq, UpdateReplicationReq, UpdateStatus, UserInfo, VersionInfo, } from './types'; @@ -68,6 +68,7 @@ export interface DataProvider { // Pools getPools(): Promise; + getMissingPools(): Promise; createPool(r: CreatePoolReq): Promise; importPool(name?: string): Promise; scrubAction(pool: string, action: 'start' | 'pause' | 'stop'): Promise; diff --git a/web/src/data/types.ts b/web/src/data/types.ts index d7f355c..68fd248 100644 --- a/web/src/data/types.ts +++ b/web/src/data/types.ts @@ -243,6 +243,12 @@ export interface Pool { raidz_vdevs?: string[]; } +// Pool conocido que ya no aparece en zpool list (GET /api/pools/missing, #136). +export interface MissingPool { + name: string; + last_seen: string; // RFC3339 +} + // Entrada de 'zpool history -i' (GET /api/pools/{name}/history). export interface PoolHistoryEntry { ts: string; diff --git a/web/src/ui/i18n.ts b/web/src/ui/i18n.ts index 2a7c8ab..0286bd7 100644 --- a/web/src/ui/i18n.ts +++ b/web/src/ui/i18n.ts @@ -92,6 +92,8 @@ const es = { kpi_snaps: 'Snapshots', kpi_snaps_foot: 'tareas programadas activas', kpi_scrub: 'Último scrub', kpi_scrub_errors: 'errores', dash_pools: 'Pools', dash_see_all: 'Ver todos', + dash_missing_title: 'Pool sin importar', + dash_missing_body: '{pools} no aparece en zpool list: no está importado (visto por última vez hace {mins} min). Revisa el servidor o impórtalo desde Pools.', dash_alerts: 'Alertas recientes', dash_activity: 'Actividad', dash_temps: 'Temperatura de discos', dash_events: 'Registro de eventos', dash_no_alerts: 'Sin alertas. Todo en orden.', @@ -744,6 +746,8 @@ const en: Record = { kpi_snaps: 'Snapshots', kpi_snaps_foot: 'active scheduled tasks', kpi_scrub: 'Last scrub', kpi_scrub_errors: 'errors', dash_pools: 'Pools', dash_see_all: 'View all', + dash_missing_title: 'Pool not imported', + dash_missing_body: '{pools} is missing from zpool list: it is not imported (last seen {mins} min ago). Check the server or import it from Pools.', dash_alerts: 'Recent alerts', dash_activity: 'Activity', dash_temps: 'Disk temperatures', dash_events: 'Event log', dash_no_alerts: 'No alerts. Everything is fine.', diff --git a/web/src/views/Dashboard.tsx b/web/src/views/Dashboard.tsx index c61b30a..0d8da2a 100644 --- a/web/src/views/Dashboard.tsx +++ b/web/src/views/Dashboard.tsx @@ -41,10 +41,14 @@ export default function Dashboard() { const perf = useData((p) => p.getPerformance()); const disks = useData((p) => p.getDisks()); const recs = useData((p) => p.getRecommendations()); + const missing = useData((p) => p.getMissingPools()); // Suscripción a eventos en tiempo real: refresca KPIs y progreso de scrub useEffect(() => subscribeEvents((ev) => { - if (ev.type === 'overview' || ev.type === 'alert.new' || ev.type === 'pool.status') ov.reload(); + if (ev.type === 'overview' || ev.type === 'alert.new' || ev.type === 'pool.status') { + ov.reload(); + missing.reload(); + } if (ev.type === 'scrub.progress') { pools.setData((cur) => cur?.map((p) => p.name === ev.pool ? { ...p, scrub: { ...p.scrub, state: 'running', kind: ev.kind ?? p.scrub.kind, pct: ev.pct, eta_sec: ev.eta_sec } } @@ -108,6 +112,28 @@ export default function Dashboard() { )} + {/* Pools conocidos que no aparecen en zpool list (#136): aviso crítico + persistente hasta que el pool vuelva o se importe desde Pools. */} + {(missing.data?.length ?? 0) > 0 && ( +
+
+ {missing.data!.map((m) => { + const mins = Math.max(1, Math.round((Date.now() - new Date(m.last_seen).getTime()) / 60000)); + return ( +
+
!
+
+ {t('dash_missing_title')}: {m.name} +
{t('dash_missing_body', { pools: m.name, mins })}
+
+ +
+ ); + })} +
+
+ )} +

{t('dash_pools')}