Commit Graph

67 Commits

Author SHA1 Message Date
Zimin A.N. 08405fecf7 feat(admin-ui): time-travel UI — view dictionary as-of any past date
CEO plan v1 stretch — закрывает gap "Time-travel UI (slider)".

Backend уже поддерживает ?at=<ISO> в read-api endpoints (DictionaryReadController):
findActiveAt(dict, key, at) возвращает active version на этот момент.
Был только UI gap.

UI:
- New toolbar button "Просмотр на дату" с ClockCounterClockwiseIcon.
  aria-pressed когда timeTravelAt активен.
- Datetime picker раскрывается inline (не модал — quick interactions).
  Native <input type=datetime-local>, конвертируется ↔ ISO.
- Active state ambient banner (orbit/8 background) если ?at= задан + picker
  закрыт — показывает "Просмотр на: {date}" + "Сейчас" клир link.
- "Закрыть" кнопка для picker'а; "Сейчас" сбрасывает at.

URL searchParams:
- DictSearch type расширен `at?: string` (ISO datetime).
- validateSearch проверяет `Date.parse(at)` — invalid timestamps drop'ятся.
- Share-friendly: "ordinis.k8s.265.../dictionaries/spacecraft?at=2025-12-31T00:00:00Z"
  работает per F5 + ссылка коллеге.

Records query:
- RecordsFilter type +at?: string.
- recordsQuery передаёт ?at=… backend'у в дополнение к as_scope/bbox/polygon.
- queryKey включает at — switching между моментами trigger'ит refetch.

i18n RU/EN: timeTravel.{button,label,viewing,clear,close}.

Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.

Backend смену не требует — DictionaryReadController.list() уже принимал
@RequestParam(value = "at") OffsetDateTime at и роут'ил в RecordReadService.
2026-05-08 12:39:53 +03:00
Zimin A.N. 0a8275ef4c feat(redis-projection): per-dict feature flag (CEO plan E2 tiered perf)
Per-dictionary opt-in для Redis projection materialization. Default false
— projection-writer пропускает event'ы того dict'а, никаких dead writes
в Redis. Включается админ'ом через UI checkbox в Metadata tab.

Migration 0017:
- ALTER TABLE dictionary_definitions ADD COLUMN redis_projection_enabled
  BOOLEAN NOT NULL DEFAULT false.
- Index idx_dict_def_redis_proj для быстрого filtering в list queries.

Backend:
- DictionaryDefinition entity: new field + getter/setter.
- DictionaryResponse DTO: returns flag в response.
- CreateDictionaryRequest DTO: optional Boolean (null/missing = no change).
- DictionaryDefinitionService.create + updateSchema: применяет flag из request.
- ProjectionWriter (projection-writer service):
  * upsert() — early-return + skip counter если flag=false на dict.
  * delete() — symmetric: skip если flag=false (защита от stale keys
    после flag-flip; полный cleanup TODO Phase 2).
  * New metric: ordinis_projection_records_skipped_total.

Admin UI:
- DictionaryEditorDialog Metadata tab: checkbox "Включить Redis-проекцию"
  с описанием. Apply через CreateDictionaryRequest payload.
- DictionaryDefinition + CreateDictionaryRequest types updated.
- i18n RU/EN: schema.redisProjection.{label,hint}.

Что НЕ делается (deferred Phase 2):
- read-api Redis routing — read-api сейчас всегда идёт в PG read replica.
  Когда dict опт-инится в флаг, projection материализуется, но read-api
  ещё не использует. Read routing — отдельная feature с benchmark proof
  (через JMH + k6 SLO test).
- One-shot cleanup job для stale keys после flag-flip from true→false.

Behavior change в production:
- До patch: projection-writer пишет в Redis для ВСЕХ dict events (40
  dictionaries). Default flag=false после migration → projection-writer
  пропускает всё → Redis keys для existing dicts становятся stale.
  Это OK потому что read-api НИКОГДА не читал из Redis (writes были
  dead). Stale keys eventually evicted Redis maxmemory policy.
- После patch: только dicts с redis_projection_enabled=true получают
  материализацию. По default — нет. Admin opt-ins per-dict через UI.

Verify:
- mvn test (full reactor): SUCCESS, all green.
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration 0017 applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
2026-05-08 12:34:58 +03:00
Zimin A.N. 5bc82d2951 feat(admin-ui): webhook event preview tab в DictionaryEditorDialog
CEO plan v1 polish — закрывает gap "Webhook preview при определении схемы".

Что добавлено:
- New tab "События" (4-й после Метаданные / Поля / JSON) в schema editor.
- Per-event sample JSON envelope для всех 5 event types которые fire'ят
  на dict + records: RecordCreated, RecordUpdated, RecordDeleted,
  DefinitionCreated, DefinitionUpdated.
- Sample data автогенерируется из schema property types:
  string → "sample", integer → 42, number → 3.14, boolean → true,
  array → [<sample>], object → recursive, x-localized → {ru-RU, en-US},
  enum → first value, x-references → "REF-001", date/datetime → ISO.
- Topic name pattern shown: ordinis.{dict}.{scope.lower} для record events,
  ordinis.definitions для definition events.
- Disclaimer что это client-side preview — реальный shape определяется
  ordinis-events-api DTOs.

Helps integrators (Альтум, Геопортал, BI consumers) понять shape события
до того как dict published — без ожидания live event'а.

Files:
- src/components/schema/eventPreview.ts (new, ~155 LOC) — pure helper
- src/components/schema/EventsPreviewTab.tsx (new, ~70 LOC) — UI
- src/components/schema/DictionaryEditorDialog.tsx — +16 LOC, mount tab
- src/i18n.ts — schema.tabs.events + schema.events.{intro,topic,disclaimer}
  RU + EN

Verify:
- pnpm tsc --noEmit: clean
- pnpm test (vitest): 89/89 PASS (4.34s)
- pnpm build: clean
2026-05-08 12:14:35 +03:00
Zimin A.N. 3eeaba058f feat(cascade): Phase 3 — CascadeCloseService + 409 routing + UI confirm dialog
dict-relationships-v2 epic, Phase 3. Cascade engine + UX guards.

Backend:
- New CascadeCloseService:
  * evaluatePlan(target, key, at) — read-only, splits dependents по
    onClose mode (BLOCK / WARN / CASCADE).
  * executeCascade(target, key, when, reason, confirmed) —
    @Transactional(timeout=30, isolation=SERIALIZABLE) atomic close
    target + all CASCADE deps в one hop (design F.OUT). 503
    x_references_cascade_timeout если tx exceeds 30s, 400
    x_references_cascade_too_large если N > 500.
  * RecordCloseSink interface — test seam над repo+audit+outbox
    mechanics (concrete classes на JDK 25 + Mockito incompat).
- Wired DictionaryRecordService.close():
  * Optional<CascadeCloseService> inject через ctor (back-compat ctor
    для тестов с null deps).
  * Pre-close evaluatePlan: blockers → 409 x_references_blocked_by_dependents,
    cascade non-empty → 409 x_references_cascade_required (просит cascade-close
    endpoint). WARN-only — close проходит.
- Endpoints в DictionaryRecordController:
  * GET /dictionaries/{dict}/records/{key}/cascade-preview — read-only план.
  * POST /dictionaries/{dict}/records/{key}/cascade-close?confirmed=true —
    atomic execute. Existing DELETE остался (теперь учитывает план).

Tests (8 unit tests):
- evaluatePlan empty / split-by-mode (BLOCK/WARN/CASCADE)
- 409 blockers exist (not closed)
- 409 cascade-required без confirmed
- WARN-only — closes target only, warnings returned
- CASCADE — atomic close target + 2 cascaded (verifies order + reasons)
- BLOCK overrides CASCADE — 409 даже при confirmed=true
- Empty plan — closes only target

Admin UI:
- New CascadeConfirmDialog component:
  * Trigger: 409 на existing close → opens dialog с GET cascade-preview.
  * BLOCKERS section — Alert error, list первых 5, "resolve first" hint.
  * CASCADE section — per-source grouping, sample 3 + overflow,
    badges per (count, onClose mode).
  * WARN section — yellow Alert "будут orphan".
  * Reason input + "Type CONFIRM" gate если N > 50 (UX guard).
  * Cancel — close без changes.
- Wired в dictionaries.$name.tsx: existing close-confirm flow на 409
  cascade error pivots в new dialog (same UX path, transparent для user).
- Types в client.ts: CascadeEntry, CascadePlan, CascadeCloseResult.
- Mutation useCascadeCloseRecord, query useCascadePreview.
- i18n RU/EN: cascade.* (35 keys, plurals для счётчиков).

Verify:
- mvn -pl ordinis-rest-api -am test: 106/106 PASS (8 new cascade tests).
- mvn verify -pl '!ordinis-app': все модули SUCCESS (5.1s).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.

Backward-compat: bundle v1.2.1 (без x-references-on-close) → onClose=BLOCK
default → existing dependents теперь блокируют close (правильное поведение
для FK semantics). Если был race condition close-with-deps в v1, он
exposed теперь как 409. CRITICAL: REGRESSION test покрывает.
2026-05-08 11:23:46 +03:00
Zimin A.N. c06ae263e0 feat(lineage): Phase 2 — materialized view + throttled refresh + UI staleness
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 обычно.
2026-05-08 11:05:33 +03:00
Zimin A.N. 6a365fcef7 feat(admin-ui): Phase 1 lineage UI — "Used by" + record refs cards
dict-relationships-v2 epic, Phase 1 admin UI. Backend endpoints поднимаются
коммитом 46a0a9c, тут — UI surface'ит их.

Что добавлено:
- `DictionaryDependentsPanel` — schema-level "На этот словарь ссылаются:"
  panel на странице справочника. Chip per (sourceDict.field → targetField,
  active records count, onClose mode badge: block/warn/cascade).
  Hide-on-empty: большинство справочников без incoming refs panel не видят.
- `RecordDependentsPanel` — record-level "На эту запись ссылаются:" section
  внутри `RecordHistoryDrawer`. Per-source summary chips + paginated items
  list (size=20, prev/next). Hide-on-empty.

Types в `client.ts`: `OnCloseAction`, `SchemaDependent`, `RecordDependent`,
`PerSourceSummary`, `RecordDependentsPage`.

Queries в `queries.ts`:
- `useDictionaryDependents(name)` — staleTime 5min (schemas slow-moving),
  404/403 → empty.
- `useRecordDependents(dict, key, page, size)` — disabled пока key undefined.

Mount points:
- `routes/dictionaries.$name.tsx`: `<DictionaryDependentsPanel>` после
  PageHeader, перед AOI banner.
- `components/record/RecordHistoryDrawer.tsx`: `<RecordDependentsPanel>`
  выше existing version timeline.

i18n RU/EN:
- `lineage.usedBy.{title,count,activeCount,openSourceDict}`
- `lineage.refs.{title,total,field,created,openSourceRecord}`
- `lineage.onClose.{BLOCK,WARN,CASCADE}` (показ как лейблы)
- `pagination.{prev,next,pageOf}` — общие keys.

Plurals: `_one/_few/_many/_other` для RU (правильная грамматика count'ов).

Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS (4.04s).
- pnpm build: clean (warnings о chunk size — pre-existing для @nstart/ui).

Бекенд endpoints живут на:
- GET /api/v1/dictionaries/{name}/dependents
- GET /api/v1/records/{dict}/{key}/dependents?page&size
2026-05-08 10:52:46 +03:00
Zimin A.N. 27648ee2f7 fix(ci): pnpm via npm install (skip corepack) — Dockerfile + build-docs job
Corepack может hang в CI runner на integrity-check либо non-TTY prompt
при первом activate. Заменяем на прямой npm install с pinned version
9.15.4 (latest stable v9, lockfile compat: lockfileVersion '9.0').

Изменения:
- ordinis-admin-ui/Dockerfile: corepack → npm install -g pnpm@9.15.4
- docs/Dockerfile: same swap
- .gitlab-ci.yml build-docs: same swap

Verified locally: docker build ordinis-admin-ui за 23 sec, no hang.
2026-05-08 00:35:41 +03:00
Zimin A.N. a73d0d31aa fix(admin-ui): pin pnpm@9 в Dockerfile — обойти v10 postinstall block в CI
pnpm@latest (= v10+) теперь блокирует postinstall scripts по умолчанию и
просит "Run pnpm approve-builds to pick which dependencies should be
allowed to run scripts". В CI нет интерактивного prompt'а → exit code 1.

Pin на v9 — keeps старое behaviour, совместимо с pnpm-lock.yaml v9.0.
Same подход что в docs/Dockerfile (pnpm@9.0.0 уже).

Если нужен v10 — добавь в package.json:
  "pnpm": { "onlyBuiltDependencies": ["esbuild", ...] }
либо --config.dangerouslyAllowAllBuilds=true в CI command.
2026-05-08 00:10:08 +03:00
Zimin A.N. 4252ad93a8 feat(records): bulk export CSV выбранных + pagination
Backend POST /api/v1/dictionaries/{name}/records/export-selected.csv:
принимает businessKeys[] (1..500), возвращает CSV с колонками
business_key, valid_from, valid_to, scope, data (JSONB inline).
Не-найденные ключи отдаются строкой с пустыми ячейками — admin видит
что ключ обработан, но данных нет. RFC 4180 экранирование.

requireReadAccess: для экспорта достаточно прав чтения (не write).

POST а не GET потому что >100 ключей не помещается в URL params
(Server: Request URI Too Long).

Frontend useBulkExportRecords: axios POST с responseType: blob →
temporary <a download> link → клик → revokeObjectURL через rAF.
Filename берётся из Content-Disposition header.

Toolbar: новая кнопка "Экспорт CSV" (variant=secondary) рядом с
"Закрыть выбранные" (variant=danger). Loading state через isPending.

Pagination для records list (PAGE_SIZE=50, client-side):
- visibleRecords = filteredRecords.slice(...)
- Footer "Записи 1–50 из 100, Стр. 1 из 2" с Prev/Next кнопками
- Auto-reset на page=0 при изменении filter/scope/AOI
- selectAll/visibleKeys теперь = только current page (WYSIWYG)
- При >50 записей видно навигацию, при <=50 footer скрыт

Прогон: 20/20 e2e + 60/60 rest-api + 89/89 admin-ui.
2026-05-07 12:12:31 +03:00
Zimin A.N. 5941207955 feat(records): bulk close — multi-select toolbar + per-record транзакция
Backend POST /api/v1/dictionaries/{name}/records/bulk-close принимает
businessKeys[] (1..500) + reason. BulkRecordService крутит цикл через
inject'ed DictionaryRecordService — каждый close в своей транзакции
(SERIALIZABLE), partial success возможен. Result: closed[] / skipped[]
(already_closed, not_found) / errors[].

Frontend: checkbox column + sticky toolbar когда selection.size > 0.
Header checkbox с indeterminate state для partial select. selectAll
включает все ВИДИМЫЕ ключи (не игнорирует фильтры). При изменении
фильтра — useEffect выкидывает невидимые ключи из selection (WYSIWYG).

Bulk close dialog: count + reason input → confirm → результат панель
"Закрыто N, пропущено M, ошибок K" с детальным списком skipped/errors.
После успеха selection остаётся для skipped/errors — юзер видит что
ещё не сделано.

Лимит 500: защита от случайного огромного селекта или бага UI.
Дедупликация внутри batch'а silent skip — не error.
2026-05-07 11:19:57 +03:00
Zimin A.N. 92507ab0a1 feat(audit): URL persistence фильтров + chip-list активных
Фильтры audit log теперь живут в URL search params: dict, action, user,
bk, from, to, page, size. Result:
- reload не сбрасывает фильтр
- админ может поделиться ссылкой "audit за вчера на ground_station"
- browser back/forward работает по фильтрам как навигация
- ссылка из логов / тикета сразу открывает нужный срез

Chip-list над таблицей показывает активные фильтры; клик на chip
убирает только этот фильтр (не сбрасывает остальные). UX выгрузка
без копания в свёрнутой панели.

Apply на Enter для TextInput'ов user/businessKey — быстрее чем кнопка.

Validation в validateSearch: action — enum, page — non-negative int,
size — whitelist (50/100/200). Невалидные query params silently
дропаются (не throw — admin-ui остаётся на странице).
2026-05-07 11:03:43 +03:00
Zimin A.N. c0444ccfec fix(geo): replace leaflet-draw на custom DIY draw — fix instant-finish bug
leaflet-draw 1.0.4 (последний release 2017) сломан с Leaflet 1.8+:
mousedown сразу триггерит mouseup, прямоугольник схлопывается в
точку при первом нажатии. На пользовательском скрине quadrate ~5px
вместо нарисованной области.

Заменил полностью на свою draw-implementation (~80 строк):
- Toolbar UI: 3 кнопки (Прямоугольник / Полигон / Очистить)
- Active mode highlights ultramarain background
- Rectangle: mousedown → preview → mouseup finalize. Map.dragging
  отключается в rectangle mode чтобы drag не панил карту.
- Polygon: click добавляет вершину + dashed mouse-line preview к
  курсору. Double-click closes (min 3 vertices). doubleClickZoom
  disabled на map чтобы не конфликтовало.
- DrawMode read через ref чтобы listeners не пересоздавались на
  каждом state change.

Удалены deps:
- leaflet-draw 1.0.4 (broken)
- @types/leaflet-draw

Bundle: -68KB minified / -23KB gzip (less broken legacy code).

Tests: 89 passed. i18n: RU/EN ключи для новой toolbar.
2026-05-06 22:22:09 +03:00
Zimin A.N. 7a8bfedfa5 fix(geo): убрать Leaflet attribution из AOI map
В Leaflet 1.9.4 default attribution содержит ukrainian flag emoji
('🇺🇦 Leaflet'). Для admin tool ЦУОД политически нейтральнее без него.

attributionControl: false выключает весь control (Leaflet + OSM
credit). OSM tile usage всё ещё legitimate — admin internal tool,
не публичный сервис.
2026-05-06 22:17:27 +03:00
Zimin A.N. 9aa3d842d2 fix(geo): AOI map blank — RAF poll до появления div + shrink-0 защита
DevTools на пользовательском скрине показал:
- Map div СУЩЕСТВУЕТ в DOM (display:block, height:520 inline)
- Реальный размер 974×254 — flex-shrink порезал до ~50% запрошенного
  потому что parent (Modal body) overflow-hidden + max-h:calc(100vh-2rem)
  на узком viewport (497px) не вмещал 520+ контента.
- Класса 'leaflet-container' на div НЕТ — L.map() так и не вызвался.

Корень: Modal от @nstart/ui рендерит children только после
useAnimatedPresence фликает isMounted=true (на след. рендер после
isOpen=true). Мой useEffect фаерится РАНЬШЕ — div ещё не в DOM,
mapDivRef.current=null. Старый guard 'if (!div) return' выходил
БЕЗ schedule RAF — больше никогда не пробовал.

Фикс в tryInit: schedule RAF и для null div, и для 0-size. Cap 100
кадров (~1.6s). Это finally доводит до L.map() когда div появится
и получит размер.

Plus: shrink-0 + min-height: 520 на map div чтобы flex-shrink не
ужимал до 254px на узких viewport. Visual quality 520px stable.
2026-05-06 22:12:17 +03:00
Zimin A.N. 325838cd12 fix(geo): AOI map blank — hardcode height 520px, decouple от flex/vh
Console показывает no errors AND no tile requests. Это значит
L.map(div) либо не вызывается, либо вызывается с 0×0 контейнером
(Leaflet не запрашивает 0 tiles при 0-viewport).

Гипотеза: 'flex-1' + 'height: 60vh' конфликтуют с Modal transform
animation (translate-y-2 scale-[0.98] во время fade-in). На
момент useEffect div.clientHeight иногда читался 0 несмотря на
inline style.

Decouple полностью: hardcode '520px' pixel height, display:block,
width:100%. Не использует flex layout. Стабильно >0 на любом
viewport.
2026-05-06 22:05:22 +03:00
Zimin A.N. 5534b9d479 fix(geo): AOI map blank — defer init до non-zero clientSize
Предыдущий fix с ResizeObserver + RAF не помог: на пользовательском
скрине карта снова blank, нет даже zoom controls и attribution.
Гипотеза: Modal от @nstart/ui использует display:none → block для
animation. В момент useEffect div ещё имеет 0×0 (parent display:none
makes all children clientWidth/Height = 0 regardless of inline style).
Leaflet L.map(div) с 0×0 контейнером не вычисляет initial tile grid,
и invalidateSize позже не recompute'ит некоторые internal collections.

Решение: recursive RAF poll до тех пор пока div.clientWidth и
clientHeight > 0, тогда init'им L.map(). Cap на 100 кадров (~1.6s)
fallback init с тем что есть — на случай если modal никогда не
покажется по какой-то причине.

ResizeObserver всё равно ставится после init для window resize /
dev tools toggle / responsive — но не критичен для первого рендера.
2026-05-06 21:55:36 +03:00
Zimin A.N. d8686c0352 feat(schema): создание словаря по шаблону существующего
Для семейств похожих словарей (satellite_type / consumer_type /
processing_level — все 'code+name+description' с таким же scope)
admin копировал JSON руками или дублировал поля в Поля-табе. Это
~3 минуты на каждый.

Теперь в create-mode dictionary editor сверху появился блок 'Создать
по шаблону': SingleSelect списка существующих словарей. На выбор
fetch'ится detail и заполняются:
- description, scope, bundle
- supportedLocales / defaultLocale
- properties (через parseSchemaJson — round-trip preserved для FK,
  enum, localized, opaque kinds)
- idSource

Не трогаем:
- name (admin вводит уникальный имя — это валидация на 422)
- displayName (специфичен для нового словаря)
- schemaVersion (resets на 1.0.0)

Чисто client-side: queryClient.fetchQuery(dictionaryDetailQuery(name))
— TanStack Query сам кеширует результат и для users 'Изменить схему'
позже. Не требует backend изменений.

Edit-mode не показывает шаблон — там initial уже имеет schema
существующего словаря.
2026-05-06 21:41:21 +03:00
Zimin A.N. f2cf34e2fc feat(webhook): delivery list — server-side фильтр по status
Раньше при page=50 admin не мог найти редкий dlq среди
delivered'ов: client-side filter показал бы 1 совпадение из 50,
а на page=1 могли быть ещё. Делаем server-side через ?status=.

Backend:
- WebhookDeliveryRepository.findBySubscriptionIdAndStatusOrderByCreatedAtDesc
  — Spring Data derived query, JPA генерирует SQL.
- GET /admin/webhooks/subscriptions/{id}/deliveries?status=dlq —
  опциональный param. Invalid status → 400 OrdinisException.
  Allowed: pending, retrying, delivered, dlq.

UI:
- 4 chip'а pending/retrying/delivered/dlq над таблицей deliveries
  (одинаковый паттерн со scope-чипами в dictionaries).
- Click toggle + reset page=0. 'Сбросить' появляется когда фильтр
  активен.
- Status в URL пока не персистится — для одной session-страницы
  не критично.
2026-05-06 21:34:04 +03:00
Zimin A.N. 5bcc9586c7 feat(dict): records list — schema-driven extra columns (FK + enum)
Раньше records list показывал только businessKey / name / scope /
locale / validFrom. На spacecraft с 14 полями admin не видел статуса
или типа КА — должен был открыть edit modal каждой записи чтобы
понять что есть OPERATIONAL и что есть LOST.

Drop unhelpful 'locale' колонку (для не-localized словарей всегда '—').
Replace на до 2 schema-driven 'значимых' колонок:
- FK поля (x-references) — синий fill, маркируют ссылку на dict
- Enum поля — серый fill, fixed allow-list

Spacecraft теперь показывает в таблице: businessKey, name,
satellite_type_code (FK), status (FK), scope, validFrom — всю
полезную информацию за один взгляд.

Schema interpretation:
- detailQuery.data.schemaJson.properties → filter scalar non-localized
- Skip name/code/businessKey (уже показаны)
- Take первые 2 enum/reference (column count fits 1280px viewport)
- humanize key для header label (snake_case → Snake Case)

Tests: 89 passed (без изменений — render-логика, integration test
требует QueryClient + schema fixture, ROI низкий).
2026-05-06 21:10:19 +03:00
Zimin A.N. 5e1b39db03 fix(dict): name column в records list — pick localized value, не [object Object]
Раньше для записей с 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).
2026-05-06 21:05:50 +03:00
Zimin A.N. 66e89ce707 feat(webhook): test ping — admin верифицирует receiver одной кнопкой
После создания 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.
2026-05-06 20:59:47 +03:00
Zimin A.N. a2d5df2430 feat(schema): FK тип в schema-builder — picker для x-references
Раньше для добавления FK поля admin должен был лезть в JSON-tab
и руками писать 'x-references': 'dict.field' — рискованно
(typo в имени словаря не диагностится до save), и неочевидно для
не-дев пользователей.

Теперь:
- В Type dropdown появилось 'Ссылка на словарь (FK)' рядом с Enum
- При выборе reference показывается двухпольный picker:
  - SingleSelect списка доступных словарей (через useDictionaries
    запрос — admin видит full list с displayName)
  - TextInput поля в target (default 'code', соответствует
    x-id-source большинства словарей)
- Highlight border-ultramarain/20 + bg-ultramarain/4 — визуально
  отличается от обычного scalar-конфига

types.ts:
- 'reference' в PropertyKind union + PROPERTY_KINDS list
- referenceTarget?: string в PropertyDef ('dict.field')
- propertyToSchema → {type:'string','x-references':'...'}
- inferKind распознаёт x-references → reference kind (не opaque
  fallback) — round-trip preserved
- kindOf включает 'reference' для diff: смена reference↔string
  ловится как breaking change

Tests: +1 (76 total) — reference roundtrip build→parse.

После CI rebuild создатели справочников делают FK без JSON tab.
2026-05-06 20:49:39 +03:00
Zimin A.N. 4ae6dc3863 feat(ui): favicon — Ordinis 'O' + спутник на орбите, brand ultramarain
Раньше browser tab показывал default favicon (белый круг или
generic). Теперь:
- favicon.svg — rounded ultramarain (#120a8f) tile + белая 'O'
  кольцом (Ordinis = order, latin) + маленькая точка-спутник на
  орбите (связь с ДЗЗ-доменом ЦУОД). SVG масштабируется на retina
  + tab + bookmark без потери качества.
- safari-pinned-tab.svg — monochrome mask icon, Safari заливает
  theme-color при pinned tab.
- meta theme-color = brand ultramarain — Chrome/Edge address bar
  на mobile получит brand цвет, Safari iOS standalone PWA тоже.

Без apple-touch-icon PNG — admin tool не используется как
homescreen install, ROI не оправдывает PNG generation pipeline.
SVG favicon работает на iOS 17+ для Safari.
2026-05-06 20:44:41 +03:00
Zimin A.N. 2e3f2ddd16 feat(webhook): retry delivery — POST /deliveries/{id}/retry + UI кнопка
Раньше для 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.
2026-05-06 20:36:17 +03:00
Zimin A.N. d5a28d251d feat(dict): URL search params для filter state — share-friendly persistence
Раньше 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
2026-05-06 20:30:40 +03:00
Zimin A.N. dd42626fe2 fix(ui): AOI map blank на open + FK select loading spinner
AOI map blank:
Pickер открывался с пустым контейнером — без zoom controls и
attribution. Leaflet вычисляет tile grid от containerSize в момент
L.map() create(); modal animation + flex-1 layout давали 0×0 на
первом кадре, тайлы не запрашивались, и map оставался blank.
setTimeout(invalidateSize, 100ms) не покрывал случай когда modal
ещё анимировал.

Решение: ResizeObserver на map div + invalidateSize при каждом
resize. Покрывает modal fade-in, dev tools toggle, window resize.
Плюс requestAnimationFrame invalidate для initial flush. Inline
style.minHeight=420/height=60vh — защита если flex не распределил
высоту.

FK select loading state:
Раньше при isLoading select disabled + показывал '…'. Теперь:
- chevron справа заменяется на animated CircleNotchIcon (animate-spin)
- aria-busy=true для screen readers
- цвет spinner ultramarain — маркирует активность

Refactor: chevron перенесён из background-image в абсолютно
позиционированный <div pointer-events-none>. Pointer-events-none
не блокирует click по нативному select.
2026-05-06 20:24:48 +03:00
Zimin A.N. b4281de41f feat(dict): records filter — search + scope chips client-side
На странице словаря над таблицей появилась 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 ключи.
2026-05-06 20:21:05 +03:00
Zimin A.N. 668f1b8b5b fix(form): FK select стилизация — match TextInput/SingleSelect look
После перехода на native <select> в предыдущем коммите рендер
показывал OS default (двойная стрелка на macOS Safari, узкая высота,
native blue focus border). Не попадало в стиль формы — рядом
TextInput/DatePicker от @nstart/ui выглядели иначе.

Фикс:
- appearance-none убирает OS default
- Кастомный chevron как inline SVG в data-uri (color #687078 = carbon/40,
  same weight что у CaretDown в SingleSelect)
- pl-3 / pr-10 / py-2 = совпадает с TextInput высотой (~38px)
- focus:ring-1 ultramarain — same focus state как у TextInput

Native browser dropdown поведение сохраняется (auto-flip, scroll,
type-ahead) — просто внешне стало match'ить @nstart/ui дизайн систему.
2026-05-06 20:19:11 +03:00
Zimin A.N. 05af8e1520 feat(geo): AOI picker — Leaflet draw → bbox/polygon фильтр записей
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.
2026-05-06 20:01:14 +03:00
Zimin A.N. 4fa96bffed fix(form): FK select на native <select> — popup не вылазит за viewport
Проблема: SingleSelect popup рендерился через createPortal с
position:fixed по bbox триггера, но без auto-flip. Когда триггер
у дна modal'а (а с x-references это типичный кейс — спутник имеет
много полей), popup уходил за нижний край viewport — нижние опции
обрезались. На словарях с 20+ полями modal становится высоким и
проблема почти всегда воспроизводится.

Native <select> делегирует popup positioning браузеру: auto-flip
вверх когда мало места снизу, native scroll внутри dropdown,
type-ahead search через клавиатуру (если ввести 'OP' — прыгнет
на OPERATIONAL), mobile-friendly. Stylinglypko под @nstart/ui
(border-regolith / border-mars / focus-ultramarain) — визуально
встаёт в линейку с TextInput / DatePicker.

SingleSelect остаётся для enum-полей (короткие закрытые списки,
чаще ≤5 опций — там popup overflow не возникает).
2026-05-06 19:52:12 +03:00
Zimin A.N. c5d41da834 feat(form): x-references поля → SingleSelect с опциями из target словаря
Поля spacecraft.status, satellite_type_code и другие FK с x-references
теперь показывают dropdown с записями из целевого справочника вместо
свободного ввода. Label формируется как '<code> — <name.ru-RU>' если
target имеет localized name.

Fallback на TextInput когда target dict недоступен (scope-hide → 404)
или пуст — admin может ввести значение вручную, валидация серверная.

Кеш 5 минут (referenced data slow-moving) через TanStack Query.

UI tests +2 (75 total).
2026-05-06 19:29:01 +03:00
Zimin A.N. e300d4ea8b fix(schema-editor): preserve nested objects via 'opaque' kind
Bug: parseSchemaJson fallback на line 196 возвращал {kind: 'string'}
для unknown types (object с nested properties, array of objects, etc.).
При save back to schema это давало lossy round-trip:

  spacecraft.orbit (object с 7 nested fields)
  → parseSchemaJson → kind: 'string'
  → propertyToSchema → {type: 'string'}
  → diffSchemas → BREAKING CHANGE 'object → string' → save blocked

Симптом видел user: edit modal spacecraft показывает 'НЕСОВМЕСТИМОЕ
ИЗМЕНЕНИЕ СХЕМЫ' для orbit, save заблокирован, хотя admin не трогал
orbit.

Fix:
- Новый PropertyKind 'opaque' для unknown types
- PropertyDef.rawSchema (optional JsonSchema) хранит оригинал
- parseSchemaJson fallback теперь {kind:'opaque', rawSchema:raw}
- propertyToSchema для opaque возвращает rawSchema verbatim (с
  description override если admin менял в Поля-табе)
- PropertyEditor: 'opaque' исключён из Type dropdown (admin не может
  вручную выбрать), для existing opaque поля показывается warning
  card с raw JSON read-only + hint что редактируется через JSON-таб

Test coverage: 73 tests passing (5 + 10 SchemaBuilder + 7 + ...).
Эффект: edit любого справочника с nested objects (orbit) больше не
триггерит false-positive breaking change.

i18n: schema.kind.opaque + schema.opaque.{title,hint} × ru-RU/en-US.
2026-05-06 17:29:42 +03:00
Zimin A.N. 96bfac174d feat(webhook): Phase 4 — admin-ui /webhooks page
Routes:
- /webhooks (layout) → /webhooks/index (list) + /webhooks/$id (detail)
- nav link добавлен в __root.tsx между Outbox и Language switch

List page (webhooks.index.tsx):
- Table: name | URL | scope filter (colored dots) | dictionaries | status
- ScopeChips reuse SCOPE_DOT из lib/scope-style.ts (consistency с
  dictionaries scope coloring)
- Create dialog: name, URL, scope filter (MultiSelect), dictionary &
  event type (CSV inputs), description. Client-side validation
  (URL scheme, name required) дублирует server-side @Pattern.
- Secret reveal modal после create: warning + plaintext + copy button.
  Single-time view — после закрытия secret не получить, только rotate.

Detail page (webhooks.$id.tsx):
- Subscription metadata grid (URL, status, filters, masked secret,
  createdBy + timestamp)
- Action buttons: rotate-secret + delete (с confirmation)
- Delivery history table: outboxEventId | status badge (delivered/
  retrying/pending/dlq) | attempts | last attempt | HTTP code | error
- Pagination 50/page
- Status badges: delivered=success, retrying=warning, pending=neutral,
  dlq=error

API client (queries + mutations):
- 4 queries: subscriptions list, subscription by id, deliveries page,
  DLQ page (last unused в UI пока, но готов для admin DLQ tab)
- 4 mutations: create, update, delete, rotateSecret. Все invalidate
  правильные query keys.

i18n: 50 ключей × 2 локали (ru-RU + en-US) под webhooks.*
2026-05-06 15:22:28 +03:00
Zimin A.N. 9b20cbf9b9 chore(admin-ui): fix pre-existing TypeScript errors
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)
2026-05-06 13:22:17 +03:00
Zimin A.N. 3f2a6b5d53 feat(admin-ui): scope accent on dictionary detail page
Перенесли scope-визуальные константы (SCOPE_ORDER, SCOPE_DOT,
SCOPE_ACCENT) в `lib/scope-style.ts` для переиспользования. На detail
странице (`dictionaries/$name`) добавили top accent strip 4px цветом
scope + цветной чип в breadcrumb рядом с back-link. Теперь при переходе
из списка в карточку scope сразу виден без чтения badge в таблице.
2026-05-06 12:50:44 +03:00
Zimin A.N. 353130c529 feat(admin-ui): group dictionaries by scope with color accent
40 справочников в плоском grid тяжело сканировать. Разделили на секции
PUBLIC / INTERNAL / RESTRICTED с цветными accent-полосами слева у карточек
(emerald / amber / rose). Search фильтрует across all sections, пустые
секции скрыты. Цвет даёт instant scan, секции — explicit разделение.
2026-05-06 12:43:01 +03:00
Zimin A.N. 171d049fc5 feat(admin-ui): поиск справочников + счётчик активных записей
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 когда поиск пустой.
2026-05-06 08:46:45 +03:00
Zimin A.N. ab6f6cb860 feat(admin-ui): RequireAuth gate + RTL component тесты
RequireAuth: гейтит весь UI по аутентификации. Anonymous юзер вместо
красного "AxiosError 401" получает immediate signinRedirect на Keycloak;
authenticated юзер с истёкшим токеном получает silent renew → fallback на
full redirect. Guards против infinite loop:
- auth.isLoading / activeNavigator — bootstrap или signin/signout уже идёт
- ?error= в URL — вернулись с провалом из Keycloak, показываем сообщение
- auth.error без isAuthenticated — Keycloak недоступен, показываем сообщение

main.tsx: RequireAuth обёрнут вокруг Router/Query, чтобы tan-stack-router и
React Query не делали запросов до auth (иначе 401-каскад на старте).

TokenSync: 401 interceptor теперь редиректит и anonymous (раньше срабатывал
только для authenticated с истёкшим токеном), с теми же guards.

RTL component тесты — 22 теста на 3 ключевых компонента:
- SchemaBuilder (7): empty state, add/remove/reorder properties
- PropertyEditor (10): kind change, required/unique toggle, kind-specific
  поля (string→pattern, integer→min/max, enum→values, array_string→uniqueItems),
  move up/down disabled на boundaries
- SchemaDrivenForm (5): identity tab с required, переключение на extra,
  isPending → save disabled, cancel callback, serverError → Alert

Setup:
- vite.config.ts: test block (jsdom env, setup file, css off)
- src/test/setup.ts: jest-dom matchers, cleanup, HTMLCanvasElement.getContext
  stub (lottie-web из @nstart/ui крашит в jsdom без canvas)
- src/test/render.tsx: renderWithI18n helper, форсит ru-RU
  (LanguageDetector в node по умолчанию даёт en-US)

Deps: +@testing-library/react +@testing-library/jest-dom
+@testing-library/user-event +jsdom
2026-05-06 00:11:07 +03:00
Zimin A.N. 11faf086a4 perf(admin-ui): manualChunks splits vendor по domain → лучше кэширование
До: 1 chunk index-XXX.js 2 МБ / 703 КБ gzip — любой commit инвалидирует весь.

После: 10 chunks. Vendor стабилен и кэшируется, при правке кода только
main (18 КБ gz) перезагружается:

  vendor-react       194 / 60 gz   (rare changes)
  vendor-tanstack    127 / 40 gz
  vendor-forms       145 / 46 gz   (react-hook-form + ajv)
  vendor-nstart-ui  1229 /465 gz   (corp UI — brand assets + stickers)
  vendor-icons        62 / 13 gz   (phosphor)
  vendor-auth         72 / 19 gz   (oidc-client-ts + react-oidc-context)
  vendor-i18n         56 / 18 gz
  vendor-net          42 / 17 gz   (axios + crypto)
  vendor-misc         15 / 6 gz
  index (main)        67 / 19 gz   ← наш код

vendor-nstart-ui всё ещё 465 КБ gz — доминирует. Это brand assets
(stickers, lottie animations) которые eager-imported в библиотеке.
Дальнейший win требует tree-shaking в @nstart/ui — отдельная задача.
2026-05-05 17:32:33 +03:00
Zimin A.N. cef78f9971 feat(admin-ui): SSO через Keycloak (PKCE, public client)
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
2026-05-04 18:28:31 +03:00
Zimin A.N. 5ffb51e119 fix(admin-ui/nginx): proxy /admin/* и /swagger-ui|v3/api-docs на writer
После добавления audit/outbox admin endpoints + Swagger UI они оказались за
SPA-fallback'ом — nginx отдавал index.html вместо проксирования на writer.

- /api/v1/admin/* (AuditAdminController + OutboxAdminController) → writer.
- /swagger-ui*, /v3/api-docs*, /swagger-resources, /webjars/swagger-ui →
  writer (там springdoc сидит).
2026-05-04 15:54:43 +03:00
Zimin A.N. 7b2fe6f2d5 feat(audit): полноценный writer в audit_log + admin UI (audit + outbox DLQ)
До этого 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).
2026-05-04 15:44:14 +03:00
Zimin A.N. f7efb23e09 test+ci: 51 frontend unit-тестов (Vitest) + CI rollback на single resolve-tag
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.
2026-05-04 04:37:24 +03:00
Zimin A.N. 14d32d93c3 fix(admin-ui): корректные бейджи в истории — future/active/expired по реальному времени
Раньше любую запись с validTo<9999 помечал 'закрыта'. Но запись с validTo в
будущем — не закрытая, а либо ЗАПЛАНИРОВАННАЯ (validFrom > now), либо АКТИВНАЯ
(validFrom <= now < validTo). User видел 'закрыта' на v3 которая ещё не настала.

Теперь:
- future:  validFrom > now → бейдж 'запланирована' (info), точка orbit/horizon
- active:  validFrom <= now < validTo → бейдж 'активна' (success) если не latest
- expired: validTo <= now → 'закрыта' (neutral), точка пустая
- latest active: 'текущая' (primary), точка ultramarain

Бессрочная запись (validTo year>=9999) больше не показывает 'действует до:' —
у бессрочной нет конечной даты, отображение его смущало. i18n: новые ключи
history.active / history.scheduled (RU/EN).
2026-05-04 04:27:44 +03:00
Zimin A.N. 303a5f0402 feat(ci+ux): path-filtered CI build + Edit semantics в bitemporal model
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 с инструкцией.
2026-05-04 04:24:17 +03:00
Zimin A.N. c7b651011a feat(ux): улучшение работы с датами validFrom/validTo
Backend (defensive):
- GlobalExceptionHandler ловит HttpMessageNotReadableException → 400 c понятным
  сообщением вместо 500 'Internal server error'.
- DateTimeParseException → code='invalid_date_format', сообщение с подсказкой
  ISO формата (например 2026-05-08T14:30:00+03:00).
- InvalidFormatException → code='invalid_field_format' с именем сломанного поля.
- Раньше падало 500 если фронт отправлял 'YYYY-MM-DD' вместо OffsetDateTime
  (из-за browser cache на старом bundle).

Frontend:
- Submit-валидация validFrom < validTo с inline error на validTo поле,
  переключение на таб 'Идентификация' с фокусом на ошибке.
- DateTimeField теперь принимает hint и error props и показывает их под
  парой DatePicker+TextInput[time].
- Подсказки i18n под полями: 'Когда запись становится активной. Пусто = с момента
  создания.' / 'Когда перестаёт действовать. Пусто = бессрочно.'
- Новая i18n ключ form.error.validToBeforeFrom (RU/EN).
2026-05-04 04:15:58 +03:00
Zimin A.N. b5c2378ab7 fix(admin-ui): нумерация версий в истории по позиции, не по rec.version
rec.version — JPA @Version (optimistic lock counter). Растёт на UPDATE, поэтому
у нескольких закрытых исторических версий может оказаться одинаковое значение
(была 0, стала 1 при close, новая запись 0, потом 1, итого две закрытые с v=1).
В UI это выглядит как дубли v1, v1.

Используем индекс позиции в timeline: новая = data.length, старая = 1.
Отображение: v3 (текущая) → v2 → v1.
2026-05-04 04:11:04 +03:00
Zimin A.N. 07c8a19cf3 fix(admin-ui): pure-date парсинг как local midnight (защита TZ западнее UTC)
format=date поля приходят как 'YYYY-MM-DD' без времени. new Date(s) интерпретирует
их как UTC-midnight. В TZ западнее UTC (UTC-5 etc.) local Date уезжает на день
назад → getDate() возвращает (день-1).

Сейчас pure-date строки регексим и собираем через Date(y, m-1, d) — всегда
local midnight. Datetime строки парсятся как раньше.
2026-05-04 04:01:20 +03:00
Zimin A.N. 80703f9caa fix(admin-ui): timezone bug в validTo/validFrom + кнопка сброса DatePicker
Bug 1: ставил 8 мая, показывало 9.
Раньше formatIsoDateTime отдавал 'YYYY-MM-DDTHH:MM:00Z' (UTC). На load
new Date(s) парсил UTC и показывал в МСК (UTC+3) → +3 часа → день вперёд.
Сейчас:
  - localTzOffset(d) вычисляет offset из браузера, например '+03:00'
  - formatIsoDateTime(d, time) собирает Date в local TZ и отдаёт ISO с
    явным offset: '2026-05-08T23:59:00+03:00'
  - extractTime(s, fallback) парсит через new Date + getHours/getMinutes,
    показывая time в local TZ юзера

Bug 2: кнопка сброса (X) в DatePicker не очищала validFrom/validTo.
combineDateTime(null) возвращал current value вместо ''. Фикс — на null
возвращаем пустую строку, value уходит в form state как ''.
2026-05-04 03:59:53 +03:00
Zimin A.N. 0e12a254c2 fix(admin-ui): datetime поля внутри schema теряли время — теперь DateTimeField
Поля с format=date-time в schema (например orbit.epoch у spacecraft) рендерились
через DatePicker и форс-сетили T00:00:00Z. Теперь та же DateTimeField (DatePicker
+ time input) что и для validFrom/validTo. format=date по-прежнему чистый
DatePicker без времени.
2026-05-04 03:42:11 +03:00