Files
hamlab/web/src/ui.tsx
T
Mitchell-JohnsonandClaude Opus 5 32fdbf169a 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>
2026-09-27 22:18:59 -07:00

189 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Component, ReactNode, useEffect, useRef, useState } from 'react'
// ---- spinners ----
export function Spinner({ size = 14 }: { size?: number }) {
return <span className="spin" style={{ width: size, height: size }} aria-hidden="true" />
}
// The × in the corner of a detail pane. A drawn cross rather than the × glyph, which sits
// differently in every font and theme.
export function CloseBtn({ onClick, title = 'Close' }: { onClick: () => void; title?: string }) {
return (
<button type="button" className="detail-close" title={title} aria-label={title} onClick={onClick}>
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2l10 10M12 2L2 12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" fill="none" /></svg>
</button>
)
}
// A filled bar. Red past 95%, amber past 85%, unless the caller says otherwise.
export function Meter({ frac, cls }: { frac: number; cls?: string }) {
const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '')
return <div className="meter"><div className={'fill ' + c} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
export function Loading({ label = 'Loading…' }: { label?: string }) {
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
}
// Button that shows a spinner while its async handler runs; `confirm` asks first.
export function Btn({ onClick, busy, confirm, className = 'btn', disabled, title, children }: {
onClick: () => unknown | Promise<unknown>; busy?: boolean; confirm?: string; className?: string; disabled?: boolean; title?: string; children: ReactNode
}) {
const [inner, setInner] = useState(false)
const b = !!busy || inner
return (
<button className={className + (b ? ' busy' : '')} disabled={disabled || b} title={title}
onClick={async () => {
if (confirm && !(await askYes(confirm))) return
const r = onClick()
if (r && typeof (r as Promise<unknown>).then === 'function') {
setInner(true)
try { await r } finally { setInner(false) }
}
}}>
{b && <Spinner />}{children}
</button>
)
}
// ---- toasts: short feedback for actions ----
interface Toast { id: number; kind: 'ok' | 'err' | 'info'; msg: string }
const listeners = new Set<(t: Toast[]) => void>()
let toasts: Toast[] = []
let seq = 0
function emit() { listeners.forEach((l) => l(toasts)) }
export function dismissToast(id: number) { toasts = toasts.filter((t) => t.id !== id); emit() }
export function toast(msg: string, kind: Toast['kind'] = 'ok') {
const t = { id: ++seq, kind, msg }
toasts = [...toasts.slice(-4), t]
emit()
window.setTimeout(() => dismissToast(t.id), kind === 'err' ? 8000 : 3500)
}
export function Toasts() {
const [list, setList] = useState(toasts)
useEffect(() => { listeners.add(setList); return () => { listeners.delete(setList) } }, [])
return (
<div className="toasts">
{list.map((t) => <div key={t.id} className={'toast ' + t.kind} onClick={() => dismissToast(t.id)}>{t.msg}</div>)}
</div>
)
}
// act runs an action and toasts its outcome. Returns whether it succeeded.
export async function act(label: string, fn: () => Promise<unknown>): Promise<boolean> {
try { await fn(); toast(label); return true }
catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
}
// ---- thin activity bar at the top while any request is in flight ----
export function NetBar() {
const [n, setN] = useState(0)
useEffect(() => {
const h = (e: Event) => setN((e as CustomEvent<number>).detail)
window.addEventListener('hamlab:inflight', h)
return () => window.removeEventListener('hamlab:inflight', h)
}, [])
return <div className={'netbar' + (n > 0 ? ' on' : '')} />
}
// useEscape runs fn when Escape is pressed while active — every menu and dialog closes that way.
// A question (askYes/askText) sits on top of whatever asked it: Escape answers the question and
// goes no further, or one key would cancel the question and close the dialog underneath as well.
let escapeTaken: Event | null = null
export function useEscape(fn: () => void, active = true, isAsk = false) {
const ref = useRef(fn); ref.current = fn
useEffect(() => {
if (!active) return
const h = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
if (isAsk) { escapeTaken = e; ref.current() } else if (asks.length === 0 && escapeTaken !== e) ref.current()
}
window.addEventListener('keydown', h)
return () => window.removeEventListener('keydown', h)
}, [active])
}
// ---- dialogs: askYes / askText replace the browser's confirm() and prompt() ----
// Same promise shape as the natives (false / null on cancel), drawn like the rest of the
// console, usable on a phone, closed by Escape or a tap outside.
interface Ask { id: number; msg: string; input?: string; ok: string; danger: boolean; done: (v: string | boolean | null) => void }
let asks: Ask[] = []
let nextId = 0
const askListeners = new Set<(a: Ask[]) => void>()
const emitAsks = () => askListeners.forEach((l) => l(asks))
function ask(msg: string, input: string | undefined, ok?: string): Promise<string | boolean | null> {
return new Promise((resolve) => {
const id = ++nextId
const danger = /^(delete|remove|revoke|close|send sig|stop|disable|reset|discard|force)/i.test(msg) || /\bforce\b/i.test(msg)
asks = [...asks, { id, msg, input, ok: ok ?? (input !== undefined ? 'OK' : danger ? msg.split(/[ ?]/)[0] : 'OK'), danger, done: (v) => { asks = asks.filter((a) => a.id !== id); emitAsks(); resolve(v) } }]
emitAsks()
})
}
export const askYes = (msg: string, ok?: string) => ask(msg, undefined, ok) as Promise<boolean>
export const askText = (msg: string, initial = '', ok?: string) => ask(msg, initial, ok) as Promise<string | null>
function AskBox({ a }: { a: Ask }) {
const [val, setVal] = useState(a.input ?? '')
const cancel = () => a.done(a.input !== undefined ? null : false)
const accept = () => a.done(a.input !== undefined ? val : true)
useEscape(cancel, true, true)
return (
<div className="modal-back ask-back" onClick={cancel}>
<form className="modal ask" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); accept() }}>
<div className="ask-msg">{a.msg}</div>
{a.input !== undefined && <input autoFocus value={val} onChange={(e) => setVal(e.target.value)} onFocus={(e) => e.target.select()} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
<div className="svc-actions">
<button type="submit" autoFocus={a.input === undefined} className={'btn ' + (a.danger ? 'danger' : 'on')}>{a.ok}</button>
<span className="spacer" />
<button type="button" className="btn" onClick={cancel}>Cancel</button>
</div>
</form>
</div>
)
}
export function Dialogs() {
const [list, setList] = useState<Ask[]>(asks)
useEffect(() => { askListeners.add(setList); return () => { askListeners.delete(setList) } }, [])
const top = list[list.length - 1]
return top ? <AskBox key={top.id} a={top} /> : null
}
// Unsaved work: whoever holds some says so, and whoever is about to navigate away asks.
let unsaved = ''
export function setUnsaved(what: string) { unsaved = what }
export const hasUnsaved = () => unsaved !== ''
export async function okToLeave(): Promise<boolean> {
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 (
<div className="dash">
<div className="dash-bar"><h2 className="section">{this.props.page}</h2></div>
<section className="card">
<div className="error">This page hit a fault and stopped drawing. The rest of the console is unaffected.</div>
<div className="muted small mono" style={{ marginTop: 8 }}>{String(this.state.err)}</div>
<div className="svc-actions" style={{ marginTop: 10 }}>
<button className="btn" onClick={() => this.setState({ err: null })}>Try again</button>
</div>
</section>
</div>
)
}
}