diff --git a/README.es.md b/README.es.md index c90a8eb..596f74f 100644 --- a/README.es.md +++ b/README.es.md @@ -269,6 +269,9 @@ El servicio lee `/etc/easyzfs/env`: | `COOKIE_SECURE` | - | `1` = cookie Secure (tras proxy TLS) | | `EASYZFS_SUDO` | auto | `1`/`0` fuerza o desactiva `sudo -n` en zpool/zfs/smartctl/lsblk/crontab | | `RETENTION_DAYS` | `30` | Retención de series (purga diaria 03:30) | +| `EASYZFS_ZPOOL_INTERVAL` | `10` | Intervalo de recolección completa (segundos) con la web UI abierta | +| `EASYZFS_ZPOOL_ALERT_INTERVAL` | `60` | Intervalo del heartbeat de salud/alertas (segundos) con la UI cerrada | +| `EASYZFS_ZPOOL_IDLE_INTERVAL` | `300` | Intervalo de recolección completa (segundos) con la UI cerrada | | `VAPID_PUBLIC_KEY` | - | Clave pública Web Push (generada por el instalador) | | `VAPID_PRIVATE_KEY` | - | Clave privada Web Push (solo servidor; push desactivado si falta) | | `VAPID_SUBJECT` | `mailto:easyzfs@localhost` | Contacto VAPID (`mailto:`, requerido por Safari) | @@ -367,6 +370,10 @@ Dependencias Go (mantenidas a 2 a propósito): ## Registro de cambios +### v2.9.21 + +- **Reduce la recolección de ZFS cuando la UI está cerrada (#126)**: la web UI mantiene un stream SSE abierto contra `/api/events`, así que `ZpoolCollector` usa el número de suscriptores del hub como señal de presencia. Con la UI abierta ejecuta una recolección completa cada `EASYZFS_ZPOOL_INTERVAL` (10 s por defecto). Con la UI cerrada pasa a un heartbeat ligero de salud/alertas cada `EASYZFS_ZPOOL_ALERT_INTERVAL` (60 s por defecto) y solo hace una recolección completa cada `EASYZFS_ZPOOL_IDLE_INTERVAL` (300 s por defecto). Las alertas en segundo plano (DEGRADED, scrub, capacidad) siguen evaluándose al menos cada minuto, y las series de capacidad siguen persistiendo cada 10 minutos, mientras que los nodos en reposo emiten muchísimos menos comandos `zpool`/`zfs`. + ### v2.9.20 - **Reduce el volumen de logs sudo/journald de los colectores periódicos (#124)**: el servicio de EasyZFS generaba decenas de miles de entradas sudo al día porque cada comando `zpool`/`zfs` que ejecutaban los colectores se logueaba en `sudo` como comando permitido. Se añade `Defaults:easyzfs !pam_session, !log_allowed` al sudoers para que los comandos permitidos no se logueen; los intentos fallidos siguen registrándose. Se complementa con cachés TTL en `ZpoolCollector` para propiedades estables del pool (`autotrim`, `checkpoint`, `compressratio`) y para `zpool status -t`, y una nueva variable de entorno `EASYZFS_ZPOOL_INTERVAL` (60 s por defecto) para controlar el intervalo del colector. Validado en nodos reales de Proxmox: `journalctl -g 'easyzfs :'` pasa de miles de entradas al día a casi cero mientras el estado de pools, datasets y snapshots sigue actualizándose. diff --git a/README.md b/README.md index c720634..211f21c 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,9 @@ The service reads `/etc/easyzfs/env`: | `COOKIE_SECURE` | - | `1` = Secure cookie (behind TLS proxy) | | `EASYZFS_SUDO` | auto | `1`/`0` forces or disables `sudo -n` on zpool/zfs/smartctl/lsblk/crontab | | `RETENTION_DAYS` | `30` | Series retention (daily purge 03:30) | +| `EASYZFS_ZPOOL_INTERVAL` | `10` | Full collection interval (seconds) while the web UI is open | +| `EASYZFS_ZPOOL_ALERT_INTERVAL` | `60` | Health/alert heartbeat interval (seconds) while the UI is closed | +| `EASYZFS_ZPOOL_IDLE_INTERVAL` | `300` | Full collection interval (seconds) while the UI is closed | | `VAPID_PUBLIC_KEY` | - | Web Push public key (installer-generated) | | `VAPID_PRIVATE_KEY` | - | Web Push private key (server only; push disabled if missing) | | `VAPID_SUBJECT` | `mailto:easyzfs@localhost` | VAPID contact (`mailto:`, required by Safari) | @@ -359,6 +362,10 @@ Go dependencies (kept to 2 on purpose): ## Changelog +### v2.9.21 + +- **Throttle ZFS collector when no UI is connected (#126)**: the web UI keeps an SSE stream open to `/api/events`, so the `ZpoolCollector` now uses the hub's subscriber count as a presence signal. While the UI is open it runs a full collection every `EASYZFS_ZPOOL_INTERVAL` (default 10 s). When the UI is closed it falls back to a cheap health/alert heartbeat every `EASYZFS_ZPOOL_ALERT_INTERVAL` (default 60 s) and only runs a full collection every `EASYZFS_ZPOOL_IDLE_INTERVAL` (default 300 s). Background alerts (DEGRADED, scrub, capacity) keep firing at least every minute, and capacity series still persist every 10 minutes, while idle nodes issue dramatically fewer `zpool`/`zfs` commands. + ### v2.9.20 - **Reduce sudo/journald log volume from periodic collectors (#124)**: the EasyZFS service generated tens of thousands of sudo log entries per day because every `zpool`/`zfs` command run by the collectors was logged by `sudo` as an allowed command. Add `Defaults:easyzfs !pam_session, !log_allowed` to the sudoers file so allowed commands are no longer logged; failed attempts still are. Complement this with TTL caches in `ZpoolCollector` for stable pool properties (`autotrim`, `checkpoint`, `compressratio`) and for `zpool status -t`, plus a new `EASYZFS_ZPOOL_INTERVAL` environment variable (default 60 s) to control the collector tick interval. Validated on real Proxmox nodes: `journalctl -g 'easyzfs :'` drops from thousands of entries per day to near zero while pool status, datasets and snapshots keep updating. diff --git a/deploy/install.sh b/deploy/install.sh index da15bc0..58a5272 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -966,7 +966,7 @@ DEMO=1" info "MODO DEMO activado (DEMO=1): datos de muestra; las mutaciones responden 403 demo_mode." info "Para pasar a producción: quita DEMO=1 de ${ENV_FILE} y reinicia el servicio." else - info "Opcionales que puedes añadir: COOKIE_SECURE=1 (tras proxy TLS), RETENTION_DAYS=30, EASYZFS_ZPOOL_INTERVAL=60 (segundos), DEMO=1, MOCK=1." + info "Opcionales que puedes añadir: COOKIE_SECURE=1 (tras proxy TLS), RETENTION_DAYS=30, EASYZFS_ZPOOL_INTERVAL=10 (segundos con UI abierta), EASYZFS_ZPOOL_ALERT_INTERVAL=60 (heartbeat cerrada), EASYZFS_ZPOOL_IDLE_INTERVAL=300 (full collect cerrada), DEMO=1, MOCK=1." fi } diff --git a/internal/collectors/collectors.go b/internal/collectors/collectors.go index 07185de..46df3cc 100644 --- a/internal/collectors/collectors.go +++ b/internal/collectors/collectors.go @@ -63,7 +63,7 @@ func Build(cfg *config.Config, d *sql.DB, h *hub.Hub, al *alerts.Alerter) (*Prov m := NewMock(h, al) return &Providers{Pools: m, Disks: m, SysTimers: m, Perf: m, Caps: m}, []Collector{m, mant} } - zc := NewZpoolCollector(d, h, al, cfg.ZpoolInterval) + zc := NewZpoolCollector(d, h, al, cfg.ZpoolInterval, cfg.ZpoolAlertInterval, cfg.ZpoolIdleInterval, h.SubscriberCount) sc := NewSensorsCollector(h) smc := NewSmartCollector(d, h, al, sc) ssc := NewSchedSysCollector() diff --git a/internal/collectors/zpool.go b/internal/collectors/zpool.go index ae3036d..04765c8 100644 --- a/internal/collectors/zpool.go +++ b/internal/collectors/zpool.go @@ -23,13 +23,15 @@ import ( ) const ( - zpoolIntervalDef = 60 * time.Second - zpoolMaxBackoff = 5 * time.Minute - seriesInterval = 10 * time.Minute // persistir series con esta cadencia minima - historyTTL = 10 * time.Minute // re-leer 'zpool history' como maximo con esta cadencia - historyTimeout = 90 * time.Second // historiales grandes (bigtank: ~20 s / 275 MB) - propTTL = 5 * time.Minute // propiedades estables: autotrim, checkpoint, compressratio - trimTTL = 2 * time.Minute // estado TRIM no cambia tan rapido; reduce llamadas -t + zpoolIntervalDef = 10 * time.Second // full collect con la UI abierta (#126) + zpoolAlertDef = 60 * time.Second // heartbeat de salud con la UI cerrada + zpoolIdleDef = 5 * time.Minute // full collect con la UI cerrada + zpoolMaxBackoff = 5 * time.Minute + seriesInterval = 10 * time.Minute // persistir series con esta cadencia minima + historyTTL = 10 * time.Minute // re-leer 'zpool history' como maximo con esta cadencia + historyTimeout = 90 * time.Second // historiales grandes (bigtank: ~20 s / 275 MB) + propTTL = 5 * time.Minute // propiedades estables: autotrim, checkpoint, compressratio + trimTTL = 2 * time.Minute // estado TRIM no cambia tan rapido; reduce llamadas -t ) // ZpoolCollector — caché de pools, datasets y snapshots. @@ -51,8 +53,12 @@ type ZpoolCollector struct { prevPct map[string]int lastSeries map[string]time.Time - // Intervalo entre recolectas periodicas (configurable; #124). - interval time.Duration + // Intervalos dinamicos segun presencia de UI (#126). + activeInterval time.Duration // full collect con UI abierta + alertInterval time.Duration // heartbeat de salud con UI cerrada + idleInterval time.Duration // full collect con UI cerrada + lastFull time.Time + presenceFn func() int // suscriptores SSE; 0 = nadie con la UI abierta // Cache de propiedades estables y de trim para no repetir comandos en // cada tick del colector. @@ -64,23 +70,35 @@ type ZpoolCollector struct { refreshCh chan struct{} } -// NewZpoolCollector crea el colector. interval=0 usa el default de 60 s. -func NewZpoolCollector(d *sql.DB, h *hub.Hub, al *alerts.Alerter, interval time.Duration) *ZpoolCollector { - if interval <= 0 { - interval = zpoolIntervalDef +// NewZpoolCollector crea el colector. Cero en cualquier intervalo usa su default. +// presenceFn devuelve el numero de suscriptores SSE; se inyecta desde Build. +func NewZpoolCollector(d *sql.DB, h *hub.Hub, al *alerts.Alerter, + activeInterval, alertInterval, idleInterval time.Duration, + presenceFn func() int) *ZpoolCollector { + if activeInterval <= 0 { + activeInterval = zpoolIntervalDef + } + if alertInterval <= 0 { + alertInterval = zpoolAlertDef + } + if idleInterval <= 0 { + idleInterval = zpoolIdleDef } return &ZpoolCollector{ - db: d, - h: h, - al: al, - interval: interval, - prevStatus: map[string]string{}, - prevPct: map[string]int{}, - lastSeries: map[string]time.Time{}, - lastPropsAt: map[string]time.Time{}, - history: map[string][]model.HistoryEntry{}, - historyAt: map[string]time.Time{}, - refreshCh: make(chan struct{}, 1), + db: d, + h: h, + al: al, + activeInterval: activeInterval, + alertInterval: alertInterval, + idleInterval: idleInterval, + presenceFn: presenceFn, + prevStatus: map[string]string{}, + prevPct: map[string]int{}, + lastSeries: map[string]time.Time{}, + lastPropsAt: map[string]time.Time{}, + history: map[string][]model.HistoryEntry{}, + historyAt: map[string]time.Time{}, + refreshCh: make(chan struct{}, 1), } } @@ -98,28 +116,45 @@ func (c *ZpoolCollector) RefreshSoon() { } } -// Run — bucle con ticker, backoff tras 3 fallos seguidos (patrón del skill). +// Run — bucle con ticker dinamico: full collect con UI abierta, heartbeat +// ligero con UI cerrada, y backoff tras 3 fallos seguidos. func (c *ZpoolCollector) Run(ctx context.Context) { - interval := c.interval - t := time.NewTicker(interval) - defer t.Stop() - if err := c.collectOnce(ctx); err != nil { + // Primera pasada completa para llenar la caché antes de que nadie abra la UI. + if err := c.fullCollect(ctx); err != nil { log.Printf("zpool: %v", err) c.fails++ } + + interval := c.nextInterval(false) + t := time.NewTicker(interval) + defer t.Stop() + for { select { case <-ctx.Done(): return case <-c.refreshCh: - // Refresco bajo demanda (mutación reciente): recolecta y - // reinicia el tick periódico desde este momento. - if err := c.collectOnce(ctx); err != nil { + // Refresco bajo demanda (mutacion reciente): recolecta completa y + // reinicia el tick periodico desde este momento. + if err := c.fullCollect(ctx); err != nil { log.Printf("zpool refresh: %v", err) + c.fails++ + } else { + c.fails = 0 } - t.Reset(interval) + t.Reset(c.nextInterval(c.presenceFn() > 0)) case <-t.C: - if err := c.collectOnce(ctx); err != nil { + active := c.presenceFn() > 0 + var err error + if active || time.Since(c.lastFull) >= c.idleInterval { + err = c.fullCollect(ctx) + if err == nil { + c.lastFull = time.Now() + } + } else { + err = c.lightCollect(ctx) + } + if err != nil { log.Printf("zpool: %v", err) c.fails++ } else { @@ -132,15 +167,22 @@ func (c *ZpoolCollector) Run(ctx context.Context) { c.stale = true interval = min(2*interval, zpoolMaxBackoff) t.Reset(interval) - } else if interval != c.interval { + } else { c.stale = false - interval = c.interval - t.Reset(interval) + t.Reset(c.nextInterval(active)) } } } } +// nextInterval elige el siguiente intervalo segun haya UI abierta. +func (c *ZpoolCollector) nextInterval(active bool) time.Duration { + if active { + return c.activeInterval + } + return c.alertInterval +} + // Pools — caché de pools (copia defensiva). func (c *ZpoolCollector) Pools() []model.Pool { c.mu.RLock() @@ -183,7 +225,7 @@ func (c *ZpoolCollector) SnapshotGroups() []model.SnapGroup { } // collectOnce — una pasada completa: list → status por pool → datasets → snapshots. -func (c *ZpoolCollector) collectOnce(ctx context.Context) error { +func (c *ZpoolCollector) fullCollect(ctx context.Context) error { pools, err := c.listPools(ctx) if err != nil { return err @@ -241,11 +283,37 @@ func (c *ZpoolCollector) collectOnce(ctx context.Context) error { c.mu.Unlock() c.publishChanges(pools) - c.al.EvaluatePools(ctx, pools) + if c.al != nil { + c.al.EvaluatePools(ctx, pools) + } c.persistSeries(ctx, pools) return nil } +// lightCollect — heartbeat con la UI cerrada: solo list + status + alertas. +// No ejecuta datasets, snapshots, historial ni series, reduciendo el numero de +// comandos sudo a lo estrictamente necesario para alertas (#126). +func (c *ZpoolCollector) lightCollect(ctx context.Context) error { + pools, err := c.listPools(ctx) + if err != nil { + return err + } + for i := range pools { + c.fillStatus(ctx, &pools[i]) + c.resolveVdevPaths(ctx, &pools[i]) + } + + c.mu.Lock() + c.pools = pools + c.mu.Unlock() + + c.publishChanges(pools) + if c.al != nil { + c.al.EvaluatePools(ctx, pools) + } + return nil +} + // listPools — 'zpool list -Hp' con columnas explícitas por nombre. func (c *ZpoolCollector) listPools(ctx context.Context) ([]model.Pool, error) { out, err := executil.Run(ctx, 10*time.Second, "zpool", "list", "-Hp", @@ -952,6 +1020,9 @@ func (c *ZpoolCollector) publishChanges(pools []model.Pool) { // persistSeries guarda pool..used_pct cada seriesInterval (con retención). func (c *ZpoolCollector) persistSeries(ctx context.Context, pools []model.Pool) { + if c.db == nil { + return + } now := time.Now() for _, p := range pools { key := "pool." + p.Name + ".used_pct" diff --git a/internal/collectors/zpool_test.go b/internal/collectors/zpool_test.go index 33b212e..351d4cd 100644 --- a/internal/collectors/zpool_test.go +++ b/internal/collectors/zpool_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "easyzfs/internal/hub" "easyzfs/internal/model" ) @@ -211,3 +212,77 @@ func TestFillTrimTTL(t *testing.T) { t.Fatalf("esperaba 2 llamadas tras expirar TTL, hay %d", len(calls)) } } + +// fakePoolWithData crea zpool/zfs falsos que devuelven un pool simple para +// poder probar lightCollect/fullCollect sin depender del host real. +func fakePoolWithData(t *testing.T) (dir, logFile string) { + t.Helper() + dir = t.TempDir() + logFile = filepath.Join(dir, "calls.log") + + zpool := `#!/bin/sh +echo "$@" >> ` + logFile + ` +case "$1" in +list) + printf 'tank\t123456789\t12345678\t0\tONLINE\n' + ;; +status) + printf '{"pools":{"tank":{"name":"tank","state":"ONLINE","vdevs":{"tank":{"name":"tank","vdev_type":"root","state":"ONLINE","vdevs":{}}}}}}' + ;; +esac +exit 0 +` + zfs := `#!/bin/sh +echo "$@" >> ` + logFile + ` +exit 0 +` + sudo := `#!/bin/sh +while [ $# -gt 0 ]; do case "$1" in -*) shift;; *) break;; esac; done +exec "$@" +` + for name, body := range map[string]string{"zpool": zpool, "zfs": zfs, "sudo": sudo} { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return dir, logFile +} + +func TestLightCollectFewerCommandsThanFull(t *testing.T) { + _, logFile := fakePoolWithData(t) + + c := NewZpoolCollector(nil, hub.NewHub(), nil, 0, 0, 0, func() int { return 0 }) + ctx := context.Background() + + if err := c.lightCollect(ctx); err != nil { + t.Fatalf("lightCollect: %v", err) + } + lightCalls := len(readCalls(t, logFile)) + if lightCalls == 0 { + t.Fatal("lightCollect no genero llamadas") + } + + if err := c.fullCollect(ctx); err != nil { + t.Fatalf("fullCollect: %v", err) + } + fullCalls := len(readCalls(t, logFile)) + + if fullCalls <= lightCalls { + t.Fatalf("fullCollect deberia generar mas llamadas que lightCollect (full=%d light=%d)", fullCalls, lightCalls) + } +} + +func TestNextInterval(t *testing.T) { + c := NewZpoolCollector(nil, nil, nil, + 100*time.Millisecond, 200*time.Millisecond, 5*time.Second, + func() int { return 0 }) + + if got := c.nextInterval(true); got != 100*time.Millisecond { + t.Fatalf("active: esperaba 100ms, got %v", got) + } + if got := c.nextInterval(false); got != 200*time.Millisecond { + t.Fatalf("idle: esperaba 200ms, got %v", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a555421..3110001 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,9 +51,13 @@ type Config struct { SyslogProto string // SYSLOG_PROTO: udp | tcp (def udp) SyslogFacility int // SYSLOG_FACILITY (def 1 = user) - // Intervalo del colector principal de ZFS (#124). Valores altos reducen - // el numero de comandos sudo y el volumen de logs de auditoria. - ZpoolInterval time.Duration // EASYZFS_ZPOOL_INTERVAL en segundos (def 60) + // Intervalos del colector principal de ZFS (#124/#126). + // ZpoolInterval = ritmo con la UI abierta (full collect). + // ZpoolAlertInterval = heartbeat de salud/alertas con la UI cerrada. + // ZpoolIdleInterval = full collect con la UI cerrada. + 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) } // DataDir — directorio de datos del daemon (deriva de DB_PATH): ahí viven la @@ -85,6 +89,7 @@ func Load() *Config { VAPIDPrivateKey: os.Getenv("VAPID_PRIVATE_KEY"), VAPIDSubject: env("VAPID_SUBJECT", "mailto:easyzfs@localhost"), + WebhookSecret: os.Getenv("WEBHOOK_SECRET"), WebhookTimeout: time.Duration(envInt("WEBHOOK_TIMEOUT", 10)) * time.Second, WebhookRetries: envInt("WEBHOOK_RETRIES", 3), @@ -107,7 +112,9 @@ func Load() *Config { SyslogProto: env("SYSLOG_PROTO", "udp"), SyslogFacility: envInt("SYSLOG_FACILITY", 1), - ZpoolInterval: time.Duration(envInt("EASYZFS_ZPOOL_INTERVAL", 60)) * time.Second, + 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, } if cfg.Demo { cfg.Mock = true // demo implica colectores mock diff --git a/internal/hub/hub.go b/internal/hub/hub.go index c2a7e99..bdc52a6 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -64,6 +64,13 @@ func (h *Hub) Subscribe(userID string) chan Event { return ch } +// SubscriberCount devuelve el numero de conexiones SSE activas (UI abierta). +func (h *Hub) SubscriberCount() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.subs) +} + // UserActive — ¿tiene este usuario alguna conexión SSE abierta (app abierta)? func (h *Hub) UserActive(userID string) bool { if userID == "" { diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go index bcf2bd6..2e98fda 100644 --- a/internal/hub/hub_test.go +++ b/internal/hub/hub_test.go @@ -88,6 +88,28 @@ func TestHubCloseSignalsSubscribers(t *testing.T) { } } +func TestHubSubscriberCount(t *testing.T) { + h := NewHub() + defer h.Close() + + if h.SubscriberCount() != 0 { + t.Fatalf("sin suscriptores esperaba 0, got %d", h.SubscriberCount()) + } + ch1 := h.Subscribe("u1") + ch2 := h.Subscribe("u2") + if h.SubscriberCount() != 2 { + t.Fatalf("con 2 suscriptores esperaba 2, got %d", h.SubscriberCount()) + } + h.Unsubscribe(ch1) + if h.SubscriberCount() != 1 { + t.Fatalf("tras quitar uno esperaba 1, got %d", h.SubscriberCount()) + } + h.Unsubscribe(ch2) + if h.SubscriberCount() != 0 { + t.Fatalf("sin suscriptores esperaba 0, got %d", h.SubscriberCount()) + } +} + func TestHubUserActive(t *testing.T) { h := NewHub() defer h.Close()