Files
2026-10-27 16:57:53 -06:00

188 lines
5.1 KiB
Go

package beach
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
)
// Pool is a ZFS pool as TrueNAS reports it.
type Pool struct {
Name string `json:"name"`
Status string `json:"status"` // ONLINE, DEGRADED, ...
Healthy bool `json:"healthy"`
Size int64 `json:"size"`
Allocated int64 `json:"allocated"`
UsedPct float64 `json:"usedPct"`
Scan string `json:"scan"` // "scrub finished, 0 errors" or "scrub in progress 41%"
Errors int `json:"errors"`
}
// Disk is a physical disk with its SMART verdict.
type Disk struct {
Name string `json:"name"`
Model string `json:"model"`
Serial string `json:"serial"`
Size int64 `json:"size"`
Pool string `json:"pool,omitempty"`
TempC float64 `json:"tempC"` // 0 when unknown
Health string `json:"health"` // PASSED, FAILED or "" when unknown
Source string `json:"source"` // truenas or smartctl
}
type Alert struct {
Level string `json:"level"`
Message string `json:"message"`
}
type Storage struct {
Pools []Pool `json:"pools"`
Disks []Disk `json:"disks"`
Alerts []Alert `json:"alerts"`
Error string `json:"error,omitempty"`
}
// TrueNAS reads the v2.0 REST API with an API key.
type TrueNAS struct {
base string
key string
client *http.Client
}
func NewTrueNAS(base, key string, insecure bool) *TrueNAS {
tr := &http.Transport{}
if insecure {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
return &TrueNAS{base: strings.TrimRight(base, "/"), key: key, client: &http.Client{Transport: tr, Timeout: 15 * time.Second}}
}
func (t *TrueNAS) call(ctx context.Context, method, path string, body io.Reader, out any) error {
req, err := http.NewRequestWithContext(ctx, method, t.base+"/api/v2.0"+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+t.key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := t.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return fmt.Errorf("truenas: API key rejected (%s)", resp.Status)
}
if resp.StatusCode >= 300 {
return fmt.Errorf("truenas %s: %s", path, resp.Status)
}
return json.Unmarshal(b, out)
}
// Collect gathers pools, disks, temperatures and alerts. A failing secondary
// endpoint degrades the result instead of failing the whole panel.
func (t *TrueNAS) Collect(ctx context.Context) Storage {
st := Storage{Pools: []Pool{}, Disks: []Disk{}, Alerts: []Alert{}}
var pools []map[string]any
if err := t.call(ctx, "GET", "/pool", nil, &pools); err != nil {
st.Error = err.Error()
return st
}
st.Pools = parsePools(pools)
var disks []map[string]any
if err := t.call(ctx, "GET", "/disk", nil, &disks); err == nil {
st.Disks = parseDisks(disks)
} else {
st.Error = err.Error()
}
var temps map[string]*float64
if err := t.call(ctx, "POST", "/disk/temperatures", strings.NewReader(`{}`), &temps); err == nil {
for i := range st.Disks {
if v := temps[st.Disks[i].Name]; v != nil {
st.Disks[i].TempC = *v
}
}
}
var alerts []map[string]any
if err := t.call(ctx, "GET", "/alert/list", nil, &alerts); err == nil {
st.Alerts = parseAlerts(alerts)
}
return st
}
func str(m map[string]any, k string) string {
if v, ok := m[k].(string); ok {
return v
}
return ""
}
func num(m map[string]any, k string) float64 {
switch v := m[k].(type) {
case float64:
return v
case map[string]any: // some fields come as {"parsed": n, "rawvalue": "n"}
if p, ok := v["parsed"].(float64); ok {
return p
}
}
return 0
}
func parsePools(in []map[string]any) []Pool {
out := []Pool{}
for _, m := range in {
p := Pool{Name: str(m, "name"), Status: str(m, "status"), Size: int64(num(m, "size")), Allocated: int64(num(m, "allocated"))}
p.Healthy, _ = m["healthy"].(bool)
if p.Size > 0 {
p.UsedPct = float64(p.Allocated) / float64(p.Size) * 100
}
if scan, ok := m["scan"].(map[string]any); ok {
p.Errors = int(num(scan, "errors"))
fn := strings.ToLower(str(scan, "function"))
state := strings.ToLower(str(scan, "state"))
switch {
case state == "scanning":
p.Scan = fmt.Sprintf("%s in progress %.0f%%", fn, num(scan, "percentage"))
case state != "":
p.Scan = fmt.Sprintf("%s %s, %d errors", fn, state, p.Errors)
}
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func parseDisks(in []map[string]any) []Disk {
out := []Disk{}
for _, m := range in {
out = append(out, Disk{Name: str(m, "name"), Model: str(m, "model"), Serial: str(m, "serial"), Size: int64(num(m, "size")), Pool: str(m, "pool"), Source: "truenas"})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func parseAlerts(in []map[string]any) []Alert {
out := []Alert{}
for _, m := range in {
if dismissed, _ := m["dismissed"].(bool); dismissed {
continue
}
msg := str(m, "formatted")
if msg == "" {
msg = str(m, "klass")
}
out = append(out, Alert{Level: str(m, "level"), Message: msg})
}
return out
}