UI robustness: error boundary per page; fix null-array crashes (TLS certificates, idle dashboard interfaces, stats slices)
This commit is contained in:
@@ -187,8 +187,7 @@ func (e *Engine) Statistics(ctx context.Context, id string, buckets int) (Stats,
|
||||
if buckets <= 0 || buckets > 2000 {
|
||||
buckets = 300
|
||||
}
|
||||
var s Stats
|
||||
s.Counters = map[string]int64{}
|
||||
s := Stats{Counters: map[string]int64{}, Protocols: []NameCount{}, SizeBuckets: []NameCount{}, Timeline: []TimeBucket{}}
|
||||
err := e.DB.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(SUM(len),0), COALESCE(MIN(ts),0), COALESCE(MAX(ts),0) FROM packets WHERE session_id=?`, id).
|
||||
Scan(&s.Packets, &s.Bytes, &s.Start, &s.End)
|
||||
if err != nil || s.Packets == 0 {
|
||||
@@ -552,7 +551,7 @@ func (e *Engine) TLS(ctx context.Context, id string, limit int) ([]TLSRecord, []
|
||||
certFields := []string{"ip.src", "ipv6.src", "x509if.printableString", "x509sat.uTF8String", "x509ce.dNSName", "x509af.notBefore", "x509af.notAfter"}
|
||||
certRows, err := e.extractFields(ctx, id, "tls.handshake.type==11", certFields, limit)
|
||||
if err != nil {
|
||||
return recs, nil, nil // handshake records are still useful
|
||||
return recs, []CertRecord{}, nil // handshake records are still useful
|
||||
}
|
||||
certs := []CertRecord{}
|
||||
for _, c := range certRows {
|
||||
|
||||
Vendored
+2
-1
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user