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--