Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ interface UpdateEndpointOptions {
headerIdFactory?: () => string
}

interface RetryDeliveryOptions {
now?: string
}

const secureRandomHex = (length: number) => {
if (length <= 0) return ''

Expand Down Expand Up @@ -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<WebhookDelivery>((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
Expand All @@ -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) => {
Expand Down Expand Up @@ -263,5 +293,8 @@ export const usePlatformWebhooksMockStore = (scope: WebhookScope) => {
applyStateUpdate(() => next.state)
return next.signingSecret
},
retryDelivery: (deliveryId: string) => {
applyStateUpdate((prev) => retryWebhookDelivery(prev, deliveryId))
},
}
}
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -11,6 +11,7 @@ import {
Separator,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
Expand All @@ -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
}

Expand All @@ -44,8 +46,12 @@ export const PlatformWebhooksDeliveryDetailsSheet = ({
selectedDelivery,
onCopy,
onOpenChange,
onRetryDelivery,
onTabChange,
}: PlatformWebhooksDeliveryDetailsSheetProps) => {
const retryableDelivery =
selectedDelivery && selectedDelivery.status !== 'success' ? selectedDelivery : null

return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent size="default" className="flex flex-col gap-0">
Expand Down Expand Up @@ -181,6 +187,18 @@ export const PlatformWebhooksDeliveryDetailsSheet = ({
</div>
</SheetSection>
)}

{retryableDelivery && (
<SheetFooter className="shrink-0">
<Button
type="default"
icon={<RotateCcw size={14} />}
onClick={() => onRetryDelivery(retryableDelivery.id)}
>
Retry delivery
</Button>
</SheetFooter>
)}
</SheetContent>
</Sheet>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'

Expand All @@ -37,6 +38,7 @@ interface PlatformWebhooksEndpointDetailsProps {
selectedEndpoint: WebhookEndpoint
onDeliverySearchChange: (value: string) => void
onOpenDelivery: (deliveryId: string) => void
onRetryDelivery: (deliveryId: string) => void
}

export const PlatformWebhooksEndpointDetails = ({
Expand All @@ -45,6 +47,7 @@ export const PlatformWebhooksEndpointDetails = ({
selectedEndpoint,
onDeliverySearchChange,
onOpenDelivery,
onRetryDelivery,
}: PlatformWebhooksEndpointDetailsProps) => {
const hasCustomHeaders = selectedEndpoint.customHeaders.length > 0

Expand Down Expand Up @@ -122,6 +125,9 @@ export const PlatformWebhooksEndpointDetails = ({
<TableHead>Event type</TableHead>
<TableHead>Response</TableHead>
<TableHead>Attempted</TableHead>
<TableHead className="w-1">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
Expand Down Expand Up @@ -159,11 +165,30 @@ export const PlatformWebhooksEndpointDetails = ({
<TableCell>
<TimestampInfo className="text-sm" utcTimestamp={delivery.attemptAt} />
</TableCell>
<TableCell className="w-1 text-right">
<div className="flex h-full items-center justify-end">
{delivery.status !== 'success' && (
<ButtonTooltip
type="default"
className="w-7 shrink-0 hit-area-2"
icon={<RotateCcw size={14} />}
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' && <span aria-hidden className="size-7 shrink-0" />}
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4}>No deliveries found</TableCell>
<TableCell colSpan={5}>No deliveries found</TableCell>
</TableRow>
)}
</TableBody>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -357,6 +366,7 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
setDeliveryDetailsTab('event')
setDeliveryId(id)
}}
onRetryDelivery={handleRetryDelivery}
/>
)}
</PageSectionContent>
Expand All @@ -372,6 +382,7 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
selectedDelivery={selectedDelivery}
onCopy={handleCopy}
onOpenChange={(open) => !open && setDeliveryId(null)}
onRetryDelivery={handleRetryDelivery}
onTabChange={setDeliveryDetailsTab}
/>

Expand Down
Loading