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