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
7 changes: 7 additions & 0 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion deploy/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion internal/collectors/collectors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
149 changes: 110 additions & 39 deletions internal/collectors/zpool.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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),
}
}

Expand All @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -952,6 +1020,9 @@ func (c *ZpoolCollector) publishChanges(pools []model.Pool) {

// persistSeries guarda pool.<name>.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"
Expand Down
75 changes: 75 additions & 0 deletions internal/collectors/zpool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"testing"
"time"

"easyzfs/internal/hub"
"easyzfs/internal/model"
)

Expand Down Expand Up @@ -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)
}
}
Loading
Loading