Files
nixos-router/pkgs/router-webui/dashboard.go
T
nilsandClaude Sonnet 5.5 84f6002d05 Add WireGuard, DNS and more to the web UI and router-setup
Web UI: a WireGuard page (tunnels, peers, keys made on the router, client
configs with a QR code, import of wg-quick configs, live status), a DNS page
(records, wildcards, forward zones), interface dropdowns with VLAN devices,
IPv6 options and a local domain. router-setup asks about VLANs, IPv6 and
the domain. The router publishes its own name in DNS and hands the local
domain to DHCP clients; wg and jq are on the router. Includes a script
that takes over antigua's tunnels.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 23:53:34 +01:00

468 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
// VLANs defined in the network page ("enp2s0.1001") that may not be used
// yet; the interface dropdowns offer them.
VLANs []string `json:"vlans,omitempty"`
}
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
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 {
prefs := a.sys.loadPrefs()
prefs.PingTargets = targets
err = a.sys.savePrefs(prefs)
}
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())
}