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:
co-authored by
Claude Fable 5.1
parent
db40220365
commit
b44faf3ac9
+148
-2
@@ -8,10 +8,12 @@
|
||||
// the retention in the name — rather than inventing a second convention for the two tools to
|
||||
// disagree over. An existing cron job is detected and shown, so the page tells the truth
|
||||
// about the machine whether Ham Lab or something else is doing the work.
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api'
|
||||
import { FormEvent, Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import { api, ARCView, CacheDev, PoolCaching, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { fetchHistories } from './sensors'
|
||||
import Sparkline from './Sparkline'
|
||||
import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui'
|
||||
|
||||
const ago = (t?: string | number) => {
|
||||
@@ -305,6 +307,145 @@ function adopt(x: ExternalJob, fallbackDataset: string): Partial<ZFSPolicy> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the cache: the ARC in RAM, and what each pool has in front of its disks ----
|
||||
//
|
||||
// Two kinds of number. The live ones (size, the hit rate over the last sampling interval)
|
||||
// come from the sampler, which also keeps their history for the sparklines; the since-boot
|
||||
// counters, the tunables and the pool layout come with the same request from the storage
|
||||
// package. A tier a pool does not have is named and said to be absent, with what that means,
|
||||
// because "nothing shown" and "none fitted" are different answers.
|
||||
|
||||
const hitPct = (h: number, m: number) => (h + m > 0 ? (h / (h + m)) * 100 : NaN)
|
||||
const fmtPct = (v: number) => (Number.isNaN(v) ? '—' : `${v.toFixed(1)}%`)
|
||||
const fmtN = (n: number) => n.toLocaleString()
|
||||
const cssVar = (n: string) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || '#3987e5'
|
||||
|
||||
function Tier({ name, what, devs, none, note }: { name: string; what: string; devs: CacheDev[]; none: string; note?: string }) {
|
||||
return (
|
||||
<div className="arc-tier">
|
||||
<span className="k" title={what}>{name}</span>
|
||||
<span className="arc-tier-v">
|
||||
{devs.length === 0 ? <span className="none">{none}</span> : devs.map((x) => (
|
||||
<span key={x.name} className="mono" title={x.name}>
|
||||
{x.name}{x.size ? ` · ${fmtBytes(x.alloc ?? 0)} of ${fmtBytes(x.size)} used` : ''}{x.health && x.health !== 'ONLINE' ? ` · ${x.health}` : ''}
|
||||
</span>
|
||||
))}
|
||||
{devs.length > 0 && note && <span className="muted small">{note}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PoolTiers({ p, v }: { p: PoolCaching; v: ARCView }) {
|
||||
const L = v.detail.lifetime, z = v.detail.zil, a = v.live
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="sub-head">
|
||||
<span className="dname">{p.pool}</span>
|
||||
<span className="badge">caching tiers</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted small mono" title="primarycache: what the ARC may hold for this pool · secondarycache: what an L2ARC may hold">primarycache={p.primarycache || '?'} · secondarycache={p.secondarycache || '?'}</span>
|
||||
</div>
|
||||
<div className="arc-tiers">
|
||||
<Tier name="L2ARC" what="cache devices: a second-level read cache on flash, fed with what the ARC evicts" devs={p.cache}
|
||||
none={`none — a read that misses RAM goes to the pool's disks. ${fmtBytes(L.EvictL2Eligible)} evicted from RAM since boot would have been eligible for one.`}
|
||||
note={a ? `${fmtBytes(a.l2_size)} cached (${fmtBytes(a.l2_asize)} on device) · ${fmtPct(hitPct(L.L2Hits, L.L2Misses))} hit since boot · ${fmtBytes(L.L2ReadBytes)} read, ${fmtBytes(L.L2WriteBytes)} written` : undefined} />
|
||||
<Tier name="SLOG" what="log devices: where synchronous writes are committed before they reach the pool" devs={p.log}
|
||||
none={`none — the intent log is on the pool's own vdevs: ${fmtN(z.commits)} commits, ${fmtBytes(z.NormalWrite)} written to it since boot`}
|
||||
note={`${fmtN(z.commits)} commits · ${fmtBytes(z.SlogWrite)} written to the log device since boot${z.NormalWrite ? `, ${fmtBytes(z.NormalWrite)} to the pool` : ''}`} />
|
||||
<Tier name="Special" what="special allocation class: metadata and small blocks kept on their own (faster) vdev" devs={p.special}
|
||||
none="none — metadata lives with the data on the same vdevs" />
|
||||
<Tier name="Dedup" what="deduplication: a table of every block's checksum, in the ARC and on disk" devs={p.dedup}
|
||||
none={!p.dedup_prop || p.dedup_prop === 'off' ? `off · ratio ${p.dedup_ratio || '1.00x'}` : `${p.dedup_prop} · ratio ${p.dedup_ratio} · the table lives in the ARC and on the data vdevs`} />
|
||||
{p.spare.length > 0 && <Tier name="Spares" what="hot spares: take over when a vdev member fails" devs={p.spare} none="" />}
|
||||
</div>
|
||||
<div className="muted small mono" style={{ marginTop: 8 }}>sync={p.sync || '?'} · logbias={p.logbias || '?'} · compression={p.compression || '?'} · recordsize={p.recordsize || '?'}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ARCSection() {
|
||||
const [v, setV] = useState<ARCView | null>(null)
|
||||
const [hist, setHist] = useState<{ size: number[]; hit: number[] }>({ size: [], hit: [] })
|
||||
const [err, setErr] = useState('')
|
||||
const rate = useRefreshMs()
|
||||
useEffect(() => {
|
||||
let stop = false
|
||||
const poll = async () => {
|
||||
try { const r = await api.zfsARC(); if (!stop) { setV(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') }
|
||||
try {
|
||||
const hs = await fetchHistories('zfs.arc.size,zfs.arc.hit_pct')
|
||||
if (!stop) setHist({ size: hs.find((h) => h.id === 'zfs.arc.size')?.v ?? [], hit: hs.find((h) => h.id === 'zfs.arc.hit_pct')?.v ?? [] })
|
||||
} catch { /* the sparklines stay empty; the numbers still show */ }
|
||||
}
|
||||
poll()
|
||||
const t = setInterval(poll, Math.max(2000, rate))
|
||||
return () => { stop = true; clearInterval(t) }
|
||||
}, [rate])
|
||||
|
||||
if (err && !v) return <div className="error">{err}</div>
|
||||
if (!v) return <div className="muted"><Spinner /> reading the cache…</div>
|
||||
const d = v.detail, a = v.live
|
||||
if (!d.available || !a) return <div className="notice">The ZFS module reports no ARC statistics on this machine.</div>
|
||||
|
||||
const L = d.lifetime
|
||||
const ratio = a.compressed > 0 ? a.uncompressed / a.compressed : 0
|
||||
const maxSet = d.tunables.zfs_arc_max && d.tunables.zfs_arc_max !== '0'
|
||||
const atFloor = a.target <= a.min * 1.02
|
||||
const rows: [string, number, number][] = [
|
||||
['Demand data', L.DemandDataHits, L.DemandDataMisses], ['Demand metadata', L.DemandMetaHits, L.DemandMetaMisses],
|
||||
['Prefetch data', L.PrefetchDataHits, L.PrefetchDataMisses], ['Prefetch metadata', L.PrefetchMetaHits, L.PrefetchMetaMisses],
|
||||
]
|
||||
return (
|
||||
<>
|
||||
<section className="card">
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (a.no_grow ? 'warn' : 'ok')} />
|
||||
<span className="dname">ARC</span>
|
||||
<span className="badge">in RAM</span>
|
||||
<span className="spacer" />
|
||||
<span className="num">{fmtBytes(a.size)} / {fmtBytes(a.max)} · {Math.round((a.size / a.max) * 100)}%</span>
|
||||
</div>
|
||||
<Meter frac={a.size / a.max} cls="" />
|
||||
{a.no_grow && (
|
||||
<div className="notice attn" style={{ marginTop: 8 }}>
|
||||
<b>Held by memory pressure.</b> ZFS is not letting the cache grow (<span className="mono">arc_no_grow</span>): the system is short of free memory
|
||||
{atFloor ? <>, and the target sits at its floor of <b>{fmtBytes(a.min)}</b> against a ceiling of {fmtBytes(a.max)}</> : ''}.
|
||||
{' '}Whatever no longer fits is read from the disks.
|
||||
</div>
|
||||
)}
|
||||
<div className="arc-sparks">
|
||||
<div>
|
||||
<div className="muted small">Size · ceiling {fmtBytes(a.max)}</div>
|
||||
<Sparkline series={[{ name: 'size', values: hist.size, color: cssVar('--s1') }]} max={a.max} format={fmtBytes} height={48} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="muted small">Hit rate · {a.hit_pct_known ? `${a.hit_pct.toFixed(1)}% now` : 'no reads yet'} · {fmtN(Math.round(a.reads_per_sec))} reads/s</div>
|
||||
<Sparkline series={[{ name: 'hit rate', values: hist.hit, color: cssVar('--ok') }]} max={100} format={(x) => `${x.toFixed(1)}%`} height={48} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="props" style={{ marginTop: 8 }}>
|
||||
<div><span className="k">Target</span><span title="c: what the ARC may hold right now, between its floor (c_min) and ceiling (c_max)">{fmtBytes(a.target)} · floor {fmtBytes(a.min)} · ceiling {fmtBytes(a.max)} {maxSet ? '(zfs_arc_max)' : '(default: half of RAM)'}</span></div>
|
||||
<div><span className="k">Hit rate</span><span>{a.hit_pct_known ? `${a.hit_pct.toFixed(1)}% now` : 'idle'} · {a.lifetime_hit_pct.toFixed(1)}% since boot · {fmtN(a.hits)} hits, {fmtN(a.misses)} misses</span></div>
|
||||
<div><span className="k">Holds</span><span title="MRU: seen once · MFU: seen again · dirty: written, not yet on disk">MRU {fmtBytes(a.mru)} · MFU {fmtBytes(a.mfu)} · dirty {fmtBytes(a.anon)}</span></div>
|
||||
<div><span className="k">Of which</span><span>data {fmtBytes(a.data)} · metadata {fmtBytes(a.metadata)} · headers {fmtBytes(a.headers)}</span></div>
|
||||
<div><span className="k">Compressed</span><span>{ratio > 0 ? `${fmtBytes(a.uncompressed)} held in ${fmtBytes(a.compressed)} · ${ratio.toFixed(2)}x` : 'not compressed'}</span></div>
|
||||
<div><span className="k">Ghosts</span><span title="what was evicted recently and is still remembered, so a re-read can grow the right list">MRU {fmtBytes(a.mru_ghost)} · MFU {fmtBytes(a.mfu_ghost)}</span></div>
|
||||
<div><span className="k">Prefetch</span><span title="zfetch: the read-ahead that guesses the next blocks of a sequential read">{d.tunables.zfs_prefetch_disable === '1' ? 'disabled (zfs_prefetch_disable)' : `${fmtPct(hitPct(d.zfetch.Hits, d.zfetch.Misses))} of ${fmtN(d.zfetch.Hits + d.zfetch.Misses)} predictions since boot`}</span></div>
|
||||
<div><span className="k">dbuf cache</span><span title="the small cache of decompressed buffers in front of the ARC">{fmtBytes(d.dbuf.Size)} of {fmtBytes(d.dbuf.Target)} · {fmtPct(hitPct(d.dbuf.Hits, d.dbuf.Misses))} hits</span></div>
|
||||
<div><span className="k">Pressure</span><span title="since boot: throttles are allocations ZFS made wait for memory; prunes are metadata evictions asked of the filesystems">{fmtN(a.throttles)} throttles · {fmtN(a.prunes)} prunes · {fmtN(a.evict_skip)} evict skips</span></div>
|
||||
</div>
|
||||
<div className="arc-break">
|
||||
<span className="head">Since boot</span><span className="head num">hit rate</span><span className="head num">hits</span><span className="head num">misses</span>
|
||||
{rows.map(([k, h, m]) => <Fragment key={k}><span>{k}</span><span className="num">{fmtPct(hitPct(h, m))}</span><span className="num">{fmtN(h)}</span><span className="num">{fmtN(m)}</span></Fragment>)}
|
||||
<span><b>All reads</b></span><span className="num"><b>{fmtPct(hitPct(L.Hits, L.Misses))}</b></span><span className="num">{fmtN(L.Hits)}</span><span className="num">{fmtN(L.Misses)}</span>
|
||||
</div>
|
||||
</section>
|
||||
{d.pools.length > 0 && <div className="cards" style={{ marginTop: 8 }}>{d.pools.map((p) => <PoolTiers key={p.pool} p={p} v={v} />)}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
|
||||
export default function ZFS() {
|
||||
@@ -333,6 +474,11 @@ export default function ZFS() {
|
||||
{zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
|
||||
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
|
||||
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Cache</h2><span className="spacer" />
|
||||
<span className="muted small">the ARC in RAM, and what each pool has in front of its disks</span>
|
||||
</div>
|
||||
<ARCSection />
|
||||
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshot schedules</h2><span className="spacer" />
|
||||
<Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn>
|
||||
</div>
|
||||
|
||||
@@ -226,6 +226,28 @@ export interface ZFSPolicy { id: string; name: string; dataset: string; recursiv
|
||||
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
|
||||
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
|
||||
export interface ZFSOverview { zfs: ZFSInfo; policies: { policy: ZFSPolicy; cron: string; next?: string; last: ZFSRun }[]; external?: ZFSExternal | null }
|
||||
// The ARC as the sampler sees it (sizes in bytes; hit_pct over the last sampling interval).
|
||||
export interface ARCLive {
|
||||
size: number; target: number; min: number; max: number
|
||||
mru: number; mfu: number; anon: number; data: number; metadata: number; meta_used: number; headers: number
|
||||
compressed: number; uncompressed: number; mru_ghost: number; mfu_ghost: number; l2_size: number; l2_asize: number
|
||||
reads_per_sec: number; misses_per_sec: number; hit_pct: number; hit_pct_known: boolean; l2_hit_pct: number
|
||||
hits: number; misses: number; lifetime_hit_pct: number; no_grow: boolean; throttles: number; prunes: number; evict_skip: number
|
||||
}
|
||||
export interface CacheDev { name: string; size?: number; alloc?: number; health?: string }
|
||||
export interface PoolCaching {
|
||||
pool: string; cache: CacheDev[]; log: CacheDev[]; special: CacheDev[]; dedup: CacheDev[]; spare: CacheDev[]
|
||||
primarycache: string; secondarycache: string; sync: string; logbias: string; dedup_prop: string; compression: string; recordsize: string; dedup_ratio: string
|
||||
}
|
||||
export interface ARCDetail {
|
||||
available: boolean; mem_total: number; tunables: Record<string, string>
|
||||
lifetime: { Hits: number; Misses: number; DemandDataHits: number; DemandDataMisses: number; DemandMetaHits: number; DemandMetaMisses: number; PrefetchDataHits: number; PrefetchDataMisses: number; PrefetchMetaHits: number; PrefetchMetaMisses: number; L2Hits: number; L2Misses: number; L2ReadBytes: number; L2WriteBytes: number; L2Feeds: number; L2RebuildSize: number; EvictL2Eligible: number; Deleted: number; EvictSkip: number; MutexMiss: number; Prune: number; Throttle: number }
|
||||
zfetch: { Hits: number; Misses: number; IOIssued: number }
|
||||
zil: { commits: number; NormalBytes: number; NormalWrite: number; SlogBytes: number; SlogWrite: number }
|
||||
dbuf: { Size: number; Target: number; Hits: number; Misses: number }
|
||||
pools: PoolCaching[]
|
||||
}
|
||||
export interface ARCView { detail: ARCDetail; live: ARCLive | null }
|
||||
|
||||
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean }
|
||||
export interface FileInfo extends FileEntry { path: string; text: boolean }
|
||||
@@ -278,6 +300,7 @@ export const api = {
|
||||
addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, true),
|
||||
removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true),
|
||||
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
||||
zfsARC: () => req<ARCView>('GET', '/api/zfs/arc'),
|
||||
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
|
||||
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`),
|
||||
runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`),
|
||||
|
||||
@@ -740,6 +740,16 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.top-proc { height: 70vh; min-height: 420px; }
|
||||
}
|
||||
|
||||
/* ---- ZFS: the ARC card and each pool's caching tiers ---- */
|
||||
.arc-sparks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 8px; } .arc-sparks .small { margin-bottom: 2px; }
|
||||
@media (max-width: 640px) { .arc-sparks { grid-template-columns: 1fr; } }
|
||||
.arc-break { display: grid; grid-template-columns: minmax(140px, 1fr) 80px 110px 110px; gap: 2px 10px; font-size: 12px; margin-top: 10px; }
|
||||
.arc-break .head { color: var(--muted); } .arc-break .num { text-align: right; }
|
||||
.arc-tiers { display: flex; flex-direction: column; gap: 5px; font-size: 13px; margin-top: 4px; }
|
||||
.arc-tier { display: flex; gap: 8px; min-width: 0; } .arc-tier .k { color: var(--muted); width: 80px; flex-shrink: 0; cursor: help; }
|
||||
.arc-tier-v { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .arc-tier-v > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.arc-tier-v .none { color: var(--muted); white-space: normal; }
|
||||
|
||||
/* ---- Space: what is using a disk ---- */
|
||||
.space { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
|
||||
.space-bar { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); }
|
||||
|
||||
Reference in New Issue
Block a user