Files
mdm-ordinis/ordinis-admin-ui/src/routes/webhooks.$id.tsx
T
Andrei Zimin 93b2676994 fix(ui): wrap remaining UUIDs into UserCell — 3 missed spots
MR !177 added /admin/users/{sub}/display + UserCell + useUserDisplay,
but only some renderers were migrated. Three places still rendered
raw UUID strings:

1. reviews.tsx:466 — record draft drawer ("Согласование draft" header)
   showed `{draft.makerId}` raw. Tables on the same page already use
   <UserCell uuid={d.makerId} />. Verified via prod staging:
   soloviev.da approved a record draft today and the drawer header
   showed the maker as "77ec2cc8-98e3-4d70-8037-94038fcbde67" instead
   of the username.
2. RecordHistoryDrawer.tsx:110 — version history `updatedBy` raw.
3. webhooks.$id.tsx:235 — webhook subscription `createdBy` raw.

All three switched to <UserCell uuid={...} />, matching the existing
pattern. Added the import where missing. Wrapped each in
inline-flex so the UserCell sits next to the label cleanly.

No backend / API changes — same /admin/users/{sub}/display endpoint
populated by JwtUserCaptureFilter (MR !177).
2026-05-14 14:07:02 +03:00

509 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { UserCell } from '@/lib/useUserDisplay'
import {
Alert,
Badge,
Button,
EmptyState,
IconButton,
LoadingBlock,
PageHeader,
Panel,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
} from '@/ui'
import {
ArrowClockwiseIcon,
ArrowsClockwiseIcon,
KeyIcon,
PaperPlaneTiltIcon,
TrashIcon,
} from '@phosphor-icons/react'
import {
useWebhookDeliveries,
useWebhookSubscription,
} from '@/api/queries'
import {
useDeleteWebhook,
useRetryWebhookDelivery,
useRotateWebhookSecret,
useTestWebhook,
useUpdateWebhook,
} from '@/api/mutations'
import type { WebhookTestPingResult } from '@/api/client'
import { SCOPE_DOT } from '@/lib/scope-style'
import { WebhookStatsCards } from '@/components/webhooks/WebhookStatsCards'
import { WebhookStatsHistogram } from '@/components/webhooks/WebhookStatsHistogram'
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 updateMut = useUpdateWebhook()
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-body text-ink-2 hover:text-accent">
{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="text-mono break-all">{data.url}</code>
</InfoRow>
<InfoRow label={t('webhooks.col.status')}>
<div className="flex items-center gap-3">
<Badge variant={data.active ? 'success' : 'neutral'}>
{data.active ? t('webhooks.active') : t('webhooks.inactive')}
</Badge>
<button
type="button"
onClick={() => {
// Toggle active. PUT requires full payload — пересобираем
// из current state + переворачиваем active. Backend
// service treats PUT как full replace, поэтому все
// поля должны быть переданы.
updateMut.mutate({
id: data.id,
payload: {
name: data.name,
url: data.url,
scopeFilter: data.scopeFilter ?? undefined,
dictionaryFilter: data.dictionaryFilter ?? undefined,
eventTypeFilter: data.eventTypeFilter ?? undefined,
description: data.description ?? undefined,
active: !data.active,
},
})
}}
disabled={updateMut.isPending}
className="text-cap text-accent hover:underline disabled:opacity-50"
>
{data.active
? t('webhooks.action.deactivate', { defaultValue: 'Деактивировать' })
: t('webhooks.action.activate', { defaultValue: 'Активировать' })}
</button>
</div>
</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="text-cap inline-flex items-center gap-1"
>
<span className={`inline-block size-2 rounded-full ${SCOPE_DOT[s]}`} aria-hidden />
{s}
</span>
))}
</div>
) : (
<span className="text-cell text-mute">{t('webhooks.allScopes')}</span>
)}
</InfoRow>
<InfoRow label={t('webhooks.field.dictionaryFilter')}>
<span className="text-cell">
{data.dictionaryFilter && data.dictionaryFilter.length > 0
? data.dictionaryFilter.join(', ')
: t('webhooks.allDicts')}
</span>
</InfoRow>
<InfoRow label={t('webhooks.field.eventTypeFilter')}>
<span className="text-cell">
{data.eventTypeFilter && data.eventTypeFilter.length > 0
? data.eventTypeFilter.join(', ')
: t('webhooks.allEvents')}
</span>
</InfoRow>
<InfoRow label="HMAC secret">
<code className="text-mono">{data.hmacSecret}</code>
</InfoRow>
<InfoRow label={t('webhooks.field.createdBy')}>
<span className="text-cell inline-flex items-center gap-1">
<UserCell uuid={data.createdBy} /> · {new Date(data.createdAt).toLocaleString()}
</span>
</InfoRow>
</div>
</Panel>
{/* Stats cards — счётчики per status + success rate. Aggregate
из 4 параллельных queries с size=1 (cheap). */}
<WebhookStatsCards subscriptionId={id} />
{/* Time-series histogram — backend /stats?bucketBy=hour|day. SVG
stacked bars без recharts depend'ы. 24h / 7d toggle. */}
<WebhookStatsHistogram subscriptionId={id} />
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-sans text-title-md text-accent">
{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={[
'text-cap inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border transition-colors',
active
? 'border-accent bg-accent/8 text-accent'
: 'border-line bg-surface text-ink-2 hover:border-ink/40',
].join(' ')}
>
{s}
</button>
)
})}
{statusFilter && (
<button
type="button"
onClick={() => {
setPage(0)
setStatusFilter(undefined)
}}
className="text-cell text-accent 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="text-mono">{d.outboxEventId}</span>
</TableCell>
<TableCell>
<Badge variant={STATUS_BADGE[d.status] ?? 'neutral'}>
{d.status}
</Badge>
</TableCell>
<TableCell>
<span className="text-mono">{d.attemptCount}</span>
</TableCell>
<TableCell>
<span className="text-cell text-ink-2">
{d.lastAttemptAt
? new Date(d.lastAttemptAt).toLocaleString()
: '—'}
</span>
</TableCell>
<TableCell>
<span className="text-mono">
{d.lastStatusCode ?? '—'}
</span>
</TableCell>
<TableCell>
<span className="text-cell text-ink-2 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-body text-ink-2 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-cell text-ink-2 hover:text-ink"
onClick={onDismiss}
>
</button>
}
>
<div className="space-y-1 text-body">
{result.httpStatus != null && (
<div>
<span className="text-cap text-mute mr-2">
HTTP
</span>
<span className="font-mono">{result.httpStatus}</span>
</div>
)}
{result.latencyMs != null && (
<div>
<span className="text-cap text-mute mr-2">
{t('webhooks.test.latency')}
</span>
<span className="font-mono">{result.latencyMs} ms</span>
</div>
)}
{result.message && (
<div className="text-mono text-ink whitespace-pre-wrap break-all">
{result.message}
</div>
)}
</div>
</Alert>
)
}
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-cap text-mute 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-surface rounded-lg p-6 max-w-xl w-full space-y-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-sans text-title-lg text-accent">
{t('webhooks.secret.rotatedTitle')}
</h3>
<Alert variant="warning" title={t('webhooks.secret.warningTitle')}>
{t('webhooks.secret.rotatedWarning')}
</Alert>
<div className="bg-ink/5 border border-line rounded-md p-3">
<code className="text-mono break-all">{secret}</code>
</div>
<div className="flex justify-end">
<Button variant="primary" onClick={onClose}>
{t('webhooks.secret.acknowledge')}
</Button>
</div>
</div>
</div>
)
}