mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Add retry actions for platform webhook deliveries (#43846)
## What kind of change does this PR introduce? Feature / UX update. ## What is the current behavior? Platform webhook deliveries cannot be retried from the deliveries table or the delivery details sheet. ## What is the new behavior? - Adds a retry action to individual platform webhook deliveries in the endpoint deliveries table - Adds a retry action to the shared delivery details sheet footer - Limits retry to non-success deliveries - Applies the table action hit area guidance from the design system Successful deliveries should also not expose retry in this mock flow. ## Additional context - Addresses DEPR-340. - In the current mock implementation, queueing updates the existing delivery in place: it sets the status to `pending`, clears `responseCode`, and refreshes `attemptAt` to the current timestamp. - The mock does not create a separate queued job or append a new delivery record yet; the same delivery is resurfaced at the top of the list because deliveries are sorted by latest attempt time.
This commit is contained in:
1 parent
ee8cb336af
commit
aa11d60874
5 files changed
+142
-4
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<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
|
||||
@@ -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))
|
||||
},
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent size="default" className="flex flex-col gap-0">
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
+30
-3
@@ -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 = ({
|
||||
<TableHead>Event type</TableHead>
|
||||
<TableHead>Response</TableHead>
|
||||
<TableHead>Attempted</TableHead>
|
||||
<TableHead className="w-1">
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -159,11 +165,32 @@ 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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</PageSectionContent>
|
||||
@@ -372,6 +382,7 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
|
||||
selectedDelivery={selectedDelivery}
|
||||
onCopy={handleCopy}
|
||||
onOpenChange={(open) => !open && setDeliveryId(null)}
|
||||
onRetryDelivery={handleRetryDelivery}
|
||||
onTabChange={setDeliveryDetailsTab}
|
||||
/>
|
||||
|
||||
|
||||
Reference in new issue
Block a user