From 50768e2d94133cd613166c0e59c7b5c7aa20710f Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sun, 25 Oct 2026 22:34:11 -0700 Subject: [PATCH] ZFS tab: pools, caching tiers and writes full width; write and drain rates measured between real updates A txg records its bytes only when it syncs and the mover reports every 2 s, so a per-sample delta read 0 most of the time. Rates are now taken over the gap between syncs (or mover updates) and held until the next one. Co-Authored-By: Claude Opus 5.5 --- internal/metrics/zfswrite.go | 41 ++++++++++++++++++++++++++++-------- web/src/ZFS.tsx | 6 +++--- web/src/styles.css | 1 + 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/internal/metrics/zfswrite.go b/internal/metrics/zfswrite.go index 1bb8846..bd489e5 100644 --- a/internal/metrics/zfswrite.go +++ b/internal/metrics/zfswrite.go @@ -109,10 +109,14 @@ type zfsWritePrev struct { at time.Time delay uint64 overMax uint64 - txg map[string]uint64 // pool -> highest synced txg counted - logical map[string]uint64 // pool -> sum of objset nwritten - drained map[string]uint64 // landing branch -> moved + partial bytes - landing map[string]Landing // branch -> last walk + txg map[string]uint64 // pool -> highest synced txg counted + txgAt map[string]time.Time // pool -> when that txg was first seen + txgRate map[string]float64 // pool -> bytes/s over the last gap between syncs + logical map[string]uint64 // pool -> sum of objset nwritten + drained map[string]uint64 // landing branch -> moved + partial bytes, at the mover's last update + drainedAt map[string]int64 // landing branch -> that update's time, unix ms + drainRate map[string]float64 // landing branch -> bytes/s between its last two updates + landing map[string]Landing // branch -> last walk landingAt time.Time } @@ -378,6 +382,7 @@ func (c *Collector) zfsWrites(now time.Time, arc *ARC, disks []Disk) *ZFSWrites p.delay, p.overMax = tx["dmu_tx_dirty_delay"], tx["dmu_tx_dirty_over_max"] if p.txg == nil { p.txg, p.logical, p.drained, p.landing = map[string]uint64{}, map[string]uint64{}, map[string]uint64{}, map[string]Landing{} + p.txgAt, p.txgRate, p.drainedAt, p.drainRate = map[string]time.Time{}, map[string]float64{}, map[string]int64{}, map[string]float64{} } ms := readMounts() @@ -394,11 +399,22 @@ func (c *Collector) zfsWrites(now time.Time, arc *ARC, disks []Disk) *ZFSWrites for _, pool := range poolsWithKstats() { pw := PoolWrites{Pool: pool} + // A txg records what it wrote only when it has synced — every few seconds, not every + // sample — so the rate is taken over the gap between syncs and held until the next one. prevTop, had := p.txg[pool] written, top := parseTxgs(readStr(zfsKstatDir+pool+"/txgs"), prevTop) - if had && dt > 0 { - pw.WriteBps = round1(float64(written) / dt) + switch { + case !had: + p.txgAt[pool] = now + case top > prevTop: + if gap := now.Sub(p.txgAt[pool]).Seconds(); gap > 0 { + p.txgRate[pool] = round1(float64(written) / gap) + } + p.txgAt[pool] = now + case now.Sub(p.txgAt[pool]) > time.Duration(2*max(w.TxgTimeoutSec, 5))*time.Second: + p.txgRate[pool] = 0 // no sync for two timeouts: nothing is being written } + pw.WriteBps = p.txgRate[pool] p.txg[pool] = top lw := objsetWritten(pool) if prev, ok := p.logical[pool]; ok && dt > 0 && lw >= prev { @@ -418,11 +434,18 @@ func (c *Collector) zfsWrites(now time.Time, arc *ARC, disks []Disk) *ZFSWrites l.InBps = diskW[l.Device] if mv := movers[filepath.Clean(l.Branch)]; mv != nil { l.Mover = mv + // the mover writes its status every couple of seconds: measure between its updates done := mv.MovedBytes + mv.Partial - if prev, ok := p.drained[l.Branch]; ok && dt > 0 && done >= prev { - l.DrainBps = round1(float64(done-prev) / dt) + if at, ok := p.drainedAt[l.Branch]; !ok || mv.UpdatedMs != at { + if ok && mv.UpdatedMs > at && done >= p.drained[l.Branch] { + p.drainRate[l.Branch] = round1(float64(done-p.drained[l.Branch]) / (float64(mv.UpdatedMs-at) / 1000)) + } + p.drained[l.Branch], p.drainedAt[l.Branch] = done, mv.UpdatedMs } - p.drained[l.Branch] = done + if mv.State != "moving" { + p.drainRate[l.Branch] = 0 + } + l.DrainBps = p.drainRate[l.Branch] } pw.Landing = &l } diff --git a/web/src/ZFS.tsx b/web/src/ZFS.tsx index b022b1a..927dc01 100644 --- a/web/src/ZFS.tsx +++ b/web/src/ZFS.tsx @@ -568,13 +568,13 @@ function ARCSection() { All reads{fmtPct(hitPct(L.Hits, L.Misses))}{fmtN(L.Hits)}{fmtN(L.Misses)} - {d.pools.length > 0 &&
{d.pools.map((p) => )}
} + {d.pools.length > 0 &&
{d.pools.map((p) => )}
} {v.writes && v.writes.pools.length > 0 && ( <>

Writes

what is waiting to reach each pool, and how fast it is getting there
-
{v.writes.pools.map((p) => )}
+
{v.writes.pools.map((p) => )}
)} @@ -607,7 +607,7 @@ export default function ZFS() {

Pools

{zfs.pools.length === 0 &&
ZFS is present but no pools are imported.
} -
{zfs.pools.map((p) => )}
+
{zfs.pools.map((p) => )}

Cache

the ARC in RAM, and what each pool has in front of its disks diff --git a/web/src/styles.css b/web/src/styles.css index c9351d2..c243591 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -99,6 +99,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .dash > * { flex-shrink: 0; } .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; } .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; align-items: start; } +.cards.full { grid-template-columns: minmax(0, 1fr); } /* one card per row, the width of the page */ .tile, .card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; min-width: 0; } .tile .label, .card h2 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; } .tile .value { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.2; }