Initial import: nasmon health monitor, split out of home_nas

Go service (nasmon/) plus nfpm packaging (packaging/), carried over from
the home_nas monorepo. Adds rpm packaging alongside the existing deb
build (Fedora/RHEL/Rocky dependency names via nfpm overrides, per-format
systemd unit path, dpkg+rpm-compatible maintainer scripts) and a Gitea
Actions workflow that builds both formats on every push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 10:24:34 +03:00
co-authored by Claude Sonnet 5
commit 09159a5f31
61 changed files with 7882 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
package collect
import (
"bufio"
"bytes"
"regexp"
"strconv"
"strings"
"time"
)
type Vdev struct {
Name string `json:"name"`
Depth int `json:"depth"`
State string `json:"state"`
Read int64 `json:"read"`
Write int64 `json:"write"`
Cksum int64 `json:"cksum"`
Note string `json:"note,omitempty"`
}
type Pool struct {
Name string `json:"name"`
Size uint64 `json:"size"`
Alloc uint64 `json:"alloc"`
Free uint64 `json:"free"`
CapPct int `json:"cap_pct"`
Health string `json:"health"`
FragPct int `json:"frag_pct"`
Scan string `json:"scan"`
ScanErrs int64 `json:"scan_errors"`
LastScrub *time.Time `json:"last_scrub,omitempty"`
Scrubbing bool `json:"scrubbing"`
Errors string `json:"errors"`
Status string `json:"status,omitempty"`
Action string `json:"action,omitempty"`
Vdevs []Vdev `json:"vdevs"`
}
type Dataset struct {
Name string `json:"name"`
Used uint64 `json:"used"`
Avail uint64 `json:"avail"`
Refer uint64 `json:"refer"`
Mountpoint string `json:"mountpoint"`
}
// ReadPools combines zpool list and zpool status for every pool.
func ReadPools() ([]Pool, error) {
out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag")
if err != nil {
return nil, err
}
pools := parseZpoolList(out)
for i := range pools {
st, err := run(20*time.Second, "zpool", "status", pools[i].Name)
if err != nil && len(st) == 0 {
continue
}
parseZpoolStatus(st, &pools[i])
}
return pools, nil
}
func ReadDatasets() ([]Dataset, error) {
out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint")
if err != nil {
return nil, err
}
return parseZfsList(out), nil
}
func parseZpoolList(b []byte) []Pool {
var pools []Pool
sc := bufio.NewScanner(bytes.NewReader(b))
for sc.Scan() {
f := strings.Split(sc.Text(), "\t")
if len(f) < 7 {
continue
}
p := Pool{Name: f[0], Health: f[5]}
p.Size, _ = strconv.ParseUint(f[1], 10, 64)
p.Alloc, _ = strconv.ParseUint(f[2], 10, 64)
p.Free, _ = strconv.ParseUint(f[3], 10, 64)
p.CapPct = atoiPct(f[4])
p.FragPct = atoiPct(f[6])
if p.CapPct == 0 && p.Size > 0 {
p.CapPct = int(p.Alloc * 100 / p.Size)
}
pools = append(pools, p)
}
return pools
}
func atoiPct(s string) int {
n, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(s), "%"))
return n
}
var (
reSection = regexp.MustCompile(`^ *(pool|state|status|action|see|scan|scrub|config|errors|remove|checkpoint|expand):(.*)$`)
reScanDate = regexp.MustCompile(`on (\w{3} \w{3}\s+\d+ \d+:\d+:\d+ \d{4})`)
reScanErrs = regexp.MustCompile(`with (\d+) errors`)
)
// parseZpoolStatus fills scan/errors/status/vdev tree from the text output of
// "zpool status <pool>" (zfs 2.2 has no JSON output).
func parseZpoolStatus(b []byte, p *Pool) {
sc := bufio.NewScanner(bytes.NewReader(b))
section := ""
var scanLines, statusLines, actionLines []string
inConfig, headerSeen := false, false
for sc.Scan() {
line := sc.Text()
trim := strings.TrimSpace(line)
if inConfig {
if trim == "" {
if headerSeen {
inConfig = false
}
continue
}
if !headerSeen {
if strings.HasPrefix(trim, "NAME") {
headerSeen = true
}
continue
}
p.Vdevs = append(p.Vdevs, parseVdevLine(line))
continue
}
if m := reSection.FindStringSubmatch(line); m != nil {
key, rest, _ := strings.Cut(trim, ":")
key = strings.TrimSpace(key)
rest = strings.TrimSpace(rest)
section = key
switch key {
case "config":
inConfig, headerSeen = true, false
case "scan", "scrub":
scanLines = append(scanLines, rest)
case "status":
statusLines = append(statusLines, rest)
case "action":
actionLines = append(actionLines, rest)
case "errors":
p.Errors = rest
case "state":
if p.Health == "" {
p.Health = rest
}
}
continue
}
// continuation line of the current section
switch section {
case "scan", "scrub":
scanLines = append(scanLines, trim)
case "status":
statusLines = append(statusLines, trim)
case "action":
actionLines = append(actionLines, trim)
case "errors":
if trim != "" {
p.Errors += " " + trim
}
}
}
p.Scan = strings.TrimSpace(strings.Join(scanLines, " "))
p.Status = strings.TrimSpace(strings.Join(statusLines, " "))
p.Action = strings.TrimSpace(strings.Join(actionLines, " "))
p.Scrubbing = strings.Contains(p.Scan, "scrub in progress")
if m := reScanErrs.FindStringSubmatch(p.Scan); m != nil {
p.ScanErrs, _ = strconv.ParseInt(m[1], 10, 64)
}
if strings.HasPrefix(p.Scan, "scrub repaired") || strings.HasPrefix(p.Scan, "scrub canceled") {
if m := reScanDate.FindStringSubmatch(p.Scan); m != nil {
if t, err := time.ParseInLocation("Mon Jan _2 15:04:05 2006", squashSpaces(m[1]), time.Local); err == nil {
p.LastScrub = &t
}
}
}
}
func squashSpaces(s string) string { return strings.Join(strings.Fields(s), " ") }
func parseVdevLine(line string) Vdev {
// zpool indents with a tab, then two spaces per level
body := strings.TrimLeft(line, "\t")
indent := len(body) - len(strings.TrimLeft(body, " "))
f := strings.Fields(body)
v := Vdev{Depth: indent / 2}
if len(f) == 0 {
return v
}
v.Name = f[0]
if len(f) >= 5 {
v.State = f[1]
v.Read, _ = strconv.ParseInt(f[2], 10, 64)
v.Write, _ = strconv.ParseInt(f[3], 10, 64)
v.Cksum, _ = strconv.ParseInt(f[4], 10, 64)
if len(f) > 5 {
v.Note = strings.Join(f[5:], " ")
}
} else if len(f) > 1 {
v.State = f[1]
}
return v
}
func parseZfsList(b []byte) []Dataset {
var out []Dataset
sc := bufio.NewScanner(bytes.NewReader(b))
for sc.Scan() {
f := strings.Split(sc.Text(), "\t")
if len(f) < 5 {
continue
}
d := Dataset{Name: f[0], Mountpoint: f[4]}
d.Used, _ = strconv.ParseUint(f[1], 10, 64)
d.Avail, _ = strconv.ParseUint(f[2], 10, 64)
d.Refer, _ = strconv.ParseUint(f[3], 10, 64)
out = append(out, d)
}
return out
}