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:
2026-10-29 13:34:21 +01:00
co-authored by Claude Sonnet 5.5
parent e684ddb06c
commit 2a111d012b
13 changed files with 1835 additions and 37 deletions
+465
View File
@@ -0,0 +1,465 @@
package main
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"math"
"net/http"
"net/netip"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
// Prefs are the dashboard's own settings. They live beside the UI's state,
// not in settings.json: changing them needs no rebuild.
type Prefs struct {
PingTargets []string `json:"pingTargets"`
}
var defaultPrefs = Prefs{PingTargets: []string{"gateway", "1.1.1.1", "9.9.9.9"}}
func (s System) prefsPath() string { return filepath.Join(s.StateDir, "webui", "dashboard.json") }
func (s System) loadPrefs() Prefs {
data, err := os.ReadFile(s.prefsPath())
if err != nil {
return defaultPrefs
}
var p Prefs
if json.Unmarshal(data, &p) != nil || p.PingTargets == nil {
return defaultPrefs
}
return p
}
func (s System) savePrefs(p Prefs) error {
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.prefsPath()), 0o700); err != nil {
return err
}
return writeFileAtomic(s.prefsPath(), append(data, '\n'), 0o600)
}
const maxPingTargets = 5
// parsePingTargets takes targets separated by commas, spaces or lines:
// "gateway" or IP addresses.
func parsePingTargets(text string) ([]string, error) {
targets := []string{}
for _, t := range strings.FieldsFunc(text, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\r' || r == '\t' }) {
if t != "gateway" {
a, err := netip.ParseAddr(t)
if err != nil {
return nil, fmt.Errorf("%q is neither \"gateway\" nor an IP address", t)
}
t = a.String()
}
if !slices.Contains(targets, t) {
targets = append(targets, t)
}
}
if len(targets) > maxPingTargets {
return nil, fmt.Errorf("at most %d targets", maxPingTargets)
}
return targets, nil
}
// Chart is an SVG line chart, laid out here so the template only draws it.
type Chart struct {
Title string
Unit string // "bit/s", "ms", "%", "°C"
Lines []ChartLine
YTicks []ChartTick
XTicks []ChartTick
Cols []ChartCol // hover areas with a tooltip each
Empty bool
}
type ChartLine struct {
Label string
Class string // colour, see style.css
Path string // SVG path data; gaps start a new subpath
Now string // the latest value, formatted
}
type ChartTick struct {
Pos float64
Label string
}
type ChartCol struct {
X, W float64
Tip string
}
// The chart's drawing area in SVG units (the template adds the margins).
const (
chartW = 600.0
chartH = 120.0
chartCols = 200
)
type seriesSpec struct {
Name string // in the Store
Label string
Scale float64 // e.g. 8 for bytes/s -> bit/s
}
func (a *app) chart(title, unit string, tier int, now time.Time, specs []seriesSpec) Chart {
c := Chart{Title: title, Unit: unit}
var start time.Time
var step time.Duration
cols := make([][]float64, len(specs))
ymax := 0.0
for i, sp := range specs {
st, stp, vals := a.metrics.store.Read(sp.Name, tier, now)
start, step = st, stp
cols[i] = downsample(vals, chartCols, sp.Scale)
for _, v := range cols[i] {
if !math.IsNaN(v) {
ymax = math.Max(ymax, v)
}
}
}
n := chartCols
span := step * time.Duration(tiers[tier].Slots)
colDur := span / time.Duration(n)
if unit == "%" {
ymax = math.Max(ymax, 1)
if ymax > 50 {
ymax = 100
}
}
ymax = niceCeil(ymax)
c.Empty = true
for i, sp := range specs {
line := ChartLine{Label: sp.Label, Class: fmt.Sprintf("s%d", i), Now: "–"}
var path strings.Builder
pen := false
for j, v := range cols[i] {
if math.IsNaN(v) {
pen = false
continue
}
c.Empty = false
x := (float64(j) + 0.5) * chartW / float64(n)
y := chartH - v/ymax*chartH
if pen {
fmt.Fprintf(&path, "L%.1f %.1f", x, y)
} else {
fmt.Fprintf(&path, "M%.1f %.1f", x, y)
// A lone point still shows up as a short dash.
if j+1 >= len(cols[i]) || math.IsNaN(cols[i][j+1]) {
fmt.Fprintf(&path, "h1")
}
}
pen = true
line.Now = formatValue(v, unit)
}
line.Path = path.String()
c.Lines = append(c.Lines, line)
}
for k := 0; k <= 2; k++ {
v := ymax * float64(k) / 2
c.YTicks = append(c.YTicks, ChartTick{Pos: chartH - v/ymax*chartH, Label: formatValue(v, unit)})
}
layout := "15:04"
if span > 48*time.Hour {
layout = "Jan 2"
}
for k := 0; k <= 4; k++ {
t := start.Add(span * time.Duration(k) / 4)
c.XTicks = append(c.XTicks, ChartTick{Pos: chartW * float64(k) / 4, Label: t.Local().Format(layout)})
}
for j := range n {
t := start.Add(colDur * time.Duration(j))
var tip strings.Builder
tip.WriteString(t.Local().Format("Jan 2 15:04:05"))
for i, sp := range specs {
fmt.Fprintf(&tip, "\n%s: %s", sp.Label, formatValue(cols[i][j], unit))
}
c.Cols = append(c.Cols, ChartCol{X: chartW * float64(j) / float64(n), W: chartW / float64(n), Tip: tip.String()})
}
return c
}
// downsample averages vals into n columns, ignoring gaps.
func downsample(vals []float64, n int, scale float64) []float64 {
if scale == 0 {
scale = 1
}
out := make([]float64, n)
for j := range out {
lo, hi := j*len(vals)/n, (j+1)*len(vals)/n
sum, cnt := 0.0, 0
for _, v := range vals[lo:max(hi, lo+1)] {
if !math.IsNaN(v) {
sum += v
cnt++
}
}
out[j] = math.NaN()
if cnt > 0 {
out[j] = sum / float64(cnt) * scale
}
}
return out
}
// niceCeil rounds up to 1, 2 or 5 times a power of ten.
func niceCeil(v float64) float64 {
if v <= 0 || math.IsNaN(v) {
return 1
}
p := math.Pow(10, math.Floor(math.Log10(v)))
for _, m := range []float64{1, 2, 5, 10} {
if v <= m*p {
return m * p
}
}
return 10 * p
}
func formatValue(v float64, unit string) string {
if math.IsNaN(v) {
return "–"
}
switch unit {
case "bit/s":
return formatSI(v, "bit/s")
case "ms":
if v < 10 {
return fmt.Sprintf("%.1f ms", v)
}
return fmt.Sprintf("%.0f ms", v)
case "%":
if v > 0 && v < 10 {
return fmt.Sprintf("%.1f %%", v)
}
return fmt.Sprintf("%.0f %%", v)
case "°C":
return fmt.Sprintf("%.0f °C", v)
}
return fmt.Sprintf("%g %s", v, unit)
}
func formatSI(v float64, unit string) string {
for _, p := range []struct {
f float64
s string
}{{1e9, "G"}, {1e6, "M"}, {1e3, "k"}} {
if v >= p.f {
x := v / p.f
if x < 10 {
return fmt.Sprintf("%.1f %s%s", x, p.s, unit)
}
return fmt.Sprintf("%.0f %s%s", x, p.s, unit)
}
}
return fmt.Sprintf("%.0f %s", v, unit)
}
func formatBytes(v uint64) string {
f := float64(v)
for _, p := range []struct {
f float64
s string
}{{1 << 40, "TiB"}, {1 << 30, "GiB"}, {1 << 20, "MiB"}, {1 << 10, "KiB"}} {
if f >= p.f {
return fmt.Sprintf("%.1f %s", f/p.f, p.s)
}
}
return fmt.Sprintf("%d B", v)
}
// formatAge is a rough "3d 4h", "2h 5m", "4m".
func formatAge(d time.Duration) string {
if d < time.Minute {
return "<1m"
}
d = d.Round(time.Minute)
days, hours, mins := int(d.Hours())/24, int(d.Hours())%24, int(d.Minutes())%60
switch {
case days > 0:
return fmt.Sprintf("%dd %dh", days, hours)
case hours > 0:
return fmt.Sprintf("%dh %dm", hours, mins)
default:
return fmt.Sprintf("%dm", mins)
}
}
type dashboard struct {
Range string
Ranges []string
Interval int // seconds between live updates
Snap Snapshot
Leases []Lease
Events []Event
Charts []Chart
Prefs Prefs
}
func (a *app) dashboardData(rangeName string) dashboard {
tier, rangeName := rangeTier(rangeName)
now := time.Now()
s, _ := loadSettings(a.sys.settingsPath())
f := s.form()
d := dashboard{Range: rangeName, Interval: 5, Snap: a.metrics.Snapshot(), Prefs: a.sys.loadPrefs()}
// Newest first, the last few.
ev := d.Snap.Uplink.Events
d.Events = make([]Event, 0, 10)
for i := len(ev) - 1; i >= 0 && len(d.Events) < 10; i-- {
d.Events = append(d.Events, ev[i])
}
if tier > 0 {
d.Interval = 60
}
for _, r := range ranges {
d.Ranges = append(d.Ranges, r.Name)
}
d.Leases = readLeases(a.metrics.leasesPath, f.LANs, now)
traffic := func(title, dev string) Chart {
return a.chart(title, "bit/s", tier, now, []seriesSpec{
{"if:" + dev + ":rx", "down", 8},
{"if:" + dev + ":tx", "up", 8},
})
}
up := uplinkDevice(f.WAN)
d.Charts = append(d.Charts, traffic("Internet ("+up+")", up))
for _, l := range f.LANs {
dev := l.Interface
if l.VLAN != "" {
dev += "." + l.VLAN
}
c := traffic("LAN "+l.Name+" ("+dev+")", dev)
// From the LAN's side: what the router sends is what the LAN downloads.
c.Lines[0].Label, c.Lines[1].Label = "to router", "from router"
d.Charts = append(d.Charts, c)
}
for _, wg := range wireguardDevices(s) {
d.Charts = append(d.Charts, traffic("Tunnel "+wg, wg))
}
var rtt, loss []seriesSpec
for _, t := range d.Prefs.PingTargets {
rtt = append(rtt, seriesSpec{"ping:" + t + ":rtt", t, 1})
loss = append(loss, seriesSpec{"ping:" + t + ":loss", t, 1})
}
if len(rtt) > 0 {
d.Charts = append(d.Charts, a.chart("Latency", "ms", tier, now, rtt), a.chart("Packet loss", "%", tier, now, loss))
}
d.Charts = append(d.Charts,
a.chart("CPU and memory", "%", tier, now, []seriesSpec{{"sys:cpu", "CPU", 1}, {"sys:mem", "memory", 1}}))
if temp := a.chart("Temperature", "°C", tier, now, []seriesSpec{{"sys:temp", "hottest sensor", 1}}); !temp.Empty || !math.IsNaN(d.Snap.System.Temperature) {
d.Charts = append(d.Charts, temp)
}
return d
}
func wireguardDevices(s Settings) []string {
router, _ := s["router"].(map[string]any)
wgs, _ := router["wireguard"].(map[string]any)
var devs []string
for name, v := range wgs {
dev := "wg-" + name
if m, ok := v.(map[string]any); ok && str(m, "interface") != "" {
dev = str(m, "interface")
}
devs = append(devs, dev)
}
slices.Sort(devs)
return devs
}
// dashboardFragment is the live part of the overview, fetched by dashboard.js.
func (a *app) dashboardFragment(w http.ResponseWriter, r *http.Request, sess session) {
p := page{CSRF: sess.csrf, LoggedIn: true, Data: overviewData{Dashboard: a.dashboardData(r.URL.Query().Get("range"))}}
if err := a.page("overview").ExecuteTemplate(w, "dashboard", p); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (a *app) savePrefs(w http.ResponseWriter, r *http.Request, sess session) {
targets, err := parsePingTargets(r.PostFormValue("pingTargets"))
if err == nil {
err = a.sys.savePrefs(Prefs{PingTargets: targets})
}
if err != nil {
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape("Ping targets not saved: "+err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape("Ping targets saved."), http.StatusSeeOther)
}
// backup streams the state directory (settings, secrets, certificate, metric
// history) as router-backup-<host>-<time>.tar.gz; router-setup can install
// from it.
func (a *app) backup(w http.ResponseWriter, r *http.Request, _ session) {
a.metrics.Save()
s, _ := loadSettings(a.sys.settingsPath())
host := str(s, "system", "hostName")
if host == "" {
host = "router"
}
name := fmt.Sprintf("router-backup-%s-%s.tar.gz", host, time.Now().Format("20060102-1504"))
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
if err := writeBackup(w, a.sys.StateDir); err != nil {
// Headers are gone; a truncated archive fails to extract.
panic(http.ErrAbortHandler)
}
}
func writeBackup(w io.Writer, dir string) error {
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && !d.Type().IsRegular() {
return nil // sockets, symlinks (e.g. a result link)
}
info, err := d.Info()
if err != nil {
return err
}
rel, _ := filepath.Rel(dir, path)
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
hdr.Name = filepath.ToSlash(filepath.Join("router", rel))
if d.IsDir() {
hdr.Name += "/"
}
hdr.Uname, hdr.Gname, hdr.Uid, hdr.Gid = "", "", 0, 0
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if d.IsDir() {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.CopyN(tw, f, info.Size())
return err
})
return errors.Join(err, tw.Close(), gz.Close())
}