96bfac174d
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.*
389 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|