The ZFS tab shows the ARC, and names every caching tier a pool has or lacks

A Cache section between the pools and the schedules. One card for the ARC:
size against its ceiling with the target and floor, sparklines of size and
hit rate from the sampler's history, what it holds (MRU/MFU/dirty, data vs
metadata, compression, the ghost lists), the prefetcher and dbuf cache, the
memory-pressure counters, and the since-boot breakdown by demand/prefetch
and data/metadata. Then a card per pool for L2ARC, SLOG, special vdev,
dedup table and spares — the devices and their fill where they exist, and
where they don't, plainly none and what that means (the ZIL writing to the
pool's own vdevs, with how much it has written).

Live numbers come from the sampler, which reads arcstats every tick and
publishes zfs.arc.* sensors; the fuller picture is read on request, with
zpool list -Hpv and zfs get run without sudo and cached for thirty seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-02 18:30:02 -07:00
co-authored by Claude Fable 5.1
parent db40220365
commit b44faf3ac9
13 changed files with 736 additions and 2 deletions
+1
View File
@@ -295,6 +295,7 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub))
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
m.HandleFunc("GET /api/zfs", s.auth(s.zfsOverview))
m.HandleFunc("GET /api/zfs/arc", s.auth(s.zfsARC))
m.HandleFunc("PUT /api/zfs/schedules", s.auth(s.zfsPolicyPut))
m.HandleFunc("DELETE /api/zfs/schedules/{id}", s.auth(s.zfsPolicyDelete))
m.HandleFunc("POST /api/zfs/schedules/{id}/run", s.auth(s.zfsRunNow))
+11
View File
@@ -136,6 +136,17 @@ func (s *Server) zfsOverview(w http.ResponseWriter, r *http.Request) {
})
}
// zfsARC is the cache: the sampler's live ARC (size, the hit rate over the last interval)
// beside the storage package's fuller picture (since-boot counters, tunables, and each
// pool's cache/log/special devices or the lack of them).
func (s *Server) zfsARC(w http.ResponseWriter, r *http.Request) {
out := map[string]any{"detail": s.storage.ARC(r.Context()), "live": nil}
if smp, ok := s.metrics.Latest(); ok && smp.ARC != nil {
out["live"] = smp.ARC
}
jsonOK(w, 200, out)
}
func (s *Server) zfsPolicyPut(w http.ResponseWriter, r *http.Request) {
var p storage.Policy
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&p); err != nil {
+113
View File
@@ -0,0 +1,113 @@
package metrics
// The ZFS ARC — the read cache in RAM — read live from /proc/spl/kstat/zfs/arcstats on a
// machine that has the module loaded, and left out of the sample everywhere else. The kstat
// file is counters since boot; the hit rate here is over the sampling interval, which is the
// number that says whether the cache is doing its job *now*. The lifetime figures and the
// pool's caching tiers (L2ARC, SLOG, special vdevs) are the storage package's business.
import (
"strconv"
"strings"
"time"
)
const arcstatsPath = "/proc/spl/kstat/zfs/arcstats"
// ARC is the read cache as one sample sees it. Sizes are bytes.
type ARC struct {
Size uint64 `json:"size"`
Target uint64 `json:"target"` // c: what the ARC is currently allowed to hold
Min uint64 `json:"min"` // c_min: the floor it will not shrink below
Max uint64 `json:"max"` // c_max: the ceiling (zfs_arc_max, or half of RAM)
// what is in it
MRU uint64 `json:"mru"` // recently used once
MFU uint64 `json:"mfu"` // used more than once
Anon uint64 `json:"anon"` // in flight: dirty, not yet on disk
Data uint64 `json:"data"` // file contents
Metadata uint64 `json:"metadata"` // dnodes, indirect blocks, dbufs, bonus buffers
MetaUsed uint64 `json:"meta_used"`
Headers uint64 `json:"headers"`
Compressed uint64 `json:"compressed"`
Uncompressed uint64 `json:"uncompressed"`
MRUGhost uint64 `json:"mru_ghost"` // what it recently evicted and still remembers
MFUGhost uint64 `json:"mfu_ghost"`
// L2ARC, the cache devices (0 when there are none)
L2Size uint64 `json:"l2_size"`
L2ASize uint64 `json:"l2_asize"` // as stored on the device, after compression
// over this interval
ReadsPerSec float64 `json:"reads_per_sec"`
MissesPerSec float64 `json:"misses_per_sec"`
HitPct float64 `json:"hit_pct"` // of the interval's reads; an idle interval keeps the last one
HitPctKnown bool `json:"hit_pct_known"` // false until an interval has had any reads
L2HitPct float64 `json:"l2_hit_pct"`
// since boot
Hits uint64 `json:"hits"`
Misses uint64 `json:"misses"`
LifetimeHitPct float64 `json:"lifetime_hit_pct"`
// pressure
NoGrow bool `json:"no_grow"` // arc_no_grow: memory pressure is stopping it growing
Throttles uint64 `json:"throttles"` // memory_throttle_count since boot
Prunes uint64 `json:"prunes"` // arc_prune: metadata evictions asked of the filesystems
EvictSkip uint64 `json:"evict_skip"`
}
type arcPrev struct {
hits, misses, l2hits, l2misses uint64
hitPct float64
known bool
at time.Time
}
// readKstat parses a kstat table ("name type data" rows after two header lines).
func readKstat(p string) map[string]uint64 {
s := readStr(p)
if s == "" {
return nil
}
out := map[string]uint64{}
for _, line := range strings.Split(s, "\n") {
f := strings.Fields(line)
if len(f) != 3 || f[1] != "4" { // type 4 is KSTAT_DATA_UINT64
continue
}
v, err := strconv.ParseUint(f[2], 10, 64)
if err == nil {
out[f[0]] = v
}
}
return out
}
// arc reads the ARC; nil when this machine has no ZFS module loaded.
func (c *Collector) arc(now time.Time) *ARC {
k := readKstat(arcstatsPath)
if k == nil {
return nil
}
a := &ARC{Size: k["size"], Target: k["c"], Min: k["c_min"], Max: k["c_max"],
MRU: k["mru_size"], MFU: k["mfu_size"], Anon: k["anon_size"], Data: k["data_size"], Metadata: k["metadata_size"],
MetaUsed: k["arc_meta_used"], Headers: k["hdr_size"], Compressed: k["compressed_size"], Uncompressed: k["uncompressed_size"],
MRUGhost: k["mru_ghost_size"], MFUGhost: k["mfu_ghost_size"], L2Size: k["l2_size"], L2ASize: k["l2_asize"],
Hits: k["hits"], Misses: k["misses"], NoGrow: k["arc_no_grow"] != 0, Throttles: k["memory_throttle_count"], Prunes: k["arc_prune"], EvictSkip: k["evict_skip"]}
if n := a.Hits + a.Misses; n > 0 {
a.LifetimeHitPct = round1(float64(a.Hits) / float64(n) * 100)
}
p := c.prevARC
if !p.at.IsZero() && a.Hits >= p.hits && a.Misses >= p.misses {
if dt := now.Sub(p.at).Seconds(); dt > 0 {
hits, misses := a.Hits-p.hits, a.Misses-p.misses
a.ReadsPerSec = round1(float64(hits+misses) / dt)
a.MissesPerSec = round1(float64(misses) / dt)
if hits+misses > 0 {
p.hitPct, p.known = round1(float64(hits)/float64(hits+misses)*100), true
}
if l2 := (k["l2_hits"] - p.l2hits) + (k["l2_misses"] - p.l2misses); l2 > 0 && k["l2_hits"] >= p.l2hits {
a.L2HitPct = round1(float64(k["l2_hits"]-p.l2hits) / float64(l2) * 100)
}
}
}
a.HitPct, a.HitPctKnown = p.hitPct, p.known
c.prevARC = arcPrev{hits: a.Hits, misses: a.Misses, l2hits: k["l2_hits"], l2misses: k["l2_misses"], hitPct: p.hitPct, known: p.known, at: now}
return a
}
+18
View File
@@ -3,6 +3,7 @@ package metrics
import (
"encoding/json"
"os"
"strings"
"testing"
"time"
)
@@ -134,3 +135,20 @@ func TestLiveGPU(t *testing.T) {
t.Logf("%s = %v %s", x.ID, x.Value, x.Unit)
}
}
// TestLiveARC: HAMLAB_LIVE=1 go test -run TestLiveARC -v ./internal/metrics
func TestLiveARC(t *testing.T) {
if os.Getenv("HAMLAB_LIVE") == "" {
t.Skip("set HAMLAB_LIVE=1 to read the real machine")
}
c := NewCollector()
c.sample()
time.Sleep(1100 * time.Millisecond)
c.sample()
s, _ := c.Latest()
b, _ := json.MarshalIndent(s.ARC, "", " ")
t.Logf("%s", b)
for _, x := range flatten(s, func(id string) bool { return strings.HasPrefix(id, "zfs.arc.") }) {
t.Logf("%s = %v %s", x.ID, x.Value, x.Unit)
}
}
+3
View File
@@ -147,6 +147,7 @@ type Sample struct {
FS []FS `json:"fs"`
UPS *UPS `json:"ups"`
GPUs []GPU `json:"gpus"`
ARC *ARC `json:"arc"` // the ZFS read cache; nil without the module
Uptime float64 `json:"uptime"`
fsT, upsT int64 // when the slow sections were really read; sensors report these, not T
@@ -176,6 +177,7 @@ type Collector struct {
lastTable *ProcTable
upsProbe func() *UPS // nil when no UPS daemon is installed
gpu gpuState
prevARC arcPrev
subMu sync.Mutex
subs map[chan struct{}]struct{} // told after every sample; see Subscribe
@@ -360,6 +362,7 @@ func (c *Collector) sample() {
}
s.UPS, s.upsT = c.lastUPS, c.upsAt.UnixMilli()
s.GPUs = c.gpus(now, cpuTemp(s.Temps))
s.ARC = c.arc(now)
if first {
return // deltas are meaningless on the priming pass
+27
View File
@@ -173,6 +173,33 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
f.add(p+".clients", g.Name+" clients", "count", "", float64(len(g.Clients)), t, none, lbl...)
}
if a := s.ARC; a != nil {
p := "zfs.arc."
cap := upTo(float64(a.Max))
f.add(p+"size", "ARC size", "bytes", "B", float64(a.Size), t, cap)
f.add(p+"target", "ARC target", "bytes", "B", float64(a.Target), t, cap)
f.add(p+"min", "ARC floor", "bytes", "B", float64(a.Min), t, none)
f.add(p+"max", "ARC ceiling", "bytes", "B", float64(a.Max), t, none)
f.add(p+"mru", "ARC recently used", "bytes", "B", float64(a.MRU), t, cap)
f.add(p+"mfu", "ARC frequently used", "bytes", "B", float64(a.MFU), t, cap)
f.add(p+"metadata", "ARC metadata", "bytes", "B", float64(a.Metadata), t, cap)
f.add(p+"data", "ARC data", "bytes", "B", float64(a.Data), t, cap)
if a.HitPctKnown {
f.add(p+"hit_pct", "ARC hit rate", "usage", "%", a.HitPct, t, pct)
}
f.add(p+"reads", "ARC reads", "rate", "/s", a.ReadsPerSec, t, none)
f.add(p+"misses", "ARC misses", "rate", "/s", a.MissesPerSec, t, none)
f.add(p+"lifetime_hit_pct", "ARC hit rate since boot", "usage", "%", a.LifetimeHitPct, t, pct)
f.add(p+"no_grow", "ARC held by memory pressure", "state", "", a.NoGrow, t, none)
if a.Compressed > 0 {
f.add(p+"compress_ratio", "ARC compression", "ratio", "x", round1(float64(a.Uncompressed)/float64(a.Compressed)*10)/10, t, none)
}
if a.L2Size > 0 {
f.add(p+"l2_size", "L2ARC size", "bytes", "B", float64(a.L2Size), t, none)
f.add(p+"l2_hit_pct", "L2ARC hit rate", "usage", "%", a.L2HitPct, t, pct)
}
}
if u := s.UPS; u != nil {
ut := s.upsT
f.add("ups.battery", "UPS battery", "usage", "%", u.BatteryPct, ut, pct, "model", u.Model)
+292
View File
@@ -0,0 +1,292 @@
package storage
// The ZFS caching tiers, for the ZFS tab: what the ARC has done since boot and how it is
// tuned, and for each pool which cache devices it has — L2ARC, a separate log (SLOG), a
// special allocation class, a dedup table — or, as is common, none of them, which the tab
// should say plainly rather than leave a blank.
//
// The live numbers (size, the hit rate over the last interval) come from the sampler in
// internal/metrics, which also publishes them as zfs.arc.* sensors; this is the slower,
// fuller picture, read when the tab asks.
import (
"context"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// CacheDev is one member of a pool's cache, log, special, dedup or spare class.
type CacheDev struct {
Name string `json:"name"`
Size uint64 `json:"size,omitempty"`
Alloc uint64 `json:"alloc,omitempty"`
Health string `json:"health,omitempty"`
}
// PoolCaching is what one pool has beyond its data vdevs, and the properties that steer
// what is cached.
type PoolCaching struct {
Pool string `json:"pool"`
Cache []CacheDev `json:"cache"` // L2ARC
Log []CacheDev `json:"log"` // SLOG
Special []CacheDev `json:"special"` // metadata / small blocks
Dedup []CacheDev `json:"dedup"` // dedup table vdevs
Spare []CacheDev `json:"spare"`
// dataset properties at the pool root
PrimaryCache string `json:"primarycache"`
SecondaryCache string `json:"secondarycache"`
Sync string `json:"sync"`
LogBias string `json:"logbias"`
DedupProp string `json:"dedup_prop"`
Compression string `json:"compression"`
RecordSize string `json:"recordsize"`
DedupRatio string `json:"dedup_ratio"` // zpool's, e.g. "1.00x"
}
// ARCDetail is the full picture.
type ARCDetail struct {
Available bool `json:"available"`
MemTotal uint64 `json:"mem_total"`
Tunables map[string]string `json:"tunables"` // zfs module parameters that shape the cache; "" when unset
Lifetime struct {
Hits, Misses uint64 `json:",omitempty"`
DemandDataHits, DemandDataMisses uint64
DemandMetaHits, DemandMetaMisses uint64
PrefetchDataHits, PrefetchDataMisses uint64
PrefetchMetaHits, PrefetchMetaMisses uint64
L2Hits, L2Misses, L2ReadBytes, L2WriteBytes uint64
L2Feeds, L2RebuildSize uint64
EvictL2Eligible uint64 // bytes evicted from RAM that an L2ARC could have caught
Deleted, EvictSkip, MutexMiss, Prune, Throttle uint64
} `json:"lifetime"`
Zfetch struct{ Hits, Misses, IOIssued uint64 } `json:"zfetch"` // the prefetcher
ZIL struct {
Commits uint64 `json:"commits"`
NormalBytes, NormalWrite uint64 // written to the pool's own vdevs
SlogBytes, SlogWrite uint64 // written to a separate log device
} `json:"zil"`
DBuf struct{ Size, Target, Hits, Misses uint64 } `json:"dbuf"` // the dbuf cache in front of the ARC
Pools []PoolCaching `json:"pools"`
}
const kstatDir = "/proc/spl/kstat/zfs/"
func readKstat(p string) map[string]uint64 {
b, err := os.ReadFile(p)
if err != nil {
return nil
}
out := map[string]uint64{}
for _, line := range strings.Split(string(b), "\n") {
f := strings.Fields(line)
if len(f) != 3 || f[1] != "4" {
continue
}
if v, err := strconv.ParseUint(f[2], 10, 64); err == nil {
out[f[0]] = v
}
}
return out
}
var arcTunables = []string{"zfs_arc_max", "zfs_arc_min", "zfs_arc_sys_free", "zfs_arc_meta_balance", "zfs_prefetch_disable", "l2arc_write_max", "l2arc_write_boost", "l2arc_noprefetch", "l2arc_rebuild_enabled", "zfs_dirty_data_max"}
const poolCachingEvery = 30 * time.Second // zpool list -v and zfs get are an exec each
// ARC reads the kstats now and the pool layout from a short cache.
func (m *Manager) ARC(ctx context.Context) ARCDetail {
d := ARCDetail{Tunables: map[string]string{}, Pools: []PoolCaching{}}
k := readKstat(kstatDir + "arcstats")
if k == nil {
return d
}
d.Available = true
l := &d.Lifetime
l.Hits, l.Misses = k["hits"], k["misses"]
l.DemandDataHits, l.DemandDataMisses = k["demand_data_hits"], k["demand_data_misses"]
l.DemandMetaHits, l.DemandMetaMisses = k["demand_metadata_hits"], k["demand_metadata_misses"]
l.PrefetchDataHits, l.PrefetchDataMisses = k["prefetch_data_hits"], k["prefetch_data_misses"]
l.PrefetchMetaHits, l.PrefetchMetaMisses = k["prefetch_metadata_hits"], k["prefetch_metadata_misses"]
l.L2Hits, l.L2Misses, l.L2ReadBytes, l.L2WriteBytes = k["l2_hits"], k["l2_misses"], k["l2_read_bytes"], k["l2_write_bytes"]
l.L2Feeds, l.L2RebuildSize, l.EvictL2Eligible = k["l2_feeds"], k["l2_rebuild_size"], k["evict_l2_eligible"]
l.Deleted, l.EvictSkip, l.MutexMiss, l.Prune, l.Throttle = k["deleted"], k["evict_skip"], k["mutex_miss"], k["arc_prune"], k["memory_throttle_count"]
if z := readKstat(kstatDir + "zfetchstats"); z != nil {
d.Zfetch.Hits, d.Zfetch.Misses, d.Zfetch.IOIssued = z["hits"], z["misses"], z["io_issued"]
}
if z := readKstat(kstatDir + "zil"); z != nil {
d.ZIL.Commits = z["zil_commit_count"]
d.ZIL.NormalBytes, d.ZIL.NormalWrite = z["zil_itx_metaslab_normal_bytes"], z["zil_itx_metaslab_normal_write"]
d.ZIL.SlogBytes, d.ZIL.SlogWrite = z["zil_itx_metaslab_slog_bytes"], z["zil_itx_metaslab_slog_write"]
}
if z := readKstat(kstatDir + "dbufstats"); z != nil {
d.DBuf.Size, d.DBuf.Target, d.DBuf.Hits, d.DBuf.Misses = z["cache_size_bytes"], z["cache_target_bytes"], z["hash_hits"], z["hash_misses"]
}
for _, t := range arcTunables {
if b, err := os.ReadFile("/sys/module/zfs/parameters/" + t); err == nil {
d.Tunables[t] = strings.TrimSpace(string(b))
}
}
if b, err := os.ReadFile("/proc/meminfo"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, "MemTotal:") {
f := strings.Fields(line)
if len(f) >= 2 {
d.MemTotal = u64(f[1]) << 10
}
break
}
}
}
d.Pools = m.poolCaching(ctx)
return d
}
var cachingMu sync.Mutex
var cachingAt time.Time
var cachingLast []PoolCaching
func (m *Manager) poolCaching(ctx context.Context) []PoolCaching {
cachingMu.Lock()
defer cachingMu.Unlock()
if cachingLast != nil && time.Since(cachingAt) < poolCachingEvery {
return cachingLast
}
out := []PoolCaching{}
bin, ok := lookPath("zpool")
if !ok {
return out
}
// zpool list -v needs no root; the vdev tree under each pool, with the class headers
raw, code, err := m.runPlain(ctx, bin, "list", "-Hpv", "-o", "name,size,alloc,free,health")
if err != nil || code != 0 {
return out
}
out = parsePoolCaching(string(raw))
if zfs, ok := lookPath("zfs"); ok {
for i := range out {
p := &out[i]
if props, code, err := m.runPlain(ctx, zfs, "get", "-Hp", "-o", "property,value", "primarycache,secondarycache,sync,logbias,dedup,compression,recordsize", p.Pool); err == nil && code == 0 {
for _, line := range strings.Split(strings.TrimSpace(string(props)), "\n") {
f := strings.Split(line, "\t")
if len(f) < 2 {
continue
}
switch f[0] {
case "primarycache":
p.PrimaryCache = f[1]
case "secondarycache":
p.SecondaryCache = f[1]
case "sync":
p.Sync = f[1]
case "logbias":
p.LogBias = f[1]
case "dedup":
p.DedupProp = f[1]
case "compression":
p.Compression = f[1]
case "recordsize":
p.RecordSize = humanSize(u64(f[1]))
}
}
}
}
}
for _, pool := range parsePools(func() []byte {
b, _, _ := m.runPlain(ctx, bin, "list", "-Hp", "-o", "name,size,alloc,free,frag,cap,health,dedup")
return b
}()) {
for i := range out {
if out[i].Pool == pool.Name {
out[i].DedupRatio = pool.Dedup
}
}
}
cachingLast, cachingAt = out, time.Now()
return out
}
// runPlain runs a command as ourselves, never through sudo: these are read-only zpool/zfs
// queries that need no root on Linux, and a poll must not write to the auth log.
func (m *Manager) runPlain(ctx context.Context, bin string, args ...string) ([]byte, int, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, bin, args...).Output()
if ee, ok := err.(*exec.ExitError); ok {
return out, ee.ExitCode(), nil
}
return out, 0, err
}
var cacheClasses = map[string]bool{"logs": true, "log": true, "cache": true, "special": true, "dedup": true, "spares": true, "spare": true}
// parsePoolCaching reads `zpool list -Hpv -o name,size,alloc,free,health`: a pool line, then
// its vdevs indented, with a bare class name ("logs", "cache", "special", "dedup", "spares")
// introducing each extra class. Only those classes' members are kept; the data vdevs are the
// pool and are shown elsewhere.
func parsePoolCaching(text string) []PoolCaching {
var out []PoolCaching
var cur *PoolCaching
class := ""
for _, raw := range strings.Split(text, "\n") {
if strings.TrimSpace(raw) == "" {
continue
}
indented := strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t")
f := strings.Split(strings.TrimSpace(raw), "\t")
name := strings.TrimSpace(f[0])
if !indented {
out = append(out, PoolCaching{Pool: name, Cache: []CacheDev{}, Log: []CacheDev{}, Special: []CacheDev{}, Dedup: []CacheDev{}, Spare: []CacheDev{}})
cur, class = &out[len(out)-1], ""
continue
}
if cur == nil {
continue
}
if cacheClasses[name] && (len(f) < 2 || f[1] == "-" || f[1] == "") {
class = name
continue
}
if class == "" {
continue // a data vdev or one of its disks
}
// a mirror or raidz inside a class lists its members below it; keep them all, the
// grouping is visible in the vdev tree on the pool card
dev := CacheDev{Name: name}
if len(f) >= 5 {
dev.Size, dev.Alloc, dev.Health = u64(f[1]), u64(f[2]), strings.TrimSpace(f[4])
if f[1] == "-" {
dev.Size = 0
}
}
switch class {
case "logs", "log":
cur.Log = append(cur.Log, dev)
case "cache":
cur.Cache = append(cur.Cache, dev)
case "special":
cur.Special = append(cur.Special, dev)
case "dedup":
cur.Dedup = append(cur.Dedup, dev)
case "spares", "spare":
cur.Spare = append(cur.Spare, dev)
}
}
if out == nil {
out = []PoolCaching{}
}
return out
}
func humanSize(b uint64) string {
switch {
case b >= 1<<20 && b%(1<<20) == 0:
return strconv.FormatUint(b>>20, 10) + "M"
case b >= 1<<10 && b%(1<<10) == 0:
return strconv.FormatUint(b>>10, 10) + "K"
}
return strconv.FormatUint(b, 10)
}
+71
View File
@@ -0,0 +1,71 @@
package storage
import (
"context"
"encoding/json"
"os"
"testing"
)
// zpool list -Hpv on a pool with every extra class; the data raidz and its disks are skipped
const listV = "tank\t32000000000000\t23000000000000\t9000000000000\tONLINE\n" +
"\traidz2-0\t32000000000000\t23000000000000\t9000000000000\tONLINE\n" +
"\t\twwn-0x1\t4000000000000\t-\t-\tONLINE\n" +
"\t\twwn-0x2\t4000000000000\t-\t-\tONLINE\n" +
"\tspecial\t-\t-\t-\t-\n" +
"\t\tmirror-1\t500000000000\t120000000000\t380000000000\tONLINE\n" +
"\t\t\tnvme0n1p1\t500000000000\t-\t-\tONLINE\n" +
"\t\t\tnvme1n1p1\t500000000000\t-\t-\tONLINE\n" +
"\tlogs\t-\t-\t-\t-\n" +
"\t\tnvme2n1p1\t16000000000\t4000000\t15996000000\tONLINE\n" +
"\tcache\t-\t-\t-\t-\n" +
"\t\tnvme2n1p2\t400000000000\t210000000000\t190000000000\tONLINE\n" +
"\tspares\t-\t-\t-\t-\n" +
"\t\twwn-0x9\t4000000000000\t-\t-\tAVAIL\n" +
"scratch\t1000000000000\t1000000000\t999000000000\tONLINE\n" +
"\tsda\t1000000000000\t1000000000\t999000000000\tONLINE\n"
func TestParsePoolCaching(t *testing.T) {
got := parsePoolCaching(listV)
if len(got) != 2 || got[0].Pool != "tank" || got[1].Pool != "scratch" {
t.Fatalf("pools: %+v", got)
}
tank := got[0]
if len(tank.Cache) != 1 || tank.Cache[0].Name != "nvme2n1p2" || tank.Cache[0].Alloc != 210000000000 {
t.Errorf("cache: %+v", tank.Cache)
}
if len(tank.Log) != 1 || tank.Log[0].Name != "nvme2n1p1" {
t.Errorf("log: %+v", tank.Log)
}
if len(tank.Special) != 3 || tank.Special[0].Name != "mirror-1" || tank.Special[0].Alloc != 120000000000 {
t.Errorf("special: %+v", tank.Special)
}
if len(tank.Spare) != 1 || tank.Spare[0].Health != "AVAIL" {
t.Errorf("spares: %+v", tank.Spare)
}
if len(tank.Dedup) != 0 {
t.Errorf("no dedup class: %+v", tank.Dedup)
}
s := got[1]
if len(s.Cache)+len(s.Log)+len(s.Special)+len(s.Spare) != 0 {
t.Errorf("a plain pool has no extra classes: %+v", s)
}
}
func TestHumanSize(t *testing.T) {
for in, want := range map[uint64]string{131072: "128K", 1048576: "1M", 16384: "16K", 1000: "1000"} {
if got := humanSize(in); got != want {
t.Errorf("%d → %s, want %s", in, got, want)
}
}
}
// TestLiveARC reads this machine's ZFS for real: HAMLAB_LIVE=1 go test -run TestLiveARC -v ./internal/storage
func TestLiveARC(t *testing.T) {
if os.Getenv("HAMLAB_LIVE") == "" {
t.Skip("set HAMLAB_LIVE=1 to read the real machine")
}
d := New(false).ARC(context.Background())
b, _ := json.MarshalIndent(d, "", " ")
t.Logf("%s", b)
}