fix(agent): report ZFS pool capacity from the root dataset (#2540)

The pool overview used `zpool list`, whose size and alloc count raw vdev
space. On raidz that includes parity, so a TrueNAS pool with 65 TB usable
showed 98 TB capacity, while the details page (root dataset) matched TrueNAS.

Take Size, Alloc and Free from the pool's root dataset (used+avail), the same
way Btrfs reports effective capacity. If the root dataset cannot be read, keep
the raw figures and set Raw so the UI labels them as raw usage.

Fixes #2539
This commit is contained in:
kenny-ish
2026-10-12 14:14:19 -04:00
committed by GitHub
parent 9cf75cca7a
commit 32d4692853
2 changed files with 78 additions and 7 deletions
+32 -2
View File
@@ -68,7 +68,8 @@ type Dataset struct {
}
// PoolStats returns capacity and health for all pools on the system using
// `zpool list`. Frequent health and I/O sampling uses PoolKernelStats instead.
// `zpool list`, with capacity taken from each pool's root dataset where it can
// be read. Frequent health and I/O sampling uses PoolKernelStats instead.
func PoolStats() ([]PoolStat, error) {
if err := checkZfsDevice(); err != nil {
return nil, err
@@ -81,7 +82,36 @@ func PoolStats() ([]PoolStat, error) {
}
return nil, fmt.Errorf("zpool list: %w", err)
}
return parseZpoolListOutput(out)
pools, err := parseZpoolListOutput(out)
if err != nil || len(pools) == 0 {
return pools, err
}
applyRootDatasetCapacity(pools)
return pools, nil
}
// applyRootDatasetCapacity replaces the raw `zpool list` figures, which include
// parity on raidz and dRAID vdevs, with each pool's root dataset used and avail.
// As with Btrfs, the effective capacity is used+avail, so Size-Alloc is what
// applications can still write and the usage ratio matches df. A pool whose
// root dataset cannot be read keeps its raw figures and is flagged as Raw.
func applyRootDatasetCapacity(pools []PoolStat) {
roots := make(map[string]Dataset)
if out, err := commandOutput("zfs", "list", "-Hp", "-d", "0", "-o", "name,used,avail,mountpoint"); err == nil {
if datasets, err := parseZfsListOutput(out); err == nil {
for _, ds := range datasets {
roots[ds.Name] = ds
}
}
}
for i := range pools {
root, ok := roots[pools[i].Name]
if !ok {
pools[i].Raw = true
continue
}
pools[i].Size, pools[i].Alloc, pools[i].Free = root.Used+root.Avail, root.Used, root.Avail
}
}
// Datasets returns all datasets on the system with usage and mountpoint
+46 -5
View File
@@ -3,6 +3,7 @@
package zfs
import (
"errors"
"os"
"path/filepath"
"testing"
@@ -260,15 +261,55 @@ func TestPoolStatsDelegatesToZpoolWhenDevZfsPresent(t *testing.T) {
oldCommandOutput := commandOutput
called := false
commandOutput = func(name string, args ...string) ([]byte, error) {
called = true
assert.Equal(t, "zpool", name)
assert.Equal(t, []string{"list", "-Hp", "-o", "name,size,alloc,free,health"}, args)
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
switch name {
case "zpool":
called = true
assert.Equal(t, []string{"list", "-Hp", "-o", "name,size,alloc,free,health"}, args)
// raidz: the raw figures include parity
return []byte("tank\t150\t60\t90\tONLINE\n"), nil
case "zfs":
assert.Equal(t, []string{"list", "-Hp", "-d", "0", "-o", "name,used,avail,mountpoint"}, args)
return []byte("tank\t40\t60\t/tank\n"), nil
}
t.Fatalf("unexpected %s call with %v", name, args)
return nil, nil
}
t.Cleanup(func() { commandOutput = oldCommandOutput })
pools, err := PoolStats()
require.NoError(t, err)
assert.True(t, called)
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 40, Free: 60, Health: "ONLINE"}}, pools)
}
func TestPoolStatsKeepsRawCapacityWithoutRootDataset(t *testing.T) {
devFile := filepath.Join(t.TempDir(), "zfs")
require.NoError(t, os.WriteFile(devFile, nil, 0o644))
oldDevZfsPath := devZfsPath
devZfsPath = devFile
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
for _, tc := range []struct {
name string
zfsOut []byte
zfsErr error
}{
{"zfs list fails", nil, errors.New("zfs list failed")},
{"root dataset missing", []byte("other\t1\t1\t/other\n"), nil},
} {
t.Run(tc.name, func(t *testing.T) {
oldCommandOutput := commandOutput
commandOutput = func(name string, args ...string) ([]byte, error) {
if name == "zpool" {
return []byte("tank\t150\t60\t90\tONLINE\n"), nil
}
return tc.zfsOut, tc.zfsErr
}
t.Cleanup(func() { commandOutput = oldCommandOutput })
pools, err := PoolStats()
require.NoError(t, err)
assert.Equal(t, []PoolStat{{Name: "tank", Size: 150, Alloc: 60, Free: 90, Health: "ONLINE", Raw: true}}, pools)
})
}
}