Files
mdm-ordinis/ordinis-admin-ui/src/routes/audit.tsx
T
Zimin A.N. ff7e72e0fd fix(qa-report): version tag, /drafts redirect, audit filter complete, i18n cleanup
QA report v2.31.x batch:

- **VersionController.tag**: было 'none' (maven build на main без git tag) → UI
  показывал «dev · 6077d4df». Helm передаёт image.tag через env
  ORDINIS_BUILD_IMAGE_TAG, controller парсит «v2-31-2-e3c82775» → «v2.31.2».
  Helm chart change в ordinis-infra (separate MR).

- **/drafts redirect** (BUG-003): пустой 404 «Not Found» plain text → теперь
  static redirect на /my-drafts.

- **Audit action filter** (BUG-004): дропдаун содержал только 3 значения
  (CREATE/UPDATE/CLOSE), но в таблице 11 типов (DRAFT_*, DEFINITION_*).
  Frontend AuditAction enum + ACTIONS list синхронизированы с backend
  AuditLogger/SchemaDraftService/DraftService.

- **i18n cleanup** (BUG-006/007/UX-005):
  - myDrafts.description: «Все ваши submissions с pending/approved...» →
    «Все ваши черновики с историей: ожидает рассмотрения, одобрен...»
  - reviews.description: «Pending drafts от makers, ждут approve/reject» →
    «Черновики от авторов, ждут одобрения или отклонения. Общая очередь...»
  - reviews.queueTotal: «N pending drafts» → «N черновиков в очереди»
  - myDrafts.col.reviewer: «Reviewer» → «Рецензент» (RU only)
  - notifications chip labels: 'submit/approved/rejected' → 'отправлен/
    одобрен/отклонён'
  - audit.action.* expanded к 13 entries для каждого backend action
    type, с понятными лейблами «Черновик одобрен» вместо «DRAFT_APPROVE»

- **WhatsNewModal markdown** (UX-006): тексты вроде «**График доставки
  webhook'ов**» рендерились с буквальными звёздочками. Mini inline renderer
  для **bold**, *italic*, `code` — без full markdown library.
2026-05-15 22:02:29 +03:00

609 lines
20 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'
// Все 12 audit action types — должны matchить backend (AuditLogger,
// SchemaDraftService.applyDecision, DraftService.emitDraftEvent).
// QA report BUG-004: фильтр предлагал только CREATE/UPDATE/CLOSE → DRAFT_*
// и DEFINITION_* events не были фильтруемы через UI.
const ACTIONS: AuditAction[] = [
'CREATE',
'UPDATE',
'CLOSE',
'DEFINITION_CREATE',
'DEFINITION_UPDATE',
'DRAFT_CREATE',
'DRAFT_SUBMIT',
'DRAFT_REVIEW',
'DRAFT_APPROVE',
'DRAFT_REJECT',
'DRAFT_CHANGES_REQUESTED',
'DRAFT_WITHDRAW',
'DRAFT_PUBLISH',
]
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>
)
}