mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-22 01:17:48 +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>
151 lines
4.4 KiB
Go
151 lines
4.4 KiB
Go
package zfs
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// PoolStatus holds parsed `zpool status` information for one pool.
|
|
type PoolStatus struct {
|
|
Name string
|
|
State string // ONLINE, DEGRADED, FAULTED, ...
|
|
Scrub ScrubStatus
|
|
Vdevs []VdevStatus
|
|
}
|
|
|
|
// ScrubStatus holds the scrub (or resilver) status parsed from the scan line.
|
|
type ScrubStatus struct {
|
|
State string // NONE, SCANNING, FINISHED, CANCELED
|
|
Progress string // e.g. "10.00%" while scanning
|
|
Errors uint64
|
|
}
|
|
|
|
// VdevStatus is a single vdev row (mirror, raidz, or leaf disk).
|
|
type VdevStatus struct {
|
|
Name string
|
|
State string
|
|
ReadErrs uint64
|
|
WriteErrs uint64
|
|
ChecksumErrs uint64
|
|
}
|
|
|
|
var (
|
|
progressRe = regexp.MustCompile(`(\d+\.\d+)%\s+done`)
|
|
errorsRe = regexp.MustCompile(`with\s+(\d+)\s+errors`)
|
|
)
|
|
|
|
// PoolStatuses runs `zpool status` and parses per-pool state, scrub, and vdev
|
|
// information. The human-readable format has been stable across OpenZFS
|
|
// releases; rows are matched by their tabular shape rather than position.
|
|
func PoolStatuses() ([]PoolStatus, error) {
|
|
out, err := commandOutput("zpool", "status")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zpool status: %w", err)
|
|
}
|
|
return parseZpoolStatusOutput(out)
|
|
}
|
|
|
|
// parseZpoolStatusOutput parses the output of `zpool status`.
|
|
func parseZpoolStatusOutput(out []byte) ([]PoolStatus, error) {
|
|
var pools []PoolStatus
|
|
var current *PoolStatus
|
|
inConfig := false
|
|
scanContinuation := false // next non-blank line continues the scan line (progress)
|
|
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
switch {
|
|
case strings.HasPrefix(trimmed, "pool:"):
|
|
pools = append(pools, PoolStatus{Name: strings.TrimSpace(strings.TrimPrefix(trimmed, "pool:"))})
|
|
current = &pools[len(pools)-1]
|
|
inConfig = false
|
|
scanContinuation = false
|
|
case current == nil:
|
|
continue
|
|
case strings.HasPrefix(trimmed, "state:"):
|
|
current.State = strings.TrimSpace(strings.TrimPrefix(trimmed, "state:"))
|
|
case strings.HasPrefix(trimmed, "scan:"):
|
|
current.Scrub = parseScanLine(trimmed)
|
|
// zpool status prints the progress percentage on the line after scan.
|
|
scanContinuation = true
|
|
case trimmed == "config:":
|
|
inConfig = true
|
|
case scanContinuation:
|
|
// The line after scan: may be an indented progress continuation.
|
|
if m := progressRe.FindStringSubmatch(trimmed); m != nil {
|
|
current.Scrub.Progress = m[1] + "%"
|
|
}
|
|
scanContinuation = false
|
|
case inConfig && (line == "" || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")):
|
|
// Table rows are indented; blank lines separate sections. The
|
|
// column header and the pool's own row are skipped.
|
|
if trimmed != "" && !strings.HasPrefix(trimmed, "NAME") {
|
|
if vdev, ok := parseVdevLine(trimmed, current.Name); ok {
|
|
current.Vdevs = append(current.Vdevs, vdev)
|
|
}
|
|
}
|
|
case inConfig:
|
|
// unindented line (errors:, status:, next pool:) ends the table
|
|
inConfig = false
|
|
}
|
|
}
|
|
return pools, scanner.Err()
|
|
}
|
|
|
|
// parseScanLine maps a `scan:` line to a ScrubStatus.
|
|
func parseScanLine(line string) ScrubStatus {
|
|
var scrub ScrubStatus
|
|
switch {
|
|
case strings.Contains(line, "in progress"):
|
|
scrub.State = "SCANNING"
|
|
case strings.Contains(line, "canceled"):
|
|
scrub.State = "CANCELED"
|
|
case strings.Contains(line, "repaired"), strings.Contains(line, "resilvered"):
|
|
scrub.State = "FINISHED"
|
|
default:
|
|
scrub.State = "NONE"
|
|
}
|
|
if m := progressRe.FindStringSubmatch(line); m != nil {
|
|
scrub.Progress = m[1] + "%"
|
|
}
|
|
if m := errorsRe.FindStringSubmatch(line); m != nil {
|
|
if n, err := strconv.ParseUint(m[1], 10, 64); err == nil {
|
|
scrub.Errors = n
|
|
}
|
|
}
|
|
return scrub
|
|
}
|
|
|
|
// parseVdevLine parses one row of the config table. Rows have the shape
|
|
// "NAME STATE READ WRITE CKSUM [extra...]". The first data row is the pool
|
|
// itself and is skipped since it duplicates pool-level info.
|
|
func parseVdevLine(line, poolName string) (VdevStatus, bool) {
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 5 {
|
|
return VdevStatus{}, false
|
|
}
|
|
if fields[0] == poolName {
|
|
return VdevStatus{}, false
|
|
}
|
|
read, err1 := strconv.ParseUint(fields[2], 10, 64)
|
|
write, err2 := strconv.ParseUint(fields[3], 10, 64)
|
|
cksum, err3 := strconv.ParseUint(fields[4], 10, 64)
|
|
if err1 != nil || err2 != nil || err3 != nil {
|
|
return VdevStatus{}, false
|
|
}
|
|
return VdevStatus{
|
|
Name: fields[0],
|
|
State: fields[1],
|
|
ReadErrs: read,
|
|
WriteErrs: write,
|
|
ChecksumErrs: cksum,
|
|
}, true
|
|
}
|