Live page: the speed of every peer right now

A new Live page shows current download and upload per peer, updated
every 2 seconds, with the last 2 minutes as a chart and a small chart
per peer. The server reads the WireGuard counters every 2 seconds and
keeps 2 minutes in memory; GET /api/v1/live serves them, with since=
for only the newer steps. The dev simulator now adds traffic in
proportion to the time between samples.
This commit is contained in:
Daniel Redetzke
2026-10-05 14:28:29 +03:00
parent c78824b0e9
commit e01b73d677
8 changed files with 364 additions and 10 deletions
+7
View File
@@ -28,6 +28,8 @@ dependencies on the server: the binary installs, updates and removes itself.
server has a global IPv6 address. server has a global IPv6 address.
- **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for - **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for
400 days by default (Settings → Logs & history). 400 days by default (Settings → Logs & history).
- **Live view:** the speed of every peer right now, updated every 2 seconds,
with the last 2 minutes as a chart. Kept in memory only.
- **Connection history:** every online session per peer, with start, duration, - **Connection history:** every online session per peer, with start, duration,
address and traffic. A new session starts when a device changes networks. address and traffic. A new session starts when a device changes networks.
Country and network operator come from the free Country and network operator come from the free
@@ -264,6 +266,7 @@ signed in: GET /auth/mfa · POST /auth/mfa/totp/setup · /auth/mfa/totp/confirm
signed in: POST /auth/mfa/keys/begin · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id} signed in: POST /auth/mfa/keys/begin · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
signed in: POST /auth/mfa/recovery-codes signed in: POST /auth/mfa/recovery-codes
GET /status GET /stats?range=24h|7d|30d|90d GET /status GET /stats?range=24h|7d|30d|90d
GET /live?since= (speed per peer, last 2 minutes in 2-second steps)
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
GET /peers POST /peers (returns the config and QR once) GET /peers POST /peers (returns the config and QR once)
GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id} GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id}
@@ -293,6 +296,10 @@ the newer version while there is one.
Traffic is reported from the peer's point of view: `down` is what the peer Traffic is reported from the peer's point of view: `down` is what the peer
downloaded, `up` is what it uploaded. downloaded, `up` is what it uploaded.
`GET /live` answers `{"step": 2, "size": 60, "points": [{"t": …, "peers":
{"<id>": [down, up]}}]}` with speeds in bits per second, kept only in memory.
With `since` (unix seconds) it returns only newer steps.
Latency is measured by pinging the peer's tunnel address every 30 seconds. Set Latency is measured by pinging the peer's tunnel address every 30 seconds. Set
it per peer with `PATCH /peers/{id}` `{"latencyCheck": "off"|"active"|"always"}` it per peer with `PATCH /peers/{id}` `{"latencyCheck": "off"|"active"|"always"}`
(default `off`). `active` pings only while the device sends traffic, so idle (default `off`). `active` pings only while the device sends traffic, so idle
+14
View File
@@ -21,6 +21,7 @@ type App struct {
kernel Kernel kernel Kernel
recon *Reconciler recon *Reconciler
stats *Stats stats *Stats
speeds *Speeds // nil in tests
auth *Auth auth *Auth
tls *webTLS tls *webTLS
logPath string logPath string
@@ -144,6 +145,7 @@ func (a *App) routes() http.Handler {
g("GET /api/v1/status", a.status) g("GET /api/v1/status", a.status)
g("GET /api/v1/stats", a.allStats) g("GET /api/v1/stats", a.allStats)
g("GET /api/v1/live", a.liveSpeeds)
g("GET /api/v1/server", a.getServer) g("GET /api/v1/server", a.getServer)
g("PATCH /api/v1/server", a.patchServer) g("PATCH /api/v1/server", a.patchServer)
@@ -398,6 +400,18 @@ func (a *App) allStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"range": rng, "points": a.stats.series(nil, rng)}) writeJSON(w, http.StatusOK, map[string]any{"range": rng, "points": a.stats.series(nil, rng)})
} }
// liveSpeeds returns each peer's speed over the last 2 minutes; with since
// (unix seconds) only the newer steps.
func (a *App) liveSpeeds(w http.ResponseWriter, r *http.Request) {
var since int64
fmt.Sscan(r.URL.Query().Get("since"), &since)
points := []SpeedPoint{}
if a.speeds != nil {
points = a.speeds.Since(since)
}
writeJSON(w, http.StatusOK, map[string]any{"step": int(speedStep / time.Second), "size": speedPoints, "points": points})
}
func (a *App) peerStats(w http.ResponseWriter, r *http.Request) { func (a *App) peerStats(w http.ResponseWriter, r *http.Request) {
cfg := a.store.Get() cfg := a.store.Get()
if _, p := cfg.peerByID(r.PathValue("id")); p == nil { if _, p := cfg.peerByID(r.PathValue("id")); p == nil {
+23
View File
@@ -381,3 +381,26 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.card h3 { margin: 0; font-size: 16px; font-weight: 600; } .card h3 { margin: 0; font-size: 16px; font-weight: 600; }
.saves { font-size: 12px; color: var(--ink-3); } .saves { font-size: 12px; color: var(--ink-3); }
pre.log.tall { max-height: calc(100vh - 260px); min-height: 420px; } pre.log.tall { max-height: calc(100vh - 260px); min-height: 420px; }
/* live */
.livenow { display: flex; flex-wrap: wrap; gap: 12px 40px; margin-top: 14px; }
.livenow .k { font-size: 13px; color: var(--ink-2); }
.livenow .v { font-size: 30px; font-weight: 600; letter-spacing: -0.01em; margin-top: 4px; font-variant-numeric: tabular-nums; }
.livenow .v small { font-size: 16px; color: var(--ink-3); font-weight: 500; margin-left: 6px; }
.chart .larea { fill: var(--down); opacity: .15; }
.chart .ldown, .chart .lup { fill: none; stroke-width: 1.75; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
.chart .ldown { stroke: var(--down); }
.chart .lup { stroke: var(--up); }
.xaxis.lx span:first-child { transform: none; }
.xaxis.lx span:last-child { transform: translateX(-100%); }
.spark.wide { width: 96px; }
.spark .fill { fill: var(--down); opacity: .15; stroke: none; }
tr.idle td { color: var(--ink-3); }
tr.idle .spark polyline { stroke: var(--ink-3); }
tr.idle .spark .fill { fill: var(--ink-3); }
td .mono, td.num .mono { font-variant-numeric: tabular-nums; }
.liveoff { margin: 0; padding: 12px; font-size: 13px; color: var(--ink-3); }
.livesub { display: flex; align-items: center; gap: 6px; }
.dot.pulse { animation: pulse 2s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) { .dot.pulse { animation: none; } }
+145 -1
View File
@@ -47,6 +47,7 @@
peers: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.5 3.4-5.5 6.5-5.5s5.7 2 6.5 5.5"/><path d="M16 4.8a3.5 3.5 0 0 1 0 6.4M18.5 14.8c1.5.8 2.6 2.6 3 5.2"/>', peers: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.5 3.4-5.5 6.5-5.5s5.7 2 6.5 5.5"/><path d="M16 4.8a3.5 3.5 0 0 1 0 6.4M18.5 14.8c1.5.8 2.6 2.6 3 5.2"/>',
server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>', server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>',
settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>', settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
live: '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
plus: '<path d="M12 5v14M5 12h14"/>', plus: '<path d="M12 5v14M5 12h14"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>', key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
log: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/>', log: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/>',
@@ -555,7 +556,7 @@
// ---------- shell, router ---------- // ---------- shell, router ----------
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/log', 'log', 'Log'], ['#/settings', 'settings', 'Settings']]; const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/live', 'live', 'Live'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/log', 'log', 'Log'], ['#/settings', 'settings', 'Settings']];
let navLinks = {}; let navLinks = {};
let srvBox, peerCount, verRow; let srvBox, peerCount, verRow;
@@ -617,6 +618,7 @@
const ROUTES = [ const ROUTES = [
[/^#\/?$/, '#/', viewDashboard], [/^#\/?$/, '#/', viewDashboard],
[/^#\/live$/, '#/live', viewLive],
[/^#\/peers$/, '#/peers', viewPeers], [/^#\/peers$/, '#/peers', viewPeers],
[/^#\/peers\/new$/, '#/peers', viewPeerNew], [/^#\/peers\/new$/, '#/peers', viewPeerNew],
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer], [/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
@@ -1103,6 +1105,148 @@
every(30000, () => draw().catch(() => {})); every(30000, () => draw().catch(() => {}));
} }
// ---------- live ----------
// fmtRate formats a speed in bits per second.
function fmtRate(bps) {
const u = ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s'];
let i = 0, v = bps;
while (v >= 1000 && i < u.length - 1) { v /= 1000; i++; }
const s = i === 0 ? String(Math.round(v)) : v < 10 ? v.toFixed(1) : String(Math.round(v));
return s + ' ' + u[i];
}
// Below this a peer counts as idle: keepalives and background chatter.
const IDLE_BPS = 2000;
// liveChart draws download as a filled area and upload as a line over the
// last points; hover shows the values at a moment.
function liveChart(points) {
const n = points.length, W = 1000, H = 100;
const top = niceTop(Math.max(0, ...points.map((p) => Math.max(p.down, p.up))) || 1e6);
const x = (i) => (n < 2 ? W : i / (n - 1) * W).toFixed(1), y = (v) => (H - v / top * H).toFixed(2);
const line = (k) => points.map((p, i) => x(i) + ',' + y(p[k])).join(' ');
const cursor = svg('line', { class: 'cursor', x1: 0, x2: 0, y1: 0, y2: H, visibility: 'hidden' });
const plot = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, preserveAspectRatio: 'none', 'aria-hidden': 'true' },
n > 1 ? [
svg('polygon', { class: 'larea', points: '0,' + H + ' ' + line('down') + ' ' + W + ',' + H }),
svg('polyline', { class: 'ldown', points: line('down') }),
svg('polyline', { class: 'lup', points: line('up') }),
] : null, cursor);
const at = (p) => new Date(p.t * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const peak = points.reduce((m, p) => p.down + p.up > m.down + m.up ? p : m, { down: 0, up: 0 });
const idle = () => peak.t
? ['Peak ', h('strong', null, fmtRate(peak.down + peak.up)), ' at ' + at(peak) + ' · hover the chart to see a moment.']
: ['No traffic in the last 2 minutes.'];
const readout = h('div', { class: 'readout' }, idle());
const wrapEl = h('div', { class: 'plot', role: 'img', 'aria-label': 'Speed of all peers over the last 2 minutes',
onMousemove: (e) => {
const r = wrapEl.getBoundingClientRect();
const i = Math.max(0, Math.min(n - 1, Math.round((e.clientX - r.left) / r.width * (n - 1))));
const p = points[i];
cursor.setAttribute('x1', x(i)); cursor.setAttribute('x2', x(i)); cursor.setAttribute('visibility', 'visible');
readout.replaceChildren(at(p), ' · Download ', h('strong', null, fmtRate(p.down)), ' · Upload ', h('strong', null, fmtRate(p.up)));
},
onMouseleave: () => { cursor.setAttribute('visibility', 'hidden'); readout.replaceChildren(...idle()); } }, plot);
return h('div', null,
readout,
h('div', { class: 'chart small' },
h('div', { class: 'gl top' }), h('div', { class: 'gl mid' }), h('div', { class: 'gl base' }),
h('div', { class: 'yl top' }, fmtRate(top)), h('div', { class: 'yl mid' }, fmtRate(top / 2)),
wrapEl),
h('div', { class: 'xaxis lx' }, h('span', { style: { left: '0%' } }, '2 min ago'), h('span', { style: { left: '50%' } }, '1 min ago'), h('span', { style: { left: '100%' } }, 'now')));
}
// rateSpark draws a peer's total speed over the same window, scaled to its
// own peak.
function rateSpark(vals) {
const s = svg('svg', { class: 'spark wide', viewBox: '0 0 96 18', preserveAspectRatio: 'none', 'aria-hidden': 'true' });
const top = Math.max(...vals, IDLE_BPS * 4);
const n = vals.length;
if (n < 2) return s;
const pts = vals.map((v, i) => (i / (n - 1) * 96).toFixed(1) + ',' + (17 - v / top * 15).toFixed(1));
s.append(svg('polygon', { class: 'fill', points: '0,18 ' + pts.join(' ') + ' 96,18' }), svg('polyline', { points: pts.join(' ') }));
return s;
}
// viewLive shows the speed of every peer right now, from the server's
// short in-memory history (the last 2 minutes in 2-second steps).
async function viewLive(wrap) {
let peers = (await api('GET', '/peers')).peers;
let live = await api('GET', '/live');
let paused = false;
const totals = h('div', { class: 'livenow' });
const chartBox = h('div');
const rows = h('div');
const pauseBtn = h('button', { type: 'button', class: 'btn', onClick: () => {
paused = !paused;
pauseBtn.textContent = paused ? 'Resume' : 'Pause';
sub.replaceChildren(...subText());
} }, 'Pause');
const subText = () => paused
? ['Paused · the chart keeps the moment you paused']
: [h('span', { class: 'dot ok pulse' }), ' Updated every ' + live.step + ' s · speeds are averages over the step'];
const sub = h('p', { class: 'sub livesub' }, subText());
const draw = () => {
const pts = live.points;
const sumAt = (p) => Object.values(p.peers).reduce((a, [d, u]) => ({ down: a.down + d, up: a.up + u }), { down: 0, up: 0 });
const series = pts.map((p) => ({ t: p.t, ...sumAt(p) }));
const now = series[series.length - 1] || { down: 0, up: 0 };
totals.replaceChildren(
h('div', null, h('div', { class: 'k' }, h('span', { class: 'key down' }), 'Download'), h('div', { class: 'v' }, fmtRate(now.down))),
h('div', null, h('div', { class: 'k' }, h('span', { class: 'key up' }), 'Upload'), h('div', { class: 'v' }, fmtRate(now.up))),
h('div', null, h('div', { class: 'k' }, 'Active peers'), h('div', { class: 'v' }, String(peers.filter((p) => {
const r = pts.length ? pts[pts.length - 1].peers[p.id] : null;
return r && r[0] + r[1] >= IDLE_BPS;
}).length), h('small', null, '/ ' + peers.filter((p) => p.stats.online).length + ' online'))));
chartBox.replaceChildren(liveChart(series));
const last = pts.length ? pts[pts.length - 1].peers : {};
const online = peers.filter((p) => p.stats.online)
.map((p) => ({ p, r: last[p.id] || [0, 0], hist: pts.map((x) => { const v = x.peers[p.id]; return v ? v[0] + v[1] : 0; }) }))
.sort((a, b) => (b.r[0] + b.r[1]) - (a.r[0] + a.r[1]) || a.p.name.localeCompare(b.p.name));
const offline = peers.filter((p) => !p.stats.online);
const rate = (v, idle) => idle ? h('span', { class: 'muted' }, '–') : h('span', { class: 'mono' }, fmtRate(v));
rows.replaceChildren(
online.length ? h('div', { class: 'tbl' }, h('table', { class: 'narrow' },
h('thead', null, h('tr', null, h('th', null, 'Peer'), h('th', null, 'Last 2 minutes'), h('th', { class: 'num' }, 'Download'), h('th', { class: 'num' }, 'Upload'), h('th', null, 'Endpoint'))),
h('tbody', null, online.map(({ p, r, hist }) => {
const idle = r[0] + r[1] < IDLE_BPS;
return h('tr', { class: idle ? 'idle' : null },
h('td', null, h('a', { href: '#/peers/' + p.id }, p.name), idle ? h('span', { class: 'tag plain' }, 'idle') : null),
h('td', null, rateSpark(hist)),
h('td', { class: 'num' }, rate(r[0], idle)),
h('td', { class: 'num' }, rate(r[1], idle)),
h('td', null, h('span', { class: 'mono' }, p.stats.endpoint ? p.stats.endpoint.replace(/:\d+$/, '') : '–'),
p.stats.location && p.stats.location.country ? h('span', { class: 'cc', title: fmtLocation(p.stats.location) }, p.stats.location.country) : null));
})))) : h('p', { class: 'empty' }, 'No peer is online.'),
offline.length ? h('p', { class: 'liveoff' }, 'Offline: ', offline.map((p, i) => [i ? ', ' : '', h('a', { href: '#/peers/' + p.id }, p.name)])) : null);
};
fill(wrap,
h('div', { class: 'head' },
h('div', null, h('h1', null, 'Live'), sub),
h('div', { class: 'actions' }, pauseBtn)),
h('section', { class: 'card', 'aria-labelledby': 'lv' },
h('div', { class: 'cardhead' }, h('h2', { id: 'lv' }, 'All peers · right now')),
totals, chartBox),
h('section', { class: 'card flush', 'aria-labelledby': 'lp' },
h('div', { class: 'cardhead' }, h('h2', { id: 'lp' }, 'Peers'), h('span', { class: 'hint' }, 'Busiest first')),
rows));
draw();
every(2000, async () => {
if (paused) return;
try {
const more = await api('GET', '/live?since=' + (live.points.length ? live.points[live.points.length - 1].t : 0));
live = { step: more.step, points: live.points.concat(more.points).slice(-more.size) };
draw();
} catch { /* keep last */ }
});
every(15000, async () => { try { peers = (await api('GET', '/peers')).peers; } catch { /* keep last */ } });
}
function describeAudit(l) { function describeAudit(l) {
const parts = [l.msg.charAt(0).toUpperCase() + l.msg.slice(1)]; const parts = [l.msg.charAt(0).toUpperCase() + l.msg.slice(1)];
if (l.peer) parts.push(': ' + l.peer); if (l.peer) parts.push(': ' + l.peer);
+16 -7
View File
@@ -5,8 +5,10 @@ package main
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"maps"
"math/rand/v2" "math/rand/v2"
"net/netip" "net/netip"
"slices"
"sync" "sync"
"time" "time"
) )
@@ -17,6 +19,7 @@ import (
type simKernel struct { type simKernel struct {
mu sync.Mutex mu sync.Mutex
peers map[string]*PeerSample peers map[string]*PeerSample
last time.Time // previous Sample; traffic grows with the time since
} }
func newKernel() (Kernel, error) { func newKernel() (Kernel, error) {
@@ -53,17 +56,23 @@ func (k *simKernel) Apply(c *Config) error {
func (k *simKernel) Sample(string) ([]PeerSample, error) { func (k *simKernel) Sample(string) ([]PeerSample, error) {
k.mu.Lock() k.mu.Lock()
defer k.mu.Unlock() defer k.mu.Unlock()
now := time.Now()
f := 1.0
if !k.last.IsZero() {
f = now.Sub(k.last).Seconds() / 30
}
k.last = now
var out []PeerSample var out []PeerSample
i := 0 for i, key := range slices.Sorted(maps.Keys(k.peers)) {
for _, p := range k.peers { p := k.peers[key]
// Every third peer stays idle; the others move some data. // Every third peer stays idle; the others move some data, scaled to
// the time since the previous sample.
if i%3 != 2 { if i%3 != 2 {
p.TxBytes += rand.Int64N(40 << 20) p.TxBytes += int64(float64(rand.Int64N(40<<20)) * f)
p.RxBytes += rand.Int64N(6 << 20) p.RxBytes += int64(float64(rand.Int64N(6<<20)) * f)
p.LastHandshake = time.Now().Add(-time.Duration(rand.IntN(90)) * time.Second) p.LastHandshake = now.Add(-time.Duration(rand.IntN(90)) * time.Second)
} }
out = append(out, *p) out = append(out, *p)
i++
} }
return out, nil return out, nil
} }
+4 -2
View File
@@ -219,16 +219,18 @@ func run(configPath string) error {
var stopOnce sync.Once var stopOnce sync.Once
shutdown := func() { stopOnce.Do(func() { close(stop) }) } shutdown := func() { stopOnce.Do(func() { close(stop) }) }
speeds := newSpeeds(store, kernel)
auth := newAuth(store) auth := newAuth(store)
app := &App{ app := &App{
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS, store: store, kernel: kernel, recon: recon, stats: stats, speeds: speeds, auth: auth, tls: webTLS,
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown, logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
} }
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(5) wg.Add(6)
go func() { defer wg.Done(); recon.Run(stop) }() go func() { defer wg.Done(); recon.Run(stop) }()
go func() { defer wg.Done(); stats.Run(stop) }() go func() { defer wg.Done(); stats.Run(stop) }()
go func() { defer wg.Done(); speeds.Run(stop) }()
go func() { defer wg.Done(); stats.RunPings(stop) }() go func() { defer wg.Done(); stats.RunPings(stop) }()
go func() { defer wg.Done(); geo.Run(stop) }() go func() { defer wg.Done(); geo.Run(stop) }()
go func() { defer wg.Done(); app.updates.Run(stop) }() go func() { defer wg.Done(); app.updates.Run(stop) }()
+48
View File
@@ -1286,3 +1286,51 @@ func TestDropSecurityKeys(t *testing.T) {
t.Fatal("security key still in config.json") t.Fatal("security key still in config.json")
} }
} }
func TestSpeeds(t *testing.T) {
dir := t.TempDir()
store, err := openStore(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
key, _ := newPrivateKey()
pub := key.PublicKey().String()
if err := store.Update(func(c *Config) error {
c.Peers = append(c.Peers, Peer{ID: "p1", Name: "phone", IPv4: serverIPv4(netip.MustParsePrefix(c.Server.IPv4)).Next().String(), PublicKey: pub, Enabled: true})
return nil
}); err != nil {
t.Fatal(err)
}
k := &fakeKernel{}
sp := newSpeeds(store, k)
t0 := time.Unix(1_800_000_000, 0)
step := func(sec int, rx, tx int64) {
k.samples = []PeerSample{{PublicKey: pub, RxBytes: rx, TxBytes: tx}}
sp.sample(t0.Add(time.Duration(sec) * time.Second))
}
step(0, 1000, 1000)
if n := len(sp.Since(0)); n != 0 {
t.Fatalf("first sample made %d points, want 0", n)
}
step(2, 1250, 3000) // +250 up, +2000 down in 2 s
step(4, 10, 20) // counter reset: no speed for this step
pts := sp.Since(0)
if len(pts) != 2 {
t.Fatalf("got %d points, want 2", len(pts))
}
if got, want := pts[0].Peers["p1"], [2]int64{8000, 1000}; got != want {
t.Fatalf("speed = %v, want %v (down, up in bit/s)", got, want)
}
if _, ok := pts[1].Peers["p1"]; ok {
t.Fatal("a counter reset reported a speed")
}
if got := sp.Since(pts[0].T); len(got) != 1 || got[0].T != pts[1].T {
t.Fatalf("Since returned %v", got)
}
for i := 0; i < speedPoints+5; i++ {
step(6+2*i, 0, 0)
}
if n := len(sp.Since(0)); n != speedPoints {
t.Fatalf("kept %d points, want %d", n, speedPoints)
}
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"log/slog"
"sync"
"time"
)
// Speeds keeps the last few minutes of each peer's speed in memory for the
// Live page. It reads the kernel counters every speedStep, apart from the
// traffic history in Stats, and never writes to disk.
const (
speedStep = 2 * time.Second
speedPoints = 60 // 2 minutes
)
// SpeedPoint is one step: per peer ID, download and upload in bits per
// second, from the peer's point of view.
type SpeedPoint struct {
T int64 `json:"t"`
Peers map[string][2]int64 `json:"peers"`
}
type Speeds struct {
store *Store
kernel Kernel
mu sync.Mutex
last map[string][2]int64 // raw rx, tx by public key
lastAt time.Time
points []SpeedPoint
}
func newSpeeds(store *Store, kernel Kernel) *Speeds {
return &Speeds{store: store, kernel: kernel, last: map[string][2]int64{}}
}
func (s *Speeds) sample(now time.Time) {
cfg := s.store.Get()
samples, err := s.kernel.Sample(cfg.Server.Interface)
if err != nil {
slog.Debug("speed sample failed", "err", err)
return
}
idByKey := map[string]string{}
for _, p := range cfg.Peers {
if p.hasKey() {
idByKey[p.PublicKey] = p.ID
}
}
s.mu.Lock()
defer s.mu.Unlock()
secs := now.Sub(s.lastAt).Seconds()
first := s.lastAt.IsZero()
cur := map[string][2]int64{}
pt := SpeedPoint{T: now.Unix(), Peers: map[string][2]int64{}}
for _, smp := range samples {
cur[smp.PublicKey] = [2]int64{smp.RxBytes, smp.TxBytes}
id := idByKey[smp.PublicKey]
prev, ok := s.last[smp.PublicKey]
if id == "" || !ok || first {
continue
}
dRx, dTx := smp.RxBytes-prev[0], smp.TxBytes-prev[1]
if dRx < 0 || dTx < 0 { // counters were reset
continue
}
// Tx is what the server sent: the peer's download.
pt.Peers[id] = [2]int64{int64(float64(dTx*8) / secs), int64(float64(dRx*8) / secs)}
}
s.last, s.lastAt = cur, now
if first {
return
}
s.points = append(s.points, pt)
if len(s.points) > speedPoints {
s.points = s.points[len(s.points)-speedPoints:]
}
}
// Since returns the points newer than the unix time t, oldest first.
func (s *Speeds) Since(t int64) []SpeedPoint {
s.mu.Lock()
defer s.mu.Unlock()
out := []SpeedPoint{}
for _, p := range s.points {
if p.T > t {
out = append(out, p)
}
}
return out
}
func (s *Speeds) Run(stop <-chan struct{}) {
s.sample(time.Now())
t := time.NewTicker(speedStep)
defer t.Stop()
for {
select {
case <-stop:
return
case now := <-t.C:
s.sample(now)
}
}
}