Merge branch 'feat/whats-new-button' into 'main'
feat(topbar): «Что нового» button + 2026-05-12 state.md update See merge request 2-6/2-6-4/terravault/ordinis!148
This commit is contained in:
@@ -0,0 +1,184 @@
|
|||||||
|
# Состояние разработки Ordinis (ЦУОД ОДХ)
|
||||||
|
|
||||||
|
**Версия:** v2.14.0 (mega-sprint feature wave: 38 MRs за 2 дня, prod v2.8.0 → v2.14.0)
|
||||||
|
**Дата:** 2026-05-12 (vespertine update — конец дня 2)
|
||||||
|
**Owner:** zimin.an@nstart.space
|
||||||
|
**Окружения:**
|
||||||
|
- **prod** (cluster.264, https://ordinis.k8s.264.nstart.cloud, ✅ LIVE v2.14.0, Альтум traffic stable)
|
||||||
|
- **staging** (cluster.265, dev environment)
|
||||||
|
**Предыдущий snapshot:** [2026-05-07-state.md](./2026-05-07-state.md) (v1.0.1, post-cutover hardening)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Что изменилось с v1.0.1 (2026-05-07 → 2026-05-12)
|
||||||
|
|
||||||
|
### Mega-sprint feature wave
|
||||||
|
|
||||||
|
**День 1 (2026-05-11):** !108–!140 = 33 MRs merged (28 features + 3 chores + 1 closed zombie + 1 v2.11.x deploy pending). Tags v2.0.0 → v2.11.0. Highlights: backend pending endpoints (5/5 + extras), schema workflow API (8 endpoints) + Phase 1a/b/c UI, History tab wiring, TimeTravelModal fully wired, Monaco editor, friendly error UI, webhook stats dashboard.
|
||||||
|
|
||||||
|
**День 2 (2026-05-12):** !141–!147 = 7 MRs (5 merged + 2 design-only open). Tags v2.12.0 → v2.14.0. Focus shifted: feature shipping → polish + design proposals.
|
||||||
|
|
||||||
|
### День 2 detail (focus этого state.md)
|
||||||
|
|
||||||
|
**Polish features shipped:**
|
||||||
|
- **!141 → v2.12.0** RecordDrawer chip strip scroll-spy (IntersectionObserver active section highlight)
|
||||||
|
- **!142 → v2.13.0** Searchable MultiSelect через cmdk + DateRangePicker (react-day-picker, lazy chunk 67kb gz)
|
||||||
|
- **!143 → v2.14.0** Webhook delivery histogram (SVG stacked bar chart, 24h/7d toggle, hover tooltip)
|
||||||
|
|
||||||
|
**Design proposals (3 docs, не implementation):**
|
||||||
|
- **!143 + !144** Redis FK projection design v1 → v2.1 после 2 rounds /plan-eng-review. **NEAR-CLEAR.** Sprint estimate 8.5d → 4-5d после `LineageIndexService` reuse decision. 15-test plan + 3 critical safety mitigations + 3-tier kill switch + explicit SLO targets.
|
||||||
|
- **!145 + !146** AI schema assist design v1 → v2 после /office-hours. **Approach C-revised (hybrid template + per-field LLM)** chosen over v1 whole-schema LLM. 7B model на consumer GPU. P5 measurement step как prerequisite. 3-4d implementation.
|
||||||
|
- **!145 + !147** Dictionary marketplace design v1 → v2 после /office-hours + cross-model challenge. **Approach C-revised (bundle hygiene 2-3d hard cap)** chosen over v1 full marketplace (10-14d). Java handoff readiness + sales artifact angle. Anti-creep enforcement language.
|
||||||
|
|
||||||
|
**Prod deploy v2.14.0:**
|
||||||
|
- Pipeline 6700 (manual API trigger on tag)
|
||||||
|
- Bridge `trigger-deploy-prod` (id=30851) — played
|
||||||
|
- Downstream pipeline 6708 в ordinis-infra (project 368)
|
||||||
|
- Manual gate `deploy-prod` (id=30893) — played
|
||||||
|
- Helm rollout ~70s, no rollback
|
||||||
|
- Verified: `curl https://ordinis.k8s.264.nstart.cloud/api/v1/version` → `{tag: "v2.14.0", commit: "08c92627"}`
|
||||||
|
|
||||||
|
### 0.1. New design proposals (decision artifacts)
|
||||||
|
|
||||||
|
Все три ждут measurement / customer event / GPU availability перед implement:
|
||||||
|
|
||||||
|
| Design doc | Status | Effort | Prerequisite | Defer rationale |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `docs-internal/design/redis-fk-projection.md` v2.1 | CLEAR | 4-5d | Dict упрётся в latency SLA | Premature optimization, текущий read path handles RPS |
|
||||||
|
| `docs-internal/design/ai-schema-assist.md` v2 | CHOSEN (Approach C) | 3-4d | Baseline measurement 2 weeks + GPU verification | Demand evidence пока ZERO — founder-driven feature |
|
||||||
|
| `docs-internal/design/dictionary-marketplace.md` v2 | CHOSEN (Bundle hygiene C-revised) | 2-3d hard cap | Customer #2 ETA + signing key infra decision | Bundle hygiene proceeds Q3, full marketplace ждёт N=2 customers |
|
||||||
|
|
||||||
|
**Design docs moved** из `docs/design/` → `docs-internal/design/` per /docs/ scope discipline (public docs = integrator + operator + reviewer only, no engineering internals).
|
||||||
|
|
||||||
|
`/office-hours` formal docs:
|
||||||
|
- `~/.gstack/projects/claude/zimin-main-design-ai-schema-assist-20260512.md`
|
||||||
|
- `~/.gstack/projects/claude/zimin-main-design-bundle-hygiene-20260512.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Стек / архитектура (без изменений с 2026-05-07)
|
||||||
|
|
||||||
|
Backend: Java 21 + Spring Boot 3.4.2 + JPA + Liquibase + PostgreSQL 18 + PostGIS + Kafka + Caffeine + Redis. Микросервисы: ordinis-app (writer), ordinis-read-api, ordinis-projection-writer, config-server. Outbox + Kafka publisher pattern, idempotency keys, audit log, valid_from/valid_to + EXCLUSION CONSTRAINT, data_scope filtering.
|
||||||
|
|
||||||
|
Frontend: React 19 + Vite 7 + TanStack Router + TanStack Query + Tailwind v4 + Radix primitives + cmdk + sonner toaster + @phosphor-icons/react + Monaco editor (lazy) + react-day-picker (lazy). Custom UI primitives только. @nstart/ui dropped (Stage 3.9).
|
||||||
|
|
||||||
|
Auth: Keycloak OIDC + JWT scope-based RBAC. `realm_access.roles` → DataScope (PUBLIC/INTERNAL/RESTRICTED). audience optional.
|
||||||
|
|
||||||
|
Infra: cluster.264 prod / cluster.265 staging. CNPG для PostgreSQL (single-instance, daily backups к S3 через geoportal-minio). Strimzi Kafka KRaft single broker. Vault (k8s auth). Redis для projection (per-dict opt-in feature flag).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Functional coverage matrix (updated)
|
||||||
|
|
||||||
|
### 2.1. UI features ✅ live на v2.14.0
|
||||||
|
|
||||||
|
| Feature | First shipped | Polish | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Schema workflow (draft → review → publish) UI | v2.5.0-v2.8.0 (Phase 1a/b/c) | — | ✅ live |
|
||||||
|
| TimeTravel modal (changes / records / schema tabs) | v2.10.0-v2.11.0 | — | ✅ live |
|
||||||
|
| History tab + Compare modal | v2.5.0 | — | ✅ live |
|
||||||
|
| Monaco editor для schema authoring | v2.8.0 | — | ✅ live |
|
||||||
|
| /reviews tabs (Записи / Схемы) | v2.8.0 | — | ✅ live |
|
||||||
|
| Friendly error UI (QueryErrorState, 10 sites) | v2.10.0 | — | ✅ live |
|
||||||
|
| Webhook stats cards (Pending/Retry/Delivered/DLQ + success rate) | v2.9.0 | — | ✅ live |
|
||||||
|
| Webhook stats histogram (SVG, 24h/7d) | v2.14.0 | — | ✅ live |
|
||||||
|
| RecordDrawer toolbar (search полей + только заполненные + scroll-spy chip strip) | v2.5.0-v2.12.0 | active section highlight | ✅ live |
|
||||||
|
| cmdk searchable MultiSelect (webhook events) | v2.13.0 | — | ✅ live |
|
||||||
|
| DateRangePicker (audit page) | v2.13.0 | — | ✅ live |
|
||||||
|
| Dark theme sweep (bg-white → bg-surface) | v2.6.0 | — | ✅ live |
|
||||||
|
| Mobile responsive (Sheet sidebar) | previously done | — | ✅ live |
|
||||||
|
|
||||||
|
### 2.2. Backend APIs ✅ live на v2.14.0
|
||||||
|
|
||||||
|
| Endpoint group | First shipped | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Dictionary CRUD + bitemporal records | v0.x | ✅ live |
|
||||||
|
| Schema workflow API (8 endpoints + audit) | v2.2.0 | ✅ live |
|
||||||
|
| Full timeline `/changelog` с kind discrimination | v2.3.x | ✅ live |
|
||||||
|
| Diff endpoint `/changelog/{entryId}/diff` | v2.4.0 | ✅ live |
|
||||||
|
| PATCH `/drafts/{id}` (edit без recreate) | v2.7.0 | ✅ live |
|
||||||
|
| Webhook subscriptions CRUD + DLQ + replay | v1.x | ✅ live |
|
||||||
|
| **Webhook stats `/subscriptions/{id}/stats?bucketBy=hour\|day`** | v2.14.0 | ✅ live (NEW) |
|
||||||
|
|
||||||
|
### 2.3. Что НЕ shipped (design only)
|
||||||
|
|
||||||
|
| Feature | Doc | Status | Blocker |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Redis FK projection cascade invalidation | `docs/design/redis-fk-projection.md` v2.1 | CLEAR after 2 reviews | Dict latency SLA |
|
||||||
|
| AI schema assist (template + per-field LLM) | `docs/design/ai-schema-assist.md` v2 | Approach C chosen | P5 measurement + GPU |
|
||||||
|
| Bundle hygiene (manifest + publisher CLI, NO UI) | `docs/design/dictionary-marketplace.md` v2 | Approach C-revised chosen | Customer #2 ETA + signing key infra decision |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Backlog state (post-mega-sprint)
|
||||||
|
|
||||||
|
### 3.1. Active assignments (этой недели / следующих 2 weeks)
|
||||||
|
|
||||||
|
1. **P0 metric для AI schema baseline** — add `admin.schema_authoring.seconds` в `DictionaryDefinitionService.createDraft()`. Ship в v2.15.0 (~1h work). **Critical:** без этого нельзя decide P50 demand evidence для AI feature.
|
||||||
|
2. **2-weeks baseline collection** на v2.14.0 prod. После — decision: P50 > 20 min → implement AI schema assist Phase 1 (3-4d), P50 < 10 min → defer AI, ship bundle hygiene (2-3d).
|
||||||
|
3. **GPU verification** в k8s prod cluster (parallel с baseline collection). Talk to ops, confirm vLLM availability.
|
||||||
|
4. **Bundle signing key infrastructure decision** с corp ops (GitLab CI variables protected vs Vault vs HSM). Document choice.
|
||||||
|
5. **Customer #2 ETA confirm** — Альтум integration milestone, гидрометео preliminary discussion status. Informs marketplace urgency (не gates implementation though — bundle hygiene proceeds regardless).
|
||||||
|
|
||||||
|
### 3.2. Deferred (low priority)
|
||||||
|
|
||||||
|
| # | Item | Effort | Блокер |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Phase 1d Keycloak roles в `useCanReviewSchema/Publish` | ~30 мин | infra не пробросила `realm_access.roles` в JWT |
|
||||||
|
| 2 | GitHub mirror sync (`git subtree split --prefix=ordinis-admin-ui`) | 1 мин | 2 weeks v2.14.0 stability observation |
|
||||||
|
| 3 | nsi-hot.md update v2.8.0 → v2.14.0 | 10 мин | low priority maintenance |
|
||||||
|
|
||||||
|
### 3.3. Roadmap (governance / data quality / federation, 2-3 quartals)
|
||||||
|
|
||||||
|
Без изменений с 2026-05-05-state.md:
|
||||||
|
- Approval workflow v2 (multi-step approval chain, BPM Camunda integration)
|
||||||
|
- Lineage / dependency graph
|
||||||
|
- Geo first-class (bbox/polygon query)
|
||||||
|
- Cross-dictionary FK cascade rules
|
||||||
|
- Data quality rules engine (cross-field constraints, ML-anomaly detection)
|
||||||
|
- Federation MDM-кластеров (master/replica)
|
||||||
|
- Schema registry (Apicurio/Confluent) для Kafka
|
||||||
|
- Webhooks для не-Kafka consumer'ов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Open risks (updated)
|
||||||
|
|
||||||
|
1. **Customer #2 acquisition velocity** — marketplace + AI assist designs assume timeline. Если customer #2 не появляется в Q3 — design docs sit, no rework needed но opportunity cost real.
|
||||||
|
2. **P5 measurement may invalidate AI assist hypothesis.** Baseline P50 < 10 min → 30мин «admin pain» субъективна, feature defer'нется. Cost = ~1h metric work, low risk.
|
||||||
|
3. **GPU availability в prod cluster** uncertain. Если нет — AI schema assist requires either: external API за feature flag (compromise data perimeter для non-classified), procurement new GPU node (delay weeks-months), or skip feature entirely.
|
||||||
|
4. **Bundle hygiene scope creep risk.** Hard cap 2-3 days с anti-creep enforcement language — but founder discipline ultimate enforcement. PR review rule: reject additions outside «what ships» list.
|
||||||
|
5. **CHANGELOG.md bundling в frontend** (v2.14.0 «Что нового» feature) — main bundle вырос 394kb → 427kb (+33kb / +13kb gz). CHANGELOG растёт ~500 bytes per release. Через 100 releases это ~50kb extra. Acceptable now, может потребовать lazy-load или backend endpoint в будущем.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Naming / branding state
|
||||||
|
|
||||||
|
- Repo: `ordinis` (was `nsi`)
|
||||||
|
- Product display: «Ordinis» английский / «Ордин» русский (rare)
|
||||||
|
- Customer #1: ЦУОД ОДХ (anchor)
|
||||||
|
- Bundle ID convention: `<domain>.<vertical>.<bundle-name>` (e.g. `ru.cuod.dzz-ground-segment`)
|
||||||
|
- Tag scheme: semver `vMAJOR.MINOR.PATCH`, auto-bumped via semantic-release
|
||||||
|
- Prod URL: https://ordinis.k8s.264.nstart.cloud
|
||||||
|
- Swagger: https://ordinis.k8s.264.nstart.cloud/swagger-ui.html
|
||||||
|
- Docs: https://ordinis.k8s.264.nstart.cloud/docs/ (mkdocs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Workflow patterns (operational learnings, this session)
|
||||||
|
|
||||||
|
1. **GitLab deploy gate sequence:** trigger pipeline → play `trigger-deploy-prod` bridge → play downstream `deploy-prod` job. Three separate explicit confirmations.
|
||||||
|
2. **`GITLAB_HOST=git.nstart.cloud`** env var нужен для `glab api` calls (default goes к gitlab.com). `glab mr view` works without (uses git remote).
|
||||||
|
3. **Manual API pipeline trigger** для prod deploy (semantic-release bot pipelines skip CI). `glab api -X POST projects/367/pipeline?ref=v{TAG}`.
|
||||||
|
4. **Cross-model challenge** in /office-hours Phase 3.5 critical — каждый раз когда engaged surface'нул substantial issues missed by single-model reasoning. Marketplace session was strongest example: my pure-defer recommendation overcorrected, subagent caught 4 misses, final synthesis better than either pure position.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Резюме
|
||||||
|
|
||||||
|
- **Prod v2.14.0 live** на cluster.264. 6 minor versions shipped в один день (v2.8.0 → v2.14.0). 38 MRs total за 2 дня.
|
||||||
|
- **Three design proposals documented** с explicit prerequisites + measurement steps + hard caps. None implementation. Defer recommendations stand.
|
||||||
|
- **Главный риск** для дальнейшего product development — не technical, а demand signal. AI assist / marketplace требуют customer pull или measurement evidence перед dev investment.
|
||||||
|
- **Next concrete action:** ship metric `admin.schema_authoring.seconds` в v2.15.0 (~1h work), затем 2 weeks observation.
|
||||||
|
|
||||||
|
**Прогноз до next major release decision (v3 vs v2.x):** ~2-4 weeks pending baseline measurement results + customer #2 conversation outcomes.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Что нового в Ordinis
|
||||||
|
|
||||||
|
Список значимых обновлений продукта на понятном языке. Технических подробностей нет — для них есть [внутренний CHANGELOG](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/blob/main/ordinis-admin-ui/CHANGELOG.md) с привязкой к коммитам.
|
||||||
|
|
||||||
|
Обновления приходят на твой сервер автоматически при следующем deploy.
|
||||||
|
|
||||||
|
## [2.14.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.14.0) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
* **График доставки webhook'ов** — на странице webhook-подписки теперь видно историю доставок за последние 24 часа или 7 дней. Можно навести курсор на любой столбик и увидеть, сколько было успешных доставок, retry, и сколько ушло в DLQ.
|
||||||
|
|
||||||
|
## [2.13.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.13.0) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
* **Поиск в фильтре типов событий** — у webhook event-type фильтра появилось поле поиска. Можно набрать «Record» и увидеть только относящиеся типы событий, вместо ручного скроллинга через все 11.
|
||||||
|
* **Выбор диапазона дат** — в журнале аудита вместо двух отдельных полей «с» и «по» теперь один календарь, в котором можно отметить начало и конец периода. Удобнее и компактнее.
|
||||||
|
|
||||||
|
## [2.12.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.12.0) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
* **Подсветка активной секции в редакторе записи** — когда в редакторе записи прокручиваешь форму, соответствующий «таб» в верхней панели подсвечивается синим. Удобно, чтобы не теряться в длинных формах.
|
||||||
|
|
||||||
|
## [2.11.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.11.0) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
* **TimeTravel — реальные данные на момент** — во вкладках «Записи на момент» и «Структура и поля» в окне TimeTravel теперь видны фактические данные, а не заглушка «backend pending». Можно посмотреть, как выглядел справочник в любую дату из истории.
|
||||||
|
|
||||||
|
## [2.10.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.10.0) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
* **Что изменилось — вкладка с реальными изменениями** — в окне TimeTravel вкладка «Что изменилось» теперь показывает фактический diff между версиями (добавленные поля, изменённые типы, удалённые свойства), а не пустую заглушку.
|
||||||
|
|
||||||
|
### 🐛 Исправления
|
||||||
|
|
||||||
|
* **Понятные сообщения об ошибках** — вместо технического «AxiosError: Request failed with status code 503» теперь видишь нормальное «Сервер временно недоступен» с кнопкой «Повторить». Это работает для всех ошибок 4xx/5xx и проблем с сетью.
|
||||||
|
|
||||||
|
## [2.9.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.9.0) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
* **Статистика webhook-подписок** — на странице подписки появились карточки со счётчиками: в очереди, retry, доставлено, в DLQ + общий процент успешных доставок. Видно с одного взгляда, насколько здоров твой webhook.
|
||||||
|
|
||||||
|
## [2.8.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.8.0) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
* **Редактор схемы прямо в окне создания draft'а** — больше не нужно открывать отдельную форму. Окно «Создать schema draft» теперь содержит полноценный JSON-редактор (Monaco) с подсветкой синтаксиса и проверкой схемы налету.
|
||||||
|
* **Согласования — отдельные вкладки** — в разделе «Согласования» (`/reviews`) появились вкладки «Записи» и «Схемы» со счётчиками. Auto-switch на ту вкладку, где есть что согласовать.
|
||||||
|
* **Редактирование draft'а без пересоздания** — если ревьюер запросил изменения, можешь поправить schema draft inline, без удаления и создания нового. Изменения автоматически возвращают draft в статус «На доработке у автора».
|
||||||
|
* **Фильтр типов webhook-событий** — вместо ввода CSV-строкой теперь multi-select с 11 известными типами событий (`RecordCreated`, `SchemaDraftPublished` и др.).
|
||||||
|
|
||||||
|
## [2.7.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.7.0) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
* **Schema workflow Phase 1c** — окно создания schema draft, расширение раздела согласований, контроль прав доступа.
|
||||||
|
|
||||||
|
## [2.6.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.6.0) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
* **Боковая панель schema draft с действиями** — drawer показывает текущий статус draft'а и контекстные кнопки: отправить на ревью, approve, request changes, reject, publish, withdraw, edit. Видишь только те действия, которые доступны на текущем шаге.
|
||||||
|
|
||||||
|
## [2.5.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.5.0) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
* **Вкладка «История версий»** — теперь показывает реальный timeline изменений с иконками по типу события и кнопкой «Сравнить» — открывает модальное окно с before/after JSON. Раньше там была заглушка.
|
||||||
|
|
||||||
|
## [2.4.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.4.0) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
* **Полное сравнение версий схемы** — endpoint позволяет получить before/after любой пары версий схемы для конкретной записи (используется кнопкой «Сравнить» в History tab'е).
|
||||||
|
|
||||||
|
### 🐛 Исправления
|
||||||
|
|
||||||
|
* **Слайдер TimeTravel** — пометки дат больше не наезжают друг на друга при близких версиях.
|
||||||
|
* **Кнопки в редакторе записи** — кнопка «JSON» теперь видна явно (раньше скрывалась как иконка). Если у записи всего одна версия, показывается подсказка вместо disabled кнопок.
|
||||||
|
|
||||||
|
## [2.3.0-2.0.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/blob/main/CHANGELOG.md) (2026-05-12)
|
||||||
|
|
||||||
|
Большой релиз-волна: backend endpoints для полного timeline истории, schema workflow API (8 endpoints с maker-checker согласованием), Diff endpoint, тёмная тема. Подробности — в [полном CHANGELOG](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/blob/main/CHANGELOG.md).
|
||||||
+49
-7
@@ -1,16 +1,48 @@
|
|||||||
# Ordinis НСИ ЦУОД ОДХ — Integrator Guide
|
# Ordinis НСИ ЦУОД ОДХ
|
||||||
|
|
||||||
Документация для команд, интегрирующих свои сервисы с Ordinis НСИ ЦУОД.
|
Документация продукта Ordinis. Здесь — материалы для трёх ролей:
|
||||||
Сюда не входят operator runbooks и архитектурные deep-dives — те материалы
|
|
||||||
во внутренней документации проекта.
|
|
||||||
|
|
||||||
**Anchor v1 — production LIVE на cluster.142** (с 2026-05-07).
|
- **Операторы справочников** — заводят и поддерживают данные
|
||||||
|
- **Ревьюеры schema drafts** — принимают решения об изменениях схем
|
||||||
|
- **Разработчики-интеграторы** — подключают свои сервисы к API
|
||||||
|
|
||||||
|
Архитектурные deep-dive'ы, runbook'и для ops-команды и project state —
|
||||||
|
во внутренней документации проекта (не публикуется).
|
||||||
|
|
||||||
|
**Anchor v1 — production LIVE на cluster.264** (с 2026-05-07).
|
||||||
Альтум consumers подключены, blue/green cutover завершён.
|
Альтум consumers подключены, blue/green cutover завершён.
|
||||||
|
|
||||||
## С чего начать
|
## С чего начать
|
||||||
|
|
||||||
{% list tabs %}
|
{% list tabs %}
|
||||||
|
|
||||||
|
- Я оператор справочников
|
||||||
|
|
||||||
|
Заводишь записи (спутники, наземные станции, типы антенн)? Начни с
|
||||||
|
[руководства оператора](operator/index.md).
|
||||||
|
|
||||||
|
Самое полезное:
|
||||||
|
- [Создание справочника](operator/create-dictionary.md) — если справочника
|
||||||
|
ещё нет, нужно создать новый
|
||||||
|
- [Работа с записями](operator/edit-record.md) — добавить, изменить,
|
||||||
|
найти запись
|
||||||
|
- [Закрытие записи](operator/close-record.md) — корректно «удалить»,
|
||||||
|
сохранив историю
|
||||||
|
- [Разбор ошибок](operator/errors.md) — что значит сообщение, которое
|
||||||
|
вылетело
|
||||||
|
|
||||||
|
- Я ревьюер schema drafts
|
||||||
|
|
||||||
|
Принимаешь решения об изменении схем справочников? Смотри
|
||||||
|
[руководство ревьюера](reviewer/index.md) и
|
||||||
|
[пошаговую инструкцию ревью](reviewer/schema-drafts.md).
|
||||||
|
|
||||||
|
Главное помнить:
|
||||||
|
- Не ломать существующие записи (breaking changes — через миграцию)
|
||||||
|
- Не ломать downstream (Альтум, BI) — согласуй, если меняешь поле,
|
||||||
|
которое они читают
|
||||||
|
- Документируй причину approve/reject в комментарии (это аудит)
|
||||||
|
|
||||||
- Я интегрирую свой сервис
|
- Я интегрирую свой сервис
|
||||||
|
|
||||||
Начни с [обзора API](integration/api/index.md) и [авторизации](integration/api/auth.md).
|
Начни с [обзора API](integration/api/index.md) и [авторизации](integration/api/auth.md).
|
||||||
@@ -28,7 +60,7 @@
|
|||||||
- [Bundle cuod](integration/api/bundle-cuod.md) — список 40 справочников ЦУОД
|
- [Bundle cuod](integration/api/bundle-cuod.md) — список 40 справочников ЦУОД
|
||||||
- [Best practices](integration/api/best-practices.md) — частые ошибки
|
- [Best practices](integration/api/best-practices.md) — частые ошибки
|
||||||
|
|
||||||
- Я обрабатываю ошибки
|
- Я обрабатываю ошибки в интеграции
|
||||||
|
|
||||||
- [Ошибки и rate limits](integration/api/errors.md) — error codes, retry strategy
|
- [Ошибки и rate limits](integration/api/errors.md) — error codes, retry strategy
|
||||||
- [Лучшие практики](integration/api/best-practices.md) — robust integration
|
- [Лучшие практики](integration/api/best-practices.md) — robust integration
|
||||||
@@ -37,11 +69,19 @@
|
|||||||
|
|
||||||
Открой [Swagger UI](https://ordinis.k8s.264.nstart.cloud/swagger-ui.html) —
|
Открой [Swagger UI](https://ordinis.k8s.264.nstart.cloud/swagger-ui.html) —
|
||||||
там можно нажать **Authorize** → войти через Keycloak (PKCE, без secret) →
|
там можно нажать **Authorize** → войти через Keycloak (PKCE, без secret) →
|
||||||
и сразу делать "Try it out" с реальным JWT. Authorization, error codes,
|
и сразу делать «Try it out» с реальным JWT. Authorization, error codes,
|
||||||
schemas — всё через interactive UI.
|
schemas — всё через interactive UI.
|
||||||
|
|
||||||
{% endlist %}
|
{% endlist %}
|
||||||
|
|
||||||
|
## Что нового в продукте
|
||||||
|
|
||||||
|
Список значимых обновлений — в [«Что нового»](changelog.md). Обновления
|
||||||
|
автоматически приходят на твой сервер при следующем deploy.
|
||||||
|
|
||||||
|
Кнопка «Что нового» в правом верхнем углу админ-панели показывает то же
|
||||||
|
содержимое + красная точка с количеством непрочитанных релизов.
|
||||||
|
|
||||||
## Среды
|
## Среды
|
||||||
|
|
||||||
| Среда | URL | Auth | Назначение |
|
| Среда | URL | Auth | Назначение |
|
||||||
@@ -54,6 +94,8 @@
|
|||||||
|
|
||||||
- Запросы новых ролей / scope в Keycloak: `zimin.an@nstart.space`
|
- Запросы новых ролей / scope в Keycloak: `zimin.an@nstart.space`
|
||||||
- Bug reports / feature requests: через issue tracker (запросить URL у Ordinis team)
|
- Bug reports / feature requests: через issue tracker (запросить URL у Ordinis team)
|
||||||
|
- Проблема с конкретной записью или ошибкой? Сначала загляни в
|
||||||
|
[Разбор ошибок](operator/errors.md) — часто там сразу ответ
|
||||||
|
|
||||||
## Версионирование
|
## Версионирование
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Журнал аудита
|
||||||
|
|
||||||
|
Каждое изменение в системе фиксируется в **журнале аудита** — таблице,
|
||||||
|
в которой записано: кто, что, когда, что было до и после.
|
||||||
|
|
||||||
|
Доступ — в разделе **«Аудит»** в навигации (для пользователей с правом
|
||||||
|
INTERNAL и выше).
|
||||||
|
|
||||||
|
## Что попадает в журнал
|
||||||
|
|
||||||
|
- Создание, обновление, закрытие записей справочников (`CREATE` / `UPDATE` / `CLOSE`)
|
||||||
|
- Создание, обновление, удаление справочников
|
||||||
|
- Schema drafts: создание, отправка на ревью, approve, reject, publish
|
||||||
|
- Изменения настроек webhook-подписок
|
||||||
|
|
||||||
|
Технические события (запуск pod'ов, выполнение SQL миграций) — НЕ
|
||||||
|
попадают в этот аудит. Они в системных логах.
|
||||||
|
|
||||||
|
## Структура записи аудита
|
||||||
|
|
||||||
|
| Поле | Что значит |
|
||||||
|
|---|---|
|
||||||
|
| **Время** | Когда произошло событие (точное время сервера) |
|
||||||
|
| **Пользователь** | Кто сделал изменение (берётся из JWT-токена) |
|
||||||
|
| **Справочник** | Какой справочник затронут |
|
||||||
|
| **Бизнес-ключ** | Какая конкретно запись (для record-операций) |
|
||||||
|
| **Действие** | `CREATE`, `UPDATE`, `CLOSE`, `DRAFT_APPROVE` и т.п. |
|
||||||
|
| **Было / Стало** | Diff: что изменилось (для UPDATE) |
|
||||||
|
| **Trace ID** | Технический идентификатор для корреляции с логами |
|
||||||
|
|
||||||
|
## Фильтрация
|
||||||
|
|
||||||
|
В UI — toolbar с фильтрами:
|
||||||
|
- **Справочник** — конкретный или все
|
||||||
|
- **Действие** — `CREATE` / `UPDATE` / `CLOSE` и т.д.
|
||||||
|
- **Пользователь** — кто сделал
|
||||||
|
- **Бизнес-ключ** — конкретная запись
|
||||||
|
- **Период** — диапазон дат (используй DateRangePicker)
|
||||||
|
|
||||||
|
Применяется автоматически при изменении любого фильтра. URL обновляется —
|
||||||
|
можно поделиться ссылкой «аудит за вчера на ground_station».
|
||||||
|
|
||||||
|
## Что искать в аудите
|
||||||
|
|
||||||
|
### «Кто закрыл запись X?»
|
||||||
|
|
||||||
|
Фильтр: справочник = `<имя>`, бизнес-ключ = `<код>`, действие = `CLOSE`.
|
||||||
|
Видишь оператора, время, причину (если была указана).
|
||||||
|
|
||||||
|
### «Что менялось вчера в spacecraft?»
|
||||||
|
|
||||||
|
Фильтр: справочник = `spacecraft`, период = вчера. Сортировка по времени.
|
||||||
|
|
||||||
|
### «Я подозреваю некорректное изменение»
|
||||||
|
|
||||||
|
Найди запись, посмотри **diff** колонки «Было / Стало». Если изменение
|
||||||
|
действительно неверное — раскрой запись, кликни **«Восстановить эту
|
||||||
|
версию»** (создаст новую активную версию из исторической).
|
||||||
|
|
||||||
|
### «Кто часто меняет конкретный справочник?»
|
||||||
|
|
||||||
|
Фильтр: справочник + период (последний месяц). В UI отсортируй по
|
||||||
|
пользователю — видишь топ-операторов.
|
||||||
|
|
||||||
|
## Экспорт
|
||||||
|
|
||||||
|
Кнопка **«Экспортировать»** в правом верхнем — выгрузка в CSV отфильтрованных
|
||||||
|
записей. Полезно для:
|
||||||
|
- Отчётов руководству («что изменилось за квартал»)
|
||||||
|
- Compliance-проверок
|
||||||
|
- Анализа в Excel / BI
|
||||||
|
|
||||||
|
CSV содержит те же колонки, что в UI, плюс полные diff'ы в JSON-формате.
|
||||||
|
|
||||||
|
## Что НЕЛЬЗЯ найти в аудите
|
||||||
|
|
||||||
|
- **Чтения** (`READ`) — никто их не записывает (объём огромен, информативная
|
||||||
|
ценность низкая)
|
||||||
|
- **Изменения настроек системы** на уровне kubernetes / Vault / SSL —
|
||||||
|
это в системных логах ops-команды
|
||||||
|
- **Изменения вне Ordinis** — если внешняя система сделала свою копию
|
||||||
|
справочника и поменяла её, в нашем аудите этого нет
|
||||||
|
|
||||||
|
## Срок хранения
|
||||||
|
|
||||||
|
По умолчанию — **бессрочно**. Журнал растёт, но не очищается автоматически.
|
||||||
|
|
||||||
|
Если требуется retention policy (например, удалять старше 5 лет по GDPR) —
|
||||||
|
свяжись с ответственным за систему, это настраивается на уровне БД.
|
||||||
|
|
||||||
|
## Trace ID — как использовать
|
||||||
|
|
||||||
|
Если в audit-записи есть trace ID — это можно сопоставить с логами
|
||||||
|
бэкенда. Полезно когда ошибка произошла на стороне сервера и нужно
|
||||||
|
понять глубинную причину. Скопируй trace ID, отдай в support / ops.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Закрытие записи
|
||||||
|
|
||||||
|
В Ordinis нет «удалить» в привычном смысле — данные не пропадают. Вместо
|
||||||
|
этого запись **закрывается**: ей выставляется `valid_to = текущий момент`,
|
||||||
|
после чего она перестаёт считаться актуальной, но история остаётся.
|
||||||
|
|
||||||
|
## Зачем так
|
||||||
|
|
||||||
|
Bitemporal модель — два временных измерения:
|
||||||
|
- **valid_from / valid_to** — когда запись актуальна в реальном мире
|
||||||
|
- **created_at / updated_at** — когда запись была введена в систему
|
||||||
|
|
||||||
|
Это позволяет:
|
||||||
|
- Восстанавливать что было «на дату» (например, состав группировки
|
||||||
|
спутников на 2024-12-31)
|
||||||
|
- Видеть, кто и когда вносил изменения
|
||||||
|
- Не ломать downstream-системы, которые могут читать архивные данные
|
||||||
|
|
||||||
|
## Когда закрывать запись
|
||||||
|
|
||||||
|
- Спутник **выведен из эксплуатации**
|
||||||
|
- Наземная станция **демонтирована**
|
||||||
|
- Контракт **истёк**
|
||||||
|
- Тип записи **переименован** (закрыть старую, открыть новую с новым кодом)
|
||||||
|
- Дубликат **обнаружен** (оставить одну, закрыть остальные)
|
||||||
|
|
||||||
|
## Как закрыть
|
||||||
|
|
||||||
|
1. Открой запись в drawer'е
|
||||||
|
2. Кнопка **«Удалить»** в footer'е (она же — «закрыть»)
|
||||||
|
3. Опционально укажи `valid_to` — момент закрытия. По умолчанию: сейчас.
|
||||||
|
4. Подтверди
|
||||||
|
|
||||||
|
После этого:
|
||||||
|
- В UI запись по умолчанию **не отображается** (фильтр «только активные»)
|
||||||
|
- В API при `GET /records?at=YYYY-MM-DD` — отображается только если
|
||||||
|
на эту дату была активна
|
||||||
|
- История доступна через `GET /records/{id}/history`
|
||||||
|
|
||||||
|
## Как восстановить
|
||||||
|
|
||||||
|
Если запись закрыта ошибочно — открой панель **«История версий»** в
|
||||||
|
drawer'е, найди последнюю активную версию **до закрытия**, нажми
|
||||||
|
**«Восстановить как новую запись»**. Это создаст новую активную версию
|
||||||
|
с тем же бизнес-ключом, что и закрытая.
|
||||||
|
|
||||||
|
Альтернатива: создать запись с тем же бизнес-ключом заново — система
|
||||||
|
проверит, что нет пересекающихся периодов действия.
|
||||||
|
|
||||||
|
## Bulk-закрытие при cascade
|
||||||
|
|
||||||
|
Если запись X **используется** в других справочниках (через `x-references`),
|
||||||
|
система проверит ссылки:
|
||||||
|
- Если есть активные записи, ссылающиеся на X — будет предупреждение
|
||||||
|
- Можно: (а) сначала закрыть/обновить зависимые записи, (б) принять
|
||||||
|
риск orphan'ов (downstream consumers получат FK-misses)
|
||||||
|
|
||||||
|
См. [Cross-references (FK)](../integration/api/x-references.md) о том,
|
||||||
|
как зависимости валидируются на уровне API.
|
||||||
|
|
||||||
|
## Закрытие vs полное удаление
|
||||||
|
|
||||||
|
**Полное удаление записи (DELETE из БД) не поддерживается** через UI и
|
||||||
|
обычный API. Это намеренно — целостность истории важнее «чистоты».
|
||||||
|
|
||||||
|
Если ОЧЕНЬ нужно (например, по GDPR / по приказу) — администратор
|
||||||
|
выполняет это через специальную процедуру с подтверждением. Свяжись с
|
||||||
|
ответственным за систему.
|
||||||
|
|
||||||
|
## Что произойдёт при закрытии
|
||||||
|
|
||||||
|
- В БД у записи обновляется `valid_to`
|
||||||
|
- В Kafka-топик публикуется событие `RecordClosed` с указанием
|
||||||
|
`valid_to` и причины (если задана)
|
||||||
|
- В audit log пишется операция `CLOSE` с привязкой к оператору
|
||||||
|
- Downstream consumers (Альтум, BI) видят новую версию состояния
|
||||||
|
справочника на следующем чтении (или сразу через событие)
|
||||||
|
|
||||||
|
## Чек-лист перед закрытием
|
||||||
|
|
||||||
|
- [ ] Я уверен, что закрываю правильную запись (бизнес-ключ совпадает)
|
||||||
|
- [ ] Если у записи есть зависимости через FK — я их проверил
|
||||||
|
- [ ] `valid_to` соответствует реальному моменту вывода из эксплуатации
|
||||||
|
(не «сейчас» по умолчанию, если эксплуатация закончилась в прошлом)
|
||||||
|
- [ ] Я понимаю: история останется, downstream-консьюмеры узнают
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Создание справочника
|
||||||
|
|
||||||
|
Справочник — это таблица однородных записей с фиксированной схемой полей.
|
||||||
|
Прежде чем создавать его в системе, продумай: какие поля будут, какие
|
||||||
|
обязательны, какие ссылаются на другие справочники.
|
||||||
|
|
||||||
|
## Когда нужен новый справочник
|
||||||
|
|
||||||
|
- Нужны однородные данные (>5 записей со схожей структурой)
|
||||||
|
- Записи будут переиспользоваться: на них будут ссылаться другие
|
||||||
|
справочники или внешние системы
|
||||||
|
- Структура данных стабильна (поля меняются редко)
|
||||||
|
|
||||||
|
Если просто нужна пара значений «для себя» — лучше добавь поле-перечисление
|
||||||
|
(enum) в существующий справочник, чем заводи отдельный.
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 1. Открой каталог
|
||||||
|
|
||||||
|
В админ-панели — раздел «Справочники». Кнопка **«Создать справочник»** в
|
||||||
|
правом верхнем углу.
|
||||||
|
|
||||||
|
### 2. Заполни основные поля
|
||||||
|
|
||||||
|
- **Имя (name)** — машинное имя, латиница в snake_case (`spacecraft`,
|
||||||
|
`ground_station`). По этому имени справочник будет доступен в API
|
||||||
|
и других справочниках через FK.
|
||||||
|
- **Отображаемое имя** — человеческое, как видит оператор
|
||||||
|
(«Космические аппараты», «Наземные станции»).
|
||||||
|
- **Версия схемы** — `1.0.0`, повышается при изменении схемы (см.
|
||||||
|
[schema drafts](../reviewer/schema-drafts.md)).
|
||||||
|
- **Bundle** — к какому набору справочников относится (`cuod` по умолчанию).
|
||||||
|
- **Scope** — `PUBLIC` / `INTERNAL` / `RESTRICTED`. Кто сможет читать.
|
||||||
|
|
||||||
|
### 3. Опиши схему полей
|
||||||
|
|
||||||
|
Схема справочника — это **JSON Schema** (стандарт описания структуры
|
||||||
|
JSON-объектов). В редакторе (Monaco) задаёшь свойства записи.
|
||||||
|
|
||||||
|
Минимальный пример:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"x-id-source": "code",
|
||||||
|
"required": ["code"],
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"x-unique": true,
|
||||||
|
"description": "Уникальный код"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "object",
|
||||||
|
"x-localized": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Полезные аннотации
|
||||||
|
|
||||||
|
| Аннотация | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| `x-id-source: "code"` | Бизнес-ключ записи берётся из поля `code` (а не задаётся отдельно) |
|
||||||
|
| `x-unique: true` | Значение уникально по справочнику (например, нет двух записей с одинаковым `code`) |
|
||||||
|
| `x-localized: true` | Поле локализованное — хранит значения для каждого языка (`{ru: "Земля", en: "Earth"}`) |
|
||||||
|
| `x-references: "dict.field"` | Внешний ключ — ссылка на запись другого справочника |
|
||||||
|
| `description: "..."` | Подсказка для оператора, показывается в UI рядом с полем |
|
||||||
|
| `enum: ["A", "B"]` | Список допустимых значений |
|
||||||
|
| `format: "date"` / `"date-time"` | Календарный picker в форме |
|
||||||
|
|
||||||
|
#### Пример со ссылкой на другой справочник
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"x-references": "satellite_type.code",
|
||||||
|
"description": "Тип спутника"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
При вводе оператор увидит выпадающий список типов из справочника
|
||||||
|
`satellite_type`.
|
||||||
|
|
||||||
|
### 4. Опубликуй
|
||||||
|
|
||||||
|
После нажатия **«Создать»** справочник появляется в каталоге, схема
|
||||||
|
блокируется (запись валидируется по ней). Чтобы изменить схему позже —
|
||||||
|
создай [schema draft](../reviewer/schema-drafts.md) и пройди ревью.
|
||||||
|
|
||||||
|
## Что произойдёт автоматически
|
||||||
|
|
||||||
|
При создании справочника:
|
||||||
|
- В БД появляется таблица записей с этой схемой
|
||||||
|
- В REST API endpoint становится доступен: `/api/v1/dictionaries/<name>/records`
|
||||||
|
- Kafka-топик начинает получать события об изменениях записей
|
||||||
|
- Audit log начинает писать историю операций
|
||||||
|
|
||||||
|
## Чек-лист перед созданием
|
||||||
|
|
||||||
|
- [ ] Машинное имя в snake_case
|
||||||
|
- [ ] Все обязательные поля помечены в `required`
|
||||||
|
- [ ] Бизнес-ключ задан (`x-id-source` или поле в `required`)
|
||||||
|
- [ ] Локализованные поля имеют `x-localized: true`
|
||||||
|
- [ ] FK-поля указывают на существующие справочники
|
||||||
|
- [ ] Описания (`description`) даны для каждого поля — это твоя памятка
|
||||||
|
и помощь следующему оператору
|
||||||
|
|
||||||
|
## Что НЕ стоит делать
|
||||||
|
|
||||||
|
- ❌ Создавать справочник «на всякий случай» без планируемых записей
|
||||||
|
- ❌ Класть бизнес-логику в схему (валидации вида «если X то Y») — это
|
||||||
|
приведёт к жёсткой связке, тяжело менять
|
||||||
|
- ❌ Зашивать константы в `description` — описание читается оператором,
|
||||||
|
но валидацию по нему не построишь
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Работа с записями
|
||||||
|
|
||||||
|
Запись — одна строка в справочнике (например, конкретный космический
|
||||||
|
аппарат «ISS» в справочнике `spacecraft`).
|
||||||
|
|
||||||
|
## Добавить запись
|
||||||
|
|
||||||
|
1. Открой справочник в каталоге
|
||||||
|
2. Кнопка **«Создать запись»** в правом верхнем
|
||||||
|
3. Заполни поля по схеме — обязательные помечены звёздочкой
|
||||||
|
4. **Период действия** — `valid_from` / `valid_to`:
|
||||||
|
- `valid_from` — с какой даты запись актуальна (по умолчанию: сейчас)
|
||||||
|
- `valid_to` — до какой даты (по умолчанию: открыто)
|
||||||
|
5. **«Сохранить»** — запись появляется в списке
|
||||||
|
|
||||||
|
### Бизнес-ключ
|
||||||
|
|
||||||
|
Если у справочника `x-id-source: "code"`, бизнес-ключ берётся из поля
|
||||||
|
`code`. Иначе — задаётся отдельно при создании. Бизнес-ключ должен быть
|
||||||
|
уникален: попытка создать запись с уже существующим ключом → ошибка 409.
|
||||||
|
|
||||||
|
### Локализованные поля
|
||||||
|
|
||||||
|
Поля с `x-localized: true` имеют отдельные значения для каждого языка:
|
||||||
|
|
||||||
|
```
|
||||||
|
Название [ru]: Международная космическая станция
|
||||||
|
Название [en]: International Space Station
|
||||||
|
```
|
||||||
|
|
||||||
|
Если оператор ввёл только одну локаль — остальные пусты, при чтении в
|
||||||
|
другой локали покажется fallback на язык по умолчанию словаря (обычно
|
||||||
|
`ru` для ЦУОД).
|
||||||
|
|
||||||
|
### FK-поля (ссылки)
|
||||||
|
|
||||||
|
Поля с `x-references` отображаются как выпадающий список. Например, у
|
||||||
|
`spacecraft` поле `type` ссылается на `satellite_type.code` — оператор
|
||||||
|
выбирает значение из готового списка типов. Если нужный тип отсутствует —
|
||||||
|
сначала добавь его в справочник `satellite_type`, потом возвращайся.
|
||||||
|
|
||||||
|
## Редактировать запись
|
||||||
|
|
||||||
|
1. Клик по записи в списке → открывается боковая панель (drawer)
|
||||||
|
2. Меняешь нужные поля, **«Сохранить»**
|
||||||
|
3. Новая версия записи создаётся; **предыдущая остаётся в истории**
|
||||||
|
(см. [bitemporal модель](../integration/api/bitemporal.md))
|
||||||
|
|
||||||
|
История версий доступна:
|
||||||
|
- В drawer'е — кнопка **«История версий»**
|
||||||
|
- В timeline-режиме (`TimeTravel`) — слайдер по датам
|
||||||
|
|
||||||
|
### Что нельзя редактировать
|
||||||
|
|
||||||
|
- **Бизнес-ключ** — фиксирован с момента создания. Если ошибся — нужно
|
||||||
|
закрыть запись (см. [Закрытие записи](close-record.md)) и создать новую.
|
||||||
|
- **Схему** — общая для всех записей справочника, меняется только через
|
||||||
|
[schema draft](../reviewer/schema-drafts.md).
|
||||||
|
|
||||||
|
## Поиск и фильтрация
|
||||||
|
|
||||||
|
В каталоге справочника — toolbar поиска и фильтров:
|
||||||
|
|
||||||
|
- **Поиск по тексту** — ищет по бизнес-ключу и локализованным полям
|
||||||
|
- **Только заполненные** — toggle, скрывает записи где конкретное поле пустое
|
||||||
|
- **Период** — фильтр по `valid_from` / `valid_to`
|
||||||
|
- **Scope** — фильтр по уровню доступа (для администраторов)
|
||||||
|
|
||||||
|
### Глобальный поиск
|
||||||
|
|
||||||
|
В шапке (TopBar) — `⌘K` / `Ctrl+K` — поиск через все справочники по
|
||||||
|
бизнес-ключу или фрагменту названия.
|
||||||
|
|
||||||
|
## Bulk-операции
|
||||||
|
|
||||||
|
Сейчас bulk import / export — **только через API** ([интеграционный гайд](../integration/api/index.md)).
|
||||||
|
В UI массовых операций пока нет — добавятся в будущих версиях по запросам
|
||||||
|
от операторов.
|
||||||
|
|
||||||
|
## Что произойдёт при сохранении
|
||||||
|
|
||||||
|
- В БД добавляется новая версия записи
|
||||||
|
- В Kafka-топик публикуется событие `RecordCreated` или `RecordUpdated`
|
||||||
|
(потребители — Альтум, геопортал, BI — узнают об изменении в течение
|
||||||
|
секунд)
|
||||||
|
- В audit log пишется кто изменил, когда, что было до и после
|
||||||
|
- Если справочник имеет включённую Redis-проекцию — кэш обновляется
|
||||||
|
автоматически
|
||||||
|
|
||||||
|
## Типичные ошибки
|
||||||
|
|
||||||
|
См. [Разбор ошибок](errors.md). Самые частые:
|
||||||
|
|
||||||
|
- **«Бизнес-ключ уже существует»** (409) — попробуй другой `code` или
|
||||||
|
проверь, не закрыта ли существующая запись (фильтр истории)
|
||||||
|
- **«Поле X обязательно»** (422) — посмотри `required` в схеме
|
||||||
|
- **«FK не найден»** (422) — значение `x-references` указывает на запись,
|
||||||
|
которой нет; добавь в referenced справочник
|
||||||
|
- **«Период действия пересекается»** (409) — для bitemporal: нельзя
|
||||||
|
иметь две активные версии одной записи в один момент. См.
|
||||||
|
[Закрытие записи](close-record.md).
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Разбор ошибок валидации
|
||||||
|
|
||||||
|
Ordinis старается давать понятные ошибки, но иногда нужна расшифровка.
|
||||||
|
Эта страница — справочник самых частых.
|
||||||
|
|
||||||
|
## Формат ошибок
|
||||||
|
|
||||||
|
В UI ошибки появляются:
|
||||||
|
- **Инлайн под полем формы** — для конкретных полей (red text)
|
||||||
|
- **Тост в правом верхнем** — для общих ошибок (например, сеть)
|
||||||
|
- **Полноэкранный экран** — для серьёзных проблем (500 / 503)
|
||||||
|
|
||||||
|
Каждая ошибка имеет:
|
||||||
|
- **Код** (`webhook_status_invalid`, `record_not_active`) — машинный идентификатор
|
||||||
|
- **Сообщение** — человеческое описание на русском
|
||||||
|
- **Trace ID** (для серверных ошибок) — для передачи в ops
|
||||||
|
|
||||||
|
## Самые частые ошибки
|
||||||
|
|
||||||
|
### 422: «Поле X обязательно»
|
||||||
|
|
||||||
|
Что значит: схема справочника требует это поле, но ты его не заполнил.
|
||||||
|
|
||||||
|
Что делать: заполни и сохрани. Если поле кажется ненужным — это вопрос
|
||||||
|
к схеме справочника, обсуди с ревьюером (см. [schema drafts](../reviewer/schema-drafts.md)).
|
||||||
|
|
||||||
|
### 422: «Тип значения не соответствует схеме»
|
||||||
|
|
||||||
|
Что значит: например, в поле `mass_kg` (number) введён текст.
|
||||||
|
|
||||||
|
Что делать: проверь тип в hint'е под полем. Числовые поля принимают только
|
||||||
|
цифры (точка как разделитель десятых для дробей: `1234.5`).
|
||||||
|
|
||||||
|
### 422: «FK не найден»
|
||||||
|
|
||||||
|
Что значит: значение в поле со ссылкой (`x-references`) указывает на
|
||||||
|
запись, которой нет в целевом справочнике.
|
||||||
|
|
||||||
|
Что делать:
|
||||||
|
1. Открой целевой справочник (имя есть в hint'е поля)
|
||||||
|
2. Проверь, есть ли там запись с этим бизнес-ключом
|
||||||
|
3. Если нет — сначала добавь запись туда, потом возвращайся
|
||||||
|
4. Если есть, но закрыта — нужно её **активировать** или создать новую
|
||||||
|
|
||||||
|
### 409: «Бизнес-ключ уже существует»
|
||||||
|
|
||||||
|
Что значит: ты пытаешься создать запись с `code`, который уже занят
|
||||||
|
активной записью того же справочника.
|
||||||
|
|
||||||
|
Что делать:
|
||||||
|
- Выбери другой `code`
|
||||||
|
- Или: найди существующую запись и обнови её, а не создавай новую
|
||||||
|
- Или: закрой существующую (если она устарела) и создай заново
|
||||||
|
|
||||||
|
### 409: «Период действия пересекается»
|
||||||
|
|
||||||
|
Что значит: bitemporal модель не разрешает двум версиям одной записи
|
||||||
|
быть активными одновременно.
|
||||||
|
|
||||||
|
Что делать:
|
||||||
|
1. Найди активную версию того же бизнес-ключа
|
||||||
|
2. **Закрой её** (`valid_to = момент новой версии`), потом создавай новую
|
||||||
|
3. Или: укажи `valid_from` новой версии так, чтобы периоды не пересекались
|
||||||
|
|
||||||
|
См. [Закрытие записи](close-record.md).
|
||||||
|
|
||||||
|
### 403: «Нет доступа»
|
||||||
|
|
||||||
|
Что значит: у твоего пользователя нет прав на эту операцию или scope.
|
||||||
|
|
||||||
|
Что делать:
|
||||||
|
- Если справочник `RESTRICTED` — нужны права администратора, спроси у
|
||||||
|
руководителя
|
||||||
|
- Если `INTERNAL` — нужен внутренний токен (не публичный)
|
||||||
|
- Если операция «approve schema draft» — это право ревьюера, не оператора
|
||||||
|
(см. [reviewer guide](../reviewer/index.md))
|
||||||
|
|
||||||
|
### 401: «Требуется вход»
|
||||||
|
|
||||||
|
Что значит: твоя сессия истекла или нет валидного токена.
|
||||||
|
|
||||||
|
Что делать: войди заново через Keycloak. После logout / refresh всё должно
|
||||||
|
работать.
|
||||||
|
|
||||||
|
### 503: «Сервер временно недоступен»
|
||||||
|
|
||||||
|
Что значит: backend временно не отвечает (rolling restart, плановое
|
||||||
|
обслуживание, или сетевой сбой).
|
||||||
|
|
||||||
|
Что делать:
|
||||||
|
1. Подожди 30-60 секунд, нажми **«Повторить»**
|
||||||
|
2. Если повторяется > 5 минут — свяжись с ops
|
||||||
|
3. Никакие данные не пропали — сервер вернётся, твои изменения сохранены
|
||||||
|
локально пока не отправил
|
||||||
|
|
||||||
|
### Network error / «Нет соединения»
|
||||||
|
|
||||||
|
Что значит: твой браузер не может достучаться до сервера. Чаще всего
|
||||||
|
проблема в твоей сети, не в Ordinis.
|
||||||
|
|
||||||
|
Что делать:
|
||||||
|
1. Проверь соединение (открой любой другой сайт)
|
||||||
|
2. Если корпоративная сеть — проверь VPN / прокси
|
||||||
|
3. Если изменения не сохранились — они **не пропадут**, попробуй
|
||||||
|
повторить когда соединение вернётся
|
||||||
|
|
||||||
|
### «AxiosError: Request failed with status code 503» (raw)
|
||||||
|
|
||||||
|
Что значит: техническая ошибка, которая «убежала» в UI без friendly
|
||||||
|
обёртки. Это **баг UI**, не баг данных.
|
||||||
|
|
||||||
|
Что делать: сделай скриншот, передай в support с trace ID (если есть).
|
||||||
|
|
||||||
|
## Schema validation на стороне сервера
|
||||||
|
|
||||||
|
Когда ты сохраняешь запись, сервер валидирует её по JSON Schema справочника.
|
||||||
|
Если ошибка — увидишь подробности:
|
||||||
|
|
||||||
|
```
|
||||||
|
Validation failed:
|
||||||
|
- /satellite_type: must be one of ['OPERATIONAL', 'TESTING', 'DECOMMISSIONED']
|
||||||
|
- /mass_kg: must be number, got string
|
||||||
|
- /name: required property missing
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждая строка — отдельная проблема. Поправь все, сохрани заново.
|
||||||
|
|
||||||
|
## Если не помогает
|
||||||
|
|
||||||
|
1. **Скриншот** — снимок экрана со всеми видимыми деталями ошибки
|
||||||
|
2. **Trace ID** — если есть в сообщении, скопируй
|
||||||
|
3. **Время** — точное время, когда возникла
|
||||||
|
4. **Что ты делал** — последняя последовательность действий
|
||||||
|
5. Передай это команде поддержки. По этим данным они смогут восстановить
|
||||||
|
контекст через [журнал аудита](audit-log.md) и серверные логи.
|
||||||
|
|
||||||
|
## Что НЕ нужно делать
|
||||||
|
|
||||||
|
- ❌ **Очищать кэш браузера / cookies** — это сломает сессию, ошибку не уберёт
|
||||||
|
- ❌ **Открывать в инкогнито** — то же самое
|
||||||
|
- ❌ **Менять JSON вручную в DevTools** — данные не сохранятся всё равно,
|
||||||
|
но можешь напугать себя
|
||||||
|
- ❌ **Заводить дубликат записи «чтобы попробовать»** — если первая не
|
||||||
|
сохранилась, она и не сохранилась, дубликат не появится
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Руководство оператора Ordinis НСИ
|
||||||
|
|
||||||
|
Это руководство — для **операторов справочников**: тех, кто заводит и
|
||||||
|
поддерживает данные в Ordinis (космические аппараты, наземные станции,
|
||||||
|
типы антенн, операторов спутниковой связи и т.д.).
|
||||||
|
|
||||||
|
Если ты **разработчик-интегратор** (Альтум, BI, геопортал) — смотри
|
||||||
|
[интеграцию по API](../integration/api/index.md).
|
||||||
|
|
||||||
|
Если ты **ревьюер** schema drafts — смотри
|
||||||
|
[руководство ревьюера](../reviewer/index.md).
|
||||||
|
|
||||||
|
## Что внутри
|
||||||
|
|
||||||
|
- [Создание справочника](create-dictionary.md) — как добавить новый
|
||||||
|
справочник (например, «типы антенн») и описать его поля
|
||||||
|
- [Работа с записями](edit-record.md) — как добавлять, редактировать,
|
||||||
|
искать записи внутри справочника
|
||||||
|
- [Закрытие записи](close-record.md) — как корректно «удалить» запись,
|
||||||
|
сохранив историю (bitemporal модель)
|
||||||
|
- [Журнал аудита](audit-log.md) — как посмотреть, кто и когда что менял
|
||||||
|
- [Разбор ошибок](errors.md) — типичные ошибки валидации и как их понять
|
||||||
|
|
||||||
|
## Базовые понятия
|
||||||
|
|
||||||
|
**Справочник** — таблица с однородными записями (например, «спутники»,
|
||||||
|
«наземные станции», «частотные диапазоны»). У каждого справочника есть
|
||||||
|
**схема** — описание полей: какие поля, какого типа, обязательные или
|
||||||
|
нет, ссылаются ли на другие справочники.
|
||||||
|
|
||||||
|
**Запись** — одна строка в справочнике. У каждой записи есть **бизнес-ключ**
|
||||||
|
(уникальный код) и **данные** (значения полей по схеме). Также есть
|
||||||
|
**период действия** — `valid_from` / `valid_to`, в течение которого
|
||||||
|
запись считается актуальной.
|
||||||
|
|
||||||
|
**Scope** — уровень доступа к данным:
|
||||||
|
- `PUBLIC` — открыто всем
|
||||||
|
- `INTERNAL` — для внутренних потребителей
|
||||||
|
- `RESTRICTED` — только для администраторов
|
||||||
|
|
||||||
|
**Schema draft** — предложение изменить схему справочника (добавить поле,
|
||||||
|
изменить тип). Не применяется автоматически — проходит ревью, затем
|
||||||
|
публикуется.
|
||||||
|
|
||||||
|
## Какую роль ты выполняешь
|
||||||
|
|
||||||
|
| Если ты... | Иди в раздел |
|
||||||
|
|---|---|
|
||||||
|
| Создаёшь новый справочник | [Создание справочника](create-dictionary.md) |
|
||||||
|
| Добавляешь запись в существующий справочник | [Работа с записями](edit-record.md) |
|
||||||
|
| Хочешь «удалить» устаревшую запись | [Закрытие записи](close-record.md) |
|
||||||
|
| Видишь ошибку валидации, не понимаешь | [Разбор ошибок](errors.md) |
|
||||||
|
| Проверяешь, кто менял запись | [Журнал аудита](audit-log.md) |
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Руководство ревьюера Ordinis
|
||||||
|
|
||||||
|
Это руководство — для **ревьюеров schema drafts**: тех, кто принимает или
|
||||||
|
отклоняет предложения изменить схему справочников.
|
||||||
|
|
||||||
|
Если ты **оператор** (заводишь записи) — смотри
|
||||||
|
[руководство оператора](../operator/index.md).
|
||||||
|
|
||||||
|
## Когда нужен ревью
|
||||||
|
|
||||||
|
Изменения схемы справочника (добавление поля, изменение типа, новое
|
||||||
|
ограничение) **не применяются автоматически**. Оператор создаёт **schema
|
||||||
|
draft** — предложение, которое проходит через ревью:
|
||||||
|
|
||||||
|
```
|
||||||
|
DRAFT → REVIEW_PENDING → APPROVED → PUBLISHED
|
||||||
|
↘ REJECTED ↘ CHANGES_REQUESTED
|
||||||
|
↘ WITHDRAWN
|
||||||
|
```
|
||||||
|
|
||||||
|
Ты как ревьюер решаешь: безопасно ли применить это изменение? Не сломает
|
||||||
|
ли оно существующих записей? Не сломает ли консьюмеров (Альтум, BI и пр.)?
|
||||||
|
|
||||||
|
## Что внутри
|
||||||
|
|
||||||
|
- [Ревью schema drafts](schema-drafts.md) — пошаговая инструкция, как
|
||||||
|
смотреть diff, какие проверки делать, как принимать решение
|
||||||
|
|
||||||
|
## Когда чаще всего стоит REJECT или REQUEST_CHANGES
|
||||||
|
|
||||||
|
| Сценарий | Что делать |
|
||||||
|
|---|---|
|
||||||
|
| Меняется тип существующего поля (string → number) | **REJECT** — поломает существующие записи. Если очень нужно, делать через миграцию данных, не через draft |
|
||||||
|
| Удаляется обязательное поле | **REJECT** или **REQUEST_CHANGES** с альтернативой (мигрировать данные) |
|
||||||
|
| Добавляется обязательное поле без default | **REQUEST_CHANGES** — нужен default или поле должно быть опциональным |
|
||||||
|
| Добавляется FK без проверки целостности | **REQUEST_CHANGES** — попроси добавить orphan check |
|
||||||
|
| Меняется `enum` (удаляется значение) | **REQUEST_CHANGES** — закрой все записи с этим значением сначала |
|
||||||
|
| Просто добавляется опциональное поле | Обычно **APPROVE** — безопасно |
|
||||||
|
| Косметика (description, examples) | **APPROVE** — это документация |
|
||||||
|
|
||||||
|
## Когда APPROVE
|
||||||
|
|
||||||
|
- Добавление опционального поля
|
||||||
|
- Расширение `enum` (новое значение)
|
||||||
|
- Уточнение `description` / `examples`
|
||||||
|
- Добавление формата валидации (`format: date`) к ранее не-валидируемому полю,
|
||||||
|
если все существующие данные ему соответствуют
|
||||||
|
|
||||||
|
## Базовые принципы
|
||||||
|
|
||||||
|
1. **Не ломай существующие записи.** Если изменение делает 1+ запись
|
||||||
|
невалидной — это breaking change, нужна data migration.
|
||||||
|
|
||||||
|
2. **Не ломай downstream.** Если поле читают Альтум / геопортал / BI — спроси
|
||||||
|
их перед изменением имени или типа.
|
||||||
|
|
||||||
|
3. **Документируй причину.** Approve / reject комментарий — это аудит.
|
||||||
|
Через год кто-то будет смотреть «почему мы так сделали».
|
||||||
|
|
||||||
|
4. **Сомневаешься — REQUEST_CHANGES.** Это не отказ, это просьба автора
|
||||||
|
уточнить. Лучше потратить день на обсуждение, чем неделю на rollback.
|
||||||
|
|
||||||
|
## Связанные материалы
|
||||||
|
|
||||||
|
- [Обзор API](../integration/api/index.md) — чтобы понять, как изменение
|
||||||
|
отразится на потребителях
|
||||||
|
- [Bitemporal модель](../integration/api/bitemporal.md) — как работают
|
||||||
|
периоды действия (важно для миграций)
|
||||||
|
- [Cross-references](../integration/api/x-references.md) — как изменения
|
||||||
|
FK влияют на зависимые справочники
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# Ревью schema drafts
|
||||||
|
|
||||||
|
Пошаговая инструкция: как смотреть драфт, какие проверки делать, как
|
||||||
|
принимать решение.
|
||||||
|
|
||||||
|
## Где найти драфты на ревью
|
||||||
|
|
||||||
|
В навигации — раздел **«Согласования»** (`/reviews`). Вкладка **«Схемы»** —
|
||||||
|
ожидающие schema drafts (отсортированы по дате создания, старые сверху).
|
||||||
|
|
||||||
|
Также: на странице справочника, если у него **активный draft**, появляется
|
||||||
|
жёлтый банер «На согласовании: <автор>». Клик → откроется drawer с
|
||||||
|
содержимым драфта.
|
||||||
|
|
||||||
|
## Окно ревью (Schema Draft Drawer)
|
||||||
|
|
||||||
|
При открытии драфта видишь:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Согласование draft [×] │
|
||||||
|
│ автор: zimin.an · отправлен: 2026-05-12 14:00 · v1.0.0→2.0.0│
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ Комментарий автора: │
|
||||||
|
│ «Добавляем поле frequency_band — будет FK на │
|
||||||
|
│ frequency_band.code, пока опциональное чтобы не ломать │
|
||||||
|
│ существующие записи.» │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ Текущая (live) schema │ Предложено │
|
||||||
|
│ { │ { │
|
||||||
|
│ "code": ... │ "code": ..., │
|
||||||
|
│ "name": ... │ "name": ..., │
|
||||||
|
│ } │ "frequency_band": { │
|
||||||
|
│ │ "x-references": │
|
||||||
|
│ │ "frequency_band.code" │
|
||||||
|
│ │ } │
|
||||||
|
│ │ } │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ ◯ Breaking change keys: нет │
|
||||||
|
│ ✓ Все существующие записи остаются валидными │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ Комментарий ревьюера: │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ... │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ [REJECT] [REQUEST CHANGES] [APPROVE] [PUBLISH] │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что проверять — чек-лист
|
||||||
|
|
||||||
|
### 1. Понимаешь ли ты, ЧТО автор хочет сделать?
|
||||||
|
|
||||||
|
Прочти комментарий автора. Он должен объяснить:
|
||||||
|
- **Зачем** менять схему (новое требование? bug fix?)
|
||||||
|
- **Что** меняется (поле, тип, ограничение)
|
||||||
|
- **Что с существующими записями** (мигрируем? остаются как есть?)
|
||||||
|
|
||||||
|
Если непонятно — **REQUEST CHANGES** с вопросом. Не угадывай намерения.
|
||||||
|
|
||||||
|
### 2. Diff schema — что изменилось
|
||||||
|
|
||||||
|
В drawer'е — side-by-side сравнение. Смотри:
|
||||||
|
- **Новые поля** — выделены зелёным
|
||||||
|
- **Удалённые поля** — красным
|
||||||
|
- **Изменённые поля** — жёлтым (с подсветкой, что именно)
|
||||||
|
- **Breaking change keys** — система автоматически помечает: `type`,
|
||||||
|
`format`, `required`, `x-references`, `*Of`, удаления
|
||||||
|
|
||||||
|
### 3. Совместимость с существующими записями
|
||||||
|
|
||||||
|
Откладывается всё на server: при попытке `publish` сервер прогоняет все
|
||||||
|
существующие записи через новую схему и проверяет.
|
||||||
|
|
||||||
|
Но ревьюер должен **проверить руками** в простых случаях:
|
||||||
|
- Поле добавляется как **обязательное** (`required`) без default? → существующие
|
||||||
|
записи становятся невалидными
|
||||||
|
- Поле удаляется и оно **использовалось**? → потеряется данные
|
||||||
|
- Меняется `enum` (убирается значение)? → записи с этим значением станут
|
||||||
|
невалидны
|
||||||
|
|
||||||
|
Если сомневаешься — **запроси у автора**: «прогони `GET /records?validate=new-schema`
|
||||||
|
и пришли результат». Эндпоинт покажет, сколько записей сломается.
|
||||||
|
|
||||||
|
### 4. Влияние на downstream
|
||||||
|
|
||||||
|
Schema change часто требует **уведомления потребителей**:
|
||||||
|
- Альтум — если меняем поле, которое они используют в карточках
|
||||||
|
- Геопортал — если меняем geo-поля
|
||||||
|
- BI — если меняем имена / типы полей, которые ушли в их витрины
|
||||||
|
|
||||||
|
Если не уверен — **REQUEST CHANGES** с просьбой согласовать с командами.
|
||||||
|
Лучше потратить 2 дня на согласование, чем 2 дня на rollback.
|
||||||
|
|
||||||
|
### 5. Версионирование
|
||||||
|
|
||||||
|
Схема имеет `schemaVersion` (`1.0.0`, `2.0.0`). Правила:
|
||||||
|
- **Major bump** — breaking change (удаление, изменение типа,
|
||||||
|
обязательность). Все консьюмеры должны быть готовы.
|
||||||
|
- **Minor bump** — backward-compatible (добавление опциональных полей,
|
||||||
|
расширение `enum`, новые `description`).
|
||||||
|
- **Patch** — косметика (документация, examples).
|
||||||
|
|
||||||
|
Если автор предлагает minor bump для breaking change — **REQUEST CHANGES**
|
||||||
|
с просьбой обновить версию.
|
||||||
|
|
||||||
|
### 6. Тесты / валидация
|
||||||
|
|
||||||
|
Если справочник имеет sample data в bundle — после schema change нужно
|
||||||
|
проверить, что sample data всё ещё валидна. Это проверяет CI автоматически
|
||||||
|
после publish, но лучше catch'нуть раньше.
|
||||||
|
|
||||||
|
## Действия
|
||||||
|
|
||||||
|
### APPROVE
|
||||||
|
|
||||||
|
Изменение безопасное, можно публиковать. Кнопка переводит статус драфта в
|
||||||
|
`APPROVED`. **Публикация ещё не произошла** — нужен отдельный клик
|
||||||
|
«PUBLISH», который применяет схему к live справочнику.
|
||||||
|
|
||||||
|
Опциональный комментарий — почему approve. Появится в audit log.
|
||||||
|
|
||||||
|
### REJECT
|
||||||
|
|
||||||
|
Изменение неприемлемо в принципе, обсуждать нечего. Например:
|
||||||
|
- Удаляется поле, которое critical для бизнеса
|
||||||
|
- Меняется тип так, что данные потеряются необратимо
|
||||||
|
|
||||||
|
Драфт переходит в `REJECTED`. **Обязателен комментарий** — почему отказ.
|
||||||
|
Автор увидит, может создать новый драфт с учётом feedback.
|
||||||
|
|
||||||
|
### REQUEST CHANGES
|
||||||
|
|
||||||
|
Изменение в принципе ОК, но требует доработки. Например:
|
||||||
|
- Нужно изменить версию (major вместо minor)
|
||||||
|
- Нужно сделать поле опциональным с default
|
||||||
|
- Нужно добавить data migration script
|
||||||
|
|
||||||
|
Драфт переходит в `CHANGES_REQUESTED`. Автор увидит, отредактирует, отправит
|
||||||
|
заново (вернётся в `REVIEW_PENDING`). **Обязателен комментарий** — что
|
||||||
|
именно править.
|
||||||
|
|
||||||
|
### WITHDRAW
|
||||||
|
|
||||||
|
Только автор может withdraw свой драфт (если передумал). Ревьюер этого не
|
||||||
|
делает.
|
||||||
|
|
||||||
|
### PUBLISH
|
||||||
|
|
||||||
|
Только после `APPROVE`. Применяет новую схему к live справочнику:
|
||||||
|
- Обновляется `schema_definition` в БД
|
||||||
|
- Все будущие записи валидируются по новой схеме
|
||||||
|
- Существующие записи **не модифицируются** (их данные остаются как есть)
|
||||||
|
- Публикуется событие в Kafka — консьюмеры узнают о смене схемы
|
||||||
|
|
||||||
|
После publish откатить **нельзя в один клик** — нужен новый draft с
|
||||||
|
inverse change.
|
||||||
|
|
||||||
|
## Что произойдёт при APPROVE → PUBLISH
|
||||||
|
|
||||||
|
1. `dictionary_definitions.schema_json` обновляется до новой версии
|
||||||
|
2. `dictionary_definitions.schema_version` инкрементируется
|
||||||
|
3. В audit log пишется операция `SCHEMA_PUBLISHED`
|
||||||
|
4. В Kafka топик публикуется `DictionarySchemaUpdated` event с before/after
|
||||||
|
5. Redis projection (если включена) cascade-invalidates dependent projections
|
||||||
|
6. Frontend admin-ui получает push notification (через next polling) →
|
||||||
|
баннер «новая схема» появляется у активных операторов
|
||||||
|
|
||||||
|
## Типичные паттерны
|
||||||
|
|
||||||
|
### Добавление опционального поля
|
||||||
|
|
||||||
|
```
|
||||||
|
- frequency_band: {x-references: "frequency_band.code"} ← новое, опциональное
|
||||||
|
```
|
||||||
|
|
||||||
|
Обычно **APPROVE**. Существующие записи остаются валидными (поле просто
|
||||||
|
`undefined`).
|
||||||
|
|
||||||
|
### Добавление обязательного поля без default
|
||||||
|
|
||||||
|
```
|
||||||
|
- mass_kg: {type: "number"} ← новое, в required
|
||||||
|
```
|
||||||
|
|
||||||
|
Обычно **REQUEST CHANGES** — либо `default`, либо `nullable`, либо
|
||||||
|
сначала миграция данных.
|
||||||
|
|
||||||
|
### Изменение `x-references`
|
||||||
|
|
||||||
|
```
|
||||||
|
- type: {x-references: "satellite_type.code"}
|
||||||
|
+ type: {x-references: "satellite_type_v2.name"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**REQUEST CHANGES** в подавляющем большинстве случаев. Если справочник
|
||||||
|
переименован — нужно migration data, не просто schema swap.
|
||||||
|
|
||||||
|
### Расширение `enum`
|
||||||
|
|
||||||
|
```
|
||||||
|
- status: {enum: ["ACTIVE", "INACTIVE"]}
|
||||||
|
+ status: {enum: ["ACTIVE", "INACTIVE", "MAINTENANCE"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**APPROVE** — обратно совместимо.
|
||||||
|
|
||||||
|
### Сужение `enum`
|
||||||
|
|
||||||
|
```
|
||||||
|
- status: {enum: ["ACTIVE", "INACTIVE", "MAINTENANCE"]}
|
||||||
|
+ status: {enum: ["ACTIVE", "INACTIVE"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**REJECT** или **REQUEST CHANGES** — записи со значением `MAINTENANCE`
|
||||||
|
станут невалидны. Нужна data migration сначала.
|
||||||
|
|
||||||
|
### Удаление поля
|
||||||
|
|
||||||
|
```
|
||||||
|
- legacy_id: {type: "string", x-unique: true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Опасно. Спроси:
|
||||||
|
- Кто-то использует это поле в downstream?
|
||||||
|
- Что мы делаем с данными в нём?
|
||||||
|
- Не лучше ли его deprecate (оставить, не использовать)?
|
||||||
|
|
||||||
|
Обычно **REQUEST CHANGES** с обсуждением.
|
||||||
|
|
||||||
|
## Если ошибся
|
||||||
|
|
||||||
|
Ревьюеры — люди. Если approve'нул и потом понял что зря:
|
||||||
|
1. **Если PUBLISH ещё не произошёл** — попроси автора `WITHDRAW`, потом
|
||||||
|
`REJECT` или создать новый
|
||||||
|
2. **Если PUBLISH произошёл** — создай **новый schema draft** с inverse
|
||||||
|
change (откати изменение), пройди ревью заново
|
||||||
|
3. **Документируй в audit** — комментарий «откатываю, потому что X» —
|
||||||
|
важная информация для будущих ревьюеров
|
||||||
|
|
||||||
|
Это не катастрофа, но и не норма. Если ошибаешься > 1 раза в квартал —
|
||||||
|
стоит пересмотреть процедуру ревью (может, нужен mandatory peer-review).
|
||||||
+26
-1
@@ -1,10 +1,35 @@
|
|||||||
title: Ordinis НСИ ЦУОД ОДХ — Integrator Guide
|
title: Ordinis НСИ ЦУОД ОДХ
|
||||||
href: index.md
|
href: index.md
|
||||||
|
|
||||||
items:
|
items:
|
||||||
- name: Введение
|
- name: Введение
|
||||||
href: index.md
|
href: index.md
|
||||||
|
|
||||||
|
- name: Что нового
|
||||||
|
href: changelog.md
|
||||||
|
|
||||||
|
- name: Руководство оператора
|
||||||
|
items:
|
||||||
|
- name: Обзор
|
||||||
|
href: operator/index.md
|
||||||
|
- name: Создание справочника
|
||||||
|
href: operator/create-dictionary.md
|
||||||
|
- name: Работа с записями
|
||||||
|
href: operator/edit-record.md
|
||||||
|
- name: Закрытие записи
|
||||||
|
href: operator/close-record.md
|
||||||
|
- name: Журнал аудита
|
||||||
|
href: operator/audit-log.md
|
||||||
|
- name: Разбор ошибок
|
||||||
|
href: operator/errors.md
|
||||||
|
|
||||||
|
- name: Руководство ревьюера
|
||||||
|
items:
|
||||||
|
- name: Обзор
|
||||||
|
href: reviewer/index.md
|
||||||
|
- name: Ревью schema drafts
|
||||||
|
href: reviewer/schema-drafts.md
|
||||||
|
|
||||||
- name: Интеграция по API
|
- name: Интеграция по API
|
||||||
items:
|
items:
|
||||||
- name: Обзор
|
- name: Обзор
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { Link, useLocation } from '@tanstack/react-router'
|
import { Link, useLocation } from '@tanstack/react-router'
|
||||||
import { useDictionaries, useDictionaryDetail } from '@/api/queries'
|
import { useDictionaries, useDictionaryDetail } from '@/api/queries'
|
||||||
import { VersionBadge } from '@/components/version/VersionBadge'
|
import { VersionBadge } from '@/components/version/VersionBadge'
|
||||||
|
import { WhatsNewButton } from '@/components/version/WhatsNewButton'
|
||||||
import { Bell, Menu } from 'lucide-react'
|
import { Bell, Menu } from 'lucide-react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +93,7 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
<span className="hidden md:inline-flex">
|
<span className="hidden md:inline-flex">
|
||||||
<VersionBadge />
|
<VersionBadge />
|
||||||
</span>
|
</span>
|
||||||
|
<WhatsNewButton />
|
||||||
<NotificationsBell />
|
<NotificationsBell />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -103,6 +105,9 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
* (webhook errors, draft reviews, system notices). Click → drawer/popup.
|
* (webhook errors, draft reviews, system notices). Click → drawer/popup.
|
||||||
* Сейчас static bell без counter — wire'ится когда backend notifications
|
* Сейчас static bell без counter — wire'ится когда backend notifications
|
||||||
* stream появится.
|
* stream появится.
|
||||||
|
*
|
||||||
|
* <p>«Что нового» (WhatsNewButton) — adjacent button показывает release
|
||||||
|
* notes, отделён от operational notifications (разные mental models).
|
||||||
*/
|
*/
|
||||||
function NotificationsBell() {
|
function NotificationsBell() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { GiftIcon, SparkleIcon } from '@phosphor-icons/react'
|
||||||
|
import { Modal, Badge } from '@/ui'
|
||||||
|
import {
|
||||||
|
useChangelog,
|
||||||
|
readLastSeenVersion,
|
||||||
|
writeLastSeenVersion,
|
||||||
|
isVersionNewer,
|
||||||
|
type ChangelogEntry,
|
||||||
|
type ChangelogSection,
|
||||||
|
} from './useChangelog'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Что нового» — button в TopBar + modal с release notes.
|
||||||
|
*
|
||||||
|
* <p>Behavior:
|
||||||
|
* <ul>
|
||||||
|
* <li>Button показывает sparkle icon + red dot если есть unread versions
|
||||||
|
* (entries newer than {@code localStorage.ord-whatsnew-last-seen}).</li>
|
||||||
|
* <li>Click → modal с list of versions, newest first, max 10 recent.</li>
|
||||||
|
* <li>Modal open → mark latest version как seen (persisted в localStorage).</li>
|
||||||
|
* <li>Дальнейшие visits: dot не показывается пока новый release tag не shipped.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Source: CHANGELOG.md bundled через Vite {@code ?raw} import → parsed
|
||||||
|
* клиент-side. No backend changes.
|
||||||
|
*
|
||||||
|
* <p>i18n: button + modal title. Entries themselves остаются как-они есть из
|
||||||
|
* CHANGELOG (mix ru/en commit messages — это intentional, авторы commit'ов
|
||||||
|
* вписывают на родном языке).
|
||||||
|
*/
|
||||||
|
export function WhatsNewButton() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const entries = useChangelog()
|
||||||
|
|
||||||
|
// useState вместо useUnreadCount чтобы re-compute после modal close
|
||||||
|
// (localStorage write не trigger'ит React re-render).
|
||||||
|
const [lastSeen, setLastSeen] = useState<string | null>(() => readLastSeenVersion())
|
||||||
|
const unreadCount = useMemo(
|
||||||
|
() => entries.filter((e) => isVersionNewer(e.version, lastSeen)).length,
|
||||||
|
[entries, lastSeen],
|
||||||
|
)
|
||||||
|
|
||||||
|
// На modal close — persist latest version как seen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && entries.length > 0) {
|
||||||
|
const latest = entries[0].version
|
||||||
|
writeLastSeenVersion(latest)
|
||||||
|
setLastSeen(latest)
|
||||||
|
}
|
||||||
|
}, [open, entries])
|
||||||
|
|
||||||
|
// Show только 10 recent entries — older releases via «full changelog» link
|
||||||
|
const recent = entries.slice(0, 10)
|
||||||
|
|
||||||
|
const label = t('whatsnew.button', { defaultValue: 'Что нового' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className={cn(
|
||||||
|
'relative size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2',
|
||||||
|
'inline-flex items-center justify-center transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GiftIcon size={14} weight={unreadCount > 0 ? 'fill' : 'regular'} />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span
|
||||||
|
aria-label={t('whatsnew.unread', {
|
||||||
|
count: unreadCount,
|
||||||
|
defaultValue: `${unreadCount} новых`,
|
||||||
|
})}
|
||||||
|
className="absolute -top-0.5 -right-0.5 inline-flex items-center justify-center min-w-[14px] h-3.5 px-1 rounded-full bg-mars text-on-mars text-[9px] font-semibold leading-none"
|
||||||
|
>
|
||||||
|
{unreadCount > 9 ? '9+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<SparkleIcon size={18} weight="fill" className="text-accent" />
|
||||||
|
{t('whatsnew.title', { defaultValue: 'Что нового в Ordinis' })}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={t('whatsnew.description', {
|
||||||
|
defaultValue:
|
||||||
|
'Последние релизы продукта. Обновления автоматически попадают на ваш сервер при следующем deploy.',
|
||||||
|
})}
|
||||||
|
maxWidth="max-w-2xl"
|
||||||
|
>
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto -mx-1 px-1">
|
||||||
|
{recent.length === 0 ? (
|
||||||
|
<div className="text-mute text-body py-8 text-center">
|
||||||
|
{t('whatsnew.empty', {
|
||||||
|
defaultValue: 'Список релизов пока пуст.',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-5">
|
||||||
|
{recent.map((entry) => (
|
||||||
|
<li key={entry.version}>
|
||||||
|
<EntryCard
|
||||||
|
entry={entry}
|
||||||
|
isUnread={isVersionNewer(entry.version, lastSeen)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-3 border-t border-line-2 text-cap text-mute text-center">
|
||||||
|
{t('whatsnew.footer', {
|
||||||
|
defaultValue: 'Полный список изменений — см. CHANGELOG.md в репозитории.',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryCard({ entry, isUnread }: { entry: ChangelogEntry; isUnread: boolean }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border p-4 transition-colors',
|
||||||
|
isUnread
|
||||||
|
? 'border-accent/40 bg-accent-bg/40'
|
||||||
|
: 'border-line bg-surface',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline gap-2 mb-3">
|
||||||
|
<span className="text-title-sm text-ink font-semibold tabular-nums">
|
||||||
|
v{entry.version}
|
||||||
|
</span>
|
||||||
|
{entry.date && (
|
||||||
|
<span className="text-cap text-mute tabular-nums">{entry.date}</span>
|
||||||
|
)}
|
||||||
|
{isUnread && (
|
||||||
|
<Badge variant="info" className="ml-auto">
|
||||||
|
{t('whatsnew.newBadge', { defaultValue: 'новое' })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.sections.length === 0 ? (
|
||||||
|
<div className="text-cell text-mute italic">
|
||||||
|
{t('whatsnew.entryEmpty', {
|
||||||
|
defaultValue: 'Описание не указано.',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{entry.sections.map((section, i) => (
|
||||||
|
<SectionBlock key={i} section={section} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionBlock({ section }: { section: ChangelogSection }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-cap text-mute font-semibold mb-1.5 inline-flex items-center gap-1.5">
|
||||||
|
{section.emoji && <span aria-hidden>{section.emoji}</span>}
|
||||||
|
<span>{section.title}</span>
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1 ml-1">
|
||||||
|
{section.items.map((item, i) => (
|
||||||
|
<li key={i} className="text-body text-ink-2 flex items-start gap-1.5">
|
||||||
|
<span className="text-mute mt-1 shrink-0" aria-hidden>•</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
{item.scope && (
|
||||||
|
<span className="text-mono text-cap text-accent mr-1.5">{item.scope}:</span>
|
||||||
|
)}
|
||||||
|
<span>{item.description}</span>
|
||||||
|
{item.commit && item.commitUrl && (
|
||||||
|
<a
|
||||||
|
href={item.commitUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="text-mono text-cap text-mute hover:text-ink ml-1.5"
|
||||||
|
title={`Commit ${item.commit}`}
|
||||||
|
>
|
||||||
|
{item.commit.substring(0, 7)}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseChangelog, isVersionNewer } from './useChangelog'
|
||||||
|
|
||||||
|
describe('parseChangelog', () => {
|
||||||
|
it('parses single version с features section', () => {
|
||||||
|
const raw = `# Release Notes
|
||||||
|
|
||||||
|
## [2.14.0](https://example.com/compare/v2.13.0...v2.14.0) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
* **webhook:** time-series histogram stats endpoint ([5479142](https://example.com/commit/5479142))
|
||||||
|
`
|
||||||
|
const entries = parseChangelog(raw)
|
||||||
|
expect(entries).toHaveLength(1)
|
||||||
|
expect(entries[0].version).toBe('2.14.0')
|
||||||
|
expect(entries[0].date).toBe('2026-05-12')
|
||||||
|
expect(entries[0].compareUrl).toContain('compare/v2.13.0...v2.14.0')
|
||||||
|
expect(entries[0].sections).toHaveLength(1)
|
||||||
|
expect(entries[0].sections[0].emoji).toBe('✨')
|
||||||
|
expect(entries[0].sections[0].title).toBe('Features')
|
||||||
|
expect(entries[0].sections[0].items).toHaveLength(1)
|
||||||
|
expect(entries[0].sections[0].items[0].scope).toBe('webhook')
|
||||||
|
expect(entries[0].sections[0].items[0].description).toBe('time-series histogram stats endpoint')
|
||||||
|
expect(entries[0].sections[0].items[0].commit).toBe('5479142')
|
||||||
|
expect(entries[0].sections[0].items[0].commitUrl).toContain('/commit/5479142')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses multiple versions newest-first', () => {
|
||||||
|
const raw = `## [2.14.0](url1) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
* **a:** first ([abc1234](url2))
|
||||||
|
|
||||||
|
## [2.13.0](url3) (2026-05-11)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
* **b:** second ([def5678](url4))
|
||||||
|
`
|
||||||
|
const entries = parseChangelog(raw)
|
||||||
|
expect(entries.map((e) => e.version)).toEqual(['2.14.0', '2.13.0'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles section без emoji', () => {
|
||||||
|
const raw = `## [1.0.0](url) (2026-01-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* first feature
|
||||||
|
`
|
||||||
|
const entries = parseChangelog(raw)
|
||||||
|
expect(entries[0].sections[0].emoji).toBeUndefined()
|
||||||
|
expect(entries[0].sections[0].title).toBe('Features')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles item без scope', () => {
|
||||||
|
const raw = `## [1.0.0](url) (2026-01-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* description without scope
|
||||||
|
* another one ([abc1234](url2))
|
||||||
|
`
|
||||||
|
const entries = parseChangelog(raw)
|
||||||
|
const items = entries[0].sections[0].items
|
||||||
|
expect(items[0].scope).toBeUndefined()
|
||||||
|
expect(items[0].description).toBe('description without scope')
|
||||||
|
expect(items[1].scope).toBeUndefined()
|
||||||
|
expect(items[1].description).toBe('another one')
|
||||||
|
expect(items[1].commit).toBe('abc1234')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty changelog', () => {
|
||||||
|
expect(parseChangelog('')).toEqual([])
|
||||||
|
expect(parseChangelog('# Just a title\n\nNo versions yet.')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles version без date', () => {
|
||||||
|
const raw = `## [1.0.0](url)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* foo
|
||||||
|
`
|
||||||
|
const entries = parseChangelog(raw)
|
||||||
|
expect(entries[0].version).toBe('1.0.0')
|
||||||
|
expect(entries[0].date).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles version с multiple sections', () => {
|
||||||
|
const raw = `## [2.10.0](url) (2026-05-12)
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
* **timetravel:** new tab
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
* **ux:** friendly errors
|
||||||
|
`
|
||||||
|
const entries = parseChangelog(raw)
|
||||||
|
expect(entries[0].sections).toHaveLength(2)
|
||||||
|
expect(entries[0].sections[0].title).toBe('Features')
|
||||||
|
expect(entries[0].sections[1].title).toBe('Fixes')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isVersionNewer', () => {
|
||||||
|
it('returns true когда seen is null', () => {
|
||||||
|
expect(isVersionNewer('2.14.0', null)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('compares major.minor.patch correctly', () => {
|
||||||
|
expect(isVersionNewer('2.14.0', '2.13.0')).toBe(true)
|
||||||
|
expect(isVersionNewer('2.14.1', '2.14.0')).toBe(true)
|
||||||
|
expect(isVersionNewer('3.0.0', '2.99.99')).toBe(true)
|
||||||
|
expect(isVersionNewer('2.13.0', '2.14.0')).toBe(false)
|
||||||
|
expect(isVersionNewer('2.14.0', '2.14.0')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips v prefix', () => {
|
||||||
|
expect(isVersionNewer('v2.14.0', '2.13.0')).toBe(true)
|
||||||
|
expect(isVersionNewer('2.14.0', 'v2.13.0')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false для unparseable formats (treat as not newer)', () => {
|
||||||
|
expect(isVersionNewer('dev', '2.14.0')).toBe(false)
|
||||||
|
expect(isVersionNewer('2.14.0', 'dev')).toBe(false)
|
||||||
|
expect(isVersionNewer('local', 'unknown')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
// Public CHANGELOG (human-readable, без commit hashes / scope prefixes,
|
||||||
|
// curated handler-friendly text). Lives в `docs/changelog.md` чтобы single
|
||||||
|
// source of truth для public-facing changelog был один (docs build тоже
|
||||||
|
// его публикует на /docs/changelog.html через Diplodoc).
|
||||||
|
//
|
||||||
|
// Internal CHANGELOG (semantic-release auto-generated, с commit hashes для
|
||||||
|
// engineering team) — `ordinis-admin-ui/CHANGELOG.md`. Не bundled в UI.
|
||||||
|
// eslint-disable-next-line import/no-unresolved -- Vite ?raw query resolved at build
|
||||||
|
import changelogRaw from '../../../../docs/changelog.md?raw'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed CHANGELOG entry — one version block from semantic-release output.
|
||||||
|
*
|
||||||
|
* <p>Source format (semantic-release default):
|
||||||
|
* <pre>
|
||||||
|
* ## [2.14.0](https://git.nstart.cloud/.../compare/v2.13.0...v2.14.0) (2026-05-12)
|
||||||
|
*
|
||||||
|
* ### ✨ Features
|
||||||
|
*
|
||||||
|
* * **webhook:** time-series histogram stats endpoint ([5479142](...))
|
||||||
|
*
|
||||||
|
* ### 🐛 Fixes
|
||||||
|
*
|
||||||
|
* * **ux:** friendly error UI вместо raw AxiosError ([3984d99](...))
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>Parsed → structured for «What's New» modal rendering.
|
||||||
|
*/
|
||||||
|
export type ChangelogEntry = {
|
||||||
|
/** Version string без 'v' префикса (e.g. "2.14.0"). */
|
||||||
|
version: string
|
||||||
|
/** Compare link к git diff между previous и этой версии. */
|
||||||
|
compareUrl?: string
|
||||||
|
/** Release date (ISO yyyy-mm-dd) если parsed из header'а. */
|
||||||
|
date?: string
|
||||||
|
/** Категория → bullets. Категории: "Features", "Fixes", "Performance", etc. */
|
||||||
|
sections: ChangelogSection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChangelogSection = {
|
||||||
|
/** Display title без emoji (e.g. "Features"). */
|
||||||
|
title: string
|
||||||
|
/** Emoji preserved if present (e.g. "✨"). */
|
||||||
|
emoji?: string
|
||||||
|
items: ChangelogItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChangelogItem = {
|
||||||
|
/** Scope если был в commit'е (e.g. "webhook", "ui"). */
|
||||||
|
scope?: string
|
||||||
|
/** Description без scope + без commit hash link. */
|
||||||
|
description: string
|
||||||
|
/** Git commit hash (8 chars). */
|
||||||
|
commit?: string
|
||||||
|
/** Full commit URL. */
|
||||||
|
commitUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse CHANGELOG.md text → structured ChangelogEntry[].
|
||||||
|
*
|
||||||
|
* <p>Format assumptions (semantic-release default):
|
||||||
|
* <ul>
|
||||||
|
* <li>Version header: {@code ## [2.14.0](url) (yyyy-mm-dd)}</li>
|
||||||
|
* <li>Section header: {@code ### ✨ Features} (emoji optional)</li>
|
||||||
|
* <li>Item: {@code * **scope:** description ([hash](url))}</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Robust against missing parts: пустые scope, missing commit URL,
|
||||||
|
* missing date. Returns entries в порядке появления (newest first для
|
||||||
|
* semantic-release output).
|
||||||
|
*/
|
||||||
|
export function parseChangelog(raw: string): ChangelogEntry[] {
|
||||||
|
const entries: ChangelogEntry[] = []
|
||||||
|
// Split на version headers — каждый ## [x.y.z] block.
|
||||||
|
const versionRegex = /^##\s+\[([^\]]+)\](?:\(([^)]+)\))?\s*(?:\(([\d-]+)\))?/gm
|
||||||
|
const matches: { start: number; end: number; version: string; url?: string; date?: string }[] = []
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = versionRegex.exec(raw)) !== null) {
|
||||||
|
matches.push({
|
||||||
|
start: m.index,
|
||||||
|
end: m.index, // tentative, finalized below
|
||||||
|
version: m[1],
|
||||||
|
url: m[2],
|
||||||
|
date: m[3],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Finalize end indices — каждый block заканчивается где начинается следующий
|
||||||
|
for (let i = 0; i < matches.length; i++) {
|
||||||
|
matches[i].end = i + 1 < matches.length ? matches[i + 1].start : raw.length
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of matches) {
|
||||||
|
const body = raw.substring(block.start, block.end)
|
||||||
|
const sections = parseSections(body)
|
||||||
|
entries.push({
|
||||||
|
version: block.version,
|
||||||
|
compareUrl: block.url,
|
||||||
|
date: block.date,
|
||||||
|
sections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSections(body: string): ChangelogSection[] {
|
||||||
|
const sectionRegex = /^###\s+(?:(\p{Emoji_Presentation}|\p{Extended_Pictographic})\s+)?(.+)$/gmu
|
||||||
|
const sections: ChangelogSection[] = []
|
||||||
|
const matches: { start: number; end: number; emoji?: string; title: string }[] = []
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = sectionRegex.exec(body)) !== null) {
|
||||||
|
matches.push({
|
||||||
|
start: m.index,
|
||||||
|
end: m.index,
|
||||||
|
emoji: m[1],
|
||||||
|
title: m[2].trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (let i = 0; i < matches.length; i++) {
|
||||||
|
matches[i].end = i + 1 < matches.length ? matches[i + 1].start : body.length
|
||||||
|
}
|
||||||
|
for (const s of matches) {
|
||||||
|
const sectionBody = body.substring(s.start, s.end)
|
||||||
|
const items = parseItems(sectionBody)
|
||||||
|
sections.push({ title: s.title, emoji: s.emoji, items })
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEM_REGEX = /^\*\s+(?:\*\*([^:]+?):\*\*\s+)?(.+?)(?:\s+\(\[([a-f0-9]{7,})\]\(([^)]+)\)\))?\s*$/gm
|
||||||
|
|
||||||
|
function parseItems(sectionBody: string): ChangelogItem[] {
|
||||||
|
const items: ChangelogItem[] = []
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
const regex = new RegExp(ITEM_REGEX.source, ITEM_REGEX.flags)
|
||||||
|
while ((m = regex.exec(sectionBody)) !== null) {
|
||||||
|
items.push({
|
||||||
|
scope: m[1]?.trim(),
|
||||||
|
description: m[2].trim(),
|
||||||
|
commit: m[3],
|
||||||
|
commitUrl: m[4],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: returns parsed CHANGELOG entries. Memoized — parse runs once per
|
||||||
|
* page load (CHANGELOG bundled into build, не fetched runtime).
|
||||||
|
*
|
||||||
|
* <p>Entries sorted newest-first (semantic-release output order).
|
||||||
|
*/
|
||||||
|
export function useChangelog(): ChangelogEntry[] {
|
||||||
|
return useMemo(() => parseChangelog(changelogRaw), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalStorage key для tracking last-seen version. Per-user (browser-scoped).
|
||||||
|
* Если key empty / missing — пользователь никогда не открывал What's New →
|
||||||
|
* unread = ALL entries (limited к recent N в UI).
|
||||||
|
*/
|
||||||
|
const LS_KEY = 'ord-whatsnew-last-seen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns version string from localStorage, или null если не set.
|
||||||
|
*/
|
||||||
|
export function readLastSeenVersion(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LS_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist last-seen version. Called когда user открывает What's New modal
|
||||||
|
* AND видит latest entry. Throttle через just writing once per session
|
||||||
|
* (modal close persists current latest version → next session checks delta).
|
||||||
|
*/
|
||||||
|
export function writeLastSeenVersion(version: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_KEY, version)
|
||||||
|
} catch {
|
||||||
|
// localStorage может быть disabled (Safari private mode) — fail silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare versions для unread detection. Semantic versioning major.minor.patch.
|
||||||
|
* Returns true если {@code candidate} newer than {@code seen}.
|
||||||
|
*
|
||||||
|
* <p>Robust к unknown formats: «dev», «local», missing parts. Returns false
|
||||||
|
* (treat as not-newer) для unparseable.
|
||||||
|
*/
|
||||||
|
export function isVersionNewer(candidate: string, seen: string | null): boolean {
|
||||||
|
if (!seen) return true // нет baseline → все entries unread
|
||||||
|
const parse = (v: string): number[] | null => {
|
||||||
|
const m = v.match(/^v?(\d+)\.(\d+)\.(\d+)/)
|
||||||
|
if (!m) return null
|
||||||
|
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
|
||||||
|
}
|
||||||
|
const a = parse(candidate)
|
||||||
|
const b = parse(seen)
|
||||||
|
if (!a || !b) return false
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (a[i] > b[i]) return true
|
||||||
|
if (a[i] < b[i]) return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute count of entries newer than last seen — для notification dot
|
||||||
|
* на «What's New» button.
|
||||||
|
*/
|
||||||
|
export function useUnreadCount(): number {
|
||||||
|
const entries = useChangelog()
|
||||||
|
return useMemo(() => {
|
||||||
|
const seen = readLastSeenVersion()
|
||||||
|
return entries.filter((e) => isVersionNewer(e.version, seen)).length
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- seen из localStorage, не trigger'ит re-render автоматически
|
||||||
|
}, [entries])
|
||||||
|
}
|
||||||
@@ -24,6 +24,18 @@ i18n
|
|||||||
'nav.section.admin': 'Администрирование',
|
'nav.section.admin': 'Администрирование',
|
||||||
'topbar.search.label': 'Поиск',
|
'topbar.search.label': 'Поиск',
|
||||||
'topbar.search.placeholder': 'Поиск по справочникам, ⌘K',
|
'topbar.search.placeholder': 'Поиск по справочникам, ⌘K',
|
||||||
|
'topbar.notifications': 'Уведомления',
|
||||||
|
'whatsnew.button': 'Что нового',
|
||||||
|
'whatsnew.title': 'Что нового в Ordinis',
|
||||||
|
'whatsnew.description': 'Последние релизы продукта. Обновления автоматически попадают на ваш сервер при следующем deploy.',
|
||||||
|
'whatsnew.unread_one': '{{count}} новое',
|
||||||
|
'whatsnew.unread_few': '{{count}} новых',
|
||||||
|
'whatsnew.unread_many': '{{count}} новых',
|
||||||
|
'whatsnew.unread_other': '{{count}} новых',
|
||||||
|
'whatsnew.newBadge': 'новое',
|
||||||
|
'whatsnew.empty': 'Список релизов пока пуст.',
|
||||||
|
'whatsnew.entryEmpty': 'Описание не указано.',
|
||||||
|
'whatsnew.footer': 'Полный список изменений — см. CHANGELOG.md в репозитории.',
|
||||||
'graph.title': 'Граф связей справочников',
|
'graph.title': 'Граф связей справочников',
|
||||||
'graph.description': 'Сетевая диаграмма всех справочников и их FK-связей. Кликните по узлу — откроется детальный вид.',
|
'graph.description': 'Сетевая диаграмма всех справочников и их FK-связей. Кликните по узлу — откроется детальный вид.',
|
||||||
'graph.comingSoon.title': 'Скоро',
|
'graph.comingSoon.title': 'Скоро',
|
||||||
@@ -734,6 +746,16 @@ i18n
|
|||||||
'nav.section.admin': 'Admin',
|
'nav.section.admin': 'Admin',
|
||||||
'topbar.search.label': 'Search',
|
'topbar.search.label': 'Search',
|
||||||
'topbar.search.placeholder': 'Search dictionaries, ⌘K',
|
'topbar.search.placeholder': 'Search dictionaries, ⌘K',
|
||||||
|
'topbar.notifications': 'Notifications',
|
||||||
|
'whatsnew.button': "What's new",
|
||||||
|
'whatsnew.title': "What's new in Ordinis",
|
||||||
|
'whatsnew.description': 'Latest releases. Updates land on your server with the next deploy.',
|
||||||
|
'whatsnew.unread_one': '{{count}} new',
|
||||||
|
'whatsnew.unread_other': '{{count}} new',
|
||||||
|
'whatsnew.newBadge': 'new',
|
||||||
|
'whatsnew.empty': 'No releases yet.',
|
||||||
|
'whatsnew.entryEmpty': 'No description.',
|
||||||
|
'whatsnew.footer': 'Full changelog — see CHANGELOG.md in repository.',
|
||||||
'graph.title': 'Dictionary relations graph',
|
'graph.title': 'Dictionary relations graph',
|
||||||
'graph.description': 'Network diagram of all dictionaries and their FK links. Click a node for detail.',
|
'graph.description': 'Network diagram of all dictionaries and their FK links. Click a node for detail.',
|
||||||
'graph.comingSoon.title': 'Coming soon',
|
'graph.comingSoon.title': 'Coming soon',
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
fs: {
|
||||||
|
// Allow importing files outside admin-ui (e.g. ../docs/changelog.md
|
||||||
|
// для WhatsNewButton — single source of truth для public changelog
|
||||||
|
// живёт в docs/ чтобы mkdocs тоже его publish'ил).
|
||||||
|
allow: ['..'],
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.VITE_API_PROXY ?? 'http://localhost:8080',
|
target: process.env.VITE_API_PROXY ?? 'http://localhost:8080',
|
||||||
|
|||||||
Reference in New Issue
Block a user