diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index e19cb30..4ab85bf 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -6,6 +6,7 @@ import { type CreateWebhookSubscriptionRequest, type DictionaryDetail, type RecordResponse, + type WebhookDelivery, type WebhookSubscription, } from './client' @@ -164,3 +165,29 @@ export const useRotateWebhookSecret = () => { }, }) } + +/** + * Replay delivery из DLQ или retrying — backend сбрасывает attemptCount=0, + * dlqAt=null, status=retrying. Dispatcher подхватит на следующем sendTick. + * + * Use case: оператор fixed receiver URL/cert, хочет replay'нуть failed + * delivery без ожидания TTL bypass'а. + */ +export const useRetryWebhookDelivery = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (deliveryId: number): Promise => { + const { data } = await apiClient.post( + `/admin/webhooks/deliveries/${deliveryId}/retry`, + ) + return data + }, + onSuccess: () => { + // Invalidate всё что показывает delivery rows: detail page deliveries, + // global DLQ list. Cheaper чем targeted invalidation т.к. delivery + // counts маленькие. + qc.invalidateQueries({ queryKey: ['webhooks', 'deliveries'] }) + qc.invalidateQueries({ queryKey: ['webhooks', 'dlq'] }) + }, + }) +} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index f79cef5..c16811e 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -64,6 +64,8 @@ i18n 'webhooks.deliveries.col.lastAttempt': 'Последняя попытка', 'webhooks.deliveries.col.lastStatusCode': 'HTTP статус', 'webhooks.deliveries.col.lastError': 'Последняя ошибка', + 'webhooks.deliveries.action.retry': 'Повторить доставку', + 'webhooks.deliveries.confirmRetry': 'Сбросить attemptCount=0 и поставить в очередь повтор? Receiver получит payload снова.', 'audit.title': 'Журнал аудита', 'audit.subtitle': 'Юридически значимый лог изменений НСИ', 'audit.filter.dictionary': 'Справочник', @@ -306,6 +308,8 @@ i18n 'webhooks.deliveries.col.lastAttempt': 'Last attempt', 'webhooks.deliveries.col.lastStatusCode': 'HTTP status', 'webhooks.deliveries.col.lastError': 'Last error', + 'webhooks.deliveries.action.retry': 'Retry delivery', + 'webhooks.deliveries.confirmRetry': 'Reset attemptCount=0 and queue a retry? The receiver will get the payload again.', 'audit.title': 'Audit log', 'audit.subtitle': 'Legally significant log of NSI changes', 'audit.filter.dictionary': 'Dictionary', diff --git a/ordinis-admin-ui/src/routes/webhooks.$id.tsx b/ordinis-admin-ui/src/routes/webhooks.$id.tsx index 393c491..f697a10 100644 --- a/ordinis-admin-ui/src/routes/webhooks.$id.tsx +++ b/ordinis-admin-ui/src/routes/webhooks.$id.tsx @@ -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(null) const handleRotate = () => { @@ -193,41 +201,58 @@ function WebhookDetailPage() { {t('webhooks.deliveries.col.lastAttempt')} {t('webhooks.deliveries.col.lastStatusCode')} {t('webhooks.deliveries.col.lastError')} + + {t('dict.col.actions')} + - {deliveries.data.content.map((d) => ( - - - {d.outboxEventId} - - - - {d.status} - - - - {d.attemptCount} - - - - {d.lastAttemptAt - ? new Date(d.lastAttemptAt).toLocaleString() - : '—'} - - - - - {d.lastStatusCode ?? '—'} - - - - - {d.lastError ?? '—'} - - - - ))} + {deliveries.data.content.map((d) => { + const canRetry = d.status === 'dlq' || d.status === 'retrying' + return ( + + + {d.outboxEventId} + + + + {d.status} + + + + {d.attemptCount} + + + + {d.lastAttemptAt + ? new Date(d.lastAttemptAt).toLocaleString() + : '—'} + + + + + {d.lastStatusCode ?? '—'} + + + + + {d.lastError ?? '—'} + + + + {canRetry && ( + } + disabled={retryMut.isPending} + onClick={() => handleRetry(d.id)} + /> + )} + + + ) + })} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/webhook/WebhookSubscriptionController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/webhook/WebhookSubscriptionController.java index a133cfb..50c108e 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/webhook/WebhookSubscriptionController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/webhook/WebhookSubscriptionController.java @@ -142,6 +142,28 @@ public class WebhookSubscriptionController { return deliveries.map(WebhookDeliveryResponse::from); } + /** + * Replay delivery из DLQ или retrying state — сбрасывает attemptCount=0, + * dlqAt=null, status=retrying, nextAttemptAt=now. Dispatcher подхватит на + * следующем sendTick. + * + *

Use case: оператор fixed receiver URL после deploy/cert renewal, + * хочет replay'нуть failed delivery без ожидания TTL bypass'а. + * + *

RBAC: RESTRICTED — это destructive op (можно spam'ить receiver). + */ + @PostMapping("/deliveries/{id}/retry") + public WebhookDeliveryResponse retryDelivery(@PathVariable Long id) { + requireAdmin(); + WebhookDelivery delivery = deliveryRepository.findById(id) + .orElseThrow(() -> OrdinisException.notFound( + "webhook_delivery_not_found", + "Webhook delivery " + id + " not found.")); + delivery.resetDlq(); + deliveryRepository.save(delivery); + return WebhookDeliveryResponse.from(delivery); + } + // === Helpers === /** RESTRICTED scope обязателен для mutating endpoints. */