Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 813c3e8bd7 | |||
| 8d97f6c61e | |||
| dbd1808803 | |||
| 5850f3976e | |||
| e01b73d677 | |||
| c78824b0e9 | |||
| 37a6e3cda8 | |||
| beb02a5ca2 | |||
| dec89d003c |
@@ -28,6 +28,8 @@ dependencies on the server: the binary installs, updates and removes itself.
|
||||
server has a global IPv6 address.
|
||||
- **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for
|
||||
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,
|
||||
address and traffic. A new session starts when a device changes networks.
|
||||
Country and network operator come from the free
|
||||
@@ -264,6 +266,8 @@ 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/recovery-codes
|
||||
GET /status GET /stats?range=24h|7d|30d|90d
|
||||
GET /live?since= (speed per peer, last 2 minutes in 2-second steps)
|
||||
GET /live/stream (the same as server-sent events)
|
||||
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
|
||||
GET /peers POST /peers (returns the config and QR once)
|
||||
GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id}
|
||||
@@ -293,6 +297,12 @@ the newer version while there is one.
|
||||
Traffic is reported from the peer's point of view: `down` is what the peer
|
||||
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. `GET /live/stream`
|
||||
sends the same messages as server-sent events: the history first, then one
|
||||
message per new step.
|
||||
|
||||
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"}`
|
||||
(default `off`). `active` pings only while the device sends traffic, so idle
|
||||
|
||||
@@ -21,6 +21,7 @@ type App struct {
|
||||
kernel Kernel
|
||||
recon *Reconciler
|
||||
stats *Stats
|
||||
speeds *Speeds // nil in tests
|
||||
auth *Auth
|
||||
tls *webTLS
|
||||
logPath string
|
||||
@@ -144,6 +145,8 @@ func (a *App) routes() http.Handler {
|
||||
|
||||
g("GET /api/v1/status", a.status)
|
||||
g("GET /api/v1/stats", a.allStats)
|
||||
g("GET /api/v1/live", a.liveSpeeds)
|
||||
g("GET /api/v1/live/stream", a.liveStream)
|
||||
|
||||
g("GET /api/v1/server", a.getServer)
|
||||
g("PATCH /api/v1/server", a.patchServer)
|
||||
@@ -398,6 +401,60 @@ 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)})
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
// liveStream sends the same data as server-sent events: the current points
|
||||
// first, then each new step as soon as it is sampled. The session is checked
|
||||
// again with every step, so signing out ends the stream.
|
||||
func (a *App) liveStream(w http.ResponseWriter, r *http.Request) {
|
||||
if a.speeds == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "live speeds are not available"})
|
||||
return
|
||||
}
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.SetWriteDeadline(time.Time{}) // the server's write timeout would cut the stream
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // nginx: do not buffer
|
||||
points, ch, cancel := a.speeds.Subscribe()
|
||||
defer cancel()
|
||||
send := func(points []SpeedPoint) bool {
|
||||
b, _ := json.Marshal(map[string]any{"step": int(speedStep / time.Second), "size": speedPoints, "points": points})
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
|
||||
return false
|
||||
}
|
||||
return rc.Flush() == nil
|
||||
}
|
||||
if !send(points) {
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-a.speeds.Done():
|
||||
return
|
||||
case pt := <-ch:
|
||||
if _, ok := a.auth.Authenticate(r); !ok {
|
||||
return
|
||||
}
|
||||
if !send([]SpeedPoint{pt}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) peerStats(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.store.Get()
|
||||
if _, p := cfg.peerByID(r.PathValue("id")); p == nil {
|
||||
|
||||
@@ -254,7 +254,10 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
|
||||
.chart .grp span.up { background: var(--up); }
|
||||
.chart .grp span.total { background: var(--down); }
|
||||
.chart .grp.on span.total { background: var(--down-strong); }
|
||||
.xaxis { display: flex; justify-content: space-between; margin: 8px 0 0 56px; font-size: 11px; color: var(--axis-ink); }
|
||||
.xaxis { position: relative; height: 22px; margin-left: 56px; font-size: 11px; color: var(--axis-ink); }
|
||||
.xaxis span { position: absolute; top: 0; padding-top: 7px; transform: translateX(-50%); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.xaxis span.edge::before { content: ''; position: absolute; left: 50%; top: 0; height: 4px; border-left: 1px solid var(--axis); }
|
||||
@media (max-width: 640px) { .xaxis span.minor { display: none; } }
|
||||
.chart.loading { opacity: .5; }
|
||||
.chart .plot { position: absolute; left: 56px; right: 0; top: 0; bottom: 1px; }
|
||||
.chart .plot svg { width: 100%; height: 100%; display: block; overflow: visible; }
|
||||
@@ -378,3 +381,26 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
||||
.card h3 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.saves { font-size: 12px; color: var(--ink-3); }
|
||||
pre.log.tall { max-height: calc(100vh - 260px); min-height: 420px; }
|
||||
|
||||
/* live */
|
||||
.livenow { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px 24px; 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); }
|
||||
.chart .plot.live svg { overflow: hidden; }
|
||||
.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; }
|
||||
.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; } }
|
||||
|
||||
@@ -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"/>',
|
||||
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"/>',
|
||||
live: '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
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"/>',
|
||||
@@ -384,8 +385,8 @@
|
||||
function pointLabel(t, range) {
|
||||
const d = new Date(t * 1000);
|
||||
if (range === '24h') {
|
||||
const hrs = Math.round((Date.now() - d.getTime()) / 3600000);
|
||||
return hrs <= 0 ? 'This hour' : hrs + ' h ago';
|
||||
const hm = (x) => x.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return hm(d) + '–' + hm(new Date(d.getTime() + 3600000));
|
||||
}
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' });
|
||||
}
|
||||
@@ -430,9 +431,35 @@
|
||||
h('div', { class: 'gl top' }), h('div', { class: 'gl mid' }), h('div', { class: 'gl base' }),
|
||||
h('div', { class: 'yl top' }, fmtBytes(top)), h('div', { class: 'yl mid' }, fmtBytes(top / 2)),
|
||||
bars),
|
||||
h('div', { class: 'xaxis' },
|
||||
h('span', null, pointLabel(points[0].t, range)),
|
||||
h('span', null, range === '24h' ? 'now' : pointLabel(points[points.length - 1].t, range))));
|
||||
timeAxis(points.map((p) => p.t), range === '24h' ? 3600 : 86400));
|
||||
}
|
||||
|
||||
// timeAxis labels the bottom of a chart with clock times or dates. Points
|
||||
// are evenly spaced buckets of step seconds. Hourly buckets get the hour
|
||||
// they start at, every 3 hours (6 on narrow screens), with the date at
|
||||
// midnight; daily buckets get the date under the bar, every bar for a
|
||||
// week and every 7th bar, counted back from today, for a month.
|
||||
function timeAxis(ts, step) {
|
||||
const n = ts.length;
|
||||
const ticks = [];
|
||||
if (step < 86400) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = new Date(ts[i] * 1000);
|
||||
if (d.getMinutes() !== 0 || d.getHours() % 3 !== 0 || i === 0) continue;
|
||||
const text = d.getHours() === 0
|
||||
? d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric' })
|
||||
: d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
ticks.push({ at: i / n, text, minor: d.getHours() % 6 !== 0, edge: true });
|
||||
}
|
||||
} else {
|
||||
const every = n > 10 ? 7 : 1;
|
||||
for (let i = n - 1; i >= 0; i -= every) {
|
||||
const d = new Date(ts[i] * 1000);
|
||||
ticks.push({ at: (i + 0.5) / n, text: d.toLocaleDateString(undefined, n > 10 ? { day: 'numeric', month: 'short' } : { weekday: 'short', day: 'numeric' }) });
|
||||
}
|
||||
}
|
||||
return h('div', { class: 'xaxis', 'aria-hidden': 'true' },
|
||||
ticks.map((k) => h('span', { class: (k.minor ? 'minor' : '') + (k.edge ? ' edge' : ''), style: { left: (k.at * 100).toFixed(3) + '%' } }, k.text)));
|
||||
}
|
||||
|
||||
// latencyChart draws the median as a line over a min–max band, one point
|
||||
@@ -489,7 +516,7 @@
|
||||
h('div', { class: 'gl top' }), h('div', { class: 'gl mid' }), h('div', { class: 'gl base' }),
|
||||
h('div', { class: 'yl top' }, fmtMs(top)), h('div', { class: 'yl mid' }, fmtMs(top / 2)),
|
||||
wrapEl),
|
||||
h('div', { class: 'xaxis' }, h('span', null, '24 h ago'), h('span', null, '12 h ago'), h('span', null, 'now')));
|
||||
timeAxis(points.map((p) => p.t), 300));
|
||||
}
|
||||
|
||||
// pairDialog creates an API token and shows it once, with the pairing QR
|
||||
@@ -529,7 +556,7 @@
|
||||
|
||||
// ---------- 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 srvBox, peerCount, verRow;
|
||||
|
||||
@@ -591,6 +618,7 @@
|
||||
|
||||
const ROUTES = [
|
||||
[/^#\/?$/, '#/', viewDashboard],
|
||||
[/^#\/live$/, '#/live', viewLive],
|
||||
[/^#\/peers$/, '#/peers', viewPeers],
|
||||
[/^#\/peers\/new$/, '#/peers', viewPeerNew],
|
||||
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
|
||||
@@ -994,18 +1022,35 @@
|
||||
|
||||
// ---------- dashboard ----------
|
||||
|
||||
// RANGES are the time ranges offered above the traffic charts.
|
||||
const RANGES = [['24h', '24 h'], ['7d', '7 days'], ['30d', '30 days']];
|
||||
|
||||
async function viewDashboard(wrap) {
|
||||
let range = '24h';
|
||||
const draw = async () => {
|
||||
const [st, pl, stats, logs] = await Promise.all([
|
||||
api('GET', '/status'),
|
||||
api('GET', '/peers'),
|
||||
api('GET', '/stats?range=24h'),
|
||||
api('GET', '/stats?range=' + range),
|
||||
me.isAdmin ? api('GET', '/logs?audit=1&limit=6').catch(() => null) : null,
|
||||
]);
|
||||
const peers = pl.peers;
|
||||
const failing = st.checks.filter((c) => !c.ok);
|
||||
const ifCheck = st.checks.find((c) => c.name === 'WireGuard interface');
|
||||
const top = [...peers].sort((a, b) => (b.stats.down24h + b.stats.up24h) - (a.stats.down24h + a.stats.up24h)).slice(0, 6);
|
||||
// A range switch fetches and redraws only the chart.
|
||||
const traffic = h('div', null, chart(stats.points, range, 'total', true));
|
||||
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Time range' });
|
||||
const drawPills = () => pills.replaceChildren(...RANGES.map(([k, t]) =>
|
||||
h('button', { type: 'button', class: range === k ? 'pill on' : 'pill', 'aria-pressed': String(range === k), onClick: async () => {
|
||||
range = k;
|
||||
drawPills();
|
||||
try {
|
||||
const s = await api('GET', '/stats?range=' + k);
|
||||
if (range === k) traffic.replaceChildren(chart(s.points, k, 'total', true));
|
||||
} catch (x) { toast(x.message, true); }
|
||||
} }, t)));
|
||||
drawPills();
|
||||
|
||||
fill(wrap,
|
||||
h('div', { class: 'head' },
|
||||
@@ -1036,8 +1081,8 @@
|
||||
h('div', { class: 's' }, 'Service up ' + ago(st.started).replace(' ago', '') + ' · ' + (st.healthy ? 'all checks pass' : failing.length + ' check(s) failing')))),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'tput' },
|
||||
h('div', { class: 'cardhead' }, h('h2', { id: 'tput' }, 'Traffic, all peers · last 24 hours')),
|
||||
chart(stats.points, '24h', 'total', true)),
|
||||
h('div', { class: 'cardhead' }, h('h2', { id: 'tput' }, 'Traffic, all peers'), pills),
|
||||
traffic),
|
||||
|
||||
h('div', { class: 'cols' },
|
||||
h('section', { class: 'card flush' },
|
||||
@@ -1060,6 +1105,217 @@
|
||||
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;
|
||||
|
||||
// The figures and the table average the last few steps so they do not
|
||||
// swing with every burst; the charts show each step.
|
||||
const AVG_STEPS = 5;
|
||||
|
||||
const calm = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// liveChart draws download as a filled area and upload as a line. It is
|
||||
// built once and updated in place: each new step enters just beyond the
|
||||
// right edge and the chart slides left by one step over the step's length,
|
||||
// so it moves steadily instead of jumping. Hover shows the values at a
|
||||
// moment.
|
||||
function liveChart() {
|
||||
const W = 1000, H = 100;
|
||||
let pts = [], size = 60, step = 2, off = 0, dx = W / 59, t0 = 0, moving = false;
|
||||
const area = svg('polygon', { class: 'larea' });
|
||||
const down = svg('polyline', { class: 'ldown' });
|
||||
const up = svg('polyline', { class: 'lup' });
|
||||
const cursor = svg('line', { class: 'cursor', x1: 0, x2: 0, y1: 0, y2: H, visibility: 'hidden' });
|
||||
const g = svg('g', null, area, down, up, cursor);
|
||||
const plot = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, preserveAspectRatio: 'none', 'aria-hidden': 'true' }, g);
|
||||
const ylTop = h('div', { class: 'yl top' }), ylMid = h('div', { class: 'yl mid' });
|
||||
const at = (p) => new Date(p.t * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const idle = () => {
|
||||
const peak = pts.reduce((m, p) => p.down + p.up > m.down + m.up ? p : m, { down: 0, up: 0 });
|
||||
return 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' });
|
||||
let hoverT = null;
|
||||
const x = (i) => off + i * dx;
|
||||
const show = (i) => {
|
||||
const p = pts[i];
|
||||
hoverT = p.t;
|
||||
cursor.setAttribute('x1', x(i).toFixed(1)); cursor.setAttribute('x2', x(i).toFixed(1)); cursor.setAttribute('visibility', 'visible');
|
||||
readout.replaceChildren(at(p), ' · Download ', h('strong', null, fmtRate(p.down)), ' · Upload ', h('strong', null, fmtRate(p.up)));
|
||||
};
|
||||
const plotEl = h('div', { class: 'plot live', role: 'img', 'aria-label': 'Speed of all peers over the last 2 minutes',
|
||||
onMousemove: (e) => {
|
||||
if (!pts.length) return;
|
||||
const r = plotEl.getBoundingClientRect();
|
||||
const shift = moving ? Math.min(1, (performance.now() - t0) / (step * 1000)) * dx : 0;
|
||||
const xv = (e.clientX - r.left) / r.width * W + shift;
|
||||
show(Math.max(0, Math.min(pts.length - 1, Math.round((xv - off) / dx))));
|
||||
},
|
||||
onMouseleave: () => { hoverT = null; cursor.setAttribute('visibility', 'hidden'); readout.replaceChildren(...idle()); } }, plot);
|
||||
const el = h('div', null,
|
||||
readout,
|
||||
h('div', { class: 'chart small' },
|
||||
h('div', { class: 'gl top' }), h('div', { class: 'gl mid' }), h('div', { class: 'gl base' }),
|
||||
ylTop, ylMid, plotEl),
|
||||
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')));
|
||||
|
||||
// update draws points; slide is true for a new step arriving live.
|
||||
const update = (points, sz, st, slide) => {
|
||||
pts = points; size = sz; step = st; dx = W / (size - 1);
|
||||
moving = slide && !calm();
|
||||
const n = pts.length;
|
||||
// The newest point sits at the right edge, or one step beyond it
|
||||
// while sliding in.
|
||||
off = W - (n - 1) * dx + (moving ? dx : 0);
|
||||
const top = niceTop(Math.max(0, ...pts.map((p) => Math.max(p.down, p.up))) || 1e6);
|
||||
const y = (v) => (H - v / top * H).toFixed(2);
|
||||
const line = (k) => pts.map((p, i) => x(i).toFixed(1) + ',' + y(p[k])).join(' ');
|
||||
if (n > 1) {
|
||||
area.setAttribute('points', x(0).toFixed(1) + ',' + H + ' ' + line('down') + ' ' + x(n - 1).toFixed(1) + ',' + H);
|
||||
down.setAttribute('points', line('down'));
|
||||
up.setAttribute('points', line('up'));
|
||||
}
|
||||
ylTop.textContent = fmtRate(top);
|
||||
ylMid.textContent = fmtRate(top / 2);
|
||||
g.style.transition = 'none';
|
||||
g.style.transform = 'translateX(0)';
|
||||
if (moving) {
|
||||
g.getBoundingClientRect(); // start the slide from 0
|
||||
t0 = performance.now();
|
||||
g.style.transition = 'transform ' + step + 's linear';
|
||||
g.style.transform = 'translateX(' + (-dx).toFixed(2) + 'px)';
|
||||
}
|
||||
const i = hoverT == null ? -1 : pts.findIndex((p) => p.t === hoverT);
|
||||
if (i >= 0) show(i);
|
||||
else { hoverT = null; cursor.setAttribute('visibility', 'hidden'); readout.replaceChildren(...idle()); }
|
||||
};
|
||||
return { el, update };
|
||||
}
|
||||
|
||||
// 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. The server streams its
|
||||
// short in-memory history (the last 2 minutes in 2-second steps) and then
|
||||
// each new step as it is sampled.
|
||||
async function viewLive(wrap) {
|
||||
let peers = (await api('GET', '/peers')).peers;
|
||||
let live = { step: 2, size: 60, points: [] };
|
||||
let paused = false, es = null, retry = 0;
|
||||
const down = h('div', { class: 'v' }), up = h('div', { class: 'v' }), active = h('div', { class: 'v' });
|
||||
const totals = h('div', { class: 'livenow' },
|
||||
h('div', null, h('div', { class: 'k' }, h('span', { class: 'key down' }), 'Download'), down),
|
||||
h('div', null, h('div', { class: 'k' }, h('span', { class: 'key up' }), 'Upload'), up),
|
||||
h('div', null, h('div', { class: 'k' }, 'Active peers'), active));
|
||||
const lc = liveChart();
|
||||
const rows = h('div');
|
||||
const pauseBtn = h('button', { type: 'button', class: 'btn', onClick: () => {
|
||||
paused = !paused;
|
||||
pauseBtn.textContent = paused ? 'Resume' : 'Pause';
|
||||
sub.replaceChildren(...subText());
|
||||
if (paused) close(); else open();
|
||||
} }, 'Pause');
|
||||
const subText = () => paused
|
||||
? ['Paused · the chart keeps the moment you paused']
|
||||
: [h('span', { class: 'dot ok pulse' }), ' Updated every ' + live.step + ' s · figures are ' + AVG_STEPS * live.step + '-second averages'];
|
||||
const sub = h('p', { class: 'sub livesub' }, subText());
|
||||
|
||||
const draw = (slide) => {
|
||||
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 recent = pts.slice(-AVG_STEPS);
|
||||
const avg = (f) => recent.length ? recent.reduce((a, p) => a + f(p), 0) / recent.length : 0;
|
||||
const last = {};
|
||||
for (const p of peers) last[p.id] = [avg((x) => (x.peers[p.id] || [0, 0])[0]), avg((x) => (x.peers[p.id] || [0, 0])[1])];
|
||||
down.textContent = fmtRate(avg((p) => sumAt(p).down));
|
||||
up.textContent = fmtRate(avg((p) => sumAt(p).up));
|
||||
active.replaceChildren(String(peers.filter((p) => { const r = last[p.id]; return r && r[0] + r[1] >= IDLE_BPS; }).length),
|
||||
h('small', null, '/ ' + peers.filter((p) => p.stats.online).length + ' online'));
|
||||
lc.update(series, live.size, live.step, slide);
|
||||
|
||||
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 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.'));
|
||||
};
|
||||
|
||||
// The first message of a stream is the whole history; later ones carry
|
||||
// one new step each. One step more than the server keeps is held, so
|
||||
// the chart's left edge stays filled while it slides.
|
||||
function open() {
|
||||
if (es || paused || document.hidden || !wrap.isConnected) return;
|
||||
let first = true;
|
||||
es = new EventSource('/api/v1/live/stream');
|
||||
es.onmessage = (e) => {
|
||||
retry = 0;
|
||||
const m = JSON.parse(e.data);
|
||||
live = { step: m.step, size: m.size, points: first ? m.points : live.points.concat(m.points).slice(-m.size - 1) };
|
||||
draw(!first);
|
||||
first = false;
|
||||
};
|
||||
es.onerror = () => {
|
||||
if (es.readyState !== EventSource.CLOSED) { first = true; return; } // the browser reconnects
|
||||
close();
|
||||
// Refused, e.g. signed out: api() shows the sign-in page on 401.
|
||||
api('GET', '/status').then(() => { if (wrap.isConnected) setTimeout(open, Math.min(30000, 2000 * 2 ** retry++)); }).catch(() => {});
|
||||
};
|
||||
}
|
||||
function close() { if (es) { es.close(); es = null; } }
|
||||
const onVis = () => { if (document.hidden) close(); else open(); };
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
cleanups.push(() => { close(); document.removeEventListener('visibilitychange', onVis); });
|
||||
|
||||
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, lc.el),
|
||||
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(false);
|
||||
open();
|
||||
every(15000, async () => { try { peers = (await api('GET', '/peers')).peers; } catch { /* keep last */ } });
|
||||
}
|
||||
|
||||
function describeAudit(l) {
|
||||
const parts = [l.msg.charAt(0).toUpperCase() + l.msg.slice(1)];
|
||||
if (l.peer) parts.push(': ' + l.peer);
|
||||
@@ -1327,7 +1583,7 @@
|
||||
sessMore.textContent = allSessions ? 'Show fewer' : 'Show all ' + sessions.length;
|
||||
};
|
||||
drawSessions();
|
||||
let range = '7d';
|
||||
let range = '24h';
|
||||
const st = peerState(p);
|
||||
const traffic = h('div');
|
||||
const totals = h('div', { class: 'legend-row' });
|
||||
@@ -1349,7 +1605,7 @@
|
||||
};
|
||||
|
||||
async function drawTraffic() {
|
||||
pills.replaceChildren(...[['24h', '24 h'], ['7d', '7 days'], ['30d', '30 days']].map(([k, t]) =>
|
||||
pills.replaceChildren(...RANGES.map(([k, t]) =>
|
||||
h('button', { type: 'button', class: range === k ? 'pill on' : 'pill', 'aria-pressed': String(range === k), onClick: () => { range = k; drawTraffic(); } }, t)));
|
||||
const s = await api('GET', '/peers/' + id + '/stats?range=' + range);
|
||||
const down = s.points.reduce((a, x) => a + x.down, 0), up = s.points.reduce((a, x) => a + x.up, 0);
|
||||
|
||||
+16
-7
@@ -5,8 +5,10 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math/rand/v2"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
type simKernel struct {
|
||||
mu sync.Mutex
|
||||
peers map[string]*PeerSample
|
||||
last time.Time // previous Sample; traffic grows with the time since
|
||||
}
|
||||
|
||||
func newKernel() (Kernel, error) {
|
||||
@@ -53,17 +56,23 @@ func (k *simKernel) Apply(c *Config) error {
|
||||
func (k *simKernel) Sample(string) ([]PeerSample, error) {
|
||||
k.mu.Lock()
|
||||
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
|
||||
i := 0
|
||||
for _, p := range k.peers {
|
||||
// Every third peer stays idle; the others move some data.
|
||||
for i, key := range slices.Sorted(maps.Keys(k.peers)) {
|
||||
p := k.peers[key]
|
||||
// Every third peer stays idle; the others move some data, scaled to
|
||||
// the time since the previous sample.
|
||||
if i%3 != 2 {
|
||||
p.TxBytes += rand.Int64N(40 << 20)
|
||||
p.RxBytes += rand.Int64N(6 << 20)
|
||||
p.LastHandshake = time.Now().Add(-time.Duration(rand.IntN(90)) * time.Second)
|
||||
p.TxBytes += int64(float64(rand.Int64N(40<<20)) * f)
|
||||
p.RxBytes += int64(float64(rand.Int64N(6<<20)) * f)
|
||||
p.LastHandshake = now.Add(-time.Duration(rand.IntN(90)) * time.Second)
|
||||
}
|
||||
out = append(out, *p)
|
||||
i++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -219,16 +219,18 @@ func run(configPath string) error {
|
||||
var stopOnce sync.Once
|
||||
shutdown := func() { stopOnce.Do(func() { close(stop) }) }
|
||||
|
||||
speeds := newSpeeds(store, kernel)
|
||||
auth := newAuth(store)
|
||||
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,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(5)
|
||||
wg.Add(6)
|
||||
go func() { defer wg.Done(); recon.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(); geo.Run(stop) }()
|
||||
go func() { defer wg.Done(); app.updates.Run(stop) }()
|
||||
|
||||
@@ -1286,3 +1286,56 @@ func TestDropSecurityKeys(t *testing.T) {
|
||||
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))
|
||||
}
|
||||
_, ch, cancel := sp.Subscribe()
|
||||
defer cancel()
|
||||
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 got := <-ch; got.T != pts[0].T {
|
||||
t.Fatalf("subscriber got step %d, want %d", got.T, pts[0].T)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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
|
||||
subs map[chan SpeedPoint]struct{}
|
||||
done chan struct{} // closed when Run returns
|
||||
}
|
||||
|
||||
func newSpeeds(store *Store, kernel Kernel) *Speeds {
|
||||
return &Speeds{store: store, kernel: kernel, last: map[string][2]int64{}, subs: map[chan SpeedPoint]struct{}{}, done: make(chan struct{})}
|
||||
}
|
||||
|
||||
// Subscribe returns the current points and a channel that receives each new
|
||||
// one; cancel ends the subscription. A subscriber that falls behind misses
|
||||
// points rather than holding up the sampler.
|
||||
func (s *Speeds) Subscribe() (points []SpeedPoint, ch <-chan SpeedPoint, cancel func()) {
|
||||
c := make(chan SpeedPoint, 4)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.subs[c] = struct{}{}
|
||||
return append([]SpeedPoint{}, s.points...), c, func() {
|
||||
s.mu.Lock()
|
||||
delete(s.subs, c)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Done is closed when the sampler stops, so streams can end.
|
||||
func (s *Speeds) Done() <-chan struct{} { return s.done }
|
||||
|
||||
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:]
|
||||
}
|
||||
for c := range s.subs {
|
||||
select {
|
||||
case c <- pt:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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{}) {
|
||||
defer close(s.done)
|
||||
s.sample(time.Now())
|
||||
t := time.NewTicker(speedStep)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case now := <-t.C:
|
||||
s.sample(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user