feat(webhook): retry delivery — POST /deliveries/{id}/retry + UI кнопка

Раньше для replay'а failed/DLQ delivery нужно было либо ждать
backoff schedule (до 24h), либо лезть в БД и руками сбрасывать
attempt_count + dlq_at. Теперь admin замечает failed delivery в
UI, фиксит receiver (cert renewal, URL update), кликает 'повторить'
— dispatcher подхватит на следующем sendTick.

Backend:
- POST /api/v1/admin/webhooks/deliveries/{id}/retry
- RBAC: RESTRICTED (можно spam'ить receiver = destructive)
- Использует existing WebhookDelivery.resetDlq() — атомарно
  status=retrying, attemptCount=0, dlqAt=null, nextAttemptAt=now

UI:
- Колонка 'Действия' в delivery history таблице (на webhook detail page)
- IconButton 'Повторить' показывается только для status=dlq|retrying
  (delivered/pending не имеет смысла retry'ить)
- Confirm dialog объясняет что receiver получит payload снова
- onSuccess invalidate ['webhooks','deliveries'] + ['webhooks','dlq']

Tests: 75 admin-ui passed, rest-api compiles clean. Логика
resetDlq() уже покрыта unit tests в WebhookDeliveryTest.
This commit is contained in:
Zimin A.N.
2026-05-06 20:36:17 +03:00
parent d5a28d251d
commit 2e3f2ddd16
4 changed files with 110 additions and 32 deletions
+57 -32
View File
@@ -18,6 +18,7 @@ import {
TableRow,
} from '@nstart/ui'
import {
ArrowClockwiseIcon,
ArrowsClockwiseIcon,
KeyIcon,
TrashIcon,
@@ -28,6 +29,7 @@ import {
} from '@/api/queries'
import {
useDeleteWebhook,
useRetryWebhookDelivery,
useRotateWebhookSecret,
} from '@/api/mutations'
import { SCOPE_DOT } from '@/lib/scope-style'
@@ -51,6 +53,12 @@ function WebhookDetailPage() {
const deliveries = useWebhookDeliveries(id, page, 50)
const rotateMut = useRotateWebhookSecret()
const deleteMut = useDeleteWebhook()
const retryMut = useRetryWebhookDelivery()
const handleRetry = (deliveryId: number) => {
if (!window.confirm(t('webhooks.deliveries.confirmRetry'))) return
retryMut.mutate(deliveryId)
}
const [revealedSecret, setRevealedSecret] = useState<string | null>(null)
const handleRotate = () => {
@@ -193,41 +201,58 @@ function WebhookDetailPage() {
<TableHeaderCell>{t('webhooks.deliveries.col.lastAttempt')}</TableHeaderCell>
<TableHeaderCell>{t('webhooks.deliveries.col.lastStatusCode')}</TableHeaderCell>
<TableHeaderCell>{t('webhooks.deliveries.col.lastError')}</TableHeaderCell>
<TableHeaderCell align="right">
{t('dict.col.actions')}
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{deliveries.data.content.map((d) => (
<TableRow key={d.id}>
<TableCell>
<span className="font-mono text-2xs">{d.outboxEventId}</span>
</TableCell>
<TableCell>
<Badge variant={STATUS_BADGE[d.status] ?? 'neutral'}>
{d.status}
</Badge>
</TableCell>
<TableCell>
<span className="font-mono text-2xs">{d.attemptCount}</span>
</TableCell>
<TableCell>
<span className="text-2xs text-carbon/70">
{d.lastAttemptAt
? new Date(d.lastAttemptAt).toLocaleString()
: '—'}
</span>
</TableCell>
<TableCell>
<span className="font-mono text-2xs">
{d.lastStatusCode ?? '—'}
</span>
</TableCell>
<TableCell>
<span className="text-2xs text-carbon/70 line-clamp-2 max-w-md inline-block">
{d.lastError ?? '—'}
</span>
</TableCell>
</TableRow>
))}
{deliveries.data.content.map((d) => {
const canRetry = d.status === 'dlq' || d.status === 'retrying'
return (
<TableRow key={d.id}>
<TableCell>
<span className="font-mono text-2xs">{d.outboxEventId}</span>
</TableCell>
<TableCell>
<Badge variant={STATUS_BADGE[d.status] ?? 'neutral'}>
{d.status}
</Badge>
</TableCell>
<TableCell>
<span className="font-mono text-2xs">{d.attemptCount}</span>
</TableCell>
<TableCell>
<span className="text-2xs text-carbon/70">
{d.lastAttemptAt
? new Date(d.lastAttemptAt).toLocaleString()
: '—'}
</span>
</TableCell>
<TableCell>
<span className="font-mono text-2xs">
{d.lastStatusCode ?? '—'}
</span>
</TableCell>
<TableCell>
<span className="text-2xs text-carbon/70 line-clamp-2 max-w-md inline-block">
{d.lastError ?? '—'}
</span>
</TableCell>
<TableCell align="right">
{canRetry && (
<IconButton
label={t('webhooks.deliveries.action.retry')}
variant="default"
icon={<ArrowClockwiseIcon weight="regular" />}
disabled={retryMut.isPending}
onClick={() => handleRetry(d.id)}
/>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Panel>