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:
Zimin A.N.
2026-05-06 15:22:28 +03:00
parent e9a7278709
commit 96bfac174d
9 changed files with 1078 additions and 1 deletions
+53
View File
@@ -173,3 +173,56 @@ export type DlqPage = {
number: number number: number
size: number size: number
} }
// === Webhooks (v2) ===
export type WebhookSubscription = {
id: string
name: string
url: string
scopeFilter?: DataScope[] | null
dictionaryFilter?: string[] | null
eventTypeFilter?: string[] | null
/** Masked `sk_****<last4>` или plaintext только в response создания/rotate. */
hmacSecret: string
active: boolean
createdAt: string
createdBy: string
updatedAt: string
description?: string | null
}
export type CreateWebhookSubscriptionRequest = {
name: string
url: string
scopeFilter?: DataScope[]
dictionaryFilter?: string[]
eventTypeFilter?: string[]
active?: boolean
description?: string
}
export type WebhookDeliveryStatus = 'pending' | 'retrying' | 'delivered' | 'dlq'
export type WebhookDelivery = {
id: number
subscriptionId: string
outboxEventId: number
status: WebhookDeliveryStatus
attemptCount: number
createdAt: string
nextAttemptAt: string
lastAttemptAt?: string | null
deliveredAt?: string | null
lastError?: string | null
lastStatusCode?: number | null
dlqAt?: string | null
}
export type WebhookDeliveryPage = {
content: WebhookDelivery[]
totalElements: number
totalPages: number
number: number
size: number
}
+68
View File
@@ -3,8 +3,10 @@ import {
apiClient, apiClient,
type CreateDictionaryRequest, type CreateDictionaryRequest,
type CreateRecordRequest, type CreateRecordRequest,
type CreateWebhookSubscriptionRequest,
type DictionaryDetail, type DictionaryDetail,
type RecordResponse, type RecordResponse,
type WebhookSubscription,
} from './client' } from './client'
const idempotencyKey = () => const idempotencyKey = () =>
@@ -96,3 +98,69 @@ export const useCloseRecord = (dictionaryName: string) => {
}, },
}) })
} }
// === Webhooks (v2) ===
export const useCreateWebhook = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (req: CreateWebhookSubscriptionRequest): Promise<WebhookSubscription> => {
const { data } = await apiClient.post<WebhookSubscription>(
'/admin/webhooks/subscriptions',
req,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] })
},
})
}
export const useUpdateWebhook = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: {
id: string
payload: CreateWebhookSubscriptionRequest
}): Promise<WebhookSubscription> => {
const { data } = await apiClient.put<WebhookSubscription>(
`/admin/webhooks/subscriptions/${encodeURIComponent(params.id)}`,
params.payload,
)
return data
},
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] })
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions', vars.id] })
},
})
}
export const useDeleteWebhook = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (id: string): Promise<void> => {
await apiClient.delete(`/admin/webhooks/subscriptions/${encodeURIComponent(id)}`)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] })
},
})
}
export const useRotateWebhookSecret = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (id: string): Promise<WebhookSubscription> => {
const { data } = await apiClient.post<WebhookSubscription>(
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}/rotate-secret`,
)
return data
},
onSuccess: (_data, id) => {
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] })
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions', id] })
},
})
}
+66
View File
@@ -9,6 +9,8 @@ import {
type FlattenedRecord, type FlattenedRecord,
type OutboxStats, type OutboxStats,
type RecordResponse, type RecordResponse,
type WebhookDeliveryPage,
type WebhookSubscription,
} from './client' } from './client'
export const dictionariesQuery = queryOptions({ export const dictionariesQuery = queryOptions({
@@ -135,6 +137,70 @@ export const dlqQuery = (page: number, size: number) =>
export const useDlq = (page: number, size: number) => useQuery(dlqQuery(page, size)) export const useDlq = (page: number, size: number) => useQuery(dlqQuery(page, size))
// === Webhooks (v2) ===
export const webhookSubscriptionsQuery = queryOptions({
queryKey: ['webhooks', 'subscriptions'] as const,
queryFn: async (): Promise<WebhookSubscription[]> => {
const { data } = await apiClient.get<WebhookSubscription[]>(
'/admin/webhooks/subscriptions',
)
return data
},
})
export const useWebhookSubscriptions = () => useQuery(webhookSubscriptionsQuery)
export const webhookSubscriptionQuery = (id: string) =>
queryOptions({
queryKey: ['webhooks', 'subscriptions', id] as const,
queryFn: async (): Promise<WebhookSubscription> => {
const { data } = await apiClient.get<WebhookSubscription>(
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}`,
)
return data
},
})
export const useWebhookSubscription = (id: string) =>
useQuery({
...webhookSubscriptionQuery(id),
enabled: Boolean(id),
})
export const webhookDeliveriesQuery = (id: string, page: number, size: number) =>
queryOptions({
queryKey: ['webhooks', 'deliveries', id, page, size] as const,
queryFn: async (): Promise<WebhookDeliveryPage> => {
const { data } = await apiClient.get<WebhookDeliveryPage>(
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}/deliveries`,
{ params: { page, size } },
)
return data
},
})
export const useWebhookDeliveries = (id: string, page: number, size: number) =>
useQuery({
...webhookDeliveriesQuery(id, page, size),
enabled: Boolean(id),
})
export const webhookDlqQuery = (page: number, size: number) =>
queryOptions({
queryKey: ['webhooks', 'dlq', page, size] as const,
queryFn: async (): Promise<WebhookDeliveryPage> => {
const { data } = await apiClient.get<WebhookDeliveryPage>(
'/admin/webhooks/deliveries/dlq',
{ params: { page, size } },
)
return data
},
})
export const useWebhookDlq = (page: number, size: number) =>
useQuery(webhookDlqQuery(page, size))
export const useDictionaries = () => useQuery(dictionariesQuery) export const useDictionaries = () => useQuery(dictionariesQuery)
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name)) export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
export const useRecords = (dictionaryName: string, scopeCsv: string) => export const useRecords = (dictionaryName: string, scopeCsv: string) =>
+96
View File
@@ -16,6 +16,54 @@ i18n
'nav.dictionaries': 'Справочники', 'nav.dictionaries': 'Справочники',
'nav.audit': 'Аудит', 'nav.audit': 'Аудит',
'nav.outbox': 'Outbox', 'nav.outbox': 'Outbox',
'nav.webhooks': 'Webhooks',
'webhooks.title': 'Webhook subscriptions',
'webhooks.subtitle': 'HTTP-доставка событий не-Kafka подписчикам',
'webhooks.empty': 'Нет подписок. Создайте первую через кнопку справа.',
'webhooks.active': 'Активна',
'webhooks.inactive': 'Отключена',
'webhooks.allScopes': 'все scope',
'webhooks.allDicts': 'все справочники',
'webhooks.allEvents': 'все события',
'webhooks.notFound': 'Подписка не найдена',
'webhooks.col.name': 'Имя',
'webhooks.col.url': 'URL',
'webhooks.col.scopes': 'Scope filter',
'webhooks.col.dictionaries': 'Справочники',
'webhooks.col.status': 'Статус',
'webhooks.action.create': 'Создать',
'webhooks.action.delete': 'Удалить',
'webhooks.action.rotateSecret': 'Сменить secret',
'webhooks.confirmDelete': 'Удалить подписку «{{name}}»? Все pending deliveries удалятся каскадом.',
'webhooks.confirmRotate': 'Сменить HMAC secret? Старый сразу перестанет работать — receiver надо обновить заранее.',
'webhooks.field.name': 'Имя подписки',
'webhooks.field.url': 'Webhook URL',
'webhooks.field.urlHint': 'https:// или http://. Private CIDR (10/8, 192.168/16, 127/8) запрещены SSRF guard',
'webhooks.field.scopeFilter': 'Scope filter',
'webhooks.field.scopeFilterHint': 'Пусто = все scope',
'webhooks.field.dictionaryFilter': 'Справочники (CSV)',
'webhooks.field.dictionaryFilterHint': 'Через запятую: spacecraft, satellite_type. Пусто = все',
'webhooks.field.eventTypeFilter': 'Типы событий (CSV)',
'webhooks.field.eventTypeFilterHint': 'record.created, record.updated, record.closed. Пусто = все',
'webhooks.field.description': 'Описание',
'webhooks.field.createdBy': 'Создатель',
'webhooks.error.nameUrlRequired': 'Имя и URL обязательны',
'webhooks.error.urlScheme': 'URL должен начинаться с http:// или https://',
'webhooks.secret.title': 'HMAC secret для «{{name}}»',
'webhooks.secret.warningTitle': 'Скопируй сейчас!',
'webhooks.secret.warning': 'Этот secret показывается ОДИН раз. Дальше будет замаскирован. Передай receiver чтобы он мог проверять X-Ordinis-Signature header.',
'webhooks.secret.rotatedTitle': 'Secret обновлён',
'webhooks.secret.rotatedWarning': 'Новый secret. Старый перестал работать. Обнови receiver.',
'webhooks.secret.copy': 'Копировать',
'webhooks.secret.copied': 'Скопировано',
'webhooks.secret.acknowledge': 'Сохранил',
'webhooks.deliveries.title': 'История доставок',
'webhooks.deliveries.empty': 'Доставок ещё не было',
'webhooks.deliveries.col.eventId': 'Event ID',
'webhooks.deliveries.col.attempts': 'Попыток',
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
'webhooks.deliveries.col.lastStatusCode': 'HTTP статус',
'webhooks.deliveries.col.lastError': 'Последняя ошибка',
'audit.title': 'Журнал аудита', 'audit.title': 'Журнал аудита',
'audit.subtitle': 'Юридически значимый лог изменений НСИ', 'audit.subtitle': 'Юридически значимый лог изменений НСИ',
'audit.filter.dictionary': 'Справочник', 'audit.filter.dictionary': 'Справочник',
@@ -192,6 +240,54 @@ i18n
'nav.dictionaries': 'Dictionaries', 'nav.dictionaries': 'Dictionaries',
'nav.audit': 'Audit', 'nav.audit': 'Audit',
'nav.outbox': 'Outbox', 'nav.outbox': 'Outbox',
'nav.webhooks': 'Webhooks',
'webhooks.title': 'Webhook subscriptions',
'webhooks.subtitle': 'HTTP delivery of events to non-Kafka consumers',
'webhooks.empty': 'No subscriptions yet. Create one via the button on the right.',
'webhooks.active': 'Active',
'webhooks.inactive': 'Disabled',
'webhooks.allScopes': 'all scopes',
'webhooks.allDicts': 'all dictionaries',
'webhooks.allEvents': 'all events',
'webhooks.notFound': 'Subscription not found',
'webhooks.col.name': 'Name',
'webhooks.col.url': 'URL',
'webhooks.col.scopes': 'Scope filter',
'webhooks.col.dictionaries': 'Dictionaries',
'webhooks.col.status': 'Status',
'webhooks.action.create': 'Create',
'webhooks.action.delete': 'Delete',
'webhooks.action.rotateSecret': 'Rotate secret',
'webhooks.confirmDelete': 'Delete subscription "{{name}}"? All pending deliveries will be cascade-deleted.',
'webhooks.confirmRotate': 'Rotate HMAC secret? The old one stops working immediately — update the receiver first.',
'webhooks.field.name': 'Subscription name',
'webhooks.field.url': 'Webhook URL',
'webhooks.field.urlHint': 'https:// or http://. Private CIDRs (10/8, 192.168/16, 127/8) blocked by SSRF guard',
'webhooks.field.scopeFilter': 'Scope filter',
'webhooks.field.scopeFilterHint': 'Empty = all scopes',
'webhooks.field.dictionaryFilter': 'Dictionaries (CSV)',
'webhooks.field.dictionaryFilterHint': 'Comma-separated: spacecraft, satellite_type. Empty = all',
'webhooks.field.eventTypeFilter': 'Event types (CSV)',
'webhooks.field.eventTypeFilterHint': 'record.created, record.updated, record.closed. Empty = all',
'webhooks.field.description': 'Description',
'webhooks.field.createdBy': 'Created by',
'webhooks.error.nameUrlRequired': 'Name and URL are required',
'webhooks.error.urlScheme': 'URL must start with http:// or https://',
'webhooks.secret.title': 'HMAC secret for "{{name}}"',
'webhooks.secret.warningTitle': 'Copy now!',
'webhooks.secret.warning': 'This secret is shown ONCE. It will be masked afterwards. Pass it to the receiver so it can verify the X-Ordinis-Signature header.',
'webhooks.secret.rotatedTitle': 'Secret rotated',
'webhooks.secret.rotatedWarning': 'New secret. Old one stopped working. Update the receiver.',
'webhooks.secret.copy': 'Copy',
'webhooks.secret.copied': 'Copied',
'webhooks.secret.acknowledge': 'Saved it',
'webhooks.deliveries.title': 'Delivery history',
'webhooks.deliveries.empty': 'No deliveries yet',
'webhooks.deliveries.col.eventId': 'Event ID',
'webhooks.deliveries.col.attempts': 'Attempts',
'webhooks.deliveries.col.lastAttempt': 'Last attempt',
'webhooks.deliveries.col.lastStatusCode': 'HTTP status',
'webhooks.deliveries.col.lastError': 'Last error',
'audit.title': 'Audit log', 'audit.title': 'Audit log',
'audit.subtitle': 'Legally significant log of NSI changes', 'audit.subtitle': 'Legally significant log of NSI changes',
'audit.filter.dictionary': 'Dictionary', 'audit.filter.dictionary': 'Dictionary',
+77 -1
View File
@@ -9,13 +9,21 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as WebhooksRouteImport } from './routes/webhooks'
import { Route as OutboxRouteImport } from './routes/outbox' import { Route as OutboxRouteImport } from './routes/outbox'
import { Route as DictionariesRouteImport } from './routes/dictionaries' import { Route as DictionariesRouteImport } from './routes/dictionaries'
import { Route as AuditRouteImport } from './routes/audit' import { Route as AuditRouteImport } from './routes/audit'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as WebhooksIndexRouteImport } from './routes/webhooks.index'
import { Route as DictionariesIndexRouteImport } from './routes/dictionaries.index' import { Route as DictionariesIndexRouteImport } from './routes/dictionaries.index'
import { Route as WebhooksIdRouteImport } from './routes/webhooks.$id'
import { Route as DictionariesNameRouteImport } from './routes/dictionaries.$name' import { Route as DictionariesNameRouteImport } from './routes/dictionaries.$name'
const WebhooksRoute = WebhooksRouteImport.update({
id: '/webhooks',
path: '/webhooks',
getParentRoute: () => rootRouteImport,
} as any)
const OutboxRoute = OutboxRouteImport.update({ const OutboxRoute = OutboxRouteImport.update({
id: '/outbox', id: '/outbox',
path: '/outbox', path: '/outbox',
@@ -36,11 +44,21 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const WebhooksIndexRoute = WebhooksIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => WebhooksRoute,
} as any)
const DictionariesIndexRoute = DictionariesIndexRouteImport.update({ const DictionariesIndexRoute = DictionariesIndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
getParentRoute: () => DictionariesRoute, getParentRoute: () => DictionariesRoute,
} as any) } as any)
const WebhooksIdRoute = WebhooksIdRouteImport.update({
id: '/$id',
path: '/$id',
getParentRoute: () => WebhooksRoute,
} as any)
const DictionariesNameRoute = DictionariesNameRouteImport.update({ const DictionariesNameRoute = DictionariesNameRouteImport.update({
id: '/$name', id: '/$name',
path: '/$name', path: '/$name',
@@ -52,15 +70,20 @@ export interface FileRoutesByFullPath {
'/audit': typeof AuditRoute '/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren '/dictionaries': typeof DictionariesRouteWithChildren
'/outbox': typeof OutboxRoute '/outbox': typeof OutboxRoute
'/webhooks': typeof WebhooksRouteWithChildren
'/dictionaries/$name': typeof DictionariesNameRoute '/dictionaries/$name': typeof DictionariesNameRoute
'/webhooks/$id': typeof WebhooksIdRoute
'/dictionaries/': typeof DictionariesIndexRoute '/dictionaries/': typeof DictionariesIndexRoute
'/webhooks/': typeof WebhooksIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/audit': typeof AuditRoute '/audit': typeof AuditRoute
'/outbox': typeof OutboxRoute '/outbox': typeof OutboxRoute
'/dictionaries/$name': typeof DictionariesNameRoute '/dictionaries/$name': typeof DictionariesNameRoute
'/webhooks/$id': typeof WebhooksIdRoute
'/dictionaries': typeof DictionariesIndexRoute '/dictionaries': typeof DictionariesIndexRoute
'/webhooks': typeof WebhooksIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -68,8 +91,11 @@ export interface FileRoutesById {
'/audit': typeof AuditRoute '/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren '/dictionaries': typeof DictionariesRouteWithChildren
'/outbox': typeof OutboxRoute '/outbox': typeof OutboxRoute
'/webhooks': typeof WebhooksRouteWithChildren
'/dictionaries/$name': typeof DictionariesNameRoute '/dictionaries/$name': typeof DictionariesNameRoute
'/webhooks/$id': typeof WebhooksIdRoute
'/dictionaries/': typeof DictionariesIndexRoute '/dictionaries/': typeof DictionariesIndexRoute
'/webhooks/': typeof WebhooksIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -78,18 +104,31 @@ export interface FileRouteTypes {
| '/audit' | '/audit'
| '/dictionaries' | '/dictionaries'
| '/outbox' | '/outbox'
| '/webhooks'
| '/dictionaries/$name' | '/dictionaries/$name'
| '/webhooks/$id'
| '/dictionaries/' | '/dictionaries/'
| '/webhooks/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/audit' | '/outbox' | '/dictionaries/$name' | '/dictionaries' to:
| '/'
| '/audit'
| '/outbox'
| '/dictionaries/$name'
| '/webhooks/$id'
| '/dictionaries'
| '/webhooks'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/audit' | '/audit'
| '/dictionaries' | '/dictionaries'
| '/outbox' | '/outbox'
| '/webhooks'
| '/dictionaries/$name' | '/dictionaries/$name'
| '/webhooks/$id'
| '/dictionaries/' | '/dictionaries/'
| '/webhooks/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -97,10 +136,18 @@ export interface RootRouteChildren {
AuditRoute: typeof AuditRoute AuditRoute: typeof AuditRoute
DictionariesRoute: typeof DictionariesRouteWithChildren DictionariesRoute: typeof DictionariesRouteWithChildren
OutboxRoute: typeof OutboxRoute OutboxRoute: typeof OutboxRoute
WebhooksRoute: typeof WebhooksRouteWithChildren
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/webhooks': {
id: '/webhooks'
path: '/webhooks'
fullPath: '/webhooks'
preLoaderRoute: typeof WebhooksRouteImport
parentRoute: typeof rootRouteImport
}
'/outbox': { '/outbox': {
id: '/outbox' id: '/outbox'
path: '/outbox' path: '/outbox'
@@ -129,6 +176,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/webhooks/': {
id: '/webhooks/'
path: '/'
fullPath: '/webhooks/'
preLoaderRoute: typeof WebhooksIndexRouteImport
parentRoute: typeof WebhooksRoute
}
'/dictionaries/': { '/dictionaries/': {
id: '/dictionaries/' id: '/dictionaries/'
path: '/' path: '/'
@@ -136,6 +190,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DictionariesIndexRouteImport preLoaderRoute: typeof DictionariesIndexRouteImport
parentRoute: typeof DictionariesRoute parentRoute: typeof DictionariesRoute
} }
'/webhooks/$id': {
id: '/webhooks/$id'
path: '/$id'
fullPath: '/webhooks/$id'
preLoaderRoute: typeof WebhooksIdRouteImport
parentRoute: typeof WebhooksRoute
}
'/dictionaries/$name': { '/dictionaries/$name': {
id: '/dictionaries/$name' id: '/dictionaries/$name'
path: '/$name' path: '/$name'
@@ -160,11 +221,26 @@ const DictionariesRouteWithChildren = DictionariesRoute._addFileChildren(
DictionariesRouteChildren, DictionariesRouteChildren,
) )
interface WebhooksRouteChildren {
WebhooksIdRoute: typeof WebhooksIdRoute
WebhooksIndexRoute: typeof WebhooksIndexRoute
}
const WebhooksRouteChildren: WebhooksRouteChildren = {
WebhooksIdRoute: WebhooksIdRoute,
WebhooksIndexRoute: WebhooksIndexRoute,
}
const WebhooksRouteWithChildren = WebhooksRoute._addFileChildren(
WebhooksRouteChildren,
)
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AuditRoute: AuditRoute, AuditRoute: AuditRoute,
DictionariesRoute: DictionariesRouteWithChildren, DictionariesRoute: DictionariesRouteWithChildren,
OutboxRoute: OutboxRoute, OutboxRoute: OutboxRoute,
WebhooksRoute: WebhooksRouteWithChildren,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
+7
View File
@@ -44,6 +44,13 @@ function RootLayout() {
> >
{t('nav.outbox')} {t('nav.outbox')}
</Link> </Link>
<Link
to="/webhooks"
className="text-carbon hover:text-ultramarain"
activeProps={{ className: 'text-ultramarain font-medium' }}
>
{t('nav.webhooks')}
</Link>
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<LanguageSwitch <LanguageSwitch
@@ -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>
)
}
@@ -0,0 +1,388 @@
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>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/webhooks')({
component: WebhooksLayout,
})
function WebhooksLayout() {
return <Outlet />
}