import { Component, ReactNode, useEffect, useRef, useState } from 'react'
// ---- spinners ----
export function Spinner({ size = 14 }: { size?: number }) {
return
}
// 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 (
)
}
// 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
)
}
// act runs an action and toasts its outcome. Returns whether it succeeded.
export async function act(label: string, fn: () => Promise): Promise {
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).detail)
window.addEventListener('hamlab:inflight', h)
return () => window.removeEventListener('hamlab:inflight', h)
}, [])
return
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 {
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
export const askText = (msg: string, initial = '', ok?: string) => ask(msg, initial, ok) as Promise
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 (
)
}
export function Dialogs() {
const [list, setList] = useState(asks)
useEffect(() => { askListeners.add(setList); return () => { askListeners.delete(setList) } }, [])
const top = list[list.length - 1]
return 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 {
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.