// 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 = { ethernet: 'wired', wifi: 'Wi-Fi', bridge: 'bridge', bond: 'bond', loopback: 'loopback', virtual: 'virtual', vlan: 'VLAN', wlan: 'Wi-Fi', wireguard: 'VPN tunnel', none: 'tunnel' } // A WireGuard interface is listed here because it is an interface and carries traffic, but // everything about it that is worth knowing — who is on it, when they last spoke — belongs to // the VPN tab, and is only there. const isTunnel = (f: Iface) => f.kind === 'wireguard' || /^(wg|tun|tap)[0-9]/.test(f.name) const goVPN = () => window.dispatchEvent(new CustomEvent('hamlab:view', { detail: 'vpn' })) // Where the bytes came from. The usual source is anycast and its edge is the interesting // part; a fallback source is somewhere fixed, and then *which* one is the interesting part, // because a mirror far away reads low and the figure should not pretend otherwise. const where = (r: SpeedResult) => r.source && r.source !== 'cloudflare' ? ` from ${r.source}` : r.colo ? ` via ${r.colo}` : '' // 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 (
) } function Figure({ label, unit, value, live, waiting }: { label: string; unit: string; value?: number; live?: boolean; waiting?: boolean }) { return (
{label}
{value === undefined || value === null ? (waiting ? '·' : '—') : mbps(value)}{unit}
) } export default function Network() { const [d, setD] = useState(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 }>({ 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
{err ?
{err === 'not found' ? "This machine's Ham Lab is older than the Network tab. Update it from Machines." : err}
: }
const o = d.outside const failed = run?.result?.error return (

Internet speed

{last ? `measured ${ago(last.at)}` : 'not measured yet'}
{running && (
{run!.phase === 'latency' ? 'measuring the round trip' : run!.phase === 'download' ? 'pulling down' : 'pushing up'} · {fmtBytes(run!.bytes || 0)} moved
)}
{running ? <>Stoprunning on {d.report.host} — it carries on if you close this : <>Run speed test {last ? `${fmtBytes((last.down_bytes ?? 0) + (last.up_bytes ?? 0))} moved in ${last.took_sec}s${where(last)}` : 'about ten seconds'}}
{failed &&
{failed === 'cancelled' ? 'Stopped.' : failed}
} {!failed && last?.note &&
{last.note}
}
{d.method}
{hist.length > 1 && ( <>

Past tests

{hist.length} kept
`${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)' }]} />
{[...hist].reverse().slice(0, 8).map((r) => (
{new Date(r.at).toLocaleString()} ↓ {mbps(r.down_mbps)} · ↑ {mbps(r.up_mbps)} Mbps · {r.latency_ms} ms
))}
)}

Out there

Public address{o.ip || '—'}{o.error ? ` · ${o.error}` : ''}
{o.colo &&
Reached via{o.colo}{o.country ? ` · ${o.country}` : ''}
} {d.report.gateway &&
Gateway{d.report.gateway}{d.report.gateway_via ? ` via ${d.report.gateway_via}` : ''}
} {!!d.report.dns?.length &&
DNS{d.report.dns.join(', ')}
} {!!d.report.search?.length &&
Search{d.report.search.join(' ')}
}

Interfaces

{hidden > 0 && } {all && }
{shown.map((f) => { const rx = rateOf(f.name, 'rx'), tx = rateOf(f.name, 'tx') return (
{f.name} {f.default && internet} {KIND[f.kind] ?? f.kind} {isTunnel(f) && } {!f.up ? down : !f.carrier && no link} {typeof rx === 'number' ? `↓ ${fmtValue(rx, 'B/s')}` : ''} {typeof tx === 'number' ? `↑ ${fmtValue(tx, 'B/s')}` : ''}
{f.addrs.length > 0 &&
Address{f.addrs.filter((a) => !a.v6).map((a) => `${a.ip}/${a.prefix}`).join(', ') || '—'}
} {f.addrs.some((a) => a.v6) &&
IPv6 a.v6).map((a) => `${a.ip}/${a.prefix}`).join('\n')}>{f.addrs.filter((a) => a.v6).length} addresses
} {!!f.speed_mbps &&
Link{f.speed_mbps >= 1000 ? `${f.speed_mbps / 1000} Gbps` : `${f.speed_mbps} Mbps`}{f.duplex ? ` ${f.duplex} duplex` : ''}
} {f.signal_dbm !== undefined &&
Signal{f.signal_dbm} dBm
} {f.mac &&
MAC{f.mac}
}
MTU{f.mtu}{f.driver ? ` · ${f.driver}` : ''}
Moved↓ {fmtBytes(f.rx_total)} · ↑ {fmtBytes(f.tx_total)}
) })}
) }