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
+66
View File
@@ -9,6 +9,8 @@ import {
type FlattenedRecord,
type OutboxStats,
type RecordResponse,
type WebhookDeliveryPage,
type WebhookSubscription,
} from './client'
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))
// === 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 useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
export const useRecords = (dictionaryName: string, scopeCsv: string) =>