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 }