A Network tab, with an internet speed test

Per machine like every other tab: the interfaces as the kernel has them
(addresses, MAC, MTU, negotiated link speed, carrier, totals, live rates from
the sampler's own sensors), the default route and resolvers, what the internet
sees of this connection, and a speed test with the last fifty results kept.

The test runs in the daemon, not the browser, so the figure is the line of the
machine in the switcher — the router's, if you switch to gate — and it carries
on when the phone locks. It measures against Cloudflare's public speed
endpoints, so there is nothing to install on any host, which is what makes it
work the same on Debian and on OpenWrt where flash is scarce.

What the measurement does and does not count:

  - Several streams, HTTP/2 deliberately off. One stream over one connection
    measures a congestion window, and HTTP/2 would put every stream back onto
    one connection and measure the same thing.
  - The first second of each phase is thrown away as the ramp.
  - A phase ends on 64 MiB as well as on four seconds, so a fast line says what
    it is without moving a gigabyte. A minimum window keeps a very fast line
    from filling the cap before there is anything to divide by.
  - Latency is the round trip less the far end's own Server-Timing. A zero-byte
    response is mostly Cloudflare's worker thinking: left in, it reported 31 ms
    for a line the edge itself measures at 3.5. Jitter is the mean change
    between consecutive probes, so one slow probe cannot run away with it.
  - The far end rate-limits by volume. So: a 20 s cooldown counted from the last
    attempt rather than the last success, streams that retry twice, the rate
    computed over the window in which bytes were actually still arriving, and a
    plain sentence when it was refused. Coasting to the end of the clock with
    nothing arriving reported a third of the truth.

The last result is published as net.internet.* sensors, each stamped with when
the test ran, so the line can be a widget, a graph, an alert or a Prometheus
series like anything else. The public address is refreshed on its own quarter
hour, which is worth having here: it is a DHCP lease and it moves.

Tests are offline — the window, the byte cap, the refusal and the cancellation
run against a local server; the route parser's byte order is pinned by a case
that would have caught the bug it did. The live measurement is behind
HAMLAB_NET_TEST=1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-27 20:11:03 -07:00
co-authored by Claude Opus 5
parent a0987c940f
commit 2f59a54678
15 changed files with 1683 additions and 8 deletions
+188
View File
@@ -0,0 +1,188 @@
// Network: how this machine reaches the internet, and how fast the line actually is.
//
// The speed test runs on the machine in the switcher, not in this browser — so it measures
// that machine's line, and it keeps running if the phone is locked or the tab is closed. What
// is drawn here is what the daemon reports about a test it is running itself.
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, Iface, NetworkInfo, SpeedResult, SpeedRun } from './api'
import { Sensor, fmtValue, useSensorStream } from './sensors'
import { fmtBytes } from './fmt'
import { Btn, Loading, toast } from './ui'
import { useRefreshMs } from './prefs'
import Sparkline from './Sparkline'
// Docker's per-network bridges, and the veth ends inside them, are real interfaces and
// nobody wants to read nineteen of them. They fold away unless asked for.
const scenery = (f: Iface) =>
!f.default && (/^br-[0-9a-f]{12}$/.test(f.name) || /^(veth|docker)/.test(f.name) || f.kind === 'loopback')
const KIND: Record<string, string> = { ethernet: 'wired', wifi: 'Wi-Fi', bridge: 'bridge', bond: 'bond', loopback: 'loopback', virtual: 'virtual', vlan: 'VLAN', wlan: 'Wi-Fi' }
// A round number of megabits, which is how a line is sold and how it should be read.
const mbps = (v: number) => (v >= 100 ? v.toFixed(0) : v.toFixed(1))
const ago = (iso: string) => {
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000)
if (s < 60) return 'just now'
if (s < 3600) return `${Math.floor(s / 60)} min ago`
if (s < 86400) return `${Math.floor(s / 3600)} h ago`
return `${Math.floor(s / 86400)} d ago`
}
// The four figures a speed test produces, big enough to read from across the room.
function Figures({ r, live }: { r?: SpeedResult; live?: SpeedRun }) {
const running = !!live?.running
const phase = live?.phase
const downNow = running && phase === 'download' ? live!.mbps : undefined
const upNow = running && phase === 'upload' ? live!.mbps : undefined
return (
<div className="speedgrid">
<Figure label="Download" unit="Mbps" value={downNow ?? r?.down_mbps} live={downNow !== undefined} waiting={running && phase === 'latency'} />
<Figure label="Upload" unit="Mbps" value={upNow ?? r?.up_mbps} live={upNow !== undefined} waiting={running && phase !== 'upload'} />
<Figure label="Latency" unit="ms" value={r?.latency_ms} waiting={running && phase === 'latency'} />
<Figure label="Jitter" unit="ms" value={r?.jitter_ms} waiting={running && phase === 'latency'} />
</div>
)
}
function Figure({ label, unit, value, live, waiting }: { label: string; unit: string; value?: number; live?: boolean; waiting?: boolean }) {
return (
<div className={'speedfig' + (live ? ' live' : '')}>
<div className="label">{label}</div>
<div className="big">{value === undefined || value === null ? (waiting ? '·' : '—') : mbps(value)}<i>{unit}</i></div>
</div>
)
}
export default function Network() {
const [d, setD] = useState<NetworkInfo | null>(null)
const [err, setErr] = useState('')
const [all, setAll] = useState(false)
const [busy, setBusy] = useState(false)
const rate = useRefreshMs()
const load = useCallback(async () => {
try { setD(await api.network()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => { load() }, [load])
const run = d?.run
const running = !!run?.running
// While a test is running the state is polled hard, because the needle should move; at rest
// the page is a read and asks again only now and then.
useEffect(() => {
const every = running ? Math.max(400, Math.min(rate, 1000)) : Math.max(5000, rate)
const t = setInterval(() => {
if (document.visibilityState !== 'visible') return
if (running) api.speedtest().then((r) => setD((old) => (old ? { ...old, run: r } : old))).catch(() => {})
else load()
}, every)
return () => clearInterval(t)
}, [running, rate, load])
// The run that just ended brings a result and a history entry with it.
const phase = run?.phase
useEffect(() => { if (phase === 'done') load() }, [phase, load])
const live = useSensorStream<{ n: Record<string, Sensor> }>({ n: 'net.*' }, { everyMs: Math.max(1000, rate) })
const sens = live.data?.n ?? {}
const rateOf = (name: string, dir: 'rx' | 'tx') => sens[`net.${name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_')}.${dir}`]?.value
const ifaces = d?.report.ifaces ?? []
const shown = all ? ifaces : ifaces.filter((f) => !scenery(f))
const hidden = ifaces.length - shown.length
const hist = useMemo(() => (d?.history ?? []).filter((r) => !r.error), [d])
const last = run?.result && !run.result.error ? run.result : hist[hist.length - 1]
const start = async () => {
setBusy(true)
try { const r = await api.startSpeedtest(); setD((old) => (old ? { ...old, run: r } : old)) }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
finally { setBusy(false) }
}
const cancel = async () => {
try { await api.cancelSpeedtest(); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Network tab. Update it from Machines." : err}</div> : <Loading label="Reading the network…" />}</div>
const o = d.outside
const failed = run?.result?.error
return (
<div className="dash net">
<div className="dash-bar"><h2 className="section">Internet speed</h2><span className="spacer" />
<span className="muted small">{last ? `measured ${ago(last.at)}` : 'not measured yet'}</span></div>
<section className="card speedtest">
<Figures r={last} live={run} />
{running && (
<div className="speedbar">
<div className="meter"><div className="fill" style={{ width: `${Math.round((run!.done || 0) * 100)}%` }} /></div>
<span className="muted small">{run!.phase === 'latency' ? 'measuring the round trip' : run!.phase === 'download' ? 'pulling down' : 'pushing up'} · {fmtBytes(run!.bytes || 0)} moved</span>
</div>
)}
<div className="svc-actions">
{running
? <><Btn className="btn danger" onClick={cancel}>Stop</Btn><span className="muted small">running on {d.report.host} — it carries on if you close this</span></>
: <><Btn className="btn on" busy={busy} onClick={start}>Run speed test</Btn>
<span className="muted small">{last ? `${fmtBytes((last.down_bytes ?? 0) + (last.up_bytes ?? 0))} moved in ${last.took_sec}s${last.colo ? ` via ${last.colo}` : ''}` : 'about 15 seconds'}</span></>}
</div>
{failed && <div className="error">{failed === 'cancelled' ? 'Stopped.' : failed}</div>}
{!failed && last?.note && <div className="notice">{last.note}</div>}
<div className="muted small method">{d.method}</div>
</section>
{hist.length > 1 && (
<>
<div className="dash-bar"><h2 className="section">Past tests</h2><span className="spacer" /><span className="muted small">{hist.length} kept</span></div>
<section className="card">
<Sparkline height={60} format={(v) => `${mbps(v)} Mbps`}
series={[{ name: 'down', values: hist.map((r) => r.down_mbps), color: 'var(--s1)' },
{ name: 'up', values: hist.map((r) => r.up_mbps), color: 'var(--s2)' }]} />
<div className="props" style={{ marginTop: 8 }}>
{[...hist].reverse().slice(0, 8).map((r) => (
<div key={r.at}><span className="k">{new Date(r.at).toLocaleString()}</span>
<span className="mono">↓ {mbps(r.down_mbps)} · ↑ {mbps(r.up_mbps)} Mbps · {r.latency_ms} ms</span></div>
))}
</div>
</section>
</>
)}
<div className="dash-bar"><h2 className="section">Out there</h2></div>
<section className="card">
<div className="props">
<div><span className="k">Public address</span><span className="mono">{o.ip || '—'}{o.error ? ` · ${o.error}` : ''}</span></div>
{o.colo && <div><span className="k">Reached via</span><span>{o.colo}{o.country ? ` · ${o.country}` : ''}</span></div>}
{d.report.gateway && <div><span className="k">Gateway</span><span className="mono">{d.report.gateway}{d.report.gateway_via ? ` via ${d.report.gateway_via}` : ''}</span></div>}
{!!d.report.dns?.length && <div><span className="k">DNS</span><span className="mono">{d.report.dns.join(', ')}</span></div>}
{!!d.report.search?.length && <div><span className="k">Search</span><span className="mono">{d.report.search.join(' ')}</span></div>}
</div>
</section>
<div className="dash-bar"><h2 className="section">Interfaces</h2><span className="spacer" />
{hidden > 0 && <button className="link" onClick={() => setAll(true)}>show {hidden} more</button>}
{all && <button className="link" onClick={() => setAll(false)}>hide the scenery</button>}</div>
{shown.map((f) => {
const rx = rateOf(f.name, 'rx'), tx = rateOf(f.name, 'tx')
return (
<section className="card iface" key={f.name}>
<div className="sub-head">
<span className="dname mono">{f.name}</span>
{f.default && <span className="badge ok" title="the default route leaves by this one">internet</span>}
<span className="badge">{KIND[f.kind] ?? f.kind}</span>
{!f.up ? <span className="badge bad">down</span> : !f.carrier && <span className="badge bad">no link</span>}
<span className="spacer" />
<span className="muted small mono">{typeof rx === 'number' ? `↓ ${fmtValue(rx, 'B/s')}` : ''} {typeof tx === 'number' ? `↑ ${fmtValue(tx, 'B/s')}` : ''}</span>
</div>
<div className="props">
{f.addrs.length > 0 && <div><span className="k">Address</span><span className="mono">{f.addrs.filter((a) => !a.v6).map((a) => `${a.ip}/${a.prefix}`).join(', ') || '—'}</span></div>}
{f.addrs.some((a) => a.v6) && <div><span className="k">IPv6</span><span className="mono" title={f.addrs.filter((a) => a.v6).map((a) => `${a.ip}/${a.prefix}`).join('\n')}>{f.addrs.filter((a) => a.v6).length} addresses</span></div>}
{!!f.speed_mbps && <div><span className="k">Link</span><span>{f.speed_mbps >= 1000 ? `${f.speed_mbps / 1000} Gbps` : `${f.speed_mbps} Mbps`}{f.duplex ? ` ${f.duplex} duplex` : ''}</span></div>}
{f.signal_dbm !== undefined && <div><span className="k">Signal</span><span>{f.signal_dbm} dBm</span></div>}
{f.mac && <div><span className="k">MAC</span><span className="mono">{f.mac}</span></div>}
<div><span className="k">MTU</span><span>{f.mtu}{f.driver ? ` · ${f.driver}` : ''}</span></div>
<div><span className="k">Moved</span><span className="mono">↓ {fmtBytes(f.rx_total)} · ↑ {fmtBytes(f.tx_total)}</span></div>
</div>
</section>
)
})}
</div>
)
}
+12 -3
View File
@@ -12,6 +12,7 @@ import Jobs from './Jobs'
import Updates from './Updates'
import Disks from './Disks'
import Hardware from './Hardware'
import Network from './Network'
import ZFS from './ZFS'
import KVM from './KVM'
import Files from './Files'
@@ -53,6 +54,12 @@ const BellIcon = (
</svg>
)
const GlobeIcon = (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true">
<circle cx="8" cy="8" r="6.2" /><path d="M1.9 8h12.2M8 1.8c1.7 1.8 2.6 3.9 2.6 6.2S9.7 12.4 8 14.2C6.3 12.4 5.4 10.3 5.4 8S6.3 3.6 8 1.8z" />
</svg>
)
const GROUPS: Group[] = [
{ id: 'procs', label: 'Processes', icon: '▚' },
{ id: 'terminal', label: 'Terminal', icon: '>_' },
@@ -64,6 +71,7 @@ const GROUPS: Group[] = [
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
{ id: 'docker', label: 'Docker', icon: DockerIcon },
{ id: 'kvm', label: 'KVM', icon: '⌨' },
{ id: 'network', label: 'Network', icon: GlobeIcon },
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
{ id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
@@ -73,8 +81,8 @@ const GROUPS: Group[] = [
{ id: 'settings', label: 'Settings', icon: '⚙' },
]
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
@@ -304,6 +312,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{view === 'updates' && <Updates />}
{view === 'disks' && <Disks />}
{view === 'hardware' && <Hardware />}
{view === 'network' && <Network />}
{view === 'zfs' && <ZFS />}
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
{termView && <TermTabs terms={ordered} active={active} creating={creating}
@@ -331,7 +340,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
</footer>
</div>
)
+22
View File
@@ -171,6 +171,24 @@ export interface SpaceFile { path: string; size: number; apparent: number; mtime
export interface KVM { id: string; name: string; url: string; machine: string; added: string }
// ---- the Network tab ----
export interface IfAddr { ip: string; prefix: number; v6?: boolean }
export interface Iface {
name: string; kind: string; mac?: string; mtu: number; up: boolean; carrier: boolean
speed_mbps?: number; duplex?: string; driver?: string; signal_dbm?: number
addrs: IfAddr[]; rx_total: number; tx_total: number; default?: boolean
}
export interface NetReport { at: string; host?: string; ifaces: Iface[]; gateway?: string; gateway_via?: string; dns?: string[]; search?: string[] }
export interface Outside { at: string; ip?: string; colo?: string; country?: string; error?: string }
export interface SpeedResult {
at: string; down_mbps: number; up_mbps: number; latency_ms: number; jitter_ms: number
down_bytes: number; up_bytes: number; took_sec: number; colo?: string; ip?: string; note?: string; error?: string
}
// A test in progress, or the last one to have finished. `done` is 0–1 across the whole test
// and `mbps` is the live figure for the phase under way.
export interface SpeedRun { started: string; running: boolean; phase: string; done: number; mbps: number; bytes: number; result?: SpeedResult }
export interface NetworkInfo { report: NetReport; outside: Outside; run?: SpeedRun; history: SpeedResult[]; method: string }
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean }
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
@@ -284,6 +302,10 @@ export const api = {
tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
hardware: (refresh = false) => req<Hardware>('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`),
network: () => req<NetworkInfo>('GET', '/api/network'),
speedtest: () => req<SpeedRun>('GET', '/api/network/speedtest'),
startSpeedtest: () => req<SpeedRun>('POST', '/api/network/speedtest'),
cancelSpeedtest: () => req<void>('POST', '/api/network/speedtest/cancel'),
docker: () => req<DockerInfo>('GET', '/api/docker'),
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
dockerLogs: (id: string, since = '') => req<{ lines: DockerLogLine[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
+15
View File
@@ -422,6 +422,21 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
.mitem { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-align: left; }
.mitem:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); }
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
/* Network tab: the speed test's four figures, large enough to read at arm's length, and the
bar that fills while one is running. */
.net .speedgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
@media (max-width: 560px) { .net .speedgrid { grid-template-columns: repeat(2, 1fr); } }
.net .speedfig { min-width: 0; }
.net .speedfig .label { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
.net .speedfig .big { font-size: 28px; font-weight: 600; line-height: 1.15; font-variant-numeric: tabular-nums; }
.net .speedfig .big i { font-size: 12px; font-weight: 400; font-style: normal; color: var(--muted); margin-left: 4px; }
.net .speedfig.live .big { color: var(--accent); }
.net .speedbar { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
.net .card.speedtest .svc-actions { margin-top: 10px; }
.net .card.speedtest .method { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
.net .card.iface { margin-top: 8px; }
.net .card + .dash-bar { margin-top: 14px; }
.addform { display: flex; flex-direction: column; gap: 10px; }
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.addform input, .addform select { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }