feat(audit): полноценный writer в audit_log + admin UI (audit + outbox DLQ)

До этого audit_log entity была определена, но никто туда не писал — реальный
аудит шёл только через Hibernate Envers (без user/IP/trace). Теперь:

- AuditLogger service пишет в той же транзакции что и CRUD (DictionaryRecordService).
  Захватывает: user (JWT sub либо preferred_username), scope, IP (X-Forwarded-For),
  UA, trace_id (MDC), request_id. Не ломает основной flow при сбое — только
  громко логирует.
- AuditLogRepository: гибкий search() с nullable фильтрами (dictionary, user,
  action, businessKey, from, to) + Pageable.
- AuditAdminController: GET /admin/audit (paginated) + /admin/audit/export.csv
  (cap 10k строк против OOM).

Frontend:
- /audit route: фильтр-панель, таблица с цветными бейджами (CREATE/UPDATE/CLOSE),
  expand-row с JSON before/after diff, footer IP/UA/req_id, кнопка экспорта CSV.
  Pagination 50/100/200.
- /outbox route: stat-cards pending/DLQ + DLQ-таблица с last_error.
- Nav links + i18n (RU + EN).
This commit is contained in:
Zimin A.N.
2026-05-04 15:44:14 +03:00
parent e182b30c58
commit 7b2fe6f2d5
13 changed files with 1191 additions and 5 deletions
+64
View File
@@ -107,3 +107,67 @@ export type CreateRecordRequest = {
validFrom?: string
validTo?: string
}
export type AuditAction = 'CREATE' | 'UPDATE' | 'CLOSE'
export type AuditEntry = {
id: number
eventTime: string
eventType: string
action: AuditAction | string
userId?: string | null
userScope?: DataScope | null
dictionaryName?: string | null
dictionaryId?: string | null
recordId?: string | null
businessKey?: string | null
payloadBefore?: Record<string, unknown> | null
payloadAfter?: Record<string, unknown> | null
traceId?: string | null
requestId?: string | null
ipAddress?: string | null
userAgent?: string | null
}
export type AuditPage = {
content: AuditEntry[]
totalElements: number
totalPages: number
number: number
size: number
first: boolean
last: boolean
}
export type AuditFilters = {
dictionaryName?: string
userId?: string
action?: AuditAction | ''
businessKey?: string
from?: string
to?: string
page?: number
size?: number
}
export type OutboxStats = { pending: number; dlq: number }
export type DlqEntry = {
id: number
eventType: string
dictionaryName?: string | null
aggregateId: string
kafkaTopic: string
attemptCount: number
lastError?: string | null
createdAt: string
dlqAt: string
}
export type DlqPage = {
content: DlqEntry[]
totalElements: number
totalPages: number
number: number
size: number
}
+68
View File
@@ -1,9 +1,13 @@
import { useQuery, queryOptions } from '@tanstack/react-query'
import {
apiClient,
type AuditFilters,
type AuditPage,
type DictionaryDefinition,
type DictionaryDetail,
type DlqPage,
type FlattenedRecord,
type OutboxStats,
type RecordResponse,
} from './client'
@@ -67,6 +71,70 @@ export const recordRawQuery = (dictionaryName: string, businessKey: string) =>
},
})
const auditParams = (f: AuditFilters): Record<string, string | number> => {
const out: Record<string, string | number> = {
page: f.page ?? 0,
size: f.size ?? 50,
}
if (f.dictionaryName) out.dictionaryName = f.dictionaryName
if (f.userId) out.userId = f.userId
if (f.action) out.action = f.action
if (f.businessKey) out.businessKey = f.businessKey
if (f.from) out.from = f.from
if (f.to) out.to = f.to
return out
}
export const auditQuery = (f: AuditFilters) =>
queryOptions({
queryKey: ['audit', f] as const,
queryFn: async (): Promise<AuditPage> => {
const { data } = await apiClient.get<AuditPage>('/admin/audit', {
params: auditParams(f),
})
return data
},
})
export const useAudit = (f: AuditFilters) => useQuery(auditQuery(f))
export const buildAuditExportUrl = (f: AuditFilters): string => {
const base = (apiClient.defaults.baseURL ?? '/api/v1').replace(/\/$/, '')
const sp = new URLSearchParams()
if (f.dictionaryName) sp.set('dictionaryName', f.dictionaryName)
if (f.userId) sp.set('userId', f.userId)
if (f.action) sp.set('action', f.action)
if (f.businessKey) sp.set('businessKey', f.businessKey)
if (f.from) sp.set('from', f.from)
if (f.to) sp.set('to', f.to)
const qs = sp.toString()
return `${base}/admin/audit/export.csv${qs ? `?${qs}` : ''}`
}
export const outboxStatsQuery = queryOptions({
queryKey: ['outbox', 'stats'] as const,
queryFn: async (): Promise<OutboxStats> => {
const { data } = await apiClient.get<OutboxStats>('/admin/outbox/stats')
return data
},
refetchInterval: 30_000,
})
export const useOutboxStats = () => useQuery(outboxStatsQuery)
export const dlqQuery = (page: number, size: number) =>
queryOptions({
queryKey: ['outbox', 'dlq', page, size] as const,
queryFn: async (): Promise<DlqPage> => {
const { data } = await apiClient.get<DlqPage>('/admin/outbox/dlq', {
params: { page, size },
})
return data
},
})
export const useDlq = (page: number, size: number) => useQuery(dlqQuery(page, size))
export const useDictionaries = () => useQuery(dictionariesQuery)
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
export const useRecords = (dictionaryName: string, scopeCsv: string) =>
+90
View File
@@ -14,6 +14,51 @@ i18n
translation: {
'app.title': 'Ordinis НСИ ЦУОД',
'nav.dictionaries': 'Справочники',
'nav.audit': 'Аудит',
'nav.outbox': 'Outbox',
'audit.title': 'Журнал аудита',
'audit.subtitle': 'Юридически значимый лог изменений НСИ',
'audit.filter.dictionary': 'Справочник',
'audit.filter.user': 'Пользователь',
'audit.filter.action': 'Действие',
'audit.filter.businessKey': 'Бизнес-ключ',
'audit.filter.from': 'С',
'audit.filter.to': 'По',
'audit.filter.reset': 'Сбросить',
'audit.filter.apply': 'Применить',
'audit.col.time': 'Время',
'audit.col.action': 'Действие',
'audit.col.dictionary': 'Справочник',
'audit.col.businessKey': 'Бизнес-ключ',
'audit.col.user': 'Пользователь',
'audit.col.scope': 'Scope',
'audit.col.trace': 'Trace',
'audit.col.diff': 'Изменения',
'audit.action.expand': 'Раскрыть',
'audit.action.export': 'Экспорт CSV',
'audit.empty': 'Нет записей по выбранным фильтрам',
'audit.action.CREATE': 'Создание',
'audit.action.UPDATE': 'Изменение',
'audit.action.CLOSE': 'Закрытие',
'audit.diff.before': 'Было',
'audit.diff.after': 'Стало',
'audit.page.of': 'Стр. {{cur}} из {{total}}',
'audit.page.size': 'На странице',
'audit.page.prev': 'Назад',
'audit.page.next': 'Вперёд',
'outbox.title': 'Outbox events',
'outbox.subtitle': 'Очередь публикации в Kafka и DLQ',
'outbox.stats.pending': 'В очереди',
'outbox.stats.dlq': 'В DLQ',
'outbox.dlq.col.id': 'ID',
'outbox.dlq.col.eventType': 'Тип',
'outbox.dlq.col.dictionary': 'Справочник',
'outbox.dlq.col.aggregateId': 'Aggregate ID',
'outbox.dlq.col.topic': 'Topic',
'outbox.dlq.col.attempts': 'Попыток',
'outbox.dlq.col.lastError': 'Последняя ошибка',
'outbox.dlq.col.dlqAt': 'В DLQ с',
'outbox.dlq.empty': 'DLQ пуста — все события публикуются успешно',
'header.scope': 'Доступный scope',
'dict.list.subtitle': 'Каталоги НСИ ЦУОД ОДХ',
'dict.empty': 'В этом справочнике пока нет записей',
@@ -133,6 +178,51 @@ i18n
translation: {
'app.title': 'Ordinis MDM',
'nav.dictionaries': 'Dictionaries',
'nav.audit': 'Audit',
'nav.outbox': 'Outbox',
'audit.title': 'Audit log',
'audit.subtitle': 'Legally significant log of NSI changes',
'audit.filter.dictionary': 'Dictionary',
'audit.filter.user': 'User',
'audit.filter.action': 'Action',
'audit.filter.businessKey': 'Business key',
'audit.filter.from': 'From',
'audit.filter.to': 'To',
'audit.filter.reset': 'Reset',
'audit.filter.apply': 'Apply',
'audit.col.time': 'Time',
'audit.col.action': 'Action',
'audit.col.dictionary': 'Dictionary',
'audit.col.businessKey': 'Business key',
'audit.col.user': 'User',
'audit.col.scope': 'Scope',
'audit.col.trace': 'Trace',
'audit.col.diff': 'Diff',
'audit.action.expand': 'Expand',
'audit.action.export': 'Export CSV',
'audit.empty': 'No entries match the filters',
'audit.action.CREATE': 'Create',
'audit.action.UPDATE': 'Update',
'audit.action.CLOSE': 'Close',
'audit.diff.before': 'Before',
'audit.diff.after': 'After',
'audit.page.of': 'Page {{cur}} of {{total}}',
'audit.page.size': 'Per page',
'audit.page.prev': 'Prev',
'audit.page.next': 'Next',
'outbox.title': 'Outbox events',
'outbox.subtitle': 'Publication queue and DLQ',
'outbox.stats.pending': 'Pending',
'outbox.stats.dlq': 'In DLQ',
'outbox.dlq.col.id': 'ID',
'outbox.dlq.col.eventType': 'Type',
'outbox.dlq.col.dictionary': 'Dictionary',
'outbox.dlq.col.aggregateId': 'Aggregate ID',
'outbox.dlq.col.topic': 'Topic',
'outbox.dlq.col.attempts': 'Attempts',
'outbox.dlq.col.lastError': 'Last error',
'outbox.dlq.col.dlqAt': 'DLQ since',
'outbox.dlq.empty': 'DLQ empty — all events publish successfully',
'header.scope': 'Allowed scope',
'dict.list.subtitle': 'Reference data catalogues',
'dict.empty': 'No records in this dictionary yet',
+46 -2
View File
@@ -9,16 +9,28 @@
// 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 OutboxRouteImport } from './routes/outbox'
import { Route as DictionariesRouteImport } from './routes/dictionaries'
import { Route as AuditRouteImport } from './routes/audit'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DictionariesIndexRouteImport } from './routes/dictionaries.index'
import { Route as DictionariesNameRouteImport } from './routes/dictionaries.$name'
const OutboxRoute = OutboxRouteImport.update({
id: '/outbox',
path: '/outbox',
getParentRoute: () => rootRouteImport,
} as any)
const DictionariesRoute = DictionariesRouteImport.update({
id: '/dictionaries',
path: '/dictionaries',
getParentRoute: () => rootRouteImport,
} as any)
const AuditRoute = AuditRouteImport.update({
id: '/audit',
path: '/audit',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -37,42 +49,65 @@ const DictionariesNameRoute = DictionariesNameRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren
'/outbox': typeof OutboxRoute
'/dictionaries/$name': typeof DictionariesNameRoute
'/dictionaries/': typeof DictionariesIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/audit': typeof AuditRoute
'/outbox': typeof OutboxRoute
'/dictionaries/$name': typeof DictionariesNameRoute
'/dictionaries': typeof DictionariesIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren
'/outbox': typeof OutboxRoute
'/dictionaries/$name': typeof DictionariesNameRoute
'/dictionaries/': typeof DictionariesIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/dictionaries' | '/dictionaries/$name' | '/dictionaries/'
fullPaths:
| '/'
| '/audit'
| '/dictionaries'
| '/outbox'
| '/dictionaries/$name'
| '/dictionaries/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/dictionaries/$name' | '/dictionaries'
to: '/' | '/audit' | '/outbox' | '/dictionaries/$name' | '/dictionaries'
id:
| '__root__'
| '/'
| '/audit'
| '/dictionaries'
| '/outbox'
| '/dictionaries/$name'
| '/dictionaries/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuditRoute: typeof AuditRoute
DictionariesRoute: typeof DictionariesRouteWithChildren
OutboxRoute: typeof OutboxRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/outbox': {
id: '/outbox'
path: '/outbox'
fullPath: '/outbox'
preLoaderRoute: typeof OutboxRouteImport
parentRoute: typeof rootRouteImport
}
'/dictionaries': {
id: '/dictionaries'
path: '/dictionaries'
@@ -80,6 +115,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DictionariesRouteImport
parentRoute: typeof rootRouteImport
}
'/audit': {
id: '/audit'
path: '/audit'
fullPath: '/audit'
preLoaderRoute: typeof AuditRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -120,7 +162,9 @@ const DictionariesRouteWithChildren = DictionariesRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuditRoute: AuditRoute,
DictionariesRoute: DictionariesRouteWithChildren,
OutboxRoute: OutboxRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+14
View File
@@ -29,6 +29,20 @@ function RootLayout() {
>
{t('nav.dictionaries')}
</Link>
<Link
to="/audit"
className="text-carbon hover:text-ultramarain"
activeProps={{ className: 'text-ultramarain font-medium' }}
>
{t('nav.audit')}
</Link>
<Link
to="/outbox"
className="text-carbon hover:text-ultramarain"
activeProps={{ className: 'text-ultramarain font-medium' }}
>
{t('nav.outbox')}
</Link>
</nav>
<div className="ml-auto">
<LanguageSwitch
+414
View File
@@ -0,0 +1,414 @@
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
Badge,
Button,
DatePicker,
EmptyState,
IconButton,
LoadingBlock,
PageHeader,
SingleSelect,
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeaderCell,
TableRow,
TextInput,
} from '@nstart/ui'
import {
ArrowsClockwiseIcon,
CaretDownIcon,
CaretRightIcon,
DownloadIcon,
} from '@phosphor-icons/react'
import { buildAuditExportUrl, useAudit, useDictionaries } from '@/api/queries'
import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client'
import { localTzOffset, parseFormDate } from '@/lib/dates'
export const Route = createFileRoute('/audit')({
component: AuditPage,
})
const ACTIONS: AuditAction[] = ['CREATE', 'UPDATE', 'CLOSE']
const PAGE_SIZE_OPTIONS = [
{ id: '50', label: '50' },
{ id: '100', label: '100' },
{ id: '200', label: '200' },
]
function AuditPage() {
const { t } = useTranslation()
const dictionariesQuery = useDictionaries()
const [filters, setFilters] = useState<AuditFilters>({ page: 0, size: 50 })
const [draft, setDraft] = useState<AuditFilters>(filters)
const { data, isLoading, error, refetch, isFetching } = useAudit(filters)
const dictionaryOptions = useMemo(() => {
const opts: { id: string; label: string }[] = [{ id: '', label: '—' }]
for (const d of dictionariesQuery.data ?? []) {
opts.push({ id: d.name, label: d.displayName ?? d.name })
}
return opts
}, [dictionariesQuery.data])
const actionOptions = useMemo(
() => [
{ id: '', label: '—' },
...ACTIONS.map((a) => ({ id: a, label: t(`audit.action.${a}`) })),
],
[t],
)
const apply = () => setFilters({ ...draft, page: 0 })
const reset = () => {
const empty: AuditFilters = { page: 0, size: filters.size ?? 50 }
setDraft(empty)
setFilters(empty)
}
const totalPages = data?.totalPages ?? 0
const currentPage = data?.number ?? 0
return (
<div className="space-y-6">
<PageHeader
title={t('audit.title')}
description={t('audit.subtitle')}
actions={
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
leftIcon={<ArrowsClockwiseIcon size={16} />}
onClick={() => refetch()}
disabled={isFetching}
>
{t('audit.filter.apply')}
</Button>
<a
href={buildAuditExportUrl(filters)}
target="_blank"
rel="noreferrer"
className="inline-flex"
>
<Button
type="button"
variant="primary"
leftIcon={<DownloadIcon size={16} />}
>
{t('audit.action.export')}
</Button>
</a>
</div>
}
/>
<FilterPanel
filters={draft}
onChange={setDraft}
dictionaryOptions={dictionaryOptions}
actionOptions={actionOptions}
onApply={apply}
onReset={reset}
/>
{error ? (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
) : isLoading ? (
<LoadingBlock size="md" label={t('loading')} />
) : !data || data.content.length === 0 ? (
<EmptyState title={t('audit.empty')} />
) : (
<>
<AuditTable rows={data.content} />
<Pagination
page={currentPage}
totalPages={totalPages}
size={filters.size ?? 50}
onPrev={() =>
setFilters((f) => ({ ...f, page: Math.max(0, (f.page ?? 0) - 1) }))
}
onNext={() =>
setFilters((f) => ({ ...f, page: (f.page ?? 0) + 1 }))
}
onSizeChange={(size) =>
setFilters((f) => ({ ...f, page: 0, size }))
}
/>
</>
)}
</div>
)
}
type FilterPanelProps = {
filters: AuditFilters
onChange: (f: AuditFilters) => void
dictionaryOptions: { id: string; label: string }[]
actionOptions: { id: string; label: string }[]
onApply: () => void
onReset: () => void
}
function FilterPanel({
filters,
onChange,
dictionaryOptions,
actionOptions,
onApply,
onReset,
}: FilterPanelProps) {
const { t } = useTranslation()
const set = <K extends keyof AuditFilters>(k: K, v: AuditFilters[K]) =>
onChange({ ...filters, [k]: v })
const fromDate = parseFormDate(filters.from)
const toDate = parseFormDate(filters.to)
return (
<div className="bg-white border border-regolith rounded-lg p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<SingleSelect
label={t('audit.filter.dictionary')}
options={dictionaryOptions}
value={filters.dictionaryName ?? ''}
onChange={(id) => set('dictionaryName', id || undefined)}
/>
<SingleSelect
label={t('audit.filter.action')}
options={actionOptions}
value={filters.action ?? ''}
onChange={(id) => set('action', (id as AuditAction) || '')}
/>
<TextInput
label={t('audit.filter.user')}
value={filters.userId ?? ''}
onChange={(e) => set('userId', e.target.value || undefined)}
/>
<TextInput
label={t('audit.filter.businessKey')}
value={filters.businessKey ?? ''}
onChange={(e) => set('businessKey', e.target.value || undefined)}
/>
<DatePicker
label={t('audit.filter.from')}
value={fromDate}
onChange={(d) =>
set(
'from',
d
? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate(),
).padStart(2, '0')}T00:00:00${localTzOffset(d)}`
: undefined,
)
}
/>
<DatePicker
label={t('audit.filter.to')}
value={toDate}
onChange={(d) =>
set(
'to',
d
? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate(),
).padStart(2, '0')}T23:59:59${localTzOffset(d)}`
: undefined,
)
}
/>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-regolith">
<Button type="button" variant="secondary" onClick={onReset}>
{t('audit.filter.reset')}
</Button>
<Button type="button" variant="primary" onClick={onApply}>
{t('audit.filter.apply')}
</Button>
</div>
</div>
)
}
function AuditTable({ rows }: { rows: AuditEntry[] }) {
const { t } = useTranslation()
return (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{t('audit.col.time')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.action')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.dictionary')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.businessKey')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.user')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.scope')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.trace')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.diff')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{rows.length === 0 ? (
<TableEmpty colSpan={8}>{t('audit.empty')}</TableEmpty>
) : (
rows.map((r) => <AuditRow key={r.id} row={r} />)
)}
</TableBody>
</Table>
)
}
function AuditRow({ row }: { row: AuditEntry }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const time = new Date(row.eventTime)
const timeLabel = time.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
const actionVariant =
row.action === 'CREATE' ? 'success' : row.action === 'CLOSE' ? 'error' : 'info'
return (
<>
<TableRow>
<TableCell>
<span className="text-2xs tabular-nums">{timeLabel}</span>
</TableCell>
<TableCell>
<Badge variant={actionVariant}>
{t(`audit.action.${row.action}`, row.action)}
</Badge>
</TableCell>
<TableCell>{row.dictionaryName ?? '—'}</TableCell>
<TableCell>
<span className="font-mono text-xs">{row.businessKey ?? '—'}</span>
</TableCell>
<TableCell>{row.userId ?? 'anonymous'}</TableCell>
<TableCell>
{row.userScope ? <Badge variant="info">{row.userScope}</Badge> : null}
</TableCell>
<TableCell>
<span
className="font-mono text-2xs text-carbon/70"
title={row.traceId ?? ''}
>
{row.traceId ? row.traceId.slice(0, 8) : '—'}
</span>
</TableCell>
<TableCell>
<IconButton
type="button"
icon={open ? <CaretDownIcon /> : <CaretRightIcon />}
label={t('audit.action.expand')}
onClick={() => setOpen((v) => !v)}
/>
</TableCell>
</TableRow>
{open && (
<TableRow>
<TableCell colSpan={8}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 bg-regolith/30 rounded p-3">
<div>
<div className="text-2xs uppercase tracking-label text-carbon/70 mb-1">
{t('audit.diff.before')}
</div>
<pre className="text-2xs font-mono whitespace-pre-wrap break-all bg-white border border-regolith rounded p-2 max-h-60 overflow-auto">
{row.payloadBefore
? JSON.stringify(row.payloadBefore, null, 2)
: '—'}
</pre>
</div>
<div>
<div className="text-2xs uppercase tracking-label text-carbon/70 mb-1">
{t('audit.diff.after')}
</div>
<pre className="text-2xs font-mono whitespace-pre-wrap break-all bg-white border border-regolith rounded p-2 max-h-60 overflow-auto">
{row.payloadAfter
? JSON.stringify(row.payloadAfter, null, 2)
: '—'}
</pre>
</div>
{(row.ipAddress || row.userAgent || row.requestId) && (
<div className="md:col-span-2 text-2xs text-carbon/70 flex flex-wrap gap-x-4 gap-y-1 pt-1">
{row.ipAddress && <span>IP: {row.ipAddress}</span>}
{row.requestId && <span>req: {row.requestId}</span>}
{row.userAgent && (
<span className="truncate max-w-md">UA: {row.userAgent}</span>
)}
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
)
}
type PaginationProps = {
page: number
totalPages: number
size: number
onPrev: () => void
onNext: () => void
onSizeChange: (size: number) => void
}
function Pagination({
page,
totalPages,
size,
onPrev,
onNext,
onSizeChange,
}: PaginationProps) {
const { t } = useTranslation()
return (
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-carbon/70">{t('audit.page.size')}</span>
<SingleSelect
options={PAGE_SIZE_OPTIONS}
value={String(size)}
onChange={(id) => onSizeChange(Number(id))}
/>
</div>
<div className="flex items-center gap-3 text-sm">
<Button
type="button"
variant="secondary"
onClick={onPrev}
disabled={page <= 0}
>
{t('audit.page.prev')}
</Button>
<span className="tabular-nums">
{t('audit.page.of', { cur: page + 1, total: Math.max(totalPages, 1) })}
</span>
<Button
type="button"
variant="secondary"
onClick={onNext}
disabled={page + 1 >= totalPages}
>
{t('audit.page.next')}
</Button>
</div>
</div>
)
}
+174
View File
@@ -0,0 +1,174 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
Badge,
Button,
EmptyState,
LoadingBlock,
PageHeader,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
} from '@nstart/ui'
import { ArrowsClockwiseIcon } from '@phosphor-icons/react'
import { useDlq, useOutboxStats } from '@/api/queries'
export const Route = createFileRoute('/outbox')({
component: OutboxPage,
})
function OutboxPage() {
const { t } = useTranslation()
const [page, setPage] = useState(0)
const size = 50
const stats = useOutboxStats()
const dlq = useDlq(page, size)
return (
<div className="space-y-6">
<PageHeader
title={t('outbox.title')}
description={t('outbox.subtitle')}
actions={
<Button
type="button"
variant="secondary"
leftIcon={<ArrowsClockwiseIcon size={16} />}
onClick={() => {
stats.refetch()
dlq.refetch()
}}
>
{t('audit.filter.apply')}
</Button>
}
/>
<div className="grid grid-cols-2 gap-4">
<StatCard
label={t('outbox.stats.pending')}
value={stats.data?.pending ?? '—'}
tone="info"
/>
<StatCard
label={t('outbox.stats.dlq')}
value={stats.data?.dlq ?? '—'}
tone={(stats.data?.dlq ?? 0) > 0 ? 'error' : 'success'}
/>
</div>
{dlq.error ? (
<Alert variant="error" title={t('error.failed')}>
{String(dlq.error)}
</Alert>
) : dlq.isLoading ? (
<LoadingBlock size="md" label={t('loading')} />
) : !dlq.data || dlq.data.content.length === 0 ? (
<EmptyState title={t('outbox.dlq.empty')} />
) : (
<>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{t('outbox.dlq.col.id')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.eventType')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.dictionary')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.aggregateId')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.topic')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.attempts')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.lastError')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.dlqAt')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{dlq.data.content.map((row) => (
<TableRow key={row.id}>
<TableCell>
<span className="font-mono text-2xs">{row.id}</span>
</TableCell>
<TableCell>
<Badge variant="warning">{row.eventType}</Badge>
</TableCell>
<TableCell>{row.dictionaryName ?? '—'}</TableCell>
<TableCell>
<span className="font-mono text-2xs">{row.aggregateId}</span>
</TableCell>
<TableCell>
<span className="font-mono text-2xs">{row.kafkaTopic}</span>
</TableCell>
<TableCell>{row.attemptCount}</TableCell>
<TableCell>
<span
className="text-2xs text-carbon/70 truncate max-w-md inline-block align-middle"
title={row.lastError ?? ''}
>
{row.lastError ?? '—'}
</span>
</TableCell>
<TableCell>
<span className="text-2xs tabular-nums">
{new Date(row.dlqAt).toLocaleString()}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-end gap-3 text-sm">
<Button
type="button"
variant="secondary"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page <= 0}
>
{t('audit.page.prev')}
</Button>
<span className="tabular-nums">
{t('audit.page.of', {
cur: (dlq.data.number ?? 0) + 1,
total: Math.max(dlq.data.totalPages ?? 1, 1),
})}
</span>
<Button
type="button"
variant="secondary"
onClick={() => setPage((p) => p + 1)}
disabled={(dlq.data.number ?? 0) + 1 >= (dlq.data.totalPages ?? 1)}
>
{t('audit.page.next')}
</Button>
</div>
</>
)}
</div>
)
}
type StatCardProps = {
label: string
value: number | string
tone: 'info' | 'success' | 'error'
}
function StatCard({ label, value, tone }: StatCardProps) {
const colour =
tone === 'error'
? 'text-mars'
: tone === 'success'
? 'text-grass'
: 'text-ultramarain'
return (
<div className="bg-white border border-regolith rounded-lg p-4">
<div className="text-2xs uppercase tracking-label text-carbon/70 mb-1">
{label}
</div>
<div className={`text-2xl font-primary tabular-nums ${colour}`}>{value}</div>
</div>
)
}