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,4 @@
|
|||||||
|
bin/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Alex
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Beach
|
||||||
|
|
||||||
|
A one-page control panel for a homelab. One binary, one SQLite file, and everything is done in the web UI.
|
||||||
|
|
||||||
|
| Section | Source |
|
||||||
|
|---|---|
|
||||||
|
| **Containers** | Docker Engine API: state, ports, health, start / stop / restart, last 200 log lines |
|
||||||
|
| **Pools, disks, SMART** | TrueNAS REST API (`/api/v2.0`: pools, scrub state, disks, temperatures, alerts), and/or local `smartctl` |
|
||||||
|
| **Host** | CPU, memory, swap, load, uptime and mounted filesystems from `/proc` (Linux) |
|
||||||
|
| **Tunnels & sites** | [Hammerhead](../hammerhead)'s `GET /api/orgs/{org}/state`: site agents online/offline, port mappings up/down, target health |
|
||||||
|
|
||||||
|
A section appears only when it is configured, and one failing source never takes down the page: it shows its own error.
|
||||||
|
|
||||||
|
It is built in the same style as Hammerhead, Blacktip, Bamboo and Sandbar, but has no dependency on Reef.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```
|
||||||
|
go build -o bin/ ./cmd/beach
|
||||||
|
bin/beach -listen :8095 -data beach.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8095`, create the administrator account, then open **Settings**:
|
||||||
|
|
||||||
|
- **Docker**: the socket path (default `/var/run/docker.sock`) or `tcp://host:2375`. Beach must be able to read the socket.
|
||||||
|
- **TrueNAS**: the URL and an API key (Credentials → API Keys). Tick "self-signed certificate" if needed.
|
||||||
|
- **smartctl**: reads SMART from the machine Beach runs on (needs `smartmontools` and permission to open the devices). Results are merged with TrueNAS by serial number.
|
||||||
|
- **Hammerhead**: the URL, your organization ID and an API key for that organization. Read access is enough.
|
||||||
|
|
||||||
|
There are two flags only: `-listen` and `-data`. There is no config file or environment variable.
|
||||||
|
|
||||||
|
## Running in Docker
|
||||||
|
|
||||||
|
Mount the Docker socket (this gives the container control of Docker, which is what the start/stop buttons need) and, for host metrics, run it with the host's `/proc` visible:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run -d --name beach -p 127.0.0.1:8095:8095 -v beach:/data \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
beach -data /data/beach.db
|
||||||
|
```
|
||||||
|
|
||||||
|
(There is no published image yet: build one from `go build` output in a `scratch`/`distroless` image.)
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Sign-in is a name and password (bcrypt), with a 30-day session cookie (`HttpOnly`, `SameSite=Strict`, `Secure` behind HTTPS) and a lockout after 8 failed attempts per address in 5 minutes. Every write needs an `X-Beach` header.
|
||||||
|
- Anyone who can sign in can start, stop and restart every container and read its logs. Put Beach behind Hammerhead/Blacktip or bind it to `127.0.0.1`; do not expose it plainly.
|
||||||
|
- API keys for TrueNAS and Hammerhead are stored in the SQLite file as plain text (they are never sent back to the browser). Protect the file like the keys themselves.
|
||||||
|
- Beach only reads from TrueNAS and Hammerhead.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
The collectors parse recorded API shapes and `/proc` text, so the tests run anywhere. Host collection itself is Linux only (`host_linux.go`); other platforms report just the CPU count.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT, see [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Beach is a one-page control panel for a homelab: containers, disks, host load and tunnels.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"beach/internal/beach"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listen := flag.String("listen", ":8095", "address to listen on")
|
||||||
|
data := flag.String("data", "beach.db", "path of the SQLite file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
st, err := beach.OpenStore(*data)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
srv := &http.Server{Addr: *listen, Handler: beach.NewServer(st), ReadHeaderTimeout: 10 * time.Second}
|
||||||
|
log.Printf("Beach listening on %s (data: %s)", *listen, *data)
|
||||||
|
log.Fatal(srv.ListenAndServe())
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
module beach
|
||||||
|
|
||||||
|
go 1.27.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/crypto v0.58.0
|
||||||
|
modernc.org/sqlite v1.61.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.49.0 // indirect
|
||||||
|
modernc.org/libc v1.79.0 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.12.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||||
|
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||||
|
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
|
||||||
|
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||||
|
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
|
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||||
|
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||||
|
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||||
|
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
|
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||||
|
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||||
|
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
|
||||||
|
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
|
||||||
|
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
|
||||||
|
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
|
||||||
|
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
|
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
|
||||||
|
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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×tamps=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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 & sites</h2><div id="tunnelBody"></div></section>
|
||||||
|
</div>
|
||||||
|
<section id="diskSec"><h2>Disks & 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||||
Reference in new issue
Block a user