c06ae263e0
dict-relationships-v2 epic, Phase 2. Precomputed reverse FK index +
throttled refresh + feature flag + UI staleness badge. Migration катится
всегда; query path активируется через ordinis.lineage.mv.enabled=true.
Backend:
- Liquibase 0016: CREATE MATERIALIZED VIEW record_dependents_index +
UNIQUE INDEX(source_record_id, source_field) (REQUIRED для REFRESH
CONCURRENTLY) + composite indices для record-level и schema-level lookup.
Plus lineage_mv_meta table — single-row tracking (last_refreshed_at,
duration_ms, row_count, status, error).
- DependentsQueryMv (interface impl, @Primary + @ConditionalOnProperty)
— один SQL query через MV вместо N JSONB lookups (по одному per
source dict).
- MaterializedViewRefreshService:
* @Scheduled refresh каждые ordinis.lineage.mv.refresh-interval-sec
(default 60). Storm prevention: AtomicReference guard для in-flight,
cooldown через ordinis.lineage.mv.min-interval-sec (default 30).
* REFRESH CONCURRENTLY с fallback на plain REFRESH когда MV пустой
(initial state). Не блокирует concurrent reads.
* MvGateway interface (extracted из-за JDK 25 + Mockito incompat для
concrete JdbcTemplate). Production: JdbcMvGateway (nested static).
- LineageIndexService теперь возвращает LineageMeta в RecordDependentsPage
— mvEnabled, lastRefreshedAt, lastStatus, lastDurationMs. Optional<MV>
inject — works без MV (mvEnabled=false, всё null).
Tests:
- MaterializedViewRefreshServiceTest (9 tests):
* scheduledRefresh first time → REFRESH CONCURRENTLY
* skip when within cooldown
* refreshNow throws RefreshThrottledException when throttled
* refresh succeeds после cooldown
* fallback на plain REFRESH когда CONCURRENTLY fails
* STORM PREVENTION (R2): 100 concurrent refresh attempts → ровно 1
actual refresh (via cooldown + in-flight guard)
* lastRefresh returns meta correctly
* updates lineage_mv_meta after refresh (status=ok)
* updates with error если оба refresh'а fail (status=error)
Admin UI:
- RecordDependentsPage type расширен LineageMeta { mvEnabled,
lastRefreshedAt, lastStatus, lastDurationMs }.
- RecordDependentsPanel показывает staleness badge "обновлено N мин
назад" если MV active + last refresh > 2 min ago. Tooltip объясняет
что closed-between-refreshes records отфильтрованы query-side.
- i18n RU/EN: lineage.refs.{stale, staleHint}.
Verify:
- mvn -pl ordinis-rest-api -am test: 98/98 PASS (+9 new MV tests).
- mvn verify -pl '!ordinis-app': все модули SUCCESS (9.3s).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
Deploy strategy:
- Migration катится автоматом (initial REFRESH < 5 min на 5k records).
- Query path остаётся JSONB direct (Phase 1) до явного flip flag'а
ordinis.lineage.mv.enabled=true. Это позволит верифицировать MV
корректность на staging без риска для prod.
- После flip: refresh каждые 60 sec, stale window <= 2 min обычно.
637 lines
39 KiB
TypeScript
637 lines
39 KiB
TypeScript
import i18n from 'i18next'
|
||
import { initReactI18next } from 'react-i18next'
|
||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||
|
||
i18n
|
||
.use(LanguageDetector)
|
||
.use(initReactI18next)
|
||
.init({
|
||
fallbackLng: 'ru-RU',
|
||
supportedLngs: ['ru-RU', 'en-US'],
|
||
interpolation: { escapeValue: false },
|
||
resources: {
|
||
'ru-RU': {
|
||
translation: {
|
||
'app.title': 'Ordinis НСИ ЦУОД',
|
||
'nav.dictionaries': 'Справочники',
|
||
'nav.audit': 'Аудит',
|
||
'nav.outbox': 'Outbox',
|
||
'nav.webhooks': 'Webhooks',
|
||
'webhooks.title': 'Webhook subscriptions',
|
||
'webhooks.subtitle': 'HTTP-доставка событий не-Kafka подписчикам',
|
||
'webhooks.empty': 'Нет подписок. Создайте первую через кнопку справа.',
|
||
'webhooks.active': 'Активна',
|
||
'webhooks.inactive': 'Отключена',
|
||
'webhooks.allScopes': 'все scope',
|
||
'webhooks.allDicts': 'все справочники',
|
||
'webhooks.allEvents': 'все события',
|
||
'webhooks.notFound': 'Подписка не найдена',
|
||
'webhooks.col.name': 'Имя',
|
||
'webhooks.col.url': 'URL',
|
||
'webhooks.col.scopes': 'Scope filter',
|
||
'webhooks.col.dictionaries': 'Справочники',
|
||
'webhooks.col.status': 'Статус',
|
||
'webhooks.action.create': 'Создать',
|
||
'webhooks.action.delete': 'Удалить',
|
||
'webhooks.action.rotateSecret': 'Сменить secret',
|
||
'webhooks.action.test': 'Тест ping',
|
||
'webhooks.test.success': 'Receiver получил test event и ответил 2xx',
|
||
'webhooks.test.failure': 'Receiver не принял test event',
|
||
'webhooks.test.rejected': 'Запрос не отправлен (SSRF guard / serialization)',
|
||
'webhooks.test.latency': 'latency',
|
||
'webhooks.confirmDelete': 'Удалить подписку «{{name}}»? Все pending deliveries удалятся каскадом.',
|
||
'webhooks.confirmRotate': 'Сменить HMAC secret? Старый сразу перестанет работать — receiver надо обновить заранее.',
|
||
'webhooks.field.name': 'Имя подписки',
|
||
'webhooks.field.url': 'Webhook URL',
|
||
'webhooks.field.urlHint': 'https:// или http://. Private CIDR (10/8, 192.168/16, 127/8) запрещены SSRF guard',
|
||
'webhooks.field.scopeFilter': 'Scope filter',
|
||
'webhooks.field.scopeFilterHint': 'Пусто = все scope',
|
||
'webhooks.field.dictionaryFilter': 'Справочники (CSV)',
|
||
'webhooks.field.dictionaryFilterHint': 'Через запятую: spacecraft, satellite_type. Пусто = все',
|
||
'webhooks.field.eventTypeFilter': 'Типы событий (CSV)',
|
||
'webhooks.field.eventTypeFilterHint': 'record.created, record.updated, record.closed. Пусто = все',
|
||
'webhooks.field.description': 'Описание',
|
||
'webhooks.field.createdBy': 'Создатель',
|
||
'webhooks.error.nameUrlRequired': 'Имя и URL обязательны',
|
||
'webhooks.error.urlScheme': 'URL должен начинаться с http:// или https://',
|
||
'webhooks.secret.title': 'HMAC secret для «{{name}}»',
|
||
'webhooks.secret.warningTitle': 'Скопируй сейчас!',
|
||
'webhooks.secret.warning': 'Этот secret показывается ОДИН раз. Дальше будет замаскирован. Передай receiver чтобы он мог проверять X-Ordinis-Signature header.',
|
||
'webhooks.secret.rotatedTitle': 'Secret обновлён',
|
||
'webhooks.secret.rotatedWarning': 'Новый secret. Старый перестал работать. Обнови receiver.',
|
||
'webhooks.secret.copy': 'Копировать',
|
||
'webhooks.secret.copied': 'Скопировано',
|
||
'webhooks.secret.acknowledge': 'Сохранил',
|
||
'webhooks.deliveries.title': 'История доставок',
|
||
'webhooks.deliveries.empty': 'Доставок ещё не было',
|
||
'webhooks.deliveries.col.eventId': 'Event ID',
|
||
'webhooks.deliveries.col.attempts': 'Попыток',
|
||
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
|
||
'webhooks.deliveries.col.lastStatusCode': 'HTTP статус',
|
||
'webhooks.deliveries.col.lastError': 'Последняя ошибка',
|
||
'webhooks.deliveries.action.retry': 'Повторить доставку',
|
||
'webhooks.deliveries.confirmRetry': 'Сбросить attemptCount=0 и поставить в очередь повтор? Receiver получит payload снова.',
|
||
'webhooks.deliveries.filter.label': 'Фильтр по статусу',
|
||
'webhooks.deliveries.filter.clear': 'Сбросить',
|
||
'audit.title': 'Журнал аудита',
|
||
'audit.subtitle': 'Юридически значимый лог изменений НСИ',
|
||
'audit.filter.dictionary': 'Справочник',
|
||
'audit.filter.user': 'Пользователь',
|
||
'audit.filter.action': 'Действие',
|
||
'audit.filter.businessKey': 'Бизнес-ключ',
|
||
'audit.filter.from': 'С',
|
||
'audit.filter.to': 'По',
|
||
'audit.filter.reset': 'Сбросить',
|
||
'audit.filter.apply': 'Применить',
|
||
'audit.filter.active': 'Активные фильтры',
|
||
'audit.filter.removeChip': 'Убрать фильтр',
|
||
'audit.col.time': 'Время',
|
||
'audit.col.action': 'Действие',
|
||
'audit.col.dictionary': 'Справочник',
|
||
'audit.col.businessKey': 'Бизнес-ключ',
|
||
'audit.col.user': 'Пользователь',
|
||
'audit.col.scope': 'Scope',
|
||
'audit.col.trace': 'Trace',
|
||
'audit.col.diff': 'Изменения',
|
||
'audit.action.expand': 'Раскрыть',
|
||
'audit.action.export': 'Экспорт CSV',
|
||
'audit.empty': 'Нет записей по выбранным фильтрам',
|
||
'audit.action.CREATE': 'Создание',
|
||
'audit.action.UPDATE': 'Изменение',
|
||
'audit.action.CLOSE': 'Закрытие',
|
||
'audit.diff.before': 'Было',
|
||
'audit.diff.after': 'Стало',
|
||
'audit.page.of': 'Стр. {{cur}} из {{total}}',
|
||
'audit.page.size': 'На странице',
|
||
'audit.page.prev': 'Назад',
|
||
'audit.page.next': 'Вперёд',
|
||
'outbox.title': 'Outbox events',
|
||
'outbox.subtitle': 'Очередь публикации в Kafka и DLQ',
|
||
'outbox.stats.pending': 'В очереди',
|
||
'outbox.stats.dlq': 'В DLQ',
|
||
'outbox.dlq.col.id': 'ID',
|
||
'outbox.dlq.col.eventType': 'Тип',
|
||
'outbox.dlq.col.dictionary': 'Справочник',
|
||
'outbox.dlq.col.aggregateId': 'Aggregate ID',
|
||
'outbox.dlq.col.topic': 'Topic',
|
||
'outbox.dlq.col.attempts': 'Попыток',
|
||
'outbox.dlq.col.lastError': 'Последняя ошибка',
|
||
'outbox.dlq.col.dlqAt': 'В DLQ с',
|
||
'outbox.dlq.empty': 'DLQ пуста — все события публикуются успешно',
|
||
'header.scope': 'Доступный scope',
|
||
'dict.list.subtitle': 'Каталоги НСИ ЦУОД ОДХ',
|
||
'dict.list.search.placeholder': 'Поиск по названию, коду или описанию',
|
||
'dict.list.search.empty': 'Ничего не найдено',
|
||
'dict.list.found': 'Показано {{shown}} из {{total}}',
|
||
'dict.list.recordCount_one': '{{count}} запись',
|
||
'dict.list.recordCount_few': '{{count}} записи',
|
||
'dict.list.recordCount_many': '{{count}} записей',
|
||
'dict.list.recordCount_other': '{{count}} записей',
|
||
'dict.list.section.PUBLIC': 'Публичные',
|
||
'dict.list.section.INTERNAL': 'Внутренние',
|
||
'dict.list.section.RESTRICTED': 'Ограниченные',
|
||
'dict.empty': 'В этом справочнике пока нет записей',
|
||
'dict.col.businessKey': 'Бизнес-ключ',
|
||
'dict.col.scope': 'Scope',
|
||
'dict.col.validFrom': 'Действует с',
|
||
'dict.col.locale': 'Локаль',
|
||
'dict.col.actions': 'Действия',
|
||
'dict.list.records': 'записей',
|
||
'dict.action.create': 'Создать запись',
|
||
'dict.action.edit': 'Изменить',
|
||
'dict.action.close': 'Закрыть',
|
||
'dict.confirmClose.title': 'Закрыть запись?',
|
||
'dict.confirmClose.body':
|
||
'Запись «{{key}}» будет помечена как закрытая (valid_to = now). Действие обратимо через создание новой версии.',
|
||
'dict.confirmClose.reason': 'Причина (опционально)',
|
||
'dict.confirmClose.reasonPlaceholder': 'например, «дубликат» или «выведено из эксплуатации»',
|
||
'dict.bulk.selectAll': 'Выбрать все на странице',
|
||
'dict.bulk.selectRow': 'Выбрать запись',
|
||
'dict.bulk.selectedCount_one': 'Выбрано {{count}} из {{total}}',
|
||
'dict.bulk.selectedCount_few': 'Выбрано {{count}} из {{total}}',
|
||
'dict.bulk.selectedCount_many': 'Выбрано {{count}} из {{total}}',
|
||
'dict.bulk.selectedCount_other': 'Выбрано {{count}} из {{total}}',
|
||
'dict.bulk.clearSelection': 'Снять выделение',
|
||
'dict.bulk.closeAction': 'Закрыть выбранные',
|
||
'dict.bulk.exportAction': 'Экспорт CSV',
|
||
'dict.page.prev': 'Назад',
|
||
'dict.page.next': 'Вперёд',
|
||
'dict.page.of': 'Стр. {{cur}} из {{total}}',
|
||
'dict.page.range': 'Записи {{from}}–{{to}} из {{total}}',
|
||
'dict.bulk.confirmTitle': 'Массовое закрытие записей',
|
||
'dict.bulk.confirmBody':
|
||
'{{count}} запис(ей) будут помечены как закрытые (valid_to = now). Действие обратимо через создание новой версии. Каждая запись закрывается отдельно — если часть уже закрыта, остальные обработаются.',
|
||
'dict.bulk.confirmAction': 'Закрыть {{count}}',
|
||
'dict.bulk.resultClosed': 'Закрыто: {{count}}',
|
||
'dict.bulk.resultSkipped': 'Пропущено (уже закрыто или не найдено): {{count}}',
|
||
'dict.bulk.resultErrors': 'Ошибок: {{count}}',
|
||
'dict.bulk.dismiss': 'Готово',
|
||
'form.identity': 'Идентификация',
|
||
'form.idSourceNote': 'Бизнес-ключ создастся автоматически из поля «{{field}}»',
|
||
'form.tabs.identity': 'Идентификация',
|
||
'form.tabs.description': 'Описание',
|
||
'form.tabs.extra': 'Дополнительно',
|
||
'form.businessKey': 'Бизнес-ключ',
|
||
'form.validFrom': 'Действует с',
|
||
'form.validFromHint': 'Когда запись становится активной. Пусто = с момента создания.',
|
||
'form.validTo': 'Действует до',
|
||
'form.validToHint': 'Когда перестаёт действовать. Пусто = бессрочно.',
|
||
'form.error.validToBeforeFrom': 'Дата окончания должна быть позже даты начала.',
|
||
'form.dataFields': 'Поля записи',
|
||
'form.cancel': 'Отмена',
|
||
'form.save': 'Сохранить',
|
||
'form.saving': 'Сохранение...',
|
||
'form.error.required': 'Обязательное поле',
|
||
'scope.PUBLIC': 'PUBLIC — публичный',
|
||
'scope.INTERNAL': 'INTERNAL — внутренний',
|
||
'scope.RESTRICTED': 'RESTRICTED — ограниченный',
|
||
'schema.action.create': 'Создать справочник',
|
||
'schema.action.edit': 'Изменить справочник',
|
||
'schema.action.editSchema': 'Схема',
|
||
'schema.tabs.metadata': 'Метаданные',
|
||
'schema.tabs.schema': 'Поля',
|
||
'schema.tabs.preview': 'JSON',
|
||
'schema.name': 'Имя',
|
||
'schema.nameHint': 'snake_case, начинается с буквы (например my_dictionary)',
|
||
'schema.nameInvalid': 'Только snake_case: a-z, 0-9, _; начинается с буквы',
|
||
'schema.displayName': 'Отображаемое имя',
|
||
'schema.description': 'Описание',
|
||
'schema.scope': 'Scope',
|
||
'schema.bundle': 'Bundle',
|
||
'schema.bundleHint': 'Обычно cuod',
|
||
'schema.template.label': 'Создать по шаблону',
|
||
'schema.template.hint': 'Скопирует схему, scope и locale выбранного словаря. Имя оставьте уникальное; версия сбросится на 1.0.0.',
|
||
'schema.template.placeholder': '— без шаблона —',
|
||
'schema.version': 'Версия схемы',
|
||
'schema.supportedLocales': 'Поддерживаемые локали',
|
||
'schema.defaultLocale': 'Локаль по умолчанию',
|
||
'schema.property': 'Поле',
|
||
'schema.required': 'Обязательное',
|
||
'schema.pattern': 'Pattern (regex)',
|
||
'schema.enumValues': 'Значения enum (через запятую)',
|
||
'schema.enumValuesHint': 'Например: ACTIVE, INACTIVE, RETIRED',
|
||
'schema.arrayEnumOptional': 'Допустимые значения (опц., через запятую)',
|
||
'schema.unique': 'Уникальное',
|
||
'schema.uniqueHint': 'Дубликаты по этому полю запрещены',
|
||
'schema.idSource': 'Использовать как ID (бизнес-ключ)',
|
||
'schema.idSourceHint': 'Бизнес-ключ записи будет автоматически = значению этого поля',
|
||
'schema.idSourceManual': 'Ввод вручную',
|
||
'schema.versionSuggest': 'Рекомендуемая версия: {{v}}',
|
||
'schema.diff.blockedTitle': 'Несовместимое изменение схемы',
|
||
'schema.diff.blockedBody': 'Эти изменения сломают существующие записи. Сохранение заблокировано. Создайте новый справочник или откатите изменения.',
|
||
'schema.diff.compatibleTitle': 'Изменения схемы (совместимые)',
|
||
'history.title': 'История версий',
|
||
'history.empty': 'История пуста',
|
||
'history.current': 'текущая',
|
||
'history.active': 'активна',
|
||
'history.scheduled': 'запланирована',
|
||
'history.closed': 'закрыта',
|
||
'history.validFrom': 'действует с',
|
||
'history.validTo': 'действует до',
|
||
'history.updatedBy': 'кем',
|
||
'history.viewData': 'данные записи (JSON)',
|
||
// === Lineage (dict-relationships-v2 Phase 1) ===
|
||
'lineage.usedBy.title': 'На этот словарь ссылаются',
|
||
'lineage.usedBy.count_one': '{{count}} связь',
|
||
'lineage.usedBy.count_few': '{{count}} связи',
|
||
'lineage.usedBy.count_many': '{{count}} связей',
|
||
'lineage.usedBy.count_other': '{{count}} связей',
|
||
'lineage.usedBy.activeCount_one': '{{count}} запись',
|
||
'lineage.usedBy.activeCount_few': '{{count}} записи',
|
||
'lineage.usedBy.activeCount_many': '{{count}} записей',
|
||
'lineage.usedBy.activeCount_other': '{{count}} записей',
|
||
'lineage.usedBy.openSourceDict': 'Открыть словарь {{dict}}',
|
||
'lineage.refs.title': 'На эту запись ссылаются',
|
||
'lineage.refs.total_one': 'всего {{count}} ссылка',
|
||
'lineage.refs.total_few': 'всего {{count}} ссылки',
|
||
'lineage.refs.total_many': 'всего {{count}} ссылок',
|
||
'lineage.refs.total_other': 'всего {{count}} ссылок',
|
||
'lineage.refs.field': 'поле {{field}}',
|
||
'lineage.refs.created': 'создана {{when}}',
|
||
'lineage.refs.openSourceRecord': 'Открыть запись {{key}} в источнике',
|
||
'lineage.refs.stale': 'обновлено {{minutes}} мин назад',
|
||
'lineage.refs.staleHint': 'Данные из materialized view; обновляются раз в минуту. Закрытые между обновлениями записи отфильтрованы по valid_to.',
|
||
'lineage.onClose.BLOCK': 'block',
|
||
'lineage.onClose.WARN': 'warn',
|
||
'lineage.onClose.CASCADE': 'cascade',
|
||
'pagination.prev': 'Назад',
|
||
'pagination.next': 'Вперёд',
|
||
'pagination.pageOf': 'стр. {{page}} из {{total}}',
|
||
'schema.empty': 'Полей нет — добавь первое',
|
||
'schema.addProperty': 'Добавить поле',
|
||
'schema.delete': 'Удалить',
|
||
'schema.moveUp': 'Выше',
|
||
'schema.moveDown': 'Ниже',
|
||
'schema.kind.label': 'Тип',
|
||
'schema.kind.string': 'Строка',
|
||
'schema.kind.integer': 'Целое',
|
||
'schema.kind.number': 'Число',
|
||
'schema.kind.boolean': 'Логическое',
|
||
'schema.kind.enum': 'Enum (выбор из списка)',
|
||
'schema.kind.reference': 'Ссылка на словарь (FK)',
|
||
'schema.kind.localized': 'I18N строка (ru-RU/en-US/...)',
|
||
'schema.kind.date': 'Дата',
|
||
'schema.kind.datetime': 'Дата+время',
|
||
'schema.kind.array_string': 'Массив строк',
|
||
'schema.kind.opaque': 'Сложный (JSON)',
|
||
'schema.reference.title': 'FK на запись справочника',
|
||
'schema.reference.hint': 'Значение поля будет проверяться при сохранении: оно должно совпадать с business-key или указанным полем существующей записи целевого словаря.',
|
||
'schema.reference.targetDict': 'Целевой словарь',
|
||
'schema.reference.targetField': 'Поле',
|
||
'schema.reference.targetFieldHint': 'Обычно code или business_key',
|
||
'schema.reference.required': 'Заполните оба: словарь и поле — иначе FK не сработает.',
|
||
'schema.opaque.title': 'Сложное поле — редактируется только через JSON-таб',
|
||
'schema.opaque.hint': 'Nested object / array of objects. Поля-табе нет редактора, raw schema показан ниже. Меняй через вкладку "JSON".',
|
||
'field.name': 'Наименование',
|
||
'field.designator': 'Designator (COSPAR)',
|
||
'field.norad_id': 'NORAD ID',
|
||
'field.status': 'Статус',
|
||
'field.country': 'Страна',
|
||
'field.operator': 'Оператор',
|
||
'field.mass_kg': 'Масса (кг)',
|
||
'field.mission': 'Миссия',
|
||
'field.orbit': 'Орбита',
|
||
'field.launch_date': 'Дата запуска',
|
||
'field.decay_date': 'Дата схода',
|
||
'field.spectrum_bands': 'Спектральные диапазоны',
|
||
'field.satellite_type_code': 'Тип КА (код)',
|
||
'field.code': 'Код',
|
||
'field.description': 'Описание',
|
||
'field.location': 'Местоположение',
|
||
'field.coordinates': 'Координаты',
|
||
'field.frequency_bands': 'Частотные диапазоны',
|
||
'loading': 'Загрузка...',
|
||
'error.failed': 'Не удалось загрузить данные',
|
||
'auth.login': 'Войти',
|
||
'auth.logout': 'Выйти',
|
||
'dict.filter.searchPlaceholder': 'Поиск по записям…',
|
||
'dict.filter.scopeLabel': 'Фильтр по scope',
|
||
'dict.filter.matched': 'Найдено {{count}} из {{total}}',
|
||
'dict.filter.clear': 'Сбросить',
|
||
'dict.filter.noMatches': 'По фильтру записей не найдено',
|
||
'aoi.button': 'AOI фильтр',
|
||
'aoi.activeBbox': 'AOI: bbox {{value}}',
|
||
'aoi.activePolygon': 'AOI: полигон ({{points}} точек)',
|
||
'aoi.clear': 'Сбросить AOI',
|
||
'aoi.dialog.title': 'Выбор области интереса (AOI)',
|
||
'aoi.dialog.help': 'Нарисуйте прямоугольник или полигон. Применяется как фильтр к записям со spatial geometry.',
|
||
'aoi.dialog.apply': 'Применить',
|
||
'aoi.dialog.drawRectangle': 'Прямоугольник',
|
||
'aoi.dialog.drawPolygon': 'Полигон',
|
||
'aoi.dialog.clear': 'Очистить',
|
||
'aoi.dialog.polygonHint': 'Клик — добавить точку, двойной клик — закрыть полигон (минимум 3 точки)',
|
||
'aoi.dialog.mapAriaLabel': 'Карта для выбора области интереса',
|
||
'aoi.error.noShape': 'Сначала нарисуйте область на карте',
|
||
'aoi.error.invalid': 'Геометрия некорректна — попробуйте перерисовать',
|
||
},
|
||
},
|
||
'en-US': {
|
||
translation: {
|
||
'app.title': 'Ordinis MDM',
|
||
'nav.dictionaries': 'Dictionaries',
|
||
'nav.audit': 'Audit',
|
||
'nav.outbox': 'Outbox',
|
||
'nav.webhooks': 'Webhooks',
|
||
'webhooks.title': 'Webhook subscriptions',
|
||
'webhooks.subtitle': 'HTTP delivery of events to non-Kafka consumers',
|
||
'webhooks.empty': 'No subscriptions yet. Create one via the button on the right.',
|
||
'webhooks.active': 'Active',
|
||
'webhooks.inactive': 'Disabled',
|
||
'webhooks.allScopes': 'all scopes',
|
||
'webhooks.allDicts': 'all dictionaries',
|
||
'webhooks.allEvents': 'all events',
|
||
'webhooks.notFound': 'Subscription not found',
|
||
'webhooks.col.name': 'Name',
|
||
'webhooks.col.url': 'URL',
|
||
'webhooks.col.scopes': 'Scope filter',
|
||
'webhooks.col.dictionaries': 'Dictionaries',
|
||
'webhooks.col.status': 'Status',
|
||
'webhooks.action.create': 'Create',
|
||
'webhooks.action.delete': 'Delete',
|
||
'webhooks.action.rotateSecret': 'Rotate secret',
|
||
'webhooks.action.test': 'Test ping',
|
||
'webhooks.test.success': 'Receiver got the test event and replied 2xx',
|
||
'webhooks.test.failure': 'Receiver rejected the test event',
|
||
'webhooks.test.rejected': 'Request not sent (SSRF guard / serialization)',
|
||
'webhooks.test.latency': 'latency',
|
||
'webhooks.confirmDelete': 'Delete subscription "{{name}}"? All pending deliveries will be cascade-deleted.',
|
||
'webhooks.confirmRotate': 'Rotate HMAC secret? The old one stops working immediately — update the receiver first.',
|
||
'webhooks.field.name': 'Subscription name',
|
||
'webhooks.field.url': 'Webhook URL',
|
||
'webhooks.field.urlHint': 'https:// or http://. Private CIDRs (10/8, 192.168/16, 127/8) blocked by SSRF guard',
|
||
'webhooks.field.scopeFilter': 'Scope filter',
|
||
'webhooks.field.scopeFilterHint': 'Empty = all scopes',
|
||
'webhooks.field.dictionaryFilter': 'Dictionaries (CSV)',
|
||
'webhooks.field.dictionaryFilterHint': 'Comma-separated: spacecraft, satellite_type. Empty = all',
|
||
'webhooks.field.eventTypeFilter': 'Event types (CSV)',
|
||
'webhooks.field.eventTypeFilterHint': 'record.created, record.updated, record.closed. Empty = all',
|
||
'webhooks.field.description': 'Description',
|
||
'webhooks.field.createdBy': 'Created by',
|
||
'webhooks.error.nameUrlRequired': 'Name and URL are required',
|
||
'webhooks.error.urlScheme': 'URL must start with http:// or https://',
|
||
'webhooks.secret.title': 'HMAC secret for "{{name}}"',
|
||
'webhooks.secret.warningTitle': 'Copy now!',
|
||
'webhooks.secret.warning': 'This secret is shown ONCE. It will be masked afterwards. Pass it to the receiver so it can verify the X-Ordinis-Signature header.',
|
||
'webhooks.secret.rotatedTitle': 'Secret rotated',
|
||
'webhooks.secret.rotatedWarning': 'New secret. Old one stopped working. Update the receiver.',
|
||
'webhooks.secret.copy': 'Copy',
|
||
'webhooks.secret.copied': 'Copied',
|
||
'webhooks.secret.acknowledge': 'Saved it',
|
||
'webhooks.deliveries.title': 'Delivery history',
|
||
'webhooks.deliveries.empty': 'No deliveries yet',
|
||
'webhooks.deliveries.col.eventId': 'Event ID',
|
||
'webhooks.deliveries.col.attempts': 'Attempts',
|
||
'webhooks.deliveries.col.lastAttempt': 'Last attempt',
|
||
'webhooks.deliveries.col.lastStatusCode': 'HTTP status',
|
||
'webhooks.deliveries.col.lastError': 'Last error',
|
||
'webhooks.deliveries.action.retry': 'Retry delivery',
|
||
'webhooks.deliveries.confirmRetry': 'Reset attemptCount=0 and queue a retry? The receiver will get the payload again.',
|
||
'webhooks.deliveries.filter.label': 'Status filter',
|
||
'webhooks.deliveries.filter.clear': 'Clear',
|
||
'audit.title': 'Audit log',
|
||
'audit.subtitle': 'Legally significant log of NSI changes',
|
||
'audit.filter.dictionary': 'Dictionary',
|
||
'audit.filter.user': 'User',
|
||
'audit.filter.action': 'Action',
|
||
'audit.filter.businessKey': 'Business key',
|
||
'audit.filter.from': 'From',
|
||
'audit.filter.to': 'To',
|
||
'audit.filter.reset': 'Reset',
|
||
'audit.filter.apply': 'Apply',
|
||
'audit.filter.active': 'Active filters',
|
||
'audit.filter.removeChip': 'Remove filter',
|
||
'audit.col.time': 'Time',
|
||
'audit.col.action': 'Action',
|
||
'audit.col.dictionary': 'Dictionary',
|
||
'audit.col.businessKey': 'Business key',
|
||
'audit.col.user': 'User',
|
||
'audit.col.scope': 'Scope',
|
||
'audit.col.trace': 'Trace',
|
||
'audit.col.diff': 'Diff',
|
||
'audit.action.expand': 'Expand',
|
||
'audit.action.export': 'Export CSV',
|
||
'audit.empty': 'No entries match the filters',
|
||
'audit.action.CREATE': 'Create',
|
||
'audit.action.UPDATE': 'Update',
|
||
'audit.action.CLOSE': 'Close',
|
||
'audit.diff.before': 'Before',
|
||
'audit.diff.after': 'After',
|
||
'audit.page.of': 'Page {{cur}} of {{total}}',
|
||
'audit.page.size': 'Per page',
|
||
'audit.page.prev': 'Prev',
|
||
'audit.page.next': 'Next',
|
||
'outbox.title': 'Outbox events',
|
||
'outbox.subtitle': 'Publication queue and DLQ',
|
||
'outbox.stats.pending': 'Pending',
|
||
'outbox.stats.dlq': 'In DLQ',
|
||
'outbox.dlq.col.id': 'ID',
|
||
'outbox.dlq.col.eventType': 'Type',
|
||
'outbox.dlq.col.dictionary': 'Dictionary',
|
||
'outbox.dlq.col.aggregateId': 'Aggregate ID',
|
||
'outbox.dlq.col.topic': 'Topic',
|
||
'outbox.dlq.col.attempts': 'Attempts',
|
||
'outbox.dlq.col.lastError': 'Last error',
|
||
'outbox.dlq.col.dlqAt': 'DLQ since',
|
||
'outbox.dlq.empty': 'DLQ empty — all events publish successfully',
|
||
'header.scope': 'Allowed scope',
|
||
'dict.list.subtitle': 'Reference data catalogues',
|
||
'dict.list.search.placeholder': 'Search by name, code or description',
|
||
'dict.list.search.empty': 'Nothing found',
|
||
'dict.list.found': 'Showing {{shown}} of {{total}}',
|
||
'dict.list.recordCount_one': '{{count}} record',
|
||
'dict.list.recordCount_other': '{{count}} records',
|
||
'dict.list.section.PUBLIC': 'Public',
|
||
'dict.list.section.INTERNAL': 'Internal',
|
||
'dict.list.section.RESTRICTED': 'Restricted',
|
||
'dict.empty': 'No records in this dictionary yet',
|
||
'dict.col.businessKey': 'Business key',
|
||
'dict.col.scope': 'Scope',
|
||
'dict.col.validFrom': 'Valid from',
|
||
'dict.col.locale': 'Locale',
|
||
'dict.col.actions': 'Actions',
|
||
'dict.list.records': 'records',
|
||
'dict.action.create': 'Create record',
|
||
'dict.action.edit': 'Edit',
|
||
'dict.action.close': 'Close',
|
||
'dict.confirmClose.title': 'Close record?',
|
||
'dict.confirmClose.body':
|
||
'Record "{{key}}" will be marked as closed (valid_to = now). Reversible by creating a new version.',
|
||
'dict.confirmClose.reason': 'Reason (optional)',
|
||
'dict.confirmClose.reasonPlaceholder': 'e.g., "duplicate" or "decommissioned"',
|
||
'dict.bulk.selectAll': 'Select all on page',
|
||
'dict.bulk.selectRow': 'Select record',
|
||
'dict.bulk.selectedCount_one': '{{count}} of {{total}} selected',
|
||
'dict.bulk.selectedCount_other': '{{count}} of {{total}} selected',
|
||
'dict.bulk.clearSelection': 'Clear selection',
|
||
'dict.bulk.closeAction': 'Close selected',
|
||
'dict.bulk.exportAction': 'Export CSV',
|
||
'dict.page.prev': 'Prev',
|
||
'dict.page.next': 'Next',
|
||
'dict.page.of': 'Page {{cur}} of {{total}}',
|
||
'dict.page.range': 'Records {{from}}–{{to}} of {{total}}',
|
||
'dict.bulk.confirmTitle': 'Bulk close records',
|
||
'dict.bulk.confirmBody':
|
||
'{{count}} record(s) will be marked as closed (valid_to = now). Reversible by creating a new version. Each record is closed individually — if some are already closed, others will still be processed.',
|
||
'dict.bulk.confirmAction': 'Close {{count}}',
|
||
'dict.bulk.resultClosed': 'Closed: {{count}}',
|
||
'dict.bulk.resultSkipped': 'Skipped (already closed or not found): {{count}}',
|
||
'dict.bulk.resultErrors': 'Errors: {{count}}',
|
||
'dict.bulk.dismiss': 'Done',
|
||
'form.identity': 'Identity',
|
||
'form.idSourceNote': 'Business key auto-derived from "{{field}}" field',
|
||
'form.tabs.identity': 'Identity',
|
||
'form.tabs.description': 'Description',
|
||
'form.tabs.extra': 'Additional',
|
||
'form.businessKey': 'Business key',
|
||
'form.validFrom': 'Valid from',
|
||
'form.validFromHint': 'When this record becomes active. Empty = from creation moment.',
|
||
'form.validTo': 'Valid to',
|
||
'form.validToHint': 'When it stops being valid. Empty = forever.',
|
||
'form.error.validToBeforeFrom': 'Valid-to must be later than valid-from.',
|
||
'form.dataFields': 'Record fields',
|
||
'form.cancel': 'Cancel',
|
||
'form.save': 'Save',
|
||
'form.saving': 'Saving...',
|
||
'form.error.required': 'Required field',
|
||
'scope.PUBLIC': 'PUBLIC',
|
||
'scope.INTERNAL': 'INTERNAL',
|
||
'scope.RESTRICTED': 'RESTRICTED',
|
||
'schema.action.create': 'Create dictionary',
|
||
'schema.action.edit': 'Edit dictionary',
|
||
'schema.action.editSchema': 'Schema',
|
||
'schema.tabs.metadata': 'Metadata',
|
||
'schema.tabs.schema': 'Fields',
|
||
'schema.tabs.preview': 'JSON',
|
||
'schema.name': 'Name',
|
||
'schema.nameHint': 'snake_case, starts with letter (e.g. my_dictionary)',
|
||
'schema.nameInvalid': 'Only snake_case: a-z, 0-9, _; must start with letter',
|
||
'schema.displayName': 'Display name',
|
||
'schema.description': 'Description',
|
||
'schema.scope': 'Scope',
|
||
'schema.bundle': 'Bundle',
|
||
'schema.bundleHint': 'Usually cuod',
|
||
'schema.template.label': 'Create from template',
|
||
'schema.template.hint': 'Copies schema, scope and locales from the selected dictionary. Use a unique name; version resets to 1.0.0.',
|
||
'schema.template.placeholder': '— no template —',
|
||
'schema.version': 'Schema version',
|
||
'schema.supportedLocales': 'Supported locales',
|
||
'schema.defaultLocale': 'Default locale',
|
||
'schema.property': 'Field',
|
||
'schema.required': 'Required',
|
||
'schema.pattern': 'Pattern (regex)',
|
||
'schema.enumValues': 'Enum values (comma-separated)',
|
||
'schema.enumValuesHint': 'E.g.: ACTIVE, INACTIVE, RETIRED',
|
||
'schema.arrayEnumOptional': 'Allowed values (optional, comma-separated)',
|
||
'schema.unique': 'Unique',
|
||
'schema.uniqueHint': 'Duplicates by this field are not allowed',
|
||
'schema.idSource': 'Use as ID (business key)',
|
||
'schema.idSourceHint': 'Record business key auto-derived from this field value',
|
||
'schema.idSourceManual': 'Manual input',
|
||
'schema.versionSuggest': 'Suggested version: {{v}}',
|
||
'schema.diff.blockedTitle': 'Breaking schema change',
|
||
'schema.diff.blockedBody': 'These changes break existing records. Save blocked. Create a new dictionary or revert.',
|
||
'schema.diff.compatibleTitle': 'Schema changes (compatible)',
|
||
'history.title': 'Version history',
|
||
'history.empty': 'No history',
|
||
'history.current': 'current',
|
||
'history.active': 'active',
|
||
'history.scheduled': 'scheduled',
|
||
'history.closed': 'closed',
|
||
'history.validFrom': 'valid from',
|
||
'history.validTo': 'valid to',
|
||
'history.updatedBy': 'by',
|
||
'history.viewData': 'record data (JSON)',
|
||
// === Lineage (dict-relationships-v2 Phase 1) ===
|
||
'lineage.usedBy.title': 'Used by',
|
||
'lineage.usedBy.count_one': '{{count}} reference',
|
||
'lineage.usedBy.count_other': '{{count}} references',
|
||
'lineage.usedBy.activeCount_one': '{{count}} record',
|
||
'lineage.usedBy.activeCount_other': '{{count}} records',
|
||
'lineage.usedBy.openSourceDict': 'Open dictionary {{dict}}',
|
||
'lineage.refs.title': 'References to this record',
|
||
'lineage.refs.total_one': '{{count}} reference total',
|
||
'lineage.refs.total_other': '{{count}} references total',
|
||
'lineage.refs.field': 'field {{field}}',
|
||
'lineage.refs.created': 'created {{when}}',
|
||
'lineage.refs.openSourceRecord': 'Open referencing record {{key}}',
|
||
'lineage.refs.stale': 'refreshed {{minutes}} min ago',
|
||
'lineage.refs.staleHint': 'Data from materialized view, refreshed once per minute. Records closed between refreshes are filtered by valid_to.',
|
||
'lineage.onClose.BLOCK': 'block',
|
||
'lineage.onClose.WARN': 'warn',
|
||
'lineage.onClose.CASCADE': 'cascade',
|
||
'pagination.prev': 'Prev',
|
||
'pagination.next': 'Next',
|
||
'pagination.pageOf': 'page {{page}} of {{total}}',
|
||
'schema.empty': 'No fields yet — add the first one',
|
||
'schema.addProperty': 'Add field',
|
||
'schema.delete': 'Delete',
|
||
'schema.moveUp': 'Move up',
|
||
'schema.moveDown': 'Move down',
|
||
'schema.kind.label': 'Type',
|
||
'schema.kind.string': 'String',
|
||
'schema.kind.integer': 'Integer',
|
||
'schema.kind.number': 'Number',
|
||
'schema.kind.boolean': 'Boolean',
|
||
'schema.kind.enum': 'Enum (pick from list)',
|
||
'schema.kind.reference': 'Reference to dictionary (FK)',
|
||
'schema.kind.localized': 'I18N string (ru-RU/en-US/...)',
|
||
'schema.kind.date': 'Date',
|
||
'schema.kind.datetime': 'Date+time',
|
||
'schema.kind.array_string': 'Array of strings',
|
||
'schema.kind.opaque': 'Complex (JSON)',
|
||
'schema.opaque.title': 'Complex field — edit only via JSON tab',
|
||
'schema.opaque.hint': 'Nested object / array of objects. No editor in Fields tab, raw schema shown below. Edit via JSON tab.',
|
||
'schema.reference.title': 'FK to dictionary record',
|
||
'schema.reference.hint': 'Value is validated on save: must match the business-key or specified field of an existing record in the target dictionary.',
|
||
'schema.reference.targetDict': 'Target dictionary',
|
||
'schema.reference.targetField': 'Field',
|
||
'schema.reference.targetFieldHint': 'Usually code or business_key',
|
||
'schema.reference.required': 'Fill both target dictionary and field — otherwise FK will not work.',
|
||
'field.name': 'Name',
|
||
'field.designator': 'Designator (COSPAR)',
|
||
'field.norad_id': 'NORAD ID',
|
||
'field.status': 'Status',
|
||
'field.country': 'Country',
|
||
'field.operator': 'Operator',
|
||
'field.mass_kg': 'Mass (kg)',
|
||
'field.mission': 'Mission',
|
||
'field.orbit': 'Orbit',
|
||
'field.launch_date': 'Launch date',
|
||
'field.decay_date': 'Decay date',
|
||
'field.spectrum_bands': 'Spectrum bands',
|
||
'field.satellite_type_code': 'Satellite type code',
|
||
'field.code': 'Code',
|
||
'field.description': 'Description',
|
||
'field.location': 'Location',
|
||
'field.coordinates': 'Coordinates',
|
||
'field.frequency_bands': 'Frequency bands',
|
||
'loading': 'Loading...',
|
||
'error.failed': 'Failed to load data',
|
||
'auth.login': 'Sign in',
|
||
'auth.logout': 'Sign out',
|
||
'dict.filter.searchPlaceholder': 'Search records…',
|
||
'dict.filter.scopeLabel': 'Filter by scope',
|
||
'dict.filter.matched': 'Matched {{count}} of {{total}}',
|
||
'dict.filter.clear': 'Clear',
|
||
'dict.filter.noMatches': 'No records match the filter',
|
||
'aoi.button': 'AOI filter',
|
||
'aoi.activeBbox': 'AOI: bbox {{value}}',
|
||
'aoi.activePolygon': 'AOI: polygon ({{points}} points)',
|
||
'aoi.clear': 'Clear AOI',
|
||
'aoi.dialog.title': 'Select Area of Interest (AOI)',
|
||
'aoi.dialog.help': 'Draw a rectangle or polygon. Applied as filter to records with spatial geometry.',
|
||
'aoi.dialog.apply': 'Apply',
|
||
'aoi.dialog.drawRectangle': 'Rectangle',
|
||
'aoi.dialog.drawPolygon': 'Polygon',
|
||
'aoi.dialog.clear': 'Clear',
|
||
'aoi.dialog.polygonHint': 'Click adds vertex, double-click closes polygon (min 3 vertices)',
|
||
'aoi.dialog.mapAriaLabel': 'Map for selecting area of interest',
|
||
'aoi.error.noShape': 'Draw an area on the map first',
|
||
'aoi.error.invalid': 'Invalid geometry — please redraw',
|
||
},
|
||
},
|
||
},
|
||
})
|
||
|
||
export default i18n
|