f2cf34e2fc
Раньше при 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-страницы
не критично.
467 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|