93b2676994
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).
509 lines
17 KiB
TypeScript
509 lines
17 KiB
TypeScript
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>
|
||
)
|
||
}
|