Initial Beach: homelab control panel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6938da4d7a
18 files changed
+2071
No files matched your search
@@ -0,0 +1,187 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user