Initial Beach: homelab control panel

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 16:57:53 -06:00
commit 6938da4d7a
18 files changed
+2071

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
package beach
import (
"context"
"encoding/binary"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestParseContainers(t *testing.T) {
body := `[
{"Id":"b","Names":["/zeta"],"Image":"nginx","State":"exited","Status":"Exited (0) 2 days ago","Created":1},
{"Id":"a","Names":["/alpha"],"Image":"redis","State":"running","Status":"Up 3 hours (healthy)","Created":2,
"Ports":[{"PrivatePort":6379,"PublicPort":6380,"Type":"tcp"},{"PrivatePort":6379,"PublicPort":6380,"Type":"tcp"},{"PrivatePort":9,"Type":"tcp"}]}]`
cs, err := parseContainers([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(cs) != 2 || cs[0].Name != "alpha" {
t.Fatalf("running containers should sort first: %+v", cs)
}
if len(cs[0].Ports) != 1 || cs[0].Ports[0] != "6380:6379/tcp" {
t.Fatalf("ports not deduplicated or unpublished port kept: %v", cs[0].Ports)
}
if cs[1].Ports == nil {
t.Fatal("ports must be an empty array, not null")
}
}
func frame(stream byte, s string) []byte {
h := make([]byte, 8)
h[0] = stream
binary.BigEndian.PutUint32(h[4:], uint32(len(s)))
return append(h, s...)
}
func TestDemux(t *testing.T) {
in := append(frame(1, "hello\n"), frame(2, "oops\n")...)
if got := demux(in); got != "hello\noops\n" {
t.Fatalf("got %q", got)
}
if got := demux([]byte("2026-01-01T00:00:00Z plain tty line\n")); !strings.Contains(got, "plain tty line") {
t.Fatalf("unframed text lost: %q", got)
}
}
func TestDockerAPI(t *testing.T) {
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
switch {
case r.URL.Path == "/containers/json":
w.Write([]byte(`[{"Id":"abc","Names":["/web"],"State":"running","Status":"Up"}]`))
case strings.HasSuffix(r.URL.Path, "/json"):
w.Write([]byte(`{"Config":{"Tty":false}}`))
case strings.HasSuffix(r.URL.Path, "/logs"):
w.Write(frame(1, "line one\n"))
case strings.HasSuffix(r.URL.Path, "/stop"):
w.WriteHeader(304)
case strings.HasSuffix(r.URL.Path, "/start"):
w.WriteHeader(404)
w.Write([]byte(`{"message":"No such container: abc"}`))
}
}))
defer srv.Close()
d := NewDocker("tcp://" + strings.TrimPrefix(srv.URL, "http://"))
ctx := context.Background()
cs, err := d.List(ctx)
if err != nil || len(cs) != 1 || cs[0].Name != "web" {
t.Fatalf("list: %v %+v", err, cs)
}
if out, err := d.Logs(ctx, "abc", 50); err != nil || out != "line one\n" {
t.Fatalf("logs: %q %v", out, err)
}
if err := d.Action(ctx, "abc", "stop"); err != nil {
t.Fatalf("304 means already stopped, not an error: %v", err)
}
if err := d.Action(ctx, "abc", "start"); err == nil || !strings.Contains(err.Error(), "No such container") {
t.Fatalf("engine message should surface: %v", err)
}
if err := d.Action(ctx, "abc", "rm"); err == nil {
t.Fatal("unknown action must be refused")
}
}
func TestTrueNAS(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
w.WriteHeader(401)
return
}
switch r.URL.Path {
case "/api/v2.0/pool":
w.Write([]byte(`[{"name":"tank","status":"ONLINE","healthy":true,"size":1000,"allocated":250,
"scan":{"function":"SCRUB","state":"FINISHED","errors":0}},
{"name":"fast","status":"DEGRADED","healthy":false,"size":0,"allocated":0,"scan":{"function":"SCRUB","state":"SCANNING","percentage":41.2}}]`))
case "/api/v2.0/disk":
w.Write([]byte(`[{"name":"sdb","model":"WD","serial":"S2","size":4000,"pool":"tank"},{"name":"sda","model":"WD","serial":"S1","size":4000,"pool":null}]`))
case "/api/v2.0/disk/temperatures":
w.Write([]byte(`{"sda":34,"sdb":null}`))
case "/api/v2.0/alert/list":
w.Write([]byte(`[{"level":"WARNING","formatted":"Pool degraded","dismissed":false},{"level":"INFO","formatted":"old","dismissed":true}]`))
default:
w.WriteHeader(404)
}
}))
defer srv.Close()
st := NewTrueNAS(srv.URL, "secret", false).Collect(context.Background())
if st.Error != "" {
t.Fatal(st.Error)
}
if len(st.Pools) != 2 || st.Pools[0].Name != "fast" {
t.Fatalf("pools: %+v", st.Pools)
}
tank := st.Pools[1]
if tank.UsedPct != 25 || tank.Scan != "scrub finished, 0 errors" || !tank.Healthy {
t.Fatalf("tank: %+v", tank)
}
if st.Pools[0].Scan != "scrub in progress 41%" {
t.Fatalf("scan progress: %q", st.Pools[0].Scan)
}
if len(st.Disks) != 2 || st.Disks[0].Name != "sda" || st.Disks[0].TempC != 34 || st.Disks[1].TempC != 0 {
t.Fatalf("disks: %+v", st.Disks)
}
if len(st.Alerts) != 1 || st.Alerts[0].Message != "Pool degraded" {
t.Fatalf("dismissed alerts must be hidden: %+v", st.Alerts)
}
bad := NewTrueNAS(srv.URL, "wrong", false).Collect(context.Background())
if !strings.Contains(bad.Error, "API key rejected") {
t.Fatalf("bad key error: %q", bad.Error)
}
}
const smartJSON = `{"device":{"name":"/dev/sda"},"model_name":"ST4000","serial_number":"Z1","user_capacity":{"bytes":4000787030016},
"smart_status":{"passed":%s},"temperature":{"current":38}}`
func TestParseSmartctl(t *testing.T) {
d, err := parseSmartctl([]byte(strings.Replace(smartJSON, "%s", "true", 1)))
if err != nil || d.Health != "PASSED" || d.TempC != 38 || d.Model != "ST4000" {
t.Fatalf("%+v %v", d, err)
}
d, _ = parseSmartctl([]byte(strings.Replace(smartJSON, "%s", "false", 1)))
if d.Health != "FAILED" {
t.Fatalf("failed disk reported as %q", d.Health)
}
d, _ = parseSmartctl([]byte(`{"device":{"name":"/dev/sdc"}}`))
if d.Health != "" {
t.Fatalf("no verdict must stay unknown, got %q", d.Health)
}
}
func TestLocalSmartAndMerge(t *testing.T) {
fake := func(ctx context.Context, args ...string) ([]byte, error) {
if args[0] == "--scan" {
return []byte(`{"devices":[{"name":"/dev/sda"},{"name":"/dev/sdz"}]}`), nil
}
if args[len(args)-1] == "/dev/sda" {
return []byte(strings.Replace(smartJSON, "%s", "true", 1)), nil
}
return []byte(`{"device":{"name":"/dev/sdz"},"serial_number":"ONLY","smart_status":{"passed":false}}`), nil
}
local, err := LocalSmart(context.Background(), fake)
if err != nil || len(local) != 2 {
t.Fatalf("%v %+v", err, local)
}
tn := []Disk{{Name: "sda", Serial: "Z1", Source: "truenas"}}
merged := mergeDisks(tn, local)
if len(merged) != 2 || merged[0].Health != "PASSED" || merged[0].TempC != 38 || merged[0].Source != "truenas" {
t.Fatalf("same disk should be merged, not duplicated: %+v", merged)
}
if merged[1].Serial != "ONLY" || merged[1].Health != "FAILED" {
t.Fatalf("smartctl-only disk missing: %+v", merged)
}
}
func TestHostParsers(t *testing.T) {
a, err := parseCPU("cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 1 1 1 1 1 1 1 1 1\n")
if err != nil {
t.Fatal(err)
}
b, _ := parseCPU("cpu 200 0 200 750 150 0 0 0 0 0\n")
// between samples: busy +200, idle(+iowait) +100
if got := cpuPct(a, b); got < 66 || got > 67 {
t.Fatalf("cpu %.1f", got)
}
if cpuPct(b, a) != 0 {
t.Fatal("a counter reset must not give a negative or huge value")
}
total, used, st, su := parseMeminfo("MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 200 kB\nSwapFree: 150 kB\n")
if total != 1000*1024 || used != 600*1024 || st != 200*1024 || su != 50*1024 {
t.Fatalf("%d %d %d %d", total, used, st, su)
}
if l := parseLoad("0.50 1.25 2.00 1/200 999"); l != [3]float64{0.5, 1.25, 2} {
t.Fatalf("%v", l)
}
if parseUptime("3600.55 7000.1") != 3600.55 {
t.Fatal("uptime")
}
mounts := "/dev/sda1 / ext4 rw 0 0\ntmpfs /run tmpfs rw 0 0\ntank/data /mnt/tank zfs rw 0 0\n/dev/sda1 /var/lib/docker/x ext4 rw 0 0\n/dev/sdb1 /var/lib/docker/y ext4 rw 0 0\n"
if got := realMounts(mounts); len(got) != 2 || got[0] != "/" || got[1] != "/mnt/tank" {
t.Fatalf("%v", got)
}
if h := CollectHost(); h.CPUs < 1 {
t.Fatal("CollectHost should report CPU count")
}
}
func TestParseTunnels(t *testing.T) {
body := `{"sites":[{"id":"s1","name":"homelab","version":"v0.1.8"},{"id":"s2","name":"vps","local":true}],
"online":{"s1":true,"s2":false},
"mappings":[{"name":"wiki","proto":"web","hostname":"wiki.example.com","port":443,"enabled":true,"statusKnown":true,
"targets":[{},{}],"status":{"targets":[{"healthy":true},{"healthy":false}]}},
{"name":"ssh","proto":"tcp","port":22,"enabled":true,"statusKnown":true,"status":{"error":"bind: in use"}},
{"name":"off","proto":"tcp","port":1,"enabled":false}]}`
tn, err := parseTunnels([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(tn.Sites) != 2 || tn.Sites[0].Name != "homelab" || !tn.Sites[0].Online || tn.Sites[1].Online {
t.Fatalf("sites: %+v", tn.Sites)
}
byName := map[string]TunnelPort{}
for _, p := range tn.Ports {
byName[p.Name] = p
}
if w := byName["wiki"]; !w.Up || w.Healthy != 1 || w.Targets != 2 {
t.Fatalf("wiki: %+v", w)
}
if byName["ssh"].Up || byName["off"].Up {
t.Fatal("errored or disabled ports must not be up")
}
}
func TestFetchTunnels(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/orgs/o1/state" || r.Header.Get("Authorization") != "Bearer k" {
w.WriteHeader(401)
return
}
w.Write([]byte(`{"sites":[],"mappings":[],"online":{}}`))
}))
defer srv.Close()
if tn := FetchTunnels(context.Background(), srv.URL+"/", "k", "o1"); tn.Error != "" {
t.Fatal(tn.Error)
}
if tn := FetchTunnels(context.Background(), srv.URL, "nope", "o1"); !strings.Contains(tn.Error, "401") {
t.Fatalf("expected 401 error, got %q", tn.Error)
}
}
+179
View File
@@ -0,0 +1,179 @@
package beach
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
const defaultDockerHost = "/var/run/docker.sock"
// Container is one row in the containers table.
type Container struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"` // running, exited, ...
Status string `json:"status"` // "Up 3 hours (healthy)"
Ports []string `json:"ports"`
Created int64 `json:"created"`
}
// Docker talks to the Engine API over a unix socket or plain TCP.
type Docker struct {
base string
client *http.Client
}
// NewDocker accepts "/var/run/docker.sock", "unix:///path", "tcp://host:2375" or "host:2375".
func NewDocker(host string) *Docker {
host = strings.TrimSpace(host)
d := &Docker{base: "http://docker"}
tr := &http.Transport{}
switch {
case strings.HasPrefix(host, "tcp://"), strings.HasPrefix(host, "http://"):
d.base = "http://" + strings.TrimPrefix(strings.TrimPrefix(host, "tcp://"), "http://")
case strings.HasPrefix(host, "/"), strings.HasPrefix(host, "unix://"):
path := strings.TrimPrefix(host, "unix://")
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{Timeout: 3 * time.Second}).DialContext(ctx, "unix", path)
}
default:
d.base = "http://" + host
}
d.client = &http.Client{Transport: tr, Timeout: 30 * time.Second}
return d
}
func (d *Docker) do(ctx context.Context, method, path string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, method, d.base+path, nil)
if err != nil {
return nil, err
}
resp, err := d.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if resp.StatusCode >= 300 && resp.StatusCode != http.StatusNotModified {
var e struct{ Message string }
json.Unmarshal(body, &e)
if e.Message == "" {
e.Message = resp.Status
}
return nil, fmt.Errorf("docker: %s", e.Message)
}
return body, nil
}
func (d *Docker) List(ctx context.Context) ([]Container, error) {
body, err := d.do(ctx, "GET", "/containers/json?all=1")
if err != nil {
return nil, err
}
return parseContainers(body)
}
func parseContainers(body []byte) ([]Container, error) {
var raw []struct {
ID string
Names []string
Image string
State string
Status string
Created int64
Ports []struct {
PrivatePort int
PublicPort int
Type string
}
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
out := make([]Container, 0, len(raw))
for _, r := range raw {
c := Container{ID: r.ID, Image: r.Image, State: r.State, Status: r.Status, Created: r.Created, Ports: []string{}}
if len(r.Names) > 0 {
c.Name = strings.TrimPrefix(r.Names[0], "/")
}
seen := map[string]bool{}
for _, p := range r.Ports {
if p.PublicPort == 0 {
continue
}
s := fmt.Sprintf("%d:%d/%s", p.PublicPort, p.PrivatePort, p.Type)
if !seen[s] { // IPv4 and IPv6 bindings repeat
seen[s] = true
c.Ports = append(c.Ports, s)
}
}
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool {
if (out[i].State == "running") != (out[j].State == "running") {
return out[i].State == "running"
}
return out[i].Name < out[j].Name
})
return out, nil
}
// Action is one of start, stop, restart.
func (d *Docker) Action(ctx context.Context, id, action string) error {
switch action {
case "start", "stop", "restart":
default:
return fmt.Errorf("unknown action %q", action)
}
_, err := d.do(ctx, "POST", "/containers/"+url.PathEscape(id)+"/"+action)
return err
}
func (d *Docker) Logs(ctx context.Context, id string, tail int) (string, error) {
if tail < 1 || tail > 5000 {
tail = 200
}
// the multiplexed format is only used for containers without a TTY
var info struct{ Config struct{ Tty bool } }
if b, err := d.do(ctx, "GET", "/containers/"+url.PathEscape(id)+"/json"); err == nil {
json.Unmarshal(b, &info)
}
body, err := d.do(ctx, "GET", fmt.Sprintf("/containers/%s/logs?stdout=1&stderr=1&timestamps=1&tail=%d", url.PathEscape(id), tail))
if err != nil {
return "", err
}
if info.Config.Tty {
return string(body), nil
}
return demux(body), nil
}
// demux strips the 8-byte stream headers Docker puts in front of each log frame.
func demux(b []byte) string {
var out bytes.Buffer
for len(b) >= 8 {
if b[0] > 2 || b[1] != 0 || b[2] != 0 || b[3] != 0 { // not framed; treat as plain text
out.Write(b)
return out.String()
}
n := int(binary.BigEndian.Uint32(b[4:8]))
b = b[8:]
if n > len(b) {
n = len(b)
}
out.Write(b[:n])
b = b[n:]
}
return out.String()
}
+111
View File
@@ -0,0 +1,111 @@
package beach
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
type TunnelSite struct {
Name string `json:"name"`
Online bool `json:"online"`
Local bool `json:"local"`
Version string `json:"version,omitempty"`
LastSeen time.Time `json:"lastSeen"`
}
type TunnelPort struct {
Name string `json:"name"`
Proto string `json:"proto"`
Hostname string `json:"hostname,omitempty"`
Port int `json:"port"`
Enabled bool `json:"enabled"`
Up bool `json:"up"` // the edge reports it listening without error
Known bool `json:"known"`
Healthy int `json:"healthy"`
Targets int `json:"targets"`
}
type Tunnels struct {
Sites []TunnelSite `json:"sites"`
Ports []TunnelPort `json:"ports"`
Error string `json:"error,omitempty"`
}
// FetchTunnels reads GET /api/orgs/{org}/state from Hammerhead with an API key.
func FetchTunnels(ctx context.Context, base, key, org string) Tunnels {
u := strings.TrimRight(base, "/") + "/api/orgs/" + url.PathEscape(org) + "/state"
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return Tunnels{Error: err.Error()}
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return Tunnels{Error: err.Error()}
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode != 200 {
return Tunnels{Error: fmt.Sprintf("hammerhead: %s", resp.Status)}
}
t, err := parseTunnels(b)
if err != nil {
return Tunnels{Error: err.Error()}
}
return t
}
func parseTunnels(b []byte) (Tunnels, error) {
var st struct {
Sites []struct {
ID string `json:"id"`
Name string `json:"name"`
Local bool `json:"local"`
Version string `json:"version"`
LastSeen time.Time `json:"lastSeen"`
} `json:"sites"`
Online map[string]bool `json:"online"`
Mappings []struct {
Name string `json:"name"`
Proto string `json:"proto"`
Hostname string `json:"hostname"`
Port int `json:"port"`
Enabled bool `json:"enabled"`
Targets []any `json:"targets"`
Status struct {
Error string `json:"error"`
Targets []struct {
Healthy bool `json:"healthy"`
} `json:"targets"`
} `json:"status"`
Known bool `json:"statusKnown"`
} `json:"mappings"`
}
if err := json.Unmarshal(b, &st); err != nil {
return Tunnels{}, fmt.Errorf("hammerhead: unexpected response: %w", err)
}
t := Tunnels{Sites: []TunnelSite{}, Ports: []TunnelPort{}}
for _, s := range st.Sites {
t.Sites = append(t.Sites, TunnelSite{Name: s.Name, Online: st.Online[s.ID], Local: s.Local, Version: s.Version, LastSeen: s.LastSeen})
}
for _, m := range st.Mappings {
p := TunnelPort{Name: m.Name, Proto: m.Proto, Hostname: m.Hostname, Port: m.Port, Enabled: m.Enabled, Known: m.Known, Targets: len(m.Targets)}
p.Up = m.Enabled && m.Known && m.Status.Error == ""
for _, th := range m.Status.Targets {
if th.Healthy {
p.Healthy++
}
}
t.Ports = append(t.Ports, p)
}
sort.Slice(t.Sites, func(i, j int) bool { return t.Sites[i].Name < t.Sites[j].Name })
sort.Slice(t.Ports, func(i, j int) bool { return t.Ports[i].Name < t.Ports[j].Name })
return t, nil
}
+129
View File
@@ -0,0 +1,129 @@
package beach
import (
"fmt"
"strconv"
"strings"
)
// Host is the machine Beach runs on.
type Host struct {
Hostname string `json:"hostname"`
Uptime float64 `json:"uptimeSeconds"`
Load [3]float64 `json:"load"`
CPUPct float64 `json:"cpuPct"`
CPUs int `json:"cpus"`
MemTotal uint64 `json:"memTotal"`
MemUsed uint64 `json:"memUsed"`
SwapTotal uint64 `json:"swapTotal"`
SwapUsed uint64 `json:"swapUsed"`
Disks []Filesystem `json:"filesystems"`
Supported bool `json:"supported"`
}
type Filesystem struct {
Mount string `json:"mount"`
Total uint64 `json:"total"`
Used uint64 `json:"used"`
}
// cpuTimes is the aggregate line of /proc/stat.
type cpuTimes struct{ busy, total uint64 }
func parseCPU(stat string) (cpuTimes, error) {
for _, line := range strings.Split(stat, "\n") {
f := strings.Fields(line)
if len(f) < 5 || f[0] != "cpu" {
continue
}
var v []uint64
for _, s := range f[1:] {
n, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return cpuTimes{}, err
}
v = append(v, n)
}
var total uint64
for i, n := range v {
if i < 8 { // guest times are already counted in user/nice
total += n
}
}
idle := v[3]
if len(v) > 4 {
idle += v[4] // iowait
}
return cpuTimes{busy: total - idle, total: total}, nil
}
return cpuTimes{}, fmt.Errorf("no cpu line")
}
// cpuPct is the busy share between two samples.
func cpuPct(a, b cpuTimes) float64 {
if b.total <= a.total || b.busy < a.busy {
return 0
}
return float64(b.busy-a.busy) / float64(b.total-a.total) * 100
}
func parseMeminfo(s string) (total, used, swapTotal, swapUsed uint64) {
kb := map[string]uint64{}
for _, line := range strings.Split(s, "\n") {
f := strings.Fields(line)
if len(f) >= 2 {
n, _ := strconv.ParseUint(f[1], 10, 64)
kb[strings.TrimSuffix(f[0], ":")] = n * 1024
}
}
total = kb["MemTotal"]
avail, ok := kb["MemAvailable"]
if !ok {
avail = kb["MemFree"] + kb["Buffers"] + kb["Cached"]
}
if avail <= total {
used = total - avail
}
swapTotal = kb["SwapTotal"]
if kb["SwapFree"] <= swapTotal {
swapUsed = swapTotal - kb["SwapFree"]
}
return
}
func parseLoad(s string) (l [3]float64) {
f := strings.Fields(s)
for i := 0; i < 3 && i < len(f); i++ {
l[i], _ = strconv.ParseFloat(f[i], 64)
}
return
}
func parseUptime(s string) float64 {
f := strings.Fields(s)
if len(f) == 0 {
return 0
}
v, _ := strconv.ParseFloat(f[0], 64)
return v
}
// realMounts picks the mounts worth showing from /proc/mounts: real block or
// ZFS filesystems, once per device, without Docker's own overlay mounts.
func realMounts(mounts string) []string {
ok := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "ext3": true, "f2fs": true, "vfat": true, "ntfs": true, "ntfs3": true, "exfat": true}
seen := map[string]bool{}
var out []string
for _, line := range strings.Split(mounts, "\n") {
f := strings.Fields(line)
if len(f) < 3 || !ok[f[2]] || seen[f[0]] {
continue
}
if strings.HasPrefix(f[1], "/var/lib/docker") || strings.HasPrefix(f[1], "/proc") || strings.HasPrefix(f[1], "/sys") {
continue
}
seen[f[0]] = true
out = append(out, f[1])
}
return out
}
+53
View File
@@ -0,0 +1,53 @@
//go:build linux
package beach
import (
"os"
"runtime"
"sync"
"syscall"
"time"
)
var (
cpuMu sync.Mutex
lastCPU cpuTimes
lastAt time.Time
lastPct float64
)
func readFile(p string) string { b, _ := os.ReadFile(p); return string(b) }
// CollectHost reads /proc. CPU use is the change since the previous call; the
// first call takes a short sample of its own.
func CollectHost() Host {
h := Host{Supported: true, CPUs: runtime.NumCPU(), Disks: []Filesystem{}}
h.Hostname, _ = os.Hostname()
h.Uptime = parseUptime(readFile("/proc/uptime"))
h.Load = parseLoad(readFile("/proc/loadavg"))
h.MemTotal, h.MemUsed, h.SwapTotal, h.SwapUsed = parseMeminfo(readFile("/proc/meminfo"))
cpuMu.Lock()
if now, err := parseCPU(readFile("/proc/stat")); err == nil {
if lastAt.IsZero() {
time.Sleep(250 * time.Millisecond)
if n2, err := parseCPU(readFile("/proc/stat")); err == nil {
lastPct, now = cpuPct(now, n2), n2
}
lastCPU, lastAt = now, time.Now()
} else if time.Since(lastAt) > 500*time.Millisecond {
lastPct = cpuPct(lastCPU, now)
lastCPU, lastAt = now, time.Now()
}
}
h.CPUPct = lastPct
cpuMu.Unlock()
for _, m := range realMounts(readFile("/proc/mounts")) {
var st syscall.Statfs_t
if syscall.Statfs(m, &st) == nil && st.Blocks > 0 {
bs := uint64(st.Bsize)
h.Disks = append(h.Disks, Filesystem{Mount: m, Total: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs})
}
}
return h
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !linux
package beach
import (
"os"
"runtime"
)
// CollectHost has no portable source off Linux; Beach is meant to run on the homelab box.
func CollectHost() Host {
h := Host{CPUs: runtime.NumCPU(), Disks: []Filesystem{}}
h.Hostname, _ = os.Hostname()
return h
}
+385
View File
@@ -0,0 +1,385 @@
package beach
import (
"context"
"embed"
"encoding/json"
"errors"
"io/fs"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
//go:embed web
var webFS embed.FS
const cookieName = "beach_session"
var dummyHash = func() string {
h, _ := bcrypt.GenerateFromPassword([]byte("beach"), bcrypt.DefaultCost)
return string(h)
}()
type Server struct {
st *Store
mux *http.ServeMux
smart runner // replaceable in tests
mu sync.Mutex
fails map[string][]time.Time // login failures per client address
}
func NewServer(st *Store) *Server {
s := &Server{st: st, mux: http.NewServeMux(), smart: execSmartctl, fails: map[string][]time.Time{}}
web, _ := fs.Sub(webFS, "web")
s.mux.Handle("GET /", http.FileServerFS(web))
s.mux.HandleFunc("GET /api/session", s.session)
s.mux.HandleFunc("POST /api/setup", s.setup)
s.mux.HandleFunc("POST /api/login", s.login)
s.mux.HandleFunc("POST /api/logout", s.logout)
s.mux.HandleFunc("GET /api/overview", s.auth(s.overview))
s.mux.HandleFunc("GET /api/settings", s.auth(s.getSettings))
s.mux.HandleFunc("PUT /api/settings", s.auth(s.putSettings))
s.mux.HandleFunc("POST /api/containers/{id}/{action}", s.auth(s.containerAction))
s.mux.HandleFunc("GET /api/containers/{id}/logs", s.auth(s.containerLogs))
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:")
// a custom header on every write keeps cross-site forms from reaching the API
if r.Method != "GET" && r.Method != "HEAD" && r.Header.Get("X-Beach") == "" {
fail(w, 403, errors.New("missing X-Beach header"))
return
}
s.mux.ServeHTTP(w, r)
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func fail(w http.ResponseWriter, code int, err error) {
writeJSON(w, code, map[string]string{"error": err.Error()})
}
func (s *Server) user(r *http.Request) string {
c, err := r.Cookie(cookieName)
if err != nil {
return ""
}
u, err := s.st.SessionUser(c.Value)
if err != nil {
return ""
}
return u
}
func (s *Server) auth(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.user(r) == "" {
fail(w, 401, errors.New("sign in to continue"))
return
}
fn(w, r)
}
}
func (s *Server) session(w http.ResponseWriter, r *http.Request) {
u := s.user(r)
writeJSON(w, 200, map[string]any{"setup": !s.st.HasUsers(), "user": u})
}
type creds struct{ Name, Password string }
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, name string) {
tok, err := s.st.NewSession(name, 30*24*time.Hour)
if err != nil {
fail(w, 500, err)
return
}
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: tok, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", MaxAge: 30 * 24 * 3600})
writeJSON(w, 200, map[string]any{"user": name})
}
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
var c creds
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&c) != nil {
fail(w, 400, errors.New("bad request"))
return
}
c.Name = strings.TrimSpace(c.Name)
if c.Name == "" || len(c.Password) < 8 {
fail(w, 400, errors.New("choose a name and a password of at least 8 characters"))
return
}
// serialize so two simultaneous first-run requests cannot both win
s.mu.Lock()
defer s.mu.Unlock()
if s.st.HasUsers() {
fail(w, 409, errors.New("Beach is already set up"))
return
}
h, err := bcrypt.GenerateFromPassword([]byte(c.Password), bcrypt.DefaultCost)
if err != nil {
fail(w, 500, err)
return
}
if err := s.st.AddUser(c.Name, string(h)); err != nil {
fail(w, 500, err)
return
}
s.startSession(w, r, c.Name)
}
func clientIP(r *http.Request) string {
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return h
}
// tooManyFails allows 8 failed sign-ins per client in 5 minutes.
func (s *Server) tooManyFails(ip string, record bool) bool {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
kept := s.fails[ip][:0]
for _, t := range s.fails[ip] {
if now.Sub(t) < 5*time.Minute {
kept = append(kept, t)
}
}
if record {
kept = append(kept, now)
}
s.fails[ip] = kept
return len(kept) >= 8
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if s.tooManyFails(ip, false) {
fail(w, 429, errors.New("too many attempts, try again in a few minutes"))
return
}
var c creds
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&c) != nil {
fail(w, 400, errors.New("bad request"))
return
}
hash, ok := s.st.UserHash(strings.TrimSpace(c.Name))
if !ok {
hash = dummyHash // spend the same time as a real check so names cannot be probed
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(c.Password)) != nil || !ok {
s.tooManyFails(ip, true)
fail(w, 401, errors.New("wrong name or password"))
return
}
s.startSession(w, r, strings.TrimSpace(c.Name))
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieName); err == nil {
s.st.DeleteSession(c.Value)
}
http.SetCookie(w, &http.Cookie{Name: cookieName, Path: "/", MaxAge: -1})
writeJSON(w, 200, map[string]bool{"ok": true})
}
// settingsView never carries secrets back to the browser.
type settingsView struct {
Settings
TrueNASKeySet bool `json:"truenasKeySet"`
HammerheadKeySet bool `json:"hammerheadKeySet"`
}
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
set := s.st.Settings()
v := settingsView{Settings: set, TrueNASKeySet: set.TrueNAS.APIKey != "", HammerheadKeySet: set.Hammerhead.APIKey != ""}
v.TrueNAS.APIKey, v.Hammerhead.APIKey = "", ""
writeJSON(w, 200, v)
}
func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
var in Settings
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&in) != nil {
fail(w, 400, errors.New("bad request"))
return
}
old := s.st.Settings()
// a blank key box means "keep the saved key"
if in.TrueNAS.APIKey == "" {
in.TrueNAS.APIKey = old.TrueNAS.APIKey
}
if in.Hammerhead.APIKey == "" {
in.Hammerhead.APIKey = old.Hammerhead.APIKey
}
for _, u := range []string{in.TrueNAS.URL, in.Hammerhead.URL} {
if u != "" && !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
fail(w, 400, errors.New("URLs must start with http:// or https://"))
return
}
}
if in.RefreshSeconds < 2 || in.RefreshSeconds > 3600 {
in.RefreshSeconds = 10
}
if err := s.st.SaveSettings(in); err != nil {
fail(w, 500, err)
return
}
s.getSettings(w, r)
}
// Overview is the single view: one object, each section collected independently.
type Overview struct {
Time time.Time `json:"time"`
Host Host `json:"host"`
Docker *DockerView `json:"docker"`
Storage *Storage `json:"storage"`
Tunnels *Tunnels `json:"tunnels"`
Refresh int `json:"refreshSeconds"`
}
type DockerView struct {
Containers []Container `json:"containers"`
Error string `json:"error,omitempty"`
}
func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
set := s.st.Settings()
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
o := Overview{Time: time.Now(), Refresh: set.RefreshSeconds}
var wg sync.WaitGroup
run := func(f func()) { wg.Add(1); go func() { defer wg.Done(); f() }() }
run(func() { o.Host = CollectHost() })
if set.DockerHost != "" {
run(func() {
v := &DockerView{Containers: []Container{}}
cs, err := NewDocker(set.DockerHost).List(ctx)
if err != nil {
v.Error = err.Error()
} else {
v.Containers = cs
}
o.Docker = v
})
}
if (set.TrueNAS.URL != "" && set.TrueNAS.APIKey != "") || set.Smartctl {
run(func() {
st := Storage{Pools: []Pool{}, Disks: []Disk{}, Alerts: []Alert{}}
if set.TrueNAS.URL != "" && set.TrueNAS.APIKey != "" {
st = NewTrueNAS(set.TrueNAS.URL, set.TrueNAS.APIKey, set.TrueNAS.Insecure).Collect(ctx)
}
if set.Smartctl {
disks, err := LocalSmart(ctx, s.smart)
if err != nil && st.Error == "" {
st.Error = "smartctl: " + err.Error()
}
st.Disks = mergeDisks(st.Disks, disks)
}
o.Storage = &st
})
}
if set.Hammerhead.URL != "" && set.Hammerhead.APIKey != "" && set.Hammerhead.Org != "" {
run(func() {
t := FetchTunnels(ctx, set.Hammerhead.URL, set.Hammerhead.APIKey, set.Hammerhead.Org)
o.Tunnels = &t
})
}
wg.Wait()
writeJSON(w, 200, o)
}
// mergeDisks lets smartctl fill in what TrueNAS did not report (health, temperature) for the same device,
// and adds disks only smartctl can see.
func mergeDisks(tn, local []Disk) []Disk {
idx := map[string]int{}
for i, d := range tn {
idx[d.Serial+"|"+d.Name] = i
if d.Serial != "" {
idx[d.Serial] = i
}
}
for _, l := range local {
i, ok := -1, false
if l.Serial != "" {
i, ok = lookup(idx, l.Serial)
}
if !ok {
i, ok = lookup(idx, "|"+strings.TrimPrefix(l.Name, "/dev/"))
}
if !ok {
tn = append(tn, l)
continue
}
if tn[i].Health == "" {
tn[i].Health = l.Health
}
if tn[i].TempC == 0 {
tn[i].TempC = l.TempC
}
}
return tn
}
func lookup(m map[string]int, k string) (int, bool) { i, ok := m[k]; return i, ok }
var idRe = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,128}$`)
func (s *Server) docker(w http.ResponseWriter, r *http.Request) (*Docker, string, bool) {
set := s.st.Settings()
id := r.PathValue("id")
if set.DockerHost == "" {
fail(w, 400, errors.New("Docker is not configured"))
return nil, "", false
}
if !idRe.MatchString(id) {
fail(w, 400, errors.New("bad container id"))
return nil, "", false
}
return NewDocker(set.DockerHost), id, true
}
func (s *Server) containerAction(w http.ResponseWriter, r *http.Request) {
d, id, ok := s.docker(w, r)
if !ok {
return
}
if err := d.Action(r.Context(), id, r.PathValue("action")); err != nil {
fail(w, 502, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (s *Server) containerLogs(w http.ResponseWriter, r *http.Request) {
d, id, ok := s.docker(w, r)
if !ok {
return
}
tail, _ := strconv.Atoi(r.URL.Query().Get("tail"))
out, err := d.Logs(r.Context(), id, tail)
if err != nil {
fail(w, 502, err)
return
}
writeJSON(w, 200, map[string]string{"logs": out})
}
+137
View File
@@ -0,0 +1,137 @@
package beach
import (
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
type client struct {
t *testing.T
c *http.Client
u string
}
func newClient(t *testing.T) (*client, *Store) {
st, err := OpenStore(filepath.Join(t.TempDir(), "beach.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
ts := httptest.NewServer(NewServer(st))
t.Cleanup(ts.Close)
jar, _ := cookiejar.New(nil)
return &client{t, &http.Client{Jar: jar}, ts.URL}, st
}
func (c *client) do(method, path, body string, hdr bool) (int, map[string]any) {
req, _ := http.NewRequest(method, c.u+path, strings.NewReader(body))
if hdr {
req.Header.Set("X-Beach", "1")
}
resp, err := c.c.Do(req)
if err != nil {
c.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var m map[string]any
json.Unmarshal(b, &m)
return resp.StatusCode, m
}
func TestAuthFlow(t *testing.T) {
c, _ := newClient(t)
if code, _ := c.do("GET", "/api/overview", "", false); code != 401 {
t.Fatalf("overview without a session: %d", code)
}
if code, _ := c.do("POST", "/api/setup", `{"name":"a","password":"longenough"}`, false); code != 403 {
t.Fatalf("writes without X-Beach must be refused, got %d", code)
}
if code, _ := c.do("POST", "/api/setup", `{"name":"a","password":"short"}`, true); code != 400 {
t.Fatalf("short password: %d", code)
}
if _, m := c.do("GET", "/api/session", "", false); m["setup"] != true {
t.Fatalf("fresh install should ask for setup: %v", m)
}
if code, _ := c.do("POST", "/api/setup", `{"name":"alex","password":"longenough"}`, true); code != 200 {
t.Fatalf("setup: %d", code)
}
if code, _ := c.do("POST", "/api/setup", `{"name":"eve","password":"longenough"}`, true); code != 409 {
t.Fatalf("second setup must be refused, got %d", code)
}
if code, m := c.do("GET", "/api/overview", "", false); code != 200 || m["host"] == nil {
t.Fatalf("overview after setup: %d %v", code, m)
}
c.do("POST", "/api/logout", "", true)
if code, _ := c.do("GET", "/api/overview", "", false); code != 401 {
t.Fatal("logout should end the session")
}
if code, _ := c.do("POST", "/api/login", `{"name":"alex","password":"wrongwrong"}`, true); code != 401 {
t.Fatalf("bad password: %d", code)
}
if code, _ := c.do("POST", "/api/login", `{"name":"alex","password":"longenough"}`, true); code != 200 {
t.Fatalf("login: %d", code)
}
}
func TestLoginRateLimit(t *testing.T) {
c, _ := newClient(t)
c.do("POST", "/api/setup", `{"name":"alex","password":"longenough"}`, true)
c.do("POST", "/api/logout", "", true)
var last int
for i := 0; i < 9; i++ {
last, _ = c.do("POST", "/api/login", `{"name":"alex","password":"nope-nope"}`, true)
}
if last != 429 {
t.Fatalf("expected lockout after repeated failures, got %d", last)
}
if code, _ := c.do("POST", "/api/login", `{"name":"alex","password":"longenough"}`, true); code != 429 {
t.Fatalf("lockout should hold even for the right password, got %d", code)
}
}
func TestSettingsKeepSecrets(t *testing.T) {
c, st := newClient(t)
c.do("POST", "/api/setup", `{"name":"alex","password":"longenough"}`, true)
body := `{"dockerHost":"","truenas":{"url":"https://nas","apiKey":"k1"},"hammerhead":{"url":"https://hh","org":"o","apiKey":"k2"},"refreshSeconds":5}`
code, m := c.do("PUT", "/api/settings", body, true)
if code != 200 || m["truenasKeySet"] != true || m["hammerheadKeySet"] != true {
t.Fatalf("%d %v", code, m)
}
if tn := m["truenas"].(map[string]any); tn["apiKey"] != "" {
t.Fatal("API key must never be sent back to the browser")
}
// saving again with blank key boxes keeps the stored keys
c.do("PUT", "/api/settings", `{"dockerHost":"","truenas":{"url":"https://nas","apiKey":""},"hammerhead":{"url":"https://hh","org":"o","apiKey":""},"refreshSeconds":5}`, true)
if s := st.Settings(); s.TrueNAS.APIKey != "k1" || s.Hammerhead.APIKey != "k2" {
t.Fatalf("keys lost: %+v", s)
}
if code, _ := c.do("PUT", "/api/settings", `{"truenas":{"url":"ftp://x"}}`, true); code != 400 {
t.Fatalf("bad URL scheme accepted: %d", code)
}
}
func TestOverviewSectionsFollowSettings(t *testing.T) {
c, _ := newClient(t)
c.do("POST", "/api/setup", `{"name":"alex","password":"longenough"}`, true)
c.do("PUT", "/api/settings", `{"dockerHost":"","refreshSeconds":10}`, true)
_, m := c.do("GET", "/api/overview", "", false)
if m["docker"] != nil || m["storage"] != nil || m["tunnels"] != nil {
t.Fatalf("unconfigured sections must be absent: %v", m)
}
c.do("PUT", "/api/settings", `{"dockerHost":"127.0.0.1:1","refreshSeconds":10}`, true)
_, m = c.do("GET", "/api/overview", "", false)
d, _ := m["docker"].(map[string]any)
if d == nil || d["error"] == nil {
t.Fatalf("unreachable Docker should show an error, not break the page: %v", m)
}
if code, _ := c.do("POST", "/api/containers/..%2Fx/start", "", true); code != 400 && code != 404 {
t.Fatalf("path tricks in container id: %d", code)
}
}
+85
View File
@@ -0,0 +1,85 @@
package beach
import (
"context"
"encoding/json"
"os/exec"
"sort"
"time"
)
// parseSmartctl reads `smartctl -j -H -i -A` output for one device.
func parseSmartctl(b []byte) (Disk, error) {
var r struct {
Device struct {
Name string `json:"name"`
} `json:"device"`
ModelName string `json:"model_name"`
SerialNumber string `json:"serial_number"`
UserCapacity struct {
Bytes int64 `json:"bytes"`
} `json:"user_capacity"`
SmartStatus struct {
Passed *bool `json:"passed"`
} `json:"smart_status"`
Temperature struct {
Current float64 `json:"current"`
} `json:"temperature"`
}
if err := json.Unmarshal(b, &r); err != nil {
return Disk{}, err
}
d := Disk{Name: r.Device.Name, Model: r.ModelName, Serial: r.SerialNumber, Size: r.UserCapacity.Bytes, TempC: r.Temperature.Current, Source: "smartctl"}
if r.SmartStatus.Passed != nil {
d.Health = "FAILED"
if *r.SmartStatus.Passed {
d.Health = "PASSED"
}
}
return d, nil
}
// runner lets tests replace the smartctl binary.
type runner func(ctx context.Context, args ...string) ([]byte, error)
func execSmartctl(ctx context.Context, args ...string) ([]byte, error) {
// smartctl uses non-zero exit bits to report problems; the JSON is still valid
out, err := exec.CommandContext(ctx, "smartctl", args...).Output()
if len(out) > 0 {
return out, nil
}
return out, err
}
// LocalSmart scans for devices and reads each one.
func LocalSmart(ctx context.Context, run runner) ([]Disk, error) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
b, err := run(ctx, "--scan", "-j")
if err != nil {
return nil, err
}
var scan struct {
Devices []struct {
Name string `json:"name"`
} `json:"devices"`
}
if err := json.Unmarshal(b, &scan); err != nil {
return nil, err
}
out := []Disk{}
for _, dev := range scan.Devices {
b, err := run(ctx, "-j", "-H", "-i", "-A", dev.Name)
if err != nil {
continue
}
if d, err := parseSmartctl(b); err == nil {
if d.Name == "" {
d.Name = dev.Name
}
out = append(out, d)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
+124
View File
@@ -0,0 +1,124 @@
package beach
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"time"
_ "modernc.org/sqlite"
)
// Store is Beach's one SQLite file (pure Go, WAL mode). Settings are a single
// JSON document, so everything is edited in the web UI and there is no config file.
type Store struct{ db *sql.DB }
const schema = `
CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK (id = 1), body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY, hash TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY, user TEXT NOT NULL, expires INTEGER NOT NULL);
`
// Settings is everything configurable. Empty sections are simply not collected.
type Settings struct {
DockerHost string `json:"dockerHost"` // unix socket path, or tcp://host:2375; empty disables Docker
TrueNAS struct {
URL string `json:"url"`
APIKey string `json:"apiKey"`
Insecure bool `json:"insecure"` // accept a self-signed certificate
} `json:"truenas"`
Smartctl bool `json:"smartctl"` // also read SMART from local smartctl
Hammerhead struct {
URL string `json:"url"`
APIKey string `json:"apiKey"`
Org string `json:"org"`
} `json:"hammerhead"`
RefreshSeconds int `json:"refreshSeconds"`
}
func OpenStore(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if _, err := db.Exec(schema); err != nil {
return nil, err
}
return &Store{db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) Settings() Settings {
var set Settings
var body string
if s.db.QueryRow(`SELECT body FROM settings WHERE id=1`).Scan(&body) == nil {
_ = json.Unmarshal([]byte(body), &set)
} else {
set.DockerHost = defaultDockerHost
}
if set.RefreshSeconds < 2 {
set.RefreshSeconds = 10
}
return set
}
func (s *Store) SaveSettings(set Settings) error {
b, _ := json.Marshal(set)
_, err := s.db.Exec(`INSERT INTO settings (id, body) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET body=excluded.body`, string(b))
return err
}
func (s *Store) HasUsers() bool {
var n int
s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n > 0
}
func (s *Store) AddUser(name, hash string) error {
_, err := s.db.Exec(`INSERT INTO users (name, hash) VALUES (?, ?)`, name, hash)
return err
}
func (s *Store) UserHash(name string) (string, bool) {
var h string
err := s.db.QueryRow(`SELECT hash FROM users WHERE name=?`, name).Scan(&h)
return h, err == nil
}
func hashToken(t string) string {
h := sha256.Sum256([]byte(t))
return hex.EncodeToString(h[:])
}
// NewSession returns a random token; only its hash is stored.
func (s *Store) NewSession(user string, ttl time.Duration) (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
tok := hex.EncodeToString(b)
_, err := s.db.Exec(`INSERT INTO sessions (hash, user, expires) VALUES (?, ?, ?)`, hashToken(tok), user, time.Now().Add(ttl).Unix())
return tok, err
}
func (s *Store) SessionUser(tok string) (string, error) {
var u string
var exp int64
if err := s.db.QueryRow(`SELECT user, expires FROM sessions WHERE hash=?`, hashToken(tok)).Scan(&u, &exp); err != nil {
return "", err
}
if time.Now().Unix() > exp {
s.DeleteSession(tok)
return "", errors.New("expired")
}
return u, nil
}
func (s *Store) DeleteSession(tok string) {
s.db.Exec(`DELETE FROM sessions WHERE hash=?`, hashToken(tok))
}
+187
View File
@@ -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
}
+229
View File
@@ -0,0 +1,229 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Beach</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='14' fill='%23e9b44c'/%3E%3C/svg%3E">
<style>
:root{--bg:#f6f4ef;--card:#fff;--fg:#1d2327;--mut:#6b7378;--line:#e3dfd5;--ok:#1f8a4c;--warn:#b7791f;--bad:#c53030;--acc:#0e6e8c}
@media (prefers-color-scheme:dark){:root{--bg:#14181b;--card:#1d2226;--fg:#e8eaeb;--mut:#8d979c;--line:#2d3439;--ok:#4ac17c;--warn:#e0a84a;--bad:#f06a6a;--acc:#4fb3d1}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.45 system-ui,sans-serif}
header{display:flex;align-items:center;gap:12px;padding:12px 20px;border-bottom:1px solid var(--line);background:var(--card);position:sticky;top:0;z-index:2}
header h1{font-size:18px;margin:0;flex:1}
main{max-width:1200px;margin:0 auto;padding:16px 20px 60px;display:grid;gap:16px}
section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow-x:auto}
section h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 10px;display:flex;gap:8px;align-items:center}
table{border-collapse:collapse;width:100%}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);white-space:nowrap}
th{color:var(--mut);font-weight:500;font-size:12px}
tr:last-child td{border-bottom:0}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px;background:var(--mut)}
.ok{background:var(--ok)}.warn{background:var(--warn)}.bad{background:var(--bad)}
.t-ok{color:var(--ok)}.t-warn{color:var(--warn)}.t-bad{color:var(--bad)}.mut{color:var(--mut)}
button{font:inherit;border:1px solid var(--line);background:var(--card);color:var(--fg);border-radius:6px;padding:3px 10px;cursor:pointer}
button:hover{border-color:var(--acc)}button.primary{background:var(--acc);color:#fff;border-color:var(--acc)}
input[type=text],input[type=password],input[type=url],input[type=number]{font:inherit;width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}
.stat .v{font-size:22px;font-weight:600}.stat .l{color:var(--mut);font-size:12px}
.bar{height:6px;background:var(--line);border-radius:3px;overflow:hidden;margin-top:4px}.bar i{display:block;height:100%;background:var(--acc)}
.bar i.warn{background:var(--warn)}.bar i.bad{background:var(--bad)}
.err{color:var(--bad);font-size:13px;margin:4px 0}
.cols{display:grid;grid-template-columns:1fr 1fr;gap:16px}@media(max-width:800px){.cols{grid-template-columns:1fr}}
dialog{border:1px solid var(--line);border-radius:10px;background:var(--card);color:var(--fg);width:min(900px,94vw);padding:16px}
dialog::backdrop{background:#0008}
pre{background:var(--bg);padding:10px;border-radius:6px;max-height:60vh;overflow:auto;font-size:12px;white-space:pre-wrap;word-break:break-all}
label{display:block;margin:10px 0 3px;color:var(--mut);font-size:12px}
.login{max-width:340px;margin:12vh auto;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:20px}
.hide{display:none!important}
</style>
</head>
<body>
<div id="auth" class="login hide">
<h1 id="authTitle" style="margin-top:0">Beach</h1>
<p class="mut" id="authHint"></p>
<label>Name</label><input type="text" id="aName" autocomplete="username">
<label>Password</label><input type="password" id="aPass" autocomplete="current-password">
<p class="err" id="aErr"></p>
<button class="primary" id="aGo"></button>
</div>
<div id="app" class="hide">
<header>
<h1>🏖 Beach <span class="mut" id="hostName" style="font-weight:400;font-size:13px"></span></h1>
<span class="mut" id="updated"></span>
<button id="btnSettings">Settings</button>
<button id="btnLogout">Sign out</button>
</header>
<main>
<section id="hostSec"><h2>Host</h2><div class="grid" id="hostStats"></div></section>
<section id="dockerSec"><h2>Containers <span id="dockerCount" class="mut"></span></h2><div id="dockerBody"></div></section>
<div class="cols">
<section id="poolSec"><h2>Pools</h2><div id="poolBody"></div></section>
<section id="tunnelSec"><h2>Tunnels &amp; sites</h2><div id="tunnelBody"></div></section>
</div>
<section id="diskSec"><h2>Disks &amp; SMART</h2><div id="diskBody"></div></section>
</main>
</div>
<dialog id="logDlg"><h2 id="logTitle" style="margin-top:0"></h2><pre id="logBody"></pre><button id="logClose">Close</button> <button id="logReload">Reload</button></dialog>
<dialog id="setDlg">
<h2 style="margin-top:0">Settings</h2>
<label>Docker (socket path, or tcp://host:2375 — empty turns Docker off)</label><input type="text" id="sDocker">
<div class="cols">
<div>
<h3>TrueNAS</h3>
<label>URL</label><input type="url" id="sTnUrl" placeholder="https://truenas.local">
<label>API key <span id="sTnSet" class="mut"></span></label><input type="password" id="sTnKey" autocomplete="off" placeholder="leave blank to keep">
<label><input type="checkbox" id="sTnInsecure"> Accept a self-signed certificate</label>
<label><input type="checkbox" id="sSmart"> Also read SMART from local <code>smartctl</code></label>
</div>
<div>
<h3>Hammerhead</h3>
<label>URL</label><input type="url" id="sHhUrl" placeholder="https://hammerhead.example.com">
<label>Organization ID</label><input type="text" id="sHhOrg">
<label>API key <span id="sHhSet" class="mut"></span></label><input type="password" id="sHhKey" autocomplete="off" placeholder="leave blank to keep">
</div>
</div>
<label>Refresh every (seconds)</label><input type="number" id="sRefresh" min="2" max="3600" style="width:100px">
<p class="err" id="sErr"></p>
<button class="primary" id="sSave">Save</button> <button id="sCancel">Cancel</button>
</dialog>
<script>
const $ = id => document.getElementById(id);
let timer = null, logId = null;
async function api(method, path, body) {
const r = await fetch(path, {method, headers: {'X-Beach': '1', 'Content-Type': 'application/json'}, body: body ? JSON.stringify(body) : undefined});
const j = await r.json().catch(() => ({}));
if (r.status === 401 && path !== '/api/login') { showAuth(false); throw new Error(j.error || 'signed out'); }
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
}
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function bytes(n) { if (!n) return '0 B'; const u = ['B','KB','MB','GB','TB','PB']; let i = Math.min(5, Math.floor(Math.log(n) / Math.log(1024))); return (n / 1024 ** i).toFixed(i > 1 ? 1 : 0) + ' ' + u[i]; }
function dur(s) { s = Math.floor(s); const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; }
const dot = c => `<span class="dot ${c}"></span>`;
function bar(p) { p = Math.max(0, Math.min(100, p)); return `<div class="bar"><i class="${p > 90 ? 'bad' : p > 75 ? 'warn' : ''}" style="width:${p}%"></i></div>`; }
function stat(l, v, p) { return `<div class="stat"><div class="l">${esc(l)}</div><div class="v">${v}</div>${p == null ? '' : bar(p)}</div>`; }
function showAuth(setup) {
$('app').classList.add('hide'); $('auth').classList.remove('hide');
clearTimeout(timer);
$('authTitle').textContent = setup ? 'Welcome to Beach' : 'Sign in';
$('authHint').textContent = setup ? 'Create the administrator account.' : '';
$('aGo').textContent = setup ? 'Create account' : 'Sign in';
$('aGo').onclick = async () => {
$('aErr').textContent = '';
try { await api('POST', setup ? '/api/setup' : '/api/login', {name: $('aName').value, password: $('aPass').value}); $('aPass').value = ''; start(); }
catch (e) { $('aErr').textContent = e.message; }
};
}
async function start() {
const s = await api('GET', '/api/session');
if (!s.user) return showAuth(s.setup);
$('auth').classList.add('hide'); $('app').classList.remove('hide');
refresh();
}
async function refresh() {
clearTimeout(timer);
let o;
try { o = await api('GET', '/api/overview'); } catch (e) { $('updated').textContent = e.message; timer = setTimeout(refresh, 15000); return; }
render(o);
$('updated').textContent = 'Updated ' + new Date(o.time).toLocaleTimeString();
timer = setTimeout(refresh, o.refreshSeconds * 1000);
}
function render(o) {
const h = o.host;
$('hostName').textContent = h.hostname || '';
if (!h.supported) $('hostStats').innerHTML = '<span class="mut">Host metrics are only collected on Linux.</span>';
else {
const memP = h.memTotal ? h.memUsed / h.memTotal * 100 : 0;
let s = stat('CPU', h.cpuPct.toFixed(0) + '%', h.cpuPct) + stat('Memory', `${bytes(h.memUsed)} / ${bytes(h.memTotal)}`, memP)
+ stat('Load', h.load.map(x => x.toFixed(2)).join(' · '), Math.min(100, h.load[0] / h.cpus * 100)) + stat('Uptime', dur(h.uptimeSeconds));
if (h.swapTotal) s += stat('Swap', `${bytes(h.swapUsed)} / ${bytes(h.swapTotal)}`, h.swapUsed / h.swapTotal * 100);
for (const f of h.filesystems) s += stat(f.mount, `${bytes(f.used)} / ${bytes(f.total)}`, f.used / f.total * 100);
$('hostStats').innerHTML = s;
}
const d = o.docker;
$('dockerSec').classList.toggle('hide', !d);
if (d) {
const run = d.containers.filter(c => c.state === 'running').length;
$('dockerCount').textContent = d.error ? '' : `${run} running / ${d.containers.length}`;
$('dockerBody').innerHTML = (d.error ? `<div class="err">${esc(d.error)}</div>` : '') + (d.containers.length ? `<table><tr><th>Name</th><th>Image</th><th>Status</th><th>Ports</th><th></th></tr>${d.containers.map(c => {
const up = c.state === 'running', bad = /unhealthy/.test(c.status) || c.state === 'dead';
return `<tr><td>${dot(bad ? 'bad' : up ? 'ok' : '')}${esc(c.name)}</td><td class="mut">${esc(c.image)}</td><td>${esc(c.status)}</td><td class="mut">${esc(c.ports.join(', '))}</td>
<td>${up ? `<button data-a="stop" data-id="${esc(c.id)}">Stop</button> <button data-a="restart" data-id="${esc(c.id)}">Restart</button>` : `<button data-a="start" data-id="${esc(c.id)}">Start</button>`} <button data-a="logs" data-id="${esc(c.id)}" data-n="${esc(c.name)}">Logs</button></td></tr>`;
}).join('')}</table>` : d.error ? '' : '<span class="mut">No containers.</span>');
}
const s = o.storage;
$('poolSec').classList.toggle('hide', !s); $('diskSec').classList.toggle('hide', !s);
if (s) {
$('poolBody').innerHTML = (s.error ? `<div class="err">${esc(s.error)}</div>` : '')
+ s.pools.map(p => `<div style="margin-bottom:10px">${dot(p.healthy ? 'ok' : p.status === 'ONLINE' ? 'warn' : 'bad')}<b>${esc(p.name)}</b> <span class="${p.healthy ? 't-ok' : 't-bad'}">${esc(p.status)}</span> <span class="mut">${bytes(p.allocated)} / ${bytes(p.size)}</span>${bar(p.usedPct)}<div class="mut">${esc(p.scan || 'never scrubbed')}</div></div>`).join('')
+ s.alerts.map(a => `<div class="t-warn">⚠ ${esc(a.level)}: ${esc(a.message)}</div>`).join('');
$('diskBody').innerHTML = s.disks.length ? `<table><tr><th>Disk</th><th>Model</th><th>Serial</th><th>Size</th><th>Pool</th><th>Temp</th><th>SMART</th></tr>${s.disks.map(k => {
const hc = k.health === 'PASSED' ? 'ok' : k.health ? 'bad' : '', tc = k.tempC >= 55 ? 'bad' : k.tempC >= 45 ? 'warn' : '';
return `<tr><td>${esc(k.name)}</td><td>${esc(k.model)}</td><td class="mut">${esc(k.serial)}</td><td>${bytes(k.size)}</td><td>${esc(k.pool || '')}</td><td class="${tc && 't-' + tc}">${k.tempC ? k.tempC + ' °C' : '–'}</td><td>${dot(hc)}${esc(k.health || 'unknown')}</td></tr>`;
}).join('')}</table>` : '<span class="mut">No disks reported.</span>';
}
const t = o.tunnels;
$('tunnelSec').classList.toggle('hide', !t);
if (t) {
$('tunnelBody').innerHTML = (t.error ? `<div class="err">${esc(t.error)}</div>` : '')
+ `<table>${t.sites.map(x => `<tr><td>${dot(x.online ? 'ok' : 'bad')}${esc(x.name)}${x.local ? ' <span class="mut">(edge-local)</span>' : ''}</td><td class="mut">${esc(x.version || '')}</td><td>${x.online ? 'online' : 'offline'}</td></tr>`).join('')}
${t.ports.map(p => `<tr><td>${dot(!p.enabled ? '' : p.up ? 'ok' : 'bad')}${esc(p.name)}</td><td class="mut">${esc(p.hostname || p.proto + ':' + p.port)}</td><td>${!p.enabled ? 'disabled' : p.up ? `up${p.targets ? ` · ${p.healthy}/${p.targets} healthy` : ''}` : 'down'}</td></tr>`).join('')}</table>`;
}
}
$('dockerBody').addEventListener('click', async e => {
const b = e.target.closest('button'); if (!b) return;
const {a, id} = b.dataset;
if (a === 'logs') return openLogs(id, b.dataset.n);
b.disabled = true;
try { await api('POST', `/api/containers/${encodeURIComponent(id)}/${a}`); } catch (err) { alert(err.message); }
refresh();
});
async function openLogs(id, name) {
logId = id; $('logTitle').textContent = name + ' — last 200 lines';
if (!$('logDlg').open) $('logDlg').showModal();
$('logBody').textContent = 'Loading…';
try { $('logBody').textContent = (await api('GET', `/api/containers/${encodeURIComponent(id)}/logs?tail=200`)).logs || '(empty)'; } catch (e) { $('logBody').textContent = e.message; }
$('logBody').scrollTop = 1e9;
}
$('logClose').onclick = () => $('logDlg').close();
$('logReload').onclick = () => openLogs(logId, $('logTitle').textContent.split(' — ')[0]);
$('btnLogout').onclick = async () => { await api('POST', '/api/logout').catch(() => {}); start(); };
$('btnSettings').onclick = async () => {
const s = await api('GET', '/api/settings');
$('sDocker').value = s.dockerHost; $('sTnUrl').value = s.truenas.url; $('sTnInsecure').checked = s.truenas.insecure; $('sSmart').checked = s.smartctl;
$('sHhUrl').value = s.hammerhead.url; $('sHhOrg').value = s.hammerhead.org; $('sRefresh').value = s.refreshSeconds;
$('sTnKey').value = $('sHhKey').value = ''; $('sTnSet').textContent = s.truenasKeySet ? '(saved)' : ''; $('sHhSet').textContent = s.hammerheadKeySet ? '(saved)' : '';
$('sErr').textContent = ''; $('setDlg').showModal();
};
$('sCancel').onclick = () => $('setDlg').close();
$('sSave').onclick = async () => {
try {
await api('PUT', '/api/settings', {dockerHost: $('sDocker').value.trim(), smartctl: $('sSmart').checked, refreshSeconds: +$('sRefresh').value,
truenas: {url: $('sTnUrl').value.trim(), apiKey: $('sTnKey').value, insecure: $('sTnInsecure').checked},
hammerhead: {url: $('sHhUrl').value.trim(), org: $('sHhOrg').value.trim(), apiKey: $('sHhKey').value}});
$('setDlg').close(); refresh();
} catch (e) { $('sErr').textContent = e.message; }
};
['aName', 'aPass'].forEach(i => $(i).addEventListener('keydown', e => { if (e.key === 'Enter') $('aGo').click(); }));
start();
</script>
</body>
</html>