mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 16:28:58 +00:00
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:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' : ''}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user