feat(timetravel): Records + Schema tabs wired with real data
This commit is contained in:
@@ -2,8 +2,8 @@ import { useMemo, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react'
|
import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react'
|
||||||
import { Badge, Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui'
|
import { Badge, Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui'
|
||||||
import type { ChangelogEntry, DictionaryDetail } from '@/api/client'
|
import type { ChangelogEntry, DictionaryDetail, FlattenedRecord } from '@/api/client'
|
||||||
import { useChangelog } from '@/api/queries'
|
import { useChangelog, useChangelogDiff, useRecords } from '@/api/queries'
|
||||||
import { kindBadgeVariant, kindIcon, kindLabel } from '@/components/changelog/kind-meta'
|
import { kindBadgeVariant, kindIcon, kindLabel } from '@/components/changelog/kind-meta'
|
||||||
import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal'
|
import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -88,6 +88,26 @@ export function TimeTravelModal({
|
|||||||
)
|
)
|
||||||
}, [changelog.data, picked])
|
}, [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.
|
// Range: от min(marks, createdAt) до now+padding.
|
||||||
const createdMs = Date.parse(detail.createdAt)
|
const createdMs = Date.parse(detail.createdAt)
|
||||||
const range = useMemo(() => {
|
const range = useMemo(() => {
|
||||||
@@ -271,7 +291,7 @@ export function TimeTravelModal({
|
|||||||
active={tab === 'records'}
|
active={tab === 'records'}
|
||||||
onClick={() => setTab('records')}
|
onClick={() => setTab('records')}
|
||||||
label={t('timeTravel.tabs.records', { defaultValue: 'Записи на момент' })}
|
label={t('timeTravel.tabs.records', { defaultValue: 'Записи на момент' })}
|
||||||
count={totalRecords}
|
count={recordsQuery.data?.length ?? totalRecords}
|
||||||
/>
|
/>
|
||||||
<TabBtn
|
<TabBtn
|
||||||
active={tab === 'schema'}
|
active={tab === 'schema'}
|
||||||
@@ -292,26 +312,19 @@ export function TimeTravelModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === 'records' && (
|
{tab === 'records' && (
|
||||||
<div className="flex items-center justify-center text-mute text-body">
|
<RecordsTabContent
|
||||||
<span>
|
loading={recordsQuery.isLoading}
|
||||||
{pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '}
|
records={recordsQuery.data ?? []}
|
||||||
{totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')}
|
pickedDate={pickedDate}
|
||||||
</span>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{tab === 'schema' && (
|
{tab === 'schema' && (
|
||||||
<div className="flex items-center justify-center text-mute text-body">
|
<SchemaTabContent
|
||||||
<span>
|
dictName={detail.name}
|
||||||
v{detail.schemaVersion} ·{' '}
|
currentSchema={detail.schemaJson}
|
||||||
{Object.keys(detail.schemaJson.properties ?? {}).length}{' '}
|
currentVersion={detail.schemaVersion}
|
||||||
{pluralRu(
|
pickedEntry={pickedVersionEntry}
|
||||||
Object.keys(detail.schemaJson.properties ?? {}).length,
|
/>
|
||||||
'поле',
|
|
||||||
'поля',
|
|
||||||
'полей',
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -686,3 +699,224 @@ function ChangesTabContent({
|
|||||||
</ul>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -203,6 +203,20 @@ i18n
|
|||||||
'queryError.generic.body': 'Что-то пошло не так. Попробуйте ещё раз.',
|
'queryError.generic.body': 'Что-то пошло не так. Попробуйте ещё раз.',
|
||||||
'timeTravel.empty.noEvents': 'Между этими моментами ничего не менялось.',
|
'timeTravel.empty.noEvents': 'Между этими моментами ничего не менялось.',
|
||||||
'timeTravel.action.diff': 'Diff',
|
'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.eventId': 'Event ID',
|
||||||
'webhooks.deliveries.col.attempts': 'Попыток',
|
'webhooks.deliveries.col.attempts': 'Попыток',
|
||||||
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
|
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
|
||||||
@@ -870,6 +884,19 @@ i18n
|
|||||||
'queryError.generic.body': 'Something went wrong. Please try again.',
|
'queryError.generic.body': 'Something went wrong. Please try again.',
|
||||||
'timeTravel.empty.noEvents': 'Nothing changed between these moments.',
|
'timeTravel.empty.noEvents': 'Nothing changed between these moments.',
|
||||||
'timeTravel.action.diff': 'Diff',
|
'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.eventId': 'Event ID',
|
||||||
'webhooks.deliveries.col.attempts': 'Attempts',
|
'webhooks.deliveries.col.attempts': 'Attempts',
|
||||||
'webhooks.deliveries.col.lastAttempt': 'Last attempt',
|
'webhooks.deliveries.col.lastAttempt': 'Last attempt',
|
||||||
|
|||||||
Reference in New Issue
Block a user