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