Fix the blank VPN tab, and stop one page taking the console with it

On a machine with wireguard-tools installed but no tunnel up — core, which is
the machine the console opens on — the tunnel list was a nil slice, which is
null in JSON, and the page took its length. That throws, React unmounts the
whole tree, and what is left is a blank window with nothing to go on.

Two fixes, because either alone leaves the failure possible. The parse returns
an empty list rather than nil, with a test that pins the marshalled shape; and
the page treats a missing list as an empty one, so a node still running an older
build cannot blank the console either.

And the reason a small mistake had such a large blast radius: nothing caught it.
Each page now renders inside a boundary, so a fault costs that page and says
what it was, while the rail, the switcher and every other tab carry on. The
terminals are deliberately left outside it — they hold live WebSockets, and
remounting them to recover from somebody else's error would drop the sessions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-27 22:18:59 -07:00
co-authored by Claude Opus 5
parent acb83d3a4a
commit 32fdbf169a
5 changed files with 69 additions and 10 deletions
+12 -7
View File
@@ -48,14 +48,15 @@ function Peer({ p, server }: { p: VPNPeer; server: boolean }) {
function Tunnel({ t }: { t: VPNTunnel }) {
const server = t.kind === 'server'
const online = t.peers.filter((p) => p.online).length
const peers = t.peers ?? []
const online = peers.filter((p) => p.online).length
return (
<>
<div className="dash-bar">
<h2 className="section">{t.name}</h2>
<span className="badge">{server ? 'devices dial in' : t.kind === 'client' ? 'this machine dials out' : 'tunnel'}</span>
<span className="spacer" />
<span className="muted small">{server ? `${online} of ${t.peers.length} online` : t.exit ? `via ${t.exit}` : ''}</span>
<span className="muted small">{server ? `${online} of ${peers.length} online` : t.exit ? `via ${t.exit}` : ''}</span>
</div>
<section className="card">
<div className="props">
@@ -66,8 +67,8 @@ function Tunnel({ t }: { t: VPNTunnel }) {
{t.fwmark && <div><span className="k">Firewall mark</span><span className="mono">{t.fwmark}</span></div>}
</div>
</section>
{t.peers.map((p) => <Peer key={p.public_key} p={p} server={server} />)}
{t.peers.length === 0 && <section className="card"><div className="empty">No peers are configured on this tunnel.</div></section>}
{peers.map((p) => <Peer key={p.public_key} p={p} server={server} />)}
{peers.length === 0 && <section className="card"><div className="empty">No peers are configured on this tunnel.</div></section>}
</>
)
}
@@ -88,9 +89,13 @@ export default function VPN() {
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the VPN tab. Update it from Machines." : err}</div> : <Loading label="Reading the tunnels…" />}</div>
// A machine running an older build answers with null rather than an empty list, and taking
// the length of that is what a blank page is made of.
const tunnels = d.tunnels ?? []
return (
<div className="dash net">
{d.tunnels.length === 0 && (
{tunnels.length === 0 && (
<>
<div className="dash-bar"><h2 className="section">VPN</h2></div>
<section className="card"><div className="empty">{d.note || 'Nothing here.'}</div>
@@ -100,8 +105,8 @@ export default function VPN() {
</section>
</>
)}
{d.tunnels.map((t) => <Tunnel key={t.name} t={t} />)}
{d.tunnels.length > 0 && (
{tunnels.map((t) => <Tunnel key={t.name} t={t} />)}
{tunnels.length > 0 && (
<div className="muted small method" style={{ marginTop: 12 }}>
Read from the kernel with <span className="mono">wg show</span>; names come from the configuration beside it.
Online means a handshake in the last three minutes — WireGuard is silent when idle, so there is no connection to be in.