diff --git a/agent/agent.go b/agent/agent.go
index 0dd8638e..b3598f4e 100644
--- a/agent/agent.go
+++ b/agent/agent.go
@@ -48,6 +48,7 @@ type Agent struct {
keys []gossh.PublicKey // SSH public keys
smartManager *SmartManager // Manages SMART data
systemdManager *systemdManager // Manages systemd services
+ zfsManager *ZfsManager // Manages ZFS pool and dataset data
}
// NewAgent creates a new agent with the given data directory for persisting data.
@@ -121,6 +122,19 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize handler registry
agent.handlerRegistry = NewHandlerRegistry()
+ agent.zfsManager = newZfsManager()
+
+ // ZFS_INTERVAL env var to update ZFS detail data at this interval
+ if zfsIntervalEnv, exists := utils.GetEnv("ZFS_INTERVAL"); exists {
+ if duration, err := time.ParseDuration(zfsIntervalEnv); err == nil && duration > 0 {
+ agent.zfsManager.detailInterval = duration
+ agent.systemDetails.ZfsInterval = duration
+ slog.Info("ZFS_INTERVAL", "duration", duration)
+ } else {
+ slog.Warn("Invalid ZFS_INTERVAL", "err", err)
+ }
+ }
+
// initialize disk info
agent.initializeDiskInfo()
diff --git a/agent/disk.go b/agent/disk.go
index e76a999e..fa96fb23 100644
--- a/agent/disk.go
+++ b/agent/disk.go
@@ -537,7 +537,16 @@ func normalizeDeviceName(value string) string {
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
a.fsNames = a.fsNames[:0]
now := time.Now()
+ // ZFS datasets have no /proc/diskstats entry, so they are excluded from
+ // I/O tracking instead of warning about a missing device (#1541).
+ var zfsMountpoints map[string]bool
+ if a.zfsManager != nil {
+ zfsMountpoints = a.zfsManager.ZfsMountpoints()
+ }
for device, stats := range a.fsStats {
+ if zfsMountpoints[stats.Mountpoint] {
+ continue
+ }
// skip if not in diskIoCounters
d, exists := diskIoCounters[device]
if !exists {
@@ -562,20 +571,31 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
!a.lastDiskUsageUpdate.IsZero() &&
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
+ // ZFS dataset mountpoints use `zfs list` values because statfs(2) reports
+ // dataset-level usage that excludes child datasets (#1541).
+ var zfsUsage map[string]zfsDatasetUsage
+ if a.zfsManager != nil {
+ zfsUsage = a.zfsManager.DatasetUsage()
+ }
+
// disk usage
for _, stats := range a.fsStats {
// Skip non-root filesystems if caching is active
if cacheExtraFs && !stats.Root {
continue
}
- if d, err := disk.Usage(stats.Mountpoint); err == nil {
- stats.DiskTotal = utils.BytesToGigabytes(d.Total)
- stats.DiskUsed = utils.BytesToGigabytes(d.Used)
- if stats.Root {
- systemStats.DiskTotal = utils.BytesToGigabytes(d.Total)
- systemStats.DiskUsed = utils.BytesToGigabytes(d.Used)
- systemStats.DiskPct = utils.TwoDecimals(d.UsedPercent)
+ var total, used uint64
+ var usedPct float64
+ if u, ok := zfsUsage[stats.Mountpoint]; ok {
+ total = u.used + u.avail
+ used = u.used
+ if total > 0 {
+ usedPct = float64(used) / float64(total) * 100
}
+ } else if d, err := disk.Usage(stats.Mountpoint); err == nil {
+ total = d.Total
+ used = d.Used
+ usedPct = d.UsedPercent
} else {
// reset stats if error (likely unmounted)
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
@@ -583,6 +603,14 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
stats.DiskUsed = 0
stats.TotalRead = 0
stats.TotalWrite = 0
+ continue
+ }
+ stats.DiskTotal = utils.BytesToGigabytes(total)
+ stats.DiskUsed = utils.BytesToGigabytes(used)
+ if stats.Root {
+ systemStats.DiskTotal = stats.DiskTotal
+ systemStats.DiskUsed = stats.DiskUsed
+ systemStats.DiskPct = utils.TwoDecimals(usedPct)
}
}
diff --git a/agent/disk_zfs_test.go b/agent/disk_zfs_test.go
new file mode 100644
index 00000000..5f0f3f82
--- /dev/null
+++ b/agent/disk_zfs_test.go
@@ -0,0 +1,109 @@
+//go:build testing
+
+package agent
+
+import (
+ "testing"
+
+ "github.com/henrygd/beszel/agent/zfs"
+ "github.com/henrygd/beszel/internal/entities/system"
+ "github.com/shirou/gopsutil/v4/disk"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestUpdateDiskUsageZfsMountpoint verifies that a filesystem whose mountpoint
+// is a ZFS dataset reports `zfs list` usage (which includes child datasets)
+// instead of the dataset-scoped statfs values (#1541).
+func TestUpdateDiskUsageZfsMountpoint(t *testing.T) {
+ zm := &ZfsManager{}
+ zm.datasetsFn = func() ([]zfs.Dataset, error) {
+ return []zfs.Dataset{
+ {Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
+ }, nil
+ }
+ agent := &Agent{
+ fsStats: map[string]*system.FsStats{
+ "tank": {Root: false, Mountpoint: "/tank"},
+ },
+ zfsManager: zm,
+ }
+
+ var stats system.Stats
+ agent.updateDiskUsage(&stats)
+
+ fs := agent.fsStats["tank"]
+ require.NotNil(t, fs)
+ assert.Equal(t, 22350.81, fs.DiskTotal) // (used + avail) in GiB
+ assert.Equal(t, 11175.87, fs.DiskUsed)
+ // Non-root filesystems do not populate system-level stats.
+ assert.Equal(t, float64(0), stats.DiskTotal)
+}
+
+// TestUpdateDiskUsageZfsRootPopulatesSystemStats verifies the root disk values
+// are derived from ZFS usage when the root mountpoint is a ZFS dataset.
+func TestUpdateDiskUsageZfsRootPopulatesSystemStats(t *testing.T) {
+ zm := &ZfsManager{}
+ zm.datasetsFn = func() ([]zfs.Dataset, error) {
+ return []zfs.Dataset{
+ {Name: "rpool/ROOT/pve-1", Used: 900000000000, Avail: 300000000000, Mountpoint: "/"},
+ }, nil
+ }
+ agent := &Agent{
+ fsStats: map[string]*system.FsStats{
+ "rpool/ROOT/pve-1": {Root: true, Mountpoint: "/"},
+ },
+ zfsManager: zm,
+ }
+
+ var stats system.Stats
+ agent.updateDiskUsage(&stats)
+
+ assert.Equal(t, 1117.59, agent.fsStats["rpool/ROOT/pve-1"].DiskTotal)
+ assert.Equal(t, 838.19, agent.fsStats["rpool/ROOT/pve-1"].DiskUsed)
+ assert.Equal(t, 75.0, stats.DiskPct)
+ assert.Equal(t, 1117.59, stats.DiskTotal)
+ assert.Equal(t, 838.19, stats.DiskUsed)
+}
+
+// TestUpdateDiskUsageWithoutZfsManager falls back to statfs when no manager is
+// present (e.g. tests constructing bare Agent values).
+func TestUpdateDiskUsageWithoutZfsManager(t *testing.T) {
+ agent := &Agent{
+ fsStats: map[string]*system.FsStats{
+ "root": {Root: true, Mountpoint: "/"},
+ },
+ }
+
+ var stats system.Stats
+ agent.updateDiskUsage(&stats)
+
+ assert.True(t, agent.fsStats["root"].DiskTotal > 0, "root usage should come from statfs")
+ assert.True(t, stats.DiskTotal > 0)
+}
+
+// TestInitializeDiskIoStatsSkipsZfsMountpoints verifies ZFS filesystems are
+// excluded from diskstats I/O tracking instead of warning about a missing device.
+func TestInitializeDiskIoStatsSkipsZfsMountpoints(t *testing.T) {
+ zm := &ZfsManager{}
+ zm.datasetsFn = func() ([]zfs.Dataset, error) {
+ return []zfs.Dataset{{Name: "tank", Mountpoint: "/tank"}}, nil
+ }
+ agent := &Agent{
+ fsStats: map[string]*system.FsStats{
+ "tank": {Root: false, Mountpoint: "/tank"},
+ "sda1": {Root: false, Mountpoint: "/mnt/data"},
+ },
+ zfsManager: zm,
+ diskPrev: make(map[uint16]map[string]prevDisk),
+ }
+
+ agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
+ "sda1": {Name: "sda1", ReadBytes: 100, WriteBytes: 100},
+ })
+
+ assert.Equal(t, []string{"sda1"}, agent.fsNames)
+ assert.Equal(t, uint64(100), agent.fsStats["sda1"].TotalRead)
+ // ZFS entry is present but untouched by diskstats initialization.
+ assert.Equal(t, uint64(0), agent.fsStats["tank"].TotalRead)
+}
diff --git a/agent/handlers.go b/agent/handlers.go
index adf9c918..4c8eb30e 100644
--- a/agent/handlers.go
+++ b/agent/handlers.go
@@ -51,6 +51,7 @@ func NewHandlerRegistry() *HandlerRegistry {
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
+ registry.Register(common.GetZfsData, &GetZfsDataHandler{})
return registry
}
@@ -178,6 +179,23 @@ func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
}, hctx.RequestID)
}
+////////////////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////
+
+// GetZfsDataHandler handles ZFS detail data requests
+type GetZfsDataHandler struct{}
+
+func (h *GetZfsDataHandler) Handle(hctx *HandlerContext) error {
+ if hctx.Agent.zfsManager == nil {
+ return hctx.SendResponse(nil, hctx.RequestID)
+ }
+ var req common.ZfsDataRequest
+ if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
+ return err
+ }
+ return hctx.SendResponse(hctx.Agent.zfsManager.GetDetail(req.Force), hctx.RequestID)
+}
+
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
diff --git a/agent/handlers_test.go b/agent/handlers_test.go
index df7fa869..3150d5b7 100644
--- a/agent/handlers_test.go
+++ b/agent/handlers_test.go
@@ -4,8 +4,10 @@ package agent
import (
"testing"
+ "time"
"github.com/fxamacker/cbor/v2"
+ "github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/stretchr/testify/assert"
@@ -30,6 +32,32 @@ func TestNewAgentResponseSmartData(t *testing.T) {
assert.True(t, response.SmartComplete)
}
+func TestGetZfsDataHandlerForceRefresh(t *testing.T) {
+ poolCalls := 0
+ zm := &ZfsManager{detailInterval: time.Hour}
+ zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
+ poolCalls++
+ return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
+ }
+ zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
+ zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
+ zm.GetDetail(false)
+
+ requestData, err := cbor.Marshal(common.ZfsDataRequest{Force: true})
+ assert.NoError(t, err)
+ ctx := &HandlerContext{
+ Agent: &Agent{zfsManager: zm},
+ Request: &common.HubRequest[cbor.RawMessage]{
+ Action: common.GetZfsData,
+ Data: requestData,
+ },
+ SendResponse: func(any, *uint32) error { return nil },
+ }
+
+ assert.NoError(t, (&GetZfsDataHandler{}).Handle(ctx))
+ assert.Equal(t, 2, poolCalls)
+}
+
func (m *MockHandler) Handle(ctx *HandlerContext) error {
if m.handleFunc != nil {
return m.handleFunc(ctx)
diff --git a/agent/system.go b/agent/system.go
index 1df167ee..ff322958 100644
--- a/agent/system.go
+++ b/agent/system.go
@@ -219,6 +219,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// disk i/o (cache-aware per interval)
a.updateDiskIo(cacheTimeMs, &systemStats)
+ // zfs pool stats
+ a.zfsManager.Update(&systemStats)
+
// network stats (per cache interval)
a.updateNetworkStats(cacheTimeMs, &systemStats)
diff --git a/agent/test-data/zfs/zfs_list.txt b/agent/test-data/zfs/zfs_list.txt
new file mode 100644
index 00000000..68a020df
--- /dev/null
+++ b/agent/test-data/zfs/zfs_list.txt
@@ -0,0 +1,9 @@
+tank 12000000000000 11999000000000 /tank
+tank/apps 1000000000000 11999000000000 /tank/apps
+tank/backup 2000000000000 11999000000000 /tank/backup
+tank/media 1000000000000 11999000000000 /tank/my media
+rpool 900000000000 300000000000 -
+rpool/ROOT 1000000000 300000000000 -
+rpool/ROOT/pve-1 890000000000 300000000000 /
+rpool/data 9000000000 300000000000 -
+rpool/data/subvol-100-disk-0 400000000000 300000000000 /subvol-100-disk-0
diff --git a/agent/test-data/zfs/zpool_list.txt b/agent/test-data/zfs/zpool_list.txt
new file mode 100644
index 00000000..9938bb90
--- /dev/null
+++ b/agent/test-data/zfs/zpool_list.txt
@@ -0,0 +1,2 @@
+tank 23999000000000 12000000000000 11999000000000 ONLINE
+rpool 1200000000000 900000000000 300000000000 DEGRADED
diff --git a/agent/test-data/zfs/zpool_status.txt b/agent/test-data/zfs/zpool_status.txt
new file mode 100644
index 00000000..e25076c8
--- /dev/null
+++ b/agent/test-data/zfs/zpool_status.txt
@@ -0,0 +1,29 @@
+ pool: tank
+ state: ONLINE
+ scan: scrub repaired 0B in 00:05:12 with 0 errors on Sun Jun 1 02:00:12 2025
+config:
+
+ NAME STATE READ WRITE CKSUM
+ tank ONLINE 0 0 0
+ mirror-0 ONLINE 0 0 0
+ sda ONLINE 0 0 0
+ sdb ONLINE 0 0 0
+
+errors: No known data errors
+
+ pool: rpool
+ state: DEGRADED
+ status: One or more devices could not be used because the label is missing or
+ invalid. Sufficient replicas exist for the pool to continue functioning in a
+ degraded state.
+ scan: scrub in progress since Sun Jun 8 01:00:00 2025
+ 10.00% done, 01:30:00 to go, 0.00/s
+config:
+
+ NAME STATE READ WRITE CKSUM
+ rpool DEGRADED 0 0 0
+ mirror-0 DEGRADED 0 0 0
+ sda ONLINE 0 0 0
+ sdb FAULTED 1 2 3
+
+errors: 1 data errors, use '-v' for a list
diff --git a/agent/zfs/zfs.go b/agent/zfs/zfs.go
new file mode 100644
index 00000000..1f8f2258
--- /dev/null
+++ b/agent/zfs/zfs.go
@@ -0,0 +1,160 @@
+// Package zfs provides functions to read ZFS statistics.
+package zfs
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var commandTimeout = 10 * time.Second
+
+var commandOutput = func(name string, args ...string) ([]byte, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
+ out, err := cmd.Output()
+ if ctx.Err() != nil {
+ return nil, fmt.Errorf("%s timed out after %s: %w", name, commandTimeout, ctx.Err())
+ }
+ return out, err
+}
+
+// ErrNoZfs is returned when the ZFS utilities or kernel interfaces are unavailable.
+var ErrNoZfs = errors.New("zfs utilities unavailable")
+
+// PoolStat is a snapshot of a ZFS pool's capacity and health.
+type PoolStat struct {
+ Name string
+ Size uint64 // total capacity in bytes
+ Alloc uint64 // allocated bytes
+ Free uint64 // free bytes
+ Health string // ONLINE, DEGRADED, FAULTED, ...
+}
+
+// PoolKernelStat is the inexpensive pool telemetry exposed by the ZFS kernel.
+// NRead and NWrite are cumulative byte counters since the pool was imported.
+type PoolKernelStat struct {
+ Name string
+ Health string
+ NRead uint64
+ NWrite uint64
+}
+
+// PoolIoStats holds calculated per-second I/O rates for a pool.
+type PoolIoStats struct {
+ NRead uint64
+ NWrite uint64
+}
+
+// Dataset is a single ZFS dataset with usage information.
+type Dataset struct {
+ Name string
+ Used uint64
+ Avail uint64
+ Mountpoint string
+}
+
+// PoolStats returns capacity and health for all pools on the system using
+// `zpool list`. Frequent health and I/O sampling uses PoolKernelStats instead.
+func PoolStats() ([]PoolStat, error) {
+ out, err := commandOutput("zpool", "list", "-Hp", "-o", "name,size,alloc,free,health")
+ if err != nil {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) && strings.Contains(string(exitErr.Stderr), "no pools available") {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("zpool list: %w", err)
+ }
+ return parseZpoolListOutput(out)
+}
+
+// Datasets returns all datasets on the system with usage and mountpoint
+// information using `zfs list` (recursive by default).
+func Datasets() ([]Dataset, error) {
+ out, err := commandOutput("zfs", "list", "-Hp", "-o", "name,used,avail,mountpoint")
+ if err != nil {
+ return nil, fmt.Errorf("zfs list: %w", err)
+ }
+ return parseZfsListOutput(out)
+}
+
+// parseZpoolListOutput parses `zpool list -Hp -o name,size,alloc,free,health` output.
+// Columns are tab-separated; numeric columns are raw bytes.
+func parseZpoolListOutput(out []byte) ([]PoolStat, error) {
+ var pools []PoolStat
+ scanner := bufio.NewScanner(bytes.NewReader(out))
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ if line == "no pools available" && len(pools) == 0 {
+ return nil, nil
+ }
+ fields := strings.Split(line, "\t")
+ if len(fields) < 5 {
+ return nil, fmt.Errorf("unexpected zpool list line: %q", line)
+ }
+ size, err := strconv.ParseUint(fields[1], 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("parsing size for pool %q: %w", fields[0], err)
+ }
+ alloc, err := strconv.ParseUint(fields[2], 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("parsing alloc for pool %q: %w", fields[0], err)
+ }
+ free, err := strconv.ParseUint(fields[3], 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("parsing free for pool %q: %w", fields[0], err)
+ }
+ pools = append(pools, PoolStat{
+ Name: fields[0],
+ Size: size,
+ Alloc: alloc,
+ Free: free,
+ Health: fields[4],
+ })
+ }
+ return pools, scanner.Err()
+}
+
+// parseZfsListOutput parses `zfs list -Hp -o name,used,avail,mountpoint` output.
+// The mountpoint column may contain spaces, so it is split on tabs only.
+func parseZfsListOutput(out []byte) ([]Dataset, error) {
+ var datasets []Dataset
+ scanner := bufio.NewScanner(bytes.NewReader(out))
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ fields := strings.SplitN(line, "\t", 4)
+ if len(fields) < 4 {
+ return nil, fmt.Errorf("unexpected zfs list line: %q", line)
+ }
+ used, err := strconv.ParseUint(fields[1], 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("parsing used for dataset %q: %w", fields[0], err)
+ }
+ avail, err := strconv.ParseUint(fields[2], 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("parsing avail for dataset %q: %w", fields[0], err)
+ }
+ datasets = append(datasets, Dataset{
+ Name: fields[0],
+ Used: used,
+ Avail: avail,
+ Mountpoint: fields[3],
+ })
+ }
+ return datasets, scanner.Err()
+}
diff --git a/agent/zfs/zfs_freebsd.go b/agent/zfs/zfs_freebsd.go
index 869776a3..e0bcd04e 100644
--- a/agent/zfs/zfs_freebsd.go
+++ b/agent/zfs/zfs_freebsd.go
@@ -3,9 +3,17 @@
package zfs
import (
+ "errors"
+
"golang.org/x/sys/unix"
)
func ARCSize() (uint64, error) {
return unix.SysctlUint64("kstat.zfs.misc.arcstats.size")
}
+
+// FreeBSD does not expose Linux's per-pool procfs kstats. Capacity, health,
+// and detail collection still work through the cached utilities.
+func PoolKernelStats() ([]PoolKernelStat, error) {
+ return nil, errors.ErrUnsupported
+}
diff --git a/agent/zfs/zfs_linux.go b/agent/zfs/zfs_linux.go
index 9f94380d..6be9b90e 100644
--- a/agent/zfs/zfs_linux.go
+++ b/agent/zfs/zfs_linux.go
@@ -5,14 +5,18 @@ package zfs
import (
"bufio"
+ "errors"
"fmt"
"os"
+ "path/filepath"
"strconv"
"strings"
)
+var procZfsPath = "/proc/spl/kstat/zfs"
+
func ARCSize() (uint64, error) {
- file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
+ file, err := os.Open(filepath.Join(procZfsPath, "arcstats"))
if err != nil {
return 0, err
}
@@ -29,6 +33,167 @@ func ARCSize() (uint64, error) {
return strconv.ParseUint(fields[2], 10, 64)
}
}
+ if err := scanner.Err(); err != nil {
+ return 0, err
+ }
return 0, fmt.Errorf("size field not found in arcstats")
}
+
+// PoolKernelStats reads pool state and cumulative I/O counters directly from
+// procfs. These kstats are the same interfaces used by node_exporter's Linux
+// ZFS collector and avoid keeping a `zpool iostat` subprocess alive.
+func PoolKernelStats() ([]PoolKernelStat, error) {
+ poolDirs := make(map[string]struct{})
+ for _, filename := range []string{"state", "io", "objset-*"} {
+ paths, err := filepath.Glob(filepath.Join(procZfsPath, "*", filename))
+ if err != nil {
+ return nil, err
+ }
+ for _, path := range paths {
+ poolDirs[filepath.Dir(path)] = struct{}{}
+ }
+ }
+ if len(poolDirs) == 0 {
+ return nil, ErrNoZfs
+ }
+ pools := make([]PoolKernelStat, 0, len(poolDirs))
+ for poolDir := range poolDirs {
+ nread, nwrite, err := readPoolCounters(poolDir)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ continue // pool may have been exported after the glob
+ }
+ return nil, err
+ }
+ state, err := os.ReadFile(filepath.Join(poolDir, "state"))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ pools = append(pools, PoolKernelStat{
+ Name: filepath.Base(poolDir), Health: strings.ToUpper(strings.TrimSpace(string(state))),
+ NRead: nread, NWrite: nwrite,
+ })
+ }
+ if len(pools) == 0 {
+ return nil, ErrNoZfs
+ }
+ return pools, nil
+}
+
+// readPoolCounters supports both ZFS kernel interfaces. OpenZFS through 2.3
+// exposes aggregate vdev counters in "io". When that file is unavailable, sum
+// the logical I/O counters exposed for each dataset in the pool.
+func readPoolCounters(poolDir string) (uint64, uint64, error) {
+ nread, nwrite, err := readPoolIO(filepath.Join(poolDir, "io"))
+ if err == nil || !errors.Is(err, os.ErrNotExist) {
+ return nread, nwrite, err
+ }
+ return readPoolObjsets(poolDir)
+}
+
+func readPoolIO(path string) (uint64, uint64, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return 0, 0, err
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ fields := strings.Fields(scanner.Text())
+ if len(fields) < 2 || fields[0] != "nread" {
+ continue
+ }
+ if !scanner.Scan() {
+ break
+ }
+ values := strings.Fields(scanner.Text())
+ if len(values) < 2 {
+ break
+ }
+ nread, err := strconv.ParseUint(values[0], 10, 64)
+ if err != nil {
+ return 0, 0, fmt.Errorf("parsing nread in %s: %w", path, err)
+ }
+ nwrite, err := strconv.ParseUint(values[1], 10, 64)
+ if err != nil {
+ return 0, 0, fmt.Errorf("parsing nwritten in %s: %w", path, err)
+ }
+ return nread, nwrite, nil
+ }
+ if err := scanner.Err(); err != nil {
+ return 0, 0, err
+ }
+ return 0, 0, fmt.Errorf("I/O counters not found in %s", path)
+}
+
+func readPoolObjsets(poolDir string) (uint64, uint64, error) {
+ paths, err := filepath.Glob(filepath.Join(poolDir, "objset-*"))
+ if err != nil {
+ return 0, 0, err
+ }
+ if len(paths) == 0 {
+ return 0, 0, fmt.Errorf("dataset I/O counters not found in %s", poolDir)
+ }
+
+ var totalRead, totalWrite uint64
+ objsetsRead := 0
+ for _, path := range paths {
+ nread, nwrite, err := readObjsetIO(path)
+ if errors.Is(err, os.ErrNotExist) {
+ continue // dataset may have been destroyed after the glob
+ }
+ if err != nil {
+ return 0, 0, err
+ }
+ totalRead += nread
+ totalWrite += nwrite
+ objsetsRead++
+ }
+ if objsetsRead == 0 {
+ return 0, 0, fmt.Errorf("dataset I/O counters not found in %s", poolDir)
+ }
+ return totalRead, totalWrite, nil
+}
+
+func readObjsetIO(path string) (uint64, uint64, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return 0, 0, err
+ }
+ defer file.Close()
+
+ var nread, nwrite uint64
+ var foundRead, foundWrite bool
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ fields := strings.Fields(scanner.Text())
+ if len(fields) < 3 {
+ continue
+ }
+ var target *uint64
+ switch fields[0] {
+ case "nread":
+ target = &nread
+ foundRead = true
+ case "nwritten":
+ target = &nwrite
+ foundWrite = true
+ default:
+ continue
+ }
+ value, err := strconv.ParseUint(fields[2], 10, 64)
+ if err != nil {
+ return 0, 0, fmt.Errorf("parsing %s in %s: %w", fields[0], path, err)
+ }
+ *target = value
+ }
+ if err := scanner.Err(); err != nil {
+ return 0, 0, err
+ }
+ if !foundRead || !foundWrite {
+ return 0, 0, fmt.Errorf("incomplete I/O counters in %s", path)
+ }
+ return nread, nwrite, nil
+}
diff --git a/agent/zfs/zfs_linux_test.go b/agent/zfs/zfs_linux_test.go
new file mode 100644
index 00000000..8c8e8623
--- /dev/null
+++ b/agent/zfs/zfs_linux_test.go
@@ -0,0 +1,90 @@
+//go:build testing && linux
+
+package zfs
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPoolKernelStats(t *testing.T) {
+ root := t.TempDir()
+ oldPath := procZfsPath
+ procZfsPath = root
+ t.Cleanup(func() { procZfsPath = oldPath })
+
+ poolDir := filepath.Join(root, "tank")
+ require.NoError(t, os.MkdirAll(poolDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(poolDir, "io"), []byte(
+ "11 3 0x00 1 80 0 0\n"+
+ "nread nwritten reads writes wtime wlentime wupdate rtime rlentime rupdate wcnt rcnt\n"+
+ "1884160 6450688 22 978 0 0 0 0 0 0 0 0\n",
+ ), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(poolDir, "state"), []byte("DEGRADED\n"), 0o644))
+
+ stats, err := PoolKernelStats()
+ require.NoError(t, err)
+ require.Len(t, stats, 1)
+ assert.Equal(t, PoolKernelStat{
+ Name: "tank", Health: "DEGRADED", NRead: 1884160, NWrite: 6450688,
+ }, stats[0])
+}
+
+func TestPoolKernelStatsOpenZfs24(t *testing.T) {
+ root := t.TempDir()
+ oldPath := procZfsPath
+ procZfsPath = root
+ t.Cleanup(func() { procZfsPath = oldPath })
+
+ poolDir := filepath.Join(root, "tank")
+ require.NoError(t, os.MkdirAll(poolDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(poolDir, "state"), []byte("ONLINE\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(poolDir, "objset-0x1"), []byte(
+ "34 1 0x01 28 7872 0 0\n"+
+ "name type data\n"+
+ "dataset_name 7 tank\n"+
+ "nwritten 4 2000\n"+
+ "nread 4 1000\n",
+ ), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(poolDir, "objset-0x2"), []byte(
+ "34 1 0x01 28 7872 0 0\n"+
+ "name type data\n"+
+ "dataset_name 7 tank/videos\n"+
+ "nwritten 4 400\n"+
+ "nread 4 300\n",
+ ), 0o644))
+
+ stats, err := PoolKernelStats()
+ require.NoError(t, err)
+ require.Len(t, stats, 1)
+ assert.Equal(t, PoolKernelStat{
+ Name: "tank", Health: "ONLINE", NRead: 1300, NWrite: 2400,
+ }, stats[0])
+}
+
+func TestPoolKernelStatsNoZfs(t *testing.T) {
+ oldPath := procZfsPath
+ procZfsPath = t.TempDir()
+ t.Cleanup(func() { procZfsPath = oldPath })
+
+ _, err := PoolKernelStats()
+ assert.ErrorIs(t, err, ErrNoZfs)
+}
+
+func TestReadPoolIORejectsMalformedCounters(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "io")
+ require.NoError(t, os.WriteFile(path, []byte("nread nwritten\nnope 10\n"), 0o644))
+ _, _, err := readPoolIO(path)
+ require.Error(t, err)
+}
+
+func TestReadObjsetIORequiresAllCounters(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "objset-0x1")
+ require.NoError(t, os.WriteFile(path, []byte("nread 4 10\n"), 0o644))
+ _, _, err := readObjsetIO(path)
+ require.Error(t, err)
+}
diff --git a/agent/zfs/zfs_status.go b/agent/zfs/zfs_status.go
new file mode 100644
index 00000000..052d7e72
--- /dev/null
+++ b/agent/zfs/zfs_status.go
@@ -0,0 +1,150 @@
+package zfs
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// PoolStatus holds parsed `zpool status` information for one pool.
+type PoolStatus struct {
+ Name string
+ State string // ONLINE, DEGRADED, FAULTED, ...
+ Scrub ScrubStatus
+ Vdevs []VdevStatus
+}
+
+// ScrubStatus holds the scrub (or resilver) status parsed from the scan line.
+type ScrubStatus struct {
+ State string // NONE, SCANNING, FINISHED, CANCELED
+ Progress string // e.g. "10.00%" while scanning
+ Errors uint64
+}
+
+// VdevStatus is a single vdev row (mirror, raidz, or leaf disk).
+type VdevStatus struct {
+ Name string
+ State string
+ ReadErrs uint64
+ WriteErrs uint64
+ ChecksumErrs uint64
+}
+
+var (
+ progressRe = regexp.MustCompile(`(\d+\.\d+)%\s+done`)
+ errorsRe = regexp.MustCompile(`with\s+(\d+)\s+errors`)
+)
+
+// PoolStatuses runs `zpool status` and parses per-pool state, scrub, and vdev
+// information. The human-readable format has been stable across OpenZFS
+// releases; rows are matched by their tabular shape rather than position.
+func PoolStatuses() ([]PoolStatus, error) {
+ out, err := commandOutput("zpool", "status")
+ if err != nil {
+ return nil, fmt.Errorf("zpool status: %w", err)
+ }
+ return parseZpoolStatusOutput(out)
+}
+
+// parseZpoolStatusOutput parses the output of `zpool status`.
+func parseZpoolStatusOutput(out []byte) ([]PoolStatus, error) {
+ var pools []PoolStatus
+ var current *PoolStatus
+ inConfig := false
+ scanContinuation := false // next non-blank line continues the scan line (progress)
+
+ scanner := bufio.NewScanner(bytes.NewReader(out))
+ for scanner.Scan() {
+ line := scanner.Text()
+ trimmed := strings.TrimSpace(line)
+
+ switch {
+ case strings.HasPrefix(trimmed, "pool:"):
+ pools = append(pools, PoolStatus{Name: strings.TrimSpace(strings.TrimPrefix(trimmed, "pool:"))})
+ current = &pools[len(pools)-1]
+ inConfig = false
+ scanContinuation = false
+ case current == nil:
+ continue
+ case strings.HasPrefix(trimmed, "state:"):
+ current.State = strings.TrimSpace(strings.TrimPrefix(trimmed, "state:"))
+ case strings.HasPrefix(trimmed, "scan:"):
+ current.Scrub = parseScanLine(trimmed)
+ // zpool status prints the progress percentage on the line after scan.
+ scanContinuation = true
+ case trimmed == "config:":
+ inConfig = true
+ case scanContinuation:
+ // The line after scan: may be an indented progress continuation.
+ if m := progressRe.FindStringSubmatch(trimmed); m != nil {
+ current.Scrub.Progress = m[1] + "%"
+ }
+ scanContinuation = false
+ case inConfig && (line == "" || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")):
+ // Table rows are indented; blank lines separate sections. The
+ // column header and the pool's own row are skipped.
+ if trimmed != "" && !strings.HasPrefix(trimmed, "NAME") {
+ if vdev, ok := parseVdevLine(trimmed, current.Name); ok {
+ current.Vdevs = append(current.Vdevs, vdev)
+ }
+ }
+ case inConfig:
+ // unindented line (errors:, status:, next pool:) ends the table
+ inConfig = false
+ }
+ }
+ return pools, scanner.Err()
+}
+
+// parseScanLine maps a `scan:` line to a ScrubStatus.
+func parseScanLine(line string) ScrubStatus {
+ var scrub ScrubStatus
+ switch {
+ case strings.Contains(line, "in progress"):
+ scrub.State = "SCANNING"
+ case strings.Contains(line, "canceled"):
+ scrub.State = "CANCELED"
+ case strings.Contains(line, "repaired"), strings.Contains(line, "resilvered"):
+ scrub.State = "FINISHED"
+ default:
+ scrub.State = "NONE"
+ }
+ if m := progressRe.FindStringSubmatch(line); m != nil {
+ scrub.Progress = m[1] + "%"
+ }
+ if m := errorsRe.FindStringSubmatch(line); m != nil {
+ if n, err := strconv.ParseUint(m[1], 10, 64); err == nil {
+ scrub.Errors = n
+ }
+ }
+ return scrub
+}
+
+// parseVdevLine parses one row of the config table. Rows have the shape
+// "NAME STATE READ WRITE CKSUM [extra...]". The first data row is the pool
+// itself and is skipped since it duplicates pool-level info.
+func parseVdevLine(line, poolName string) (VdevStatus, bool) {
+ fields := strings.Fields(line)
+ if len(fields) < 5 {
+ return VdevStatus{}, false
+ }
+ if fields[0] == poolName {
+ return VdevStatus{}, false
+ }
+ read, err1 := strconv.ParseUint(fields[2], 10, 64)
+ write, err2 := strconv.ParseUint(fields[3], 10, 64)
+ cksum, err3 := strconv.ParseUint(fields[4], 10, 64)
+ if err1 != nil || err2 != nil || err3 != nil {
+ return VdevStatus{}, false
+ }
+ return VdevStatus{
+ Name: fields[0],
+ State: fields[1],
+ ReadErrs: read,
+ WriteErrs: write,
+ ChecksumErrs: cksum,
+ }, true
+}
diff --git a/agent/zfs/zfs_test.go b/agent/zfs/zfs_test.go
new file mode 100644
index 00000000..0472434d
--- /dev/null
+++ b/agent/zfs/zfs_test.go
@@ -0,0 +1,143 @@
+//go:build testing
+
+package zfs
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func fixturePath(name string) string {
+ return filepath.Join("..", "test-data", "zfs", name)
+}
+
+func TestParseZpoolListOutput(t *testing.T) {
+ data, err := os.ReadFile(fixturePath("zpool_list.txt"))
+ require.NoError(t, err)
+
+ pools, err := parseZpoolListOutput(data)
+ require.NoError(t, err)
+ require.Len(t, pools, 2)
+
+ assert.Equal(t, PoolStat{Name: "tank", Size: 23999000000000, Alloc: 12000000000000, Free: 11999000000000, Health: "ONLINE"}, pools[0])
+ assert.Equal(t, PoolStat{Name: "rpool", Size: 1200000000000, Alloc: 900000000000, Free: 300000000000, Health: "DEGRADED"}, pools[1])
+}
+
+func TestParseZpoolListOutputIgnoresEmptyLines(t *testing.T) {
+ pools, err := parseZpoolListOutput([]byte("tank\t100\t50\t50\tONLINE\n\n"))
+ require.NoError(t, err)
+ require.Len(t, pools, 1)
+ assert.Equal(t, "tank", pools[0].Name)
+}
+
+func TestParseZpoolListOutputNoPools(t *testing.T) {
+ pools, err := parseZpoolListOutput([]byte("no pools available\n"))
+ require.NoError(t, err)
+ assert.Empty(t, pools)
+}
+
+func TestParseZpoolListOutputRejectsMalformedLine(t *testing.T) {
+ _, err := parseZpoolListOutput([]byte("tank\t100\t50\n"))
+ require.Error(t, err)
+
+ _, err = parseZpoolListOutput([]byte("tank\tnotanumber\t50\t50\tONLINE\n"))
+ require.Error(t, err)
+}
+
+func TestParseZfsListOutput(t *testing.T) {
+ data, err := os.ReadFile(fixturePath("zfs_list.txt"))
+ require.NoError(t, err)
+
+ datasets, err := parseZfsListOutput(data)
+ require.NoError(t, err)
+ require.Len(t, datasets, 9)
+
+ // Mountpoint with a space must be kept intact (tab-split only).
+ assert.Equal(t, "/tank/my media", datasets[3].Mountpoint)
+ // Unmounted datasets/zvols report "-".
+ assert.Equal(t, "-", datasets[4].Mountpoint)
+ assert.Equal(t, uint64(12000000000000), datasets[0].Used)
+ assert.Equal(t, uint64(11999000000000), datasets[0].Avail)
+}
+
+func TestParseZpoolStatusOutput(t *testing.T) {
+ data, err := os.ReadFile(fixturePath("zpool_status.txt"))
+ require.NoError(t, err)
+
+ pools, err := parseZpoolStatusOutput(data)
+ require.NoError(t, err)
+ require.Len(t, pools, 2)
+
+ tank := pools[0]
+ assert.Equal(t, "tank", tank.Name)
+ assert.Equal(t, "ONLINE", tank.State)
+ assert.Equal(t, "FINISHED", tank.Scrub.State)
+ assert.Equal(t, "", tank.Scrub.Progress)
+ assert.Equal(t, uint64(0), tank.Scrub.Errors)
+ // Pool row itself is skipped; mirror + 2 disks remain.
+ require.Len(t, tank.Vdevs, 3)
+ assert.Equal(t, "mirror-0", tank.Vdevs[0].Name)
+ assert.Equal(t, "sda", tank.Vdevs[1].Name)
+ assert.Equal(t, "sdb", tank.Vdevs[2].Name)
+
+ rpool := pools[1]
+ assert.Equal(t, "rpool", rpool.Name)
+ assert.Equal(t, "DEGRADED", rpool.State)
+ assert.Equal(t, "SCANNING", rpool.Scrub.State)
+ assert.Equal(t, "10.00%", rpool.Scrub.Progress)
+ require.Len(t, rpool.Vdevs, 3)
+ assert.Equal(t, "FAULTED", rpool.Vdevs[2].State)
+ assert.Equal(t, uint64(1), rpool.Vdevs[2].ReadErrs)
+ assert.Equal(t, uint64(2), rpool.Vdevs[2].WriteErrs)
+ assert.Equal(t, uint64(3), rpool.Vdevs[2].ChecksumErrs)
+}
+
+func TestParseScanLine(t *testing.T) {
+ assert.Equal(t, "FINISHED", parseScanLine("scan: scrub repaired 0B in 00:05:12 with 0 errors on Sun Jun 1 02:00:12 2025").State)
+ assert.Equal(t, uint64(3), parseScanLine("scan: scrub repaired 10G in 01:00:00 with 3 errors on Sun Jun 1 02:00:12 2025").Errors)
+ assert.Equal(t, "SCANNING", parseScanLine("scan: scrub in progress since Sun Jun 8 01:00:00 2025").State)
+ assert.Equal(t, "CANCELED", parseScanLine("scan: scrub canceled on Sun Jun 1 02:00:12 2025").State)
+ assert.Equal(t, "FINISHED", parseScanLine("scan: resilvered 1.23G in 00:01:00 with 0 errors on Sun Jun 1 02:00:12 2025").State)
+ assert.Equal(t, "NONE", parseScanLine("scan: none requested").State)
+}
+
+func TestCommandOutputForcesLocaleAndTimesOut(t *testing.T) {
+ t.Setenv("BESZEL_ZFS_COMMAND_HELPER", "1")
+ out, err := commandOutput(os.Args[0], "-test.run=TestZfsCommandHelperProcess", "--", "locale")
+ require.NoError(t, err)
+ assert.Equal(t, "C/C", string(out))
+
+ oldTimeout := commandTimeout
+ commandTimeout = 20 * time.Millisecond
+ t.Cleanup(func() { commandTimeout = oldTimeout })
+ _, err = commandOutput(os.Args[0], "-test.run=TestZfsCommandHelperProcess", "--", "sleep")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "timed out")
+}
+
+func TestZfsCommandHelperProcess(t *testing.T) {
+ if os.Getenv("BESZEL_ZFS_COMMAND_HELPER") != "1" {
+ return
+ }
+ mode := ""
+ for i, arg := range os.Args {
+ if arg == "--" && i+1 < len(os.Args) {
+ mode = os.Args[i+1]
+ break
+ }
+ }
+ switch strings.TrimSpace(mode) {
+ case "locale":
+ _, _ = fmt.Printf("%s/%s", os.Getenv("LC_ALL"), os.Getenv("LANG"))
+ case "sleep":
+ time.Sleep(time.Second)
+ }
+ os.Exit(0)
+}
diff --git a/agent/zfs/zfs_unsupported.go b/agent/zfs/zfs_unsupported.go
index 95aa8deb..0e9d3abc 100644
--- a/agent/zfs/zfs_unsupported.go
+++ b/agent/zfs/zfs_unsupported.go
@@ -7,3 +7,7 @@ import "errors"
func ARCSize() (uint64, error) {
return 0, errors.ErrUnsupported
}
+
+func PoolKernelStats() ([]PoolKernelStat, error) {
+ return nil, errors.ErrUnsupported
+}
diff --git a/agent/zfs_pool.go b/agent/zfs_pool.go
new file mode 100644
index 00000000..12988711
--- /dev/null
+++ b/agent/zfs_pool.go
@@ -0,0 +1,320 @@
+package agent
+
+import (
+ "log/slog"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/henrygd/beszel/agent/zfs"
+ "github.com/henrygd/beszel/internal/entities/system"
+ zfsentity "github.com/henrygd/beszel/internal/entities/zfs"
+)
+
+// zfsDatasetUsage holds usage values for a ZFS dataset mountpoint.
+type zfsDatasetUsage struct {
+ used uint64
+ avail uint64
+}
+
+// datasetUsageRefreshInterval controls how often `zfs list` is re-run for the
+// mountpoint usage map. Dataset inventory changes rarely.
+const datasetUsageRefreshInterval = 5 * time.Minute
+
+// poolStatsRefreshInterval controls how often `zpool list` is re-run for pool
+// capacity. Health and I/O are read from procfs on Linux, so the utility only
+// needs to refresh slow-moving space accounting.
+const poolStatsRefreshInterval = time.Minute
+
+type poolKernelSample struct {
+ nread uint64
+ nwrite uint64
+ at time.Time
+}
+
+// ZfsManager collects ZFS pool and dataset statistics. Collection functions
+// are fields so unit tests can substitute them (same pattern as
+// diskDiscovery.usageFn). It is safe for concurrent use by a single goroutine
+// only; callers must hold the agent lock like updateDiskUsage does.
+type ZfsManager struct {
+ poolStatsFn func() ([]zfs.PoolStat, error) // capacity/health source
+ datasetsFn func() ([]zfs.Dataset, error) // dataset inventory source
+ kernelStatsFn func() ([]zfs.PoolKernelStat, error) // procfs pool state/I/O source
+ poolStatusesFn func() ([]zfs.PoolStatus, error) // scrub/vdev detail source
+
+ poolData []zfs.PoolStat // cached pool inventory (TTL below)
+ lastPoolStats time.Time
+ kernelSamples map[string]poolKernelSample
+
+ datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
+ lastUsageRefresh time.Time
+
+ // Detail data (pools, vdevs, scrub, datasets) is cached and refreshed on
+ // an interval. Accessed from handler goroutines, so it is mutex-protected.
+ detailMu sync.Mutex
+ detail *zfsentity.ZfsData
+ lastDetailRefresh time.Time
+ detailInterval time.Duration
+}
+
+// newZfsManager creates a ZfsManager wired to the system's ZFS utilities.
+func newZfsManager() *ZfsManager {
+ return &ZfsManager{
+ poolStatsFn: zfs.PoolStats,
+ datasetsFn: zfs.Datasets,
+ kernelStatsFn: zfs.PoolKernelStats,
+ poolStatusesFn: zfs.PoolStatuses,
+ detailInterval: time.Hour,
+ }
+}
+
+// Update refreshes systemStats.ZfsPools with the latest pool data. I/O
+// throughput and health come from inexpensive kernel kstats on Linux. Pool
+// capacity and dataset usage come from separately cached utility calls. It is
+// a no-op when ZFS is absent.
+func (zm *ZfsManager) Update(systemStats *system.Stats) {
+ pools := zm.poolStats()
+ if len(pools) == 0 {
+ return
+ }
+
+ kernelStats, ioRates := zm.kernelStats()
+
+ if systemStats.ZfsPools == nil {
+ systemStats.ZfsPools = make(map[string]*system.ZfsPool, len(pools))
+ }
+ for i := range pools {
+ pool := &pools[i]
+ // Full precision, matching the dataset values below; the frontend
+ // formats any magnitude.
+ stats := &system.ZfsPool{
+ Total: float64(pool.Size) / (1024 * 1024 * 1024),
+ Used: float64(pool.Alloc) / (1024 * 1024 * 1024),
+ Health: pool.Health,
+ }
+ if kernel, exists := kernelStats[pool.Name]; exists && kernel.Health != "" {
+ stats.Health = kernel.Health
+ }
+ if io, exists := ioRates[pool.Name]; exists {
+ stats.ReadBytes = io.NRead
+ stats.WriteBytes = io.NWrite
+ }
+ slog.Debug("ZFS pool sample", "pool", pool.Name, "health", stats.Health, "used_gb", stats.Used, "read_bps", stats.ReadBytes, "write_bps", stats.WriteBytes)
+ systemStats.ZfsPools[pool.Name] = stats
+ }
+
+}
+
+// poolStats returns the cached pool inventory, re-running `zpool list` at most
+// every poolStatsRefreshInterval. On failure the previous inventory is
+// retained and the refresh is retried on the next cadence.
+func (zm *ZfsManager) poolStats() []zfs.PoolStat {
+ if zm.lastPoolStats.IsZero() || time.Since(zm.lastPoolStats) >= poolStatsRefreshInterval {
+ pools, err := zm.poolStatsFn()
+ if err != nil {
+ slog.Debug("ZFS pool stats unavailable", "err", err)
+ } else {
+ zm.poolData = pools
+ }
+ zm.lastPoolStats = time.Now()
+ }
+ return zm.poolData
+}
+
+// kernelStats reads cumulative pool counters and converts them to per-second
+// rates. Counter decreases indicate a pool export/import and reset the
+// baseline instead of producing an underflow spike.
+func (zm *ZfsManager) kernelStats() (map[string]zfs.PoolKernelStat, map[string]zfs.PoolIoStats) {
+ if zm.kernelStatsFn == nil {
+ return nil, nil
+ }
+ stats, err := zm.kernelStatsFn()
+ if err != nil {
+ slog.Debug("ZFS kernel stats unavailable", "err", err)
+ return nil, nil
+ }
+ now := time.Now()
+ byName := make(map[string]zfs.PoolKernelStat, len(stats))
+ rates := make(map[string]zfs.PoolIoStats, len(stats))
+ nextSamples := make(map[string]poolKernelSample, len(stats))
+ for _, stat := range stats {
+ byName[stat.Name] = stat
+ if previous, ok := zm.kernelSamples[stat.Name]; ok && now.After(previous.at) &&
+ stat.NRead >= previous.nread && stat.NWrite >= previous.nwrite {
+ seconds := now.Sub(previous.at).Seconds()
+ rates[stat.Name] = zfs.PoolIoStats{
+ NRead: uint64(float64(stat.NRead-previous.nread) / seconds),
+ NWrite: uint64(float64(stat.NWrite-previous.nwrite) / seconds),
+ }
+ }
+ nextSamples[stat.Name] = poolKernelSample{nread: stat.NRead, nwrite: stat.NWrite, at: now}
+ }
+ zm.kernelSamples = nextSamples
+ return byName, rates
+}
+
+// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
+// and rebuilds the mountpoint-keyed usage map.
+func (zm *ZfsManager) refreshDatasetUsage() {
+ if !zm.lastUsageRefresh.IsZero() && time.Since(zm.lastUsageRefresh) < datasetUsageRefreshInterval {
+ return
+ }
+ datasets, err := zm.datasetsFn()
+ if err != nil {
+ slog.Debug("ZFS dataset usage unavailable", "err", err)
+ } else {
+ usage := make(map[string]zfsDatasetUsage, len(datasets))
+ for _, ds := range datasets {
+ if ds.Mountpoint != "" && ds.Mountpoint != "-" {
+ usage[ds.Mountpoint] = zfsDatasetUsage{used: ds.Used, avail: ds.Avail}
+ }
+ }
+ zm.datasetUsage = usage
+ }
+ zm.lastUsageRefresh = time.Now()
+}
+
+// DatasetUsage returns ZFS dataset usage keyed by mountpoint, refreshed at
+// most every datasetUsageRefreshInterval. On failure the previous map is
+// retained and a debug log is emitted.
+func (zm *ZfsManager) DatasetUsage() map[string]zfsDatasetUsage {
+ zm.refreshDatasetUsage()
+ return zm.datasetUsage
+}
+
+// GetDetail returns ZFS detail data (pool health, scrub, vdevs, datasets).
+// Scheduled requests use the cached snapshot until stale; manual requests can
+// force collection. On failure the previous snapshot is retained.
+func (zm *ZfsManager) GetDetail(force bool) *zfsentity.ZfsData {
+ zm.detailMu.Lock()
+ defer zm.detailMu.Unlock()
+
+ if force || zm.detail == nil || time.Since(zm.lastDetailRefresh) >= zm.detailInterval {
+ if data, err := zm.collectDetail(zm.detail); err != nil {
+ slog.Debug("ZFS detail collection failed", "err", err)
+ if zm.detail == nil {
+ return &zfsentity.ZfsData{}
+ }
+ return &zfsentity.ZfsData{Pools: zm.detail.Pools}
+ } else {
+ zm.detail = data
+ zm.lastDetailRefresh = time.Now()
+ }
+ }
+ if zm.detail == nil {
+ return &zfsentity.ZfsData{}
+ }
+ return zm.detail
+}
+
+// collectDetail builds a ZfsData payload from the current system state.
+func (zm *ZfsManager) collectDetail(previous *zfsentity.ZfsData) (*zfsentity.ZfsData, error) {
+ pools, err := zm.poolStatsFn()
+ if err != nil {
+ return nil, err
+ }
+ if len(pools) == 0 {
+ return &zfsentity.ZfsData{Pools: []*zfsentity.PoolDetail{}, Complete: true}, nil
+ }
+
+ statuses, statusErr := zm.poolStatusesFn()
+ if statusErr != nil {
+ slog.Debug("ZFS pool status unavailable", "err", statusErr)
+ }
+ datasets, datasetsErr := zm.datasetsFn()
+ if datasetsErr != nil {
+ slog.Debug("ZFS datasets unavailable", "err", datasetsErr)
+ }
+
+ statusByPool := make(map[string]zfs.PoolStatus, len(statuses))
+ for _, st := range statuses {
+ statusByPool[st.Name] = st
+ }
+
+ previousByPool := make(map[string]*zfsentity.PoolDetail)
+ if previous != nil {
+ for _, pool := range previous.Pools {
+ if pool != nil {
+ previousByPool[pool.Name] = pool
+ }
+ }
+ }
+
+ data := &zfsentity.ZfsData{Pools: make([]*zfsentity.PoolDetail, 0, len(pools)), Complete: true}
+ for i := range pools {
+ p := &pools[i]
+ detail := &zfsentity.PoolDetail{
+ Name: p.Name,
+ Health: p.Health,
+ Size: p.Size,
+ Alloc: p.Alloc,
+ Free: p.Free,
+ }
+ if st, ok := statusByPool[p.Name]; statusErr == nil && ok {
+ if st.Scrub.State != "" && st.Scrub.State != "NONE" {
+ detail.Scrub = &zfsentity.Scrub{
+ State: st.Scrub.State,
+ Progress: st.Scrub.Progress,
+ Errors: st.Scrub.Errors,
+ }
+ }
+ for _, v := range st.Vdevs {
+ detail.Vdevs = append(detail.Vdevs, &zfsentity.Vdev{
+ Name: v.Name,
+ State: v.State,
+ ReadErrs: v.ReadErrs,
+ WriteErrs: v.WriteErrs,
+ ChecksumErrs: v.ChecksumErrs,
+ })
+ }
+ } else {
+ if cached := previousByPool[p.Name]; cached != nil {
+ detail.Scrub = cached.Scrub
+ detail.Vdevs = cached.Vdevs
+ }
+ }
+ if datasetsErr == nil {
+ foundDataset := false
+ for _, ds := range datasets {
+ if poolOfDataset(ds.Name) == p.Name {
+ foundDataset = true
+ detail.Datasets = append(detail.Datasets, &zfsentity.Dataset{
+ Name: ds.Name,
+ Used: ds.Used,
+ Avail: ds.Avail,
+ Mountpoint: ds.Mountpoint,
+ })
+ }
+ }
+ if !foundDataset {
+ if cached := previousByPool[p.Name]; cached != nil {
+ detail.Datasets = cached.Datasets
+ }
+ }
+ } else if cached := previousByPool[p.Name]; cached != nil {
+ detail.Datasets = cached.Datasets
+ }
+ data.Pools = append(data.Pools, detail)
+ }
+ return data, nil
+}
+
+// poolOfDataset returns the pool name for a dataset name (everything before
+// the first '/'). Datasets without a separator belong to a pool of the same
+// name.
+func poolOfDataset(name string) string {
+ if idx := strings.IndexByte(name, '/'); idx >= 0 {
+ return name[:idx]
+ }
+ return name
+}
+
+// ZfsMountpoints returns the set of mountpoints backed by ZFS datasets.
+func (zm *ZfsManager) ZfsMountpoints() map[string]bool {
+ usage := zm.DatasetUsage()
+ mountpoints := make(map[string]bool, len(usage))
+ for mountpoint := range usage {
+ mountpoints[mountpoint] = true
+ }
+ return mountpoints
+}
diff --git a/agent/zfs_pool_test.go b/agent/zfs_pool_test.go
new file mode 100644
index 00000000..7101ae89
--- /dev/null
+++ b/agent/zfs_pool_test.go
@@ -0,0 +1,245 @@
+//go:build testing
+
+package agent
+
+import (
+ "testing"
+ "time"
+
+ "github.com/henrygd/beszel/agent/zfs"
+ "github.com/henrygd/beszel/internal/entities/system"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUpdatePopulatesZfsPools(t *testing.T) {
+ zm := &ZfsManager{}
+ zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
+ return []zfs.PoolStat{{Name: "tank", Size: 23999000000000, Alloc: 12000000000000, Free: 11999000000000, Health: "DEGRADED"}}, nil
+ }
+ zm.datasetsFn = func() ([]zfs.Dataset, error) {
+ return []zfs.Dataset{
+ {Name: "tank/apps", Used: 5000000000000, Avail: 11999000000000, Mountpoint: "/tank/apps"},
+ {Name: "tank/backup", Used: 6000000000000, Avail: 11999000000000, Mountpoint: "/tank/backup"},
+ // Small zvol (Proxmox VM EFI disk): must not round to zero.
+ {Name: "rpool/vm-100-disk-2", Used: 4194304, Avail: 0, Mountpoint: "-"},
+ }, nil
+ }
+ var kernelCalls int
+ zm.kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
+ kernelCalls++
+ return []zfs.PoolKernelStat{{
+ Name: "tank", Health: "ONLINE",
+ NRead: uint64(kernelCalls-1) * 1250, NWrite: uint64(kernelCalls-1) * 5120,
+ }}, nil
+ }
+
+ var stats system.Stats
+ // The first kernel sample establishes the cumulative-counter baseline.
+ zm.Update(&stats)
+ zm.kernelSamples["tank"] = poolKernelSample{at: time.Now().Add(-time.Second)}
+ zm.Update(&stats)
+ require.NotNil(t, stats.ZfsPools)
+ require.Contains(t, stats.ZfsPools, "tank")
+ assert.InDelta(t, 22350.8105, stats.ZfsPools["tank"].Total, 0.0001) // Size in GiB
+ assert.InDelta(t, 11175.8709, stats.ZfsPools["tank"].Used, 0.0001) // Alloc in GiB
+ assert.Equal(t, "ONLINE", stats.ZfsPools["tank"].Health)
+ assert.InDelta(t, 1250, stats.ZfsPools["tank"].ReadBytes, 5)
+ assert.InDelta(t, 5120, stats.ZfsPools["tank"].WriteBytes, 5)
+
+}
+
+// TestUpdateKernelStatsMissing verifies pools without a kernel sample report zero
+// I/O instead of erroring.
+func TestUpdateKernelStatsMissing(t *testing.T) {
+ zm := &ZfsManager{}
+ zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
+ return []zfs.PoolStat{{Name: "tank", Size: 1, Alloc: 1, Health: "ONLINE"}}, nil
+ }
+ zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
+ zm.kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
+ return nil, zfs.ErrNoZfs
+ }
+
+ var stats system.Stats
+ zm.Update(&stats)
+ require.NotNil(t, stats.ZfsPools)
+ assert.Equal(t, uint64(0), stats.ZfsPools["tank"].ReadBytes)
+ assert.Equal(t, uint64(0), stats.ZfsPools["tank"].WriteBytes)
+}
+
+func TestUpdateKernelCounterReset(t *testing.T) {
+ zm := &ZfsManager{}
+ zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
+ return []zfs.PoolStat{{Name: "tank", Health: "ONLINE"}}, nil
+ }
+ zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
+ zm.kernelSamples = map[string]poolKernelSample{
+ "tank": {nread: 100, nwrite: 200, at: time.Now().Add(-time.Second)},
+ }
+ zm.kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
+ return []zfs.PoolKernelStat{{Name: "tank", Health: "ONLINE", NRead: 10, NWrite: 20}}, nil
+ }
+
+ var stats system.Stats
+ zm.Update(&stats)
+ assert.Equal(t, uint64(0), stats.ZfsPools["tank"].ReadBytes)
+ assert.Equal(t, uint64(0), stats.ZfsPools["tank"].WriteBytes)
+}
+
+func TestUpdateNoZfs(t *testing.T) {
+ zm := &ZfsManager{}
+ calls := 0
+ zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
+ calls++
+ return nil, zfs.ErrNoZfs
+ }
+
+ var stats system.Stats
+ zm.Update(&stats)
+ zm.Update(&stats)
+ assert.Nil(t, stats.ZfsPools)
+ assert.Equal(t, 1, calls, "failed pool discovery should be cached until the next refresh interval")
+}
+
+func TestUpdateEmptyPools(t *testing.T) {
+ zm := &ZfsManager{}
+ calls := 0
+ zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
+ calls++
+ return nil, nil
+ }
+
+ var stats system.Stats
+ zm.Update(&stats)
+ zm.Update(&stats)
+ assert.Nil(t, stats.ZfsPools)
+ assert.Equal(t, 1, calls, "an empty pool inventory should be cached until the next refresh interval")
+}
+
+func TestDatasetUsage(t *testing.T) {
+ zm := &ZfsManager{}
+ calls := 0
+ zm.datasetsFn = func() ([]zfs.Dataset, error) {
+ calls++
+ return []zfs.Dataset{
+ {Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
+ {Name: "tank/apps", Used: 1000000000000, Avail: 11999000000000, Mountpoint: "/tank/apps"},
+ {Name: "rpool", Used: 900000000000, Avail: 300000000000, Mountpoint: "-"}, // zvol/unmounted: excluded
+ }, nil
+ }
+
+ usage := zm.DatasetUsage()
+ require.Len(t, usage, 2)
+ assert.Equal(t, zfsDatasetUsage{used: 12000000000000, avail: 11999000000000}, usage["/tank"])
+ assert.Equal(t, zfsDatasetUsage{used: 1000000000000, avail: 11999000000000}, usage["/tank/apps"])
+ assert.Equal(t, 1, calls)
+
+ // Second call within the refresh window must not re-run the collector.
+ zm.DatasetUsage()
+ assert.Equal(t, 1, calls)
+}
+
+func TestDatasetUsageRefreshOnErrorKeepsPrevious(t *testing.T) {
+ zm := &ZfsManager{}
+ zm.datasetsFn = func() ([]zfs.Dataset, error) {
+ return []zfs.Dataset{{Name: "tank", Used: 1, Avail: 1, Mountpoint: "/tank"}}, nil
+ }
+ assert.Len(t, zm.DatasetUsage(), 1)
+
+ // Force refresh window expiry, then a failing collector.
+ zm.lastUsageRefresh = time.Now().Add(-10 * time.Minute)
+ zm.datasetsFn = func() ([]zfs.Dataset, error) {
+ return nil, zfs.ErrNoZfs
+ }
+ usage := zm.DatasetUsage()
+ assert.Len(t, usage, 1, "previous usage should be retained on error")
+}
+
+func TestGetDetailForceRefresh(t *testing.T) {
+ zm := &ZfsManager{detailInterval: time.Hour}
+ poolCalls := 0
+ zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
+ poolCalls++
+ return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
+ }
+ zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
+ zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
+
+ first := zm.GetDetail(false)
+ assert.True(t, first.Complete)
+ require.Len(t, first.Pools, 1)
+ assert.Equal(t, uint64(1), first.Pools[0].Alloc)
+
+ cached := zm.GetDetail(false)
+ require.Len(t, cached.Pools, 1)
+ assert.Equal(t, uint64(1), cached.Pools[0].Alloc)
+ assert.Equal(t, 1, poolCalls)
+
+ refreshed := zm.GetDetail(true)
+ assert.True(t, refreshed.Complete)
+ require.Len(t, refreshed.Pools, 1)
+ assert.Equal(t, uint64(2), refreshed.Pools[0].Alloc)
+ assert.Equal(t, 2, poolCalls)
+}
+
+func TestGetDetailSuccessfulEmptyInventoryClearsCache(t *testing.T) {
+ zm := &ZfsManager{detailInterval: time.Hour}
+ zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
+ return []zfs.PoolStat{{Name: "tank"}}, nil
+ }
+ zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
+ zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
+
+ require.Len(t, zm.GetDetail(false).Pools, 1)
+ zm.poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, nil }
+ empty := zm.GetDetail(true)
+ assert.True(t, empty.Complete)
+ assert.Empty(t, empty.Pools)
+}
+
+func TestGetDetailFailureReturnsIncompleteCachedInventory(t *testing.T) {
+ zm := &ZfsManager{detailInterval: time.Hour}
+ zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
+ return []zfs.PoolStat{{Name: "tank"}}, nil
+ }
+ zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) {
+ return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "mirror-0"}}}}, nil
+ }
+ zm.datasetsFn = func() ([]zfs.Dataset, error) {
+ return []zfs.Dataset{{Name: "tank/data"}}, nil
+ }
+ first := zm.GetDetail(false)
+ require.True(t, first.Complete)
+ require.Len(t, first.Pools[0].Vdevs, 1)
+ require.Len(t, first.Pools[0].Datasets, 1)
+
+ zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, zfs.ErrNoZfs }
+ zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, zfs.ErrNoZfs }
+ partial := zm.GetDetail(true)
+ require.True(t, partial.Complete)
+ require.Len(t, partial.Pools[0].Vdevs, 1)
+ require.Len(t, partial.Pools[0].Datasets, 1)
+
+ zm.poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, zfs.ErrNoZfs }
+ lastSuccessfulRefresh := zm.lastDetailRefresh
+ failed := zm.GetDetail(true)
+ assert.False(t, failed.Complete)
+ require.Len(t, failed.Pools, 1)
+ assert.Equal(t, "tank", failed.Pools[0].Name)
+ assert.Equal(t, lastSuccessfulRefresh, zm.lastDetailRefresh)
+}
+
+func TestZfsMountpoints(t *testing.T) {
+ zm := &ZfsManager{}
+ zm.datasetsFn = func() ([]zfs.Dataset, error) {
+ return []zfs.Dataset{
+ {Name: "tank", Mountpoint: "/tank"},
+ {Name: "rpool/ROOT/pve-1", Mountpoint: "/"},
+ }, nil
+ }
+ mountpoints := zm.ZfsMountpoints()
+ assert.Len(t, mountpoints, 2)
+ assert.True(t, mountpoints["/tank"])
+ assert.True(t, mountpoints["/"])
+}
diff --git a/beszel.go b/beszel.go
index a288c0f6..82e87e81 100644
--- a/beszel.go
+++ b/beszel.go
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
const (
// Version is the current version of the application.
- Version = "0.18.8"
+ Version = "0.18.9"
// AppName is the name of the application.
AppName = "beszel"
)
@@ -16,3 +16,6 @@ var MinVersionCbor = semver.MustParse("0.12.0")
// MinVersionAgentResponse is the minimum supported version for AgentResponse compatibility.
var MinVersionAgentResponse = semver.MustParse("0.13.0")
+
+// MinVersionZfsData is the minimum agent version that supports ZFS detail requests.
+var MinVersionZfsData = semver.MustParse("0.18.9")
diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go
index 826f4d68..6dc57626 100644
--- a/internal/alerts/alerts.go
+++ b/internal/alerts/alerts.go
@@ -57,12 +57,18 @@ type SystemAlertStats struct {
Battery [2]uint8 `json:"bat"`
Batteries map[string]uint8 `json:"bats"`
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
+ ZfsPools map[string]SystemAlertZfsPool `json:"z"`
}
type SystemAlertGPUData struct {
Usage float64 `json:"u"`
}
+type SystemAlertZfsPool struct {
+ Total float64 `json:"d"`
+ Used float64 `json:"du"`
+}
+
type SystemAlertData struct {
systemRecord *core.Record
alertData CachedAlertData
@@ -111,6 +117,9 @@ func (am *AlertManager) bindEvents() {
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
+ am.hub.OnRecordAfterCreateSuccess("zfs_pools").BindFunc(am.handleZfsPoolCreateAlert)
+ am.hub.OnRecordAfterUpdateSuccess("zfs_pools").BindFunc(am.handleZfsPoolAlert)
+ am.hub.OnRecordAfterDeleteSuccess("zfs_pools").BindFunc(resolveZfsPoolHistoryOnDelete)
am.hub.OnServe().BindFunc(func(e *core.ServeEvent) error {
// Populate all alerts into cache on startup
diff --git a/internal/alerts/alerts_system.go b/internal/alerts/alerts_system.go
index 04001d98..690a6397 100644
--- a/internal/alerts/alerts_system.go
+++ b/internal/alerts/alerts_system.go
@@ -44,6 +44,14 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
maxUsedPct = usedPct
}
}
+ for _, pool := range data.Stats.ZfsPools {
+ if pool != nil && pool.Total > 0 {
+ usedPct := pool.Used / pool.Total * 100
+ if usedPct > maxUsedPct {
+ maxUsedPct = usedPct
+ }
+ }
+ }
val = maxUsedPct
case "Temperature":
if data.Info.DashboardTemp < 1 {
@@ -208,6 +216,16 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
}
}
+ // add zfs pool usage from historical record
+ for key, pool := range stats.ZfsPools {
+ if pool.Total > 0 {
+ zfsKey := zfsDiskAlertKey(key)
+ if _, ok := alert.mapSums[zfsKey]; !ok {
+ alert.mapSums[zfsKey] = 0.0
+ }
+ alert.mapSums[zfsKey] += float32(pool.Used / pool.Total * 100)
+ }
+ }
case "Temperature":
if alert.mapSums == nil {
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
@@ -255,7 +273,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
sumPct := float32(value)
if sumPct > maxPct {
maxPct = sumPct
- alert.descriptor = fmt.Sprintf("Usage of %s", key)
+ alert.descriptor = diskAlertDescriptor(key)
}
}
alert.val = float64(maxPct / float32(alert.count))
@@ -301,6 +319,17 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
return nil
}
+func zfsDiskAlertKey(poolName string) string {
+ return "zfs:" + poolName
+}
+
+func diskAlertDescriptor(key string) string {
+ if poolName, ok := strings.CutPrefix(key, "zfs:"); ok {
+ return fmt.Sprintf("Usage of ZFS pool %s", poolName)
+ }
+ return fmt.Sprintf("Usage of %s", key)
+}
+
func hasRepresentativeBattery(legacy [2]uint8, batteries map[string]uint8) bool {
return legacy != [2]uint8{} || len(batteries) > 0
}
diff --git a/internal/alerts/alerts_zfs.go b/internal/alerts/alerts_zfs.go
new file mode 100644
index 00000000..f3591f46
--- /dev/null
+++ b/internal/alerts/alerts_zfs.go
@@ -0,0 +1,142 @@
+package alerts
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+)
+
+// handleZfsPoolAlert sends alerts when a ZFS pool health state worsens and
+// resolves the alert history entry when the pool recovers. Like the SMART
+// hook, this is automatic and does not require user opt-in.
+func (am *AlertManager) handleZfsPoolAlert(e *core.RecordEvent) error {
+ return am.handleZfsPoolHealthAlert(e, e.Record.Original().GetString("health"))
+}
+
+func (am *AlertManager) handleZfsPoolCreateAlert(e *core.RecordEvent) error {
+ return am.handleZfsPoolHealthAlert(e, "")
+}
+
+func (am *AlertManager) handleZfsPoolHealthAlert(e *core.RecordEvent, oldHealth string) error {
+ newHealth := e.Record.GetString("health")
+ oldSeverity := zfsPoolSeverity(oldHealth)
+ newSeverity := zfsPoolSeverity(newHealth)
+
+ systemID := e.Record.GetString("system")
+ if systemID == "" {
+ return e.Next()
+ }
+
+ systemRecord, err := e.App.FindRecordById("systems", systemID)
+ if err != nil {
+ e.App.Logger().Error("Failed to find system for ZFS alert", "err", err, "systemID", systemID)
+ return e.Next()
+ }
+
+ // Pool recovered to a healthy state: resolve any open history entries.
+ if newSeverity == 1 && oldSeverity > 1 {
+ resolveAllAlertHistoryRecords(e.App, e.Record.Id)
+ return e.Next()
+ }
+
+ if !shouldSendZfsPoolAlert(oldSeverity, newSeverity) {
+ return e.Next()
+ }
+
+ systemName := systemRecord.GetString("name")
+ poolName := e.Record.GetString("name")
+
+ title := fmt.Sprintf("ZFS pool %s on %s: %s", newHealth, systemName, poolName)
+ message := fmt.Sprintf("ZFS pool %s (%s) was first observed as %s", poolName, systemName, newHealth)
+ if oldSeverity > 0 {
+ message = fmt.Sprintf("ZFS pool %s (%s) health changed from %s to %s", poolName, systemName, oldHealth, newHealth)
+ }
+
+ userIDs := systemRecord.GetStringSlice("users")
+ if len(userIDs) == 0 {
+ return e.Next()
+ }
+
+ for _, userID := range userIDs {
+ if err := am.SendAlert(AlertMessageData{
+ UserID: userID,
+ SystemID: systemID,
+ Title: title,
+ Message: message,
+ Link: am.hub.MakeLink("system", systemID),
+ LinkText: "View " + systemName,
+ }); err != nil {
+ e.App.Logger().Error("Failed to send ZFS alert", "err", err, "userID", userID)
+ }
+ _ = createZfsPoolHistoryRecord(e.App, userID, systemID, e.Record.Id, poolName)
+ }
+
+ return e.Next()
+}
+
+// resolveZfsPoolHistoryOnDelete resolves open alert history entries when a
+// pool record is deleted (manually or because the pool disappeared), so the
+// UI does not keep showing an ongoing alert for a pool that no longer exists.
+func resolveZfsPoolHistoryOnDelete(e *core.RecordEvent) error {
+ resolveAllAlertHistoryRecords(e.App, e.Record.Id)
+ return e.Next()
+}
+
+// shouldSendZfsPoolAlert reports whether a health transition warrants an alert.
+// First observations of unhealthy pools and worsening transitions are reported.
+func shouldSendZfsPoolAlert(oldSeverity, newSeverity int) bool {
+ return newSeverity > 1 && (oldSeverity == 0 || newSeverity > oldSeverity)
+}
+
+// zfsPoolSeverity ranks pool health states: healthy (1), degraded (2),
+// failed/unavailable (3), unknown (0).
+func zfsPoolSeverity(health string) int {
+ switch health {
+ case "ONLINE":
+ return 1
+ case "DEGRADED":
+ return 2
+ case "FAULTED", "OFFLINE", "UNAVAIL", "REMOVED", "SUSPENDED":
+ return 3
+ default:
+ return 0
+ }
+}
+
+// createZfsPoolHistoryRecord logs a pool health alert in the alerts history so
+// it is visible in the UI without creating an editable alert configuration.
+func createZfsPoolHistoryRecord(app core.App, userID, systemID, alertID, poolName string) error {
+ collection, err := app.FindCachedCollectionByNameOrId("alerts_history")
+ if err != nil {
+ return err
+ }
+ record := core.NewRecord(collection)
+ record.Set("user", userID)
+ record.Set("system", systemID)
+ record.Set("alert_id", alertID)
+ record.Set("name", "ZFS Pool: "+poolName)
+ return app.Save(record)
+}
+
+// resolveAllAlertHistoryRecords resolves every open history entry for an alert
+// record id (one per system user).
+func resolveAllAlertHistoryRecords(app core.App, alertID string) {
+ records, err := app.FindRecordsByFilter(
+ "alerts_history",
+ "alert_id={:alert_id} && resolved=null",
+ "", 0, 0,
+ dbx.Params{"alert_id": alertID},
+ )
+ if err != nil || len(records) == 0 {
+ return
+ }
+ now := time.Now().UTC()
+ for _, record := range records {
+ record.Set("resolved", now)
+ if err := app.Save(record); err != nil {
+ app.Logger().Error("Failed to resolve ZFS alert history", "err", err, "recordId", record.Id)
+ }
+ }
+}
diff --git a/internal/alerts/alerts_zfs_disk_test.go b/internal/alerts/alerts_zfs_disk_test.go
new file mode 100644
index 00000000..bd20c915
--- /dev/null
+++ b/internal/alerts/alerts_zfs_disk_test.go
@@ -0,0 +1,145 @@
+//go:build testing
+
+package alerts_test
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/henrygd/beszel/internal/entities/system"
+ beszelTests "github.com/henrygd/beszel/internal/tests"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/types"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestDiskAlertZfsPoolMultiMinute verifies that ZFS pool usage participates in
+// the Disk threshold alert using historical per-minute values, mirroring the
+// extra-filesystem behavior.
+func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
+ hub, user := beszelTests.GetHubWithUser(t)
+ defer hub.Cleanup()
+
+ systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
+ require.NoError(t, err)
+ systemRecord := systems[0]
+
+ diskAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
+ "name": "Disk",
+ "system": systemRecord.Id,
+ "user": user.Id,
+ "value": 80, // threshold: 80%
+ "min": 2, // requires historical averaging
+ })
+ require.NoError(t, err)
+
+ am := hub.GetAlertManager()
+ now := time.Now().UTC()
+
+ poolHigh := map[string]*system.ZfsPool{
+ "tank": {Total: 1000, Used: 920}, // 92% - above threshold
+ }
+
+ recordTimes := []time.Duration{
+ -180 * time.Second,
+ -90 * time.Second,
+ -60 * time.Second,
+ -30 * time.Second,
+ }
+
+ for _, offset := range recordTimes {
+ stats := system.Stats{
+ DiskPct: 30, // root disk at 30% - below threshold
+ ZfsPools: poolHigh,
+ }
+ statsJSON, _ := json.Marshal(stats)
+
+ recordTime := now.Add(offset)
+ record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
+ "system": systemRecord.Id,
+ "type": "1m",
+ "stats": string(statsJSON),
+ })
+ require.NoError(t, err)
+ record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
+ err = hub.SaveNoValidate(record)
+ require.NoError(t, err)
+ }
+
+ combinedDataHigh := &system.CombinedData{
+ Stats: system.Stats{
+ DiskPct: 30,
+ ZfsPools: poolHigh,
+ },
+ Info: system.Info{
+ DiskPct: 30,
+ },
+ }
+
+ systemRecord.Set("updated", now)
+ err = hub.SaveNoValidate(systemRecord)
+ require.NoError(t, err)
+
+ err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
+ require.NoError(t, err)
+
+ time.Sleep(20 * time.Millisecond)
+
+ diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
+ require.NoError(t, err)
+ assert.True(t, diskAlert.GetBool("triggered"),
+ "Alert should be triggered when ZFS pool average (92%%) exceeds threshold (80%%)")
+
+ // --- Resolution: pool drops to 50%, alert should resolve ---
+
+ poolLow := map[string]*system.ZfsPool{
+ "tank": {Total: 1000, Used: 500}, // 50% - below threshold
+ }
+
+ newNow := now.Add(2 * time.Minute)
+ for _, offset := range recordTimes {
+ stats := system.Stats{
+ DiskPct: 30,
+ ZfsPools: poolLow,
+ }
+ statsJSON, _ := json.Marshal(stats)
+
+ recordTime := newNow.Add(offset)
+ record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
+ "system": systemRecord.Id,
+ "type": "1m",
+ "stats": string(statsJSON),
+ })
+ require.NoError(t, err)
+ record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
+ err = hub.SaveNoValidate(record)
+ require.NoError(t, err)
+ }
+
+ combinedDataLow := &system.CombinedData{
+ Stats: system.Stats{
+ DiskPct: 30,
+ ZfsPools: poolLow,
+ },
+ Info: system.Info{
+ DiskPct: 30,
+ },
+ }
+
+ systemRecord.Set("updated", newNow)
+ err = hub.SaveNoValidate(systemRecord)
+ require.NoError(t, err)
+
+ err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
+ require.NoError(t, err)
+
+ time.Sleep(20 * time.Millisecond)
+
+ diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
+ require.NoError(t, err)
+ assert.False(t, diskAlert.GetBool("triggered"),
+ "Alert should be resolved when ZFS pool average (50%%) drops below threshold (80%%)")
+}
diff --git a/internal/alerts/alerts_zfs_key_test.go b/internal/alerts/alerts_zfs_key_test.go
new file mode 100644
index 00000000..52e83c5b
--- /dev/null
+++ b/internal/alerts/alerts_zfs_key_test.go
@@ -0,0 +1,15 @@
+//go:build testing
+
+package alerts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestZfsDiskAlertKeyIsNamespaced(t *testing.T) {
+ assert.Equal(t, "zfs:tank", zfsDiskAlertKey("tank"))
+ assert.Equal(t, "Usage of ZFS pool tank", diskAlertDescriptor(zfsDiskAlertKey("tank")))
+ assert.Equal(t, "Usage of tank", diskAlertDescriptor("tank"))
+}
diff --git a/internal/alerts/alerts_zfs_test.go b/internal/alerts/alerts_zfs_test.go
new file mode 100644
index 00000000..708653d9
--- /dev/null
+++ b/internal/alerts/alerts_zfs_test.go
@@ -0,0 +1,292 @@
+//go:build testing
+
+package alerts_test
+
+import (
+ "testing"
+ "time"
+
+ beszelTests "github.com/henrygd/beszel/internal/tests"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestZfsPoolAlertOnlineToDegraded(t *testing.T) {
+ hub, user := beszelTests.GetHubWithUser(t)
+ defer hub.Cleanup()
+
+ system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
+ "name": "test-system",
+ "users": []string{user.Id},
+ "host": "127.0.0.1",
+ })
+ assert.NoError(t, err)
+
+ pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
+ "system": system.Id,
+ "name": "tank",
+ "health": "ONLINE",
+ })
+ assert.NoError(t, err)
+
+ // Re-fetch so PocketBase tracks original values
+ pool, err = hub.FindRecordById("zfs_pools", pool.Id)
+ assert.NoError(t, err)
+
+ pool.Set("health", "DEGRADED")
+ err = hub.Save(pool)
+ assert.NoError(t, err)
+
+ time.Sleep(50 * time.Millisecond)
+
+ assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after pool became DEGRADED")
+ lastMessage := hub.TestMailer.LastMessage()
+ assert.Contains(t, lastMessage.Subject, "ZFS pool DEGRADED on test-system")
+ assert.Contains(t, lastMessage.Subject, "tank")
+ assert.Contains(t, lastMessage.Text, "ONLINE to DEGRADED")
+}
+
+func TestZfsPoolAlertDegradedToFaulted(t *testing.T) {
+ hub, user := beszelTests.GetHubWithUser(t)
+ defer hub.Cleanup()
+
+ system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
+ "name": "test-system",
+ "users": []string{user.Id},
+ "host": "127.0.0.1",
+ })
+ assert.NoError(t, err)
+
+ pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
+ "system": system.Id,
+ "name": "rpool",
+ "health": "DEGRADED",
+ })
+ assert.NoError(t, err)
+
+ pool, err = hub.FindRecordById("zfs_pools", pool.Id)
+ assert.NoError(t, err)
+
+ pool.Set("health", "FAULTED")
+ err = hub.Save(pool)
+ assert.NoError(t, err)
+
+ time.Sleep(50 * time.Millisecond)
+
+ assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should alert on initial DEGRADED state and later FAULTED transition")
+ lastMessage := hub.TestMailer.LastMessage()
+ assert.Contains(t, lastMessage.Subject, "ZFS pool FAULTED on test-system")
+}
+
+func TestZfsPoolAlertNoAlertOnRecovery(t *testing.T) {
+ hub, user := beszelTests.GetHubWithUser(t)
+ defer hub.Cleanup()
+
+ system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
+ "name": "test-system",
+ "users": []string{user.Id},
+ "host": "127.0.0.1",
+ })
+ assert.NoError(t, err)
+
+ pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
+ "system": system.Id,
+ "name": "tank",
+ "health": "DEGRADED",
+ })
+ assert.NoError(t, err)
+
+ // Trigger a worsening alert first
+ pool, err = hub.FindRecordById("zfs_pools", pool.Id)
+ assert.NoError(t, err)
+ pool.Set("health", "FAULTED")
+ err = hub.Save(pool)
+ assert.NoError(t, err)
+ time.Sleep(50 * time.Millisecond)
+ assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "expected alerts for initial DEGRADED state and DEGRADED -> FAULTED")
+
+ // Recovery back to ONLINE must not send a new alert
+ pool, err = hub.FindRecordById("zfs_pools", pool.Id)
+ assert.NoError(t, err)
+ pool.Set("health", "ONLINE")
+ err = hub.Save(pool)
+ assert.NoError(t, err)
+ time.Sleep(50 * time.Millisecond)
+ assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "recovery should not send a new alert")
+
+ // And the open history entry should have been resolved
+ history, err := hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id}", "", 0, 0, map[string]any{"alert_id": pool.Id})
+ assert.NoError(t, err)
+ requireHistoryResolved(t, history)
+}
+
+func TestZfsPoolAlertUnknownHealthDoesNotResolve(t *testing.T) {
+ hub, user := beszelTests.GetHubWithUser(t)
+ defer hub.Cleanup()
+
+ system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
+ "name": "test-system",
+ "users": []string{user.Id},
+ "host": "127.0.0.1",
+ })
+ require.NoError(t, err)
+
+ pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
+ "system": system.Id,
+ "name": "tank",
+ "health": "DEGRADED",
+ })
+ require.NoError(t, err)
+ time.Sleep(50 * time.Millisecond)
+
+ pool, err = hub.FindRecordById("zfs_pools", pool.Id)
+ require.NoError(t, err)
+ pool.Set("health", "")
+ require.NoError(t, hub.Save(pool))
+ time.Sleep(50 * time.Millisecond)
+
+ history, err := hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id} && resolved=null", "", 0, 0, map[string]any{"alert_id": pool.Id})
+ require.NoError(t, err)
+ require.Len(t, history, 1, "unknown health must not resolve an active alert")
+}
+
+func TestZfsPoolAlertUnknownToFaulted(t *testing.T) {
+ hub, user := beszelTests.GetHubWithUser(t)
+ defer hub.Cleanup()
+
+ system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
+ "name": "test-system",
+ "users": []string{user.Id},
+ "host": "127.0.0.1",
+ })
+ assert.NoError(t, err)
+
+ pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
+ "system": system.Id,
+ "name": "tank",
+ "health": "",
+ })
+ assert.NoError(t, err)
+
+ pool, err = hub.FindRecordById("zfs_pools", pool.Id)
+ assert.NoError(t, err)
+
+ pool.Set("health", "FAULTED")
+ err = hub.Save(pool)
+ assert.NoError(t, err)
+
+ time.Sleep(50 * time.Millisecond)
+
+ assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should alert when a previously unknown pool becomes FAULTED")
+}
+
+func TestZfsPoolAlertOnInitialUnhealthyState(t *testing.T) {
+ hub, user := beszelTests.GetHubWithUser(t)
+ defer hub.Cleanup()
+
+ system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
+ "name": "test-system",
+ "users": []string{user.Id},
+ "host": "127.0.0.1",
+ })
+ require.NoError(t, err)
+
+ pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
+ "system": system.Id,
+ "name": "tank",
+ "health": "DEGRADED",
+ })
+ require.NoError(t, err)
+ time.Sleep(50 * time.Millisecond)
+
+ require.EqualValues(t, 1, hub.TestMailer.TotalSend())
+ assert.Contains(t, hub.TestMailer.LastMessage().Text, "first observed as DEGRADED")
+
+ pool, err = hub.FindRecordById("zfs_pools", pool.Id)
+ require.NoError(t, err)
+ require.NoError(t, hub.Save(pool))
+ time.Sleep(50 * time.Millisecond)
+ assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "unchanged unhealthy health must not duplicate alerts")
+}
+
+func TestZfsPoolAlertWritesHistory(t *testing.T) {
+ hub, user := beszelTests.GetHubWithUser(t)
+ defer hub.Cleanup()
+
+ system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
+ "name": "test-system",
+ "users": []string{user.Id},
+ "host": "127.0.0.1",
+ })
+ assert.NoError(t, err)
+
+ pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
+ "system": system.Id,
+ "name": "tank",
+ "health": "ONLINE",
+ })
+ assert.NoError(t, err)
+
+ pool, err = hub.FindRecordById("zfs_pools", pool.Id)
+ assert.NoError(t, err)
+ pool.Set("health", "FAULTED")
+ err = hub.Save(pool)
+ assert.NoError(t, err)
+
+ time.Sleep(50 * time.Millisecond)
+
+ history, err := hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id}", "", 0, 0, map[string]any{"alert_id": pool.Id})
+ assert.NoError(t, err)
+ require.Len(t, history, 1, "expected one history entry per user")
+ assert.Equal(t, "ZFS Pool: tank", history[0].GetString("name"))
+ assert.Equal(t, system.Id, history[0].GetString("system"))
+}
+
+func TestZfsPoolAlertResolvedOnRecordDelete(t *testing.T) {
+ hub, user := beszelTests.GetHubWithUser(t)
+ defer hub.Cleanup()
+
+ system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
+ "name": "test-system",
+ "users": []string{user.Id},
+ "host": "127.0.0.1",
+ })
+ assert.NoError(t, err)
+
+ pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
+ "system": system.Id,
+ "name": "tank",
+ "health": "ONLINE",
+ })
+ assert.NoError(t, err)
+
+ // Trigger an alert so an open history entry exists.
+ pool, err = hub.FindRecordById("zfs_pools", pool.Id)
+ assert.NoError(t, err)
+ pool.Set("health", "FAULTED")
+ err = hub.Save(pool)
+ assert.NoError(t, err)
+ time.Sleep(50 * time.Millisecond)
+
+ history, err := hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id} && resolved=null", "", 0, 0, map[string]any{"alert_id": pool.Id})
+ assert.NoError(t, err)
+ require.Len(t, history, 1, "expected one open history entry")
+
+ // Deleting the pool record must resolve the open entry.
+ err = hub.Delete(pool)
+ assert.NoError(t, err)
+ time.Sleep(50 * time.Millisecond)
+
+ history, err = hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id}", "", 0, 0, map[string]any{"alert_id": pool.Id})
+ assert.NoError(t, err)
+ require.Len(t, history, 1)
+ requireHistoryResolved(t, history)
+}
+
+func requireHistoryResolved(t *testing.T, history []*core.Record) {
+ t.Helper()
+ for _, record := range history {
+ assert.False(t, record.GetDateTime("resolved").Time().IsZero(), "expected history entry to be resolved")
+ }
+}
diff --git a/internal/common/common-ws.go b/internal/common/common-ws.go
index dee77918..3f69ba88 100644
--- a/internal/common/common-ws.go
+++ b/internal/common/common-ws.go
@@ -22,6 +22,8 @@ const (
GetSmartData
// Request detailed systemd service info from agent
GetSystemdInfo
+ // Request ZFS detail data from agent
+ GetZfsData
// Add new actions here...
)
@@ -64,6 +66,10 @@ type DataRequestOptions struct {
IncludeDetails bool `cbor:"1,keyasint"`
}
+type ZfsDataRequest struct {
+ Force bool `cbor:"0,keyasint,omitempty"`
+}
+
type ContainerLogsRequest struct {
ContainerID string `cbor:"0,keyasint"`
}
diff --git a/internal/dockerfile_agent_alpine b/internal/dockerfile_agent_alpine
index 09a20fe9..10bab26f 100644
--- a/internal/dockerfile_agent_alpine
+++ b/internal/dockerfile_agent_alpine
@@ -23,7 +23,7 @@ COPY --from=builder /agent /agent
# AMD GPU name lookup (used by agent on Linux when /usr/share/libdrm/amdgpu.ids is read)
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
-RUN apk add --no-cache smartmontools
+RUN apk add --no-cache smartmontools zfs
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]
diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go
index a73db34e..66858397 100644
--- a/internal/entities/system/system.go
+++ b/internal/entities/system/system.go
@@ -33,8 +33,6 @@ type Stats struct {
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
- Fans map[string]uint16 `json:"f,omitempty" cbor:"36,keyasint,omitempty"`
- Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
@@ -52,7 +50,20 @@ type Stats struct {
CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..]
DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"35,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %]
MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats
- DiskIOTotal [2]uint64 `json:"diot,omitzero" cbor:"38,keyasint,omitzero"` // [total read bytes, total write bytes] cumulative device counters
+ Fans map[string]uint16 `json:"f,omitempty" cbor:"36,keyasint,omitempty"`
+ Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"`
+ ZfsPools map[string]*ZfsPool `json:"z,omitempty" cbor:"39,keyasint,omitempty"` // ZFS pool metrics, keyed by pool name
+ DiskIOTotal [2]uint64 `json:"diot,omitzero" cbor:"38,keyasint,omitzero"` // [total read bytes, total write bytes] cumulative device counters
+
+}
+
+// ZfsPool holds per-pool ZFS metrics for a single collection interval.
+type ZfsPool struct {
+ Total float64 `json:"d" cbor:"0,keyasint"` // total capacity in GiB
+ Used float64 `json:"du" cbor:"1,keyasint"` // allocated in GiB
+ ReadBytes uint64 `json:"rb,omitzero" cbor:"2,keyasint,omitzero"` // read throughput in bytes/s
+ WriteBytes uint64 `json:"wb,omitzero" cbor:"3,keyasint,omitzero"` // write throughput in bytes/s
+ Health string `json:"h,omitempty" cbor:"4,keyasint,omitempty"` // ONLINE, DEGRADED, FAULTED, ...
}
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
@@ -183,6 +194,7 @@ type Details struct {
Podman bool `cbor:"8,keyasint,omitempty"`
MemoryTotal uint64 `cbor:"9,keyasint"`
SmartInterval time.Duration `cbor:"10,keyasint,omitempty"`
+ ZfsInterval time.Duration `cbor:"11,keyasint,omitempty"` // interval for ZFS detail refresh
}
// Final data structure to return to the hub
diff --git a/internal/entities/zfs/zfs.go b/internal/entities/zfs/zfs.go
new file mode 100644
index 00000000..51d01723
--- /dev/null
+++ b/internal/entities/zfs/zfs.go
@@ -0,0 +1,45 @@
+// Package zfs defines the ZFS detail data exchanged between agent and hub.
+package zfs
+
+// ZfsData is the detail payload returned by the agent for the GetZfsData action.
+type ZfsData struct {
+ Pools []*PoolDetail `json:"pools,omitempty"`
+ Complete bool `json:"complete,omitempty"`
+}
+
+// PoolDetail holds the verbose state of a single pool: capacity, health,
+// scrub, vdev, and dataset information.
+type PoolDetail struct {
+ Name string `json:"name"`
+ Health string `json:"health,omitempty"`
+ Size uint64 `json:"size,omitempty"` // bytes
+ Alloc uint64 `json:"alloc,omitempty"` // bytes
+ Free uint64 `json:"free,omitempty"` // bytes
+ Scrub *Scrub `json:"scrub,omitempty"`
+ Vdevs []*Vdev `json:"vdevs,omitempty"`
+ Datasets []*Dataset `json:"datasets,omitempty"`
+}
+
+// Scrub holds the scrub (or resilver) status of a pool.
+type Scrub struct {
+ State string `json:"state,omitempty"` // NONE, SCANNING, FINISHED, CANCELED
+ Progress string `json:"progress,omitempty"`
+ Errors uint64 `json:"errors,omitempty"`
+}
+
+// Vdev is a single vdev (mirror, raidz, or leaf disk) with error counters.
+type Vdev struct {
+ Name string `json:"name"`
+ State string `json:"state,omitempty"`
+ ReadErrs uint64 `json:"readErrs,omitempty"`
+ WriteErrs uint64 `json:"writeErrs,omitempty"`
+ ChecksumErrs uint64 `json:"checksumErrs,omitempty"`
+}
+
+// Dataset is a single ZFS dataset with usage information.
+type Dataset struct {
+ Name string `json:"name"`
+ Used uint64 `json:"used,omitempty"`
+ Avail uint64 `json:"avail,omitempty"`
+ Mountpoint string `json:"mount,omitempty"`
+}
diff --git a/internal/hub/api.go b/internal/hub/api.go
index d2d009c0..c57d8dc8 100644
--- a/internal/hub/api.go
+++ b/internal/hub/api.go
@@ -125,6 +125,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// refresh SMART devices for a system
apiAuth.POST("/smart/refresh", h.refreshSmartData).BindFunc(excludeReadOnlyRole)
+ // refresh ZFS pool details for a system
+ apiAuth.POST("/zfs/refresh", h.refreshZfsData).BindFunc(excludeReadOnlyRole)
// get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo)
// /containers routes
@@ -389,3 +391,23 @@ func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
+
+// refreshZfsData handles POST /api/beszel/zfs/refresh requests
+// Fetches fresh ZFS detail data from the agent and updates the collection
+func (h *Hub) refreshZfsData(e *core.RequestEvent) error {
+ systemID := e.Request.URL.Query().Get("system")
+ if systemID == "" {
+ return e.BadRequestError("Invalid system parameter", nil)
+ }
+
+ system, err := h.sm.GetSystem(systemID)
+ if err != nil || !system.HasUser(e.App, e.Auth) {
+ return e.NotFoundError("", nil)
+ }
+
+ if err := system.FetchAndSaveZfsPools(true); err != nil {
+ return e.InternalServerError("", err)
+ }
+
+ return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
+}
diff --git a/internal/hub/collections.go b/internal/hub/collections.go
index 5b939a44..eb88da62 100644
--- a/internal/hub/collections.go
+++ b/internal/hub/collections.go
@@ -91,6 +91,12 @@ func setCollectionAuthSettings(app core.App) error {
}); err != nil {
return err
}
+ if err := applyCollectionRules(app, []string{"zfs_pools"}, collectionRules{
+ list: &systemScopedReadRule,
+ view: &systemScopedReadRule,
+ }); err != nil {
+ return err
+ }
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
list: &systemScopedReadRule,
diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go
index 6c0879a1..43955b52 100644
--- a/internal/hub/systems/system.go
+++ b/internal/hub/systems/system.go
@@ -21,6 +21,7 @@ import (
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
+ "github.com/henrygd/beszel/internal/entities/zfs"
"github.com/henrygd/beszel"
@@ -49,6 +50,8 @@ type System struct {
detailsFetched atomic.Bool // True if static system details have been fetched and saved
smartFetching atomic.Bool // True if SMART devices are currently being fetched
smartInterval time.Duration // Interval for periodic SMART data updates
+ zfsFetching atomic.Bool // True if ZFS pools are currently being fetched
+ zfsInterval time.Duration // Interval for periodic ZFS detail data updates
}
func (sm *SystemManager) NewSystem(systemId string) *System {
@@ -154,6 +157,12 @@ func (sys *System) update() error {
// to prevent premature expiration leading to new fetch if interval is different.
sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute)
}
+ // update zfs interval if it's set on the agent side
+ if data.Details.ZfsInterval > 0 {
+ sys.zfsInterval = data.Details.ZfsInterval
+ sys.manager.hub.Logger().Info("ZFS interval updated from agent details", "system", sys.Id, "interval", sys.zfsInterval.String())
+ sys.manager.zfsFetchMap.UpdateExpiration(sys.Id, sys.zfsInterval+time.Minute)
+ }
}
// Fetch and save SMART devices when system first comes online or at intervals
@@ -170,6 +179,20 @@ func (sys *System) update() error {
}
}
+ // Fetch and save ZFS pool details when system first comes online or at intervals
+ if backgroundZfsFetchEnabled() && sys.detailsFetched.Load() && sys.supportsZfsData() {
+ if sys.zfsInterval <= 0 {
+ sys.zfsInterval = time.Hour
+ }
+ if sys.shouldFetchZfs() && sys.zfsFetching.CompareAndSwap(false, true) {
+ sys.manager.hub.Logger().Info("ZFS fetch", "system", sys.Id, "interval", sys.zfsInterval.String())
+ go func() {
+ defer sys.zfsFetching.Store(false)
+ _ = sys.FetchAndSaveZfsPools(false)
+ }()
+ }
+ }
+
return err
}
@@ -241,6 +264,10 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
}
}
+ if err := sys.syncZfsPoolHealth(txApp, data.Stats.ZfsPools); err != nil {
+ return err
+ }
+
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up)
systemRecord.Set("info", data.Info)
@@ -558,6 +585,15 @@ func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) {
return result, err
}
+// FetchZfsDataFromAgent fetches ZFS detail data from the agent.
+func (sys *System) FetchZfsDataFromAgent(force bool) (*zfs.ZfsData, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ var result zfs.ZfsData
+ err := sys.request(ctx, common.GetZfsData, common.ZfsDataRequest{Force: force}, &result)
+ return &result, err
+}
+
func makeStableHashId(strings ...string) string {
hash := fnv.New32a()
for _, str := range strings {
@@ -728,6 +764,7 @@ func (s *System) createSSHClient() error {
}
s.agentVersion, _ = extractAgentVersion(string(client.Conn.ServerVersion()))
s.manager.resetFailedSmartFetchState(s.Id)
+ s.manager.resetFailedZfsFetchState(s.Id)
return nil
}
diff --git a/internal/hub/systems/system_manager.go b/internal/hub/systems/system_manager.go
index 42f9562d..8be62379 100644
--- a/internal/hub/systems/system_manager.go
+++ b/internal/hub/systems/system_manager.go
@@ -46,6 +46,7 @@ type SystemManager struct {
systems *store.Store[string, *System] // Thread-safe store of active systems
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
+ zfsFetchMap *expirymap.ExpiryMap[zfsFetchState] // Stores last ZFS fetch time/result; TTL is only for cleanup
ctx context.Context // Cancelled when the app terminates
cancel context.CancelFunc // Cancels ctx and all child system contexts
}
@@ -67,6 +68,7 @@ func NewSystemManager(hub hubLike) *SystemManager {
systems: store.New(map[string]*System{}),
hub: hub,
smartFetchMap: expirymap.New[smartFetchState](time.Hour),
+ zfsFetchMap: expirymap.New[zfsFetchState](time.Hour),
}
sm.ctx, sm.cancel = context.WithCancel(context.Background())
return sm
@@ -343,6 +345,15 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
}
}
+// resetFailedZfsFetchState clears only failed ZFS cooldown entries so a fresh
+// agent reconnect retries ZFS discovery immediately after configuration changes.
+func (sm *SystemManager) resetFailedZfsFetchState(systemID string) {
+ state, ok := sm.zfsFetchMap.GetOk(systemID)
+ if ok && !state.Successful {
+ sm.zfsFetchMap.Remove(systemID)
+ }
+}
+
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
func (sm *SystemManager) createSSHClientConfig() error {
privateKey, err := sm.hub.GetSSHKey("")
diff --git a/internal/hub/systems/system_zfs.go b/internal/hub/systems/system_zfs.go
new file mode 100644
index 00000000..221160f2
--- /dev/null
+++ b/internal/hub/systems/system_zfs.go
@@ -0,0 +1,193 @@
+package systems
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/henrygd/beszel"
+ "github.com/henrygd/beszel/internal/entities/system"
+ "github.com/henrygd/beszel/internal/entities/zfs"
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+)
+
+var errIncompleteZfsData = errors.New("incomplete ZFS pool inventory")
+
+type zfsFetchState struct {
+ LastAttempt int64
+ Successful bool
+}
+
+func (sys *System) supportsZfsData() bool {
+ return sys.agentVersion.GTE(beszel.MinVersionZfsData)
+}
+
+// FetchAndSaveZfsPools fetches ZFS detail data from the agent and saves it to
+// the database. force bypasses the agent's detail cache for manual refreshes.
+func (sys *System) FetchAndSaveZfsPools(force bool) error {
+ zfsData, err := sys.FetchZfsDataFromAgent(force)
+ if err != nil {
+ sys.recordZfsFetchResult(err, 0)
+ return err
+ }
+ if zfsData == nil || !zfsData.Complete {
+ err = errIncompleteZfsData
+ sys.recordZfsFetchResult(err, 0)
+ return err
+ }
+ err = sys.saveZfsPools(zfsData)
+ sys.recordZfsFetchResult(err, len(zfsData.Pools))
+ return err
+}
+
+// recordZfsFetchResult stores a cooldown entry for the ZFS interval and marks
+// whether the last fetch produced any pools, so failed setup can retry on reconnect.
+func (sys *System) recordZfsFetchResult(err error, poolCount int) {
+ if sys.manager == nil {
+ return
+ }
+ interval := sys.zfsFetchInterval()
+ success := err == nil && poolCount > 0
+ if sys.manager.hub != nil {
+ sys.manager.hub.Logger().Info("ZFS fetch result", "system", sys.Id, "success", success, "pools", poolCount, "interval", interval.String(), "err", err)
+ }
+ sys.manager.zfsFetchMap.Set(sys.Id, zfsFetchState{LastAttempt: time.Now().UnixMilli(), Successful: success}, interval+time.Minute)
+}
+
+// shouldFetchZfs returns true when there is no active ZFS cooldown entry for this system.
+func (sys *System) shouldFetchZfs() bool {
+ if sys.manager == nil {
+ return true
+ }
+ state, ok := sys.manager.zfsFetchMap.GetOk(sys.Id)
+ if !ok {
+ return true
+ }
+ return !time.UnixMilli(state.LastAttempt).Add(sys.zfsFetchInterval()).After(time.Now())
+}
+
+// zfsFetchInterval returns the agent-provided ZFS interval or the default when unset.
+func (sys *System) zfsFetchInterval() time.Duration {
+ if sys.zfsInterval > 0 {
+ return sys.zfsInterval
+ }
+ return time.Hour
+}
+
+// saveZfsPools saves ZFS pool detail data to the zfs_pools collection and
+// removes records for pools no longer reported by a complete agent inventory.
+func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
+ if zfsData == nil || !zfsData.Complete {
+ return errIncompleteZfsData
+ }
+
+ hub := sys.manager.hub
+ collection, err := hub.FindCachedCollectionByNameOrId("zfs_pools")
+ if err != nil {
+ return err
+ }
+
+ return hub.RunInTransaction(func(txApp core.App) error {
+ alive := make(map[string]bool, len(zfsData.Pools))
+ for _, pool := range zfsData.Pools {
+ if pool == nil {
+ continue
+ }
+ alive[pool.Name] = true
+ if err := sys.upsertZfsPoolRecord(txApp, collection, pool); err != nil {
+ return err
+ }
+ }
+
+ existing, err := txApp.FindRecordsByFilter(
+ collection,
+ "system={:system}",
+ "", 0, 0,
+ dbx.Params{"system": sys.Id},
+ )
+ if err != nil {
+ return err
+ }
+ for _, record := range existing {
+ if !alive[record.GetString("name")] {
+ if err := txApp.Delete(record); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+ })
+}
+
+func (sys *System) upsertZfsPoolRecord(app core.App, collection *core.Collection, pool *zfs.PoolDetail) error {
+ recordID := makeStableHashId(sys.Id, pool.Name)
+
+ record, err := app.FindRecordById(collection, recordID)
+ if err != nil {
+ if !errors.Is(err, sql.ErrNoRows) {
+ return err
+ }
+ record = core.NewRecord(collection)
+ record.Set("id", recordID)
+ }
+
+ record.Set("system", sys.Id)
+ record.Set("name", pool.Name)
+ record.Set("health", pool.Health)
+ record.Set("size", pool.Size)
+ record.Set("alloc", pool.Alloc)
+ record.Set("free", pool.Free)
+ record.Set("scrub", pool.Scrub)
+ record.Set("vdevs", pool.Vdevs)
+ record.Set("datasets", pool.Datasets)
+ record.Set("details_updated", time.Now().UTC())
+
+ return app.SaveNoValidate(record)
+}
+
+// syncZfsPoolHealth persists newly discovered pools and health transitions from
+// regular system samples. Detailed fields remain owned by the hourly refresh.
+func (sys *System) syncZfsPoolHealth(app core.App, pools map[string]*system.ZfsPool) error {
+ if len(pools) == 0 {
+ return nil
+ }
+ collection, err := app.FindCachedCollectionByNameOrId("zfs_pools")
+ if err != nil {
+ return err
+ }
+ const gib = 1024 * 1024 * 1024
+ for name, pool := range pools {
+ if pool == nil {
+ continue
+ }
+ recordID := makeStableHashId(sys.Id, name)
+ record, err := app.FindRecordById(collection, recordID)
+ if err != nil {
+ if !errors.Is(err, sql.ErrNoRows) {
+ return err
+ }
+ record = core.NewRecord(collection)
+ record.Set("id", recordID)
+ record.Set("system", sys.Id)
+ record.Set("name", name)
+ record.Set("health", pool.Health)
+ record.Set("size", uint64(pool.Total*gib))
+ record.Set("alloc", uint64(pool.Used*gib))
+ record.Set("free", uint64(max(pool.Total-pool.Used, 0)*gib))
+ if err := app.SaveNoValidate(record); err != nil {
+ return fmt.Errorf("creating ZFS pool summary %q: %w", name, err)
+ }
+ continue
+ }
+ if record.GetString("health") == pool.Health {
+ continue
+ }
+ record.Set("health", pool.Health)
+ if err := app.SaveNoValidate(record); err != nil {
+ return fmt.Errorf("updating ZFS pool health %q: %w", name, err)
+ }
+ }
+ return nil
+}
diff --git a/internal/hub/systems/system_zfs_test.go b/internal/hub/systems/system_zfs_test.go
new file mode 100644
index 00000000..dd4433d4
--- /dev/null
+++ b/internal/hub/systems/system_zfs_test.go
@@ -0,0 +1,153 @@
+//go:build testing
+
+package systems
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/blang/semver"
+ "github.com/henrygd/beszel/internal/entities/system"
+ "github.com/henrygd/beszel/internal/entities/zfs"
+ "github.com/henrygd/beszel/internal/hub/expirymap"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSupportsZfsData(t *testing.T) {
+ sys := &System{agentVersion: semver.MustParse("0.18.8")}
+ assert.False(t, sys.supportsZfsData())
+
+ sys.agentVersion = semver.MustParse("0.18.9")
+ assert.True(t, sys.supportsZfsData())
+}
+
+func TestRecordZfsFetchResult(t *testing.T) {
+ sm := &SystemManager{zfsFetchMap: expirymap.New[zfsFetchState](time.Hour)}
+ t.Cleanup(sm.zfsFetchMap.StopCleaner)
+
+ sys := &System{
+ Id: "system-1",
+ manager: sm,
+ zfsInterval: time.Hour,
+ }
+
+ // Successful fetch with pools
+ sys.recordZfsFetchResult(nil, 2)
+ state, ok := sm.zfsFetchMap.GetOk(sys.Id)
+ assert.True(t, ok, "expected zfs fetch result to be stored")
+ assert.True(t, state.Successful, "expected successful fetch state to be recorded")
+
+ // Failed fetch
+ sys.recordZfsFetchResult(errors.New("failed"), 0)
+ state, ok = sm.zfsFetchMap.GetOk(sys.Id)
+ assert.True(t, ok, "expected failed zfs fetch state to be stored")
+ assert.False(t, state.Successful, "expected failed zfs fetch state to be marked unsuccessful")
+
+ // Successful fetch but no pools
+ sys.recordZfsFetchResult(nil, 0)
+ state, ok = sm.zfsFetchMap.GetOk(sys.Id)
+ assert.True(t, ok, "expected fetch with zero pools to be stored")
+ assert.False(t, state.Successful, "expected fetch with zero pools to be marked unsuccessful")
+}
+
+func TestShouldFetchZfs(t *testing.T) {
+ sm := &SystemManager{zfsFetchMap: expirymap.New[zfsFetchState](time.Hour)}
+ t.Cleanup(sm.zfsFetchMap.StopCleaner)
+
+ sys := &System{
+ Id: "system-1",
+ manager: sm,
+ zfsInterval: time.Hour,
+ }
+
+ assert.True(t, sys.shouldFetchZfs(), "expected initial zfs fetch to be allowed")
+
+ sys.recordZfsFetchResult(errors.New("failed"), 0)
+ assert.False(t, sys.shouldFetchZfs(), "expected zfs fetch to be blocked while interval entry exists")
+
+ sm.zfsFetchMap.Remove(sys.Id)
+ assert.True(t, sys.shouldFetchZfs(), "expected zfs fetch to be allowed after interval entry is cleared")
+}
+
+func TestZfsFetchIntervalDefault(t *testing.T) {
+ sys := &System{}
+ assert.Equal(t, time.Hour, sys.zfsFetchInterval())
+
+ sys.zfsInterval = 5 * time.Minute
+ assert.Equal(t, 5*time.Minute, sys.zfsFetchInterval())
+}
+
+func TestResetFailedZfsFetchState(t *testing.T) {
+ sm := &SystemManager{zfsFetchMap: expirymap.New[zfsFetchState](time.Hour)}
+ t.Cleanup(sm.zfsFetchMap.StopCleaner)
+
+ sm.zfsFetchMap.Set("system-1", zfsFetchState{LastAttempt: time.Now().UnixMilli(), Successful: false}, time.Hour)
+ sm.resetFailedZfsFetchState("system-1")
+ _, ok := sm.zfsFetchMap.GetOk("system-1")
+ assert.False(t, ok, "expected failed zfs fetch state to be cleared on reconnect")
+
+ sm.zfsFetchMap.Set("system-1", zfsFetchState{LastAttempt: time.Now().UnixMilli(), Successful: true}, time.Hour)
+ sm.resetFailedZfsFetchState("system-1")
+ _, ok = sm.zfsFetchMap.GetOk("system-1")
+ assert.True(t, ok, "expected successful zfs fetch state to be preserved")
+}
+
+func TestSaveZfsPoolsCompleteEmptyPrunesFinalPool(t *testing.T) {
+ sys, app := newTestSystemWithHub(t)
+ require.NoError(t, sys.saveZfsPools(&zfs.ZfsData{
+ Complete: true,
+ Pools: []*zfs.PoolDetail{{Name: "tank", Health: "ONLINE"}},
+ }))
+ records, err := app.FindRecordsByFilter("zfs_pools", "system={:system}", "", 0, 0, map[string]any{"system": sys.Id})
+ require.NoError(t, err)
+ require.Len(t, records, 1)
+ assert.False(t, records[0].GetDateTime("details_updated").Time().IsZero())
+
+ require.NoError(t, sys.saveZfsPools(&zfs.ZfsData{Complete: true}))
+ records, err = app.FindRecordsByFilter("zfs_pools", "system={:system}", "", 0, 0, map[string]any{"system": sys.Id})
+ require.NoError(t, err)
+ assert.Empty(t, records)
+}
+
+func TestSaveZfsPoolsIncompletePreservesRecords(t *testing.T) {
+ sys, app := newTestSystemWithHub(t)
+ require.NoError(t, sys.saveZfsPools(&zfs.ZfsData{
+ Complete: true,
+ Pools: []*zfs.PoolDetail{{Name: "tank", Health: "ONLINE"}},
+ }))
+ assert.ErrorIs(t, sys.saveZfsPools(&zfs.ZfsData{}), errIncompleteZfsData)
+ records, err := app.FindRecordsByFilter("zfs_pools", "system={:system}", "", 0, 0, map[string]any{"system": sys.Id})
+ require.NoError(t, err)
+ assert.Len(t, records, 1)
+}
+
+func TestSyncZfsPoolHealthWritesOnlyTransitions(t *testing.T) {
+ sys, app := newTestSystemWithHub(t)
+ collection, err := app.FindCachedCollectionByNameOrId("zfs_pools")
+ require.NoError(t, err)
+
+ require.NoError(t, sys.syncZfsPoolHealth(app, map[string]*system.ZfsPool{
+ "tank": {Total: 100, Used: 25, Health: "ONLINE"},
+ }))
+ record, err := app.FindRecordById(collection, makeStableHashId(sys.Id, "tank"))
+ require.NoError(t, err)
+ firstUpdated := record.GetDateTime("updated")
+ assert.Equal(t, "ONLINE", record.GetString("health"))
+ assert.EqualValues(t, 100*1024*1024*1024, record.GetInt("size"))
+
+ require.NoError(t, sys.syncZfsPoolHealth(app, map[string]*system.ZfsPool{
+ "tank": {Total: 100, Used: 30, Health: "ONLINE"},
+ }))
+ record, err = app.FindRecordById(collection, record.Id)
+ require.NoError(t, err)
+ assert.Equal(t, firstUpdated, record.GetDateTime("updated"))
+
+ require.NoError(t, sys.syncZfsPoolHealth(app, map[string]*system.ZfsPool{
+ "tank": {Total: 100, Used: 30, Health: "DEGRADED"},
+ }))
+ record, err = app.FindRecordById(collection, record.Id)
+ require.NoError(t, err)
+ assert.Equal(t, "DEGRADED", record.GetString("health"))
+}
diff --git a/internal/hub/systems/systems_production.go b/internal/hub/systems/systems_production.go
index 754c555b..b41dfa4d 100644
--- a/internal/hub/systems/systems_production.go
+++ b/internal/hub/systems/systems_production.go
@@ -7,3 +7,6 @@ package systems
// The hub integration tests create/replace systems and clean up the test apps quickly.
// Background SMART fetching can outlive teardown and crash in PocketBase internals (nil DB).
func backgroundSmartFetchEnabled() bool { return true }
+
+// Background ZFS fetching follows the same policy as SMART fetching.
+func backgroundZfsFetchEnabled() bool { return true }
diff --git a/internal/hub/systems/systems_test_helpers.go b/internal/hub/systems/systems_test_helpers.go
index b6ae4782..5d69ac12 100644
--- a/internal/hub/systems/systems_test_helpers.go
+++ b/internal/hub/systems/systems_test_helpers.go
@@ -17,6 +17,9 @@ import (
// the automatic background fetch during tests.
func backgroundSmartFetchEnabled() bool { return false }
+// Background ZFS fetching follows the same policy as SMART fetching.
+func backgroundZfsFetchEnabled() bool { return false }
+
// TESTING ONLY: GetSystemCount returns the number of systems in the store
func (sm *SystemManager) GetSystemCount() int {
return sm.systems.Length()
@@ -115,6 +118,7 @@ func (sm *SystemManager) RemoveAllSystems() {
sm.RemoveSystem(system.Id)
}
sm.smartFetchMap.StopCleaner()
+ sm.zfsFetchMap.StopCleaner()
}
// ResetContextForTesting replaces the manager context for a new synctest bubble.
diff --git a/internal/migrations/zfs_pools.go b/internal/migrations/zfs_pools.go
new file mode 100644
index 00000000..a0c9c1bc
--- /dev/null
+++ b/internal/migrations/zfs_pools.go
@@ -0,0 +1,184 @@
+package migrations
+
+import (
+ "github.com/pocketbase/pocketbase/core"
+ m "github.com/pocketbase/pocketbase/migrations"
+)
+
+// Creates the zfs_pools collection for per-system ZFS pool detail data
+// (pool health, capacity, scrub, vdevs, datasets). Upserts rather than
+// deletes missing collections, so it is safe on fresh and existing installs.
+func init() {
+ m.Register(func(app core.App) error {
+ // update collections
+ jsonData := `[
+ {
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "cascadeDelete": true,
+ "collectionId": "2hz5ncl8tizk5nx",
+ "hidden": false,
+ "id": "relation1204987316",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "system",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text7739291048",
+ "max": 0,
+ "min": 0,
+ "name": "name",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text5528164482",
+ "max": 0,
+ "min": 0,
+ "name": "health",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "number8862034195",
+ "max": null,
+ "min": null,
+ "name": "size",
+ "onlyInt": true,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number4418907321",
+ "max": null,
+ "min": null,
+ "name": "alloc",
+ "onlyInt": true,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number2904183765",
+ "max": null,
+ "min": null,
+ "name": "free",
+ "onlyInt": true,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "json4466109723",
+ "maxSize": 0,
+ "name": "scrub",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "json9012873456",
+ "maxSize": 0,
+ "name": "vdevs",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "json7182045639",
+ "maxSize": 0,
+ "name": "datasets",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "date9274163058",
+ "max": "",
+ "min": "",
+ "name": "details_updated",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "id": "pbc_8441057391",
+ "indexes": [
+ "CREATE INDEX ` + "`" + `idx_zfsPoolsSystem` + "`" + ` ON ` + "`" + `zfs_pools` + "`" + ` (` + "`" + `system` + "`" + `)"
+ ],
+ "listRule": null,
+ "name": "zfs_pools",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
+ }
+]`
+
+ err := app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
+ if err != nil {
+ return err
+ }
+
+ return nil
+ }, func(app core.App) error {
+ return nil
+ })
+}
diff --git a/internal/records/records.go b/internal/records/records.go
index 465d0bf2..4b27e57a 100644
--- a/internal/records/records.go
+++ b/internal/records/records.go
@@ -127,6 +127,7 @@ func (rm *RecordManager) CreateLongerRecords() {
"created": shorterRecordPeriod,
},
)).
+ OrderBy("created").
All(&recordIds)
// continue if not enough shorter records
@@ -196,6 +197,7 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
tempCount := float64(0)
var fanSums map[string]uint64
fanCount := uint64(0)
+ zfsPoolCounts := make(map[string]uint64)
// Accumulate totals
for i := range records {
@@ -336,6 +338,31 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
}
}
+ // Accumulate ZFS pool stats. Counts are tracked per entry so a pool
+ // missing from some samples is not averaged as zero.
+ if stats.ZfsPools != nil {
+ if sum.ZfsPools == nil {
+ sum.ZfsPools = make(map[string]*system.ZfsPool, len(stats.ZfsPools))
+ }
+ for name, value := range stats.ZfsPools {
+ if value == nil {
+ continue
+ }
+ pool := sum.ZfsPools[name]
+ if pool == nil {
+ pool = &system.ZfsPool{}
+ sum.ZfsPools[name] = pool
+ }
+ pool.Total += value.Total
+ pool.Used += value.Used
+ pool.ReadBytes += value.ReadBytes
+ pool.WriteBytes += value.WriteBytes
+ if value.Health != "" {
+ pool.Health = value.Health
+ }
+ zfsPoolCounts[name]++
+ }
+ }
// Accumulate GPU data
if stats.GPUData != nil {
if sum.GPUData == nil {
@@ -446,6 +473,14 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
}
}
+ // Average ZFS pool stats.
+ for name, pool := range sum.ZfsPools {
+ entryCount := zfsPoolCounts[name]
+ pool.Total = twoDecimals(pool.Total / float64(entryCount))
+ pool.Used = twoDecimals(pool.Used / float64(entryCount))
+ pool.ReadBytes /= entryCount
+ pool.WriteBytes /= entryCount
+ }
// Average GPU data
if sum.GPUData != nil {
for id := range sum.GPUData {
diff --git a/internal/records/records_averaging_test.go b/internal/records/records_averaging_test.go
index bc974499..243b8aa3 100644
--- a/internal/records/records_averaging_test.go
+++ b/internal/records/records_averaging_test.go
@@ -669,6 +669,33 @@ func TestAverageSystemStatsSlice_MixedOptionalFields(t *testing.T) {
assert.Equal(t, 20.0, result.GPUData["gpu0"].Usage)
}
+func TestAverageSystemStatsSlice_Zfs(t *testing.T) {
+ input := []system.Stats{
+ {
+ ZfsPools: map[string]*system.ZfsPool{
+ "tank": {Total: 100, Used: 40, ReadBytes: 100, WriteBytes: 200, Health: "ONLINE"},
+ },
+ },
+ {},
+ {
+ ZfsPools: map[string]*system.ZfsPool{
+ "tank": {Total: 120, Used: 60, ReadBytes: 300, WriteBytes: 400, Health: "DEGRADED"},
+ "backup": {Total: 50, Used: 10, ReadBytes: 25, WriteBytes: 50, Health: "ONLINE"},
+ },
+ },
+ }
+
+ result := records.AverageSystemStatsSlice(input)
+
+ require.Len(t, result.ZfsPools, 2)
+ assert.Equal(t, &system.ZfsPool{
+ Total: 110, Used: 50, ReadBytes: 200, WriteBytes: 300, Health: "DEGRADED",
+ }, result.ZfsPools["tank"])
+ assert.Equal(t, &system.ZfsPool{
+ Total: 50, Used: 10, ReadBytes: 25, WriteBytes: 50, Health: "ONLINE",
+ }, result.ZfsPools["backup"])
+}
+
// Tests with 10 records matching the common real-world case (10 x 1m -> 1 x 10m).
func TestAverageSystemStatsSlice_TenRecords(t *testing.T) {
input := make([]system.Stats, 10)
diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx
index 495814ed..34a54cd6 100644
--- a/internal/site/src/components/routes/system.tsx
+++ b/internal/site/src/components/routes/system.tsx
@@ -8,10 +8,11 @@ import { useSystemData } from "./system/use-system-data"
import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts"
import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts"
import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
+import { ZfsCharts } from "./system/charts/zfs-charts"
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
-import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
+import { LazyContainersTable, LazySmartTable, LazySystemdTable, LazyZfsTable } from "./system/lazy-tables"
import { LoadAverageChart } from "./system/charts/load-average-chart"
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
import { GpuIcon } from "../ui/icons"
@@ -63,6 +64,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const hasContainersTable = hasContainers && compareSemVer(chartData.agentVersion, SEMVER_0_14_0) >= 0
const hasSystemd = system.info.sv
const hasGpu = hasGpuData || hasGpuPowerData
+ const hasZfs = Object.keys(systemStats.at(-1)?.stats?.z ?? {}).length > 0
// keep tabsRef in sync for keyboard navigation
const tabs = ["core", "disk"]
@@ -142,6 +144,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {