7b2fe6f2d5
До этого 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).
415 lines
12 KiB
TypeScript
415 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|