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:
setDrawer(false)} />
+ g.id === view)?.label ?? view}> {view === 'settings' && } {view === 'users' && } {view === 'kvm' && } @@ -338,6 +339,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {view === 'vpn' && } {view === 'zfs' && } {view === 'files' && } + {termView && window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />} diff --git a/web/src/VPN.tsx b/web/src/VPN.tsx index 57fb270..ac7282e 100644 --- a/web/src/VPN.tsx +++ b/web/src/VPN.tsx @@ -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 ( <>

{t.name}

{server ? 'devices dial in' : t.kind === 'client' ? 'this machine dials out' : 'tunnel'} - {server ? `${online} of ${t.peers.length} online` : t.exit ? `via ${t.exit}` : ''} + {server ? `${online} of ${peers.length} online` : t.exit ? `via ${t.exit}` : ''}
@@ -66,8 +67,8 @@ function Tunnel({ t }: { t: VPNTunnel }) { {t.fwmark &&
Firewall mark{t.fwmark}
}
- {t.peers.map((p) => )} - {t.peers.length === 0 &&
No peers are configured on this tunnel.
} + {peers.map((p) => )} + {peers.length === 0 &&
No peers are configured on this tunnel.
} ) } @@ -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 (
- {d.tunnels.length === 0 && ( + {tunnels.length === 0 && ( <>

VPN

{d.note || 'Nothing here.'}
@@ -100,8 +105,8 @@ export default function VPN() {
)} - {d.tunnels.map((t) => )} - {d.tunnels.length > 0 && ( + {tunnels.map((t) => )} + {tunnels.length > 0 && (
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.
+
{String(this.state.err)}
+
+ +
+
+
+ ) + } +}