feat: add ZFS monitoring (#2209)

- track pool capacity, health, I/O, scrub status, and vdev errors
- report dataset usage and correct ZFS filesystem metrics
- add pool charts, detail views, refresh controls, and health alerts
- persist pool details and include ZFS usage in disk alerts
- support configurable detail intervals and legacy agent compatibility

---------

Co-authored-by: hank <hank@henrygd.me>
This commit is contained in:
Tamás Vince
2026-09-01 18:19:36 +02:00
committed by GitHub
parent b38fb7dafa
commit 917d069ab3
46 changed files with 3768 additions and 15 deletions

View File

@@ -127,6 +127,7 @@ func (rm *RecordManager) CreateLongerRecords() {
"created": shorterRecordPeriod,
},
)).
OrderBy("created").
All(&recordIds)
// continue if not enough shorter records
@@ -196,6 +197,7 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
tempCount := float64(0)
var fanSums map[string]uint64
fanCount := uint64(0)
zfsPoolCounts := make(map[string]uint64)
// Accumulate totals
for i := range records {
@@ -336,6 +338,31 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
}
}
// Accumulate ZFS pool stats. Counts are tracked per entry so a pool
// missing from some samples is not averaged as zero.
if stats.ZfsPools != nil {
if sum.ZfsPools == nil {
sum.ZfsPools = make(map[string]*system.ZfsPool, len(stats.ZfsPools))
}
for name, value := range stats.ZfsPools {
if value == nil {
continue
}
pool := sum.ZfsPools[name]
if pool == nil {
pool = &system.ZfsPool{}
sum.ZfsPools[name] = pool
}
pool.Total += value.Total
pool.Used += value.Used
pool.ReadBytes += value.ReadBytes
pool.WriteBytes += value.WriteBytes
if value.Health != "" {
pool.Health = value.Health
}
zfsPoolCounts[name]++
}
}
// Accumulate GPU data
if stats.GPUData != nil {
if sum.GPUData == nil {
@@ -446,6 +473,14 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
}
}
// Average ZFS pool stats.
for name, pool := range sum.ZfsPools {
entryCount := zfsPoolCounts[name]
pool.Total = twoDecimals(pool.Total / float64(entryCount))
pool.Used = twoDecimals(pool.Used / float64(entryCount))
pool.ReadBytes /= entryCount
pool.WriteBytes /= entryCount
}
// Average GPU data
if sum.GPUData != nil {
for id := range sum.GPUData {

View File

@@ -669,6 +669,33 @@ func TestAverageSystemStatsSlice_MixedOptionalFields(t *testing.T) {
assert.Equal(t, 20.0, result.GPUData["gpu0"].Usage)
}
func TestAverageSystemStatsSlice_Zfs(t *testing.T) {
input := []system.Stats{
{
ZfsPools: map[string]*system.ZfsPool{
"tank": {Total: 100, Used: 40, ReadBytes: 100, WriteBytes: 200, Health: "ONLINE"},
},
},
{},
{
ZfsPools: map[string]*system.ZfsPool{
"tank": {Total: 120, Used: 60, ReadBytes: 300, WriteBytes: 400, Health: "DEGRADED"},
"backup": {Total: 50, Used: 10, ReadBytes: 25, WriteBytes: 50, Health: "ONLINE"},
},
},
}
result := records.AverageSystemStatsSlice(input)
require.Len(t, result.ZfsPools, 2)
assert.Equal(t, &system.ZfsPool{
Total: 110, Used: 50, ReadBytes: 200, WriteBytes: 300, Health: "DEGRADED",
}, result.ZfsPools["tank"])
assert.Equal(t, &system.ZfsPool{
Total: 50, Used: 10, ReadBytes: 25, WriteBytes: 50, Health: "ONLINE",
}, result.ZfsPools["backup"])
}
// Tests with 10 records matching the common real-world case (10 x 1m -> 1 x 10m).
func TestAverageSystemStatsSlice_TenRecords(t *testing.T) {
input := make([]system.Stats, 10)