Add a dashboard and backup download to the web UI
Metric history (5s to 1h tiers, persisted), uplink status, ping targets, DHCP leases, and a tar.gz backup of the state directory. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTierAveragesAndGaps(t *testing.T) {
|
||||
s := newStore()
|
||||
base := time.Unix(1_800_000_000, 0) // a multiple of every step
|
||||
for i := range 12 { // one minute of 5 s samples
|
||||
s.Add("x", base.Add(time.Duration(i)*5*time.Second), float64(i))
|
||||
}
|
||||
// A gap of 10 minutes, then one more sample.
|
||||
s.Add("x", base.Add(11*time.Minute), 100)
|
||||
|
||||
now := base.Add(11 * time.Minute)
|
||||
start, step, vals := s.Read("x", 0, now)
|
||||
if step != 5*time.Second || len(vals) != 720 {
|
||||
t.Fatalf("step %v, %d values", step, len(vals))
|
||||
}
|
||||
at := func(ts time.Time) float64 { return vals[int(ts.Sub(start)/step)] }
|
||||
if at(base) != 0 || at(base.Add(55*time.Second)) != 11 {
|
||||
t.Fatalf("5 s values: %v %v", at(base), at(base.Add(55*time.Second)))
|
||||
}
|
||||
if !math.IsNaN(at(base.Add(5 * time.Minute))) {
|
||||
t.Fatal("the gap is not empty")
|
||||
}
|
||||
if at(now) != 100 || !math.IsNaN(at(base.Add(-5*time.Second))) {
|
||||
t.Fatal("latest bucket or before the first sample")
|
||||
}
|
||||
// The minute tier averages the first minute.
|
||||
start, step, vals = s.Read("x", 1, now)
|
||||
if v := vals[int(base.Sub(start)/step)]; v != 5.5 {
|
||||
t.Fatalf("minute average %v", v)
|
||||
}
|
||||
|
||||
// An hour later the 5 s ring has turned over: the old values are gone.
|
||||
later := now.Add(2 * time.Hour)
|
||||
s.Add("x", later, 1)
|
||||
_, _, vals = s.Read("x", 0, later)
|
||||
for i, v := range vals[:len(vals)-1] {
|
||||
if !math.IsNaN(v) {
|
||||
t.Fatalf("stale value %v at %d", v, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSurvivesSaving(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "m.gob")
|
||||
s := newStore()
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
s.Add("x", now, 42)
|
||||
s.SetConnected(true, now, "198.51.100.7")
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, err := loadStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, vals := l.Read("x", 0, now)
|
||||
connected, since, events := l.Uplink()
|
||||
if vals[len(vals)-1] != 42 || !connected || !since.Equal(now) || len(events) != 1 {
|
||||
t.Fatalf("loaded %v %v %v %v", vals[len(vals)-1], connected, since, events)
|
||||
}
|
||||
}
|
||||
|
||||
const leaseFile = `address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
|
||||
192.168.10.101,52:54:00:12:34:56,01:52,3600,%d,1,0,0,laptop,0,,0
|
||||
192.168.10.100,52:54:00:aa:bb:cc,01:52,3600,%d,1,0,0,old,name,0,,0
|
||||
192.168.10.100,52:54:00:aa:bb:cc,01:52,3600,%d,1,0,0,phone,0,,0
|
||||
192.168.10.102,52:54:00:dd:ee:ff,01:52,0,%d,1,0,0,gone,0,,0
|
||||
192.168.10.103,52:54:00:dd:ee:00,01:52,3600,%d,1,0,0,expired,0,,0
|
||||
`
|
||||
|
||||
func TestReadLeases(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
future, past := now.Unix()+1800, now.Unix()-10
|
||||
path := filepath.Join(t.TempDir(), "dhcp4.leases")
|
||||
os.WriteFile(path, []byte(fmt.Sprintf(leaseFile, future, future, future, future, past)), 0o600)
|
||||
lans := []LANForm{{Name: "main", Address: "192.168.10.1"}}
|
||||
got := readLeases(path, lans, now)
|
||||
if len(got) != 2 || got[0].Address != "192.168.10.100" || got[0].Hostname != "phone" ||
|
||||
got[1].Hostname != "laptop" || got[1].LAN != "main" {
|
||||
t.Fatalf("leases: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func csrfFrom(t *testing.T, page string) string {
|
||||
t.Helper()
|
||||
m := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(page)
|
||||
if m == nil {
|
||||
t.Fatalf("no CSRF token in:\n%s", page)
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func TestParsePingTargets(t *testing.T) {
|
||||
got, err := parsePingTargets("gateway, 1.1.1.1\n2606:4700:4700::1111 1.1.1.1")
|
||||
if err != nil || strings.Join(got, " ") != "gateway 1.1.1.1 2606:4700:4700::1111" {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
if _, err := parsePingTargets("example.com"); err == nil {
|
||||
t.Fatal("host names are not accepted")
|
||||
}
|
||||
if _, err := parsePingTargets("1.1.1.1 1.1.1.2 1.1.1.3 1.1.1.4 1.1.1.5 1.1.1.6"); err == nil {
|
||||
t.Fatal("too many targets accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func login(t *testing.T, h http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
rec := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login: %d", rec.Code)
|
||||
}
|
||||
return rec.Result().Cookies()[0]
|
||||
}
|
||||
|
||||
func TestDashboardRenders(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
now := time.Now()
|
||||
for i := range 20 {
|
||||
at := now.Add(time.Duration(i-20) * 5 * time.Second)
|
||||
a.metrics.store.Add("if:eth1:rx", at, 1e6)
|
||||
a.metrics.store.Add("if:eth1:tx", at, 2e5)
|
||||
}
|
||||
h := a.routes()
|
||||
cookie := login(t, h)
|
||||
for _, path := range []string{"/", "/?range=7d", "/dashboard?range=24h", "/advanced"} {
|
||||
rec := do(h, "GET", path, nil, cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: %d\n%s", path, rec.Code, rec.Body)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if path == "/" && (!strings.Contains(body, "Internet (eth1)") || !strings.Contains(body, "8.0 Mbit/s") ||
|
||||
!strings.Contains(body, "/static/dashboard.js")) {
|
||||
t.Fatalf("overview lacks the traffic chart:\n%s", body)
|
||||
}
|
||||
if strings.HasPrefix(path, "/dashboard") && strings.Contains(body, "<html") {
|
||||
t.Fatal("the fragment is a whole page")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackup(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := login(t, h)
|
||||
page := do(h, "GET", "/advanced", nil, cookie).Body.String()
|
||||
token := csrfFrom(t, page)
|
||||
rec := do(h, "POST", "/backup", url.Values{"csrf": {token}}, cookie)
|
||||
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Disposition"), `attachment; filename="router-backup-`) {
|
||||
t.Fatalf("backup: %d %v", rec.Code, rec.Header())
|
||||
}
|
||||
gz, err := gzip.NewReader(rec.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files := map[string]string{}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := io.ReadAll(tr)
|
||||
files[hdr.Name] = string(data)
|
||||
}
|
||||
settings, _ := os.ReadFile(sys.settingsPath())
|
||||
if files["router/config/settings.json"] != string(settings) || !strings.HasPrefix(files["router/webui-password"], "pbkdf2") {
|
||||
t.Fatalf("backup content: %v", keys(files))
|
||||
}
|
||||
if _, ok := files["router/webui/metrics.gob"]; !ok {
|
||||
t.Fatalf("no metric history in the backup: %v", keys(files))
|
||||
}
|
||||
if rec := do(h, "POST", "/backup", url.Values{}, cookie); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("backup without CSRF token: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func keys(m map[string]string) []string {
|
||||
var out []string
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user