Files
mdm-ordinis/ordinis-admin-ui/src/routes/webhooks.$id.tsx
T
Zimin A.N. f2cf34e2fc feat(webhook): delivery list — server-side фильтр по status
Раньше при page=50 admin не мог найти редкий dlq среди
delivered'ов: client-side filter показал бы 1 совпадение из 50,
а на page=1 могли быть ещё. Делаем server-side через ?status=.

Backend:
- WebhookDeliveryRepository.findBySubscriptionIdAndStatusOrderByCreatedAtDesc
  — Spring Data derived query, JPA генерирует SQL.
- GET /admin/webhooks/subscriptions/{id}/deliveries?status=dlq —
  опциональный param. Invalid status → 400 OrdinisException.
  Allowed: pending, retrying, delivered, dlq.

UI:
- 4 chip'а pending/retrying/delivered/dlq над таблицей deliveries
  (одинаковый паттерн со scope-чипами в dictionaries).
- Click toggle + reset page=0. 'Сбросить' появляется когда фильтр
  активен.
- Status в URL пока не персистится — для одной session-страницы
  не критично.
2026-05-06 21:34:04 +03:00

467 lines
15 KiB
TypeScript

import { useState } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
Badge,
Button,
EmptyState,
IconButton,
LoadingBlock,
PageHeader,
Panel,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
} from '@nstart/ui'
import {
ArrowClockwiseIcon,
ArrowsClockwiseIcon,
KeyIcon,
PaperPlaneTiltIcon,
TrashIcon,
} from '@phosphor-icons/react'
import {
useWebhookDeliveries,
useWebhookSubscription,
} from '@/api/queries'
import {
useDeleteWebhook,
useRetryWebhookDelivery,
useRotateWebhookSecret,
useTestWebhook,
} from '@/api/mutations'
import type { WebhookTestPingResult } from '@/api/client'
import { SCOPE_DOT } from '@/lib/scope-style'
export const Route = createFileRoute('/webhooks/$id')({
component: WebhookDetailPage,
})
const STATUS_BADGE: Record<string, 'success' | 'warning' | 'error' | 'neutral'> = {
delivered: 'success',
retrying: 'warning',
pending: 'neutral',
dlq: 'error',
}
function WebhookDetailPage() {
const { id } = Route.useParams()
const { t } = useTranslation()
const sub = useWebhookSubscription(id)
const [page, setPage] = useState(0)
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
const deliveries = useWebhookDeliveries(id, page, 50, statusFilter)
const handleStatusToggle = (s: string) => {
setPage(0)
setStatusFilter((curr) => (curr === s ? undefined : s))
}
const rotateMut = useRotateWebhookSecret()
const deleteMut = useDeleteWebhook()
const retryMut = useRetryWebhookDelivery()
const testMut = useTestWebhook()
const [testResult, setTestResult] = useState<WebhookTestPingResult | null>(null)
const handleTest = () => {
setTestResult(null)
testMut.mutate(id, {
onSuccess: (r) => setTestResult(r),
onError: (e) =>
setTestResult({
status: 'rejected',
message: e instanceof Error ? e.message : String(e),
}),
})
}
const handleRetry = (deliveryId: number) => {
if (!window.confirm(t('webhooks.deliveries.confirmRetry'))) return
retryMut.mutate(deliveryId)
}
const [revealedSecret, setRevealedSecret] = useState<string | null>(null)
const handleRotate = () => {
if (!window.confirm(t('webhooks.confirmRotate'))) return
rotateMut.mutate(id, {
onSuccess: (s) => setRevealedSecret(s.hmacSecret),
})
}
const handleDelete = () => {
if (!sub.data) return
if (!window.confirm(t('webhooks.confirmDelete', { name: sub.data.name }))) return
deleteMut.mutate(id, {
onSuccess: () => {
window.location.assign('/webhooks')
},
})
}
if (sub.isLoading) return <LoadingBlock size="md" label={t('loading')} />
if (sub.error || !sub.data) {
return (
<Alert variant="error" title={t('error.failed')}>
{String(sub.error ?? t('webhooks.notFound'))}
</Alert>
)
}
const data = sub.data
return (
<div className="space-y-6">
<PageHeader
breadcrumb={
<Link to="/webhooks" className="text-sm text-carbon/70 hover:text-ultramarain">
{t('nav.webhooks')}
</Link>
}
title={data.name}
description={data.description ?? data.url}
actions={
<div className="flex items-center gap-2">
<Button
variant="secondary"
leftIcon={<PaperPlaneTiltIcon weight="bold" size={16} />}
loading={testMut.isPending}
onClick={handleTest}
>
{t('webhooks.action.test')}
</Button>
<Button
variant="secondary"
leftIcon={<KeyIcon weight="bold" size={16} />}
loading={rotateMut.isPending}
onClick={handleRotate}
>
{t('webhooks.action.rotateSecret')}
</Button>
<Button
variant="ghost"
leftIcon={<TrashIcon weight="bold" size={16} />}
loading={deleteMut.isPending}
onClick={handleDelete}
>
{t('webhooks.action.delete')}
</Button>
</div>
}
/>
{testResult && <TestResultAlert result={testResult} onDismiss={() => setTestResult(null)} />}
<Panel>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4">
<InfoRow label={t('webhooks.field.url')}>
<code className="font-mono text-2xs break-all">{data.url}</code>
</InfoRow>
<InfoRow label={t('webhooks.col.status')}>
<Badge variant={data.active ? 'success' : 'neutral'}>
{data.active ? t('webhooks.active') : t('webhooks.inactive')}
</Badge>
</InfoRow>
<InfoRow label={t('webhooks.field.scopeFilter')}>
{data.scopeFilter && data.scopeFilter.length > 0 ? (
<div className="flex gap-2">
{data.scopeFilter.map((s) => (
<span
key={s}
className="inline-flex items-center gap-1 text-2xs uppercase tracking-label"
>
<span className={`inline-block size-2 rounded-full ${SCOPE_DOT[s]}`} aria-hidden />
{s}
</span>
))}
</div>
) : (
<span className="text-2xs text-carbon/50">{t('webhooks.allScopes')}</span>
)}
</InfoRow>
<InfoRow label={t('webhooks.field.dictionaryFilter')}>
<span className="text-2xs">
{data.dictionaryFilter && data.dictionaryFilter.length > 0
? data.dictionaryFilter.join(', ')
: t('webhooks.allDicts')}
</span>
</InfoRow>
<InfoRow label={t('webhooks.field.eventTypeFilter')}>
<span className="text-2xs">
{data.eventTypeFilter && data.eventTypeFilter.length > 0
? data.eventTypeFilter.join(', ')
: t('webhooks.allEvents')}
</span>
</InfoRow>
<InfoRow label="HMAC secret">
<code className="font-mono text-2xs">{data.hmacSecret}</code>
</InfoRow>
<InfoRow label={t('webhooks.field.createdBy')}>
<span className="text-2xs">
{data.createdBy} · {new Date(data.createdAt).toLocaleString()}
</span>
</InfoRow>
</div>
</Panel>
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-primary text-base text-ultramarain">
{t('webhooks.deliveries.title')}
</h2>
<IconButton
label={t('audit.filter.apply')}
variant="default"
icon={<ArrowsClockwiseIcon weight="regular" />}
onClick={() => deliveries.refetch()}
/>
</div>
<div
className="flex flex-wrap items-center gap-1.5 mb-3"
role="group"
aria-label={t('webhooks.deliveries.filter.label')}
>
{(['pending', 'retrying', 'delivered', 'dlq'] as const).map((s) => {
const active = statusFilter === s
return (
<button
key={s}
type="button"
onClick={() => handleStatusToggle(s)}
aria-pressed={active}
className={[
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border text-2xs uppercase tracking-label font-secondary transition-colors',
active
? 'border-ultramarain bg-ultramarain/8 text-ultramarain'
: 'border-regolith bg-white text-carbon/70 hover:border-carbon/40',
].join(' ')}
>
{s}
</button>
)
})}
{statusFilter && (
<button
type="button"
onClick={() => {
setPage(0)
setStatusFilter(undefined)
}}
className="text-xs text-ultramarain hover:underline ml-2"
>
{t('webhooks.deliveries.filter.clear')}
</button>
)}
</div>
{deliveries.isLoading ? (
<LoadingBlock size="sm" />
) : !deliveries.data || deliveries.data.content.length === 0 ? (
<EmptyState title={t('webhooks.deliveries.empty')} />
) : (
<Panel>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{t('webhooks.deliveries.col.eventId')}</TableHeaderCell>
<TableHeaderCell>{t('webhooks.col.status')}</TableHeaderCell>
<TableHeaderCell>{t('webhooks.deliveries.col.attempts')}</TableHeaderCell>
<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) => {
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>
)}
{deliveries.data && (deliveries.data.totalPages ?? 0) > 1 && (
<div className="flex justify-center gap-2 mt-3">
<Button
variant="ghost"
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
</Button>
<span className="text-sm text-carbon/70 self-center">
{page + 1} / {deliveries.data.totalPages}
</span>
<Button
variant="ghost"
disabled={(page + 1) >= (deliveries.data.totalPages ?? 0)}
onClick={() => setPage((p) => p + 1)}
>
</Button>
</div>
)}
</div>
{revealedSecret && (
<RotateRevealModal
secret={revealedSecret}
onClose={() => setRevealedSecret(null)}
/>
)}
</div>
)
}
function TestResultAlert({
result,
onDismiss,
}: {
result: WebhookTestPingResult
onDismiss: () => void
}) {
const { t } = useTranslation()
const variant =
result.status === 'success' ? 'success' : result.status === 'failure' ? 'error' : 'warning'
const titleKey =
result.status === 'success'
? 'webhooks.test.success'
: result.status === 'failure'
? 'webhooks.test.failure'
: 'webhooks.test.rejected'
return (
<Alert
variant={variant}
title={t(titleKey)}
action={
<button
type="button"
className="text-xs text-carbon/70 hover:text-carbon"
onClick={onDismiss}
>
</button>
}
>
<div className="space-y-1 text-sm">
{result.httpStatus != null && (
<div>
<span className="text-2xs uppercase tracking-label text-carbon/60 mr-2">
HTTP
</span>
<span className="font-mono">{result.httpStatus}</span>
</div>
)}
{result.latencyMs != null && (
<div>
<span className="text-2xs uppercase tracking-label text-carbon/60 mr-2">
{t('webhooks.test.latency')}
</span>
<span className="font-mono">{result.latencyMs} ms</span>
</div>
)}
{result.message && (
<div className="font-mono text-2xs text-carbon/80 whitespace-pre-wrap break-all">
{result.message}
</div>
)}
</div>
</Alert>
)
}
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-2xs uppercase tracking-label text-carbon/60 mb-1">
{label}
</div>
<div>{children}</div>
</div>
)
}
function RotateRevealModal({
secret,
onClose,
}: {
secret: string
onClose: () => void
}) {
const { t } = useTranslation()
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="bg-white rounded-lg p-6 max-w-xl w-full space-y-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-primary text-lg text-ultramarain">
{t('webhooks.secret.rotatedTitle')}
</h3>
<Alert variant="warning" title={t('webhooks.secret.warningTitle')}>
{t('webhooks.secret.rotatedWarning')}
</Alert>
<div className="bg-carbon/5 border border-regolith rounded-md p-3">
<code className="font-mono text-2xs break-all">{secret}</code>
</div>
<div className="flex justify-end">
<Button variant="primary" onClick={onClose}>
{t('webhooks.secret.acknowledge')}
</Button>
</div>
</div>
</div>
)
}