Beside Network, per machine. Every WireGuard interface on the machine in the switcher, with its address, listen port and public key, and under it the peers: name, allowed addresses, last handshake, where they connect from, and what they have moved. Per machine is the point rather than a limitation. The tunnel devices dial into and a tunnel a machine dials out through are different boxes here, and putting them on one page would invite the question of which box you were looking at. The kernel is the truth and the configuration only supplies names: wg show all dump has the handshakes and the counters and knows nothing but public keys, so the # comment above a peer in a wg-quick file, or a UCI section's description on OpenWrt, is read alongside purely to label them. Whether a tunnel is dialled or dialling is worked out from the shape of its peers, and says neither when it cannot tell. Online means a handshake in the last three minutes, and the tab says so in as many words: WireGuard is silent when idle, so there is no connection to be in. Read-only, and no secrets leave: the private key is the second column of the dump and is read past, pre-shared keys are noted as present and never read. The Network tab keeps listing tunnels as the interfaces they are, and hands off to here for anything about them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
12 KiB
TypeScript
202 lines
12 KiB
TypeScript
// 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', 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 (
|
|
<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${where(last)}` : 'about ten 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>
|
|
{isTunnel(f) && <button className="link" onClick={goVPN} title="who is on this tunnel">peers →</button>}
|
|
{!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>
|
|
)
|
|
}
|