/** * ZFS / ZFS-on-Linux collector via /proc/spl/kstat/zfs and zpool list. * Enable: PEARDATA_ZFS=1 (auto when kstat present and unset) */ import fs from 'fs' import path from 'path' import { execFileSync } from '../../utils/exec.js' import { CollectorPlugin } from './plugin.js' import { registerChart } from '../../../shared/metrics.js' export function isZfsEnabled() { const v = process.env.PEARDATA_ZFS if (v === '0' || v === 'off' || v === 'false') return false if (v === '1' || v === 'on' || v === 'true') return true return fs.existsSync('/proc/spl/kstat/zfs') } function readArcstats() { const p = '/proc/spl/kstat/zfs/arcstats' if (!fs.existsSync(p)) return null /** @type {Record} */ const out = {} for (const line of fs.readFileSync(p, 'utf8').split('\n')) { const parts = line.trim().split(/\s+/) if (parts.length >= 3 && /^\d+$/.test(parts[2])) out[parts[0]] = Number(parts[2]) } return out } export class ZfsCollector extends CollectorPlugin { constructor() { super({ name: 'zfs' }) this.prev = null this.prevTs = 0 } isEnabled() { return isZfsEnabled() } async collect() { const ts = Date.now() const arc = readArcstats() /** @type {Array<{ chart: string, context: string, ts: number, values: object }>} */ const batch = [] if (arc) { registerChart({ id: 'zfs.arc', name: 'zfs.arc', context: 'zfs.arc', title: 'ZFS ARC size', units: 'MiB', family: 'zfs', chartType: 'stacked', priority: 4600, plugin: 'zfs', dimensions: [ { id: 'size', name: 'size', algorithm: 'absolute' }, { id: 'target', name: 'target', algorithm: 'absolute' }, { id: 'mru', name: 'mru', algorithm: 'absolute' }, { id: 'mfu', name: 'mfu', algorithm: 'absolute' }, ], }) registerChart({ id: 'zfs.arc_hits', name: 'zfs.arc_hits', context: 'zfs.arc_hits', title: 'ZFS ARC hits/misses', units: 'events/s', family: 'zfs', chartType: 'line', priority: 4610, plugin: 'zfs', dimensions: [ { id: 'hits', name: 'hits', algorithm: 'incremental' }, { id: 'misses', name: 'misses', algorithm: 'incremental' }, ], }) const dt = this.prevTs ? (ts - this.prevTs) / 1000 : 0 const prev = this.prev const mib = (n) => (n || 0) / (1024 * 1024) batch.push({ chart: 'zfs.arc', context: 'zfs.arc', ts, values: { size: mib(arc.size), target: mib(arc.c), mru: mib(arc.mru_size), mfu: mib(arc.mfu_size), }, }) batch.push({ chart: 'zfs.arc_hits', context: 'zfs.arc_hits', ts, values: { hits: prev && dt > 0 ? Math.max(0, (arc.hits || 0) - (prev.hits || 0)) / dt : 0, misses: prev && dt > 0 ? Math.max(0, (arc.misses || 0) - (prev.misses || 0)) / dt : 0, }, }) this.prev = arc this.prevTs = ts } // zpool list -Hp try { const { stdout } = execFileSync( 'zpool', ['list', '-Hp', '-o', 'name,size,alloc,free,health'], { encoding: 'utf8' } ) if (stdout) { for (const line of String(stdout).trim().split('\n')) { const [name, size, alloc, free, health] = line.split('\t') if (!name) continue const id = `zfs.pool.${name.replace(/[^\w.-]/g, '_')}` registerChart({ id, name: id, context: 'zfs.pool', title: `ZFS pool ${name}`, units: 'GiB', family: name, chartType: 'stacked', priority: 4620, plugin: 'zfs', dimensions: [ { id: 'alloc', name: 'alloc', algorithm: 'absolute' }, { id: 'free', name: 'free', algorithm: 'absolute' }, { id: 'online', name: 'online', algorithm: 'absolute' }, ], }) batch.push({ chart: id, context: 'zfs.pool', ts, values: { alloc: Number(alloc) / (1024 ** 3), free: Number(free) / (1024 ** 3), online: String(health).toUpperCase() === 'ONLINE' ? 1 : 0, }, }) void size } } } catch { // zpool not installed } // Also scan /proc/spl/kstat/zfs/*/io try { const root = '/proc/spl/kstat/zfs' for (const ent of fs.readdirSync(root)) { const ioPath = path.join(root, ent, 'io') if (!fs.existsSync(ioPath)) continue // pool-level io kstat — skip if not file } } catch { // ignore } return batch } } let singleton = null export function getZfsCollector() { if (!singleton) singleton = new ZfsCollector() return singleton }