mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 08:57:48 +02:00
feat(agent): report btrfs filesystems as storage pools (#2315)
Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
@@ -48,7 +48,7 @@ type Agent struct {
|
|||||||
keys []gossh.PublicKey // SSH public keys
|
keys []gossh.PublicKey // SSH public keys
|
||||||
smartManager *SmartManager // Manages SMART data
|
smartManager *SmartManager // Manages SMART data
|
||||||
systemdManager *systemdManager // Manages systemd services
|
systemdManager *systemdManager // Manages systemd services
|
||||||
zfsManager *ZfsManager // Manages ZFS pool and dataset data
|
storagePoolManager *StoragePoolManager // Manages storage pool and dataset data
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
@@ -122,12 +122,12 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// initialize handler registry
|
// initialize handler registry
|
||||||
agent.handlerRegistry = NewHandlerRegistry()
|
agent.handlerRegistry = NewHandlerRegistry()
|
||||||
|
|
||||||
agent.zfsManager = newZfsManager()
|
agent.storagePoolManager = newStoragePoolManager()
|
||||||
|
|
||||||
// ZFS_INTERVAL env var to update ZFS detail data at this interval
|
// Retain ZFS_INTERVAL for the shared storage pool detail refresh interval.
|
||||||
if zfsIntervalEnv, exists := utils.GetEnv("ZFS_INTERVAL"); exists {
|
if zfsIntervalEnv, exists := utils.GetEnv("ZFS_INTERVAL"); exists {
|
||||||
if duration, err := time.ParseDuration(zfsIntervalEnv); err == nil && duration > 0 {
|
if duration, err := time.ParseDuration(zfsIntervalEnv); err == nil && duration > 0 {
|
||||||
agent.zfsManager.detailInterval = duration
|
agent.storagePoolManager.detailInterval = duration
|
||||||
agent.systemDetails.ZfsInterval = duration
|
agent.systemDetails.ZfsInterval = duration
|
||||||
slog.Info("ZFS_INTERVAL", "duration", duration)
|
slog.Info("ZFS_INTERVAL", "duration", duration)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
26
agent/btrfs/btrfs.go
Normal file
26
agent/btrfs/btrfs.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Package btrfs reads btrfs filesystem state from sysfs.
|
||||||
|
package btrfs
|
||||||
|
|
||||||
|
// Filesystem is a mounted btrfs filesystem read from /sys/fs/btrfs/<uuid>.
|
||||||
|
type Filesystem struct {
|
||||||
|
UUID string // stable filesystem UUID from sysfs
|
||||||
|
MountID string // kernel filesystem identity for matching monitored mounts
|
||||||
|
IODevice string // sole member block-device name, empty for multi-device/unknown pools
|
||||||
|
Name string // label, else first mountpoint, else UUID
|
||||||
|
Size uint64 // effective usable capacity, or raw member capacity when Raw
|
||||||
|
Raw bool // capacity and usage are physical bytes, unsuitable for disk alerts
|
||||||
|
Alloc uint64 // raw bytes allocated to data, metadata and system chunks
|
||||||
|
Health string // ONLINE, or DEGRADED when a device is missing
|
||||||
|
NRead uint64 // cumulative bytes read across member devices
|
||||||
|
NWrite uint64 // cumulative bytes written across member devices
|
||||||
|
Devices []Device
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device is one member device (devinfo/<devid>) with its error counters.
|
||||||
|
type Device struct {
|
||||||
|
Name string // "devid N"; sysfs does not expose the block device path
|
||||||
|
State string // ONLINE or MISSING
|
||||||
|
ReadErrs uint64
|
||||||
|
WriteErrs uint64
|
||||||
|
CorruptionErrs uint64
|
||||||
|
}
|
||||||
285
agent/btrfs/btrfs_linux.go
Normal file
285
agent/btrfs/btrfs_linux.go
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package btrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sysfsPath = "/sys/fs/btrfs"
|
||||||
|
mountsPath = "/proc/self/mounts"
|
||||||
|
mountinfoPath = "/proc/self/mountinfo"
|
||||||
|
mountUUID = MountID
|
||||||
|
deviceSize = ioctlDeviceSize
|
||||||
|
filesystemUsage = statfsUsage
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filesystems returns all mounted btrfs filesystems, or nil when there are none.
|
||||||
|
func Filesystems() ([]Filesystem, error) {
|
||||||
|
entries, err := os.ReadDir(sysfsPath)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mounts := mountpointsByDevice()
|
||||||
|
var filesystems []Filesystem
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() || entry.Name() == "features" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs, err := readFilesystem(filepath.Join(sysfsPath, entry.Name()), mounts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("btrfs %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
filesystems = append(filesystems, fs)
|
||||||
|
}
|
||||||
|
return filesystems, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFilesystem(dir string, mounts map[string]string) (Filesystem, error) {
|
||||||
|
fs := Filesystem{UUID: filepath.Base(dir), Name: utils.ReadStringFile(filepath.Join(dir, "label")), Health: "UNKNOWN"}
|
||||||
|
for _, kind := range []string{"data", "metadata", "system"} {
|
||||||
|
if value, ok := utils.ReadUintFile(filepath.Join(dir, "allocation", kind, "disk_used")); ok {
|
||||||
|
fs.Alloc += value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// devices/<name> links to the block device's sysfs directory.
|
||||||
|
devices, err := os.ReadDir(filepath.Join(dir, "devices"))
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fs, err
|
||||||
|
}
|
||||||
|
mountpoint := mounts["uuid:"+fs.UUID]
|
||||||
|
if fs.Name == "" {
|
||||||
|
fs.Name = mountpoint
|
||||||
|
}
|
||||||
|
var backingSize uint64
|
||||||
|
for _, dev := range devices {
|
||||||
|
if mountpoint == "" {
|
||||||
|
mountpoint = mounts[dev.Name()]
|
||||||
|
}
|
||||||
|
if fs.Name == "" {
|
||||||
|
fs.Name = mountpoint
|
||||||
|
}
|
||||||
|
devDir := filepath.Join(dir, "devices", dev.Name())
|
||||||
|
if size, ok := utils.ReadUintFile(filepath.Join(devDir, "size")); ok {
|
||||||
|
backingSize += size * 512
|
||||||
|
}
|
||||||
|
if stat := strings.Fields(utils.ReadStringFile(filepath.Join(devDir, "stat"))); len(stat) >= 7 {
|
||||||
|
fs.NRead += parseUint(stat[2]) * 512
|
||||||
|
fs.NWrite += parseUint(stat[6]) * 512
|
||||||
|
}
|
||||||
|
}
|
||||||
|
devids, err := os.ReadDir(filepath.Join(dir, "devinfo"))
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fs, err
|
||||||
|
}
|
||||||
|
capacityAvailable := len(devids) > 0
|
||||||
|
healthKnown := len(devids) > 0
|
||||||
|
for _, devid := range devids {
|
||||||
|
devDir := filepath.Join(dir, "devinfo", devid.Name())
|
||||||
|
// Replacement targets do not add filesystem capacity.
|
||||||
|
replaceTarget, _ := utils.ReadUintFile(filepath.Join(devDir, "replace_target"))
|
||||||
|
if replaceTarget != 1 {
|
||||||
|
devid, err := strconv.ParseUint(devid.Name(), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fs, err
|
||||||
|
}
|
||||||
|
size, err := deviceSize(mountpoint, devid)
|
||||||
|
if err != nil {
|
||||||
|
capacityAvailable = false
|
||||||
|
}
|
||||||
|
fs.Size += size
|
||||||
|
}
|
||||||
|
dev := Device{Name: "devid " + devid.Name(), State: "ONLINE"}
|
||||||
|
missing := utils.ReadStringFile(filepath.Join(devDir, "missing"))
|
||||||
|
if missing != "0" && missing != "1" {
|
||||||
|
healthKnown = false
|
||||||
|
dev.State = "UNKNOWN"
|
||||||
|
}
|
||||||
|
if missing == "1" {
|
||||||
|
dev.State = "MISSING"
|
||||||
|
fs.Health = "DEGRADED"
|
||||||
|
}
|
||||||
|
for line := range strings.Lines(utils.ReadStringFile(filepath.Join(devDir, "error_stats"))) {
|
||||||
|
if fields := strings.Fields(line); len(fields) == 2 {
|
||||||
|
switch fields[0] {
|
||||||
|
case "read_errs":
|
||||||
|
dev.ReadErrs = parseUint(fields[1])
|
||||||
|
case "write_errs":
|
||||||
|
dev.WriteErrs = parseUint(fields[1])
|
||||||
|
case "corruption_errs":
|
||||||
|
dev.CorruptionErrs = parseUint(fields[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.Devices = append(fs.Devices, dev)
|
||||||
|
}
|
||||||
|
// Use one capacity source for the whole filesystem: device IDs cannot be
|
||||||
|
// reliably matched to block-device names in sysfs. A partial ioctl result
|
||||||
|
// must not be added to the complete backing-device total.
|
||||||
|
if !capacityAvailable {
|
||||||
|
fs.Size = backingSize
|
||||||
|
}
|
||||||
|
if fs.Health != "DEGRADED" && healthKnown {
|
||||||
|
fs.Health = "ONLINE"
|
||||||
|
}
|
||||||
|
fs.MountID = mountUUID(mountpoint)
|
||||||
|
if len(devices) == 1 && len(devids) == 1 && fs.Health == "ONLINE" {
|
||||||
|
fs.IODevice = devices[0].Name()
|
||||||
|
}
|
||||||
|
fs.Raw = true
|
||||||
|
if used, available, err := filesystemUsage(mountpoint); err == nil {
|
||||||
|
// Effective capacity excludes reserved/unavailable space, so Size-Alloc
|
||||||
|
// is available to applications and the usage ratio matches df.
|
||||||
|
fs.Size, fs.Alloc, fs.Raw = used+available, used, false
|
||||||
|
}
|
||||||
|
if fs.Name == "" {
|
||||||
|
fs.Name = filepath.Base(dir)
|
||||||
|
}
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountpointsByDevice prefers UUID matches from mountinfo and retains source
|
||||||
|
// device names as a fallback for environments where FS_INFO is unavailable.
|
||||||
|
func mountpointsByDevice() map[string]string {
|
||||||
|
mounts := mountpointsByUUID(utils.ReadStringFile(mountinfoPath), mountUUID)
|
||||||
|
for line := range strings.Lines(utils.ReadStringFile(mountsPath)) {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 || fields[2] != "btrfs" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
device := fields[0]
|
||||||
|
if resolved, err := filepath.EvalSymlinks(device); err == nil {
|
||||||
|
device = resolved
|
||||||
|
}
|
||||||
|
if _, seen := mounts[filepath.Base(device)]; !seen {
|
||||||
|
mounts[filepath.Base(device)] = unescapeMountPath(fields[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mounts
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUint(s string) uint64 {
|
||||||
|
n, _ := strconv.ParseUint(s, 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ioctlDeviceSize reads Btrfs's recorded device size, which can be smaller
|
||||||
|
// than the block device after a filesystem resize. BTRFS_IOC_DEV_INFO is
|
||||||
|
// _IOWR(0x94, 30, struct btrfs_ioctl_dev_info_args), a 4096-byte ABI structure.
|
||||||
|
func ioctlDeviceSize(mountpoint string, devid uint64) (uint64, error) {
|
||||||
|
if mountpoint == "" {
|
||||||
|
return 0, errors.New("no accessible mountpoint")
|
||||||
|
}
|
||||||
|
f, err := os.Open(mountpoint)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
args := struct {
|
||||||
|
Devid uint64
|
||||||
|
UUID [16]byte
|
||||||
|
BytesUsed uint64
|
||||||
|
TotalBytes uint64
|
||||||
|
Reserved [4096 - 40]byte
|
||||||
|
}{Devid: devid}
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), 0xd000941e, uintptr(unsafe.Pointer(&args)))
|
||||||
|
if errno != 0 {
|
||||||
|
return 0, errno
|
||||||
|
}
|
||||||
|
return args.TotalBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filesystem magic is unsigned even when Statfs_t.Type is int32.
|
||||||
|
func isBtrfs(stat *unix.Statfs_t) bool {
|
||||||
|
return uint32(stat.Type) == unix.BTRFS_SUPER_MAGIC
|
||||||
|
}
|
||||||
|
|
||||||
|
func statfsUsage(path string) (used, available uint64, err error) {
|
||||||
|
if path == "" {
|
||||||
|
return 0, 0, errors.New("no accessible mountpoint")
|
||||||
|
}
|
||||||
|
var stat unix.Statfs_t
|
||||||
|
if err = unix.Statfs(path, &stat); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isBtrfs(&stat) {
|
||||||
|
return 0, 0, errors.New("mountpoint is not Btrfs")
|
||||||
|
}
|
||||||
|
blockSize := uint64(stat.Bsize)
|
||||||
|
return (stat.Blocks - min(stat.Blocks, stat.Bfree)) * blockSize, min(stat.Blocks, stat.Bavail) * blockSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountID returns the filesystem UUID via BTRFS_IOC_FS_INFO. Unlike statfs
|
||||||
|
// f_fsid, this identity is shared by all subvolumes and bind mounts.
|
||||||
|
func MountID(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var stat unix.Statfs_t
|
||||||
|
if unix.Statfs(path, &stat) != nil || !isBtrfs(&stat) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
args := struct {
|
||||||
|
MaxID uint64
|
||||||
|
NumDevices uint64
|
||||||
|
FSID [16]byte
|
||||||
|
Reserved [992]byte
|
||||||
|
}{}
|
||||||
|
// _IOR(0x94, 31, 1024). Reuse the platform's read-direction bits;
|
||||||
|
// MIPS/PowerPC use a different encoding than asm-generic.
|
||||||
|
request := uintptr(unix.FS_IOC_GETFLAGS&0xe0000000) | 0x0400941f
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), request, uintptr(unsafe.Pointer(&args)))
|
||||||
|
if errno != 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id := args.FSID
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x", id[:4], id[4:6], id[6:8], id[8:10], id[10:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Btrfs mountinfo device numbers can be virtual (0:N), so query the UUID
|
||||||
|
// through the mount instead of comparing those numbers with sysfs block devs.
|
||||||
|
// Retry another path when a bind mount is inaccessible. Once resolved, reuse
|
||||||
|
// the result for that mount device to avoid opening every Docker bind mount.
|
||||||
|
func mountpointsByUUID(mountinfo string, identify func(string) string) map[string]string {
|
||||||
|
mounts := make(map[string]string)
|
||||||
|
resolved := make(map[string]bool)
|
||||||
|
for line := range strings.Lines(mountinfo) {
|
||||||
|
before, after, ok := strings.Cut(line, " - ")
|
||||||
|
fields, fs := strings.Fields(before), strings.Fields(after)
|
||||||
|
if !ok || len(fields) < 6 || len(fs) < 3 || fs[0] != "btrfs" || resolved[fields[2]] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := unescapeMountPath(fields[4])
|
||||||
|
uuid := identify(path)
|
||||||
|
if uuid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolved[fields[2]] = true
|
||||||
|
if mounts["uuid:"+uuid] == "" {
|
||||||
|
mounts["uuid:"+uuid] = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mounts
|
||||||
|
}
|
||||||
|
|
||||||
|
func unescapeMountPath(path string) string {
|
||||||
|
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(path)
|
||||||
|
}
|
||||||
274
agent/btrfs/btrfs_linux_test.go
Normal file
274
agent/btrfs/btrfs_linux_test.go
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
//go:build testing && linux
|
||||||
|
|
||||||
|
package btrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilesystems(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
oldSysfs, oldMounts := sysfsPath, mountsPath
|
||||||
|
sysfsPath, mountsPath = root, filepath.Join(root, "mounts")
|
||||||
|
t.Cleanup(func() { sysfsPath, mountsPath = oldSysfs, oldMounts })
|
||||||
|
|
||||||
|
fsDir := filepath.Join(root, "1b2c3d4e-0000-0000-0000-000000000000")
|
||||||
|
write := func(rel, content string) {
|
||||||
|
path := filepath.Join(fsDir, rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "features"), 0o755))
|
||||||
|
oldUsage := filesystemUsage
|
||||||
|
filesystemUsage = func(string) (uint64, uint64, error) { return 0, 0, os.ErrNotExist }
|
||||||
|
t.Cleanup(func() { filesystemUsage = oldUsage })
|
||||||
|
oldDeviceSize := deviceSize
|
||||||
|
t.Cleanup(func() { deviceSize = oldDeviceSize })
|
||||||
|
deviceSize = func(_ string, devid uint64) (uint64, error) {
|
||||||
|
value, _ := utils.ReadUintFile(filepath.Join(fsDir, "recorded-size", strconv.FormatUint(devid, 10)))
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
// Recorded member capacities differ from the unchanged backing devices.
|
||||||
|
write("recorded-size/1", "256000\n")
|
||||||
|
write("recorded-size/2", "128000\n")
|
||||||
|
write("label", "tank\n")
|
||||||
|
write("allocation/data/disk_used", "4096\n")
|
||||||
|
write("allocation/metadata/disk_used", "2048\n")
|
||||||
|
write("allocation/system/disk_used", "1024\n")
|
||||||
|
write("devices/sda/size", "1000\n")
|
||||||
|
write("devices/sda/stat", "10 0 200 0 20 0 400 0 0 0 0\n")
|
||||||
|
write("devices/sdb/size", "1000\n")
|
||||||
|
write("devices/sdb/stat", "10 0 100 0 20 0 100 0 0 0 0\n")
|
||||||
|
write("devinfo/1/missing", "0\n")
|
||||||
|
write("devinfo/1/error_stats", "write_errs 1\nread_errs 2\nflush_errs 0\ncorruption_errs 3\ngeneration_errs 0\n")
|
||||||
|
write("devinfo/2/missing", "1\n")
|
||||||
|
|
||||||
|
filesystems, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, filesystems, 1)
|
||||||
|
assert.Equal(t, Filesystem{
|
||||||
|
UUID: "1b2c3d4e-0000-0000-0000-000000000000", Raw: true, Name: "tank", Size: 384000, Alloc: 7168, Health: "DEGRADED", NRead: 153600, NWrite: 256000,
|
||||||
|
Devices: []Device{
|
||||||
|
{Name: "devid 1", State: "ONLINE", ReadErrs: 2, WriteErrs: 1, CorruptionErrs: 3},
|
||||||
|
{Name: "devid 2", State: "MISSING"},
|
||||||
|
},
|
||||||
|
}, filesystems[0])
|
||||||
|
|
||||||
|
// Unlabeled filesystems fall back to the first mountpoint, then the UUID.
|
||||||
|
write("label", "\n")
|
||||||
|
require.NoError(t, os.WriteFile(mountsPath, []byte(
|
||||||
|
"/dev/sdz1 /other btrfs rw 0 0\n/dev/sdb /mnt/storage btrfs rw 0 0\n/dev/sdb /mnt/storage/sub btrfs rw,subvol=/sub 0 0\n",
|
||||||
|
), 0o644))
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/mnt/storage", filesystems[0].Name)
|
||||||
|
|
||||||
|
require.NoError(t, os.Remove(mountsPath))
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "1b2c3d4e-0000-0000-0000-000000000000", filesystems[0].Name)
|
||||||
|
write("devinfo/3/replace_target", "1\n")
|
||||||
|
write("recorded-size/3", "512000\n")
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(384000), filesystems[0].Size, "replacement target must not inflate capacity")
|
||||||
|
|
||||||
|
deviceSize = func(string, uint64) (uint64, error) { return 0, os.ErrPermission }
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, filesystems, 1)
|
||||||
|
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
||||||
|
assert.Equal(t, "DEGRADED", filesystems[0].Health)
|
||||||
|
assert.Equal(t, uint64(153600), filesystems[0].NRead)
|
||||||
|
|
||||||
|
// A partial ioctl result must not be mixed with the backing-device total.
|
||||||
|
deviceSize = func(_ string, devid uint64) (uint64, error) {
|
||||||
|
if devid == 2 {
|
||||||
|
return 0, os.ErrPermission
|
||||||
|
}
|
||||||
|
return 256000, nil
|
||||||
|
}
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
||||||
|
|
||||||
|
// With no mount visible (e.g. Docker), the real lookup falls back too.
|
||||||
|
deviceSize = ioctlDeviceSize
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, filesystems, 1)
|
||||||
|
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
||||||
|
|
||||||
|
filesystemUsage = func(string) (uint64, uint64, error) { return 100, 900, nil }
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(1000), filesystems[0].Size)
|
||||||
|
assert.Equal(t, uint64(100), filesystems[0].Alloc)
|
||||||
|
assert.False(t, filesystems[0].Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystemsNoBtrfs(t *testing.T) {
|
||||||
|
oldPath := sysfsPath
|
||||||
|
sysfsPath = filepath.Join(t.TempDir(), "missing")
|
||||||
|
t.Cleanup(func() { sysfsPath = oldPath })
|
||||||
|
|
||||||
|
filesystems, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, filesystems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIoctlDeviceSizeFailure(t *testing.T) {
|
||||||
|
_, err := ioctlDeviceSize("", 1)
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = ioctlDeviceSize(t.TempDir(), 1)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, unix.ENOTTY)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMountpointsDecodeEscapes(t *testing.T) {
|
||||||
|
oldMounts := mountsPath
|
||||||
|
mountsPath = filepath.Join(t.TempDir(), "mounts")
|
||||||
|
t.Cleanup(func() { mountsPath = oldMounts })
|
||||||
|
require.NoError(t, os.WriteFile(mountsPath, []byte("/dev/test-btrfs /mnt/my\\040data btrfs rw 0 0\n"), 0o644))
|
||||||
|
assert.Equal(t, "/mnt/my data", mountpointsByDevice()["test-btrfs"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystemWithoutDevinfo(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "devices", "sda"), 0755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "devices", "sda", "size"), []byte("1000"), 0644))
|
||||||
|
fs, err := readFilesystem(root, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(512000), fs.Size)
|
||||||
|
assert.True(t, fs.Raw)
|
||||||
|
assert.Equal(t, "UNKNOWN", fs.Health)
|
||||||
|
assert.Empty(t, fs.Devices)
|
||||||
|
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "devinfo", "1"), 0755))
|
||||||
|
fs, err = readFilesystem(root, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "UNKNOWN", fs.Health)
|
||||||
|
require.Len(t, fs.Devices, 1)
|
||||||
|
assert.Equal(t, "UNKNOWN", fs.Devices[0].State)
|
||||||
|
|
||||||
|
// Some older interfaces lack the devices directory too.
|
||||||
|
fs, err = readFilesystem(t.TempDir(), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "UNKNOWN", fs.Health)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalBtrfsUsage(t *testing.T) {
|
||||||
|
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for read-only live validation")
|
||||||
|
}
|
||||||
|
used, available, err := statfsUsage(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
filesystems, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, fs := range filesystems {
|
||||||
|
if !fs.Raw && fs.Alloc == used && fs.Size == used+available {
|
||||||
|
t.Logf("pool=%s used=%d available=%d effective_capacity=%d", fs.Name, used, available, fs.Size)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("collector did not report the mounted filesystem's usable capacity")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMountID(t *testing.T) {
|
||||||
|
assert.Empty(t, MountID(""))
|
||||||
|
assert.Empty(t, MountID(filepath.Join(t.TempDir(), "missing")))
|
||||||
|
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for live identity validation")
|
||||||
|
}
|
||||||
|
id := MountID(path)
|
||||||
|
require.NotEmpty(t, id)
|
||||||
|
assert.Equal(t, id, MountID(filepath.Join(path, ".")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMountinfoUUIDLookup(t *testing.T) {
|
||||||
|
info := `1 0 0:40 /@ /inaccessible ro shared:1 - btrfs /dev/mapper/unavailable rw
|
||||||
|
2 0 0:40 /@/docker/hosts /etc/hosts ro - btrfs /dev/mapper/unavailable rw
|
||||||
|
3 0 0:40 /@/docker/hostname /etc/hostname ro - btrfs /dev/mapper/unavailable rw
|
||||||
|
4 0 0:41 /subvol /extra-filesystems/my\040disk ro master:2 - btrfs /dev/missing rw
|
||||||
|
5 0 0:42 / /ext4 ro - ext4 /dev/mapper/unavailable rw
|
||||||
|
malformed
|
||||||
|
6 0 0:43 / /bad ro - btrfs
|
||||||
|
`
|
||||||
|
var calls []string
|
||||||
|
mounts := mountpointsByUUID(info, func(path string) string {
|
||||||
|
calls = append(calls, path)
|
||||||
|
switch path {
|
||||||
|
case "/etc/hosts":
|
||||||
|
return "root-uuid"
|
||||||
|
case "/extra-filesystems/my disk":
|
||||||
|
return "extra-uuid"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
assert.Equal(t, map[string]string{"uuid:root-uuid": "/etc/hosts", "uuid:extra-uuid": "/extra-filesystems/my disk"}, mounts)
|
||||||
|
assert.Equal(t, []string{"/inaccessible", "/etc/hosts", "/extra-filesystems/my disk"}, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerFilesystemWithoutDeviceNodes(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
oldSysfs, oldMounts, oldInfo, oldUUID, oldUsage := sysfsPath, mountsPath, mountinfoPath, mountUUID, filesystemUsage
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sysfsPath, mountsPath, mountinfoPath, mountUUID, filesystemUsage = oldSysfs, oldMounts, oldInfo, oldUUID, oldUsage
|
||||||
|
})
|
||||||
|
sysfsPath = filepath.Join(root, "sysfs")
|
||||||
|
mountsPath = filepath.Join(root, "missing-mounts")
|
||||||
|
mountinfoPath = filepath.Join(root, "mountinfo")
|
||||||
|
uuid := "11111111-1111-4111-8111-111111111111"
|
||||||
|
dir := filepath.Join(sysfsPath, uuid)
|
||||||
|
for path, content := range map[string]string{"devices/dm-0/size": "1000", "devinfo/1/missing": "0"} {
|
||||||
|
target := filepath.Join(dir, path)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0755))
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte(content), 0644))
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(mountinfoPath, []byte("2 1 0:40 /@/docker/hosts /etc/hosts ro - btrfs /dev/mapper/not-in-container rw\n"), 0644))
|
||||||
|
mountUUID = func(path string) string {
|
||||||
|
if path == "/etc/hosts" {
|
||||||
|
return uuid
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
filesystemUsage = func(path string) (uint64, uint64, error) { require.Equal(t, "/etc/hosts", path); return 100, 900, nil }
|
||||||
|
fs, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, fs, 1)
|
||||||
|
assert.Equal(t, uuid, fs[0].MountID)
|
||||||
|
assert.Equal(t, "dm-0", fs[0].IODevice)
|
||||||
|
assert.False(t, fs[0].Raw)
|
||||||
|
assert.Equal(t, uint64(1000), fs[0].Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLivePoolMountIdentity(t *testing.T) {
|
||||||
|
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for live validation")
|
||||||
|
}
|
||||||
|
id := MountID(path)
|
||||||
|
require.NotEmpty(t, id)
|
||||||
|
pools, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, pool := range pools {
|
||||||
|
if pool.UUID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Equal(t, id, pool.MountID)
|
||||||
|
assert.False(t, pool.Raw)
|
||||||
|
t.Logf("uuid=%s mount_identity=%s io_device=%s raw=%v", pool.UUID, pool.MountID, pool.IODevice, pool.Raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("mounted Btrfs filesystem was not discovered")
|
||||||
|
}
|
||||||
11
agent/btrfs/btrfs_unsupported.go
Normal file
11
agent/btrfs/btrfs_unsupported.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package btrfs
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func Filesystems() ([]Filesystem, error) {
|
||||||
|
return nil, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
func MountID(string) string { return "" }
|
||||||
@@ -18,11 +18,11 @@ import (
|
|||||||
// fsRegistrationContext holds the shared lookup state needed to resolve a
|
// fsRegistrationContext holds the shared lookup state needed to resolve a
|
||||||
// filesystem into the tracked fsStats key and metadata.
|
// filesystem into the tracked fsStats key and metadata.
|
||||||
type fsRegistrationContext struct {
|
type fsRegistrationContext struct {
|
||||||
filesystem string // device part of optional FILESYSTEM env var
|
filesystem string // device part of optional FILESYSTEM env var
|
||||||
filesystemName string // optional custom name from FILESYSTEM=device__name
|
filesystemName string // optional custom name from FILESYSTEM=device__name
|
||||||
isWindows bool
|
isWindows bool
|
||||||
efPath string // path to extra filesystems (default "/extra-filesystems")
|
efPath string // path to extra filesystems (default "/extra-filesystems")
|
||||||
diskIoCounters map[string]disk.IOCountersStat
|
diskIoCounters map[string]disk.IOCountersStat
|
||||||
}
|
}
|
||||||
|
|
||||||
// diskDiscovery groups the transient state for a single initializeDiskInfo run so
|
// diskDiscovery groups the transient state for a single initializeDiskInfo run so
|
||||||
@@ -325,11 +325,11 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
}
|
}
|
||||||
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
||||||
ctx := fsRegistrationContext{
|
ctx := fsRegistrationContext{
|
||||||
filesystem: filesystem,
|
filesystem: filesystem,
|
||||||
filesystemName: filesystemName,
|
filesystemName: filesystemName,
|
||||||
isWindows: isWindows,
|
isWindows: isWindows,
|
||||||
diskIoCounters: diskIoCounters,
|
diskIoCounters: diskIoCounters,
|
||||||
efPath: "/extra-filesystems",
|
efPath: "/extra-filesystems",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the appropriate root mount point for this system
|
// Get the appropriate root mount point for this system
|
||||||
@@ -540,8 +540,8 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
|
|||||||
// ZFS datasets have no /proc/diskstats entry, so they are excluded from
|
// ZFS datasets have no /proc/diskstats entry, so they are excluded from
|
||||||
// I/O tracking instead of warning about a missing device (#1541).
|
// I/O tracking instead of warning about a missing device (#1541).
|
||||||
var zfsMountpoints map[string]bool
|
var zfsMountpoints map[string]bool
|
||||||
if a.zfsManager != nil {
|
if a.storagePoolManager != nil {
|
||||||
zfsMountpoints = a.zfsManager.ZfsMountpoints()
|
zfsMountpoints = a.storagePoolManager.ZfsMountpoints()
|
||||||
}
|
}
|
||||||
for device, stats := range a.fsStats {
|
for device, stats := range a.fsStats {
|
||||||
if zfsMountpoints[stats.Mountpoint] {
|
if zfsMountpoints[stats.Mountpoint] {
|
||||||
@@ -574,8 +574,8 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
|||||||
// ZFS dataset mountpoints use `zfs list` values because statfs(2) reports
|
// ZFS dataset mountpoints use `zfs list` values because statfs(2) reports
|
||||||
// dataset-level usage that excludes child datasets (#1541).
|
// dataset-level usage that excludes child datasets (#1541).
|
||||||
var zfsUsage map[string]zfsDatasetUsage
|
var zfsUsage map[string]zfsDatasetUsage
|
||||||
if a.zfsManager != nil {
|
if a.storagePoolManager != nil {
|
||||||
zfsUsage = a.zfsManager.DatasetUsage()
|
zfsUsage = a.storagePoolManager.DatasetUsage()
|
||||||
}
|
}
|
||||||
|
|
||||||
// disk usage
|
// disk usage
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/zfs"
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
@@ -16,8 +17,8 @@ import (
|
|||||||
// is a ZFS dataset reports `zfs list` usage (which includes child datasets)
|
// is a ZFS dataset reports `zfs list` usage (which includes child datasets)
|
||||||
// instead of the dataset-scoped statfs values (#1541).
|
// instead of the dataset-scoped statfs values (#1541).
|
||||||
func TestUpdateDiskUsageZfsMountpoint(t *testing.T) {
|
func TestUpdateDiskUsageZfsMountpoint(t *testing.T) {
|
||||||
zm := &ZfsManager{}
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
zm.datasetsFn = func() ([]zfs.Dataset, error) {
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
return []zfs.Dataset{
|
return []zfs.Dataset{
|
||||||
{Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
|
{Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -26,7 +27,7 @@ func TestUpdateDiskUsageZfsMountpoint(t *testing.T) {
|
|||||||
fsStats: map[string]*system.FsStats{
|
fsStats: map[string]*system.FsStats{
|
||||||
"tank": {Root: false, Mountpoint: "/tank"},
|
"tank": {Root: false, Mountpoint: "/tank"},
|
||||||
},
|
},
|
||||||
zfsManager: zm,
|
storagePoolManager: zm,
|
||||||
}
|
}
|
||||||
|
|
||||||
var stats system.Stats
|
var stats system.Stats
|
||||||
@@ -43,8 +44,8 @@ func TestUpdateDiskUsageZfsMountpoint(t *testing.T) {
|
|||||||
// TestUpdateDiskUsageZfsRootPopulatesSystemStats verifies the root disk values
|
// TestUpdateDiskUsageZfsRootPopulatesSystemStats verifies the root disk values
|
||||||
// are derived from ZFS usage when the root mountpoint is a ZFS dataset.
|
// are derived from ZFS usage when the root mountpoint is a ZFS dataset.
|
||||||
func TestUpdateDiskUsageZfsRootPopulatesSystemStats(t *testing.T) {
|
func TestUpdateDiskUsageZfsRootPopulatesSystemStats(t *testing.T) {
|
||||||
zm := &ZfsManager{}
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
zm.datasetsFn = func() ([]zfs.Dataset, error) {
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
return []zfs.Dataset{
|
return []zfs.Dataset{
|
||||||
{Name: "rpool/ROOT/pve-1", Used: 900000000000, Avail: 300000000000, Mountpoint: "/"},
|
{Name: "rpool/ROOT/pve-1", Used: 900000000000, Avail: 300000000000, Mountpoint: "/"},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -53,7 +54,7 @@ func TestUpdateDiskUsageZfsRootPopulatesSystemStats(t *testing.T) {
|
|||||||
fsStats: map[string]*system.FsStats{
|
fsStats: map[string]*system.FsStats{
|
||||||
"rpool/ROOT/pve-1": {Root: true, Mountpoint: "/"},
|
"rpool/ROOT/pve-1": {Root: true, Mountpoint: "/"},
|
||||||
},
|
},
|
||||||
zfsManager: zm,
|
storagePoolManager: zm,
|
||||||
}
|
}
|
||||||
|
|
||||||
var stats system.Stats
|
var stats system.Stats
|
||||||
@@ -85,8 +86,8 @@ func TestUpdateDiskUsageWithoutZfsManager(t *testing.T) {
|
|||||||
// TestInitializeDiskIoStatsSkipsZfsMountpoints verifies ZFS filesystems are
|
// TestInitializeDiskIoStatsSkipsZfsMountpoints verifies ZFS filesystems are
|
||||||
// excluded from diskstats I/O tracking instead of warning about a missing device.
|
// excluded from diskstats I/O tracking instead of warning about a missing device.
|
||||||
func TestInitializeDiskIoStatsSkipsZfsMountpoints(t *testing.T) {
|
func TestInitializeDiskIoStatsSkipsZfsMountpoints(t *testing.T) {
|
||||||
zm := &ZfsManager{}
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
zm.datasetsFn = func() ([]zfs.Dataset, error) {
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
return []zfs.Dataset{{Name: "tank", Mountpoint: "/tank"}}, nil
|
return []zfs.Dataset{{Name: "tank", Mountpoint: "/tank"}}, nil
|
||||||
}
|
}
|
||||||
agent := &Agent{
|
agent := &Agent{
|
||||||
@@ -94,8 +95,8 @@ func TestInitializeDiskIoStatsSkipsZfsMountpoints(t *testing.T) {
|
|||||||
"tank": {Root: false, Mountpoint: "/tank"},
|
"tank": {Root: false, Mountpoint: "/tank"},
|
||||||
"sda1": {Root: false, Mountpoint: "/mnt/data"},
|
"sda1": {Root: false, Mountpoint: "/mnt/data"},
|
||||||
},
|
},
|
||||||
zfsManager: zm,
|
storagePoolManager: zm,
|
||||||
diskPrev: make(map[uint16]map[string]prevDisk),
|
diskPrev: make(map[uint16]map[string]prevDisk),
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
||||||
|
|||||||
@@ -186,14 +186,14 @@ func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
|||||||
type GetZfsDataHandler struct{}
|
type GetZfsDataHandler struct{}
|
||||||
|
|
||||||
func (h *GetZfsDataHandler) Handle(hctx *HandlerContext) error {
|
func (h *GetZfsDataHandler) Handle(hctx *HandlerContext) error {
|
||||||
if hctx.Agent.zfsManager == nil {
|
if hctx.Agent.storagePoolManager == nil {
|
||||||
return hctx.SendResponse(nil, hctx.RequestID)
|
return hctx.SendResponse(nil, hctx.RequestID)
|
||||||
}
|
}
|
||||||
var req common.ZfsDataRequest
|
var req common.ZfsDataRequest
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return hctx.SendResponse(hctx.Agent.zfsManager.GetDetail(req.Force), hctx.RequestID)
|
return hctx.SendResponse(hctx.Agent.storagePoolManager.GetDetail(req.Force), hctx.RequestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|||||||
@@ -34,19 +34,19 @@ func TestNewAgentResponseSmartData(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetZfsDataHandlerForceRefresh(t *testing.T) {
|
func TestGetZfsDataHandlerForceRefresh(t *testing.T) {
|
||||||
poolCalls := 0
|
poolCalls := 0
|
||||||
zm := &ZfsManager{detailInterval: time.Hour}
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
poolCalls++
|
poolCalls++
|
||||||
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
|
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
|
||||||
}
|
}
|
||||||
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
||||||
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
||||||
zm.GetDetail(false)
|
zm.GetDetail(false)
|
||||||
|
|
||||||
requestData, err := cbor.Marshal(common.ZfsDataRequest{Force: true})
|
requestData, err := cbor.Marshal(common.ZfsDataRequest{Force: true})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
ctx := &HandlerContext{
|
ctx := &HandlerContext{
|
||||||
Agent: &Agent{zfsManager: zm},
|
Agent: &Agent{storagePoolManager: zm},
|
||||||
Request: &common.HubRequest[cbor.RawMessage]{
|
Request: &common.HubRequest[cbor.RawMessage]{
|
||||||
Action: common.GetZfsData,
|
Action: common.GetZfsData,
|
||||||
Data: requestData,
|
Data: requestData,
|
||||||
|
|||||||
462
agent/storage_pool.go
Normal file
462
agent/storage_pool.go
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/btrfs"
|
||||||
|
"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
|
||||||
|
|
||||||
|
// btrfsFilesystems is the btrfs source; overridable in tests.
|
||||||
|
var btrfsFilesystems = btrfs.Filesystems
|
||||||
|
|
||||||
|
type poolKernelSample struct {
|
||||||
|
nread uint64
|
||||||
|
nwrite uint64
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoragePoolManager combines independent backend inventories. Metrics and
|
||||||
|
// dataset usage require the agent lock; GetDetail is safe for concurrent calls.
|
||||||
|
type StoragePoolManager struct {
|
||||||
|
backends []*poolBackend
|
||||||
|
detailInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// poolBackend owns one backend's collectors and caches. Collector functions
|
||||||
|
// are immutable after construction and may run concurrently for metrics/details.
|
||||||
|
type poolBackend struct {
|
||||||
|
name string
|
||||||
|
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
|
||||||
|
detailFailed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStoragePoolManager() *StoragePoolManager {
|
||||||
|
return &StoragePoolManager{
|
||||||
|
backends: []*poolBackend{newZfsBackend(), newBtrfsBackend()},
|
||||||
|
detailInterval: time.Hour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newZfsBackend() *poolBackend {
|
||||||
|
return &poolBackend{
|
||||||
|
name: "zfs",
|
||||||
|
poolStatsFn: optionalPoolSource(zfs.PoolStats),
|
||||||
|
datasetsFn: zfs.Datasets,
|
||||||
|
kernelStatsFn: optionalPoolSource(zfs.PoolKernelStats),
|
||||||
|
poolStatusesFn: optionalPoolSource(zfs.PoolStatuses),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBtrfsBackend() *poolBackend {
|
||||||
|
return &poolBackend{
|
||||||
|
name: "btrfs",
|
||||||
|
poolStatsFn: btrfsSource(btrfsPoolStats),
|
||||||
|
kernelStatsFn: btrfsSource(btrfsKernelStats),
|
||||||
|
poolStatusesFn: btrfsSource(btrfsPoolStatuses),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// datasets is optional: only backends that expose datasets provide a collector.
|
||||||
|
func (b *poolBackend) datasets() ([]zfs.Dataset, error) {
|
||||||
|
if b.datasetsFn == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return b.datasetsFn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing utility/interface is a successfully observed absent backend.
|
||||||
|
func optionalPoolSource[T any](source func() ([]T, error)) func() ([]T, error) {
|
||||||
|
return func() ([]T, error) {
|
||||||
|
items, err := source()
|
||||||
|
if errors.Is(err, zfs.ErrNoZfs) || errors.Is(err, exec.ErrNotFound) || errors.Is(err, errors.ErrUnsupported) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func btrfsSource[T any](convert func(btrfs.Filesystem) T) func() ([]T, error) {
|
||||||
|
return func() ([]T, error) {
|
||||||
|
filesystems, err := optionalPoolSource(btrfsFilesystems)()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]T, 0, len(filesystems))
|
||||||
|
for _, fs := range filesystems {
|
||||||
|
items = append(items, convert(fs))
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. The
|
||||||
|
// pool map is empty when both backends are absent.
|
||||||
|
func (m *StoragePoolManager) Update(systemStats *system.Stats) {
|
||||||
|
// Rebuild the combined map so successful pool removals clear old samples.
|
||||||
|
systemStats.ZfsPools = nil
|
||||||
|
for _, backend := range m.backends {
|
||||||
|
backend.updateBackendStats(systemStats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *poolBackend) updateBackendStats(systemStats *system.Stats) {
|
||||||
|
pools := b.poolStats()
|
||||||
|
if len(pools) == 0 {
|
||||||
|
b.kernelSamples = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
kernelStats, ioRates := b.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{
|
||||||
|
DisplayName: pool.DisplayName,
|
||||||
|
Raw: pool.Raw,
|
||||||
|
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("Storage pool sample", "backend", b.name, "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, calling its collector at most
|
||||||
|
// every poolStatsRefreshInterval. On failure the previous inventory is
|
||||||
|
// retained and the refresh is retried on the next cadence.
|
||||||
|
func (b *poolBackend) poolStats() []zfs.PoolStat {
|
||||||
|
if b.lastPoolStats.IsZero() || time.Since(b.lastPoolStats) >= poolStatsRefreshInterval {
|
||||||
|
pools, err := b.poolStatsFn()
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Storage pool stats unavailable", "backend", b.name, "err", err)
|
||||||
|
} else {
|
||||||
|
b.poolData = pools
|
||||||
|
}
|
||||||
|
b.lastPoolStats = time.Now()
|
||||||
|
}
|
||||||
|
return b.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 (b *poolBackend) kernelStats() (map[string]zfs.PoolKernelStat, map[string]zfs.PoolIoStats) {
|
||||||
|
if b.kernelStatsFn == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
stats, err := b.kernelStatsFn()
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Storage pool kernel stats unavailable", "backend", b.name, "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 := b.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}
|
||||||
|
}
|
||||||
|
b.kernelSamples = nextSamples
|
||||||
|
return byName, rates
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
|
||||||
|
// and rebuilds the mountpoint-keyed usage map.
|
||||||
|
func (b *poolBackend) refreshDatasetUsage() {
|
||||||
|
if !b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
datasets, err := b.datasets()
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Storage pool dataset usage unavailable", "backend", b.name, "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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.datasetUsage = usage
|
||||||
|
}
|
||||||
|
b.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 (m *StoragePoolManager) DatasetUsage() map[string]zfsDatasetUsage {
|
||||||
|
for _, backend := range m.backends {
|
||||||
|
if backend.name == "zfs" {
|
||||||
|
backend.refreshDatasetUsage()
|
||||||
|
return backend.datasetUsage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDetail combines backend snapshots, identifying successful inventories so
|
||||||
|
// the hub can accept partial updates without deleting failed backend records.
|
||||||
|
func (m *StoragePoolManager) GetDetail(force bool) *zfsentity.ZfsData {
|
||||||
|
data := &zfsentity.ZfsData{Complete: true}
|
||||||
|
for _, backend := range m.backends {
|
||||||
|
snapshot := backend.getBackendDetail(force, m.detailInterval)
|
||||||
|
data.Pools = append(data.Pools, snapshot.Pools...)
|
||||||
|
if snapshot.Complete {
|
||||||
|
data.CompleteBackends = append(data.CompleteBackends, backend.name)
|
||||||
|
} else {
|
||||||
|
data.Complete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *poolBackend) getBackendDetail(force bool, interval time.Duration) *zfsentity.ZfsData {
|
||||||
|
b.detailMu.Lock()
|
||||||
|
defer b.detailMu.Unlock()
|
||||||
|
|
||||||
|
if force || b.detailFailed || b.detail == nil || time.Since(b.lastDetailRefresh) >= interval {
|
||||||
|
if data, err := b.collectDetail(b.detail); err != nil {
|
||||||
|
b.detailFailed = true
|
||||||
|
slog.Debug("Storage pool detail collection failed", "backend", b.name, "err", err)
|
||||||
|
if b.detail == nil {
|
||||||
|
return &zfsentity.ZfsData{}
|
||||||
|
}
|
||||||
|
return &zfsentity.ZfsData{Pools: b.detail.Pools}
|
||||||
|
} else {
|
||||||
|
b.detailFailed = false
|
||||||
|
b.detail = data
|
||||||
|
b.lastDetailRefresh = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.detail == nil {
|
||||||
|
return &zfsentity.ZfsData{}
|
||||||
|
}
|
||||||
|
return b.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectDetail builds a ZfsData payload from the current system state.
|
||||||
|
func (b *poolBackend) collectDetail(previous *zfsentity.ZfsData) (*zfsentity.ZfsData, error) {
|
||||||
|
pools, err := b.poolStatsFn()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(pools) == 0 {
|
||||||
|
return &zfsentity.ZfsData{Pools: []*zfsentity.PoolDetail{}, Complete: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses, statusErr := b.poolStatusesFn()
|
||||||
|
if statusErr != nil {
|
||||||
|
slog.Debug("Storage pool status unavailable", "backend", b.name, "err", statusErr)
|
||||||
|
}
|
||||||
|
datasets, datasetsErr := b.datasets()
|
||||||
|
if datasetsErr != nil {
|
||||||
|
slog.Debug("Storage pool datasets unavailable", "backend", b.name, "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{
|
||||||
|
DisplayName: p.DisplayName,
|
||||||
|
Raw: p.Raw,
|
||||||
|
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 (m *StoragePoolManager) ZfsMountpoints() map[string]bool {
|
||||||
|
usage := m.DatasetUsage()
|
||||||
|
mountpoints := make(map[string]bool, len(usage))
|
||||||
|
for mountpoint := range usage {
|
||||||
|
mountpoints[mountpoint] = true
|
||||||
|
}
|
||||||
|
return mountpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
func btrfsPoolStats(fs btrfs.Filesystem) zfs.PoolStat {
|
||||||
|
return zfs.PoolStat{MountID: fs.MountID, IODevice: fs.IODevice, Raw: fs.Raw, DisplayName: fs.Name, Name: "b:" + fs.UUID, Size: fs.Size, Alloc: fs.Alloc, Free: fs.Size - min(fs.Alloc, fs.Size), Health: fs.Health}
|
||||||
|
}
|
||||||
|
|
||||||
|
func btrfsKernelStats(fs btrfs.Filesystem) zfs.PoolKernelStat {
|
||||||
|
return zfs.PoolKernelStat{Name: "b:" + fs.UUID, Health: fs.Health, NRead: fs.NRead, NWrite: fs.NWrite}
|
||||||
|
}
|
||||||
|
|
||||||
|
func btrfsPoolStatuses(fs btrfs.Filesystem) zfs.PoolStatus {
|
||||||
|
status := zfs.PoolStatus{Name: "b:" + fs.UUID, State: fs.Health, Scrub: zfs.ScrubStatus{State: "NONE"}}
|
||||||
|
for _, dev := range fs.Devices {
|
||||||
|
status.Vdevs = append(status.Vdevs, zfs.VdevStatus{
|
||||||
|
Name: dev.Name, State: dev.State,
|
||||||
|
ReadErrs: dev.ReadErrs, WriteErrs: dev.WriteErrs, ChecksumErrs: dev.CorruptionErrs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// markDuplicateCharts leaves pool telemetry and detail intact, but tells the
|
||||||
|
// hub which charts already have a filesystem equivalent. Only exact kernel
|
||||||
|
// filesystem and I/O-device matches qualify; labels are never used.
|
||||||
|
func (m *StoragePoolManager) markDuplicateCharts(stats *system.Stats, filesystems map[string]*system.FsStats, mountID func(string) string) {
|
||||||
|
identities := make(map[string]string, len(filesystems))
|
||||||
|
for device, fs := range filesystems {
|
||||||
|
if fs.DiskTotal > 0 {
|
||||||
|
identities[device] = mountID(fs.Mountpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, backend := range m.backends {
|
||||||
|
for _, pool := range backend.poolData {
|
||||||
|
sample := stats.ZfsPools[pool.Name]
|
||||||
|
if sample == nil || pool.MountID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for device, identity := range identities {
|
||||||
|
if identity != pool.MountID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Raw physical usage is not equivalent to a filesystem usage chart.
|
||||||
|
sample.HideUsage = !pool.Raw
|
||||||
|
if pool.IODevice != "" && pool.IODevice == device {
|
||||||
|
sample.HideIO = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
505
agent/storage_pool_test.go
Normal file
505
agent/storage_pool_test.go
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/btrfs"
|
||||||
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOptionalPoolSource(t *testing.T) {
|
||||||
|
failure := errors.New("timeout")
|
||||||
|
for _, err := range []error{nil, zfs.ErrNoZfs, fmt.Errorf("zpool: %w", exec.ErrNotFound), errors.ErrUnsupported, failure} {
|
||||||
|
_, got := optionalPoolSource(func() ([]zfs.PoolStat, error) { return nil, err })()
|
||||||
|
if err == failure {
|
||||||
|
assert.ErrorIs(t, got, failure)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type poolTestBackend struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
alloc uint64
|
||||||
|
read uint64
|
||||||
|
empty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *poolTestBackend) backend() *poolBackend {
|
||||||
|
name := "zfs"
|
||||||
|
if strings.HasPrefix(state.name, "b:") {
|
||||||
|
name = "btrfs"
|
||||||
|
}
|
||||||
|
return &poolBackend{
|
||||||
|
name: name,
|
||||||
|
poolStatsFn: func() ([]zfs.PoolStat, error) {
|
||||||
|
if state.empty {
|
||||||
|
return nil, state.err
|
||||||
|
}
|
||||||
|
return []zfs.PoolStat{{Name: state.name, Size: 100, Alloc: state.alloc}}, state.err
|
||||||
|
},
|
||||||
|
kernelStatsFn: func() ([]zfs.PoolKernelStat, error) {
|
||||||
|
return []zfs.PoolKernelStat{{Name: state.name, NRead: state.read}}, state.err
|
||||||
|
},
|
||||||
|
poolStatusesFn: func() ([]zfs.PoolStatus, error) { return nil, nil },
|
||||||
|
datasetsFn: func() ([]zfs.Dataset, error) { return nil, nil },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndependentPoolBackendCaches(t *testing.T) {
|
||||||
|
for _, failed := range []int{0, 1} {
|
||||||
|
t.Run([]string{"zfs", "btrfs"}[failed], func(t *testing.T) {
|
||||||
|
states := []*poolTestBackend{{name: "tank", alloc: 10}, {name: "b:uuid", alloc: 10}}
|
||||||
|
managers := []*poolBackend{states[0].backend(), states[1].backend()}
|
||||||
|
zm := &StoragePoolManager{backends: managers, detailInterval: time.Hour}
|
||||||
|
var stats system.Stats
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.Len(t, stats.ZfsPools, 2)
|
||||||
|
require.True(t, zm.GetDetail(true).Complete)
|
||||||
|
baseline := poolKernelSample{at: time.Now().Add(-time.Second)}
|
||||||
|
for i, m := range managers {
|
||||||
|
m.lastPoolStats = time.Time{}
|
||||||
|
m.kernelSamples[states[i].name] = baseline
|
||||||
|
states[i].alloc = 20
|
||||||
|
states[i].read = 100
|
||||||
|
}
|
||||||
|
states[failed].err = errors.New("collection failed")
|
||||||
|
zm.Update(&stats)
|
||||||
|
healthy := 1 - failed
|
||||||
|
assert.Equal(t, uint64(10), managers[failed].poolData[0].Alloc)
|
||||||
|
assert.Equal(t, uint64(20), managers[healthy].poolData[0].Alloc)
|
||||||
|
assert.Equal(t, baseline, managers[failed].kernelSamples[states[failed].name])
|
||||||
|
assert.Zero(t, stats.ZfsPools[states[failed].name].ReadBytes)
|
||||||
|
assert.Positive(t, stats.ZfsPools[states[healthy].name].ReadBytes)
|
||||||
|
partial := zm.GetDetail(true)
|
||||||
|
assert.False(t, partial.Complete)
|
||||||
|
assert.False(t, partial.CanRefreshPool(states[failed].name))
|
||||||
|
assert.True(t, partial.CanRefreshPool(states[healthy].name))
|
||||||
|
assert.Equal(t, uint64(10), partial.Pools[failed].Alloc)
|
||||||
|
assert.Equal(t, uint64(20), partial.Pools[healthy].Alloc)
|
||||||
|
assert.False(t, zm.GetDetail(false).Complete, "a failed forced refresh must not become complete from cache")
|
||||||
|
|
||||||
|
// Successful empty inventory removes only the healthy backend's pool.
|
||||||
|
states[healthy].empty = true
|
||||||
|
managers[healthy].lastPoolStats = time.Time{}
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.Len(t, stats.ZfsPools, 1)
|
||||||
|
assert.Contains(t, stats.ZfsPools, states[failed].name)
|
||||||
|
partial = zm.GetDetail(true)
|
||||||
|
require.Len(t, partial.Pools, 1)
|
||||||
|
assert.True(t, partial.CanRefreshPool(states[healthy].name))
|
||||||
|
|
||||||
|
// Recovery uses the retained I/O baseline, then normal removal works.
|
||||||
|
states[failed].err = nil
|
||||||
|
managers[failed].lastPoolStats = time.Time{}
|
||||||
|
zm.Update(&stats)
|
||||||
|
assert.Positive(t, stats.ZfsPools[states[failed].name].ReadBytes)
|
||||||
|
assert.True(t, zm.GetDetail(true).Complete)
|
||||||
|
states[failed].empty = true
|
||||||
|
managers[failed].lastPoolStats = time.Time{}
|
||||||
|
zm.Update(&stats)
|
||||||
|
assert.Empty(t, stats.ZfsPools)
|
||||||
|
assert.Empty(t, zm.GetDetail(true).Pools)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndependentBackendsWithoutCache(t *testing.T) {
|
||||||
|
z := &poolTestBackend{name: "tank", err: errors.New("ZFS failure")}
|
||||||
|
b := &poolTestBackend{name: "b:uuid", alloc: 20}
|
||||||
|
zm := &StoragePoolManager{backends: []*poolBackend{z.backend(), b.backend()}, detailInterval: time.Hour}
|
||||||
|
var stats system.Stats
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.Len(t, stats.ZfsPools, 1)
|
||||||
|
assert.Contains(t, stats.ZfsPools, "b:uuid")
|
||||||
|
detail := zm.GetDetail(true)
|
||||||
|
require.Len(t, detail.Pools, 1)
|
||||||
|
assert.False(t, detail.Complete)
|
||||||
|
assert.Equal(t, []string{"btrfs"}, detail.CompleteBackends)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentBackendDetailsAndMetrics(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{backends: []*poolBackend{(&poolTestBackend{name: "tank"}).backend(), (&poolTestBackend{name: "b:uuid"}).backend()}, detailInterval: time.Hour}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(metrics bool) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < 10; j++ {
|
||||||
|
if metrics {
|
||||||
|
zm.Update(&system.Stats{})
|
||||||
|
} else {
|
||||||
|
zm.GetDetail(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(i == 0)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoragePoolBackendOrder(t *testing.T) {
|
||||||
|
z := (&poolTestBackend{name: "tank"}).backend()
|
||||||
|
b := (&poolTestBackend{name: "b:uuid"}).backend()
|
||||||
|
b.datasetsFn = nil // Btrfs does not expose datasets.
|
||||||
|
z.datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return []zfs.Dataset{{Name: "tank/data", Mountpoint: "/tank", Used: 10}}, nil
|
||||||
|
}
|
||||||
|
m := &StoragePoolManager{backends: []*poolBackend{b, z}, detailInterval: time.Hour}
|
||||||
|
var stats system.Stats
|
||||||
|
m.Update(&stats)
|
||||||
|
require.Len(t, stats.ZfsPools, 2)
|
||||||
|
detail := m.GetDetail(true)
|
||||||
|
require.True(t, detail.Complete)
|
||||||
|
assert.Equal(t, []string{"btrfs", "zfs"}, detail.CompleteBackends)
|
||||||
|
assert.Empty(t, detail.Pools[0].Datasets)
|
||||||
|
assert.Len(t, detail.Pools[1].Datasets, 1)
|
||||||
|
assert.Equal(t, uint64(10), m.DatasetUsage()["/tank"].used)
|
||||||
|
|
||||||
|
b.poolData[0].MountID = "uuid"
|
||||||
|
b.poolData[0].IODevice = "sda"
|
||||||
|
calls := 0
|
||||||
|
m.markDuplicateCharts(&stats, map[string]*system.FsStats{
|
||||||
|
"sda": {Mountpoint: "/", DiskTotal: 100},
|
||||||
|
}, func(string) string { calls++; return "uuid" })
|
||||||
|
assert.Equal(t, 1, calls, "resolve each filesystem once across all backends")
|
||||||
|
assert.True(t, stats.ZfsPools["b:uuid"].HideUsage)
|
||||||
|
assert.True(t, stats.ZfsPools["b:uuid"].HideIO)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePopulatesZfsPools(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
return []zfs.PoolStat{{Name: "tank", Size: 23999000000000, Alloc: 12000000000000, Free: 11999000000000, Health: "DEGRADED"}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].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.backends[0].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.backends[0].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 := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
return []zfs.PoolStat{{Name: "tank", Size: 1, Alloc: 1, Health: "ONLINE"}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
||||||
|
zm.backends[0].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 := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
return []zfs.PoolStat{{Name: "tank", Health: "ONLINE"}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
||||||
|
zm.backends[0].kernelSamples = map[string]poolKernelSample{
|
||||||
|
"tank": {nread: 100, nwrite: 200, at: time.Now().Add(-time.Second)},
|
||||||
|
}
|
||||||
|
zm.backends[0].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 := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
calls := 0
|
||||||
|
zm.backends[0].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 := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
calls := 0
|
||||||
|
zm.backends[0].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 := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
calls := 0
|
||||||
|
zm.backends[0].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 := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].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.backends[0].lastUsageRefresh = time.Now().Add(-10 * time.Minute)
|
||||||
|
zm.backends[0].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 := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
poolCalls := 0
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
poolCalls++
|
||||||
|
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
||||||
|
zm.backends[0].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 := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
return []zfs.PoolStat{{Name: "tank"}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
||||||
|
|
||||||
|
require.Len(t, zm.GetDetail(false).Pools, 1)
|
||||||
|
zm.backends[0].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 := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
return []zfs.PoolStat{{Name: "tank"}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) {
|
||||||
|
return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "mirror-0"}}}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].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.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, zfs.ErrNoZfs }
|
||||||
|
zm.backends[0].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.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, zfs.ErrNoZfs }
|
||||||
|
lastSuccessfulRefresh := zm.backends[0].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.backends[0].lastDetailRefresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZfsMountpoints(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].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["/"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBtrfsRawCapacityPropagates(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs", poolStatsFn: func() ([]zfs.PoolStat, error) {
|
||||||
|
return []zfs.PoolStat{btrfsPoolStats(btrfs.Filesystem{UUID: "raw", Name: "raw", Size: 200, Alloc: 100, Raw: true})}, nil
|
||||||
|
},
|
||||||
|
poolStatusesFn: func() ([]zfs.PoolStatus, error) { return nil, nil },
|
||||||
|
datasetsFn: func() ([]zfs.Dataset, error) { return nil, nil }}}}
|
||||||
|
var stats system.Stats
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.True(t, stats.ZfsPools["b:raw"].Raw)
|
||||||
|
detail := zm.GetDetail(true)
|
||||||
|
require.True(t, detail.Complete)
|
||||||
|
require.Len(t, detail.Pools, 1)
|
||||||
|
assert.True(t, detail.Pools[0].Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkDuplicatePoolCharts(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, poolID, device string
|
||||||
|
raw bool
|
||||||
|
diskTotal float64
|
||||||
|
wantUsage, wantIO bool
|
||||||
|
}{
|
||||||
|
{"single device root", "fs1", "dm-0", false, 100, true, true},
|
||||||
|
{"multi device", "fs1", "", false, 100, true, false},
|
||||||
|
{"different IO device", "fs1", "nvme0n1", false, 100, true, false},
|
||||||
|
{"different filesystem", "fs2", "dm-0", false, 100, false, false},
|
||||||
|
{"unknown identity", "", "dm-0", false, 100, false, false},
|
||||||
|
{"raw usage", "fs1", "dm-0", true, 100, false, true},
|
||||||
|
{"failed disk collection", "fs1", "dm-0", false, 0, false, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs", poolData: []zfs.PoolStat{{Name: "arbitrary label", MountID: tc.poolID, IODevice: tc.device, Raw: tc.raw}}}}}
|
||||||
|
stats := &system.Stats{ZfsPools: map[string]*system.ZfsPool{"arbitrary label": {}}}
|
||||||
|
fs := map[string]*system.FsStats{"dm-0": {Root: true, Mountpoint: "/", DiskTotal: tc.diskTotal}}
|
||||||
|
zm.markDuplicateCharts(stats, fs, func(string) string { return "fs1" })
|
||||||
|
assert.Equal(t, tc.wantUsage, stats.ZfsPools["arbitrary label"].HideUsage)
|
||||||
|
assert.Equal(t, tc.wantIO, stats.ZfsPools["arbitrary label"].HideIO)
|
||||||
|
// Bind mounts and custom extra-filesystem names have the same identity.
|
||||||
|
fs["dm-0"].Root = false
|
||||||
|
fs["dm-0"].Mountpoint = "/extra-filesystems/storage"
|
||||||
|
fs["dm-0"].Name = "custom name"
|
||||||
|
stats.ZfsPools["arbitrary label"] = &system.ZfsPool{}
|
||||||
|
zm.markDuplicateCharts(stats, fs, func(string) string { return "fs1" })
|
||||||
|
assert.Equal(t, tc.wantUsage, stats.ZfsPools["arbitrary label"].HideUsage)
|
||||||
|
assert.Equal(t, tc.wantIO, stats.ZfsPools["arbitrary label"].HideIO)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBtrfsPoolIdentities(t *testing.T) {
|
||||||
|
old := btrfsFilesystems
|
||||||
|
t.Cleanup(func() { btrfsFilesystems = old })
|
||||||
|
label := "tank"
|
||||||
|
btrfsFilesystems = func() ([]btrfs.Filesystem, error) {
|
||||||
|
return []btrfs.Filesystem{
|
||||||
|
{UUID: "11111111-1111-4111-8111-111111111111", Name: label, Size: 100, Health: "ONLINE", NRead: 100, Devices: []btrfs.Device{{Name: "first"}}},
|
||||||
|
{UUID: "22222222-2222-4222-8222-222222222222", Name: "tank", Size: 200, Health: "DEGRADED", NRead: 200, Devices: []btrfs.Device{{Name: "second"}}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs", poolStatsFn: func() ([]zfs.PoolStat, error) { return []zfs.PoolStat{{Name: "tank", Size: 300}}, nil },
|
||||||
|
kernelStatsFn: func() ([]zfs.PoolKernelStat, error) { return []zfs.PoolKernelStat{{Name: "tank", NRead: 300}}, nil },
|
||||||
|
poolStatusesFn: func() ([]zfs.PoolStatus, error) {
|
||||||
|
return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "zfs-device"}}}}, nil
|
||||||
|
},
|
||||||
|
|
||||||
|
datasetsFn: func() ([]zfs.Dataset, error) { return []zfs.Dataset{{Name: "tank/data"}}, nil }}, newBtrfsBackend()}}
|
||||||
|
first := "b:11111111-1111-4111-8111-111111111111"
|
||||||
|
second := "b:22222222-2222-4222-8222-222222222222"
|
||||||
|
var stats system.Stats
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.Len(t, stats.ZfsPools, 3)
|
||||||
|
assert.Contains(t, stats.ZfsPools, "tank")
|
||||||
|
assert.Equal(t, "ONLINE", stats.ZfsPools[first].Health)
|
||||||
|
assert.Equal(t, "DEGRADED", stats.ZfsPools[second].Health)
|
||||||
|
assert.Equal(t, uint64(100), zm.backends[1].kernelSamples[first].nread)
|
||||||
|
assert.Equal(t, uint64(200), zm.backends[1].kernelSamples[second].nread)
|
||||||
|
detail := zm.GetDetail(true)
|
||||||
|
require.Len(t, detail.Pools, 3)
|
||||||
|
assert.Equal(t, "zfs-device", detail.Pools[0].Vdevs[0].Name)
|
||||||
|
assert.Len(t, detail.Pools[0].Datasets, 1)
|
||||||
|
assert.Equal(t, "first", detail.Pools[1].Vdevs[0].Name)
|
||||||
|
assert.Empty(t, detail.Pools[1].Datasets)
|
||||||
|
assert.Equal(t, "second", detail.Pools[2].Vdevs[0].Name)
|
||||||
|
label = "renamed"
|
||||||
|
zm.backends[0].lastPoolStats = time.Time{}
|
||||||
|
zm.backends[1].lastPoolStats = time.Time{}
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.Len(t, stats.ZfsPools, 3)
|
||||||
|
assert.Equal(t, "renamed", stats.ZfsPools[first].DisplayName)
|
||||||
|
assert.Equal(t, first, zm.GetDetail(true).Pools[1].Name)
|
||||||
|
assert.Equal(t, "renamed", zm.GetDetail(true).Pools[1].DisplayName)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
|
"github.com/henrygd/beszel/agent/btrfs"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/agent/zfs"
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
@@ -219,8 +220,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
// disk i/o (cache-aware per interval)
|
// disk i/o (cache-aware per interval)
|
||||||
a.updateDiskIo(cacheTimeMs, &systemStats)
|
a.updateDiskIo(cacheTimeMs, &systemStats)
|
||||||
|
|
||||||
// zfs pool stats
|
// storage pool stats
|
||||||
a.zfsManager.Update(&systemStats)
|
a.storagePoolManager.Update(&systemStats)
|
||||||
|
a.storagePoolManager.markDuplicateCharts(&systemStats, a.fsStats, btrfs.MountID)
|
||||||
|
|
||||||
// network stats (per cache interval)
|
// network stats (per cache interval)
|
||||||
a.updateNetworkStats(cacheTimeMs, &systemStats)
|
a.updateNetworkStats(cacheTimeMs, &systemStats)
|
||||||
|
|||||||
@@ -33,11 +33,15 @@ var ErrNoZfs = errors.New("zfs utilities unavailable")
|
|||||||
|
|
||||||
// PoolStat is a snapshot of a ZFS pool's capacity and health.
|
// PoolStat is a snapshot of a ZFS pool's capacity and health.
|
||||||
type PoolStat struct {
|
type PoolStat struct {
|
||||||
Name string
|
DisplayName string // optional friendly name; Name remains the stable key
|
||||||
Size uint64 // total capacity in bytes
|
MountID string // Btrfs filesystem identity, empty for other backends
|
||||||
Alloc uint64 // allocated bytes
|
IODevice string // sole Btrfs member device, if known
|
||||||
Free uint64 // free bytes
|
Raw bool // physical accounting rather than usable filesystem space
|
||||||
Health string // ONLINE, DEGRADED, FAULTED, ...
|
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.
|
// PoolKernelStat is the inexpensive pool telemetry exposed by the ZFS kernel.
|
||||||
|
|||||||
@@ -1,320 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
//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["/"])
|
|
||||||
}
|
|
||||||
@@ -66,6 +66,7 @@ type SystemAlertGPUData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertZfsPool struct {
|
type SystemAlertZfsPool struct {
|
||||||
|
Raw bool `json:"raw,omitempty"`
|
||||||
Total float64 `json:"d"`
|
Total float64 `json:"d"`
|
||||||
Used float64 `json:"du"`
|
Used float64 `json:"du"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, pool := range data.Stats.ZfsPools {
|
for _, pool := range data.Stats.ZfsPools {
|
||||||
if pool != nil && pool.Total > 0 {
|
if pool != nil && !pool.Raw && pool.Total > 0 {
|
||||||
usedPct := pool.Used / pool.Total * 100
|
usedPct := pool.Used / pool.Total * 100
|
||||||
if usedPct > maxUsedPct {
|
if usedPct > maxUsedPct {
|
||||||
maxUsedPct = usedPct
|
maxUsedPct = usedPct
|
||||||
@@ -256,7 +256,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
// add zfs pool usage from historical record
|
// add zfs pool usage from historical record
|
||||||
for key, pool := range stats.ZfsPools {
|
for key, pool := range stats.ZfsPools {
|
||||||
if pool.Total > 0 {
|
if !pool.Raw && pool.Total > 0 {
|
||||||
zfsKey := zfsDiskAlertKey(key)
|
zfsKey := zfsDiskAlertKey(key)
|
||||||
if _, ok := alert.mapSums[zfsKey]; !ok {
|
if _, ok := alert.mapSums[zfsKey]; !ok {
|
||||||
alert.mapSums[zfsKey] = 0.0
|
alert.mapSums[zfsKey] = 0.0
|
||||||
@@ -319,6 +319,11 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
if sumPct > maxPct {
|
if sumPct > maxPct {
|
||||||
maxPct = sumPct
|
maxPct = sumPct
|
||||||
alert.descriptor = diskAlertDescriptor(key)
|
alert.descriptor = diskAlertDescriptor(key)
|
||||||
|
if poolKey, ok := strings.CutPrefix(key, "zfs:"); ok {
|
||||||
|
if pool := data.Stats.ZfsPools[poolKey]; pool != nil && pool.DisplayName != "" {
|
||||||
|
alert.descriptor = diskAlertDescriptor(zfsDiskAlertKey(pool.DisplayName))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
alert.val = float64(maxPct / float32(alert.count))
|
alert.val = float64(maxPct / float32(alert.count))
|
||||||
@@ -370,7 +375,7 @@ func zfsDiskAlertKey(poolName string) string {
|
|||||||
|
|
||||||
func diskAlertDescriptor(key string) string {
|
func diskAlertDescriptor(key string) string {
|
||||||
if poolName, ok := strings.CutPrefix(key, "zfs:"); ok {
|
if poolName, ok := strings.CutPrefix(key, "zfs:"); ok {
|
||||||
return fmt.Sprintf("Usage of ZFS pool %s", poolName)
|
return fmt.Sprintf("Usage of storage pool %s", poolName)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Usage of %s", key)
|
return fmt.Sprintf("Usage of %s", key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,15 @@ func (am *AlertManager) handleZfsPoolHealthAlert(e *core.RecordEvent, oldHealth
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemName := systemRecord.GetString("name")
|
systemName := systemRecord.GetString("name")
|
||||||
poolName := e.Record.GetString("name")
|
poolName := e.Record.GetString("display_name")
|
||||||
|
if poolName == "" {
|
||||||
|
poolName = e.Record.GetString("name")
|
||||||
|
}
|
||||||
|
|
||||||
title := fmt.Sprintf("ZFS pool %s on %s: %s", newHealth, systemName, poolName)
|
title := fmt.Sprintf("Storage pool %s on %s: %s", newHealth, systemName, poolName)
|
||||||
message := fmt.Sprintf("ZFS pool %s (%s) was first observed as %s", poolName, systemName, newHealth)
|
message := fmt.Sprintf("Storage pool %s (%s) was first observed as %s", poolName, systemName, newHealth)
|
||||||
if oldSeverity > 0 {
|
if oldSeverity > 0 {
|
||||||
message = fmt.Sprintf("ZFS pool %s (%s) health changed from %s to %s", poolName, systemName, oldHealth, newHealth)
|
message = fmt.Sprintf("Storage pool %s (%s) health changed from %s to %s", poolName, systemName, oldHealth, newHealth)
|
||||||
}
|
}
|
||||||
|
|
||||||
userIDs := systemRecord.GetStringSlice("users")
|
userIDs := systemRecord.GetStringSlice("users")
|
||||||
@@ -116,7 +119,7 @@ func createZfsPoolHistoryRecord(app core.App, userID, systemID, alertID, poolNam
|
|||||||
record.Set("user", userID)
|
record.Set("user", userID)
|
||||||
record.Set("system", systemID)
|
record.Set("system", systemID)
|
||||||
record.Set("alert_id", alertID)
|
record.Set("alert_id", alertID)
|
||||||
record.Set("name", "ZFS Pool: "+poolName)
|
record.Set("name", "Storage Pool: "+poolName)
|
||||||
return app.Save(record)
|
return app.Save(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,3 +143,37 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
|
|||||||
assert.False(t, diskAlert.GetBool("triggered"),
|
assert.False(t, diskAlert.GetBool("triggered"),
|
||||||
"Alert should be resolved when ZFS pool average (50%%) drops below threshold (80%%)")
|
"Alert should be resolved when ZFS pool average (50%%) drops below threshold (80%%)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDiskAlertIgnoresRawPool(t *testing.T) {
|
||||||
|
for _, minutes := range []int{0, 2} {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
require.NoError(t, err)
|
||||||
|
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{"name": "Disk", "system": systems[0].Id, "user": user.Id, "value": 80, "min": minutes})
|
||||||
|
require.NoError(t, err)
|
||||||
|
pools := map[string]*system.ZfsPool{"btrfs": {Total: 100, Used: 99, Raw: true}}
|
||||||
|
for _, offset := range []time.Duration{-180, -90, -60, -30} {
|
||||||
|
data, err := json.Marshal(system.Stats{ZfsPools: pools})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{"system": systems[0].Id, "type": "1m", "stats": string(data)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.SetRaw("created", time.Now().UTC().Add(offset*time.Second).Format(types.DefaultDateLayout))
|
||||||
|
require.NoError(t, hub.SaveNoValidate(record))
|
||||||
|
}
|
||||||
|
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
record, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, record.GetBool("triggered"))
|
||||||
|
if minutes > 0 {
|
||||||
|
// A current usable sample must not make raw historical values eligible.
|
||||||
|
pools["btrfs"].Raw = false
|
||||||
|
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
record, err = hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, record.GetBool("triggered"))
|
||||||
|
}
|
||||||
|
hub.Cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ import (
|
|||||||
|
|
||||||
func TestZfsDiskAlertKeyIsNamespaced(t *testing.T) {
|
func TestZfsDiskAlertKeyIsNamespaced(t *testing.T) {
|
||||||
assert.Equal(t, "zfs:tank", zfsDiskAlertKey("tank"))
|
assert.Equal(t, "zfs:tank", zfsDiskAlertKey("tank"))
|
||||||
assert.Equal(t, "Usage of ZFS pool tank", diskAlertDescriptor(zfsDiskAlertKey("tank")))
|
assert.Equal(t, "Usage of storage pool tank", diskAlertDescriptor(zfsDiskAlertKey("tank")))
|
||||||
assert.Equal(t, "Usage of tank", diskAlertDescriptor("tank"))
|
assert.Equal(t, "Usage of tank", diskAlertDescriptor("tank"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestZfsPoolAlertOnlineToDegraded(t *testing.T) {
|
|||||||
|
|
||||||
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after pool became DEGRADED")
|
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after pool became DEGRADED")
|
||||||
lastMessage := hub.TestMailer.LastMessage()
|
lastMessage := hub.TestMailer.LastMessage()
|
||||||
assert.Contains(t, lastMessage.Subject, "ZFS pool DEGRADED on test-system")
|
assert.Contains(t, lastMessage.Subject, "Storage pool DEGRADED on test-system")
|
||||||
assert.Contains(t, lastMessage.Subject, "tank")
|
assert.Contains(t, lastMessage.Subject, "tank")
|
||||||
assert.Contains(t, lastMessage.Text, "ONLINE to DEGRADED")
|
assert.Contains(t, lastMessage.Text, "ONLINE to DEGRADED")
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ func TestZfsPoolAlertDegradedToFaulted(t *testing.T) {
|
|||||||
|
|
||||||
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should alert on initial DEGRADED state and later FAULTED transition")
|
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should alert on initial DEGRADED state and later FAULTED transition")
|
||||||
lastMessage := hub.TestMailer.LastMessage()
|
lastMessage := hub.TestMailer.LastMessage()
|
||||||
assert.Contains(t, lastMessage.Subject, "ZFS pool FAULTED on test-system")
|
assert.Contains(t, lastMessage.Subject, "Storage pool FAULTED on test-system")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestZfsPoolAlertNoAlertOnRecovery(t *testing.T) {
|
func TestZfsPoolAlertNoAlertOnRecovery(t *testing.T) {
|
||||||
@@ -239,7 +239,7 @@ func TestZfsPoolAlertWritesHistory(t *testing.T) {
|
|||||||
history, err := hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id}", "", 0, 0, map[string]any{"alert_id": pool.Id})
|
history, err := hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id}", "", 0, 0, map[string]any{"alert_id": pool.Id})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
require.Len(t, history, 1, "expected one history entry per user")
|
require.Len(t, history, 1, "expected one history entry per user")
|
||||||
assert.Equal(t, "ZFS Pool: tank", history[0].GetString("name"))
|
assert.Equal(t, "Storage Pool: tank", history[0].GetString("name"))
|
||||||
assert.Equal(t, system.Id, history[0].GetString("system"))
|
assert.Equal(t, system.Id, history[0].GetString("system"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,11 +59,15 @@ type Stats struct {
|
|||||||
|
|
||||||
// ZfsPool holds per-pool ZFS metrics for a single collection interval.
|
// ZfsPool holds per-pool ZFS metrics for a single collection interval.
|
||||||
type ZfsPool struct {
|
type ZfsPool struct {
|
||||||
Total float64 `json:"d" cbor:"0,keyasint"` // total capacity in GiB
|
DisplayName string `json:"n,omitempty" cbor:"8,keyasint,omitempty"`
|
||||||
Used float64 `json:"du" cbor:"1,keyasint"` // allocated in GiB
|
HideUsage bool `json:"hu,omitempty" cbor:"6,keyasint,omitempty"` // equivalent filesystem usage chart exists
|
||||||
ReadBytes uint64 `json:"rb,omitzero" cbor:"2,keyasint,omitzero"` // read throughput in bytes/s
|
HideIO bool `json:"hi,omitempty" cbor:"7,keyasint,omitempty"` // equivalent filesystem I/O chart exists
|
||||||
WriteBytes uint64 `json:"wb,omitzero" cbor:"3,keyasint,omitzero"` // write throughput in bytes/s
|
Raw bool `json:"raw,omitempty" cbor:"5,keyasint,omitempty"`
|
||||||
Health string `json:"h,omitempty" cbor:"4,keyasint,omitempty"` // ONLINE, DEGRADED, FAULTED, ...
|
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.
|
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
|
||||||
|
|||||||
@@ -1,23 +1,47 @@
|
|||||||
// Package zfs defines the ZFS detail data exchanged between agent and hub.
|
// Package zfs defines the ZFS detail data exchanged between agent and hub.
|
||||||
package zfs
|
package zfs
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// ZfsData is the detail payload returned by the agent for the GetZfsData action.
|
// ZfsData is the detail payload returned by the agent for the GetZfsData action.
|
||||||
type ZfsData struct {
|
type ZfsData struct {
|
||||||
Pools []*PoolDetail `json:"pools,omitempty"`
|
Pools []*PoolDetail `json:"pools,omitempty"`
|
||||||
Complete bool `json:"complete,omitempty"`
|
Complete bool `json:"complete,omitempty"`
|
||||||
|
// Backends whose inventories are complete, even when another backend failed.
|
||||||
|
CompleteBackends []string `json:"completeBackends,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanRefreshPool also governs deletion: missing pools may only be removed
|
||||||
|
// after a successful inventory of their backend. Complete supports old agents.
|
||||||
|
func (data *ZfsData) CanRefreshPool(name string) bool {
|
||||||
|
if data.Complete {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
backend := "zfs"
|
||||||
|
if strings.HasPrefix(name, "b:") {
|
||||||
|
backend = "btrfs"
|
||||||
|
}
|
||||||
|
for _, complete := range data.CompleteBackends {
|
||||||
|
if complete == backend {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// PoolDetail holds the verbose state of a single pool: capacity, health,
|
// PoolDetail holds the verbose state of a single pool: capacity, health,
|
||||||
// scrub, vdev, and dataset information.
|
// scrub, vdev, and dataset information.
|
||||||
type PoolDetail struct {
|
type PoolDetail struct {
|
||||||
Name string `json:"name"`
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
Health string `json:"health,omitempty"`
|
Raw bool `json:"raw,omitempty"`
|
||||||
Size uint64 `json:"size,omitempty"` // bytes
|
Name string `json:"name"`
|
||||||
Alloc uint64 `json:"alloc,omitempty"` // bytes
|
Health string `json:"health,omitempty"`
|
||||||
Free uint64 `json:"free,omitempty"` // bytes
|
Size uint64 `json:"size,omitempty"` // bytes
|
||||||
Scrub *Scrub `json:"scrub,omitempty"`
|
Alloc uint64 `json:"alloc,omitempty"` // bytes
|
||||||
Vdevs []*Vdev `json:"vdevs,omitempty"`
|
Free uint64 `json:"free,omitempty"` // bytes
|
||||||
Datasets []*Dataset `json:"datasets,omitempty"`
|
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.
|
// Scrub holds the scrub (or resilver) status of a pool.
|
||||||
|
|||||||
@@ -32,13 +32,12 @@ func (sys *System) FetchAndSaveZfsPools(force bool) error {
|
|||||||
sys.recordZfsFetchResult(err, 0)
|
sys.recordZfsFetchResult(err, 0)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if zfsData == nil || !zfsData.Complete {
|
|
||||||
err = errIncompleteZfsData
|
|
||||||
sys.recordZfsFetchResult(err, 0)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = sys.saveZfsPools(zfsData)
|
err = sys.saveZfsPools(zfsData)
|
||||||
sys.recordZfsFetchResult(err, len(zfsData.Pools))
|
poolCount := 0
|
||||||
|
if zfsData != nil {
|
||||||
|
poolCount = len(zfsData.Pools)
|
||||||
|
}
|
||||||
|
sys.recordZfsFetchResult(err, poolCount)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ func (sys *System) zfsFetchInterval() time.Duration {
|
|||||||
// saveZfsPools saves ZFS pool detail data to the zfs_pools collection and
|
// saveZfsPools saves ZFS pool detail data to the zfs_pools collection and
|
||||||
// removes records for pools no longer reported by a complete agent inventory.
|
// removes records for pools no longer reported by a complete agent inventory.
|
||||||
func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
|
func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
|
||||||
if zfsData == nil || !zfsData.Complete {
|
if zfsData == nil || (!zfsData.CanRefreshPool("zfs") && !zfsData.CanRefreshPool("b:")) {
|
||||||
return errIncompleteZfsData
|
return errIncompleteZfsData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,10 +88,10 @@ func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return hub.RunInTransaction(func(txApp core.App) error {
|
err = hub.RunInTransaction(func(txApp core.App) error {
|
||||||
alive := make(map[string]bool, len(zfsData.Pools))
|
alive := make(map[string]bool, len(zfsData.Pools))
|
||||||
for _, pool := range zfsData.Pools {
|
for _, pool := range zfsData.Pools {
|
||||||
if pool == nil {
|
if pool == nil || !zfsData.CanRefreshPool(pool.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
alive[pool.Name] = true
|
alive[pool.Name] = true
|
||||||
@@ -111,7 +110,7 @@ func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, record := range existing {
|
for _, record := range existing {
|
||||||
if !alive[record.GetString("name")] {
|
if name := record.GetString("name"); zfsData.CanRefreshPool(name) && !alive[name] {
|
||||||
if err := txApp.Delete(record); err != nil {
|
if err := txApp.Delete(record); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -119,6 +118,14 @@ func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Report partial failure only after committing healthy backend updates.
|
||||||
|
if !zfsData.Complete {
|
||||||
|
return errIncompleteZfsData
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sys *System) upsertZfsPoolRecord(app core.App, collection *core.Collection, pool *zfs.PoolDetail) error {
|
func (sys *System) upsertZfsPoolRecord(app core.App, collection *core.Collection, pool *zfs.PoolDetail) error {
|
||||||
@@ -135,10 +142,12 @@ func (sys *System) upsertZfsPoolRecord(app core.App, collection *core.Collection
|
|||||||
|
|
||||||
record.Set("system", sys.Id)
|
record.Set("system", sys.Id)
|
||||||
record.Set("name", pool.Name)
|
record.Set("name", pool.Name)
|
||||||
|
record.Set("display_name", pool.DisplayName)
|
||||||
record.Set("health", pool.Health)
|
record.Set("health", pool.Health)
|
||||||
record.Set("size", pool.Size)
|
record.Set("size", pool.Size)
|
||||||
record.Set("alloc", pool.Alloc)
|
record.Set("alloc", pool.Alloc)
|
||||||
record.Set("free", pool.Free)
|
record.Set("free", pool.Free)
|
||||||
|
record.Set("raw", pool.Raw)
|
||||||
record.Set("scrub", pool.Scrub)
|
record.Set("scrub", pool.Scrub)
|
||||||
record.Set("vdevs", pool.Vdevs)
|
record.Set("vdevs", pool.Vdevs)
|
||||||
record.Set("datasets", pool.Datasets)
|
record.Set("datasets", pool.Datasets)
|
||||||
@@ -172,7 +181,9 @@ func (sys *System) syncZfsPoolHealth(app core.App, pools map[string]*system.ZfsP
|
|||||||
record.Set("id", recordID)
|
record.Set("id", recordID)
|
||||||
record.Set("system", sys.Id)
|
record.Set("system", sys.Id)
|
||||||
record.Set("name", name)
|
record.Set("name", name)
|
||||||
|
record.Set("display_name", pool.DisplayName)
|
||||||
record.Set("health", pool.Health)
|
record.Set("health", pool.Health)
|
||||||
|
record.Set("raw", pool.Raw)
|
||||||
record.Set("size", uint64(pool.Total*gib))
|
record.Set("size", uint64(pool.Total*gib))
|
||||||
record.Set("alloc", uint64(pool.Used*gib))
|
record.Set("alloc", uint64(pool.Used*gib))
|
||||||
record.Set("free", uint64(max(pool.Total-pool.Used, 0)*gib))
|
record.Set("free", uint64(max(pool.Total-pool.Used, 0)*gib))
|
||||||
@@ -181,10 +192,15 @@ func (sys *System) syncZfsPoolHealth(app core.App, pools map[string]*system.ZfsP
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if record.GetString("health") == pool.Health {
|
if record.GetString("health") == pool.Health && record.GetBool("raw") == pool.Raw && record.GetString("display_name") == pool.DisplayName {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
record.Set("display_name", pool.DisplayName)
|
||||||
record.Set("health", pool.Health)
|
record.Set("health", pool.Health)
|
||||||
|
record.Set("raw", pool.Raw)
|
||||||
|
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 {
|
if err := app.SaveNoValidate(record); err != nil {
|
||||||
return fmt.Errorf("updating ZFS pool health %q: %w", name, err)
|
return fmt.Errorf("updating ZFS pool health %q: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,44 @@ func TestSaveZfsPoolsIncompletePreservesRecords(t *testing.T) {
|
|||||||
assert.Len(t, records, 1)
|
assert.Len(t, records, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSavePartialBackendInventory(t *testing.T) {
|
||||||
|
for _, healthy := range []string{"zfs", "btrfs"} {
|
||||||
|
t.Run(healthy, func(t *testing.T) {
|
||||||
|
sys, app := newTestSystemWithHub(t)
|
||||||
|
healthyKey, failedKey := "tank", "b:uuid"
|
||||||
|
if healthy == "btrfs" {
|
||||||
|
healthyKey, failedKey = failedKey, healthyKey
|
||||||
|
}
|
||||||
|
initial := &zfs.ZfsData{Complete: true, Pools: []*zfs.PoolDetail{
|
||||||
|
{Name: healthyKey, Alloc: 10}, {Name: failedKey, Alloc: 10},
|
||||||
|
}}
|
||||||
|
require.NoError(t, sys.saveZfsPools(initial))
|
||||||
|
failedID := makeStableHashId(sys.Id, failedKey)
|
||||||
|
before, err := app.FindRecordById("zfs_pools", failedID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
partial := &zfs.ZfsData{CompleteBackends: []string{healthy}, Pools: []*zfs.PoolDetail{
|
||||||
|
{Name: healthyKey, Alloc: 20}, {Name: failedKey, Alloc: 99},
|
||||||
|
}}
|
||||||
|
assert.ErrorIs(t, sys.saveZfsPools(partial), errIncompleteZfsData)
|
||||||
|
fresh, err := app.FindRecordById("zfs_pools", makeStableHashId(sys.Id, healthyKey))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 20, fresh.GetInt("alloc"))
|
||||||
|
cached, err := app.FindRecordById("zfs_pools", failedID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 10, cached.GetInt("alloc"))
|
||||||
|
assert.Equal(t, before.GetDateTime("details_updated"), cached.GetDateTime("details_updated"))
|
||||||
|
// An empty successful backend can prune, even while the other fails.
|
||||||
|
partial.Pools = nil
|
||||||
|
assert.ErrorIs(t, sys.saveZfsPools(partial), errIncompleteZfsData)
|
||||||
|
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.Equal(t, failedKey, records[0].GetString("name"))
|
||||||
|
require.NoError(t, sys.saveZfsPools(&zfs.ZfsData{Complete: true}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSyncZfsPoolHealthWritesOnlyTransitions(t *testing.T) {
|
func TestSyncZfsPoolHealthWritesOnlyTransitions(t *testing.T) {
|
||||||
sys, app := newTestSystemWithHub(t)
|
sys, app := newTestSystemWithHub(t)
|
||||||
collection, err := app.FindCachedCollectionByNameOrId("zfs_pools")
|
collection, err := app.FindCachedCollectionByNameOrId("zfs_pools")
|
||||||
@@ -151,3 +189,42 @@ func TestSyncZfsPoolHealthWritesOnlyTransitions(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "DEGRADED", record.GetString("health"))
|
assert.Equal(t, "DEGRADED", record.GetString("health"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestZfsRawCapacityPersistence(t *testing.T) {
|
||||||
|
sys, app := newTestSystemWithHub(t)
|
||||||
|
require.NoError(t, sys.saveZfsPools(&zfs.ZfsData{Complete: true, Pools: []*zfs.PoolDetail{{Name: "btrfs", Size: 200, Alloc: 10, Raw: true}}}))
|
||||||
|
record, err := app.FindRecordById("zfs_pools", makeStableHashId(sys.Id, "btrfs"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, record.GetBool("raw"))
|
||||||
|
require.NoError(t, sys.syncZfsPoolHealth(app, map[string]*system.ZfsPool{"btrfs": {Total: 1, Used: 0.25}}))
|
||||||
|
record, err = app.FindRecordById("zfs_pools", record.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, record.GetBool("raw"))
|
||||||
|
assert.EqualValues(t, 1024*1024*1024, record.GetInt("size"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBtrfsDisplayNameKeepsRecordIdentity(t *testing.T) {
|
||||||
|
sys, app := newTestSystemWithHub(t)
|
||||||
|
key := "b:11111111-1111-4111-8111-111111111111"
|
||||||
|
require.NoError(t, sys.syncZfsPoolHealth(app, map[string]*system.ZfsPool{
|
||||||
|
key: {DisplayName: "tank", Health: "ONLINE"},
|
||||||
|
"tank": {Health: "ONLINE"},
|
||||||
|
}))
|
||||||
|
id := makeStableHashId(sys.Id, key)
|
||||||
|
record, err := app.FindRecordById("zfs_pools", id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "tank", record.GetString("display_name"))
|
||||||
|
require.NoError(t, sys.syncZfsPoolHealth(app, map[string]*system.ZfsPool{key: {DisplayName: "renamed", Health: "ONLINE"}}))
|
||||||
|
record, err = app.FindRecordById("zfs_pools", id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, key, record.GetString("name"))
|
||||||
|
assert.Equal(t, "renamed", record.GetString("display_name"))
|
||||||
|
require.NoError(t, sys.saveZfsPools(&zfs.ZfsData{Complete: true, Pools: []*zfs.PoolDetail{
|
||||||
|
{Name: key, DisplayName: "detail name", Health: "ONLINE"}, {Name: "tank", Health: "ONLINE"},
|
||||||
|
}}))
|
||||||
|
record, err = app.FindRecordById("zfs_pools", id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "detail name", record.GetString("display_name"))
|
||||||
|
_, err = app.FindRecordById("zfs_pools", makeStableHashId(sys.Id, "tank"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|||||||
27
internal/migrations/1788955200_pool_updates.go
Normal file
27
internal/migrations/1788955200_pool_updates.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
c, err := app.FindCollectionByNameOrId("zfs_pools")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Fields.Add(&core.TextField{Name: "display_name"})
|
||||||
|
c.Fields.Add(&core.BoolField{Name: "raw"})
|
||||||
|
return app.Save(c)
|
||||||
|
}, func(app core.App) error {
|
||||||
|
c, err := app.FindCollectionByNameOrId("zfs_pools")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Fields.RemoveByName("display_name")
|
||||||
|
c.Fields.RemoveByName("raw")
|
||||||
|
|
||||||
|
return app.Save(c)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -198,6 +198,7 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
var fanSums map[string]uint64
|
var fanSums map[string]uint64
|
||||||
fanCount := uint64(0)
|
fanCount := uint64(0)
|
||||||
zfsPoolCounts := make(map[string]uint64)
|
zfsPoolCounts := make(map[string]uint64)
|
||||||
|
zfsCapacityCounts := make(map[string]uint64)
|
||||||
|
|
||||||
// Accumulate totals
|
// Accumulate totals
|
||||||
for i := range records {
|
for i := range records {
|
||||||
@@ -350,9 +351,19 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
}
|
}
|
||||||
pool := sum.ZfsPools[name]
|
pool := sum.ZfsPools[name]
|
||||||
if pool == nil {
|
if pool == nil {
|
||||||
pool = &system.ZfsPool{}
|
pool = &system.ZfsPool{HideUsage: value.HideUsage, HideIO: value.HideIO}
|
||||||
sum.ZfsPools[name] = pool
|
sum.ZfsPools[name] = pool
|
||||||
}
|
}
|
||||||
|
// Never average physical and usable capacity into the same value.
|
||||||
|
if pool.Raw != value.Raw {
|
||||||
|
pool.Total, pool.Used = 0, 0
|
||||||
|
zfsCapacityCounts[name] = 0
|
||||||
|
}
|
||||||
|
pool.HideUsage = pool.HideUsage && value.HideUsage
|
||||||
|
pool.HideIO = pool.HideIO && value.HideIO
|
||||||
|
pool.DisplayName = value.DisplayName
|
||||||
|
pool.Raw = value.Raw
|
||||||
|
zfsCapacityCounts[name]++
|
||||||
pool.Total += value.Total
|
pool.Total += value.Total
|
||||||
pool.Used += value.Used
|
pool.Used += value.Used
|
||||||
pool.ReadBytes += value.ReadBytes
|
pool.ReadBytes += value.ReadBytes
|
||||||
@@ -476,8 +487,8 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
// Average ZFS pool stats.
|
// Average ZFS pool stats.
|
||||||
for name, pool := range sum.ZfsPools {
|
for name, pool := range sum.ZfsPools {
|
||||||
entryCount := zfsPoolCounts[name]
|
entryCount := zfsPoolCounts[name]
|
||||||
pool.Total = twoDecimals(pool.Total / float64(entryCount))
|
pool.Total = twoDecimals(pool.Total / float64(zfsCapacityCounts[name]))
|
||||||
pool.Used = twoDecimals(pool.Used / float64(entryCount))
|
pool.Used = twoDecimals(pool.Used / float64(zfsCapacityCounts[name]))
|
||||||
pool.ReadBytes /= entryCount
|
pool.ReadBytes /= entryCount
|
||||||
pool.WriteBytes /= entryCount
|
pool.WriteBytes /= entryCount
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -889,3 +889,34 @@ func TestAverageContainerStatsSlice_ManyContainers(t *testing.T) {
|
|||||||
assert.Equal(t, 35.0, result[2].Cpu)
|
assert.Equal(t, 35.0, result[2].Cpu)
|
||||||
assert.Equal(t, 45.0, result[3].Cpu)
|
assert.Equal(t, 45.0, result[3].Cpu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_ZfsCapacityModes(t *testing.T) {
|
||||||
|
for _, raw := range []bool{false, true} {
|
||||||
|
result := records.AverageSystemStatsSlice([]system.Stats{
|
||||||
|
{ZfsPools: map[string]*system.ZfsPool{"pool": {Total: 200, Used: 40, Raw: !raw, ReadBytes: 100}}},
|
||||||
|
{ZfsPools: map[string]*system.ZfsPool{"pool": {Total: 100, Used: 10, Raw: raw, ReadBytes: 300}}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, &system.ZfsPool{Total: 100, Used: 10, Raw: raw, ReadBytes: 200}, result.ZfsPools["pool"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_ZfsDuplicateCharts(t *testing.T) {
|
||||||
|
for _, hide := range []bool{false, true} {
|
||||||
|
result := records.AverageSystemStatsSlice([]system.Stats{
|
||||||
|
{ZfsPools: map[string]*system.ZfsPool{"pool": {HideUsage: true, HideIO: true}}},
|
||||||
|
{ZfsPools: map[string]*system.ZfsPool{"pool": {HideUsage: hide, HideIO: hide}}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, hide, result.ZfsPools["pool"].HideUsage)
|
||||||
|
assert.Equal(t, hide, result.ZfsPools["pool"].HideIO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_BtrfsDisplayName(t *testing.T) {
|
||||||
|
result := records.AverageSystemStatsSlice([]system.Stats{
|
||||||
|
{ZfsPools: map[string]*system.ZfsPool{"b:uuid": {DisplayName: "before", Used: 10}}},
|
||||||
|
{ZfsPools: map[string]*system.ZfsPool{"b:uuid": {DisplayName: "after", Used: 20}}},
|
||||||
|
})
|
||||||
|
require.Len(t, result.ZfsPools, 1)
|
||||||
|
assert.Equal(t, "after", result.ZfsPools["b:uuid"].DisplayName)
|
||||||
|
assert.Equal(t, float64(15), result.ZfsPools["b:uuid"].Used)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useSystemData } from "./system/use-system-data"
|
|||||||
import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts"
|
import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts"
|
||||||
import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts"
|
import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts"
|
||||||
import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
|
import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
|
||||||
import { ZfsCharts } from "./system/charts/zfs-charts"
|
import { ZfsCharts } from "./system/charts/storage-pool-charts"
|
||||||
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||||
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
|
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||||
import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
|
import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function ChartCard({
|
|||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: React.ReactNode
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
grid?: boolean
|
grid?: boolean
|
||||||
empty?: boolean
|
empty?: boolean
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import AreaChartDefault from "@/components/charts/area-chart"
|
|||||||
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||||
import type { SystemStatsRecord } from "@/types"
|
import type { SystemStatsRecord } from "@/types"
|
||||||
import { ChartCard } from "../chart-card"
|
import { ChartCard } from "../chart-card"
|
||||||
|
import { RawCapacityLabel } from "../raw-capacity-label"
|
||||||
import { Unit } from "@/lib/enums"
|
import { Unit } from "@/lib/enums"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $userSettings } from "@/lib/stores"
|
import { $userSettings } from "@/lib/stores"
|
||||||
@@ -10,9 +11,11 @@ import type { SystemData } from "../use-system-data"
|
|||||||
|
|
||||||
// Accessors for ZFS metrics
|
// Accessors for ZFS metrics
|
||||||
const poolUsage =
|
const poolUsage =
|
||||||
(name: string) =>
|
(name: string, raw: boolean) =>
|
||||||
({ stats }: SystemStatsRecord) =>
|
({ stats }: SystemStatsRecord) => {
|
||||||
stats?.z?.[name]?.du ?? 0
|
const pool = stats?.z?.[name]
|
||||||
|
return pool && !!pool.raw === raw ? pool.du : null
|
||||||
|
}
|
||||||
const poolRead =
|
const poolRead =
|
||||||
(name: string) =>
|
(name: string) =>
|
||||||
({ stats }: SystemStatsRecord) =>
|
({ stats }: SystemStatsRecord) =>
|
||||||
@@ -26,9 +29,10 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
|
|||||||
const { chartData, grid, dataEmpty } = systemData
|
const { chartData, grid, dataEmpty } = systemData
|
||||||
const latest = chartData.systemStats.at(-1)?.stats
|
const latest = chartData.systemStats.at(-1)?.stats
|
||||||
const pool = latest?.z?.[poolName]
|
const pool = latest?.z?.[poolName]
|
||||||
if (!pool) {
|
if (!pool || pool.hu) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
const displayName = pool.n || poolName
|
||||||
let poolTotal = pool.d
|
let poolTotal = pool.d
|
||||||
// round to nearest GB
|
// round to nearest GB
|
||||||
if (poolTotal >= 100) {
|
if (poolTotal >= 100) {
|
||||||
@@ -39,8 +43,8 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
|
|||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={`${poolName} ${t`Usage`}`}
|
title={`${displayName} ${t`Usage`}`}
|
||||||
description={t`Usage of ZFS pool ${poolName}`}
|
description={pool.raw ? <RawCapacityLabel label={t`Raw usage of storage pool ${displayName}`} /> : t`Usage of storage pool ${displayName}`}
|
||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
@@ -57,7 +61,7 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
|
|||||||
dataPoints={[
|
dataPoints={[
|
||||||
{
|
{
|
||||||
label: t`Pool Usage`,
|
label: t`Pool Usage`,
|
||||||
dataKey: poolUsage(poolName),
|
dataKey: poolUsage(poolName, !!pool.raw),
|
||||||
color: 4,
|
color: 4,
|
||||||
opacity: 0.4,
|
opacity: 0.4,
|
||||||
},
|
},
|
||||||
@@ -70,15 +74,16 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
|
|||||||
export function ZfsPoolIOChart({ systemData, poolName }: { systemData: SystemData; poolName: string }) {
|
export function ZfsPoolIOChart({ systemData, poolName }: { systemData: SystemData; poolName: string }) {
|
||||||
const { chartData, grid, dataEmpty } = systemData
|
const { chartData, grid, dataEmpty } = systemData
|
||||||
const userSettings = useStore($userSettings)
|
const userSettings = useStore($userSettings)
|
||||||
if (!chartData.systemStats?.length) {
|
if (!chartData.systemStats?.length || chartData.systemStats.at(-1)?.stats.z?.[poolName]?.hi) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
const displayName = chartData.systemStats.at(-1)?.stats.z?.[poolName]?.n || poolName
|
||||||
return (
|
return (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={`${poolName} I/O`}
|
title={`${displayName} I/O`}
|
||||||
description={t`Throughput of ZFS pool ${poolName}`}
|
description={t`Throughput of storage pool ${displayName}`}
|
||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
@@ -114,12 +119,15 @@ export function ZfsPoolIOChart({ systemData, poolName }: { systemData: SystemDat
|
|||||||
export function ZfsCharts({ systemData }: { systemData: SystemData }) {
|
export function ZfsCharts({ systemData }: { systemData: SystemData }) {
|
||||||
const latest = systemData.chartData.systemStats?.at(-1)?.stats
|
const latest = systemData.chartData.systemStats?.at(-1)?.stats
|
||||||
const pools = latest?.z ?? {}
|
const pools = latest?.z ?? {}
|
||||||
if (Object.keys(pools).length === 0) {
|
const visiblePools = Object.keys(pools)
|
||||||
|
.filter((name) => !pools[name].hu || !pools[name].hi)
|
||||||
|
.sort((a, b) => (pools[a].n || a).localeCompare(pools[b].n || b, undefined, { numeric: true }) || a.localeCompare(b))
|
||||||
|
if (visiblePools.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
{Object.keys(pools).map((poolName) => (
|
{visiblePools.map((poolName) => (
|
||||||
<div key={poolName} className="contents">
|
<div key={poolName} className="contents">
|
||||||
<ZfsPoolUsageChart systemData={systemData} poolName={poolName} />
|
<ZfsPoolUsageChart systemData={systemData} poolName={poolName} />
|
||||||
<ZfsPoolIOChart systemData={systemData} poolName={poolName} />
|
<ZfsPoolIOChart systemData={systemData} poolName={poolName} />
|
||||||
@@ -24,7 +24,7 @@ export function LazySmartTable({ systemId }: { systemId: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ZfsTable = lazy(() => import("./zfs-table"))
|
const ZfsTable = lazy(() => import("./storage-pools-table"))
|
||||||
|
|
||||||
export function LazyZfsTable({ systemId }: { systemId: string }) {
|
export function LazyZfsTable({ systemId }: { systemId: string }) {
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { InfoIcon } from "lucide-react"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
export function RawCapacityLabel({ label = t`Raw capacity` }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{label}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t`About raw capacity`}
|
||||||
|
className="inline-flex rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-64">
|
||||||
|
{t`Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled.`}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
CircleAlertIcon,
|
CircleAlertIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
|
DatabaseIcon,
|
||||||
HardDriveDownloadIcon,
|
HardDriveDownloadIcon,
|
||||||
HardDriveIcon,
|
HardDriveIcon,
|
||||||
HardDriveUploadIcon,
|
HardDriveUploadIcon,
|
||||||
@@ -35,10 +36,13 @@ import {
|
|||||||
RotateCwIcon,
|
RotateCwIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
|
FolderTreeIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
|
||||||
const ZFS_POOL_FIELDS = "id,system,name,health,size,alloc,free,scrub,details_updated,updated"
|
import { RawCapacityLabel } from "./raw-capacity-label"
|
||||||
|
|
||||||
|
const ZFS_POOL_FIELDS = "id,system,name,display_name,health,size,alloc,free,raw,scrub,details_updated,updated"
|
||||||
|
|
||||||
/** Maps a zpool health string to a Badge variant. */
|
/** Maps a zpool health string to a Badge variant. */
|
||||||
function healthVariant(health: string): "success" | "warning" | "danger" | "outline" {
|
function healthVariant(health: string): "success" | "warning" | "danger" | "outline" {
|
||||||
@@ -81,13 +85,30 @@ function HeaderButton<T>({ column, name, Icon }: { column: Column<T>; name: stri
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function poolType(pool: ZfsPoolRecord): string {
|
||||||
|
return pool.name.startsWith("b:") ? "Btrfs" : "ZFS"
|
||||||
|
}
|
||||||
|
|
||||||
const columns: ColumnDef<ZfsPoolRecord>[] = [
|
const columns: ColumnDef<ZfsPoolRecord>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
id: "name",
|
||||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
accessorFn: (pool) => pool.display_name || pool.name,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={`Pool`} Icon={HardDriveIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={`Pool`} Icon={DatabaseIcon} />,
|
||||||
cell: ({ getValue }) => <span className="font-medium ms-1.5">{getValue() as string}</span>,
|
cell: ({ getValue }) => <span className="font-medium ms-1.5">{getValue() as string}</span>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "type",
|
||||||
|
accessorFn: poolType,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={FolderTreeIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const type = getValue() as string
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={cn("border-transparent", type === "ZFS" ? "bg-blue-200 text-blue-800" : "bg-yellow-200 text-yellow-800")}>
|
||||||
|
{type}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "health",
|
accessorKey: "health",
|
||||||
sortingFn: (a, b) => a.original.health.localeCompare(b.original.health),
|
sortingFn: (a, b) => a.original.health.localeCompare(b.original.health),
|
||||||
@@ -102,21 +123,21 @@ const columns: ColumnDef<ZfsPoolRecord>[] = [
|
|||||||
accessorFn: (record) => record.size,
|
accessorFn: (record) => record.size,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
||||||
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}</span>,
|
cell: ({ getValue, row }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}{row.original.raw ? ` (${t`Raw`})` : ""}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "used",
|
id: "used",
|
||||||
accessorFn: (record) => record.alloc,
|
accessorFn: (record) => record.alloc,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Used`} Icon={HardDriveDownloadIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Used`} Icon={HardDriveDownloadIcon} />,
|
||||||
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}</span>,
|
cell: ({ getValue, row }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}{row.original.raw ? ` (${t`Raw`})` : ""}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "free",
|
id: "free",
|
||||||
accessorFn: (record) => record.free,
|
accessorFn: (record) => record.free,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t({ message: `Free`, context: "Free space" })} Icon={HardDriveUploadIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t({ message: `Free`, context: "Free space" })} Icon={HardDriveUploadIcon} />,
|
||||||
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}</span>,
|
cell: ({ getValue, row }) => <span className="ms-1.5 tabular-nums">{row.original.raw ? "-" : formatCapacity(getValue() as number)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "scrub",
|
id: "scrub",
|
||||||
@@ -201,7 +222,7 @@ const datasetColumns: ColumnDef<ZfsDataset>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={`Dataset`} Icon={HardDriveIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={`Dataset`} Icon={DatabaseIcon} />,
|
||||||
cell: ({ getValue }) => <span className="font-mono text-xs">{getValue() as string}</span>,
|
cell: ({ getValue }) => <span className="font-mono text-xs">{getValue() as string}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -310,6 +331,7 @@ function PoolSheet({
|
|||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const [pool, setPool] = useState<ZfsPoolRecord | null>(null)
|
const [pool, setPool] = useState<ZfsPoolRecord | null>(null)
|
||||||
|
const titleRef = useRef<HTMLHeadingElement>(null)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -342,23 +364,30 @@ function PoolSheet({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className="w-full sm:max-w-220 gap-0 overflow-y-auto">
|
<SheetContent
|
||||||
|
className="w-full sm:max-w-220 gap-0 overflow-y-auto"
|
||||||
|
onOpenAutoFocus={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
titleRef.current?.focus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SheetHeader className="mb-0 border-b">
|
<SheetHeader className="mb-0 border-b">
|
||||||
<SheetTitle className="flex items-center gap-2">
|
<SheetTitle ref={titleRef} tabIndex={-1} className="flex items-center gap-2 outline-none">
|
||||||
{pool ? pool.name : `ZFS Pool`}
|
{pool ? (pool.display_name || pool.name) : `Storage Pool`}
|
||||||
{pool && <Badge variant={healthVariantValue}>{health}</Badge>}
|
{pool && <Badge variant={healthVariantValue}>{health}</Badge>}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
{pool?.size ? formatCapacity(pool.size) : null}
|
{pool?.size ? formatCapacity(pool.size) : null}
|
||||||
|
{pool?.raw && <RawCapacityLabel />}
|
||||||
{pool?.alloc ? (
|
{pool?.alloc ? (
|
||||||
<>
|
<>
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Used</Trans>: {formatCapacity(pool.alloc)}
|
<Trans>Used</Trans>: {formatCapacity(pool.alloc)}{pool.raw ? ` (${t`Raw`})` : ""}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{pool?.free ? (
|
{pool?.free && !pool.raw ? (
|
||||||
<>
|
<>
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
<span>
|
<span>
|
||||||
@@ -555,6 +584,7 @@ export default function ZfsTable({ systemId }: { systemId?: string }) {
|
|||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: zfsPools || ([] as ZfsPoolRecord[]),
|
data: zfsPools || ([] as ZfsPoolRecord[]),
|
||||||
columns: tableColumns,
|
columns: tableColumns,
|
||||||
|
initialState: { sorting: [{ id: "name", desc: false }] },
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
@@ -562,7 +592,7 @@ export default function ZfsTable({ systemId }: { systemId?: string }) {
|
|||||||
onGlobalFilterChange: setGlobalFilter,
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
globalFilterFn: (row, _columnId, filterValue) => {
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
const pool = row.original
|
const pool = row.original
|
||||||
const searchString = `${pool.name} ${pool.health ?? ""}`.toLowerCase()
|
const searchString = `${pool.display_name ?? ""} ${pool.name} ${poolType(pool)} ${pool.health ?? ""}`.toLowerCase()
|
||||||
return (filterValue as string)
|
return (filterValue as string)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(" ")
|
.split(" ")
|
||||||
@@ -587,7 +617,7 @@ export default function ZfsTable({ systemId }: { systemId?: string }) {
|
|||||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||||
<div className="px-2 sm:px-1">
|
<div className="px-2 sm:px-1">
|
||||||
<CardTitle className="mb-2">ZFS</CardTitle>
|
<CardTitle className="mb-2">Storage Pools</CardTitle>
|
||||||
<CardDescription className="flex">
|
<CardDescription className="flex">
|
||||||
<Trans>Click on a pool to view vdev and dataset details.</Trans>
|
<Trans>Click on a pool to view vdev and dataset details.</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@@ -1768,8 +1768,8 @@ msgid "Throughput of {extraFsName}"
|
|||||||
msgstr "Throughput of {extraFsName}"
|
msgstr "Throughput of {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/zfs-charts.tsx
|
#: src/components/routes/system/charts/zfs-charts.tsx
|
||||||
msgid "Throughput of ZFS pool {poolName}"
|
msgid "Throughput of storage pool {poolName}"
|
||||||
msgstr "Throughput of ZFS pool {poolName}"
|
msgstr "Throughput of storage pool {poolName}"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Time format"
|
msgid "Time format"
|
||||||
@@ -1969,8 +1969,8 @@ msgid "Usage"
|
|||||||
msgstr "Usage"
|
msgstr "Usage"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/zfs-charts.tsx
|
#: src/components/routes/system/charts/zfs-charts.tsx
|
||||||
msgid "Usage of ZFS pool {poolName}"
|
msgid "Usage of storage pool {poolName}"
|
||||||
msgstr "Usage of ZFS pool {poolName}"
|
msgstr "Usage of storage pool {poolName}"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/memory-charts.tsx
|
#: src/components/routes/system/charts/memory-charts.tsx
|
||||||
#: src/components/routes/system/charts/memory-charts.tsx
|
#: src/components/routes/system/charts/memory-charts.tsx
|
||||||
|
|||||||
8
internal/site/src/types.d.ts
vendored
8
internal/site/src/types.d.ts
vendored
@@ -181,6 +181,12 @@ export interface GPUData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ZfsPool {
|
export interface ZfsPool {
|
||||||
|
/** Friendly name; map keys are stable pool identities. */
|
||||||
|
n?: string
|
||||||
|
/** Equivalent filesystem charts are already displayed. */
|
||||||
|
hu?: boolean
|
||||||
|
hi?: boolean
|
||||||
|
raw?: boolean
|
||||||
/** total capacity (GiB) */
|
/** total capacity (GiB) */
|
||||||
d: number
|
d: number
|
||||||
/** allocated (GiB) */
|
/** allocated (GiB) */
|
||||||
@@ -217,6 +223,8 @@ export interface ZfsDataset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ZfsPoolRecord extends RecordModel {
|
export interface ZfsPoolRecord extends RecordModel {
|
||||||
|
display_name?: string
|
||||||
|
raw?: boolean
|
||||||
system: string
|
system: string
|
||||||
name: string
|
name: string
|
||||||
health: string
|
health: string
|
||||||
|
|||||||
Reference in New Issue
Block a user