188 lines
5.1 KiB
Go
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
|
|
}
|