Files
mdm-ordinis/ordinis-admin-ui/src/routes/audit.tsx
T
Zimin A.N. e6294ce79c fix(ui): shared UserCell для display UUID actor'ов везде
Browser QA на staging показал что UUID actor'а render'ится raw во многих
местах кроме drawer (MR !158 — там был inline fix):
- /reviews Records tab — column AUTHOR
- /reviews Schemas tab — column AUTHOR
- /audit log — column USER

77ec2cc8-98e3-4d70-8037-94038fcbde67 — нечитаемо для оператора. Тот же
паттерн что я делал в MR !158, теперь shared helper:

src/lib/useUserDisplay.tsx:
- useUserDisplay(uuid) hook — returns { display, isMe, fullId }
- UserCell component — readonly cell с title=fullId tooltip
- Match current user (JWT sub claim) → 'Я • <preferred_username>'
- Other user → short UUID (8 chars) + full в title
- null/undefined → 'anonymous'

Применено:
- reviews.tsx:334,625 — records + schemas table AUTHOR cells
- audit.tsx:476 — USER cell
- SchemaDraftDrawer.tsx — refactor (был inline в MR !158 → теперь shared)

TODO Phase 1d: backend endpoint /admin/users/{sub}/display чтобы и для
не-current-user отображать username.
2026-05-13 11:43:04 +03:00

591 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Badge,
Button,
DateRangePicker,
EmptyState,
IconButton,
LoadingBlock,
PageHeader,
QueryErrorState,
SingleSelect,
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeaderCell,
TableRow,
TextInput,
} from '@/ui'
import {
ArrowsClockwiseIcon,
CaretDownIcon,
CaretRightIcon,
DownloadIcon,
XIcon,
} from '@phosphor-icons/react'
import { buildAuditExportUrl, useAudit, useDictionaries } from '@/api/queries'
import { UserCell } from '@/lib/useUserDisplay'
import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client'
import { localTzOffset, parseFormDate } from '@/lib/dates'
const ACTIONS: AuditAction[] = ['CREATE', 'UPDATE', 'CLOSE']
const ACTION_VALUES: ReadonlySet<AuditAction> = new Set(ACTIONS)
const PAGE_SIZE_OPTIONS = [
{ id: '50', label: '50' },
{ id: '100', label: '100' },
{ id: '200', label: '200' },
]
const PAGE_SIZE_VALUES = new Set([50, 100, 200])
/**
* URL search params для /audit. Сериализуем фильтры чтобы:
* - reload страницы не сбрасывал фильтр
* - админ мог поделиться ссылкой "audit за вчера на ground_station"
* - browser back/forward работал по фильтрам как навигация
*
* Все поля optional. Validate'им: action — enum, page/size — number.
* Остальное — строка как есть (длина не лимитируем — backend всё равно режет).
*/
type AuditSearch = {
dict?: string
action?: AuditAction
user?: string
bk?: string
from?: string
to?: string
page?: number
size?: number
}
const validateSearch = (raw: Record<string, unknown>): AuditSearch => {
const out: AuditSearch = {}
if (typeof raw.dict === 'string' && raw.dict.length > 0) out.dict = raw.dict
if (typeof raw.action === 'string' && ACTION_VALUES.has(raw.action as AuditAction)) {
out.action = raw.action as AuditAction
}
if (typeof raw.user === 'string' && raw.user.length > 0) out.user = raw.user
if (typeof raw.bk === 'string' && raw.bk.length > 0) out.bk = raw.bk
if (typeof raw.from === 'string' && raw.from.length > 0) out.from = raw.from
if (typeof raw.to === 'string' && raw.to.length > 0) out.to = raw.to
if (typeof raw.page === 'number' && Number.isInteger(raw.page) && raw.page >= 0) {
out.page = raw.page
} else if (typeof raw.page === 'string') {
const p = Number.parseInt(raw.page, 10)
if (Number.isInteger(p) && p >= 0) out.page = p
}
if (typeof raw.size === 'number' && PAGE_SIZE_VALUES.has(raw.size)) out.size = raw.size
else if (typeof raw.size === 'string') {
const s = Number.parseInt(raw.size, 10)
if (PAGE_SIZE_VALUES.has(s)) out.size = s
}
return out
}
export const Route = createFileRoute('/audit')({
component: AuditPage,
validateSearch,
})
/** URL search → AuditFilters (API contract). */
const searchToFilters = (s: AuditSearch): AuditFilters => ({
dictionaryName: s.dict,
action: s.action ?? '',
userId: s.user,
businessKey: s.bk,
from: s.from,
to: s.to,
page: s.page ?? 0,
size: s.size ?? 50,
})
/** AuditFilters → URL search (для navigate({search})). undefined дропаем. */
const filtersToSearch = (f: AuditFilters): AuditSearch => {
const out: AuditSearch = {}
if (f.dictionaryName) out.dict = f.dictionaryName
if (f.action) out.action = f.action as AuditAction
if (f.userId) out.user = f.userId
if (f.businessKey) out.bk = f.businessKey
if (f.from) out.from = f.from
if (f.to) out.to = f.to
if (f.page && f.page > 0) out.page = f.page
if (f.size && f.size !== 50) out.size = f.size
return out
}
function AuditPage() {
const { t } = useTranslation()
const urlSearch = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const dictionariesQuery = useDictionaries()
// filters: derived из URL — single source of truth.
// draft: локальный staging фильтра до Apply — не идёт в URL до явного push.
const filters = useMemo(() => searchToFilters(urlSearch), [urlSearch])
const [draft, setDraft] = useState<AuditFilters>(filters)
// Sync draft когда URL поменялся снаружи (browser back/forward, ссылка).
useEffect(() => {
setDraft(filters)
}, [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],
)
// navigate с replace=false — каждый Apply пишет history entry.
// Reset → пустой URL без params.
const apply = () =>
navigate({ search: filtersToSearch({ ...draft, page: 0 }), replace: false })
const reset = () => {
setDraft({ page: 0, size: filters.size ?? 50 })
navigate({
search: filters.size && filters.size !== 50 ? { size: filters.size } : {},
replace: false,
})
}
const setPage = (page: number) =>
navigate({ search: filtersToSearch({ ...filters, page }), replace: false })
const setSize = (size: number) =>
navigate({ search: filtersToSearch({ ...filters, page: 0, size }), replace: false })
const totalPages = data?.totalPages ?? 0
const currentPage = data?.number ?? 0
// Активные фильтры для chip-list. Action='' → нет фильтра.
// page/size исключаем — это пагинация, не фильтр.
const activeFilters = useMemo(() => {
const chips: { key: keyof AuditFilters; label: string; value: string }[] = []
if (filters.dictionaryName) {
const dict = dictionariesQuery.data?.find((d) => d.name === filters.dictionaryName)
chips.push({
key: 'dictionaryName',
label: t('audit.filter.dictionary'),
value: dict?.displayName ?? filters.dictionaryName,
})
}
if (filters.action) {
chips.push({
key: 'action',
label: t('audit.filter.action'),
value: t(`audit.action.${filters.action}`),
})
}
if (filters.userId) {
chips.push({ key: 'userId', label: t('audit.filter.user'), value: filters.userId })
}
if (filters.businessKey) {
chips.push({
key: 'businessKey',
label: t('audit.filter.businessKey'),
value: filters.businessKey,
})
}
if (filters.from) {
chips.push({ key: 'from', label: t('audit.filter.from'), value: filters.from.slice(0, 10) })
}
if (filters.to) {
chips.push({ key: 'to', label: t('audit.filter.to'), value: filters.to.slice(0, 10) })
}
return chips
}, [filters, dictionariesQuery.data, t])
const removeFilter = (key: keyof AuditFilters) => {
const next: AuditFilters = { ...filters, page: 0 }
if (key === 'action') next.action = ''
else next[key] = undefined
navigate({ search: filtersToSearch(next), replace: false })
}
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}
/>
{activeFilters.length > 0 && (
<ActiveFilterChips filters={activeFilters} onRemove={removeFilter} />
)}
{error ? (
<QueryErrorState error={error} onRetry={() => refetch()} />
) : 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={() => setPage(Math.max(0, currentPage - 1))}
onNext={() => setPage(currentPage + 1)}
onSizeChange={setSize}
/>
</>
)}
</div>
)
}
type ActiveFilterChipsProps = {
filters: { key: keyof AuditFilters; label: string; value: string }[]
onRemove: (key: keyof AuditFilters) => void
}
function ActiveFilterChips({ filters, onRemove }: ActiveFilterChipsProps) {
const { t } = useTranslation()
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-cap text-ink-2">
{t('audit.filter.active')}
</span>
{filters.map((f) => (
<button
key={f.key}
type="button"
onClick={() => onRemove(f.key)}
className={[
'inline-flex items-center gap-1.5 px-2 py-1 rounded-sm border text-cell',
'bg-surface border-line hover:border-ink/40 hover:bg-line/30',
'transition-colors group',
].join(' ')}
title={t('audit.filter.removeChip')}
>
<span className="text-ink-2">{f.label}:</span>
<span className="font-medium text-black">{f.value}</span>
<XIcon size={12} className="text-mute group-hover:text-mars" />
</button>
))}
</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)
// Apply на Enter в TextInput'ах — быстрее чем клик мышкой
const handleEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
onApply()
}
}
return (
<div className="bg-surface border border-line 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)}
onKeyDown={handleEnter}
/>
<TextInput
label={t('audit.filter.businessKey')}
value={filters.businessKey ?? ''}
onChange={(e) => set('businessKey', e.target.value || undefined)}
onKeyDown={handleEnter}
/>
{/* DateRangePicker заменяет пару отдельных DatePicker'ов — один
* popover с двумя месяцами, выбор start/end в одном UI. ISO sтроки
* c local TZ offset формируются здесь, чтобы backend получал
* правильный bounds (T00:00:00 для from, T23:59:59 для to). */}
<div className="md:col-span-2">
<DateRangePicker
label={t('audit.filter.range', { defaultValue: 'Период' })}
value={{ from: fromDate ?? undefined, to: toDate ?? undefined }}
onChange={({ from, to }) => {
const fromIso = from
? `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(
from.getDate(),
).padStart(2, '0')}T00:00:00${localTzOffset(from)}`
: undefined
const toIso = to
? `${to.getFullYear()}-${String(to.getMonth() + 1).padStart(2, '0')}-${String(
to.getDate(),
).padStart(2, '0')}T23:59:59${localTzOffset(to)}`
: undefined
onChange({ ...filters, from: fromIso, to: toIso })
}}
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-line">
<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-cell 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=" text-mono">{row.businessKey ?? '—'}</span>
</TableCell>
<TableCell><UserCell uuid={row.userId} /></TableCell>
<TableCell>
{row.userScope ? <Badge variant="info">{row.userScope}</Badge> : null}
</TableCell>
<TableCell>
<span
className="text-mono text-ink-2"
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-line/30 rounded p-3">
<div>
<div className="text-cap text-ink-2 mb-1">
{t('audit.diff.before')}
</div>
<pre className="text-mono whitespace-pre-wrap break-all bg-surface border border-line rounded p-2 max-h-60 overflow-auto">
{row.payloadBefore
? JSON.stringify(row.payloadBefore, null, 2)
: '—'}
</pre>
</div>
<div>
<div className="text-cap text-ink-2 mb-1">
{t('audit.diff.after')}
</div>
<pre className="text-mono whitespace-pre-wrap break-all bg-surface border border-line 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-cell text-ink-2 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-body">
<span className="text-ink-2">{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-body">
<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>
)
}