Раньше для записей с x-localized name (КА, антенна, ground_station,
operator) колонка 'name / code' показывала literal '[object Object]'
потому что String({'ru-RU': 'Канопус', 'en-US': 'Kanopus'}) =
toString() стандартный → '[object Object]'.
Решение:
- Новый shared util lib/locales.ts:
- pickLocalized(value, defaultLocale) — извлекает строку из i18n
объекта или plain string. Fallback на first non-empty key
если defaultLocale не найден.
- recordDisplayName(data, defaultLocale) — name → code → '—' chain.
- Records list использует recordDisplayName с dict.defaultLocale
(берётся из DictionaryDetail).
- SchemaDrivenForm дедуплицирован — был свой локальный pickLocalized,
теперь импортирует из util'ы.
Tests: +13 (76 → 89), unit tests на pickLocalized + recordDisplayName
покрывают все edge cases (plain string, localized object, fallback
chain, null/undefined input).
После создания subscription / rotate secret admin не имел способа
проверить что receiver жив и валидирует HMAC без ожидания реального
business event. Теперь:
- POST /api/v1/admin/webhooks/subscriptions/{id}/test
- Synchronous: receiver получает synthetic event с теми же headers
что у production delivery (X-Ordinis-Signature, X-Ordinis-Event-Type,
X-Ordinis-Scope) + дополнительный X-Ordinis-Test=true чтобы
receiver мог логировать/филтровать как тест.
- HMAC sign'ится через тот же HmacSigner — admin раскопировал secret
правильно если receiver валидирует. Иначе receiver вернёт 4xx
и UI покажет ошибку.
- SSRF guard validate URL host (private CIDR + allowed-hosts override)
— same policy как у dispatcher.
Bypass'ит outbox/dispatcher — нет webhook_deliveries row, нет attempt
count, нет следов в аудите доставок (это test, не business event).
UI:
- Кнопка 'Тест ping' с PaperPlaneTilt иконкой рядом с 'Сменить secret'
на webhook detail page.
- Inline Alert после ответа: success/failure/rejected с HTTP status,
latency, body preview / error message. Dismiss '✕'.
RBAC: RESTRICTED — endpoint отправляет HTTP request на user-controlled
URL (potential abuse vector если бы был INTERNAL).
Tests: rest-api compiles, 76 admin-ui passed.
Раньше для replay'а failed/DLQ delivery нужно было либо ждать
backoff schedule (до 24h), либо лезть в БД и руками сбрасывать
attempt_count + dlq_at. Теперь admin замечает failed delivery в
UI, фиксит receiver (cert renewal, URL update), кликает 'повторить'
— dispatcher подхватит на следующем sendTick.
Backend:
- POST /api/v1/admin/webhooks/deliveries/{id}/retry
- RBAC: RESTRICTED (можно spam'ить receiver = destructive)
- Использует existing WebhookDelivery.resetDlq() — атомарно
status=retrying, attemptCount=0, dlqAt=null, nextAttemptAt=now
UI:
- Колонка 'Действия' в delivery history таблице (на webhook detail page)
- IconButton 'Повторить' показывается только для status=dlq|retrying
(delivered/pending не имеет смысла retry'ить)
- Confirm dialog объясняет что receiver получит payload снова
- onSuccess invalidate ['webhooks','deliveries'] + ['webhooks','dlq']
Tests: 75 admin-ui passed, rest-api compiles clean. Логика
resetDlq() уже покрыта unit tests в WebhookDeliveryTest.
Раньше search/scope/AOI filters жили только в локальном useState —
F5 сбрасывал, ссылку с применёнными фильтрами не расшарить.
Теперь:
- ?q=text — search query
- ?scopes=PUBLIC,INTERNAL — comma-separated subset (empty = все)
- ?bbox=west,south,east,north — bbox AOI (server-side spatial filter)
Polygon AOI остаётся в local state — слишком длинно (50+ точек) для
URL, рендерили бы /dictionaries/foo?polygon={...} с сотнями символов.
validateSearch на route + Route.useSearch() читает; navigate replace:true
обновляет URL без новой history entry на каждый keystroke search'а.
Bootstrap: при mount если ?bbox= — рендерим bbox-AOI initial state;
картинку с прямоугольником сразу видно в picker'е если открыть.
Use cases:
- Bookmark filtered view словаря
- Поделиться ссылкой '/dictionaries/spacecraft?q=Канопус&scopes=PUBLIC'
- F5 не теряет filters
На странице словаря над таблицей появилась filter bar:
- SearchInput (поиск по businessKey + JSON.stringify(data) — покрывает
локализованные name, code, описания)
- 3 scope chips PUBLIC/INTERNAL/RESTRICTED с цветными dot'ами,
toggle включает/исключает scope. Empty Set = все scope видны
(default state).
- Когда фильтры активны — показываем 'Найдено X из Y' + кнопка
Сбросить.
- Если фильтр выдал 0 результатов — EmptyState с пояснением
что под фильтр ничего не подошло.
Фильтрация client-side через useMemo. Записей в одном словаре
обычно ≤100, JSON.stringify + includes() мгновенно. AOI остаётся
server-side (геометрия требует PostGIS) — фильтры комбинируются.
i18n: RU + EN ключи.
Admin кликает 'AOI фильтр' на странице словаря → открывается
dialog с картой OSM. Рисует rectangle (→ bbox CSV) или
polygon → onApply применяется к recordsQuery как ?bbox= или
?polygon=GeoJSON.
Server-side parsing уже есть (BoundingBox.parse, GeoJsonPolygon.parse
в ordinis-rest-api) — подключили UI поверх.
Активный AOI отображается inline над таблицей с кнопкой
'Сбросить'. Edit shape возможен через leaflet-draw edit/remove
controls.
Зависимости: leaflet 1.9.4 + react-leaflet 5 + leaflet-draw 1.0.4
+ types. Bundle +231KB gzip 62KB — приемлемо для AOI-only feature
(map работает без MapLibre WASM, OSM tiles — публичные).
Picker self-contained: монтируется/уничтожается с диалогом,
invalidateSize 100ms после open чтобы Leaflet корректно посчитал
container size после modal animation.
5 ошибок TS, накопленных за прошлые итерации — теперь pnpm tsc зелёный:
- SchemaDrivenForm: убран unused import formatIsoDateTime + unused
prop `path` (передавался в FieldRenderer, но FieldRendererProps его
не объявлял и FieldBody не использовал)
- dictionaries.$name: удалена неиспользуемая функция toIsoDate
- api/client.ts: добавлен `$schema?: string` в JsonSchema type
(используется в buildSchemaJson и тестируется в types.test.ts)
Перенесли scope-визуальные константы (SCOPE_ORDER, SCOPE_DOT,
SCOPE_ACCENT) в `lib/scope-style.ts` для переиспользования. На detail
странице (`dictionaries/$name`) добавили top accent strip 4px цветом
scope + цветной чип в breadcrumb рядом с back-link. Теперь при переходе
из списка в карточку scope сразу виден без чтения badge в таблице.
40 справочников в плоском grid тяжело сканировать. Разделили на секции
PUBLIC / INTERNAL / RESTRICTED с цветными accent-полосами слева у карточек
(emerald / amber / rose). Search фильтрует across all sections, пустые
секции скрыты. Цвет даёт instant scan, секции — explicit разделение.
Backend:
- DictionaryRecordRepository.countActiveGroupedByDictionary — один GROUP BY
по всем справочникам с фильтром по scope, без N+1.
- DictionaryResponse.recordCount (nullable) + factory from(d, count).
- DictionaryDefinitionController передаёт счётчики в list/get,
фильтрованные по currentScopes() пользователя.
Frontend:
- SearchInput над сеткой карточек, client-side filter по
name/displayName/description (case-insensitive, useDeferredValue).
- Badge "N записей" (i18n plural ru/en) в углу карточки.
- "Показано N из M" рядом с поиском.
- EmptyState когда поиск пустой.
react-oidc-context + oidc-client-ts. Confidential client тоже работает —
SPA просто не использует client_secret.
- src/auth/oidcConfig.ts — UserManager settings (authority, redirect_uri,
PKCE, automaticSilentRenew). VITE_KEYCLOAK_{URL,REALM,CLIENT_ID} с дефолтами
на nstart realm + ordinis client.
- src/auth/TokenSync.tsx — синкает access_token в localStorage и axios default
header после login/refresh. Existing API код не трогали — interceptor уже
читает localStorage.
Bonus: 401 interceptor триггерит signinSilent → signinRedirect fallback.
- src/auth/AuthBadge.tsx — header'ный бейдж: "Войти" если anonymous, имя +
logout если залогинен.
- main.tsx обёрнут в <AuthProvider>.
- __root.tsx показывает AuthBadge рядом с language switch.
- .env.example с конфигом Keycloak.
- i18n auth.login / auth.logout (RU + EN).
Keycloak client `ordinis` — настроить:
Valid redirect URIs: https://ordinis.k8s.265.nstart.cloud/*,
https://ordinis.k8s.264.nstart.cloud/*,
http://localhost:5173/*
Web Origins: те же + (или "+"), PKCE Code Challenge: S256
До этого audit_log entity была определена, но никто туда не писал — реальный
аудит шёл только через Hibernate Envers (без user/IP/trace). Теперь:
- AuditLogger service пишет в той же транзакции что и CRUD (DictionaryRecordService).
Захватывает: user (JWT sub либо preferred_username), scope, IP (X-Forwarded-For),
UA, trace_id (MDC), request_id. Не ломает основной flow при сбое — только
громко логирует.
- AuditLogRepository: гибкий search() с nullable фильтрами (dictionary, user,
action, businessKey, from, to) + Pageable.
- AuditAdminController: GET /admin/audit (paginated) + /admin/audit/export.csv
(cap 10k строк против OOM).
Frontend:
- /audit route: фильтр-панель, таблица с цветными бейджами (CREATE/UPDATE/CLOSE),
expand-row с JSON before/after diff, footer IP/UA/req_id, кнопка экспорта CSV.
Pagination 50/100/200.
- /outbox route: stat-cards pending/DLQ + DLQ-таблица с last_error.
- Nav links + i18n (RU + EN).
Tests (Vitest):
- src/lib/dates.ts (вынесено из SchemaDrivenForm + dictionaries.$name): parseFormDate,
formatIsoDate, formatIsoDateTime, extractTime, combineDateTime, localTzOffset, nowIsoLocal
- src/lib/dates.test.ts (20 tests): TZ-safety, pure-date local midnight, far-future filter,
round-trip parse/format, time extraction в local TZ, clear-button (null Date → '')
- src/components/schema/types.test.ts (31 tests): buildSchemaJson всех 9 kinds + x-unique +
x-id-source, parseSchemaJson roundtrip, diffSchemas (8 breaking detection cases),
suggestVersionBump (major/minor/patch + breaking-wins-mix)
CI:
- Откатил dual resolve-tag (BACKEND/FRONTEND) на single resolve-tag.
GitLab variable interpolation в trigger jobs не подхватывает dotenv от needs-job
→ admin-ui-only push не доезжал до infra. Now backwards compat path работает
как раньше с UPSTREAM_IMAGE_TAG.
- Backend docker jobs (maven-package + 4 docker-*) теперь без rules:changes —
catch ImagePullBackOff если frontend push шёл без backend rebuild. BuildKit cache
делает повторный билд идентичных layers ~30 sec.
- Path-filter оставлен на maven-test (тесты не запускаются на frontend-only PR
— они бэкендовые) и на docker-admin-ui (frontend-only).
Run: pnpm test → 51 passed.
CI: path-filtering (наконец-то)
- Backend jobs (maven-test, maven-package, docker-app/read-api/projection-writer/migrations,
resolve-backend-tag) запускаются только при backend-changes (Java модули, pom.xml, CI yml).
- Frontend (docker-admin-ui, resolve-frontend-tag) — только при ordinis-admin-ui/**.
- Trigger downstream передаёт BACKEND_IMAGE_TAG и/или FRONTEND_IMAGE_TAG. Backwards
compat для старого UPSTREAM_IMAGE_TAG сохранён в infra pipeline.
- Frontend-only PR теперь не катит backend rolling update (raньше каждый push
тегал ВСЕ сервисы новым sha и роллил).
Edit dialog UX (bitemporal):
- При Edit validFrom default = now() (а не historical validFrom существующей записи).
Раньше юзер сдвигал validFrom назад → backend не находил активной версии в этот
момент → 'record_not_active'. Теперь по умолчанию 'новая версия с этого момента'.
- nowIsoLocal() в timezone браузера (с offset).
- validTo default пустой = бессрочно.
Backend: понятное сообщение для record_not_active с инструкцией.
History:
- Drawer на детальной странице записи (timeline всех версий через GET /records/{key}/history)
- Иконка ClockCounterClockwise в actions колонке таблицы записей
- Каждая версия показывает v(version), validFrom/To, updatedBy + expand JSON
Schema diff (breaking detection):
- Util diffSchemas() сравнивает старую/новую схему и классифицирует изменения:
breaking (removed/typeChanged/becameRequired/enumRemoved/maxLengthTightened/
patternChanged/newRequiredField), compatible (added optional/enumAdded/
becameOptional), metadata (description)
- В DictionaryEditorDialog при breaking → Alert с детальным списком + Save заблокирован
- При compatible → Alert info со списком
- suggestVersionBump: major для breaking, minor для compatible field changes,
patch для metadata-only
x-unique:
- Чекбокс «Уникальное» на PropertyEditor → emit x-unique: true в schema
- Backend enforcement позже — отдельный PR с partial unique index
x-id-source:
- SingleSelect в Метаданных «Использовать как ID» → emit x-id-source на schema root
- В SchemaDrivenForm если установлен — businessKey input скрывается, на submit
derived из data[idSource]
- Подсказка с именем поля
DateTime для validFrom/validTo:
- Заменил DatePicker (date-only) на DateTimeField компонент: DatePicker + TextInput
type=time рядом
- Default time: validFrom=00:00, validTo=23:59 (запись действует до конца дня
иначе зануляется в полночь)
- Поддерживает 2-часовые интервалы (запуск КА в 14:30 → сход в 02:15 etc.)
Bug fix:
- parseSchemaJson изменил сигнатуру с PropertyDef[] на {properties, idSource},
старый код в DictionaryEditorDialog падал "{} is not iterable". Поправлено
через destructuring const parsed = parseSchemaJson(...).
i18n: 25+ новых ключей (RU/EN) — schema.unique, schema.idSource, schema.diff.*,
history.*, form.idSourceNote.
Несколько связанных багов в форме записей:
DatePicker:
- launch_date / decay_date (format=date) и validFrom/validTo рендерились как
raw text input → теперь @nstart/ui DatePicker с RU локалью.
- DatePickerProvider добавлен в main.tsx с DATE_PICKER_LOCALE_RU.
- Far-future infinity dates (year>=9999) скрываются (пустое поле).
- ISO date format на submit: yyyy-MM-dd.
Nested object → JsonField fallback:
- orbit (type=object с raan_deg/eccentricity/...) и antennas (array of object)
показывали [object Object] в text input. Добавлен JsonField — TextArea
с JSON.stringify/parse, локальный state для невалидного промежуточного
ввода + inline parse error.
Edit raw record fetch:
- Read-api flatten'ит i18n в строку выбранной локали → form для edit получал
data.name = 'Terra' вместо {ru-RU, en-US}. На Edit теперь fetch raw record
через writer GET /api/v1/dictionaries/{name}/records/{businessKey}.
- nginx regex location ~ ^/api/v1/dictionaries/ → writer (вместо узкого regex
только для /dictionaries/{name}). GET schema, GET raw record, mutations —
всё на writer. Read-api держит /api/v1/{name}/records/* (другой URL pattern).
- LoadingBlock пока тащим raw запись + Alert на ошибке.
Свои Modal/Button/inputs выкинуты — берём nstart-ui v0.1.3 как в других corp
React приложениях. Установка через .npmrc → repo.nstart.cloud/repository/npm-hosted/.
Что сделано:
- .npmrc: scope @nstart на corp Nexus + min-release-age=7
- deps: @nstart/ui ^0.1.3, @phosphor-icons/react ^2.1.10
- bumped peer deps: i18next ^26 + react-i18next ^17 (требование nstart-ui)
- styles.css: импорт @nstart/ui/styles/{fonts,theme}.css + critical @source
директива (без неё Tailwind v4 tree-shaking дропнул бы все классы из dist либы)
- main.tsx: <NStartUiProvider> на корне
- DROP src/components/ui/Modal.tsx (свой Modal больше не нужен)
Routes:
- __root: LanguageSwitch вместо двух кнопок RU/EN, токены ultramarain/regolith/carbon
- dictionaries.index: PageHeader + Badge для scope chip + Alert/EmptyState/LoadingBlock
- dictionaries.$name: PageHeader с actions, Modal/Panel/Table/TableRow/Cell,
IconButton с Phosphor (PencilSimple, XCircle), Button variant=primary/danger/ghost
Form (SchemaDrivenForm):
- TextInput/TextArea/SingleSelect/Checkbox + FieldLabel/Hint/Error из @nstart/ui
- FormSection + FormGrid columns=1|2 для группировки
- FormActions align=end с Button loading state
- Alert variant=error для server-side ошибок (422 IdempotencyConflict / 409 InProgress)
- ajv валидация по schema_json остаётся такой же (Draft-07, как у backend)
Bundle: 192KB → 659KB gzip — тянет echarts/lottie/brand assets из nstart-ui,
для internal admin tool приемлемо (cached after first load).
После regen routeTree.gen.ts /dictionaries/$name стал child route /dictionaries.
dictionaries.tsx рендерил карточки без <Outlet/> — клик по карточке менял URL,
но child компонент негде было рендерить, поэтому видна та же страница со списком.
Разнёс по конвенции:
- dictionaries.tsx — layout с <Outlet/>
- dictionaries.index.tsx — карточки списка (matches /dictionaries)
- dictionaries.$name.tsx — без изменений (matches /dictionaries/{name})