diff --git a/modules/settings/default.nix b/modules/settings/default.nix
index 38248fb..69b7576 100644
--- a/modules/settings/default.nix
+++ b/modules/settings/default.nix
@@ -66,6 +66,8 @@ let
webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { };
in
{
+ imports = [ ./update.nix ];
+
# Outside `router`: the settings file defines that whole tree.
options.routerSettings = {
file = lib.mkOption {
@@ -154,7 +156,11 @@ in
description = "Router web UI";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
- path = [ config.systemd.package ];
+ # iproute2: the dashboard reads the default route and the PPP peer.
+ path = [
+ config.systemd.package
+ pkgs.iproute2
+ ];
serviceConfig = {
ExecStart = "${lib.getExe webui} -listen :${toString cfg.webui.port} -config ${cfg.directory} -state ${cfg.stateDirectory}";
Restart = "on-failure";
diff --git a/pkgs/router-webui/collect.go b/pkgs/router-webui/collect.go
new file mode 100644
index 0000000..a914079
--- /dev/null
+++ b/pkgs/router-webui/collect.go
@@ -0,0 +1,540 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "encoding/binary"
+ "encoding/csv"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "math"
+ "math/rand/v2"
+ "net"
+ "net/netip"
+ "os"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+)
+
+const sampleEvery = 5 * time.Second
+
+// Collector samples the box every few seconds into the Store and keeps the
+// latest snapshot for the dashboard.
+type Collector struct {
+ sys System
+ store *Store
+ storePath string
+ leasesPath string
+
+ mu sync.Mutex
+ snap Snapshot
+
+ prevIf map[string][2]uint64
+ prevCPU [2]uint64 // busy, total
+ prevTime time.Time
+}
+
+// Snapshot is the state right now.
+type Snapshot struct {
+ Time time.Time
+ Uplink UplinkStatus
+ System SystemStatus
+ Pings []PingResult
+}
+
+type UplinkStatus struct {
+ Device string // pppoe-wan or the WAN interface
+ Type string // dhcp or pppoe
+ Exists bool
+ Connected bool // an IPv4 address and the default route on the uplink
+ Reachable bool // at least one public ping target answered
+ Checked bool // public ping targets are configured (Reachable means something)
+ IPv4 []string
+ IPv6 []string
+ Gateway string
+ Since time.Time
+ Events []Event
+}
+
+type SystemStatus struct {
+ Uptime time.Duration
+ Load string
+ CPU float64 // percent; NaN until the second sample
+ MemUsed uint64
+ MemTotal uint64
+ Temperature float64 // °C, NaN if no sensor
+ DiskUsed uint64
+ DiskTotal uint64
+}
+
+type PingResult struct {
+ Target string // as configured ("gateway" or an address)
+ Address string // what was pinged
+ RTT time.Duration
+ OK bool
+}
+
+func newCollector(sys System, leasesPath string) *Collector {
+ path := filepath.Join(sys.StateDir, "webui", "metrics.gob")
+ store, err := loadStore(path)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ log.Printf("metric history: %v (starting empty)", err)
+ }
+ return &Collector{sys: sys, store: store, storePath: path, leasesPath: leasesPath}
+}
+
+func (c *Collector) Snapshot() Snapshot {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.snap
+}
+
+func (c *Collector) Save() {
+ if err := os.MkdirAll(filepath.Dir(c.storePath), 0o700); err != nil {
+ log.Printf("saving metric history: %v", err)
+ return
+ }
+ if err := c.store.Save(c.storePath); err != nil {
+ log.Printf("saving metric history: %v", err)
+ }
+}
+
+// Run samples until ctx ends, saving the history every 15 minutes and at the end.
+func (c *Collector) Run(ctx context.Context) {
+ tick := time.NewTicker(sampleEvery)
+ defer tick.Stop()
+ save := time.NewTicker(15 * time.Minute)
+ defer save.Stop()
+ c.sample(time.Now())
+ for {
+ select {
+ case <-ctx.Done():
+ c.Save()
+ return
+ case <-save.C:
+ c.Save()
+ case now := <-tick.C:
+ c.sample(now)
+ }
+ }
+}
+
+func (c *Collector) sample(now time.Time) {
+ s, _ := loadSettings(c.sys.settingsPath())
+ f := s.form()
+ prefs := c.sys.loadPrefs()
+
+ // Pings run while the rest is sampled.
+ var pings []PingResult
+ done := make(chan struct{})
+ go func() {
+ pings = pingAll(prefs.PingTargets, 2*time.Second)
+ close(done)
+ }()
+
+ dt := now.Sub(c.prevTime).Seconds()
+ counters := interfaceCounters()
+ if c.prevIf != nil && dt > 0 {
+ for dev, cur := range counters {
+ prev, ok := c.prevIf[dev]
+ if !ok || cur[0] < prev[0] || cur[1] < prev[1] {
+ continue // new device or counters reset (a PPP reconnect)
+ }
+ c.store.Add("if:"+dev+":rx", now, float64(cur[0]-prev[0])/dt)
+ c.store.Add("if:"+dev+":tx", now, float64(cur[1]-prev[1])/dt)
+ }
+ }
+ c.prevIf, c.prevTime = counters, now
+
+ sys := systemStatus()
+ busy, total := cpuTimes()
+ sys.CPU = math.NaN()
+ if c.prevCPU[1] != 0 && total > c.prevCPU[1] {
+ sys.CPU = 100 * float64(busy-c.prevCPU[0]) / float64(total-c.prevCPU[1])
+ }
+ c.prevCPU = [2]uint64{busy, total}
+ c.store.Add("sys:cpu", now, sys.CPU)
+ if sys.MemTotal > 0 {
+ c.store.Add("sys:mem", now, 100*float64(sys.MemUsed)/float64(sys.MemTotal))
+ }
+ c.store.Add("sys:temp", now, sys.Temperature)
+
+ up := uplinkStatus(f.WAN)
+ <-done
+ for _, p := range pings {
+ loss := 100.0
+ if p.OK {
+ loss = 0
+ c.store.Add("ping:"+p.Target+":rtt", now, float64(p.RTT.Microseconds())/1000)
+ }
+ c.store.Add("ping:"+p.Target+":loss", now, loss)
+ if p.Target != "gateway" {
+ up.Checked = true
+ up.Reachable = up.Reachable || p.OK
+ }
+ }
+ detail := strings.Join(up.IPv4, ", ")
+ if !up.Connected {
+ detail = ""
+ }
+ c.store.SetConnected(up.Connected, now, detail)
+ _, up.Since, up.Events = c.store.Uplink()
+
+ c.mu.Lock()
+ c.snap = Snapshot{Time: now, Uplink: up, System: sys, Pings: pings}
+ c.mu.Unlock()
+}
+
+// interfaceCounters reads rx/tx bytes of every interface but lo.
+func interfaceCounters() map[string][2]uint64 {
+ out := map[string][2]uint64{}
+ entries, _ := os.ReadDir("/sys/class/net")
+ for _, e := range entries {
+ dev := e.Name()
+ if dev == "lo" {
+ continue
+ }
+ rx, err1 := readUint(filepath.Join("/sys/class/net", dev, "statistics/rx_bytes"))
+ tx, err2 := readUint(filepath.Join("/sys/class/net", dev, "statistics/tx_bytes"))
+ if err1 == nil && err2 == nil {
+ out[dev] = [2]uint64{rx, tx}
+ }
+ }
+ return out
+}
+
+func readUint(path string) (uint64, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return 0, err
+ }
+ return strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
+}
+
+func cpuTimes() (busy, total uint64) {
+ f, err := os.Open("/proc/stat")
+ if err != nil {
+ return 0, 0
+ }
+ defer f.Close()
+ line, _ := bufio.NewReader(f).ReadString('\n')
+ fields := strings.Fields(line)
+ if len(fields) < 5 || fields[0] != "cpu" {
+ return 0, 0
+ }
+ for i, v := range fields[1:] {
+ n, _ := strconv.ParseUint(v, 10, 64)
+ total += n
+ if i != 3 && i != 4 { // idle, iowait
+ busy += n
+ }
+ }
+ return busy, total
+}
+
+func systemStatus() SystemStatus {
+ s := SystemStatus{Temperature: temperature()}
+ if data, err := os.ReadFile("/proc/uptime"); err == nil {
+ if f := strings.Fields(string(data)); len(f) > 0 {
+ secs, _ := strconv.ParseFloat(f[0], 64)
+ s.Uptime = time.Duration(secs) * time.Second
+ }
+ }
+ if data, err := os.ReadFile("/proc/loadavg"); err == nil {
+ if f := strings.Fields(string(data)); len(f) >= 3 {
+ s.Load = strings.Join(f[:3], " ")
+ }
+ }
+ if f, err := os.Open("/proc/meminfo"); err == nil {
+ mem := map[string]uint64{}
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ k, v, ok := strings.Cut(sc.Text(), ":")
+ if ok {
+ n, _ := strconv.ParseUint(strings.TrimSuffix(strings.TrimSpace(v), " kB"), 10, 64)
+ mem[k] = n * 1024
+ }
+ }
+ f.Close()
+ s.MemTotal = mem["MemTotal"]
+ if mem["MemAvailable"] <= s.MemTotal {
+ s.MemUsed = s.MemTotal - mem["MemAvailable"]
+ }
+ }
+ var st syscall.Statfs_t
+ if syscall.Statfs("/", &st) == nil {
+ s.DiskTotal = st.Blocks * uint64(st.Bsize)
+ s.DiskUsed = (st.Blocks - st.Bfree) * uint64(st.Bsize)
+ }
+ return s
+}
+
+// temperature is the hottest thermal zone, or hwmon sensor if there is none.
+func temperature() float64 {
+ hottest := math.NaN()
+ for _, pattern := range []string{"/sys/class/thermal/thermal_zone*/temp", "/sys/class/hwmon/hwmon*/temp*_input"} {
+ files, _ := filepath.Glob(pattern)
+ for _, f := range files {
+ if v, err := readUint(f); err == nil && v > 0 && v < 150000 {
+ if t := float64(v) / 1000; math.IsNaN(hottest) || t > hottest {
+ hottest = t
+ }
+ }
+ }
+ if !math.IsNaN(hottest) {
+ break
+ }
+ }
+ return hottest
+}
+
+// uplinkDevice is where the router's IPv4 internet traffic leaves.
+func uplinkDevice(wan WANForm) string {
+ if wan.Type == "pppoe" {
+ return "pppoe-wan"
+ }
+ return wan.Interface
+}
+
+func uplinkStatus(wan WANForm) UplinkStatus {
+ u := UplinkStatus{Device: uplinkDevice(wan), Type: wan.Type}
+ iface, err := net.InterfaceByName(u.Device)
+ if err != nil {
+ return u
+ }
+ u.Exists = true
+ addrs, _ := iface.Addrs()
+ for _, a := range addrs {
+ ipnet, ok := a.(*net.IPNet)
+ if !ok {
+ continue
+ }
+ if ip4 := ipnet.IP.To4(); ip4 != nil {
+ u.IPv4 = append(u.IPv4, ip4.String())
+ } else if ipnet.IP.IsGlobalUnicast() {
+ u.IPv6 = append(u.IPv6, ipnet.IP.String())
+ }
+ }
+ gw, dev := defaultRoute()
+ u.Connected = len(u.IPv4) > 0 && dev == u.Device
+ if u.Connected {
+ u.Gateway = gw
+ }
+ return u
+}
+
+// defaultRoute returns the IPv4 default route's gateway (for a point-to-point
+// uplink: the peer) and device.
+func defaultRoute() (gateway, dev string) {
+ out, err := command("ip", "-j", "-4", "route", "show", "default")
+ if err != nil {
+ return "", ""
+ }
+ var routes []struct {
+ Gateway string `json:"gateway"`
+ Dev string `json:"dev"`
+ }
+ if json.Unmarshal([]byte(out), &routes) != nil || len(routes) == 0 {
+ return "", ""
+ }
+ gateway, dev = routes[0].Gateway, routes[0].Dev
+ if gateway == "" && dev != "" {
+ out, err := command("ip", "-j", "-4", "addr", "show", "dev", dev)
+ if err != nil {
+ return "", dev
+ }
+ var links []struct {
+ AddrInfo []struct {
+ Address string `json:"address"` // the peer, on point-to-point links
+ } `json:"addr_info"`
+ }
+ if json.Unmarshal([]byte(out), &links) == nil && len(links) > 0 && len(links[0].AddrInfo) > 0 {
+ gateway = links[0].AddrInfo[0].Address
+ }
+ }
+ return gateway, dev
+}
+
+// pingAll pings every target once, in parallel. "gateway" is the default
+// route's gateway (the ISP's end of the line).
+func pingAll(targets []string, timeout time.Duration) []PingResult {
+ out := make([]PingResult, len(targets))
+ var wg sync.WaitGroup
+ for i, t := range targets {
+ out[i] = PingResult{Target: t, Address: t}
+ if t == "gateway" {
+ gw, _ := defaultRoute()
+ out[i].Address = gw
+ }
+ addr, err := netip.ParseAddr(out[i].Address)
+ if err != nil {
+ continue
+ }
+ wg.Go(func() {
+ rtt, err := ping(addr, timeout)
+ out[i].RTT, out[i].OK = rtt, err == nil
+ })
+ }
+ wg.Wait()
+ return out
+}
+
+// ping sends one ICMP echo request over a raw socket (the UI runs as root).
+func ping(addr netip.Addr, timeout time.Duration) (time.Duration, error) {
+ network, request, reply := "ip4:icmp", byte(8), byte(0)
+ if addr.Is6() && !addr.Is4In6() {
+ network, request, reply = "ip6:ipv6-icmp", 128, 129
+ }
+ conn, err := net.ListenPacket(network, "")
+ if err != nil {
+ return 0, err
+ }
+ defer conn.Close()
+ id := uint16(rand.Uint32())
+ msg := make([]byte, 16)
+ msg[0] = request
+ binary.BigEndian.PutUint16(msg[4:], id)
+ binary.BigEndian.PutUint16(msg[6:], 1)
+ if request == 8 {
+ // The kernel fills in the ICMPv6 checksum, not ICMPv4's.
+ binary.BigEndian.PutUint16(msg[2:], checksum(msg))
+ }
+ start := time.Now()
+ conn.SetDeadline(start.Add(timeout))
+ if _, err := conn.WriteTo(msg, &net.IPAddr{IP: addr.AsSlice()}); err != nil {
+ return 0, err
+ }
+ buf := make([]byte, 1500)
+ for {
+ n, from, err := conn.ReadFrom(buf)
+ if err != nil {
+ return 0, err
+ }
+ // Raw sockets see every ICMP message; ours carries our ID.
+ if n >= 8 && buf[0] == reply && binary.BigEndian.Uint16(buf[4:]) == id {
+ if ip, ok := from.(*net.IPAddr); ok && ip.IP.Equal(addr.AsSlice()) {
+ return time.Since(start), nil
+ }
+ }
+ }
+}
+
+func checksum(b []byte) uint16 {
+ var sum uint32
+ for i := 0; i+1 < len(b); i += 2 {
+ sum += uint32(b[i])<<8 | uint32(b[i+1])
+ }
+ if len(b)%2 == 1 {
+ sum += uint32(b[len(b)-1]) << 8
+ }
+ for sum>>16 != 0 {
+ sum = sum&0xffff + sum>>16
+ }
+ return ^uint16(sum)
+}
+
+// Lease is an active DHCPv4 lease.
+type Lease struct {
+ Address string
+ MAC string
+ Hostname string
+ Expires time.Time
+ LAN string
+}
+
+// readLeases reads Kea's memfile: a CSV log in which the last line for an
+// address wins. The previous file of a lease file cleanup comes first.
+func readLeases(path string, lans []LANForm, now time.Time) []Lease {
+ latest := map[string]Lease{}
+ for _, p := range []string{path + ".completed", path + ".2", path} {
+ f, err := os.Open(p)
+ if err != nil {
+ continue
+ }
+ parseLeases(f, latest, now)
+ f.Close()
+ }
+ var out []Lease
+ for _, l := range latest {
+ if l.Expires.After(now) {
+ l.LAN = lanOf(l.Address, lans)
+ out = append(out, l)
+ }
+ }
+ slices.SortFunc(out, func(a, b Lease) int {
+ x, _ := netip.ParseAddr(a.Address)
+ y, _ := netip.ParseAddr(b.Address)
+ return x.Compare(y)
+ })
+ return out
+}
+
+func parseLeases(r io.Reader, latest map[string]Lease, now time.Time) {
+ cr := csv.NewReader(r)
+ cr.FieldsPerRecord = -1
+ header, err := cr.Read()
+ if err != nil {
+ return
+ }
+ col := map[string]int{}
+ for i, h := range header {
+ col[h] = i
+ }
+ get := func(rec []string, name string) string {
+ if i, ok := col[name]; ok && i < len(rec) {
+ return rec[i]
+ }
+ return ""
+ }
+ for {
+ rec, err := cr.Read()
+ if err == io.EOF {
+ return
+ }
+ if err != nil {
+ continue
+ }
+ if len(rec) > 0 && rec[0] == header[0] {
+ continue // a header repeated by a cleanup
+ }
+ addr := get(rec, "address")
+ expire, _ := strconv.ParseInt(get(rec, "expire"), 10, 64)
+ lifetime, _ := strconv.ParseInt(get(rec, "valid_lifetime"), 10, 64)
+ state := get(rec, "state")
+ l := Lease{
+ Address: addr,
+ MAC: get(rec, "hwaddr"),
+ Hostname: strings.ReplaceAll(get(rec, "hostname"), ",", ","),
+ Expires: time.Unix(expire, 0),
+ }
+ if lifetime == 0 || (state != "" && state != "0") {
+ l.Expires = time.Time{} // released, declined or reclaimed
+ }
+ latest[addr] = l
+ }
+}
+
+func lanOf(addr string, lans []LANForm) string {
+ a, err := netip.ParseAddr(addr)
+ if err != nil {
+ return ""
+ }
+ for _, l := range lans {
+ prefix := 24
+ if p, err := strconv.Atoi(l.Prefix); err == nil {
+ prefix = p
+ }
+ if pfx, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", l.Address, prefix)); err == nil && pfx.Masked().Contains(a) {
+ return l.Name
+ }
+ }
+ return ""
+}
diff --git a/pkgs/router-webui/dashboard.go b/pkgs/router-webui/dashboard.go
new file mode 100644
index 0000000..49274c7
--- /dev/null
+++ b/pkgs/router-webui/dashboard.go
@@ -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--.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())
+}
diff --git a/pkgs/router-webui/dashboard_test.go b/pkgs/router-webui/dashboard_test.go
new file mode 100644
index 0000000..bd43b78
--- /dev/null
+++ b/pkgs/router-webui/dashboard_test.go
@@ -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, "= 0 && len(p) > i+34 {
return p[i+34:] // drop /nix/store/-
@@ -158,14 +211,54 @@ func newApp(sys System) *app {
"duration": func(d time.Duration) string { return d.Round(time.Second).String() },
"seconds": func(d time.Duration) int { return int(d.Seconds()) },
"list": func(xs ...string) []string { return xs },
+ "bytes": formatBytes,
+ "age": formatAge,
+ "ago": func(t time.Time) string { return formatAge(time.Since(t)) },
+ "until": func(t time.Time) string { return formatAge(time.Until(t)) },
+ "value": formatValue,
+ "ms": func(d time.Duration) string { return formatValue(float64(d.Microseconds())/1000, "ms") },
+ "isNaN": math.IsNaN,
+ "chartW": func() float64 { return chartW },
+ "chartH": func() float64 { return chartH },
+ "join": strings.Join,
}
- for _, page := range []string{"login", "overview", "network", "advanced"} {
- a.pages[page] = template.Must(template.New("layout.html").Funcs(funcs).
- ParseFS(assets, "templates/layout.html", "templates/"+page+".html"))
+ pages, err := a.parseTemplates()
+ if err != nil {
+ panic(err)
}
+ a.pages = pages
return a
}
+func (a *app) parseTemplates() (map[string]*template.Template, error) {
+ files := map[string][]string{"overview": {"templates/dashboard.html"}}
+ pages := map[string]*template.Template{}
+ for _, page := range []string{"login", "overview", "network", "advanced", "update"} {
+ t, err := template.New("layout.html").Funcs(a.funcs).
+ ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
+ if err != nil {
+ return nil, err
+ }
+ pages[page] = t
+ }
+ return pages, nil
+}
+
+// page returns a template; in development it first re-reads them (a typo
+// keeps the previous version and is logged).
+func (a *app) page(name string) *template.Template {
+ a.pagesMu.Lock()
+ defer a.pagesMu.Unlock()
+ if a.dev {
+ if pages, err := a.parseTemplates(); err != nil {
+ log.Printf("templates: %v", err)
+ } else {
+ a.pages = pages
+ }
+ }
+ return a.pages[name]
+}
+
func (a *app) routes() http.Handler {
mux := http.NewServeMux()
static, _ := fs.Sub(assets, "static")
@@ -181,13 +274,21 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("POST /network", a.auth(a.network))
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
mux.HandleFunc("POST /advanced", a.auth(a.advanced))
+ mux.HandleFunc("GET /dashboard", a.auth(a.dashboardFragment))
+ mux.HandleFunc("POST /prefs", a.auth(a.savePrefs))
+ mux.HandleFunc("POST /backup", a.auth(a.backup))
+ mux.HandleFunc("GET /update", a.auth(a.updatePage))
+ mux.HandleFunc("POST /update/url", a.auth(a.updateSaveURL))
+ mux.HandleFunc("POST /update/check", a.auth(a.updateCheck))
+ mux.HandleFunc("POST /update/install", a.auth(a.updateInstall))
+ mux.HandleFunc("POST /update/upload", a.updateUpload)
return securityHeaders(mux)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
- h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
+ h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
h.Set("X-Content-Type-Options", "nosniff")
// Not no-referrer: under it browsers send "Origin: null" with form posts.
h.Set("Referrer-Policy", "same-origin")
@@ -207,7 +308,7 @@ type page struct {
}
func (a *app) render(w http.ResponseWriter, name string, p page) {
- if err := a.pages[name].Execute(w, p); err != nil {
+ if err := a.page(name).Execute(w, p); err != nil {
log.Printf("rendering %s: %v", name, err)
}
}
@@ -216,14 +317,17 @@ const cookieName = "router-session"
// auth requires a session; for POSTs also a matching CSRF token and, if the
// browser sends one, a same-origin Origin header.
+func (a *app) session(r *http.Request) (session, bool) {
+ c, err := r.Cookie(cookieName)
+ if err != nil {
+ return session{}, false
+ }
+ return a.sessions.get(c.Value)
+}
+
func (a *app) auth(h func(http.ResponseWriter, *http.Request, session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- c, err := r.Cookie(cookieName)
- var sess session
- ok := err == nil
- if ok {
- sess, ok = a.sessions.get(c.Value)
- }
+ sess, ok := a.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
@@ -291,9 +395,15 @@ func (a *app) logout(w http.ResponseWriter, r *http.Request, _ session) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
+type overviewData struct {
+ Status Status
+ Dashboard dashboard
+}
+
func (a *app) overview(w http.ResponseWriter, r *http.Request, sess session) {
st := a.sys.Status()
- p := page{Title: "Overview", CSRF: sess.csrf, LoggedIn: true, Data: st, Flash: r.URL.Query().Get("msg")}
+ p := page{Title: "Overview", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg"),
+ Data: overviewData{Status: st, Dashboard: a.dashboardData(r.URL.Query().Get("range"))}}
switch {
case st.Applying || st.ConfirmRunning:
p.Refresh = 2
@@ -417,25 +527,35 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
back(w, r, "Saved. Apply to make the changes take effect.")
}
+type advancedData struct {
+ Settings string
+ PingTargets string
+}
+
func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session) {
data, err := os.ReadFile(a.sys.settingsPath())
- p := page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: string(data)}
+ p := page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg"),
+ Data: a.advancedData(string(data))}
if err != nil {
p.Error = err.Error()
}
a.render(w, "advanced", p)
}
+func (a *app) advancedData(settings string) advancedData {
+ return advancedData{Settings: settings, PingTargets: strings.Join(a.sys.loadPrefs().PingTargets, ", ")}
+}
+
func (a *app) advanced(w http.ResponseWriter, r *http.Request, sess session) {
text := r.PostFormValue("settings")
s, err := parseSettings([]byte(text))
if err != nil {
w.WriteHeader(http.StatusUnprocessableEntity)
- a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: text, Error: err.Error()})
+ a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: a.advancedData(text), Error: err.Error()})
return
}
if err := s.save(a.sys.settingsPath()); err != nil {
- a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: text, Error: err.Error()})
+ a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: a.advancedData(text), Error: err.Error()})
return
}
back(w, r, "Saved. Apply to make the changes take effect.")
diff --git a/pkgs/router-webui/main_test.go b/pkgs/router-webui/main_test.go
index 9f749df..c14a2af 100644
--- a/pkgs/router-webui/main_test.go
+++ b/pkgs/router-webui/main_test.go
@@ -19,7 +19,7 @@ func newTestApp(t *testing.T) (*app, System) {
os.WriteFile(sys.settingsPath(), []byte(sample), 0o600)
h, _ := hashPassword("hunter2hunter2")
os.WriteFile(sys.passwordPath(), []byte(h), 0o600)
- return newApp(sys), sys
+ return newApp(sys, newCollector(sys, filepath.Join(dir, "leases")), false), sys
}
func do(h http.Handler, method, path string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
diff --git a/pkgs/router-webui/metrics.go b/pkgs/router-webui/metrics.go
new file mode 100644
index 0000000..8535eb3
--- /dev/null
+++ b/pkgs/router-webui/metrics.go
@@ -0,0 +1,252 @@
+package main
+
+import (
+ "bytes"
+ "encoding/gob"
+ "math"
+ "os"
+ "slices"
+ "sync"
+ "time"
+)
+
+// Metric history: every series keeps a ring of averages per resolution. A
+// slot's index follows from its time, so writing needs no head pointer and
+// gaps (the UI was not running) simply stay NaN.
+//
+// 5 s for 1 h, 1 min for 24 h, 15 min for 7 days, 1 h for 30 days.
+var tiers = []struct {
+ Step int64 // seconds
+ Slots int
+}{
+ {5, 720},
+ {60, 1440},
+ {900, 672},
+ {3600, 720},
+}
+
+// Ranges the dashboard offers, and the tier each one reads.
+var ranges = []struct {
+ Name string
+ Tier int
+}{
+ {"1h", 0},
+ {"24h", 1},
+ {"7d", 2},
+ {"30d", 3},
+}
+
+func rangeTier(name string) (int, string) {
+ for _, r := range ranges {
+ if r.Name == name {
+ return r.Tier, r.Name
+ }
+ }
+ return 0, ranges[0].Name
+}
+
+type Tier struct {
+ Step int64
+ Values []float64 // NaN: no data
+ Last int64 // start of the newest written bucket (unix seconds)
+ // The bucket being filled.
+ Bucket int64
+ Sum float64
+ Count int
+}
+
+func newTier(step int64, slots int) *Tier {
+ t := &Tier{Step: step, Values: make([]float64, slots)}
+ for i := range t.Values {
+ t.Values[i] = math.NaN()
+ }
+ return t
+}
+
+func (t *Tier) slot(b int64) int { return int((b / t.Step) % int64(len(t.Values))) }
+
+func (t *Tier) add(at int64, v float64) {
+ b := at - at%t.Step
+ if b != t.Bucket {
+ t.flush()
+ t.Bucket, t.Sum, t.Count = b, 0, 0
+ }
+ t.Sum += v
+ t.Count++
+}
+
+// flush writes the bucket being filled into the ring.
+func (t *Tier) flush() {
+ if t.Count == 0 || t.Bucket < t.Last {
+ return
+ }
+ n := int64(len(t.Values))
+ // Clear the slots skipped since the last write (at most one full turn).
+ for b, i := t.Last+t.Step, int64(0); b < t.Bucket && i < n; b, i = b+t.Step, i+1 {
+ t.Values[t.slot(b)] = math.NaN()
+ }
+ t.Values[t.slot(t.Bucket)] = t.Sum / float64(t.Count)
+ t.Last = t.Bucket
+ t.Count = 0
+}
+
+// read returns the buckets of the last full turn of the ring up to now, the
+// one being filled included, oldest first, and the first bucket's start.
+func (t *Tier) read(now int64) (int64, []float64) {
+ n := int64(len(t.Values))
+ end := now - now%t.Step
+ start := end - (n-1)*t.Step
+ out := make([]float64, n)
+ for i := range out {
+ b := start + int64(i)*t.Step
+ switch {
+ case b == t.Bucket && t.Count > 0:
+ out[i] = t.Sum / float64(t.Count)
+ case b > t.Last || b <= t.Last-n*t.Step:
+ out[i] = math.NaN()
+ default:
+ out[i] = t.Values[t.slot(b)]
+ }
+ }
+ return start, out
+}
+
+type Series struct {
+ Tiers []*Tier
+}
+
+func newSeries() *Series {
+ s := &Series{}
+ for _, t := range tiers {
+ s.Tiers = append(s.Tiers, newTier(t.Step, t.Slots))
+ }
+ return s
+}
+
+// Event is a change of the uplink's state.
+type Event struct {
+ Time time.Time
+ Connected bool
+ Detail string
+}
+
+// Store is the history that survives restarts.
+type Store struct {
+ mu sync.Mutex
+ Series map[string]*Series
+ // The uplink's state as last seen, and its recent changes.
+ Connected bool
+ Since time.Time
+ Events []Event
+}
+
+const maxEvents = 50
+
+func newStore() *Store { return &Store{Series: map[string]*Series{}} }
+
+func (s *Store) Add(name string, at time.Time, v float64) {
+ if math.IsNaN(v) || math.IsInf(v, 0) {
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ ser := s.Series[name]
+ if ser == nil {
+ ser = newSeries()
+ s.Series[name] = ser
+ }
+ for _, t := range ser.Tiers {
+ t.add(at.Unix(), v)
+ }
+}
+
+// Read returns a series at the resolution of tier: the start, step and values.
+func (s *Store) Read(name string, tier int, now time.Time) (time.Time, time.Duration, []float64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ t := tiers[tier]
+ ser := s.Series[name]
+ if ser == nil {
+ ser = newSeries() // all NaN
+ }
+ start, vals := ser.Tiers[tier].read(now.Unix())
+ return time.Unix(start, 0), time.Duration(t.Step) * time.Second, vals
+}
+
+func (s *Store) Names() []string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ names := make([]string, 0, len(s.Series))
+ for n := range s.Series {
+ names = append(names, n)
+ }
+ slices.Sort(names)
+ return names
+}
+
+// SetConnected records the uplink's state; a change becomes an event.
+func (s *Store) SetConnected(connected bool, at time.Time, detail string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if connected == s.Connected && !s.Since.IsZero() {
+ return
+ }
+ s.Connected, s.Since = connected, at
+ s.Events = append(s.Events, Event{Time: at, Connected: connected, Detail: detail})
+ if len(s.Events) > maxEvents {
+ s.Events = s.Events[len(s.Events)-maxEvents:]
+ }
+}
+
+func (s *Store) Uplink() (bool, time.Time, []Event) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.Connected, s.Since, slices.Clone(s.Events)
+}
+
+// The on-disk format; bump storeVersion when it changes incompatibly (an old
+// file is then ignored).
+const storeVersion = 1
+
+type storeFile struct {
+ Version int
+ Series map[string]*Series
+ Connected bool
+ Since time.Time
+ Events []Event
+}
+
+func (s *Store) Save(path string) error {
+ s.mu.Lock()
+ f := storeFile{storeVersion, s.Series, s.Connected, s.Since, s.Events}
+ var buf bytes.Buffer
+ err := gob.NewEncoder(&buf).Encode(f)
+ s.mu.Unlock()
+ if err != nil {
+ return err
+ }
+ return writeFileAtomic(path, buf.Bytes(), 0o600)
+}
+
+func loadStore(path string) (*Store, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return newStore(), err
+ }
+ var f storeFile
+ if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&f); err != nil || f.Version != storeVersion {
+ return newStore(), err
+ }
+ s := &Store{Series: map[string]*Series{}, Connected: f.Connected, Since: f.Since, Events: f.Events}
+ for name, ser := range f.Series {
+ // Keep only series whose shape matches the current tiers.
+ ok := len(ser.Tiers) == len(tiers)
+ for i := 0; ok && i < len(tiers); i++ {
+ ok = ser.Tiers[i].Step == tiers[i].Step && len(ser.Tiers[i].Values) == tiers[i].Slots
+ }
+ if ok {
+ s.Series[name] = ser
+ }
+ }
+ return s, nil
+}
diff --git a/pkgs/router-webui/static/dashboard.js b/pkgs/router-webui/static/dashboard.js
new file mode 100644
index 0000000..e8d6cbc
--- /dev/null
+++ b/pkgs/router-webui/static/dashboard.js
@@ -0,0 +1,45 @@
+// Live updates for the overview: refetches the dashboard part of the page
+// (rendered by the server, like the page itself) and switches time ranges
+// without a reload. Without JavaScript the page and its links still work.
+(function () {
+ "use strict";
+ const root = document.getElementById("dashboard");
+ if (!root) return;
+ let range = root.dataset.range;
+ let interval = Number(root.dataset.interval) || 5;
+ let timer = null;
+
+ async function refresh() {
+ try {
+ const r = await fetch("/dashboard?range=" + encodeURIComponent(range), { credentials: "same-origin" });
+ if (r.redirected || r.status === 401 || r.status === 403) {
+ location.href = "/login"; // the session ended
+ return;
+ }
+ if (r.ok) root.innerHTML = await r.text();
+ } catch (e) {
+ // The router may be restarting the UI (an apply); try again later.
+ }
+ schedule();
+ }
+
+ function schedule() {
+ clearTimeout(timer);
+ if (!document.hidden) timer = setTimeout(refresh, interval * 1000);
+ }
+
+ root.addEventListener("click", function (ev) {
+ const a = ev.target.closest("a[data-range]");
+ if (!a) return;
+ ev.preventDefault();
+ range = a.dataset.range;
+ interval = range === "1h" ? 5 : 60;
+ history.replaceState(null, "", "/?range=" + encodeURIComponent(range));
+ refresh();
+ });
+ document.addEventListener("visibilitychange", function () {
+ if (document.hidden) clearTimeout(timer);
+ else refresh();
+ });
+ schedule();
+})();
diff --git a/pkgs/router-webui/static/style.css b/pkgs/router-webui/static/style.css
index e89332d..598f829 100644
--- a/pkgs/router-webui/static/style.css
+++ b/pkgs/router-webui/static/style.css
@@ -35,3 +35,36 @@ button.link { background: none; border: none; color: var(--accent); padding: 0;
.narrow label { margin: 0; }
pre.log { background: color-mix(in srgb, var(--line) 35%, transparent); padding: .75rem; border-radius: 6px; overflow-x: auto; font-size: 12.5px; max-height: 30rem; }
code { font-size: .9em; overflow-wrap: anywhere; }
+.muted { color: var(--muted); }
+.bad { color: var(--bad); }
+.ok { color: var(--good); }
+:root { --good: #1e7b34; --s0: #2457c5; --s1: #d9730d; --s2: #2f9e44; --s3: #9c36b5; --s4: #c92a2a; }
+@media (prefers-color-scheme: dark) { :root { --good: #69db7c; --s0: #7aa2ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; } }
+.ranges { display: flex; gap: .25rem; margin: 0 0 1rem; }
+.ranges a { padding: .2rem .7rem; border: 1px solid var(--line); border-radius: 4px; text-decoration: none; }
+.ranges a.active { background: var(--accent); color: var(--bg); border-color: var(--accent); }
+.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 1rem; margin: 0 0 1.25rem; }
+.card { border: 1px solid var(--line); border-left-width: 4px; border-radius: 6px; padding: .75rem 1rem; margin: 0; }
+.card.up { border-left-color: var(--good); }
+.card.degraded { border-left-color: #e8a600; }
+.card.down { border-left-color: var(--bad); }
+.card .state { font-weight: 600; margin: 0 0 .5rem; }
+.card dl { margin: 0; font-size: .92em; }
+.events { list-style: none; margin: 0; padding: 0; font-size: .9em; }
+.events li span:first-child { display: inline-block; width: 2.5rem; font-weight: 600; }
+.scroll { overflow-x: auto; }
+.leases td, .leases th { padding: .2rem .75rem .2rem 0; white-space: nowrap; }
+.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 30rem), 1fr)); gap: 1rem; margin: 0 0 1.5rem; }
+.chart { margin: 0; border: 1px solid var(--line); border-radius: 6px; padding: .5rem .75rem; min-width: 0; }
+.chart figcaption { display: flex; flex-wrap: wrap; gap: .25rem 1rem; justify-content: space-between; font-size: .9em; }
+.legend { display: flex; flex-wrap: wrap; gap: .1rem .9rem; }
+.key::before { content: ""; display: inline-block; width: .7rem; height: .2rem; margin: 0 .3rem .2rem 0; background: currentColor; vertical-align: middle; }
+.key b { font-weight: 500; color: var(--fg); }
+.chart svg { width: 100%; height: auto; display: block; overflow: visible; }
+.chart .grid { stroke: var(--line); stroke-width: 1; }
+.chart .lab { fill: var(--muted); font-size: 11px; }
+.chart .line { fill: none; stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; }
+.chart .hover { fill: transparent; }
+.chart .hover:hover { fill: color-mix(in srgb, var(--fg) 8%, transparent); }
+.s0 { color: var(--s0); stroke: var(--s0); } .s1 { color: var(--s1); stroke: var(--s1); } .s2 { color: var(--s2); stroke: var(--s2); }
+.s3 { color: var(--s3); stroke: var(--s3); } .s4 { color: var(--s4); stroke: var(--s4); }
diff --git a/pkgs/router-webui/templates/advanced.html b/pkgs/router-webui/templates/advanced.html
index 9b273fe..daa88f2 100644
--- a/pkgs/router-webui/templates/advanced.html
+++ b/pkgs/router-webui/templates/advanced.html
@@ -1,9 +1,31 @@
{{define "content"}}
+
+Settings
The complete settings: the router options of nixos-router as JSON, plus system.
They are checked when applied; nothing changes until then.
+
+
+
+
+
+Backup
+Everything on /var/lib/router: settings, PPPoE password, WireGuard keys, passwords, the web UI's
+ certificate and the dashboard history. Keep the file safe. To restore, put it on a USB stick and run
+ router-setup from the installer: it offers to install from it.
+Download backup
+
{{end}}
diff --git a/pkgs/router-webui/templates/dashboard.html b/pkgs/router-webui/templates/dashboard.html
new file mode 100644
index 0000000..7f27083
--- /dev/null
+++ b/pkgs/router-webui/templates/dashboard.html
@@ -0,0 +1,82 @@
+{{define "dashboard"}}
+{{$d := .Data.Dashboard}}{{$u := $d.Snap.Uplink}}{{$sys := $d.Snap.System}}
+
+ {{range $d.Ranges}}
{{.}} {{end}}
+
+
+
+ {{if $d.Snap.Time.IsZero}}
+
+ {{else}}
+
+ Internet
+
+ {{if and $u.Connected (or $u.Reachable (not $u.Checked))}}Connected
+ {{else if $u.Connected}}Connected, but no answer from the internet
+ {{else if not $u.Exists}}{{if eq $u.Type "pppoe"}}Not connected: no PPPoE session{{else}}Not connected: {{$u.Device}} is missing{{end}}
+ {{else}}Not connected{{end}}
+ {{if not $u.Since.IsZero}}for {{ago $u.Since}} {{end}}
+
+
+ Uplink {{$u.Device}} ({{if eq $u.Type "pppoe"}}PPPoE{{else}}DHCP{{end}})
+ {{if $u.IPv4}}IPv4 {{join $u.IPv4 ", "}} {{end}}
+ {{if $u.IPv6}}IPv6 {{join $u.IPv6 ", "}} {{end}}
+ {{if $u.Gateway}}Gateway {{$u.Gateway}} {{end}}
+ {{range $d.Snap.Pings}}
+ Ping {{.Target}} {{if .OK}}{{ms .RTT}}{{else if .Address}}no answer {{else}}no address {{end}}{{if and .Address (ne .Address .Target)}} {{.Address}} {{end}}
+ {{end}}
+
+
+ {{end}}
+
+
+ System
+
+ Uptime {{age $sys.Uptime}}
+ Load {{$sys.Load}}
+ CPU {{value $sys.CPU "%"}}
+ {{if $sys.MemTotal}}Memory {{bytes $sys.MemUsed}} of {{bytes $sys.MemTotal}} {{end}}
+ {{if not (isNaN $sys.Temperature)}}Temperature {{value $sys.Temperature "°C"}} {{end}}
+ {{if $sys.DiskTotal}}Disk {{bytes $sys.DiskUsed}} of {{bytes $sys.DiskTotal}} {{end}}
+
+
+
+ {{if $d.Events}}
+
+ Connection log
+
+ {{range $d.Events}}{{if .Connected}}up{{else}}down{{end}} {{.Time.Local.Format "Jan 2 15:04:05"}}{{if .Detail}} {{.Detail}} {{end}} {{end}}
+
+
+ {{end}}
+
+
+
+ DHCP clients
+ {{if $d.Leases}}
+
+ {{else}}No active leases.
{{end}}
+
+
+
+ {{range $d.Charts}}
+
+ {{.Title}}
+ {{range .Lines}}{{.Label}} {{.Now}} {{end}}
+
+
+ {{range .YTicks}}{{.Label}} {{end}}
+ {{range .XTicks}}{{.Label}} {{end}}
+ {{range .Lines}} {{end}}
+ {{if .Empty}}no data yet {{end}}
+ {{range .Cols}}{{.Tip}} {{end}}
+
+
+ {{end}}
+
+{{end}}
diff --git a/pkgs/router-webui/templates/overview.html b/pkgs/router-webui/templates/overview.html
index aecd2c4..07bc6e1 100644
--- a/pkgs/router-webui/templates/overview.html
+++ b/pkgs/router-webui/templates/overview.html
@@ -1,12 +1,5 @@
{{define "content"}}
-{{$s := .Data}}
-
-
- Running system {{short $s.Current}}
- Confirmed system {{short $s.Confirmed}}
-
-
-
+{{$s := .Data.Status}}
{{if $s.Applying}}
Applying… Building the new configuration. This page refreshes on its own.
{{else if $s.ConfirmRunning}}
@@ -18,20 +11,37 @@
the router goes back to the confirmed system on its own.
Confirm
-{{else}}
-
- {{if $s.Unapplied}}The settings have changes that are not part of the confirmed system.
{{else}}The settings match the confirmed system.
{{end}}
+{{else if $s.Unapplied}}
+
+ The settings have changes that are not part of the confirmed system.
Apply
- {{if $s.Unapplied}}Discard changes {{end}}
+ Discard changes
{{end}}
+
+{{template "dashboard" .}}
+
+
+
+ Configuration
+ {{if not (or $s.Applying $s.ConfirmRunning $s.Unapplied (gt (seconds $s.RollbackIn) 0))}}The settings match the confirmed system.
{{end}}
+
+ Running system {{short $s.Current}}
+ Confirmed system {{short $s.Confirmed}}
+
+ {{if not (or $s.Applying $s.ConfirmRunning $s.Unapplied (gt (seconds $s.RollbackIn) 0))}}
+ Rebuild and apply
+ {{end}}
+
+
{{if $s.Log}}
Last apply{{if $s.ApplyResult}} — {{if eq $s.ApplyResult "success"}}succeeded{{else}}failed ({{$s.ApplyResult}}){{end}}{{end}}
{{$s.Log}}
{{end}}
+
{{end}}
diff --git a/tests/apply.nix b/tests/apply.nix
index 029bf6a..4764ace 100644
--- a/tests/apply.nix
+++ b/tests/apply.nix
@@ -196,5 +196,25 @@ pkgs.testers.runNixOSTest {
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-confirm.service) = inactive")
assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == current()
assert "match the confirmed system" in curl(f"{ui}/")
+
+ with subtest("the dashboard shows the uplink, the LAN and pings from the router"):
+ curl(f"-d csrf={csrf('/advanced')} --data-urlencode 'pingTargets=gateway, 192.168.10.50' {ui}/prefs")
+ # The UI pings from its sandbox (raw socket); the client answers.
+ # (Into a file: grep -q would end curl with SIGPIPE, failing the pipe.)
+ client.wait_until_succeeds(
+ f"curl -sk -b /tmp/jar -o /tmp/dash '{ui}/dashboard?range=1h' && grep -q 'Ping 192.168.10.50[0-9.]* ms' /tmp/dash",
+ timeout=60)
+ page = curl(f"{ui}/")
+ # No DHCP server on the uplink in this test.
+ for text in ["Not connected", "LAN main (eth2)", "Internet (eth1)", "Latency", "CPU and memory", "/static/dashboard.js"]:
+ assert text in page, f"{text!r} missing:\n{page}"
+ assert "