mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 00:47:47 +02:00
- 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>
161 lines
4.6 KiB
Go
161 lines
4.6 KiB
Go
// Package zfs provides functions to read ZFS statistics.
|
|
package zfs
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var commandTimeout = 10 * time.Second
|
|
|
|
var commandOutput = func(name string, args ...string) ([]byte, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
|
out, err := cmd.Output()
|
|
if ctx.Err() != nil {
|
|
return nil, fmt.Errorf("%s timed out after %s: %w", name, commandTimeout, ctx.Err())
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
// ErrNoZfs is returned when the ZFS utilities or kernel interfaces are unavailable.
|
|
var ErrNoZfs = errors.New("zfs utilities unavailable")
|
|
|
|
// PoolStat is a snapshot of a ZFS pool's capacity and health.
|
|
type PoolStat struct {
|
|
Name string
|
|
Size uint64 // total capacity in bytes
|
|
Alloc uint64 // allocated bytes
|
|
Free uint64 // free bytes
|
|
Health string // ONLINE, DEGRADED, FAULTED, ...
|
|
}
|
|
|
|
// PoolKernelStat is the inexpensive pool telemetry exposed by the ZFS kernel.
|
|
// NRead and NWrite are cumulative byte counters since the pool was imported.
|
|
type PoolKernelStat struct {
|
|
Name string
|
|
Health string
|
|
NRead uint64
|
|
NWrite uint64
|
|
}
|
|
|
|
// PoolIoStats holds calculated per-second I/O rates for a pool.
|
|
type PoolIoStats struct {
|
|
NRead uint64
|
|
NWrite uint64
|
|
}
|
|
|
|
// Dataset is a single ZFS dataset with usage information.
|
|
type Dataset struct {
|
|
Name string
|
|
Used uint64
|
|
Avail uint64
|
|
Mountpoint string
|
|
}
|
|
|
|
// PoolStats returns capacity and health for all pools on the system using
|
|
// `zpool list`. Frequent health and I/O sampling uses PoolKernelStats instead.
|
|
func PoolStats() ([]PoolStat, error) {
|
|
out, err := commandOutput("zpool", "list", "-Hp", "-o", "name,size,alloc,free,health")
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) && strings.Contains(string(exitErr.Stderr), "no pools available") {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("zpool list: %w", err)
|
|
}
|
|
return parseZpoolListOutput(out)
|
|
}
|
|
|
|
// Datasets returns all datasets on the system with usage and mountpoint
|
|
// information using `zfs list` (recursive by default).
|
|
func Datasets() ([]Dataset, error) {
|
|
out, err := commandOutput("zfs", "list", "-Hp", "-o", "name,used,avail,mountpoint")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zfs list: %w", err)
|
|
}
|
|
return parseZfsListOutput(out)
|
|
}
|
|
|
|
// parseZpoolListOutput parses `zpool list -Hp -o name,size,alloc,free,health` output.
|
|
// Columns are tab-separated; numeric columns are raw bytes.
|
|
func parseZpoolListOutput(out []byte) ([]PoolStat, error) {
|
|
var pools []PoolStat
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if line == "no pools available" && len(pools) == 0 {
|
|
return nil, nil
|
|
}
|
|
fields := strings.Split(line, "\t")
|
|
if len(fields) < 5 {
|
|
return nil, fmt.Errorf("unexpected zpool list line: %q", line)
|
|
}
|
|
size, err := strconv.ParseUint(fields[1], 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing size for pool %q: %w", fields[0], err)
|
|
}
|
|
alloc, err := strconv.ParseUint(fields[2], 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing alloc for pool %q: %w", fields[0], err)
|
|
}
|
|
free, err := strconv.ParseUint(fields[3], 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing free for pool %q: %w", fields[0], err)
|
|
}
|
|
pools = append(pools, PoolStat{
|
|
Name: fields[0],
|
|
Size: size,
|
|
Alloc: alloc,
|
|
Free: free,
|
|
Health: fields[4],
|
|
})
|
|
}
|
|
return pools, scanner.Err()
|
|
}
|
|
|
|
// parseZfsListOutput parses `zfs list -Hp -o name,used,avail,mountpoint` output.
|
|
// The mountpoint column may contain spaces, so it is split on tabs only.
|
|
func parseZfsListOutput(out []byte) ([]Dataset, error) {
|
|
var datasets []Dataset
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
fields := strings.SplitN(line, "\t", 4)
|
|
if len(fields) < 4 {
|
|
return nil, fmt.Errorf("unexpected zfs list line: %q", line)
|
|
}
|
|
used, err := strconv.ParseUint(fields[1], 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing used for dataset %q: %w", fields[0], err)
|
|
}
|
|
avail, err := strconv.ParseUint(fields[2], 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing avail for dataset %q: %w", fields[0], err)
|
|
}
|
|
datasets = append(datasets, Dataset{
|
|
Name: fields[0],
|
|
Used: used,
|
|
Avail: avail,
|
|
Mountpoint: fields[3],
|
|
})
|
|
}
|
|
return datasets, scanner.Err()
|
|
}
|