feat(webhook): Phase 4 — admin-ui /webhooks page
Routes: - /webhooks (layout) → /webhooks/index (list) + /webhooks/$id (detail) - nav link добавлен в __root.tsx между Outbox и Language switch List page (webhooks.index.tsx): - Table: name | URL | scope filter (colored dots) | dictionaries | status - ScopeChips reuse SCOPE_DOT из lib/scope-style.ts (consistency с dictionaries scope coloring) - Create dialog: name, URL, scope filter (MultiSelect), dictionary & event type (CSV inputs), description. Client-side validation (URL scheme, name required) дублирует server-side @Pattern. - Secret reveal modal после create: warning + plaintext + copy button. Single-time view — после закрытия secret не получить, только rotate. Detail page (webhooks.$id.tsx): - Subscription metadata grid (URL, status, filters, masked secret, createdBy + timestamp) - Action buttons: rotate-secret + delete (с confirmation) - Delivery history table: outboxEventId | status badge (delivered/ retrying/pending/dlq) | attempts | last attempt | HTTP code | error - Pagination 50/page - Status badges: delivered=success, retrying=warning, pending=neutral, dlq=error API client (queries + mutations): - 4 queries: subscriptions list, subscription by id, deliveries page, DLQ page (last unused в UI пока, но готов для admin DLQ tab) - 4 mutations: create, update, delete, rotateSecret. Все invalidate правильные query keys. i18n: 50 ключей × 2 локали (ru-RU + en-US) под webhooks.*
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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 {
|
||||
ArrowsClockwiseIcon,
|
||||
KeyIcon,
|
||||
TrashIcon,
|
||||
} from '@phosphor-icons/react'
|
||||
import {
|
||||
useWebhookDeliveries,
|
||||
useWebhookSubscription,
|
||||
} from '@/api/queries'
|
||||
import {
|
||||
useDeleteWebhook,
|
||||
useRotateWebhookSecret,
|
||||
} from '@/api/mutations'
|
||||
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 deliveries = useWebhookDeliveries(id, page, 50)
|
||||
const rotateMut = useRotateWebhookSecret()
|
||||
const deleteMut = useDeleteWebhook()
|
||||
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={<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>
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
</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>
|
||||
))}
|
||||
</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 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user