Files
mdm-ordinis/ordinis-admin-ui/src/routes/webhooks.index.tsx
T
Zimin A.N. 96bfac174d 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.*
2026-05-06 15:22:28 +03:00

389 lines
11 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,
Modal,
MultiSelect,
PageHeader,
Panel,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TextArea,
TextInput,
type SelectOption,
} from '@nstart/ui'
import {
CopyIcon,
PlusIcon,
TrashIcon,
} from '@phosphor-icons/react'
import { useWebhookSubscriptions } from '@/api/queries'
import {
useCreateWebhook,
useDeleteWebhook,
} from '@/api/mutations'
import type {
CreateWebhookSubscriptionRequest,
DataScope,
WebhookSubscription,
} from '@/api/client'
import { SCOPE_DOT } from '@/lib/scope-style'
export const Route = createFileRoute('/webhooks/')({
component: WebhooksPage,
})
const SCOPE_OPTIONS: SelectOption[] = [
{ id: 'PUBLIC', label: 'PUBLIC' },
{ id: 'INTERNAL', label: 'INTERNAL' },
{ id: 'RESTRICTED', label: 'RESTRICTED' },
]
function WebhooksPage() {
const { t } = useTranslation()
const { data, isLoading, error } = useWebhookSubscriptions()
const [createOpen, setCreateOpen] = useState(false)
const [revealedSecret, setRevealedSecret] = useState<{
name: string
secret: string
} | null>(null)
const deleteMut = useDeleteWebhook()
const handleDelete = (sub: WebhookSubscription) => {
if (!window.confirm(t('webhooks.confirmDelete', { name: sub.name }))) return
deleteMut.mutate(sub.id)
}
if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
if (error) {
return (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)
}
return (
<div className="space-y-6">
<PageHeader
title={t('webhooks.title')}
description={t('webhooks.subtitle')}
actions={
<Button
type="button"
variant="primary"
leftIcon={<PlusIcon weight="bold" size={16} />}
onClick={() => setCreateOpen(true)}
>
{t('webhooks.action.create')}
</Button>
}
/>
{!data || data.length === 0 ? (
<EmptyState title={t('webhooks.empty')} />
) : (
<Panel>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{t('webhooks.col.name')}</TableHeaderCell>
<TableHeaderCell>{t('webhooks.col.url')}</TableHeaderCell>
<TableHeaderCell>{t('webhooks.col.scopes')}</TableHeaderCell>
<TableHeaderCell>{t('webhooks.col.dictionaries')}</TableHeaderCell>
<TableHeaderCell>{t('webhooks.col.status')}</TableHeaderCell>
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{data.map((sub) => (
<TableRow key={sub.id}>
<TableCell>
<Link
to="/webhooks/$id"
params={{ id: sub.id }}
className="font-medium text-ultramarain hover:underline"
>
{sub.name}
</Link>
</TableCell>
<TableCell>
<span className="font-mono text-2xs truncate max-w-xs inline-block align-middle">
{sub.url}
</span>
</TableCell>
<TableCell>
<ScopeChips scopes={sub.scopeFilter} />
</TableCell>
<TableCell>
{sub.dictionaryFilter && sub.dictionaryFilter.length > 0 ? (
<span className="text-2xs text-carbon">
{sub.dictionaryFilter.slice(0, 3).join(', ')}
{sub.dictionaryFilter.length > 3 && ` +${sub.dictionaryFilter.length - 3}`}
</span>
) : (
<span className="text-2xs text-carbon/50">{t('webhooks.allDicts')}</span>
)}
</TableCell>
<TableCell>
<Badge variant={sub.active ? 'success' : 'neutral'}>
{sub.active ? t('webhooks.active') : t('webhooks.inactive')}
</Badge>
</TableCell>
<TableCell align="right">
<IconButton
label={t('webhooks.action.delete')}
variant="danger"
icon={<TrashIcon weight="regular" />}
onClick={() => handleDelete(sub)}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Panel>
)}
<CreateWebhookDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={(sub, secret) => {
setCreateOpen(false)
setRevealedSecret({ name: sub.name, secret })
}}
/>
<SecretRevealModal
revealed={revealedSecret}
onClose={() => setRevealedSecret(null)}
/>
</div>
)
}
function ScopeChips({ scopes }: { scopes?: DataScope[] | null }) {
const { t } = useTranslation()
if (!scopes || scopes.length === 0) {
return <span className="text-2xs text-carbon/50">{t('webhooks.allScopes')}</span>
}
return (
<div className="flex gap-1">
{scopes.map((s) => (
<span
key={s}
className="inline-flex items-center gap-1 text-2xs uppercase tracking-label"
title={s}
>
<span className={`inline-block size-2 rounded-full ${SCOPE_DOT[s]}`} aria-hidden />
</span>
))}
</div>
)
}
type CreateDialogProps = {
open: boolean
onClose: () => void
onSuccess: (sub: WebhookSubscription, secret: string) => void
}
function CreateWebhookDialog({ open, onClose, onSuccess }: CreateDialogProps) {
const { t } = useTranslation()
const create = useCreateWebhook()
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const [scopeFilter, setScopeFilter] = useState<string[]>([])
const [dictionaryFilterCsv, setDictionaryFilterCsv] = useState('')
const [eventTypeFilterCsv, setEventTypeFilterCsv] = useState('')
const [description, setDescription] = useState('')
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const reset = () => {
setName('')
setUrl('')
setScopeFilter([])
setDictionaryFilterCsv('')
setEventTypeFilterCsv('')
setDescription('')
setErrorMsg(null)
}
const csvToArray = (s: string): string[] | undefined => {
const arr = s
.split(',')
.map((x) => x.trim())
.filter(Boolean)
return arr.length > 0 ? arr : undefined
}
const handleSubmit = () => {
setErrorMsg(null)
if (!name.trim() || !url.trim()) {
setErrorMsg(t('webhooks.error.nameUrlRequired'))
return
}
if (!/^https?:\/\//.test(url)) {
setErrorMsg(t('webhooks.error.urlScheme'))
return
}
const req: CreateWebhookSubscriptionRequest = {
name: name.trim(),
url: url.trim(),
scopeFilter: scopeFilter.length > 0 ? (scopeFilter as DataScope[]) : undefined,
dictionaryFilter: csvToArray(dictionaryFilterCsv),
eventTypeFilter: csvToArray(eventTypeFilterCsv),
active: true,
description: description.trim() || undefined,
}
create.mutate(req, {
onSuccess: (sub) => {
const plaintext = sub.hmacSecret
reset()
onSuccess(sub, plaintext)
},
onError: (e) => setErrorMsg(String(e)),
})
}
return (
<Modal
isOpen={open}
onClose={() => {
reset()
onClose()
}}
title={t('webhooks.action.create')}
maxWidth="max-w-2xl"
>
<div className="space-y-4">
<TextInput
label={t('webhooks.field.name')}
required
placeholder="altum-imaging-pull"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<TextInput
label={t('webhooks.field.url')}
required
placeholder="https://altum.example.com/hooks/ordinis"
hint={t('webhooks.field.urlHint')}
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<MultiSelect
label={t('webhooks.field.scopeFilter')}
options={SCOPE_OPTIONS}
value={scopeFilter}
onChange={(ids) => setScopeFilter(ids as string[])}
hint={t('webhooks.field.scopeFilterHint')}
/>
<TextInput
label={t('webhooks.field.dictionaryFilter')}
placeholder="spacecraft, satellite_type"
hint={t('webhooks.field.dictionaryFilterHint')}
value={dictionaryFilterCsv}
onChange={(e) => setDictionaryFilterCsv(e.target.value)}
/>
<TextInput
label={t('webhooks.field.eventTypeFilter')}
placeholder="record.created, record.updated"
hint={t('webhooks.field.eventTypeFilterHint')}
value={eventTypeFilterCsv}
onChange={(e) => setEventTypeFilterCsv(e.target.value)}
/>
<TextArea
label={t('webhooks.field.description')}
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{errorMsg && <Alert variant="error">{errorMsg}</Alert>}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onClick={() => {
reset()
onClose()
}}
disabled={create.isPending}
>
{t('form.cancel')}
</Button>
<Button
variant="primary"
loading={create.isPending}
onClick={handleSubmit}
>
{t('webhooks.action.create')}
</Button>
</div>
</div>
</Modal>
)
}
function SecretRevealModal({
revealed,
onClose,
}: {
revealed: { name: string; secret: string } | null
onClose: () => void
}) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
if (!revealed) return null
const handleCopy = () => {
navigator.clipboard?.writeText(revealed.secret).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<Modal
isOpen={true}
onClose={onClose}
title={t('webhooks.secret.title', { name: revealed.name })}
maxWidth="max-w-xl"
>
<div className="space-y-4">
<Alert variant="warning" title={t('webhooks.secret.warningTitle')}>
{t('webhooks.secret.warning')}
</Alert>
<div className="bg-carbon/5 border border-regolith rounded-md p-3">
<code className="font-mono text-2xs break-all">{revealed.secret}</code>
</div>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
leftIcon={<CopyIcon weight="bold" size={16} />}
onClick={handleCopy}
>
{copied ? t('webhooks.secret.copied') : t('webhooks.secret.copy')}
</Button>
<Button variant="primary" onClick={onClose}>
{t('webhooks.secret.acknowledge')}
</Button>
</div>
</div>
</Modal>
)
}