feat(timetravel): Records + Schema tabs wired with real data

This commit is contained in:
Александр Зимин
2026-05-12 13:00:03 +00:00
parent 8878b3f0b6
commit 775b8345e3
2 changed files with 282 additions and 21 deletions
@@ -2,8 +2,8 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react'
import { Badge, Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui'
import type { ChangelogEntry, DictionaryDetail } from '@/api/client'
import { useChangelog } from '@/api/queries'
import type { ChangelogEntry, DictionaryDetail, FlattenedRecord } from '@/api/client'
import { useChangelog, useChangelogDiff, useRecords } from '@/api/queries'
import { kindBadgeVariant, kindIcon, kindLabel } from '@/components/changelog/kind-meta'
import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal'
import { cn } from '@/lib/utils'
@@ -88,6 +88,26 @@ export function TimeTravelModal({
)
}, [changelog.data, picked])
// Find changelog entry which BROUGHT the dict to version active at `picked`:
// newest entry с publishedAt <= picked. Используется в Schema tab для
// "история этой версии" + diff.after schema fields.
const pickedVersionEntry = useMemo<ChangelogEntry | null>(() => {
if (!changelog.data) return null
const ordered = [...changelog.data.entries].sort(
(a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),
)
return ordered.find((e) => new Date(e.publishedAt).getTime() <= picked) ?? null
}, [changelog.data, picked])
// Records as-of picked moment — backend findActiveAt(at) на read-api.
const isAtNow0 = Math.abs(picked - now) < 60_000
const recordsQuery = useRecords(
detail.name,
'PUBLIC,INTERNAL,RESTRICTED',
// Если picked ≈ now, не передаём `at` — экономим лишний DB hit + cache key.
isAtNow0 ? undefined : { at: new Date(picked).toISOString() },
)
// Range: от min(marks, createdAt) до now+padding.
const createdMs = Date.parse(detail.createdAt)
const range = useMemo(() => {
@@ -271,7 +291,7 @@ export function TimeTravelModal({
active={tab === 'records'}
onClick={() => setTab('records')}
label={t('timeTravel.tabs.records', { defaultValue: 'Записи на момент' })}
count={totalRecords}
count={recordsQuery.data?.length ?? totalRecords}
/>
<TabBtn
active={tab === 'schema'}
@@ -292,26 +312,19 @@ export function TimeTravelModal({
/>
)}
{tab === 'records' && (
<div className="flex items-center justify-center text-mute text-body">
<span>
{pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '}
{totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')}
</span>
</div>
<RecordsTabContent
loading={recordsQuery.isLoading}
records={recordsQuery.data ?? []}
pickedDate={pickedDate}
/>
)}
{tab === 'schema' && (
<div className="flex items-center justify-center text-mute text-body">
<span>
v{detail.schemaVersion} ·{' '}
{Object.keys(detail.schemaJson.properties ?? {}).length}{' '}
{pluralRu(
Object.keys(detail.schemaJson.properties ?? {}).length,
'поле',
'поля',
'полей',
)}
</span>
</div>
<SchemaTabContent
dictName={detail.name}
currentSchema={detail.schemaJson}
currentVersion={detail.schemaVersion}
pickedEntry={pickedVersionEntry}
/>
)}
</div>
@@ -686,3 +699,224 @@ function ChangesTabContent({
</ul>
)
}
/**
* «Записи на момент» tab — table of records as-of picked moment.
* Uses {@code useRecords(at: pickedISO)} — backend findActiveAt(at).
*
* <p>Top 50 rendered. Если больше — показываем counter с pagination
* notice (full pagination не нужен в snapshot view).
*/
function RecordsTabContent({
loading,
records,
pickedDate,
}: {
loading: boolean
records: FlattenedRecord[]
pickedDate: Date
}) {
const { t } = useTranslation()
if (loading) {
return (
<div className="space-y-1">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-9 rounded-md border border-line bg-surface-2/40 animate-pulse"
/>
))}
</div>
)
}
if (records.length === 0) {
return (
<div className="flex items-center justify-center text-mute text-body py-10">
{t('timeTravel.records.empty', {
date: pickedDate.toLocaleDateString(),
defaultValue: `На ${pickedDate.toLocaleDateString()} записей не было.`,
})}
</div>
)
}
const visible = records.slice(0, 50)
const truncated = records.length > 50
return (
<div className="space-y-2">
<div className="text-cap text-mute uppercase tracking-wider">
{t('timeTravel.records.title', {
count: records.length,
defaultValue: `${records.length} записей на ${pickedDate.toLocaleDateString()}`,
})}
</div>
<div className="rounded-md border border-line overflow-hidden">
<table className="w-full text-cell">
<thead className="bg-surface-2 text-cap text-mute uppercase tracking-wider">
<tr>
<th className="text-left px-3 py-2">businessKey</th>
<th className="text-left px-3 py-2">
{t('timeTravel.records.col.name', { defaultValue: 'Название' })}
</th>
<th className="text-left px-3 py-2">scope</th>
<th className="text-left px-3 py-2">
{t('timeTravel.records.col.validFrom', { defaultValue: 'Действует с' })}
</th>
</tr>
</thead>
<tbody>
{visible.map((r) => {
const displayName =
(r.data['name_ru'] as string | undefined) ??
(r.data['name'] as string | undefined) ??
(r.data['name_en'] as string | undefined) ??
'—'
return (
<tr key={r.id} className="border-t border-line">
<td className="px-3 py-2 font-mono">{r.businessKey}</td>
<td className="px-3 py-2 text-ink truncate max-w-[300px]">{displayName}</td>
<td className="px-3 py-2 text-cap">{r.dataScope}</td>
<td className="px-3 py-2 tabular-nums text-mute">
{new Date(r.validFrom).toLocaleDateString()}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{truncated && (
<div className="text-cap text-mute italic">
{t('timeTravel.records.truncated', {
visible: visible.length,
total: records.length,
defaultValue: `Показано ${visible.length} из ${records.length}. Открыть как readonly для полного списка.`,
})}
</div>
)}
</div>
)
}
/**
* «Структура и поля» tab — fields list at picked version + history panel
* на правой колонке.
*
* <p>Поля fetch'аются через ChangelogDiff endpoint (after schema). Если
* pickedEntry null — fallback на current schemaJson.
*
* <p>Right panel показывает changelog entry который attached к picked
* version: commit (audit id), автор, дата, headers/message.
*/
function SchemaTabContent({
dictName,
currentSchema,
currentVersion,
pickedEntry,
}: {
dictName: string
currentSchema: { properties?: Record<string, unknown> }
currentVersion: string
pickedEntry: ChangelogEntry | null
}) {
const { t } = useTranslation()
// Fetch full diff для picked entry чтобы получить historical `after`
// schema. Если pickedEntry — current/latest, after === current; иначе
// berunaround older snapshot.
const diff = useChangelogDiff(dictName, pickedEntry?.id)
const after = (diff.data?.after ?? null) as
| { properties?: Record<string, unknown> }
| null
// Fallback chain: diff.after → currentSchema. Эта семантика:
// "если для версии диффа нет → показываем live schema with disclaimer".
const propsSource = after ?? currentSchema
const fields = Object.keys(propsSource.properties ?? {}).sort()
const versionLabel = pickedEntry?.version ?? currentVersion
const isCurrentSchemaFallback = !after && Boolean(pickedEntry)
return (
<div className="grid grid-cols-1 md:grid-cols-[1fr_minmax(220px,300px)] gap-6">
{/* LEFT — fields list */}
<div className="space-y-2">
<div className="text-cap text-mute uppercase tracking-wider">
{t('timeTravel.schema.fieldsTitle', {
version: versionLabel,
defaultValue: `Поля справочника на v${versionLabel}`,
})}
</div>
{diff.isLoading && (
<div className="space-y-1">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-8 rounded-md border border-line bg-surface-2/40 animate-pulse"
/>
))}
</div>
)}
{!diff.isLoading && fields.length === 0 && (
<div className="text-cell text-mute py-4 text-center">
{t('timeTravel.schema.empty', { defaultValue: 'Полей нет.' })}
</div>
)}
{!diff.isLoading && fields.length > 0 && (
<ul className="rounded-md border border-line divide-y divide-line">
{fields.map((f) => (
<li key={f} className="px-3 py-2 font-mono text-cell text-ink">
{f}
</li>
))}
</ul>
)}
{isCurrentSchemaFallback && (
<div className="text-cap text-mute italic">
{t('timeTravel.schema.fallback', {
defaultValue: 'Показана live-схема — diff на эту версию недоступен.',
})}
</div>
)}
</div>
{/* RIGHT — history panel */}
<div className="space-y-2">
<div className="text-cap text-mute uppercase tracking-wider">
{t('timeTravel.schema.historyTitle', {
defaultValue: 'История этой версии',
})}
</div>
{pickedEntry ? (
<dl className="rounded-md border border-line bg-surface p-3 space-y-2 text-cell">
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
<dt className="text-cap text-mute uppercase tracking-wider">commit</dt>
<dd className="font-mono text-accent">#{pickedEntry.id}</dd>
<dt className="text-cap text-mute uppercase tracking-wider">
{t('timeTravel.schema.author', { defaultValue: 'автор' })}
</dt>
<dd className="font-mono">{pickedEntry.publishedBy.name}</dd>
<dt className="text-cap text-mute uppercase tracking-wider">
{t('timeTravel.schema.date', { defaultValue: 'дата' })}
</dt>
<dd className="tabular-nums">
{new Date(pickedEntry.publishedAt).toLocaleString()}
</dd>
</div>
{pickedEntry.headers && (
<div className="text-body text-ink mt-2 italic">
«{pickedEntry.headers}»
</div>
)}
</dl>
) : (
<div className="text-cell text-mute italic">
{t('timeTravel.schema.noHistory', {
defaultValue: 'История недоступна для выбранного момента.',
})}
</div>
)}
</div>
</div>
)
}
+27
View File
@@ -203,6 +203,20 @@ i18n
'queryError.generic.body': 'Что-то пошло не так. Попробуйте ещё раз.',
'timeTravel.empty.noEvents': 'Между этими моментами ничего не менялось.',
'timeTravel.action.diff': 'Diff',
'timeTravel.records.empty': 'На {{date}} записей не было.',
'timeTravel.records.title_one': '{{count}} запись на {{date}}',
'timeTravel.records.title_few': '{{count}} записи на {{date}}',
'timeTravel.records.title_many': '{{count}} записей на {{date}}',
'timeTravel.records.col.name': 'Название',
'timeTravel.records.col.validFrom': 'Действует с',
'timeTravel.records.truncated': 'Показано {{visible}} из {{total}}. Откройте как readonly для полного списка.',
'timeTravel.schema.fieldsTitle': 'Поля справочника на v{{version}}',
'timeTravel.schema.empty': 'Полей нет.',
'timeTravel.schema.fallback': 'Показана live-схема — diff на эту версию недоступен.',
'timeTravel.schema.historyTitle': 'История этой версии',
'timeTravel.schema.author': 'автор',
'timeTravel.schema.date': 'дата',
'timeTravel.schema.noHistory': 'История недоступна для выбранного момента.',
'webhooks.deliveries.col.eventId': 'Event ID',
'webhooks.deliveries.col.attempts': 'Попыток',
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
@@ -870,6 +884,19 @@ i18n
'queryError.generic.body': 'Something went wrong. Please try again.',
'timeTravel.empty.noEvents': 'Nothing changed between these moments.',
'timeTravel.action.diff': 'Diff',
'timeTravel.records.empty': 'No records existed on {{date}}.',
'timeTravel.records.title_one': '{{count}} record on {{date}}',
'timeTravel.records.title_other': '{{count}} records on {{date}}',
'timeTravel.records.col.name': 'Name',
'timeTravel.records.col.validFrom': 'Valid from',
'timeTravel.records.truncated': 'Showing {{visible}} of {{total}}. Open as readonly for the full list.',
'timeTravel.schema.fieldsTitle': 'Dictionary fields at v{{version}}',
'timeTravel.schema.empty': 'No fields.',
'timeTravel.schema.fallback': 'Live schema shown — historical diff unavailable for this version.',
'timeTravel.schema.historyTitle': 'History of this version',
'timeTravel.schema.author': 'author',
'timeTravel.schema.date': 'date',
'timeTravel.schema.noHistory': 'History unavailable for the picked moment.',
'webhooks.deliveries.col.eventId': 'Event ID',
'webhooks.deliveries.col.attempts': 'Attempts',
'webhooks.deliveries.col.lastAttempt': 'Last attempt',