fix: frontend error handling — ErrorBoundary, type-safe errors, stable keys

- React ErrorBoundary wrapping entire app for graceful crash recovery
- fetchJSON error handling uses try/catch instead of .catch() chain
- CertificateDetailPage: instanceof checks replace unsafe type casts
- DataTable: keyField prop replaces array index keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-20 01:20:32 -04:00
parent 7618c5a734
commit bb7a78352e
5 changed files with 91 additions and 31 deletions
+8 -2
View File
@@ -33,8 +33,14 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
throw new Error('Authentication required'); throw new Error('Authentication required');
} }
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({ message: res.statusText })); let errorMsg = res.statusText;
throw new Error(body.message || body.error || `HTTP ${res.status}`); try {
const body = await res.json();
errorMsg = body.message || body.error || errorMsg;
} catch {
// Response body is not JSON, use status text
}
throw new Error(errorMsg || `HTTP ${res.status}`);
} }
return res.json(); return res.json();
} }
+3 -2
View File
@@ -11,9 +11,10 @@ interface DataTableProps<T> {
onRowClick?: (item: T) => void; onRowClick?: (item: T) => void;
emptyMessage?: string; emptyMessage?: string;
isLoading?: boolean; isLoading?: boolean;
keyField?: string;
} }
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading }: DataTableProps<T>) { export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id' }: DataTableProps<T>) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-16 text-slate-400"> <div className="flex items-center justify-center py-16 text-slate-400">
@@ -49,7 +50,7 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
<tbody> <tbody>
{data.map((item, i) => ( {data.map((item, i) => (
<tr <tr
key={i} key={(item as Record<string, unknown>)[keyField] as string ?? `row-${i}`}
onClick={() => onRowClick?.(item)} onClick={() => onRowClick?.(item)}
className={`border-b border-slate-700/50 transition-colors hover:bg-blue-500/5 ${onRowClick ? 'cursor-pointer' : ''}`} className={`border-b border-slate-700/50 transition-colors hover:bg-blue-500/5 ${onRowClick ? 'cursor-pointer' : ''}`}
> >
+50
View File
@@ -0,0 +1,50 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught component error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center min-h-screen bg-slate-900">
<div className="text-center p-8">
<h1 className="text-xl font-semibold text-red-400 mb-2">Something went wrong</h1>
<p className="text-sm text-slate-400 mb-4">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.reload();
}}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-500"
>
Reload Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
+26 -23
View File
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import ErrorBoundary from './components/ErrorBoundary';
import AuthProvider from './components/AuthProvider'; import AuthProvider from './components/AuthProvider';
import AuthGate from './components/AuthGate'; import AuthGate from './components/AuthGate';
import Layout from './components/Layout'; import Layout from './components/Layout';
@@ -30,28 +31,30 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <ErrorBoundary>
<AuthProvider> <QueryClientProvider client={queryClient}>
<AuthGate> <AuthProvider>
<BrowserRouter> <AuthGate>
<Routes> <BrowserRouter>
<Route element={<Layout />}> <Routes>
<Route index element={<DashboardPage />} /> <Route element={<Layout />}>
<Route path="certificates" element={<CertificatesPage />} /> <Route index element={<DashboardPage />} />
<Route path="certificates/:id" element={<CertificateDetailPage />} /> <Route path="certificates" element={<CertificatesPage />} />
<Route path="agents" element={<AgentsPage />} /> <Route path="certificates/:id" element={<CertificateDetailPage />} />
<Route path="agents/:id" element={<AgentDetailPage />} /> <Route path="agents" element={<AgentsPage />} />
<Route path="jobs" element={<JobsPage />} /> <Route path="agents/:id" element={<AgentDetailPage />} />
<Route path="notifications" element={<NotificationsPage />} /> <Route path="jobs" element={<JobsPage />} />
<Route path="policies" element={<PoliciesPage />} /> <Route path="notifications" element={<NotificationsPage />} />
<Route path="issuers" element={<IssuersPage />} /> <Route path="policies" element={<PoliciesPage />} />
<Route path="targets" element={<TargetsPage />} /> <Route path="issuers" element={<IssuersPage />} />
<Route path="audit" element={<AuditPage />} /> <Route path="targets" element={<TargetsPage />} />
</Route> <Route path="audit" element={<AuditPage />} />
</Routes> </Route>
</BrowserRouter> </Routes>
</AuthGate> </BrowserRouter>
</AuthProvider> </AuthGate>
</QueryClientProvider> </AuthProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode> </StrictMode>
); );
+4 -4
View File
@@ -130,7 +130,7 @@ export default function CertificateDetailPage() {
)} )}
{renewMutation.isError && ( {renewMutation.isError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm"> <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm">
Failed to trigger renewal: {(renewMutation.error as Error).message} Failed to trigger renewal: {renewMutation.error instanceof Error ? renewMutation.error.message : 'Unknown error'}
</div> </div>
)} )}
{deployMutation.isSuccess && ( {deployMutation.isSuccess && (
@@ -140,12 +140,12 @@ export default function CertificateDetailPage() {
)} )}
{deployMutation.isError && ( {deployMutation.isError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm"> <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm">
Failed to deploy: {(deployMutation.error as Error).message} Failed to deploy: {deployMutation.error instanceof Error ? deployMutation.error.message : 'Unknown error'}
</div> </div>
)} )}
{archiveMutation.isError && ( {archiveMutation.isError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm"> <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm">
Failed to archive: {(archiveMutation.error as Error).message} Failed to archive: {archiveMutation.error instanceof Error ? archiveMutation.error.message : 'Unknown error'}
</div> </div>
)} )}
@@ -226,7 +226,7 @@ export default function CertificateDetailPage() {
<h2 className="text-lg font-semibold text-slate-200 mb-4">Deploy Certificate</h2> <h2 className="text-lg font-semibold text-slate-200 mb-4">Deploy Certificate</h2>
{deployMutation.isError && ( {deployMutation.isError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3"> <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3">
{(deployMutation.error as Error).message} {deployMutation.error instanceof Error ? deployMutation.error.message : 'Unknown error'}
</div> </div>
)} )}
<label className="text-xs text-slate-400 block mb-2">Select Target</label> <label className="text-xs text-slate-400 block mb-2">Select Target</label>