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
+75
View File
@@ -0,0 +1,75 @@
// Package collect reads system state from /proc, /sys and CLI tools.
package collect
import (
"bufio"
"bytes"
"fmt"
"os"
"runtime"
"strconv"
"strings"
)
type CPU struct {
UsagePct float64 `json:"usage_pct"`
Cores int `json:"cores"`
Load [3]float64 `json:"load"`
}
type cpuTimes struct{ idle, total uint64 }
// CPUSampler keeps the previous /proc/stat reading to compute usage deltas.
type CPUSampler struct {
prev cpuTimes
init bool
}
func (s *CPUSampler) Sample() (CPU, error) {
b, err := os.ReadFile("/proc/stat")
if err != nil {
return CPU{}, err
}
cur, err := parseProcStat(b)
if err != nil {
return CPU{}, err
}
c := CPU{Cores: runtime.NumCPU()}
if s.init && cur.total > s.prev.total {
dt := float64(cur.total - s.prev.total)
di := float64(cur.idle - s.prev.idle)
c.UsagePct = 100 * (1 - di/dt)
}
s.prev, s.init = cur, true
if lb, err := os.ReadFile("/proc/loadavg"); err == nil {
f := strings.Fields(string(lb))
for i := 0; i < 3 && i < len(f); i++ {
c.Load[i], _ = strconv.ParseFloat(f[i], 64)
}
}
return c, nil
}
// parseProcStat returns idle and total jiffies from the aggregate "cpu" line.
func parseProcStat(b []byte) (cpuTimes, error) {
sc := bufio.NewScanner(bytes.NewReader(b))
for sc.Scan() {
f := strings.Fields(sc.Text())
if len(f) < 5 || f[0] != "cpu" {
continue
}
var t cpuTimes
for i, v := range f[1:] {
n, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return t, err
}
t.total += n
if i == 3 || i == 4 { // idle + iowait
t.idle += n
}
}
return t, nil
}
return cpuTimes{}, fmt.Errorf("no cpu line in /proc/stat")
}
+92
View File
@@ -0,0 +1,92 @@
package collect
import (
"encoding/json"
"strconv"
"strings"
"time"
)
type Disk struct {
Name string `json:"name"` // sda
Path string `json:"path"` // /dev/sda
Model string `json:"model"`
Serial string `json:"serial"`
Size uint64 `json:"size"`
Rota bool `json:"rota"`
Tran string `json:"tran"` // sata, nvme, usb
}
type lsblkOut struct {
Blockdevices []struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Model string `json:"model"`
Serial string `json:"serial"`
Size flexUint `json:"size"`
Rota flexBool `json:"rota"`
Tran string `json:"tran"`
} `json:"blockdevices"`
}
// flexBool accepts true/false as well as the "1"/"0" strings older lsblk prints.
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
switch strings.Trim(string(data), `"`) {
case "true", "1":
*b = true
default:
*b = false
}
return nil
}
// flexUint accepts a JSON number or a numeric string.
type flexUint uint64
func (u *flexUint) UnmarshalJSON(data []byte) error {
n, err := strconv.ParseUint(strings.Trim(string(data), `"`), 10, 64)
if err != nil {
*u = 0
return nil
}
*u = flexUint(n)
return nil
}
// ListDisks returns physical block devices (whole disks, no loop/zram/dm).
func ListDisks() ([]Disk, error) {
b, err := run(10*time.Second, "lsblk", "-J", "-b", "-d", "-o", "NAME,PATH,TYPE,MODEL,SERIAL,SIZE,ROTA,TRAN")
if err != nil {
return nil, err
}
return parseLsblk(b)
}
func parseLsblk(b []byte) ([]Disk, error) {
var o lsblkOut
if err := json.Unmarshal(b, &o); err != nil {
return nil, err
}
var out []Disk
for _, d := range o.Blockdevices {
if d.Type != "disk" {
continue
}
if strings.HasPrefix(d.Name, "zram") || strings.HasPrefix(d.Name, "loop") ||
strings.HasPrefix(d.Name, "dm-") || strings.HasPrefix(d.Name, "zd") || strings.HasPrefix(d.Name, "sr") {
continue
}
path := d.Path
if path == "" {
path = "/dev/" + d.Name
}
out = append(out, Disk{
Name: d.Name, Path: path, Model: strings.TrimSpace(d.Model),
Serial: strings.TrimSpace(d.Serial), Size: uint64(d.Size), Rota: bool(d.Rota), Tran: d.Tran,
})
}
return out, nil
}
+28
View File
@@ -0,0 +1,28 @@
package collect
import (
"bytes"
"context"
"errors"
"os/exec"
"time"
)
// run executes a command with a timeout and returns stdout even on non-zero
// exit (smartctl encodes state in its exit code and still prints JSON).
func run(timeout time.Duration, name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return out.Bytes(), ee
}
return out.Bytes(), errors.New(name + ": " + err.Error() + " " + errb.String())
}
return out.Bytes(), nil
}
+20
View File
@@ -0,0 +1,20 @@
//go:build linux
package collect
import "syscall"
type FSUsage struct {
Path string `json:"path"`
Total uint64 `json:"total"`
Free uint64 `json:"free"`
}
func ReadFS(path string) (FSUsage, error) {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return FSUsage{}, err
}
bs := uint64(st.Bsize)
return FSUsage{Path: path, Total: st.Blocks * bs, Free: st.Bavail * bs}, nil
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux
package collect
import "errors"
type FSUsage struct {
Path string `json:"path"`
Total uint64 `json:"total"`
Free uint64 `json:"free"`
}
func ReadFS(path string) (FSUsage, error) { return FSUsage{}, errors.New("unsupported OS") }
+77
View File
@@ -0,0 +1,77 @@
package collect
import (
"bufio"
"bytes"
"os"
"strconv"
"strings"
)
type Memory struct {
Total uint64 `json:"total"`
Available uint64 `json:"available"`
Used uint64 `json:"used"` // Total - Available
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
ARCSize uint64 `json:"arc_size"`
ARCMax uint64 `json:"arc_max"`
}
func ReadMemory() (Memory, error) {
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return Memory{}, err
}
m := parseMeminfo(b)
if ab, err := os.ReadFile("/proc/spl/kstat/zfs/arcstats"); err == nil {
m.ARCSize, m.ARCMax = parseArcstats(ab)
}
return m, nil
}
func parseMeminfo(b []byte) Memory {
kv := map[string]uint64{}
sc := bufio.NewScanner(bytes.NewReader(b))
for sc.Scan() {
f := strings.Fields(sc.Text())
if len(f) < 2 {
continue
}
n, err := strconv.ParseUint(f[1], 10, 64)
if err != nil {
continue
}
kv[strings.TrimSuffix(f[0], ":")] = n * 1024
}
m := Memory{
Total: kv["MemTotal"],
Available: kv["MemAvailable"],
SwapTotal: kv["SwapTotal"],
}
m.Used = m.Total - m.Available
m.SwapUsed = m.SwapTotal - kv["SwapFree"]
return m
}
// parseArcstats picks "size" and "c_max" from the kstat table (name type data).
func parseArcstats(b []byte) (size, max uint64) {
sc := bufio.NewScanner(bytes.NewReader(b))
for sc.Scan() {
f := strings.Fields(sc.Text())
if len(f) != 3 {
continue
}
n, err := strconv.ParseUint(f[2], 10, 64)
if err != nil {
continue
}
switch f[0] {
case "size":
size = n
case "c_max":
max = n
}
}
return
}
+62
View File
@@ -0,0 +1,62 @@
package collect
import "testing"
func TestParseProcStat(t *testing.T) {
in := "cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 50 0 25 400 25 0 0 0 0 0\n"
c, err := parseProcStat([]byte(in))
if err != nil {
t.Fatal(err)
}
if c.total != 1000 || c.idle != 850 {
t.Errorf("total=%d idle=%d", c.total, c.idle)
}
if _, err := parseProcStat([]byte("intr 1 2 3\n")); err == nil {
t.Errorf("expected error without cpu line")
}
}
func TestParseMeminfo(t *testing.T) {
in := "MemTotal: 16000000 kB\nMemFree: 1000000 kB\nMemAvailable: 9000000 kB\nSwapTotal: 2000000 kB\nSwapFree: 1500000 kB\n"
m := parseMeminfo([]byte(in))
if m.Total != 16000000*1024 || m.Available != 9000000*1024 || m.Used != 7000000*1024 {
t.Errorf("mem = %+v", m)
}
if m.SwapTotal != 2000000*1024 || m.SwapUsed != 500000*1024 {
t.Errorf("swap = %+v", m)
}
}
func TestParseArcstats(t *testing.T) {
in := "13 1 0x01 123 1 2 3\nname type data\nhits 4 1000\nsize 4 4294967296\nc_max 4 8589934592\n"
size, max := parseArcstats([]byte(in))
if size != 4294967296 || max != 8589934592 {
t.Errorf("size=%d max=%d", size, max)
}
}
func TestCPUTemp(t *testing.T) {
temps := []Temp{
{Chip: "nvme", Label: "Composite", C: 41},
{Chip: "coretemp", Label: "Package id 0", C: 55},
{Chip: "coretemp", Label: "Core 0", C: 52},
{Chip: "acpitz", Label: "temp1", C: 27},
}
c, ok := CPUTemp(temps)
if !ok || c != 55 {
t.Errorf("cpu temp = %v %v", c, ok)
}
if _, ok := CPUTemp([]Temp{{Chip: "nvme", Label: "Composite", C: 41}}); ok {
t.Errorf("nvme must not count as CPU")
}
}
func TestParseSmbSessions(t *testing.T) {
in := `{"timestamp": "x", "version": "4.19", "smb_conf": "/etc/samba/smb.conf", "sessions": {"123": {"username": "a"}, "456": {"username": "b"}}}`
if n := parseSmbSessions([]byte(in)); n != 2 {
t.Errorf("sessions = %d", n)
}
if n := parseSmbSessions([]byte("garbage")); n != 0 {
t.Errorf("garbage = %d", n)
}
}
+58
View File
@@ -0,0 +1,58 @@
package collect
import (
"encoding/json"
"os/exec"
"strings"
"time"
)
// ShareServices is the read-only view of the file-sharing daemons.
type ShareServices struct {
SMBInstalled bool `json:"smb_installed"`
NFSInstalled bool `json:"nfs_installed"`
SMBActive bool `json:"smb_active"`
NFSActive bool `json:"nfs_active"`
SMBSessions int `json:"smb_sessions"`
}
// ReadShareServices checks which sharing daemons exist and run.
func ReadShareServices() ShareServices {
var s ShareServices
if _, err := exec.LookPath("smbd"); err == nil {
s.SMBInstalled = true
s.SMBActive = unitActive("smbd")
if s.SMBActive {
s.SMBSessions = smbSessions()
}
}
if _, err := exec.LookPath("exportfs"); err == nil {
s.NFSInstalled = true
s.NFSActive = unitActive("nfs-server")
}
return s
}
func unitActive(unit string) bool {
out, _ := run(5*time.Second, "systemctl", "is-active", unit)
return strings.TrimSpace(string(out)) == "active"
}
// smbSessions counts connected SMB sessions via smbstatus --json (Samba ≥ 4.17).
func smbSessions() int {
out, err := run(10*time.Second, "smbstatus", "--json", "-b")
if err != nil && len(out) == 0 {
return 0
}
return parseSmbSessions(out)
}
func parseSmbSessions(b []byte) int {
var j struct {
Sessions map[string]json.RawMessage `json:"sessions"`
}
if json.Unmarshal(b, &j) != nil {
return 0
}
return len(j.Sessions)
}
+291
View File
@@ -0,0 +1,291 @@
package collect
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type SmartAttr struct {
ID int `json:"id"`
Name string `json:"name"`
Value int `json:"value"`
Worst int `json:"worst"`
Thresh int `json:"thresh"`
Raw int64 `json:"raw"`
RawString string `json:"raw_string"`
WhenFailed string `json:"when_failed"`
}
type NVMeHealth struct {
CriticalWarning int `json:"critical_warning"`
AvailableSpare int `json:"available_spare"`
SpareThreshold int `json:"spare_threshold"`
PercentageUsed int `json:"percentage_used"`
MediaErrors int64 `json:"media_errors"`
ErrLogEntries int64 `json:"err_log_entries"`
UnsafeShutdowns int64 `json:"unsafe_shutdowns"`
}
type SelfTest struct {
Type string `json:"type"`
Status string `json:"status"`
Passed bool `json:"passed"`
Hours int64 `json:"hours"`
InProgress bool `json:"in_progress"`
Remaining int `json:"remaining_pct"`
}
// Smart is the normalised view of one smartctl -j -a run.
type Smart struct {
Device string `json:"device"`
Protocol string `json:"protocol"` // ATA | NVMe | SCSI
Model string `json:"model"`
Serial string `json:"serial"`
Firmware string `json:"firmware"`
Capacity uint64 `json:"capacity"`
Standby bool `json:"standby"` // disk asleep, nothing was read
Supported bool `json:"supported"` // SMART available & enabled
Healthy bool `json:"healthy"`
TempC int `json:"temp_c"`
HasTemp bool `json:"has_temp"`
PowerOnHrs int64 `json:"power_on_hours"`
PowerCycles int64 `json:"power_cycles"`
Attrs []SmartAttr `json:"attrs,omitempty"`
NVMe *NVMeHealth `json:"nvme,omitempty"`
LastTest *SelfTest `json:"last_test,omitempty"`
Error string `json:"error,omitempty"`
CheckedAt time.Time `json:"checked_at"`
}
// Attr returns the ATA attribute with the given id, if present.
func (s *Smart) Attr(id int) (SmartAttr, bool) {
for _, a := range s.Attrs {
if a.ID == id {
return a, true
}
}
return SmartAttr{}, false
}
// ReadSmart runs smartctl for one device. skipStandby adds "-n standby" so a
// spun-down disk is not woken; then Standby=true and nothing else is filled.
func ReadSmart(dev string, skipStandby bool) Smart {
args := []string{"-j", "-a"}
if skipStandby {
args = append(args, "-n", "standby")
}
args = append(args, dev)
out, err := run(60*time.Second, "smartctl", args...)
s := parseSmart(out)
s.Device = dev
s.CheckedAt = time.Now()
if len(out) == 0 && err != nil {
s.Error = err.Error()
}
return s
}
type smartMsg struct {
String string `json:"string"`
Severity string `json:"severity"`
}
type smartJSON struct {
Smartctl struct {
ExitStatus int `json:"exit_status"`
Messages []smartMsg `json:"messages"`
} `json:"smartctl"`
Device struct {
Protocol string `json:"protocol"`
} `json:"device"`
ModelName string `json:"model_name"`
SerialNumber string `json:"serial_number"`
Firmware string `json:"firmware_version"`
UserCapacity struct {
Bytes uint64 `json:"bytes"`
} `json:"user_capacity"`
SmartSupport struct {
Available bool `json:"available"`
Enabled bool `json:"enabled"`
} `json:"smart_support"`
SmartStatus *struct {
Passed bool `json:"passed"`
} `json:"smart_status"`
Temperature struct {
Current *int `json:"current"`
} `json:"temperature"`
PowerOnTime struct {
Hours int64 `json:"hours"`
} `json:"power_on_time"`
PowerCycleCount int64 `json:"power_cycle_count"`
ATAAttrs struct {
Table []struct {
ID int `json:"id"`
Name string `json:"name"`
Value int `json:"value"`
Worst int `json:"worst"`
Thresh int `json:"thresh"`
WhenFailed string `json:"when_failed"`
Raw struct {
Value int64 `json:"value"`
String string `json:"string"`
} `json:"raw"`
} `json:"table"`
} `json:"ata_smart_attributes"`
ATASelfTestLog struct {
Standard struct {
Table []struct {
Type struct {
String string `json:"string"`
} `json:"type"`
Status struct {
String string `json:"string"`
Passed bool `json:"passed"`
} `json:"status"`
LifetimeHours int64 `json:"lifetime_hours"`
} `json:"table"`
} `json:"standard"`
} `json:"ata_smart_self_test_log"`
ATASmartData struct {
SelfTest struct {
Status struct {
String string `json:"string"`
RemainingPercent *int `json:"remaining_percent"`
} `json:"status"`
} `json:"self_test"`
} `json:"ata_smart_data"`
NVMeLog *struct {
CriticalWarning int `json:"critical_warning"`
AvailableSpare int `json:"available_spare"`
SpareThreshold int `json:"available_spare_threshold"`
PercentageUsed int `json:"percentage_used"`
MediaErrors int64 `json:"media_errors"`
NumErrLogEntries int64 `json:"num_err_log_entries"`
UnsafeShutdowns int64 `json:"unsafe_shutdowns"`
PowerOnHours int64 `json:"power_on_hours"`
PowerCycles int64 `json:"power_cycles"`
} `json:"nvme_smart_health_information_log"`
NVMeSelfTestLog struct {
Current struct {
Value int `json:"value"`
String string `json:"string"`
} `json:"current_self_test_operation"`
CurrentCompletion *int `json:"current_self_test_completion_percent"`
Table []struct {
Code struct {
String string `json:"string"`
} `json:"self_test_code"`
Result struct {
Value int `json:"value"`
String string `json:"string"`
} `json:"self_test_result"`
PowerOnHours int64 `json:"power_on_hours"`
} `json:"table"`
} `json:"nvme_self_test_log"`
}
func parseSmart(b []byte) Smart {
var s Smart
var j smartJSON
if err := json.Unmarshal(b, &j); err != nil {
s.Error = fmt.Sprintf("parse smartctl output: %v", err)
return s
}
for _, m := range j.Smartctl.Messages {
if strings.Contains(strings.ToUpper(m.String), "STANDBY") {
s.Standby = true
return s
}
}
// bit 0: command line error, bit 1: device open failed
if j.Smartctl.ExitStatus&3 != 0 {
for _, m := range j.Smartctl.Messages {
if m.Severity == "error" {
s.Error = m.String
break
}
}
if s.Error == "" {
s.Error = fmt.Sprintf("smartctl exit status %d", j.Smartctl.ExitStatus)
}
}
s.Protocol = j.Device.Protocol
s.Model = j.ModelName
s.Serial = j.SerialNumber
s.Firmware = j.Firmware
s.Capacity = j.UserCapacity.Bytes
s.Supported = (j.SmartSupport.Available && j.SmartSupport.Enabled) || j.SmartStatus != nil
if j.SmartStatus != nil {
s.Healthy = j.SmartStatus.Passed
}
if j.Temperature.Current != nil {
s.TempC, s.HasTemp = *j.Temperature.Current, true
}
s.PowerOnHrs = j.PowerOnTime.Hours
s.PowerCycles = j.PowerCycleCount
for _, a := range j.ATAAttrs.Table {
raw := a.Raw.Value
if a.ID == 188 {
// Seagate packs three 16-bit counters into 188; the low word is the timeout count
raw &= 0xFFFF
}
s.Attrs = append(s.Attrs, SmartAttr{
ID: a.ID, Name: a.Name, Value: a.Value, Worst: a.Worst, Thresh: a.Thresh,
Raw: raw, RawString: a.Raw.String, WhenFailed: a.WhenFailed,
})
}
if j.NVMeLog != nil {
s.NVMe = &NVMeHealth{
CriticalWarning: j.NVMeLog.CriticalWarning,
AvailableSpare: j.NVMeLog.AvailableSpare,
SpareThreshold: j.NVMeLog.SpareThreshold,
PercentageUsed: j.NVMeLog.PercentageUsed,
MediaErrors: j.NVMeLog.MediaErrors,
ErrLogEntries: j.NVMeLog.NumErrLogEntries,
UnsafeShutdowns: j.NVMeLog.UnsafeShutdowns,
}
if s.PowerOnHrs == 0 {
s.PowerOnHrs = j.NVMeLog.PowerOnHours
}
if s.PowerCycles == 0 {
s.PowerCycles = j.NVMeLog.PowerCycles
}
}
// self-test: an in-progress test wins, otherwise the newest log entry
if rp := j.ATASmartData.SelfTest.Status.RemainingPercent; rp != nil {
s.LastTest = &SelfTest{Status: j.ATASmartData.SelfTest.Status.String, InProgress: true, Remaining: *rp, Passed: true}
} else if t := j.ATASelfTestLog.Standard.Table; len(t) > 0 {
s.LastTest = &SelfTest{Type: t[0].Type.String, Status: t[0].Status.String, Passed: t[0].Status.Passed, Hours: t[0].LifetimeHours}
} else if j.NVMeSelfTestLog.Current.Value != 0 {
rem := 0
if j.NVMeSelfTestLog.CurrentCompletion != nil {
rem = 100 - *j.NVMeSelfTestLog.CurrentCompletion
}
s.LastTest = &SelfTest{Status: j.NVMeSelfTestLog.Current.String, InProgress: true, Remaining: rem, Passed: true}
} else if t := j.NVMeSelfTestLog.Table; len(t) > 0 {
s.LastTest = &SelfTest{Type: t[0].Code.String, Status: t[0].Result.String, Passed: t[0].Result.Value == 0, Hours: t[0].PowerOnHours}
}
return s
}
// StartSelfTest launches a short or long SMART self-test on the device.
func StartSelfTest(dev, kind string) error {
if kind != "short" && kind != "long" {
return fmt.Errorf("unknown self-test kind %q", kind)
}
out, err := run(30*time.Second, "smartctl", "-j", "-t", kind, dev)
if err != nil {
var j smartJSON
if json.Unmarshal(out, &j) == nil {
for _, m := range j.Smartctl.Messages {
if m.Severity == "error" {
return fmt.Errorf("smartctl -t %s %s: %s", kind, dev, m.String)
}
}
}
return fmt.Errorf("smartctl -t %s %s: %w", kind, dev, err)
}
return nil
}
+165
View File
@@ -0,0 +1,165 @@
package collect
import "testing"
const smartATA = `{
"smartctl": {"version": [7, 4], "exit_status": 0, "messages": []},
"device": {"name": "/dev/sda", "type": "sat", "protocol": "ATA"},
"model_name": "WDC WD80EFZZ-68BTXN0",
"serial_number": "WD-AAA",
"firmware_version": "81.00A81",
"user_capacity": {"blocks": 15628053168, "bytes": 8001563222016},
"rotation_rate": 5640,
"smart_support": {"available": true, "enabled": true},
"smart_status": {"passed": true},
"ata_smart_data": {
"self_test": {"status": {"value": 0, "string": "completed without error", "passed": true}}
},
"ata_smart_attributes": {"table": [
{"id": 5, "name": "Reallocated_Sector_Ct", "value": 200, "worst": 200, "thresh": 140, "when_failed": "", "raw": {"value": 0, "string": "0"}},
{"id": 9, "name": "Power_On_Hours", "value": 90, "worst": 90, "thresh": 0, "when_failed": "", "raw": {"value": 7541, "string": "7541"}},
{"id": 194, "name": "Temperature_Celsius", "value": 116, "worst": 100, "thresh": 0, "when_failed": "", "raw": {"value": 34, "string": "34"}},
{"id": 197, "name": "Current_Pending_Sector", "value": 200, "worst": 200, "thresh": 0, "when_failed": "", "raw": {"value": 8, "string": "8"}},
{"id": 199, "name": "UDMA_CRC_Error_Count", "value": 200, "worst": 200, "thresh": 0, "when_failed": "", "raw": {"value": 2, "string": "2"}},
{"id": 188, "name": "Command_Timeout", "value": 100, "worst": 100, "thresh": 0, "when_failed": "", "raw": {"value": 196611, "string": "3 3 3"}}
]},
"power_on_time": {"hours": 7541},
"power_cycle_count": 21,
"temperature": {"current": 34},
"ata_smart_self_test_log": {"standard": {"revision": 1, "table": [
{"type": {"value": 2, "string": "Extended offline"}, "status": {"value": 0, "string": "Completed without error", "passed": true}, "lifetime_hours": 7500},
{"type": {"value": 1, "string": "Short offline"}, "status": {"value": 0, "string": "Completed without error", "passed": true}, "lifetime_hours": 7400}
]}}
}`
const smartNVMe = `{
"smartctl": {"version": [7, 4], "exit_status": 0},
"device": {"name": "/dev/nvme0", "type": "nvme", "protocol": "NVMe"},
"model_name": "Samsung SSD 980 500GB",
"serial_number": "S64ANL0T",
"firmware_version": "2B4QFXO7",
"user_capacity": {"blocks": 976773168, "bytes": 500107862016},
"smart_support": {"available": true, "enabled": true},
"smart_status": {"passed": true, "nvme": {"value": 0}},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 41, "available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 3, "media_errors": 0, "num_err_log_entries": 12, "unsafe_shutdowns": 30,
"power_cycles": 300, "power_on_hours": 1234
},
"temperature": {"current": 41},
"power_cycle_count": 300,
"power_on_time": {"hours": 1234},
"nvme_self_test_log": {"current_self_test_operation": {"value": 0, "string": "No self-test in progress"},
"table": [{"self_test_code": {"value": 1, "string": "Short"}, "self_test_result": {"value": 0, "string": "Completed without error"}, "power_on_hours": 1200}]}
}`
const smartStandby = `{
"smartctl": {"version": [7, 4], "exit_status": 2,
"messages": [{"string": "Device is in STANDBY mode, exit(2)", "severity": "information"}]},
"device": {"name": "/dev/sdb", "type": "sat", "protocol": "ATA"}
}`
const smartOpenFail = `{
"smartctl": {"version": [7, 4], "exit_status": 2,
"messages": [{"string": "Smartctl open device: /dev/sdz failed: No such device", "severity": "error"}]}
}`
const smartInProgress = `{
"smartctl": {"version": [7, 4], "exit_status": 0},
"device": {"protocol": "ATA"},
"serial_number": "X",
"smart_support": {"available": true, "enabled": true},
"smart_status": {"passed": true},
"ata_smart_data": {"self_test": {"status": {"value": 249, "string": "in progress, 90% remaining", "remaining_percent": 90}}},
"ata_smart_self_test_log": {"standard": {"table": [
{"type": {"string": "Short offline"}, "status": {"string": "Completed: read failure", "passed": false}, "lifetime_hours": 10}]}}
}`
func TestParseSmartATA(t *testing.T) {
s := parseSmart([]byte(smartATA))
if s.Error != "" || s.Standby {
t.Fatalf("unexpected error/standby: %+v", s)
}
if s.Protocol != "ATA" || s.Serial != "WD-AAA" || !s.Supported || !s.Healthy {
t.Errorf("basic fields: %+v", s)
}
if !s.HasTemp || s.TempC != 34 || s.PowerOnHrs != 7541 || s.PowerCycles != 21 {
t.Errorf("temp/hours: %+v", s)
}
if a, ok := s.Attr(197); !ok || a.Raw != 8 {
t.Errorf("attr 197: %+v %v", a, ok)
}
if a, ok := s.Attr(188); !ok || a.Raw != 3 {
t.Errorf("attr 188 must be unpacked to the low word: %+v", a)
}
if s.LastTest == nil || s.LastTest.InProgress || !s.LastTest.Passed || s.LastTest.Type != "Extended offline" || s.LastTest.Hours != 7500 {
t.Errorf("last test: %+v", s.LastTest)
}
}
func TestParseSmartNVMe(t *testing.T) {
s := parseSmart([]byte(smartNVMe))
if s.NVMe == nil {
t.Fatalf("nvme log missing: %+v", s)
}
if s.NVMe.PercentageUsed != 3 || s.NVMe.AvailableSpare != 100 || s.NVMe.ErrLogEntries != 12 {
t.Errorf("nvme: %+v", s.NVMe)
}
if s.TempC != 41 || s.PowerOnHrs != 1234 || !s.Healthy {
t.Errorf("fields: %+v", s)
}
if s.LastTest == nil || s.LastTest.Type != "Short" || !s.LastTest.Passed {
t.Errorf("last test: %+v", s.LastTest)
}
}
func TestParseSmartStandby(t *testing.T) {
s := parseSmart([]byte(smartStandby))
if !s.Standby || s.Error != "" {
t.Errorf("standby not detected: %+v", s)
}
}
func TestParseSmartOpenFail(t *testing.T) {
s := parseSmart([]byte(smartOpenFail))
if s.Standby || s.Error == "" || s.Supported {
t.Errorf("open failure not reported: %+v", s)
}
}
func TestParseSmartInProgress(t *testing.T) {
s := parseSmart([]byte(smartInProgress))
if s.LastTest == nil || !s.LastTest.InProgress || s.LastTest.Remaining != 90 {
t.Errorf("in-progress test: %+v", s.LastTest)
}
}
func TestParseSmartGarbage(t *testing.T) {
s := parseSmart([]byte("not json"))
if s.Error == "" {
t.Errorf("expected parse error")
}
}
func TestParseLsblk(t *testing.T) {
in := `{"blockdevices": [
{"name":"sda","path":"/dev/sda","type":"disk","model":"WDC WD80EFZZ-68BTXN0","serial":"WD-AAA","size":8001563222016,"rota":true,"tran":"sata"},
{"name":"nvme0n1","path":"/dev/nvme0n1","type":"disk","model":"Samsung SSD 980 500GB","serial":"S64ANL0T","size":500107862016,"rota":false,"tran":"nvme"},
{"name":"loop0","path":"/dev/loop0","type":"loop","model":null,"serial":null,"size":4096,"rota":false,"tran":null},
{"name":"zd0","path":"/dev/zd0","type":"disk","model":null,"serial":null,"size":4096,"rota":false,"tran":null},
{"name":"sr0","path":"/dev/sr0","type":"rom","model":"DVD","serial":null,"size":1024,"rota":true,"tran":"sata"}
]}`
disks, err := parseLsblk([]byte(in))
if err != nil {
t.Fatal(err)
}
if len(disks) != 2 || disks[0].Name != "sda" || !disks[0].Rota || disks[1].Tran != "nvme" || disks[1].Rota {
t.Errorf("disks = %+v", disks)
}
// older util-linux prints rota as a string
old := `{"blockdevices": [{"name":"sda","type":"disk","size":"1000","rota":"1","tran":"sata"}]}`
disks, err = parseLsblk([]byte(old))
if err != nil || len(disks) != 1 || !disks[0].Rota || disks[0].Path != "/dev/sda" {
t.Errorf("old lsblk: %+v %v", disks, err)
}
}
+89
View File
@@ -0,0 +1,89 @@
package collect
import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
type Temp struct {
Chip string `json:"chip"`
Label string `json:"label"`
C float64 `json:"c"`
}
// ReadTemps lists every hwmon temperature input; falls back to thermal zones.
func ReadTemps() []Temp {
temps := readHwmon("/sys/class/hwmon")
if len(temps) == 0 {
temps = readThermalZones("/sys/class/thermal")
}
sort.Slice(temps, func(i, j int) bool {
if temps[i].Chip != temps[j].Chip {
return temps[i].Chip < temps[j].Chip
}
return temps[i].Label < temps[j].Label
})
return temps
}
func readHwmon(root string) []Temp {
var out []Temp
chips, _ := filepath.Glob(filepath.Join(root, "hwmon*"))
for _, chip := range chips {
name := readTrim(filepath.Join(chip, "name"))
inputs, _ := filepath.Glob(filepath.Join(chip, "temp*_input"))
for _, in := range inputs {
v, err := strconv.ParseFloat(readTrim(in), 64)
if err != nil {
continue
}
base := strings.TrimSuffix(in, "_input")
label := readTrim(base + "_label")
if label == "" {
label = filepath.Base(base)
}
out = append(out, Temp{Chip: name, Label: label, C: v / 1000})
}
}
return out
}
func readThermalZones(root string) []Temp {
var out []Temp
zones, _ := filepath.Glob(filepath.Join(root, "thermal_zone*"))
for _, z := range zones {
v, err := strconv.ParseFloat(readTrim(filepath.Join(z, "temp")), 64)
if err != nil {
continue
}
out = append(out, Temp{Chip: "thermal", Label: readTrim(filepath.Join(z, "type")), C: v / 1000})
}
return out
}
// CPUTemp picks the most plausible CPU package temperature from the list.
func CPUTemp(temps []Temp) (float64, bool) {
best, found := 0.0, false
for _, t := range temps {
chip := strings.ToLower(t.Chip)
lbl := strings.ToLower(t.Label)
isCPU := chip == "coretemp" || chip == "k10temp" || chip == "zenpower" ||
strings.Contains(lbl, "package") || strings.Contains(lbl, "tctl") || strings.Contains(lbl, "tdie") ||
strings.Contains(lbl, "x86_pkg_temp") || strings.Contains(lbl, "cpu")
if isCPU && (!found || t.C > best) {
best, found = t.C, true
}
}
return best, found
}
func readTrim(p string) string {
b, err := os.ReadFile(p)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
+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
}
+130
View File
@@ -0,0 +1,130 @@
package collect
import (
"testing"
"time"
)
const zpoolStatusOK = ` pool: nas_pool_1_8_gb
state: ONLINE
scan: scrub repaired 0B in 03:12:41 with 0 errors on Sun Sep 7 03:36:42 2025
config:
NAME STATE READ WRITE CKSUM
nas_pool_1_8_gb ONLINE 0 0 0
mirror-0 ONLINE 0 0 0
ata-WDC_WD80EFZZ-68BTXN0_WD-AAA ONLINE 0 0 0
ata-WDC_WD80EFZZ-68BTXN0_WD-BBB ONLINE 0 0 0
errors: No known data errors
`
const zpoolStatusDegraded = ` pool: tank
state: DEGRADED
status: One or more devices could not be opened. Sufficient replicas exist for
the pool to continue functioning in a degraded state.
action: Attach the missing device and online it using 'zpool online'.
see: https://openzfs.github.io/openzfs-docs/msg/ZFS-8000-2Q
scan: scrub in progress since Sat Sep 13 01:00:00 2025
1.20T / 7.10T scanned at 1.02G/s, 500G / 7.10T issued at 430M/s
0B repaired, 6.88% done, 04:28:12 to go
config:
NAME STATE READ WRITE CKSUM
tank DEGRADED 0 0 0
mirror-0 DEGRADED 0 0 0
ata-DISK_A ONLINE 0 0 3
1234567890123456789 UNAVAIL 0 0 0 was /dev/sdb1
logs
nvme0n1p2 ONLINE 0 0 0
errors: Permanent errors have been detected in the following files:
/tank/data/file.bin
`
func TestParseZpoolStatusOK(t *testing.T) {
p := Pool{Name: "nas_pool_1_8_gb"}
parseZpoolStatus([]byte(zpoolStatusOK), &p)
if p.Health != "ONLINE" {
t.Errorf("health = %q", p.Health)
}
if p.Errors != "No known data errors" {
t.Errorf("errors = %q", p.Errors)
}
if p.Scrubbing || p.ScanErrs != 0 {
t.Errorf("scan flags wrong: %+v", p)
}
if p.LastScrub == nil {
t.Fatalf("last scrub not parsed from %q", p.Scan)
}
want := time.Date(2025, 9, 7, 3, 36, 42, 0, time.Local)
if !p.LastScrub.Equal(want) {
t.Errorf("last scrub = %v, want %v", p.LastScrub, want)
}
if len(p.Vdevs) != 4 {
t.Fatalf("vdevs = %d: %+v", len(p.Vdevs), p.Vdevs)
}
if p.Vdevs[0].Depth != 0 || p.Vdevs[1].Depth != 1 || p.Vdevs[2].Depth != 2 {
t.Errorf("depths wrong: %+v", p.Vdevs)
}
if p.Vdevs[1].Name != "mirror-0" || p.Vdevs[1].State != "ONLINE" {
t.Errorf("mirror vdev wrong: %+v", p.Vdevs[1])
}
}
func TestParseZpoolStatusDegraded(t *testing.T) {
p := Pool{Name: "tank"}
parseZpoolStatus([]byte(zpoolStatusDegraded), &p)
if p.Health != "DEGRADED" {
t.Errorf("health = %q", p.Health)
}
if !p.Scrubbing {
t.Errorf("scrub in progress not detected: %q", p.Scan)
}
if p.LastScrub != nil {
t.Errorf("last scrub must be nil while scrubbing")
}
if p.Status == "" || p.Action == "" {
t.Errorf("status/action not captured: %q / %q", p.Status, p.Action)
}
if p.Errors == "" || p.Errors == "No known data errors" {
t.Errorf("errors = %q", p.Errors)
}
var unavail, cksum, logs bool
for _, v := range p.Vdevs {
switch {
case v.State == "UNAVAIL" && v.Note == "was /dev/sdb1":
unavail = true
case v.Name == "ata-DISK_A" && v.Cksum == 3:
cksum = true
case v.Name == "logs" && v.State == "":
logs = true
}
}
if !unavail || !cksum || !logs {
t.Errorf("vdev parse: unavail=%v cksum=%v logs=%v %+v", unavail, cksum, logs, p.Vdevs)
}
}
func TestParseZpoolList(t *testing.T) {
out := "nas_pool_1_8_gb\t7992048291840\t3500000000000\t4492048291840\t43\tONLINE\t7\ntmp_pool\t1000000000000\t900000000000\t100000000000\t90\tONLINE\t-\n"
pools := parseZpoolList([]byte(out))
if len(pools) != 2 {
t.Fatalf("pools = %d", len(pools))
}
if pools[0].CapPct != 43 || pools[0].FragPct != 7 || pools[0].Health != "ONLINE" {
t.Errorf("pool0 = %+v", pools[0])
}
if pools[1].CapPct != 90 || pools[1].FragPct != 0 {
t.Errorf("pool1 = %+v", pools[1])
}
}
func TestParseZfsList(t *testing.T) {
out := "nas_pool_1_8_gb\t100\t200\t50\t/nas_pool_1_8_gb\nnas_pool_1_8_gb/appdata\t10\t200\t10\t/nas_pool_1_8_gb/appdata\n"
ds := parseZfsList([]byte(out))
if len(ds) != 2 || ds[1].Name != "nas_pool_1_8_gb/appdata" || ds[1].Avail != 200 {
t.Errorf("datasets = %+v", ds)
}
}