diff --git a/internal/vpn/vpn.go b/internal/vpn/vpn.go
index c5ffcee..41c62d8 100644
--- a/internal/vpn/vpn.go
+++ b/internal/vpn/vpn.go
@@ -138,7 +138,9 @@ func Look(ctx context.Context) Report {
//
// The private key is in the second column and is read past without being kept.
func parseDump(out string) []Tunnel {
- var tunnels []Tunnel
+ // An empty list, never nil: this is JSON to a browser that will take its length, and a
+ // null there is a blank page rather than "no tunnels".
+ tunnels := []Tunnel{}
byName := map[string]int{}
sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
diff --git a/internal/vpn/vpn_test.go b/internal/vpn/vpn_test.go
index e22e50f..b7a4d56 100644
--- a/internal/vpn/vpn_test.go
+++ b/internal/vpn/vpn_test.go
@@ -172,3 +172,22 @@ func TestNeverConnectedHasNoTimestamp(t *testing.T) {
t.Errorf("json = %s, want the handshake of a peer that has one", b)
}
}
+
+// A machine with wireguard-tools installed but no tunnel up produces an empty dump. That has
+// to marshal as [] and not as null: the page takes the length of it, and null blanks the
+// whole console rather than saying "no tunnels".
+func TestEmptyDumpIsAnEmptyList(t *testing.T) {
+ for _, dump := range []string{"", "\n", " \n\n"} {
+ got := parseDump(dump)
+ if got == nil {
+ t.Fatalf("parseDump(%q) = nil, want an empty list", dump)
+ }
+ b, err := json.Marshal(got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(b) != "[]" {
+ t.Errorf("parseDump(%q) marshals to %s, want []", dump, b)
+ }
+ }
+}
diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx
index ad3fc11..096aa16 100644
--- a/web/src/Shell.tsx
+++ b/web/src/Shell.tsx
@@ -27,7 +27,7 @@ import { MachineStatus, setMachine } from './api'
import { LIST_FLOOR, useRefreshMs } from './prefs'
import Power from './Power'
import { applyAccent } from './accents'
-import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
+import { Btn, PageBoundary, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
import { fmtLeft, useTimeLeft } from './guest'
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
@@ -320,6 +320,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
}
>
)
}
@@ -88,9 +89,13 @@ export default function VPN() {
if (!d) return
{err ?
{err === 'not found' ? "This machine's Ham Lab is older than the VPN tab. Update it from Machines." : err}
: }
+ // 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 (
Read from the kernel with wg show; 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.
diff --git a/web/src/ui.tsx b/web/src/ui.tsx
index 38aa9a9..754fe1e 100644
--- a/web/src/ui.tsx
+++ b/web/src/ui.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useEffect, useRef, useState } from 'react'
+import { Component, ReactNode, useEffect, useRef, useState } from 'react'
// ---- spinners ----
export function Spinner({ size = 14 }: { size?: number }) {
@@ -155,3 +155,34 @@ export async function okToLeave(): Promise {
if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true }
return false
}
+
+// A page that throws takes the whole console down with it: React unmounts the tree, and what
+// is left is a blank window with no clue in it. The VPN tab did exactly that on a machine
+// whose tunnel list came back null. So each page is rendered inside one of these, and a fault
+// costs that page rather than the session — the rail, the switcher and every other tab still
+// work, and the error says which page and what it said.
+export class PageBoundary extends Component<{ page: string; children: ReactNode }, { err: Error | null }> {
+ constructor(props: { page: string; children: ReactNode }) {
+ super(props)
+ this.state = { err: null }
+ }
+ static getDerivedStateFromError(err: Error) { return { err } }
+ componentDidCatch(err: Error) { console.error('page failed:', err) }
+ // A different page is a different mount; clear the fault when the page changes.
+ componentDidUpdate(prev: { page: string }) { if (prev.page !== this.props.page && this.state.err) this.setState({ err: null }) }
+ render() {
+ if (!this.state.err) return this.props.children
+ return (
+
+
{this.props.page}
+
+
This page hit a fault and stopped drawing. The rest of the console is unaffected.