feat(agent): report btrfs filesystems as storage pools (#2315)

Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
Ani Betts
2026-09-10 01:53:39 +02:00
committed by GitHub
parent 98687be2f2
commit 8d6a5d5f6e
36 changed files with 1990 additions and 681 deletions

View File

@@ -66,6 +66,7 @@ type SystemAlertGPUData struct {
}
type SystemAlertZfsPool struct {
Raw bool `json:"raw,omitempty"`
Total float64 `json:"d"`
Used float64 `json:"du"`
}

View File

@@ -78,7 +78,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
}
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
if usedPct > maxUsedPct {
maxUsedPct = usedPct
@@ -256,7 +256,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
// add zfs pool usage from historical record
for key, pool := range stats.ZfsPools {
if pool.Total > 0 {
if !pool.Raw && pool.Total > 0 {
zfsKey := zfsDiskAlertKey(key)
if _, ok := alert.mapSums[zfsKey]; !ok {
alert.mapSums[zfsKey] = 0.0
@@ -319,6 +319,11 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
if sumPct > maxPct {
maxPct = sumPct
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))
@@ -370,7 +375,7 @@ func zfsDiskAlertKey(poolName string) string {
func diskAlertDescriptor(key string) string {
if poolName, ok := strings.CutPrefix(key, "zfs:"); ok {
return fmt.Sprintf("Usage of ZFS pool %s", poolName)
return fmt.Sprintf("Usage of storage pool %s", poolName)
}
return fmt.Sprintf("Usage of %s", key)
}

View File

@@ -46,12 +46,15 @@ func (am *AlertManager) handleZfsPoolHealthAlert(e *core.RecordEvent, oldHealth
}
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)
message := fmt.Sprintf("ZFS pool %s (%s) was first observed as %s", poolName, systemName, newHealth)
title := fmt.Sprintf("Storage pool %s on %s: %s", newHealth, systemName, poolName)
message := fmt.Sprintf("Storage pool %s (%s) was first observed as %s", poolName, systemName, newHealth)
if oldSeverity > 0 {
message = fmt.Sprintf("ZFS pool %s (%s) health changed from %s to %s", poolName, systemName, oldHealth, newHealth)
message = fmt.Sprintf("Storage pool %s (%s) health changed from %s to %s", poolName, systemName, oldHealth, newHealth)
}
userIDs := systemRecord.GetStringSlice("users")
@@ -116,7 +119,7 @@ func createZfsPoolHistoryRecord(app core.App, userID, systemID, alertID, poolNam
record.Set("user", userID)
record.Set("system", systemID)
record.Set("alert_id", alertID)
record.Set("name", "ZFS Pool: "+poolName)
record.Set("name", "Storage Pool: "+poolName)
return app.Save(record)
}

View File

@@ -143,3 +143,37 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
assert.False(t, diskAlert.GetBool("triggered"),
"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()
}
}

View File

@@ -10,6 +10,6 @@ import (
func TestZfsDiskAlertKeyIsNamespaced(t *testing.T) {
assert.Equal(t, "zfs:tank", zfsDiskAlertKey("tank"))
assert.Equal(t, "Usage of ZFS pool tank", diskAlertDescriptor(zfsDiskAlertKey("tank")))
assert.Equal(t, "Usage of storage pool tank", diskAlertDescriptor(zfsDiskAlertKey("tank")))
assert.Equal(t, "Usage of tank", diskAlertDescriptor("tank"))
}

View File

@@ -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")
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.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")
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) {
@@ -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})
assert.NoError(t, err)
require.Len(t, history, 1, "expected one history entry per user")
assert.Equal(t, "ZFS Pool: tank", history[0].GetString("name"))
assert.Equal(t, "Storage Pool: tank", history[0].GetString("name"))
assert.Equal(t, system.Id, history[0].GetString("system"))
}

View File

@@ -59,11 +59,15 @@ type Stats struct {
// ZfsPool holds per-pool ZFS metrics for a single collection interval.
type ZfsPool struct {
Total float64 `json:"d" cbor:"0,keyasint"` // total capacity in GiB
Used float64 `json:"du" cbor:"1,keyasint"` // allocated in GiB
ReadBytes uint64 `json:"rb,omitzero" cbor:"2,keyasint,omitzero"` // read throughput in bytes/s
WriteBytes uint64 `json:"wb,omitzero" cbor:"3,keyasint,omitzero"` // write throughput in bytes/s
Health string `json:"h,omitempty" cbor:"4,keyasint,omitempty"` // ONLINE, DEGRADED, FAULTED, ...
DisplayName string `json:"n,omitempty" cbor:"8,keyasint,omitempty"`
HideUsage bool `json:"hu,omitempty" cbor:"6,keyasint,omitempty"` // equivalent filesystem usage chart exists
HideIO bool `json:"hi,omitempty" cbor:"7,keyasint,omitempty"` // equivalent filesystem I/O chart exists
Raw bool `json:"raw,omitempty" cbor:"5,keyasint,omitempty"`
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.

View File

@@ -1,23 +1,47 @@
// Package zfs defines the ZFS detail data exchanged between agent and hub.
package zfs
import "strings"
// ZfsData is the detail payload returned by the agent for the GetZfsData action.
type ZfsData struct {
Pools []*PoolDetail `json:"pools,omitempty"`
Complete bool `json:"complete,omitempty"`
// 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,
// scrub, vdev, and dataset information.
type PoolDetail struct {
Name string `json:"name"`
Health string `json:"health,omitempty"`
Size uint64 `json:"size,omitempty"` // bytes
Alloc uint64 `json:"alloc,omitempty"` // bytes
Free uint64 `json:"free,omitempty"` // bytes
Scrub *Scrub `json:"scrub,omitempty"`
Vdevs []*Vdev `json:"vdevs,omitempty"`
Datasets []*Dataset `json:"datasets,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Raw bool `json:"raw,omitempty"`
Name string `json:"name"`
Health string `json:"health,omitempty"`
Size uint64 `json:"size,omitempty"` // bytes
Alloc uint64 `json:"alloc,omitempty"` // bytes
Free uint64 `json:"free,omitempty"` // bytes
Scrub *Scrub `json:"scrub,omitempty"`
Vdevs []*Vdev `json:"vdevs,omitempty"`
Datasets []*Dataset `json:"datasets,omitempty"`
}
// Scrub holds the scrub (or resilver) status of a pool.

View File

@@ -32,13 +32,12 @@ func (sys *System) FetchAndSaveZfsPools(force bool) error {
sys.recordZfsFetchResult(err, 0)
return err
}
if zfsData == nil || !zfsData.Complete {
err = errIncompleteZfsData
sys.recordZfsFetchResult(err, 0)
return err
}
err = sys.saveZfsPools(zfsData)
sys.recordZfsFetchResult(err, len(zfsData.Pools))
poolCount := 0
if zfsData != nil {
poolCount = len(zfsData.Pools)
}
sys.recordZfsFetchResult(err, poolCount)
return err
}
@@ -79,7 +78,7 @@ func (sys *System) zfsFetchInterval() time.Duration {
// saveZfsPools saves ZFS pool detail data to the zfs_pools collection and
// removes records for pools no longer reported by a complete agent inventory.
func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
if zfsData == nil || !zfsData.Complete {
if zfsData == nil || (!zfsData.CanRefreshPool("zfs") && !zfsData.CanRefreshPool("b:")) {
return errIncompleteZfsData
}
@@ -89,10 +88,10 @@ func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
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))
for _, pool := range zfsData.Pools {
if pool == nil {
if pool == nil || !zfsData.CanRefreshPool(pool.Name) {
continue
}
alive[pool.Name] = true
@@ -111,7 +110,7 @@ func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
return err
}
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 {
return err
}
@@ -119,6 +118,14 @@ func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
}
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 {
@@ -135,10 +142,12 @@ func (sys *System) upsertZfsPoolRecord(app core.App, collection *core.Collection
record.Set("system", sys.Id)
record.Set("name", pool.Name)
record.Set("display_name", pool.DisplayName)
record.Set("health", pool.Health)
record.Set("size", pool.Size)
record.Set("alloc", pool.Alloc)
record.Set("free", pool.Free)
record.Set("raw", pool.Raw)
record.Set("scrub", pool.Scrub)
record.Set("vdevs", pool.Vdevs)
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("system", sys.Id)
record.Set("name", name)
record.Set("display_name", pool.DisplayName)
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))
@@ -181,10 +192,15 @@ func (sys *System) syncZfsPoolHealth(app core.App, pools map[string]*system.ZfsP
}
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
}
record.Set("display_name", pool.DisplayName)
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 {
return fmt.Errorf("updating ZFS pool health %q: %w", name, err)
}

View File

@@ -123,6 +123,44 @@ func TestSaveZfsPoolsIncompletePreservesRecords(t *testing.T) {
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) {
sys, app := newTestSystemWithHub(t)
collection, err := app.FindCachedCollectionByNameOrId("zfs_pools")
@@ -151,3 +189,42 @@ func TestSyncZfsPoolHealthWritesOnlyTransitions(t *testing.T) {
require.NoError(t, err)
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)
}

View 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)
})
}

View File

@@ -198,6 +198,7 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
var fanSums map[string]uint64
fanCount := uint64(0)
zfsPoolCounts := make(map[string]uint64)
zfsCapacityCounts := make(map[string]uint64)
// Accumulate totals
for i := range records {
@@ -350,9 +351,19 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
}
pool := sum.ZfsPools[name]
if pool == nil {
pool = &system.ZfsPool{}
pool = &system.ZfsPool{HideUsage: value.HideUsage, HideIO: value.HideIO}
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.Used += value.Used
pool.ReadBytes += value.ReadBytes
@@ -476,8 +487,8 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
// Average ZFS pool stats.
for name, pool := range sum.ZfsPools {
entryCount := zfsPoolCounts[name]
pool.Total = twoDecimals(pool.Total / float64(entryCount))
pool.Used = twoDecimals(pool.Used / float64(entryCount))
pool.Total = twoDecimals(pool.Total / float64(zfsCapacityCounts[name]))
pool.Used = twoDecimals(pool.Used / float64(zfsCapacityCounts[name]))
pool.ReadBytes /= entryCount
pool.WriteBytes /= entryCount
}

View File

@@ -889,3 +889,34 @@ func TestAverageContainerStatsSlice_ManyContainers(t *testing.T) {
assert.Equal(t, 35.0, result[2].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)
}

View File

@@ -8,7 +8,7 @@ import { useSystemData } from "./system/use-system-data"
import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts"
import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts"
import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
import { ZfsCharts } from "./system/charts/zfs-charts"
import { ZfsCharts } from "./system/charts/storage-pool-charts"
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"

View File

@@ -95,7 +95,7 @@ export function ChartCard({
className,
}: {
title: string
description: string
description: React.ReactNode
children: React.ReactNode
grid?: boolean
empty?: boolean

View File

@@ -3,6 +3,7 @@ import AreaChartDefault from "@/components/charts/area-chart"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { SystemStatsRecord } from "@/types"
import { ChartCard } from "../chart-card"
import { RawCapacityLabel } from "../raw-capacity-label"
import { Unit } from "@/lib/enums"
import { useStore } from "@nanostores/react"
import { $userSettings } from "@/lib/stores"
@@ -10,9 +11,11 @@ import type { SystemData } from "../use-system-data"
// Accessors for ZFS metrics
const poolUsage =
(name: string) =>
({ stats }: SystemStatsRecord) =>
stats?.z?.[name]?.du ?? 0
(name: string, raw: boolean) =>
({ stats }: SystemStatsRecord) => {
const pool = stats?.z?.[name]
return pool && !!pool.raw === raw ? pool.du : null
}
const poolRead =
(name: string) =>
({ stats }: SystemStatsRecord) =>
@@ -26,9 +29,10 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
const { chartData, grid, dataEmpty } = systemData
const latest = chartData.systemStats.at(-1)?.stats
const pool = latest?.z?.[poolName]
if (!pool) {
if (!pool || pool.hu) {
return null
}
const displayName = pool.n || poolName
let poolTotal = pool.d
// round to nearest GB
if (poolTotal >= 100) {
@@ -39,8 +43,8 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
<ChartCard
empty={dataEmpty}
grid={grid}
title={`${poolName} ${t`Usage`}`}
description={t`Usage of ZFS pool ${poolName}`}
title={`${displayName} ${t`Usage`}`}
description={pool.raw ? <RawCapacityLabel label={t`Raw usage of storage pool ${displayName}`} /> : t`Usage of storage pool ${displayName}`}
>
<AreaChartDefault
chartData={chartData}
@@ -57,7 +61,7 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
dataPoints={[
{
label: t`Pool Usage`,
dataKey: poolUsage(poolName),
dataKey: poolUsage(poolName, !!pool.raw),
color: 4,
opacity: 0.4,
},
@@ -70,15 +74,16 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
export function ZfsPoolIOChart({ systemData, poolName }: { systemData: SystemData; poolName: string }) {
const { chartData, grid, dataEmpty } = systemData
const userSettings = useStore($userSettings)
if (!chartData.systemStats?.length) {
if (!chartData.systemStats?.length || chartData.systemStats.at(-1)?.stats.z?.[poolName]?.hi) {
return null
}
const displayName = chartData.systemStats.at(-1)?.stats.z?.[poolName]?.n || poolName
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={`${poolName} I/O`}
description={t`Throughput of ZFS pool ${poolName}`}
title={`${displayName} I/O`}
description={t`Throughput of storage pool ${displayName}`}
>
<AreaChartDefault
chartData={chartData}
@@ -114,12 +119,15 @@ export function ZfsPoolIOChart({ systemData, poolName }: { systemData: SystemDat
export function ZfsCharts({ systemData }: { systemData: SystemData }) {
const latest = systemData.chartData.systemStats?.at(-1)?.stats
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 (
<div className="grid xl:grid-cols-2 gap-4">
{Object.keys(pools).map((poolName) => (
{visiblePools.map((poolName) => (
<div key={poolName} className="contents">
<ZfsPoolUsageChart systemData={systemData} poolName={poolName} />
<ZfsPoolIOChart systemData={systemData} poolName={poolName} />

View File

@@ -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 }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })

View File

@@ -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>
)
}

View File

@@ -26,6 +26,7 @@ import {
CheckCircleIcon,
CircleAlertIcon,
ClockIcon,
DatabaseIcon,
HardDriveDownloadIcon,
HardDriveIcon,
HardDriveUploadIcon,
@@ -35,10 +36,13 @@ import {
RotateCwIcon,
XCircleIcon,
XIcon,
FolderTreeIcon,
} 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. */
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>[] = [
{
accessorKey: "name",
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
header: ({ column }) => <HeaderButton column={column} name={`Pool`} Icon={HardDriveIcon} />,
id: "name",
accessorFn: (pool) => pool.display_name || pool.name,
header: ({ column }) => <HeaderButton column={column} name={`Pool`} Icon={DatabaseIcon} />,
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",
sortingFn: (a, b) => a.original.health.localeCompare(b.original.health),
@@ -102,21 +123,21 @@ const columns: ColumnDef<ZfsPoolRecord>[] = [
accessorFn: (record) => record.size,
invertSorting: true,
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",
accessorFn: (record) => record.alloc,
invertSorting: true,
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",
accessorFn: (record) => record.free,
invertSorting: true,
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",
@@ -201,7 +222,7 @@ const datasetColumns: ColumnDef<ZfsDataset>[] = [
{
accessorKey: "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>,
},
{
@@ -310,6 +331,7 @@ function PoolSheet({
onOpenChange: (open: boolean) => void
}) {
const [pool, setPool] = useState<ZfsPoolRecord | null>(null)
const titleRef = useRef<HTMLHeadingElement>(null)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
@@ -342,23 +364,30 @@ function PoolSheet({
return (
<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">
<SheetTitle className="flex items-center gap-2">
{pool ? pool.name : `ZFS Pool`}
<SheetTitle ref={titleRef} tabIndex={-1} className="flex items-center gap-2 outline-none">
{pool ? (pool.display_name || pool.name) : `Storage Pool`}
{pool && <Badge variant={healthVariantValue}>{health}</Badge>}
</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
{pool?.size ? formatCapacity(pool.size) : null}
{pool?.raw && <RawCapacityLabel />}
{pool?.alloc ? (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<span>
<Trans>Used</Trans>: {formatCapacity(pool.alloc)}
<Trans>Used</Trans>: {formatCapacity(pool.alloc)}{pool.raw ? ` (${t`Raw`})` : ""}
</span>
</>
) : null}
{pool?.free ? (
{pool?.free && !pool.raw ? (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<span>
@@ -555,6 +584,7 @@ export default function ZfsTable({ systemId }: { systemId?: string }) {
const table = useReactTable({
data: zfsPools || ([] as ZfsPoolRecord[]),
columns: tableColumns,
initialState: { sorting: [{ id: "name", desc: false }] },
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
@@ -562,7 +592,7 @@ export default function ZfsTable({ systemId }: { systemId?: string }) {
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => {
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)
.toLowerCase()
.split(" ")
@@ -587,7 +617,7 @@ export default function ZfsTable({ systemId }: { systemId?: string }) {
<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="px-2 sm:px-1">
<CardTitle className="mb-2">ZFS</CardTitle>
<CardTitle className="mb-2">Storage Pools</CardTitle>
<CardDescription className="flex">
<Trans>Click on a pool to view vdev and dataset details.</Trans>
</CardDescription>

View File

@@ -1768,8 +1768,8 @@ msgid "Throughput of {extraFsName}"
msgstr "Throughput of {extraFsName}"
#: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of ZFS pool {poolName}"
msgstr "Throughput of ZFS pool {poolName}"
msgid "Throughput of storage pool {poolName}"
msgstr "Throughput of storage pool {poolName}"
#: src/components/routes/settings/general.tsx
msgid "Time format"
@@ -1969,8 +1969,8 @@ msgid "Usage"
msgstr "Usage"
#: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of ZFS pool {poolName}"
msgstr "Usage of ZFS pool {poolName}"
msgid "Usage of storage pool {poolName}"
msgstr "Usage of storage pool {poolName}"
#: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx

View File

@@ -181,6 +181,12 @@ export interface GPUData {
}
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) */
d: number
/** allocated (GiB) */
@@ -217,6 +223,8 @@ export interface ZfsDataset {
}
export interface ZfsPoolRecord extends RecordModel {
display_name?: string
raw?: boolean
system: string
name: string
health: string