UI robustness: error boundary per page; fix null-array crashes (TLS certificates, idle dashboard interfaces, stats slices)

This commit is contained in:
Andy Dixon
2026-10-09 22:14:24 +01:00
parent 331ac04759
commit b6ce5b5611
7 changed files with 34 additions and 9 deletions
View File
+2 -1
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { api, setCSRF, connectEvents, disconnectEvents } from "./api";
import { useHash, usePersist, Toaster, go } from "./ui";
import { ErrorBoundary } from "./components/ErrorBoundary";
import Login from "./pages/Login";
import Setup from "./pages/Setup";
import Overview from "./pages/Overview";
@@ -121,7 +122,7 @@ export default function App() {
<span className="row spread"><span>v{sess.version}</span><button className="small ghost" onClick={async () => { await api("/api/auth/logout", { method: "POST" }).catch(() => {}); setSess(null); disconnectEvents(); }}>Sign out</button></span>
</div>
</aside>
<main className="main">{page}</main>
<main className="main"><ErrorBoundary where={path}>{page}</ErrorBoundary></main>
<Toaster />
</div>
);
+25
View File
@@ -0,0 +1,25 @@
import { Component, type ReactNode } from "react";
// A page crash must never blank the whole appliance UI. This shows the error
// and a reload, keeping the surrounding navigation usable.
export class ErrorBoundary extends Component<{ children: ReactNode; where: string }, { err: Error | null }> {
state = { err: null as Error | null };
static getDerivedStateFromError(err: Error) { return { err }; }
componentDidUpdate(prev: { children: ReactNode; where: string }) {
if (prev.where !== this.props.where && this.state.err) this.setState({ err: null });
}
render() {
if (this.state.err) {
return (
<div className="page">
<div className="banner bad"><div>
<b>This page hit an error.</b>
{String(this.state.err.message || this.state.err)}. The rest of the appliance is unaffected — pick another page, or reload.
</div></div>
<button onClick={() => location.reload()}>Reload</button>
</div>
);
}
return this.props.children;
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ export default function Library() {
<td className="mono">{s.ended_at ? ((s.ended_at - s.started_at) / 1000).toFixed(0) + " s" : "–"}</td>
<td className="mono">{s.packets.toLocaleString()}</td>
<td className="mono">{fmtBytes(s.bytes)}</td>
<td className="mono">{s.kind === "import" ? "import" : s.interfaces.join(",")}{s.capture_filter && <div className="hint" title="capture filter">{s.capture_filter}</div>}</td>
<td className="mono">{s.kind === "import" ? "import" : (s.interfaces ?? []).join(",")}{s.capture_filter && <div className="hint" title="capture filter">{s.capture_filter}</div>}</td>
<td><div className="row" style={{ flexWrap: "nowrap" }}>
<button className="small" onClick={async () => setInfo({ s, segs: (await api(`/api/captures/${s.id}`)).segments ?? [] })}>Integrity</button>
<a className="btn small" href={`/api/captures/${s.id}/export`} download>Export</a>
+2 -2
View File
@@ -23,7 +23,7 @@ export default function Overview() {
}), []);
const fwd = h?.forwarding;
const live = h?.capture?.session as Session | undefined;
const live = h?.capture?.running ? (h.capture.session as Session) : undefined;
const assoc = clients?.clients.filter((c) => c.associated) ?? [];
const alerts: [string, "warn" | "bad"][] = [];
if (herr) alerts.push(["Health endpoint unavailable: " + herr, "bad"]);
@@ -48,7 +48,7 @@ export default function Overview() {
<div className="grid g4" style={{ marginBottom: ".9rem" }}>
<div className="card"><h3>Capture</h3>
<div className="metric">{live ? live.packets.toLocaleString() : "idle"}{live && <small>packets</small>}</div>
<div className="row" style={{ marginTop: ".4rem" }}>{live ? <Badge kind={live.paused ? "warn" : "live"}>{live.paused ? "paused" : "capturing"} on {live.interfaces.join(", ")}</Badge> : <Badge>not capturing</Badge>}</div>
<div className="row" style={{ marginTop: ".4rem" }}>{live ? <Badge kind={live.paused ? "warn" : "live"}>{live.paused ? "paused" : "capturing"} on {(live.interfaces ?? []).join(", ")}</Badge> : <Badge>not capturing</Badge>}</div>
</div>
<div className="card"><h3>Traffic rate</h3>
<div className="metric">{rate ? Math.round(rate.pps).toLocaleString() : "–"}<small>pkt/s</small></div>
+2 -2
View File
@@ -26,8 +26,8 @@ export default function TlsExplorer({ sessionId }: { sessionId: string }) {
{Object.keys(verCount).length > 0 && <div className="row" style={{ marginBottom: ".8rem" }}>{Object.entries(verCount).map(([v, n]) => <Badge key={v} kind="plain">{v}: {n}</Badge>)}</div>}
<div className="row" style={{ marginBottom: ".8rem" }}>
<div className="row" style={{ gap: ".25rem" }}>
<button className={tab === "handshakes" ? "on" : ""} onClick={() => setTab("handshakes")}>Handshakes ({data?.handshakes.length ?? 0})</button>
<button className={tab === "certs" ? "on" : ""} onClick={() => setTab("certs")}>Certificates ({data?.certificates.length ?? 0})</button>
<button className={tab === "handshakes" ? "on" : ""} onClick={() => setTab("handshakes")}>Handshakes ({data?.handshakes?.length ?? 0})</button>
<button className={tab === "certs" ? "on" : ""} onClick={() => setTab("certs")}>Certificates ({data?.certificates?.length ?? 0})</button>
</div>
<input placeholder="Filter SNI / server / subject" value={q} onChange={(e) => setQ(e.target.value)} />
</div>