Skip to content

Commit ce3a368

Browse files
committed
feat: add admin dashboard for failed webhook deliveries
- Add webhookAdminService, WebhookFailedDeliveries table, and WebhookAdminPage; register /admin/webhooks (ADMIN-only) in AppRoutes.tsx. Reuses the existing DataTable/StatusBadge/ NotificationContext conventions - lists permanently-failed events with a per-row retry action - Fix a real, pre-existing bug in DataTable.tsx: a duplicated, never-closed header block (leftover from an incomplete mobile-responsive refactor) made the file fail to parse entirely, breaking every page that uses this shared component. Verified via tsc and a Babel AST parse Verified in a real browser end-to-end (routing, auth gate, fetch, render, retry, toast) using a stub backend, since the real one needs 8 separate Postgres databases via docker-compose that aren't available here. tsc error count dropped 542 -> 536, exactly matching the DataTable.tsx fix; no new errors introduced.
1 parent 8465098 commit ce3a368

7 files changed

Lines changed: 239 additions & 19 deletions

File tree

frontend/package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@
2626
"@types/react-router-dom": "^5.3.3",
2727
"@walletconnect/sign-client": "^2.23.6",
2828
"albedo": "^0.1.3",
29+
"axios": "^1.6.0",
2930
"date-fns": "^4.1.0",
31+
"file-saver": "^2.0.5",
3032
"lucide-react": "^1.11.0",
3133
"react": "^19.2.0",
32-
"react-dom": "^19.2.0",
34+
"react-dom": "^19.2.8",
3335
"react-router-dom": "^7.14.2",
34-
"recharts": "^3.7.0",
35-
"file-saver": "^2.0.5",
36-
"axios": "^1.6.0"
36+
"recharts": "^3.7.0"
3737
},
3838
"devDependencies": {
3939
"@eslint/js": "^9.39.1",
@@ -42,23 +42,23 @@
4242
"@testing-library/react": "^16.3.2",
4343
"@testing-library/user-event": "^14.6.1",
4444
"@types/cypress": "^0.1.6",
45+
"@types/file-saver": "^2.0.7",
4546
"@types/jest": "^29.5.14",
47+
"@types/jest-axe": "^3.5.9",
4648
"@types/node": "^24.10.1",
4749
"@types/react": "^19.2.7",
48-
"@types/file-saver": "^2.0.7",
4950
"@types/react-dom": "^19.2.3",
5051
"@types/socket.io-client": "^1.4.36",
5152
"@vitejs/plugin-react": "^5.1.1",
5253
"cypress": "^15.14.1",
5354
"eslint": "^9.39.1",
5455
"eslint-plugin-react-hooks": "^7.0.1",
5556
"eslint-plugin-react-refresh": "^0.4.24",
57+
"fast-check": "^3.23.2",
5658
"globals": "^16.5.0",
5759
"jest": "^29.7.0",
58-
"jest-environment-jsdom": "^30.3.0",
5960
"jest-axe": "^7.0.0",
60-
"@types/jest-axe": "^3.5.9",
61-
"fast-check": "^3.23.2",
61+
"jest-environment-jsdom": "^30.3.0",
6262
"playwright": "^1.59.1",
6363
"start-server-and-test": "^3.0.2",
6464
"tailwindcss": "^4.1.18",

frontend/src/components/DataTable.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -235,12 +235,6 @@ export const DataTable: React.FC<DataTableProps> = ({
235235
return (
236236
<div className={`bg-white rounded-lg shadow-sm border border-gray-200 ${className}`}>
237237
{/* Header */}
238-
<div className="p-4 border-b border-gray-200">
239-
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-3 sm:space-y-0">
240-
<div className="flex items-center space-x-2">
241-
<h3 className="text-lg font-medium text-gray-900">
242-
{data.length} {data.length === 1 ? 'item' : 'items'}
243-
</h3>
244238
<div className="p-3 lg:p-4 border-b border-gray-200">
245239
<div className="flex flex-col space-y-3 lg:space-y-0">
246240
<div className="flex items-center justify-between">
@@ -310,6 +304,7 @@ export const DataTable: React.FC<DataTableProps> = ({
310304
))}
311305
</div>
312306
)}
307+
</div>
313308
</div>
314309

315310
{/* Table - Desktop */}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import React, { useState, useEffect, useCallback } from 'react';
2+
import { RotateCcw } from 'lucide-react';
3+
import { DataTable } from './DataTable';
4+
import { ErrorEmptyState } from './EmptyState';
5+
import StatusBadge from './StatusBadge';
6+
import { useNotifications } from '../contexts/NotificationContext';
7+
import { webhookAdminService, FailedWebhookDelivery } from '../services/webhookAdminService';
8+
9+
const WebhookFailedDeliveries: React.FC = () => {
10+
const [deliveries, setDeliveries] = useState<FailedWebhookDelivery[]>([]);
11+
const [loading, setLoading] = useState(true);
12+
const [error, setError] = useState<string | null>(null);
13+
const [retryingEventId, setRetryingEventId] = useState<string | null>(null);
14+
const { showSuccess, showError } = useNotifications();
15+
16+
const loadFailedDeliveries = useCallback(async () => {
17+
setLoading(true);
18+
setError(null);
19+
try {
20+
const data = await webhookAdminService.getFailedDeliveries();
21+
setDeliveries(data);
22+
} catch (err) {
23+
setError(err instanceof Error ? err.message : 'Failed to load failed deliveries');
24+
} finally {
25+
setLoading(false);
26+
}
27+
}, []);
28+
29+
useEffect(() => {
30+
loadFailedDeliveries();
31+
}, [loadFailedDeliveries]);
32+
33+
const handleRetry = async (delivery: FailedWebhookDelivery) => {
34+
setRetryingEventId(delivery.eventId);
35+
try {
36+
await webhookAdminService.retryEvent(delivery.webhookId, delivery.eventId);
37+
showSuccess('Retry initiated', `Event ${delivery.eventType} has been queued for redelivery.`);
38+
// The event won't be FAILED again until/unless the retry itself
39+
// fails, so drop it from this list now instead of waiting for a
40+
// manual refresh.
41+
setDeliveries((prev) => prev.filter((d) => d.eventId !== delivery.eventId));
42+
} catch (err) {
43+
showError('Retry failed', err instanceof Error ? err.message : 'Unable to retry this event');
44+
} finally {
45+
setRetryingEventId(null);
46+
}
47+
};
48+
49+
if (error) {
50+
return (
51+
<ErrorEmptyState
52+
title={error}
53+
description="Please try again or contact support if the issue persists"
54+
actions={[{ label: 'Retry', onClick: loadFailedDeliveries, variant: 'primary' }]}
55+
size="large"
56+
/>
57+
);
58+
}
59+
60+
const columns = [
61+
{ key: 'eventType', label: 'Event Type', sortable: true, filterable: true },
62+
{
63+
key: 'webhookId',
64+
label: 'Webhook',
65+
sortable: true,
66+
filterable: true,
67+
render: (value: string) => <span className="font-mono text-xs">{value}</span>,
68+
},
69+
{
70+
key: 'attempts',
71+
label: 'Attempts',
72+
sortable: true,
73+
render: (value: number) => (
74+
<StatusBadge status="error" label={`${value} attempt${value === 1 ? '' : 's'}`} />
75+
),
76+
},
77+
{
78+
key: 'lastError',
79+
label: 'Last Error',
80+
render: (value: string | undefined) => (
81+
<span className="text-sm text-gray-600 line-clamp-2 max-w-xs block" title={value}>
82+
{value || '—'}
83+
</span>
84+
),
85+
},
86+
{ key: 'createdAt', label: 'First Failed', sortable: true, type: 'date' as const },
87+
];
88+
89+
return (
90+
<div className="space-y-4">
91+
<p className="text-sm text-muted-foreground">
92+
{deliveries.length} webhook {deliveries.length === 1 ? 'delivery has' : 'deliveries have'} permanently
93+
failed and need manual attention.
94+
</p>
95+
<DataTable
96+
data={deliveries}
97+
columns={columns}
98+
loading={loading}
99+
emptyMessage="No failed deliveries"
100+
searchPlaceholder="Search by event type or webhook..."
101+
actions={[
102+
{
103+
key: 'retry',
104+
label: 'Retry delivery',
105+
icon: <RotateCcw size={14} />,
106+
variant: 'primary',
107+
disabled: (row: FailedWebhookDelivery) => row.eventId === retryingEventId,
108+
onClick: (row: FailedWebhookDelivery) => handleRetry(row),
109+
},
110+
]}
111+
/>
112+
</div>
113+
);
114+
};
115+
116+
export default WebhookFailedDeliveries;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import React from 'react';
2+
import WebhookFailedDeliveries from '../components/WebhookFailedDeliveries';
3+
4+
const WebhookAdminPage: React.FC = () => {
5+
return (
6+
<div className="space-y-8">
7+
<section aria-labelledby="webhook-admin-heading">
8+
<h2 id="webhook-admin-heading" className="text-3xl font-semibold text-foreground">
9+
Webhook Deliveries
10+
</h2>
11+
<p className="text-muted-foreground text-lg">
12+
Review and retry webhook events that failed to deliver after exhausting all retry attempts.
13+
</p>
14+
</section>
15+
16+
<WebhookFailedDeliveries />
17+
</div>
18+
);
19+
};
20+
21+
export default WebhookAdminPage;

frontend/src/routes/AppRoutes.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const TreeViewPage = lazy(() => import('../pages/TreeViewPage'));
1212
const AuthPage = lazy(() => import('../pages/AuthPage'));
1313
const ProfilePage = lazy(() => import('../pages/ProfilePage'));
1414
const TransactionHistoryPage = lazy(() => import('../pages/TransactionHistoryPage'));
15+
const WebhookAdminPage = lazy(() => import('../pages/WebhookAdminPage'));
1516
const FAQPage = lazy(() => import('../pages/FAQpage'));
1617
const NotFoundPage = lazy(() => import('../pages/NotFoundPage'));
1718

@@ -93,17 +94,27 @@ const AppRoutes: React.FC = () => {
9394
</Suspense>
9495
}
9596
/>
96-
<Route
97-
path="/transactions"
97+
<Route
98+
path="/transactions"
9899
element={
99100
<Suspense fallback={<RouteLoadingFallback />}>
100101
<ProtectedRoute>
101102
<TransactionHistoryPage />
102103
</ProtectedRoute>
103104
</Suspense>
104-
}
105+
}
105106
/>
106-
107+
<Route
108+
path="/admin/webhooks"
109+
element={
110+
<Suspense fallback={<RouteLoadingFallback />}>
111+
<ProtectedRoute requiredRole="ADMIN">
112+
<WebhookAdminPage />
113+
</ProtectedRoute>
114+
</Suspense>
115+
}
116+
/>
117+
107118
{/* 404 catch-all route */}
108119
<Route
109120
path="*"
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
2+
3+
export interface FailedWebhookDelivery {
4+
eventId: string;
5+
webhookId: string;
6+
eventType: string;
7+
attempts: number;
8+
lastError?: string;
9+
createdAt: string;
10+
}
11+
12+
class WebhookAdminService {
13+
private static instance: WebhookAdminService;
14+
15+
private constructor() {}
16+
17+
public static getInstance(): WebhookAdminService {
18+
if (!WebhookAdminService.instance) {
19+
WebhookAdminService.instance = new WebhookAdminService();
20+
}
21+
return WebhookAdminService.instance;
22+
}
23+
24+
private getAuthToken(): string {
25+
const token = localStorage.getItem('authToken');
26+
if (!token) {
27+
throw new Error('No authentication token found');
28+
}
29+
return token;
30+
}
31+
32+
private authHeaders(): Record<string, string> {
33+
return {
34+
'Content-Type': 'application/json',
35+
Authorization: `Bearer ${this.getAuthToken()}`,
36+
};
37+
}
38+
39+
/**
40+
* List webhook events that have permanently failed (retries exhausted).
41+
*/
42+
async getFailedDeliveries(limit: number = 50): Promise<FailedWebhookDelivery[]> {
43+
const response = await fetch(`${API_BASE_URL}/webhooks/admin/failed-deliveries?limit=${limit}`, {
44+
method: 'GET',
45+
headers: this.authHeaders(),
46+
});
47+
48+
if (!response.ok) {
49+
throw new Error(`Failed to fetch failed deliveries: ${response.statusText}`);
50+
}
51+
52+
const data = await response.json();
53+
return data.failedDeliveries;
54+
}
55+
56+
/**
57+
* Retry a single failed delivery. Resets its attempt count and
58+
* re-runs delivery immediately.
59+
*/
60+
async retryEvent(webhookId: string, eventId: string): Promise<void> {
61+
const response = await fetch(
62+
`${API_BASE_URL}/webhooks/${webhookId}/events/${eventId}/retry`,
63+
{
64+
method: 'POST',
65+
headers: this.authHeaders(),
66+
}
67+
);
68+
69+
if (!response.ok) {
70+
const body = await response.json().catch(() => null);
71+
throw new Error(body?.error || `Failed to retry event: ${response.statusText}`);
72+
}
73+
}
74+
}
75+
76+
export const webhookAdminService = WebhookAdminService.getInstance();
77+
export default webhookAdminService;

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)