Files
OpsLog/frontend/src/components/ErrorBoundary.tsx
T
rouggy d322ea9a07 feat: report UI crashes instead of showing a blank window
Two white-screen reports are open — one after logging a 10 GHz QSO, one on
starting a DVK auto-call — and neither could be investigated, for the same
reason: a render error emptied the window and left NOTHING. No line in
opslog.log, no dialog, nothing the operator could send but the words "white
screen". A fault the user cannot report is a fault that cannot be fixed.

So before hunting either trigger, the reporting path:

  - An error boundary catches a render throw, writes the error and component
    stack to the app log through the new LogUIError binding, and shows it on
    screen — selectable, with Copy and Reload.
  - Global handlers catch what a boundary cannot: throws from timers, event
    handlers and rejected promises. An auto-call loop lives entirely in
    callbacks, which is exactly where the second report came from, and those
    leave no trace at all today.

It deliberately does not try to resume: React cannot promise sane state after a
render throw, and a half-working logger would be worse than a clear stop.

This does not fix either crash. It makes the next occurrence arrive with its
cause attached, which is the step that has been missing.
2026-07-30 14:36:19 +02:00

113 lines
4.7 KiB
TypeScript

import React from 'react';
import { LogUIError } from '../../wailsjs/go/main/App';
// A render error used to empty the window and leave nothing behind: no line in
// opslog.log, no dialog, no way for the operator to say more than "white screen".
// Two separate reports — one after logging a 10 GHz QSO, one on starting a DVK
// auto-call — stayed undiagnosed for weeks for exactly that reason.
//
// So this does two things, in order of importance:
//
// 1. Writes the error and its stack to the app log through LogUIError, so the
// next report arrives WITH the cause instead of a description of a blank
// screen.
// 2. Shows it, with the text selectable and a button to copy it, because the
// person who can act on it is the one looking at the screen.
//
// It deliberately does NOT try to recover the tree. React cannot guarantee the
// state is sane after a render throw, and a half-working logger is worse than
// one that says plainly what happened and offers a reload.
type Props = { children: React.ReactNode };
type State = { error: Error | null; info: string };
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null, info: '' };
static getDerivedStateFromError(error: Error): Partial<State> {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
const stack = (info?.componentStack || error.stack || '').trim();
this.setState({ info: stack });
// Never let the reporting path throw: it runs while the app is already broken.
try {
LogUIError('render crash', `${error.name}: ${error.message}`, stack);
} catch {
/* the backend may be gone too — the on-screen copy still works */
}
}
render() {
const { error, info } = this.state;
if (!error) return this.props.children;
const report = `${error.name}: ${error.message}\n\n${info}`;
return (
<div style={{
position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24, background: '#0b1220', color: '#e5e7eb', fontFamily: 'ui-sans-serif, system-ui, sans-serif',
overflow: 'auto', zIndex: 9999,
}}>
<div style={{ maxWidth: 820, width: '100%' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px' }}>
OpsLog hit an error and stopped drawing
</h1>
<p style={{ fontSize: 13, opacity: 0.8, margin: '0 0 14px' }}>
Your log is safe this is the window, not the database. The details below
are already in the app log; send them with your report.
</p>
<pre style={{
whiteSpace: 'pre-wrap', wordBreak: 'break-word', userSelect: 'text',
fontSize: 11.5, lineHeight: 1.5, background: '#111827', border: '1px solid #1f2937',
borderRadius: 8, padding: 12, maxHeight: '48vh', overflow: 'auto', margin: 0,
}}>{report}</pre>
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
<button
onClick={() => { void navigator.clipboard?.writeText(report); }}
style={{
padding: '7px 14px', borderRadius: 6, border: '1px solid #374151',
background: '#1f2937', color: '#e5e7eb', fontSize: 13, cursor: 'pointer',
}}>
Copy the details
</button>
<button
onClick={() => window.location.reload()}
style={{
padding: '7px 14px', borderRadius: 6, border: '1px solid #1d4ed8',
background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}>
Reload the window
</button>
</div>
</div>
</div>
);
}
}
// installGlobalErrorLogging catches what a boundary cannot: an error thrown from
// a timer, an event handler or a rejected promise. Those do not unmount the tree,
// so they leave no trace at all — and an auto-call loop lives entirely in
// callbacks, which is precisely where a white screen was reported.
export function installGlobalErrorLogging() {
const report = (kind: string, message: string, stack?: string) => {
try {
LogUIError(kind, message, stack || '');
} catch {
/* nothing more we can do from here */
}
};
window.addEventListener('error', (e: ErrorEvent) => {
const src = e.filename ? ` (${e.filename}:${e.lineno}:${e.colno})` : '';
report('uncaught error', (e.message || 'unknown error') + src, e.error?.stack);
});
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
const r: any = e.reason;
report('unhandled rejection', String(r?.message ?? r ?? 'unknown'), r?.stack);
});
}