diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.test.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.test.ts new file mode 100644 index 0000000000..77e38dd6f9 --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import { + createInitialPlatformWebhooksState, + filterWebhookDeliveries, + retryWebhookDelivery, +} from './PlatformWebhooks.store' + +describe('PlatformWebhooks.store', () => { + it('retries an individual delivery by resetting it to pending with a new timestamp', () => { + const nextState = retryWebhookDelivery( + createInitialPlatformWebhooksState('project'), + 'project-delivery-2', + { now: '2026-03-17T02:15:00.000Z' } + ) + + expect(nextState.deliveries.find((delivery) => delivery.id === 'project-delivery-2')).toEqual({ + id: 'project-delivery-2', + endpointId: 'project-endpoint-1', + eventType: 'project.resource_exhausted', + status: 'pending', + responseCode: undefined, + attemptAt: '2026-03-17T02:15:00.000Z', + }) + }) + + it('returns endpoint deliveries sorted by latest attempt first after a retry', () => { + const nextState = retryWebhookDelivery( + createInitialPlatformWebhooksState('project'), + 'project-delivery-2', + { now: '2026-03-17T02:15:00.000Z' } + ) + + expect( + filterWebhookDeliveries(nextState.deliveries, 'project-endpoint-1', '').map( + (delivery) => delivery.id + ) + ).toEqual(['project-delivery-2', 'project-delivery-1', 'project-delivery-3']) + }) + + it('does not retry successful deliveries', () => { + const initialState = createInitialPlatformWebhooksState('project') + const nextState = retryWebhookDelivery(initialState, 'project-delivery-1', { + now: '2026-03-17T02:15:00.000Z', + }) + + expect(nextState).toBe(initialState) + }) +}) diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts index a73e6461c4..3ec00f9d5c 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts @@ -20,6 +20,10 @@ interface UpdateEndpointOptions { headerIdFactory?: () => string } +interface RetryDeliveryOptions { + now?: string +} + const secureRandomHex = (length: number) => { if (length <= 0) return '' @@ -180,6 +184,31 @@ export const regenerateWebhookEndpointSecret = ( } } +export const retryWebhookDelivery = ( + state: PlatformWebhooksState, + deliveryId: string, + options?: RetryDeliveryOptions +) => { + const delivery = state.deliveries.find((item) => item.id === deliveryId) + if (!delivery || delivery.status === 'success') return state + + const now = options?.now ?? new Date().toISOString() + + return { + ...state, + deliveries: state.deliveries.map((delivery) => { + if (delivery.id !== deliveryId) return delivery + + return { + ...delivery, + status: 'pending', + responseCode: undefined, + attemptAt: now, + } + }), + } +} + export const filterWebhookEndpoints = (endpoints: WebhookEndpoint[], search: string) => { const normalizedSearch = normalizeSearch(search) if (normalizedSearch.length === 0) return endpoints @@ -205,6 +234,7 @@ export const filterWebhookDeliveries = ( `${delivery.eventType} ${delivery.status} ${delivery.responseCode ?? ''}`.toLowerCase() return haystack.includes(normalizedSearch) }) + .sort((a, b) => new Date(b.attemptAt).getTime() - new Date(a.attemptAt).getTime()) } export const usePlatformWebhooksMockStore = (scope: WebhookScope) => { @@ -263,5 +293,8 @@ export const usePlatformWebhooksMockStore = (scope: WebhookScope) => { applyStateUpdate(() => next.state) return next.signingSecret }, + retryDelivery: (deliveryId: string) => { + applyStateUpdate((prev) => retryWebhookDelivery(prev, deliveryId)) + }, } } diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx index ab0b57a8c9..446442d1a5 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx @@ -1,4 +1,4 @@ -import { Copy } from 'lucide-react' +import { Copy, RotateCcw } from 'lucide-react' import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils' import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' @@ -11,6 +11,7 @@ import { Separator, Sheet, SheetContent, + SheetFooter, SheetHeader, SheetSection, SheetTitle, @@ -32,6 +33,7 @@ interface PlatformWebhooksDeliveryDetailsSheetProps { selectedDelivery: WebhookDelivery | null onCopy: (value: string, label: string) => void onOpenChange: (open: boolean) => void + onRetryDelivery: (deliveryId: string) => void onTabChange: (tab: 'event' | 'response') => void } @@ -44,8 +46,12 @@ export const PlatformWebhooksDeliveryDetailsSheet = ({ selectedDelivery, onCopy, onOpenChange, + onRetryDelivery, onTabChange, }: PlatformWebhooksDeliveryDetailsSheetProps) => { + const retryableDelivery = + selectedDelivery && selectedDelivery.status !== 'success' ? selectedDelivery : null + return ( @@ -181,6 +187,18 @@ export const PlatformWebhooksDeliveryDetailsSheet = ({ )} + + {retryableDelivery && ( + + + + )} ) diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx index 6d84c04bce..64215546f6 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx @@ -1,7 +1,7 @@ -import { Search } from 'lucide-react' - import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { RotateCcw, Search } from 'lucide-react' import { Badge, Card, @@ -15,6 +15,7 @@ import { } from 'ui' import { TimestampInfo } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' + import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types' import { statusBadgeVariant } from './PlatformWebhooksView.utils' @@ -37,6 +38,7 @@ interface PlatformWebhooksEndpointDetailsProps { selectedEndpoint: WebhookEndpoint onDeliverySearchChange: (value: string) => void onOpenDelivery: (deliveryId: string) => void + onRetryDelivery: (deliveryId: string) => void } export const PlatformWebhooksEndpointDetails = ({ @@ -45,6 +47,7 @@ export const PlatformWebhooksEndpointDetails = ({ selectedEndpoint, onDeliverySearchChange, onOpenDelivery, + onRetryDelivery, }: PlatformWebhooksEndpointDetailsProps) => { const hasCustomHeaders = selectedEndpoint.customHeaders.length > 0 @@ -122,6 +125,9 @@ export const PlatformWebhooksEndpointDetails = ({ Event type Response Attempted + + Actions + @@ -159,11 +165,30 @@ export const PlatformWebhooksEndpointDetails = ({ + +
+ {delivery.status !== 'success' && ( + } + aria-label={`Retry ${delivery.id}`} + tooltip={{ content: { side: 'top', text: 'Retry' } }} + onClick={(event) => { + event.stopPropagation() + onRetryDelivery(delivery.id) + }} + onKeyDown={(event) => event.stopPropagation()} + /> + )} + {delivery.status === 'success' && } +
+
)) ) : ( - No deliveries found + No deliveries found )}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx index c8104faa3b..14375df524 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx @@ -73,6 +73,7 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage updateEndpoint, deleteEndpoint, regenerateSecret, + retryDelivery, } = usePlatformWebhooksMockStore(scope) const [deliveryId, setDeliveryId] = useQueryState('deliveryId', parseAsString) const [panel, setPanel] = useQueryState('panel', parseAsStringLiteral(PANEL_VALUES)) @@ -258,6 +259,14 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage toast.success('Signing secret regenerated') } + const handleRetryDelivery = (deliveryId: string) => { + const delivery = deliveries.find((item) => item.id === deliveryId) + if (!delivery || delivery.status === 'success') return + + retryDelivery(deliveryId) + toast.success('Delivery queued for retry') + } + const handleCopy = (value: string, label: string) => { copyToClipboard(value) toast.success(`Copied ${label}`) @@ -357,6 +366,7 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage setDeliveryDetailsTab('event') setDeliveryId(id) }} + onRetryDelivery={handleRetryDelivery} /> )} @@ -372,6 +382,7 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage selectedDelivery={selectedDelivery} onCopy={handleCopy} onOpenChange={(open) => !open && setDeliveryId(null)} + onRetryDelivery={handleRetryDelivery} onTabChange={setDeliveryDetailsTab} />