mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 09:20:15 +01:00
🌄 feat: Add RouteErrorBoundary for Improved Client Error handling (#5396)
* feat: Add RouteErrorBoundary for improved error handling and integrate react-error-boundary package * feat: update error message * fix: correct typo in containerClassName prop in Landing component
This commit is contained in:
parent
ed57bb4711
commit
b8b7f40e98
4 changed files with 235 additions and 0 deletions
|
|
@ -79,6 +79,7 @@
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-error-boundary": "^5.0.0",
|
||||||
"react-flip-toolkit": "^7.1.0",
|
"react-flip-toolkit": "^7.1.0",
|
||||||
"react-gtm-module": "^2.0.11",
|
"react-gtm-module": "^2.0.11",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
|
|
|
||||||
216
client/src/routes/RouteErrorBoundary.tsx
Normal file
216
client/src/routes/RouteErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
import { useRouteError } from 'react-router-dom';
|
||||||
|
import { Button } from '~/components/ui';
|
||||||
|
|
||||||
|
interface UserAgentData {
|
||||||
|
getHighEntropyValues(hints: string[]): Promise<{ platform: string; platformVersion: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlatformInfo = {
|
||||||
|
os: string;
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatStackTrace = (stack: string) => {
|
||||||
|
return stack
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line, i) => ({
|
||||||
|
number: i + 1,
|
||||||
|
content: line,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlatformInfo = async (): Promise<PlatformInfo> => {
|
||||||
|
if ('userAgentData' in navigator) {
|
||||||
|
try {
|
||||||
|
const ua = navigator.userAgentData as UserAgentData;
|
||||||
|
const highEntropyValues = await ua.getHighEntropyValues(['platform', 'platformVersion']);
|
||||||
|
return {
|
||||||
|
os: highEntropyValues.platform,
|
||||||
|
version: highEntropyValues.platformVersion,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to get high entropy values:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
|
|
||||||
|
if (userAgent.includes('mac')) {
|
||||||
|
return { os: 'macOS' };
|
||||||
|
}
|
||||||
|
if (userAgent.includes('win')) {
|
||||||
|
return { os: 'Windows' };
|
||||||
|
}
|
||||||
|
if (userAgent.includes('linux')) {
|
||||||
|
return { os: 'Linux' };
|
||||||
|
}
|
||||||
|
if (userAgent.includes('android')) {
|
||||||
|
return { os: 'Android' };
|
||||||
|
}
|
||||||
|
if (userAgent.includes('ios') || userAgent.includes('iphone') || userAgent.includes('ipad')) {
|
||||||
|
return { os: 'iOS' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { os: 'Unknown' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBrowserInfo = async () => {
|
||||||
|
const platformInfo = await getPlatformInfo();
|
||||||
|
return {
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
platform: platformInfo.os,
|
||||||
|
platformVersion: platformInfo.version,
|
||||||
|
language: navigator.language,
|
||||||
|
windowSize: `${window.innerWidth}x${window.innerHeight}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RouteErrorBoundary() {
|
||||||
|
const typedError = useRouteError() as {
|
||||||
|
message?: string;
|
||||||
|
stack?: string;
|
||||||
|
status?: number;
|
||||||
|
statusText?: string;
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorDetails = {
|
||||||
|
message: typedError.message ?? 'An unexpected error occurred',
|
||||||
|
stack: typedError.stack,
|
||||||
|
status: typedError.status,
|
||||||
|
statusText: typedError.statusText,
|
||||||
|
data: typedError.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadLogs = async () => {
|
||||||
|
const browser = await getBrowserInfo();
|
||||||
|
const errorLog = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
browser,
|
||||||
|
error: {
|
||||||
|
...errorDetails,
|
||||||
|
stack:
|
||||||
|
errorDetails.stack != null && errorDetails.stack.trim() !== ''
|
||||||
|
? formatStackTrace(errorDetails.stack)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(errorLog, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `error-log-${new Date().toISOString()}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyStack = async () => {
|
||||||
|
if (errorDetails.stack != null && errorDetails.stack !== '') {
|
||||||
|
await navigator.clipboard.writeText(errorDetails.stack);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex min-h-screen flex-col items-center justify-center bg-surface-primary bg-gradient-to-br"
|
||||||
|
>
|
||||||
|
<div className="bg-surface-primary/60 mx-4 w-11/12 max-w-4xl rounded-2xl border border-border-light p-8 shadow-2xl backdrop-blur-xl">
|
||||||
|
<h2 className="mb-6 text-center text-3xl font-medium tracking-tight text-text-primary">
|
||||||
|
Oops! Something Unexpected Occurred
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
<div className="mb-4 rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-sm text-gray-600 dark:text-gray-200">
|
||||||
|
<h3 className="mb-2 font-medium">Error Message:</h3>
|
||||||
|
<pre className="whitespace-pre-wrap text-sm font-light leading-relaxed text-text-primary">
|
||||||
|
{errorDetails.message}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Information */}
|
||||||
|
{(typeof errorDetails.status === 'number' ||
|
||||||
|
typeof errorDetails.statusText === 'string') && (
|
||||||
|
<div className="mb-4 rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4 text-sm text-text-primary">
|
||||||
|
<h3 className="mb-2 font-medium">Status:</h3>
|
||||||
|
<p className="text-text-primary">
|
||||||
|
{typeof errorDetails.status === 'number' && `${errorDetails.status} `}
|
||||||
|
{typeof errorDetails.statusText === 'string' && errorDetails.statusText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stack Trace - Collapsible */}
|
||||||
|
{errorDetails.stack != null && errorDetails.stack.trim() !== '' && (
|
||||||
|
<details className="group mb-4 rounded-xl border border-border-light p-4">
|
||||||
|
<summary className="mb-2 flex cursor-pointer items-center justify-between text-sm font-medium text-text-primary">
|
||||||
|
<span>Stack Trace</span>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyStack}
|
||||||
|
className="ml-2 px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<div className="overflow-x-auto rounded-lg bg-black/5 p-4 dark:bg-white/5">
|
||||||
|
{formatStackTrace(errorDetails.stack).map(({ number, content }) => (
|
||||||
|
<div key={number} className="flex">
|
||||||
|
<span className="select-none pr-4 font-mono text-xs text-text-secondary">
|
||||||
|
{String(number).padStart(3, '0')}
|
||||||
|
</span>
|
||||||
|
<pre className="flex-1 font-mono text-xs leading-relaxed text-text-primary">
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Additional Error Data */}
|
||||||
|
{errorDetails.data != null && (
|
||||||
|
<details className="group mb-4 rounded-xl border border-border-light p-4">
|
||||||
|
<summary className="mb-2 flex cursor-pointer items-center justify-between text-sm font-medium text-text-primary">
|
||||||
|
<span>Additional Details</span>
|
||||||
|
<span className="transition-transform group-open:rotate-90">{'>'}</span>
|
||||||
|
</summary>
|
||||||
|
<pre className="whitespace-pre-wrap text-xs font-light leading-relaxed text-text-primary">
|
||||||
|
{JSON.stringify(errorDetails.data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-4">
|
||||||
|
<p className="text-sm font-light text-text-secondary">Please try one of the following:</p>
|
||||||
|
<ul className="list-inside list-disc text-sm text-text-secondary">
|
||||||
|
<li>Refresh the page</li>
|
||||||
|
<li>Clear your browser cache</li>
|
||||||
|
<li>Check your internet connection</li>
|
||||||
|
<li>Contact the Admin if the issue persists</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-4 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
|
||||||
|
<Button
|
||||||
|
variant="submit"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Refresh Page
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleDownloadLogs} className="w-full sm:w-auto">
|
||||||
|
Download Error Logs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
ApiErrorWatcher,
|
ApiErrorWatcher,
|
||||||
} from '~/components/Auth';
|
} from '~/components/Auth';
|
||||||
import { AuthContextProvider } from '~/hooks/AuthContext';
|
import { AuthContextProvider } from '~/hooks/AuthContext';
|
||||||
|
import RouteErrorBoundary from './RouteErrorBoundary';
|
||||||
import StartupLayout from './Layouts/Startup';
|
import StartupLayout from './Layouts/Startup';
|
||||||
import LoginLayout from './Layouts/Login';
|
import LoginLayout from './Layouts/Login';
|
||||||
import dashboardRoutes from './Dashboard';
|
import dashboardRoutes from './Dashboard';
|
||||||
|
|
@ -27,10 +28,12 @@ export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: 'share/:shareId',
|
path: 'share/:shareId',
|
||||||
element: <ShareRoute />,
|
element: <ShareRoute />,
|
||||||
|
errorElement: <RouteErrorBoundary />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <StartupLayout />,
|
element: <StartupLayout />,
|
||||||
|
errorElement: <RouteErrorBoundary />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'register',
|
path: 'register',
|
||||||
|
|
@ -49,9 +52,11 @@ export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: 'verify',
|
path: 'verify',
|
||||||
element: <VerifyEmail />,
|
element: <VerifyEmail />,
|
||||||
|
errorElement: <RouteErrorBoundary />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: <AuthLayout />,
|
element: <AuthLayout />,
|
||||||
|
errorElement: <RouteErrorBoundary />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|
|
||||||
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -941,6 +941,7 @@
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-error-boundary": "^5.0.0",
|
||||||
"react-flip-toolkit": "^7.1.0",
|
"react-flip-toolkit": "^7.1.0",
|
||||||
"react-gtm-module": "^2.0.11",
|
"react-gtm-module": "^2.0.11",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
|
|
@ -29392,6 +29393,18 @@
|
||||||
"react": "^18.2.0"
|
"react": "^18.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-error-boundary": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-flip-toolkit": {
|
"node_modules/react-flip-toolkit": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-flip-toolkit/-/react-flip-toolkit-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-flip-toolkit/-/react-flip-toolkit-7.1.0.tgz",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue