snapshot+hugo: speedtest tab with recent table + per-node aggregates
This commit is contained in:
@@ -23,9 +23,52 @@ type nodeData struct {
|
|||||||
Recent []sample `json:"recent"`
|
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 {
|
type snapshot struct {
|
||||||
GeneratedAt string `json:"generated_at"`
|
GeneratedAt string `json:"generated_at"`
|
||||||
Nodes map[string]nodeData `json:"nodes"`
|
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() {
|
func main() {
|
||||||
@@ -63,6 +106,12 @@ func main() {
|
|||||||
s.Nodes[node] = nd
|
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 {
|
if err := os.MkdirAll(filepath.Dir(*out), 0755); err != nil {
|
||||||
log.Fatalf("mkdir %s: %v", filepath.Dir(*out), err)
|
log.Fatalf("mkdir %s: %v", filepath.Dir(*out), err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,19 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"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
|
// SpeedTest is a single client-side speedtest result reported via the
|
||||||
// RecordSpeedTest RPC. Timestamp is unix seconds. Node is 'fl' or 'pl1'
|
// RecordSpeedTest RPC. Timestamp is unix seconds. Node is 'fl' or 'pl1'
|
||||||
// (DB-level CHECK). Country is ISO-3166-1 alpha-2 (validated at the
|
// (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()
|
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]
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: Speedtest
|
||||||
|
date: 2026-05-09
|
||||||
|
---
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<nav class="footer-nav">
|
<nav class="footer-nav">
|
||||||
|
<a href="/speedtest/">Speedtest</a>
|
||||||
<a href="/about/">About</a>
|
<a href="/about/">About</a>
|
||||||
</nav>
|
</nav>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
{{ define "main" }}
|
||||||
|
<section class="speedtest">
|
||||||
|
<h1>Speedtest results</h1>
|
||||||
|
<p class="muted">Updated <span id="ts">…</span>. Retention 30 days.</p>
|
||||||
|
|
||||||
|
<h2>Last 24h / 7d / 30d (median)</h2>
|
||||||
|
<table class="agg">
|
||||||
|
<thead><tr><th>Node</th><th>Period</th><th>Count</th><th>↓ Mbps</th><th>↑ Mbps</th><th>Ping ms</th></tr></thead>
|
||||||
|
<tbody id="agg-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Recent tests (last 200)</h2>
|
||||||
|
<div class="filters">
|
||||||
|
Node:
|
||||||
|
<select id="filter-node">
|
||||||
|
<option value="">all</option>
|
||||||
|
<option value="fl">fl</option>
|
||||||
|
<option value="pl1">pl1</option>
|
||||||
|
</select>
|
||||||
|
Country:
|
||||||
|
<input type="text" id="filter-country" maxlength="2" placeholder="RU" style="width: 4rem;">
|
||||||
|
</div>
|
||||||
|
<table class="recent">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Time</th><th>Node</th><th>Country</th>
|
||||||
|
<th>↓ Mbps</th><th>↑ Mbps</th><th>Ping ms</th><th>Jitter ms</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="recent-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function clearChildren(el) {
|
||||||
|
while (el.firstChild) el.removeChild(el.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCell(row, text) {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.textContent = text;
|
||||||
|
row.appendChild(td);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNumber(n, fixed) {
|
||||||
|
if (typeof n !== 'number' || !isFinite(n)) return '0';
|
||||||
|
return n.toFixed(fixed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTimestamp(unixSeconds) {
|
||||||
|
if (typeof unixSeconds !== 'number') return '';
|
||||||
|
return new Date(unixSeconds * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAggTable(snapshot) {
|
||||||
|
const aggBody = document.getElementById('agg-tbody');
|
||||||
|
clearChildren(aggBody);
|
||||||
|
const periods = [
|
||||||
|
{ key: 'agg_day', label: '24h' },
|
||||||
|
{ key: 'agg_week', label: '7d' },
|
||||||
|
{ key: 'agg_month', label: '30d' },
|
||||||
|
];
|
||||||
|
for (const { key, label } of periods) {
|
||||||
|
const m = snapshot[key] || {};
|
||||||
|
for (const node of ['fl', 'pl1']) {
|
||||||
|
const a = m[node];
|
||||||
|
if (!a) continue;
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
appendCell(tr, node);
|
||||||
|
appendCell(tr, label);
|
||||||
|
appendCell(tr, String(a.count | 0));
|
||||||
|
appendCell(tr, fmtNumber(a.median_dl, 1));
|
||||||
|
appendCell(tr, fmtNumber(a.median_ul, 1));
|
||||||
|
appendCell(tr, fmtNumber(a.median_ping, 1));
|
||||||
|
aggBody.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecentTable(rows) {
|
||||||
|
const body = document.getElementById('recent-tbody');
|
||||||
|
clearChildren(body);
|
||||||
|
const limited = rows.slice(0, 200);
|
||||||
|
for (const r of limited) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
appendCell(tr, fmtTimestamp(r.Timestamp));
|
||||||
|
appendCell(tr, String(r.Node || ''));
|
||||||
|
appendCell(tr, String(r.Country || ''));
|
||||||
|
appendCell(tr, fmtNumber(r.DlMbps, 1));
|
||||||
|
appendCell(tr, fmtNumber(r.UlMbps, 1));
|
||||||
|
appendCell(tr, fmtNumber(r.PingMs, 1));
|
||||||
|
appendCell(tr, fmtNumber(r.JitterMs, 1));
|
||||||
|
body.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSnapshot() {
|
||||||
|
let snapshot;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/data/snapshot.json', { cache: 'no-store' });
|
||||||
|
snapshot = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('ts').textContent = 'load error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const st = snapshot.speedtest;
|
||||||
|
if (!st) {
|
||||||
|
document.getElementById('ts').textContent = 'no data';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('ts').textContent = fmtTimestamp(st.generated_at);
|
||||||
|
renderAggTable(st);
|
||||||
|
|
||||||
|
const recent = Array.isArray(st.recent) ? st.recent : [];
|
||||||
|
const filterNode = document.getElementById('filter-node');
|
||||||
|
const filterCountry = document.getElementById('filter-country');
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const fn = filterNode.value;
|
||||||
|
const fc = filterCountry.value.toUpperCase();
|
||||||
|
const filtered = recent.filter(row =>
|
||||||
|
(!fn || row.Node === fn) &&
|
||||||
|
(!fc || row.Country === fc));
|
||||||
|
renderRecentTable(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
filterNode.addEventListener('change', applyFilters);
|
||||||
|
filterCountry.addEventListener('input', applyFilters);
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSnapshot();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.speedtest table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||||
|
.speedtest th, .speedtest td { padding: 0.4rem 0.7rem; border-bottom: 1px solid #333; text-align: left; }
|
||||||
|
.speedtest .muted { opacity: 0.6; font-size: 0.9rem; }
|
||||||
|
.speedtest .filters { margin: 1rem 0; }
|
||||||
|
</style>
|
||||||
|
{{ end }}
|
||||||
Reference in New Issue
Block a user