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:
@@ -107,3 +107,67 @@ export type CreateRecordRequest = {
|
|||||||
validFrom?: string
|
validFrom?: string
|
||||||
validTo?: 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useQuery, queryOptions } from '@tanstack/react-query'
|
import { useQuery, queryOptions } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
apiClient,
|
apiClient,
|
||||||
|
type AuditFilters,
|
||||||
|
type AuditPage,
|
||||||
type DictionaryDefinition,
|
type DictionaryDefinition,
|
||||||
type DictionaryDetail,
|
type DictionaryDetail,
|
||||||
|
type DlqPage,
|
||||||
type FlattenedRecord,
|
type FlattenedRecord,
|
||||||
|
type OutboxStats,
|
||||||
type RecordResponse,
|
type RecordResponse,
|
||||||
} from './client'
|
} 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 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) =>
|
||||||
|
|||||||
@@ -14,6 +14,51 @@ i18n
|
|||||||
translation: {
|
translation: {
|
||||||
'app.title': 'Ordinis НСИ ЦУОД',
|
'app.title': 'Ordinis НСИ ЦУОД',
|
||||||
'nav.dictionaries': 'Справочники',
|
'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',
|
'header.scope': 'Доступный scope',
|
||||||
'dict.list.subtitle': 'Каталоги НСИ ЦУОД ОДХ',
|
'dict.list.subtitle': 'Каталоги НСИ ЦУОД ОДХ',
|
||||||
'dict.empty': 'В этом справочнике пока нет записей',
|
'dict.empty': 'В этом справочнике пока нет записей',
|
||||||
@@ -133,6 +178,51 @@ i18n
|
|||||||
translation: {
|
translation: {
|
||||||
'app.title': 'Ordinis MDM',
|
'app.title': 'Ordinis MDM',
|
||||||
'nav.dictionaries': 'Dictionaries',
|
'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',
|
'header.scope': 'Allowed scope',
|
||||||
'dict.list.subtitle': 'Reference data catalogues',
|
'dict.list.subtitle': 'Reference data catalogues',
|
||||||
'dict.empty': 'No records in this dictionary yet',
|
'dict.empty': 'No records in this dictionary yet',
|
||||||
|
|||||||
@@ -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.
|
// 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 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 IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as DictionariesIndexRouteImport } from './routes/dictionaries.index'
|
import { Route as DictionariesIndexRouteImport } from './routes/dictionaries.index'
|
||||||
import { Route as DictionariesNameRouteImport } from './routes/dictionaries.$name'
|
import { Route as DictionariesNameRouteImport } from './routes/dictionaries.$name'
|
||||||
|
|
||||||
|
const OutboxRoute = OutboxRouteImport.update({
|
||||||
|
id: '/outbox',
|
||||||
|
path: '/outbox',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const DictionariesRoute = DictionariesRouteImport.update({
|
const DictionariesRoute = DictionariesRouteImport.update({
|
||||||
id: '/dictionaries',
|
id: '/dictionaries',
|
||||||
path: '/dictionaries',
|
path: '/dictionaries',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuditRoute = AuditRouteImport.update({
|
||||||
|
id: '/audit',
|
||||||
|
path: '/audit',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -37,42 +49,65 @@ const DictionariesNameRoute = DictionariesNameRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/audit': typeof AuditRoute
|
||||||
'/dictionaries': typeof DictionariesRouteWithChildren
|
'/dictionaries': typeof DictionariesRouteWithChildren
|
||||||
|
'/outbox': typeof OutboxRoute
|
||||||
'/dictionaries/$name': typeof DictionariesNameRoute
|
'/dictionaries/$name': typeof DictionariesNameRoute
|
||||||
'/dictionaries/': typeof DictionariesIndexRoute
|
'/dictionaries/': typeof DictionariesIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/audit': typeof AuditRoute
|
||||||
|
'/outbox': typeof OutboxRoute
|
||||||
'/dictionaries/$name': typeof DictionariesNameRoute
|
'/dictionaries/$name': typeof DictionariesNameRoute
|
||||||
'/dictionaries': typeof DictionariesIndexRoute
|
'/dictionaries': typeof DictionariesIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/audit': typeof AuditRoute
|
||||||
'/dictionaries': typeof DictionariesRouteWithChildren
|
'/dictionaries': typeof DictionariesRouteWithChildren
|
||||||
|
'/outbox': typeof OutboxRoute
|
||||||
'/dictionaries/$name': typeof DictionariesNameRoute
|
'/dictionaries/$name': typeof DictionariesNameRoute
|
||||||
'/dictionaries/': typeof DictionariesIndexRoute
|
'/dictionaries/': typeof DictionariesIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/dictionaries' | '/dictionaries/$name' | '/dictionaries/'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/audit'
|
||||||
|
| '/dictionaries'
|
||||||
|
| '/outbox'
|
||||||
|
| '/dictionaries/$name'
|
||||||
|
| '/dictionaries/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/dictionaries/$name' | '/dictionaries'
|
to: '/' | '/audit' | '/outbox' | '/dictionaries/$name' | '/dictionaries'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/audit'
|
||||||
| '/dictionaries'
|
| '/dictionaries'
|
||||||
|
| '/outbox'
|
||||||
| '/dictionaries/$name'
|
| '/dictionaries/$name'
|
||||||
| '/dictionaries/'
|
| '/dictionaries/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
AuditRoute: typeof AuditRoute
|
||||||
DictionariesRoute: typeof DictionariesRouteWithChildren
|
DictionariesRoute: typeof DictionariesRouteWithChildren
|
||||||
|
OutboxRoute: typeof OutboxRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/outbox': {
|
||||||
|
id: '/outbox'
|
||||||
|
path: '/outbox'
|
||||||
|
fullPath: '/outbox'
|
||||||
|
preLoaderRoute: typeof OutboxRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/dictionaries': {
|
'/dictionaries': {
|
||||||
id: '/dictionaries'
|
id: '/dictionaries'
|
||||||
path: '/dictionaries'
|
path: '/dictionaries'
|
||||||
@@ -80,6 +115,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DictionariesRouteImport
|
preLoaderRoute: typeof DictionariesRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/audit': {
|
||||||
|
id: '/audit'
|
||||||
|
path: '/audit'
|
||||||
|
fullPath: '/audit'
|
||||||
|
preLoaderRoute: typeof AuditRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -120,7 +162,9 @@ const DictionariesRouteWithChildren = DictionariesRoute._addFileChildren(
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
AuditRoute: AuditRoute,
|
||||||
DictionariesRoute: DictionariesRouteWithChildren,
|
DictionariesRoute: DictionariesRouteWithChildren,
|
||||||
|
OutboxRoute: OutboxRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ function RootLayout() {
|
|||||||
>
|
>
|
||||||
{t('nav.dictionaries')}
|
{t('nav.dictionaries')}
|
||||||
</Link>
|
</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>
|
</nav>
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<LanguageSwitch
|
<LanguageSwitch
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
-1
@@ -77,7 +77,7 @@ public class AuditLog {
|
|||||||
@Column(name = "user_agent", columnDefinition = "TEXT")
|
@Column(name = "user_agent", columnDefinition = "TEXT")
|
||||||
private String userAgent;
|
private String userAgent;
|
||||||
|
|
||||||
protected AuditLog() {}
|
public AuditLog() {}
|
||||||
|
|
||||||
public Long getId() { return id; }
|
public Long getId() { return id; }
|
||||||
public OffsetDateTime getEventTime() { return eventTime; }
|
public OffsetDateTime getEventTime() { return eventTime; }
|
||||||
|
|||||||
+26
@@ -1,6 +1,10 @@
|
|||||||
package cloud.nstart.terravault.ordinis.domain.audit;
|
package cloud.nstart.terravault.ordinis.domain.audit;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -15,4 +19,26 @@ public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
|
|||||||
List<AuditLog> findByUserIdOrderByEventTimeDesc(String userId);
|
List<AuditLog> findByUserIdOrderByEventTimeDesc(String userId);
|
||||||
|
|
||||||
List<AuditLog> findByEventTimeBetweenOrderByEventTimeDesc(OffsetDateTime from, OffsetDateTime to);
|
List<AuditLog> findByEventTimeBetweenOrderByEventTimeDesc(OffsetDateTime from, OffsetDateTime to);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Гибкий запрос для admin UI: фильтры nullable, NULL = «не фильтруем».
|
||||||
|
* Сортировка задаётся {@link Pageable}.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT a FROM AuditLog a
|
||||||
|
WHERE (:dictionaryName IS NULL OR a.dictionaryName = :dictionaryName)
|
||||||
|
AND (:userId IS NULL OR a.userId = :userId)
|
||||||
|
AND (:action IS NULL OR a.action = :action)
|
||||||
|
AND (:businessKey IS NULL OR a.businessKey = :businessKey)
|
||||||
|
AND (:from IS NULL OR a.eventTime >= :from)
|
||||||
|
AND (:to IS NULL OR a.eventTime <= :to)
|
||||||
|
""")
|
||||||
|
Page<AuditLog> search(
|
||||||
|
@Param("dictionaryName") String dictionaryName,
|
||||||
|
@Param("userId") String userId,
|
||||||
|
@Param("action") String action,
|
||||||
|
@Param("businessKey") String businessKey,
|
||||||
|
@Param("from") OffsetDateTime from,
|
||||||
|
@Param("to") OffsetDateTime to,
|
||||||
|
Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.audit;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.audit.AuditLog;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.context.request.RequestAttributes;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись в {@code audit_log} — юридически значимый лог. Источник истины для
|
||||||
|
* compliance, отдельно от технических логов в Loki.
|
||||||
|
*
|
||||||
|
* <p>Захватывает: user (sub claim из JWT либо "anonymous"), scope, IP, UA,
|
||||||
|
* trace_id из MDC (Micrometer Tracing). Вызывается из {@link
|
||||||
|
* cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService} в
|
||||||
|
* той же транзакции что и change — at-least-once семантика.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AuditLogger {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AuditLogger.class);
|
||||||
|
|
||||||
|
private final AuditLogRepository repository;
|
||||||
|
private final ScopeContext scopeContext;
|
||||||
|
|
||||||
|
public AuditLogger(AuditLogRepository repository, ScopeContext scopeContext) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.scopeContext = scopeContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordCreate(DictionaryDefinition d, DictionaryRecord r) {
|
||||||
|
write("RECORD_CREATED", "CREATE", d, r, null, r.getData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordUpdate(
|
||||||
|
DictionaryDefinition d, DictionaryRecord r, JsonNode payloadBefore) {
|
||||||
|
write("RECORD_UPDATED", "UPDATE", d, r, payloadBefore, r.getData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordClose(DictionaryDefinition d, DictionaryRecord r, String reason) {
|
||||||
|
write("RECORD_CLOSED", "CLOSE", d, r, r.getData(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void write(
|
||||||
|
String eventType,
|
||||||
|
String action,
|
||||||
|
DictionaryDefinition d,
|
||||||
|
DictionaryRecord r,
|
||||||
|
JsonNode before,
|
||||||
|
JsonNode after) {
|
||||||
|
try {
|
||||||
|
AuditLog entry = new AuditLog();
|
||||||
|
entry.setEventTime(OffsetDateTime.now());
|
||||||
|
entry.setEventType(eventType);
|
||||||
|
entry.setAction(action);
|
||||||
|
entry.setDictionaryId(d.getId());
|
||||||
|
entry.setDictionaryName(d.getName());
|
||||||
|
entry.setRecordId(r.getId());
|
||||||
|
entry.setBusinessKey(r.getBusinessKey());
|
||||||
|
entry.setPayloadBefore(before);
|
||||||
|
entry.setPayloadAfter(after);
|
||||||
|
|
||||||
|
User user = currentUser();
|
||||||
|
entry.setUserId(user.id);
|
||||||
|
entry.setUserScope(user.scope);
|
||||||
|
|
||||||
|
RequestContext rc = currentRequest();
|
||||||
|
entry.setIpAddress(rc.ip);
|
||||||
|
entry.setUserAgent(rc.userAgent);
|
||||||
|
entry.setRequestId(rc.requestId);
|
||||||
|
entry.setTraceId(MDC.get("traceId"));
|
||||||
|
|
||||||
|
repository.save(entry);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// Аудит не должен ломать основной flow — но логируем громко чтобы
|
||||||
|
// алертилось. Compliance-кейс ловит на этой ошибке.
|
||||||
|
log.error(
|
||||||
|
"Failed to write audit_log entry: dictionary={} record_id={} action={}: {}",
|
||||||
|
d.getName(), r.getId(), action, ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private User currentUser() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth instanceof JwtAuthenticationToken jwt) {
|
||||||
|
String sub = jwt.getToken().getSubject();
|
||||||
|
if (sub == null || sub.isBlank()) {
|
||||||
|
Object preferred = jwt.getToken().getClaim("preferred_username");
|
||||||
|
sub = preferred == null ? jwt.getName() : String.valueOf(preferred);
|
||||||
|
}
|
||||||
|
Set<DataScope> scopes = scopeContext.currentScopes();
|
||||||
|
DataScope effective = scopes.contains(DataScope.RESTRICTED) ? DataScope.RESTRICTED
|
||||||
|
: scopes.contains(DataScope.INTERNAL) ? DataScope.INTERNAL
|
||||||
|
: DataScope.PUBLIC;
|
||||||
|
return new User(sub, effective);
|
||||||
|
}
|
||||||
|
return new User("anonymous", DataScope.PUBLIC);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RequestContext currentRequest() {
|
||||||
|
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
|
||||||
|
if (attrs instanceof ServletRequestAttributes sra) {
|
||||||
|
HttpServletRequest req = sra.getRequest();
|
||||||
|
String ip = headerOrRemote(req, "X-Forwarded-For");
|
||||||
|
if (ip != null && ip.contains(",")) ip = ip.split(",")[0].trim();
|
||||||
|
String requestId = req.getHeader("X-Request-Id");
|
||||||
|
return new RequestContext(ip, req.getHeader("User-Agent"), requestId);
|
||||||
|
}
|
||||||
|
return new RequestContext(null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String headerOrRemote(HttpServletRequest req, String header) {
|
||||||
|
String v = req.getHeader(header);
|
||||||
|
return (v == null || v.isBlank()) ? req.getRemoteAddr() : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record User(String id, DataScope scope) {}
|
||||||
|
private record RequestContext(String ip, String userAgent, String requestId) {}
|
||||||
|
}
|
||||||
+8
-1
@@ -8,6 +8,7 @@ import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordCreated;
|
|||||||
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordDeleted;
|
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordDeleted;
|
||||||
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordUpdated;
|
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordUpdated;
|
||||||
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
|
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
|
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||||
import cloud.nstart.terravault.ordinis.validation.RecordValidator;
|
import cloud.nstart.terravault.ordinis.validation.RecordValidator;
|
||||||
@@ -43,6 +44,7 @@ public class DictionaryRecordService {
|
|||||||
private final DictionaryDefinitionService definitionService;
|
private final DictionaryDefinitionService definitionService;
|
||||||
private final RecordValidator validator;
|
private final RecordValidator validator;
|
||||||
private final OutboxRecorder outbox;
|
private final OutboxRecorder outbox;
|
||||||
|
private final AuditLogger auditLogger;
|
||||||
|
|
||||||
private static final OffsetDateTime FAR_FUTURE = OffsetDateTime.parse("9999-12-31T23:59:59Z");
|
private static final OffsetDateTime FAR_FUTURE = OffsetDateTime.parse("9999-12-31T23:59:59Z");
|
||||||
|
|
||||||
@@ -50,11 +52,13 @@ public class DictionaryRecordService {
|
|||||||
DictionaryRecordRepository repository,
|
DictionaryRecordRepository repository,
|
||||||
DictionaryDefinitionService definitionService,
|
DictionaryDefinitionService definitionService,
|
||||||
RecordValidator validator,
|
RecordValidator validator,
|
||||||
OutboxRecorder outbox) {
|
OutboxRecorder outbox,
|
||||||
|
AuditLogger auditLogger) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.definitionService = definitionService;
|
this.definitionService = definitionService;
|
||||||
this.validator = validator;
|
this.validator = validator;
|
||||||
this.outbox = outbox;
|
this.outbox = outbox;
|
||||||
|
this.auditLogger = auditLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -110,6 +114,7 @@ public class DictionaryRecordService {
|
|||||||
}
|
}
|
||||||
throw ex;
|
throw ex;
|
||||||
}
|
}
|
||||||
|
auditLogger.recordCreate(d, saved);
|
||||||
publishCreated(d, saved);
|
publishCreated(d, saved);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
@@ -163,6 +168,7 @@ public class DictionaryRecordService {
|
|||||||
throw ex;
|
throw ex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogger.recordUpdate(d, saved, prevData);
|
||||||
publishUpdated(d, saved, previousRecordId, prevData);
|
publishUpdated(d, saved, previousRecordId, prevData);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
@@ -180,6 +186,7 @@ public class DictionaryRecordService {
|
|||||||
current.setValidTo(when);
|
current.setValidTo(when);
|
||||||
repository.save(current);
|
repository.save(current);
|
||||||
|
|
||||||
|
auditLogger.recordClose(d, current, reason);
|
||||||
publishDeleted(d, current, when, reason);
|
publishDeleted(d, current, when, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.web;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.audit.AuditLog;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin endpoints для audit log. Read-only, фильтры опциональны.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code GET /admin/audit} — пагинированный поиск.</li>
|
||||||
|
* <li>{@code GET /admin/audit/export.csv} — выгрузка по тем же фильтрам
|
||||||
|
* (без пагинации; cap 10 000 строк против OOM).</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/audit")
|
||||||
|
public class AuditAdminController {
|
||||||
|
|
||||||
|
private static final int MAX_EXPORT_ROWS = 10_000;
|
||||||
|
|
||||||
|
private final AuditLogRepository repository;
|
||||||
|
|
||||||
|
public AuditAdminController(AuditLogRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Page<AuditEntry> search(
|
||||||
|
@RequestParam(required = false) String dictionaryName,
|
||||||
|
@RequestParam(required = false) String userId,
|
||||||
|
@RequestParam(required = false) String action,
|
||||||
|
@RequestParam(required = false) String businessKey,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime from,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "50") int size) {
|
||||||
|
Page<AuditLog> result = repository.search(
|
||||||
|
nullIfBlank(dictionaryName),
|
||||||
|
nullIfBlank(userId),
|
||||||
|
nullIfBlank(action),
|
||||||
|
nullIfBlank(businessKey),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
PageRequest.of(page, Math.min(size, 200), Sort.by(Sort.Direction.DESC, "eventTime")));
|
||||||
|
return result.map(AuditEntry::from);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/export.csv", produces = "text/csv")
|
||||||
|
public org.springframework.http.ResponseEntity<String> exportCsv(
|
||||||
|
@RequestParam(required = false) String dictionaryName,
|
||||||
|
@RequestParam(required = false) String userId,
|
||||||
|
@RequestParam(required = false) String action,
|
||||||
|
@RequestParam(required = false) String businessKey,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime from,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to) {
|
||||||
|
Page<AuditLog> result = repository.search(
|
||||||
|
nullIfBlank(dictionaryName),
|
||||||
|
nullIfBlank(userId),
|
||||||
|
nullIfBlank(action),
|
||||||
|
nullIfBlank(businessKey),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
PageRequest.of(0, MAX_EXPORT_ROWS, Sort.by(Sort.Direction.DESC, "eventTime")));
|
||||||
|
|
||||||
|
StringBuilder csv = new StringBuilder(
|
||||||
|
"id,event_time,event_type,action,user_id,user_scope,dictionary_name,business_key,record_id,trace_id,ip_address\n");
|
||||||
|
for (AuditLog a : result.getContent()) {
|
||||||
|
csv.append(a.getId()).append(',')
|
||||||
|
.append(a.getEventTime()).append(',')
|
||||||
|
.append(csvCell(a.getEventType())).append(',')
|
||||||
|
.append(csvCell(a.getAction())).append(',')
|
||||||
|
.append(csvCell(a.getUserId())).append(',')
|
||||||
|
.append(a.getUserScope() == null ? "" : a.getUserScope().name()).append(',')
|
||||||
|
.append(csvCell(a.getDictionaryName())).append(',')
|
||||||
|
.append(csvCell(a.getBusinessKey())).append(',')
|
||||||
|
.append(a.getRecordId() == null ? "" : a.getRecordId()).append(',')
|
||||||
|
.append(csvCell(a.getTraceId())).append(',')
|
||||||
|
.append(csvCell(a.getIpAddress()))
|
||||||
|
.append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
String filename = "audit-log-" + OffsetDateTime.now().toLocalDate() + ".csv";
|
||||||
|
return org.springframework.http.ResponseEntity.ok()
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||||
|
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
|
||||||
|
.body(csv.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullIfBlank(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String csvCell(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
if (s.indexOf(',') < 0 && s.indexOf('"') < 0 && s.indexOf('\n') < 0) return s;
|
||||||
|
return "\"" + s.replace("\"", "\"\"") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AuditEntry(
|
||||||
|
Long id,
|
||||||
|
OffsetDateTime eventTime,
|
||||||
|
String eventType,
|
||||||
|
String action,
|
||||||
|
String userId,
|
||||||
|
String userScope,
|
||||||
|
String dictionaryName,
|
||||||
|
UUID dictionaryId,
|
||||||
|
UUID recordId,
|
||||||
|
String businessKey,
|
||||||
|
JsonNode payloadBefore,
|
||||||
|
JsonNode payloadAfter,
|
||||||
|
String traceId,
|
||||||
|
String requestId,
|
||||||
|
String ipAddress,
|
||||||
|
String userAgent) {
|
||||||
|
|
||||||
|
static AuditEntry from(AuditLog a) {
|
||||||
|
return new AuditEntry(
|
||||||
|
a.getId(),
|
||||||
|
a.getEventTime(),
|
||||||
|
a.getEventType(),
|
||||||
|
a.getAction(),
|
||||||
|
a.getUserId(),
|
||||||
|
a.getUserScope() == null ? null : a.getUserScope().name(),
|
||||||
|
a.getDictionaryName(),
|
||||||
|
a.getDictionaryId(),
|
||||||
|
a.getRecordId(),
|
||||||
|
a.getBusinessKey(),
|
||||||
|
a.getPayloadBefore(),
|
||||||
|
a.getPayloadAfter(),
|
||||||
|
a.getTraceId(),
|
||||||
|
a.getRequestId(),
|
||||||
|
a.getIpAddress(),
|
||||||
|
a.getUserAgent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -38,7 +38,7 @@ class DictionaryRecordServiceTest {
|
|||||||
repo = mock(DictionaryRecordRepository.class);
|
repo = mock(DictionaryRecordRepository.class);
|
||||||
// resolveBusinessKey/enforceUniqueFields не дёргают остальные деп — передаём null,
|
// resolveBusinessKey/enforceUniqueFields не дёргают остальные деп — передаём null,
|
||||||
// mocking concrete Spring services через Mockito-inline на JDK 25 ломается.
|
// mocking concrete Spring services через Mockito-inline на JDK 25 ломается.
|
||||||
service = new DictionaryRecordService(repo, null, null, null);
|
service = new DictionaryRecordService(repo, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============= resolveBusinessKey =============
|
// ============= resolveBusinessKey =============
|
||||||
|
|||||||
Reference in New Issue
Block a user