From c01341c63ac4d8d87b5f3e5fb9049aa7cf070464 Mon Sep 17 00:00:00 2001 From: BergaBruh <49639421+BergaBruh@users.noreply.github.com> Date: Sat, 9 May 2026 19:01:24 +0500 Subject: [PATCH] snapshot+hugo: speedtest tab with recent table + per-node aggregates --- cmd/snapshot/main.go | 49 +++++++++ internal/storage/speedtests.go | 71 +++++++++++++ stats-site/content/speedtest/_index.md | 4 + stats-site/layouts/index.html | 1 + stats-site/layouts/speedtest/list.html | 140 +++++++++++++++++++++++++ 5 files changed, 265 insertions(+) create mode 100644 stats-site/content/speedtest/_index.md create mode 100644 stats-site/layouts/speedtest/list.html diff --git a/cmd/snapshot/main.go b/cmd/snapshot/main.go index 070c3e6..ebebd43 100644 --- a/cmd/snapshot/main.go +++ b/cmd/snapshot/main.go @@ -23,9 +23,52 @@ type nodeData struct { Recent []sample `json:"recent"` } +// SpeedtestSnapshot exposes recent speedtest rows + day/week/month median +// aggregates per node. Recent uses storage.SpeedTest's capitalized field +// names (no JSON tags on the struct); the frontend reads them as-is. +type SpeedtestSnapshot struct { + Recent []storage.SpeedTest `json:"recent"` + AggDay map[string]storage.Agg `json:"agg_day"` + AggWeek map[string]storage.Agg `json:"agg_week"` + AggMonth map[string]storage.Agg `json:"agg_month"` + GeneratedAt int64 `json:"generated_at"` +} + type snapshot struct { GeneratedAt string `json:"generated_at"` Nodes map[string]nodeData `json:"nodes"` + Speedtest *SpeedtestSnapshot `json:"speedtest,omitempty"` +} + +// buildSpeedtestSnapshot collects the last 200 speedtest rows + per-node +// medians over 24h/7d/30d windows. Aggregate errors are logged but +// non-fatal — the rest of the snapshot still ships. +func buildSpeedtestSnapshot(ctx context.Context, st *storage.Store) (*SpeedtestSnapshot, error) { + recent, err := st.ListRecentSpeedTests(ctx, 200) + if err != nil { + return nil, err + } + now := time.Now().Unix() + day, week, month := now-86400, now-7*86400, now-30*86400 + aggDay, errDay := st.AggregateSpeedTestsSince(ctx, day) + if errDay != nil { + log.Printf("aggregate day: %v", errDay) + } + aggWeek, errWeek := st.AggregateSpeedTestsSince(ctx, week) + if errWeek != nil { + log.Printf("aggregate week: %v", errWeek) + } + aggMonth, errMonth := st.AggregateSpeedTestsSince(ctx, month) + if errMonth != nil { + log.Printf("aggregate month: %v", errMonth) + } + return &SpeedtestSnapshot{ + Recent: recent, + AggDay: aggDay, + AggWeek: aggWeek, + AggMonth: aggMonth, + GeneratedAt: now, + }, nil } func main() { @@ -63,6 +106,12 @@ func main() { s.Nodes[node] = nd } + if stSnap, err := buildSpeedtestSnapshot(ctx, st); err != nil { + log.Printf("build speedtest snapshot: %v", err) + } else { + s.Speedtest = stSnap + } + if err := os.MkdirAll(filepath.Dir(*out), 0755); err != nil { log.Fatalf("mkdir %s: %v", filepath.Dir(*out), err) } diff --git a/internal/storage/speedtests.go b/internal/storage/speedtests.go index fee90bc..df2b45e 100644 --- a/internal/storage/speedtests.go +++ b/internal/storage/speedtests.go @@ -3,8 +3,19 @@ package storage import ( "context" "fmt" + "sort" ) +// Agg holds median aggregates for a single node over an arbitrary time +// window. Counts and medians are computed Go-side (sort + middle pick) +// because SQLite has no native percentile aggregate. +type Agg struct { + Count int64 `json:"count"` + MedianDl float64 `json:"median_dl"` + MedianUl float64 `json:"median_ul"` + MedianPing float64 `json:"median_ping"` +} + // SpeedTest is a single client-side speedtest result reported via the // RecordSpeedTest RPC. Timestamp is unix seconds. Node is 'fl' or 'pl1' // (DB-level CHECK). Country is ISO-3166-1 alpha-2 (validated at the @@ -63,3 +74,63 @@ func (s *Store) DeleteSpeedTestsBefore(ctx context.Context, ts int64) (int64, er } return res.RowsAffected() } + +// AggregateSpeedTestsSince returns per-node median aggregates of dl/ul/ping +// for rows with timestamp >= since. The result is keyed by node ("fl"/"pl1"). +// Reads only metric columns — no privacy-sensitive data (country, jitter +// not used here). +func (s *Store) AggregateSpeedTestsSince(ctx context.Context, since int64) (map[string]Agg, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT node, dl_mbps, ul_mbps, ping_ms FROM speedtests + WHERE timestamp >= ? ORDER BY node`, since) + if err != nil { + return nil, err + } + defer rows.Close() + + type bucket struct{ dl, ul, ping []float64 } + buckets := map[string]*bucket{} + for rows.Next() { + var node string + var dl, ul, ping float64 + if err := rows.Scan(&node, &dl, &ul, &ping); err != nil { + return nil, err + } + b, ok := buckets[node] + if !ok { + b = &bucket{} + buckets[node] = b + } + b.dl = append(b.dl, dl) + b.ul = append(b.ul, ul) + b.ping = append(b.ping, ping) + } + if err := rows.Err(); err != nil { + return nil, err + } + + out := map[string]Agg{} + for node, b := range buckets { + out[node] = Agg{ + Count: int64(len(b.dl)), + MedianDl: median(b.dl), + MedianUl: median(b.ul), + MedianPing: median(b.ping), + } + } + return out, nil +} + +// median sorts xs in place and returns the middle value (or mean of the +// two middle values if even-length). Returns 0 for empty input. +func median(xs []float64) float64 { + if len(xs) == 0 { + return 0 + } + sort.Float64s(xs) + mid := len(xs) / 2 + if len(xs)%2 == 0 { + return (xs[mid-1] + xs[mid]) / 2 + } + return xs[mid] +} diff --git a/stats-site/content/speedtest/_index.md b/stats-site/content/speedtest/_index.md new file mode 100644 index 0000000..6323115 --- /dev/null +++ b/stats-site/content/speedtest/_index.md @@ -0,0 +1,4 @@ +--- +title: Speedtest +date: 2026-05-09 +--- diff --git a/stats-site/layouts/index.html b/stats-site/layouts/index.html index 483dd57..daeda4e 100644 --- a/stats-site/layouts/index.html +++ b/stats-site/layouts/index.html @@ -40,6 +40,7 @@ diff --git a/stats-site/layouts/speedtest/list.html b/stats-site/layouts/speedtest/list.html new file mode 100644 index 0000000..5987a0e --- /dev/null +++ b/stats-site/layouts/speedtest/list.html @@ -0,0 +1,140 @@ +{{ define "main" }} +
+

Speedtest results

+

Updated . Retention 30 days.

+ +

Last 24h / 7d / 30d (median)

+ + + +
NodePeriodCount↓ Mbps↑ MbpsPing ms
+ +

Recent tests (last 200)

+
+ Node: + + Country: + +
+ + + + + + +
TimeNodeCountry↓ Mbps↑ MbpsPing msJitter ms
+
+ + + + +{{ end }}