diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts
index 57409f8..c3f2234 100644
--- a/ordinis-admin-ui/src/api/client.ts
+++ b/ordinis-admin-ui/src/api/client.ts
@@ -213,7 +213,26 @@ export type BulkCloseResponse = {
errors: { businessKey: string; reason: string }[]
}
-export type AuditAction = 'CREATE' | 'UPDATE' | 'CLOSE'
+/**
+ * Все backend audit action types — должны matchить AuditLogger.write(...) +
+ * SchemaDraftService.applyDecision(...) + DraftService.emitDraftEvent(...).
+ * QA report BUG-004: фильтр в audit предлагал только 3 (CREATE/UPDATE/CLOSE),
+ * остальные 8 не были фильтруемы.
+ */
+export type AuditAction =
+ | 'CREATE'
+ | 'UPDATE'
+ | 'CLOSE'
+ | 'DEFINITION_CREATE'
+ | 'DEFINITION_UPDATE'
+ | 'DRAFT_CREATE'
+ | 'DRAFT_SUBMIT'
+ | 'DRAFT_REVIEW'
+ | 'DRAFT_APPROVE'
+ | 'DRAFT_REJECT'
+ | 'DRAFT_CHANGES_REQUESTED'
+ | 'DRAFT_WITHDRAW'
+ | 'DRAFT_PUBLISH'
export type AuditEntry = {
id: number
diff --git a/ordinis-admin-ui/src/components/notifications/NotificationsDrawer.tsx b/ordinis-admin-ui/src/components/notifications/NotificationsDrawer.tsx
index 03bb946..5c7373d 100644
--- a/ordinis-admin-ui/src/components/notifications/NotificationsDrawer.tsx
+++ b/ordinis-admin-ui/src/components/notifications/NotificationsDrawer.tsx
@@ -220,17 +220,21 @@ function badgeVariant(
* Event type → short russian label для chip. Server-side resolution лучше,
* но пока mapping inline здесь — backend payloadPreview уже несёт полный
* человеческий текст, chip это просто категория.
+ *
+ *
UX-005 fix: были английские labels (submit/approved/rejected) — chip
+ * читается носителями русского как «Draft approved 15.05.2026», что выглядит
+ * как baked-in EN текст. Теперь короткие русские.
*/
function humanEventType(eventType: string): string {
const map: Record = {
- RecordDraftSubmitted: 'submit',
- RecordDraftApproved: 'approved',
- RecordDraftRejected: 'rejected',
- RecordDraftWithdrawn: 'withdrawn',
- SchemaDraftSubmitted: 'schema submit',
- SchemaDraftApproved: 'schema approved',
- SchemaDraftRejected: 'schema rejected',
- SchemaDraftPublished: 'schema published',
+ RecordDraftSubmitted: 'отправлен',
+ RecordDraftApproved: 'одобрен',
+ RecordDraftRejected: 'отклонён',
+ RecordDraftWithdrawn: 'отозван',
+ SchemaDraftSubmitted: 'схема: отправлена',
+ SchemaDraftApproved: 'схема: одобрена',
+ SchemaDraftRejected: 'схема: отклонена',
+ SchemaDraftPublished: 'схема: опубликована',
}
return map[eventType] ?? eventType
}
diff --git a/ordinis-admin-ui/src/components/version/WhatsNewModal.tsx b/ordinis-admin-ui/src/components/version/WhatsNewModal.tsx
index 134e823..6977075 100644
--- a/ordinis-admin-ui/src/components/version/WhatsNewModal.tsx
+++ b/ordinis-admin-ui/src/components/version/WhatsNewModal.tsx
@@ -1,3 +1,4 @@
+import type * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SparkleIcon } from '@phosphor-icons/react'
@@ -136,6 +137,32 @@ function EntryCard({ entry, isUnread }: { entry: ChangelogEntry; isUnread: boole
)
}
+/**
+ * Mini inline-markdown renderer для changelog описаний. UX-006: тексты
+ * вроде «**График доставки webhook'ов**» рендерились с буквальными
+ * звёздочками. Pulling full markdown library overkill для трёх правил.
+ *
+ * Поддерживает: {@code **bold**}, {@code *italic*}, {@code `code`}.
+ * Stops parsing на первом неподдержанном паттерне — текст остаётся как есть.
+ */
+function renderInlineMarkdown(text: string): React.ReactNode {
+ const parts: React.ReactNode[] = []
+ // Pattern matches **bold**, `code`, или *italic* (lazy, non-greedy).
+ const pattern = /(\*\*([^*\n]+?)\*\*|`([^`\n]+?)`|\*([^*\n]+?)\*)/g
+ let lastIndex = 0
+ let key = 0
+ let m: RegExpExecArray | null
+ while ((m = pattern.exec(text)) !== null) {
+ if (m.index > lastIndex) parts.push(text.slice(lastIndex, m.index))
+ if (m[2] !== undefined) parts.push({m[2]})
+ else if (m[3] !== undefined) parts.push({m[3]})
+ else if (m[4] !== undefined) parts.push({m[4]})
+ lastIndex = pattern.lastIndex
+ }
+ if (lastIndex < text.length) parts.push(text.slice(lastIndex))
+ return parts.length > 0 ? parts : text
+}
+
function SectionBlock({ section }: { section: ChangelogSection }) {
return (
@@ -151,7 +178,7 @@ function SectionBlock({ section }: { section: ChangelogSection }) {
{item.scope && (
{item.scope}:
)}
-
{item.description}
+
{renderInlineMarkdown(item.description)}
{item.commit && item.commitUrl && (
rootRouteImport,
} as any)
+const DraftsRoute = DraftsRouteImport.update({
+ id: '/drafts',
+ path: '/drafts',
+ getParentRoute: () => rootRouteImport,
+} as any)
const DictionariesRoute = DictionariesRouteImport.update({
id: '/dictionaries',
path: '/dictionaries',
@@ -100,6 +106,7 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren
+ '/drafts': typeof DraftsRoute
'/graph': typeof GraphRoute
'/my-drafts': typeof MyDraftsRoute
'/outbox': typeof OutboxRoute
@@ -115,6 +122,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/audit': typeof AuditRoute
+ '/drafts': typeof DraftsRoute
'/graph': typeof GraphRoute
'/my-drafts': typeof MyDraftsRoute
'/outbox': typeof OutboxRoute
@@ -131,6 +139,7 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren
+ '/drafts': typeof DraftsRoute
'/graph': typeof GraphRoute
'/my-drafts': typeof MyDraftsRoute
'/outbox': typeof OutboxRoute
@@ -149,6 +158,7 @@ export interface FileRouteTypes {
| '/'
| '/audit'
| '/dictionaries'
+ | '/drafts'
| '/graph'
| '/my-drafts'
| '/outbox'
@@ -164,6 +174,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/audit'
+ | '/drafts'
| '/graph'
| '/my-drafts'
| '/outbox'
@@ -179,6 +190,7 @@ export interface FileRouteTypes {
| '/'
| '/audit'
| '/dictionaries'
+ | '/drafts'
| '/graph'
| '/my-drafts'
| '/outbox'
@@ -196,6 +208,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuditRoute: typeof AuditRoute
DictionariesRoute: typeof DictionariesRouteWithChildren
+ DraftsRoute: typeof DraftsRoute
GraphRoute: typeof GraphRoute
MyDraftsRoute: typeof MyDraftsRoute
OutboxRoute: typeof OutboxRoute
@@ -249,6 +262,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof GraphRouteImport
parentRoute: typeof rootRouteImport
}
+ '/drafts': {
+ id: '/drafts'
+ path: '/drafts'
+ fullPath: '/drafts'
+ preLoaderRoute: typeof DraftsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/dictionaries': {
id: '/dictionaries'
path: '/dictionaries'
@@ -340,6 +360,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuditRoute: AuditRoute,
DictionariesRoute: DictionariesRouteWithChildren,
+ DraftsRoute: DraftsRoute,
GraphRoute: GraphRoute,
MyDraftsRoute: MyDraftsRoute,
OutboxRoute: OutboxRoute,
diff --git a/ordinis-admin-ui/src/routes/audit.tsx b/ordinis-admin-ui/src/routes/audit.tsx
index 61cd2b1..948d07f 100644
--- a/ordinis-admin-ui/src/routes/audit.tsx
+++ b/ordinis-admin-ui/src/routes/audit.tsx
@@ -32,7 +32,25 @@ import { UserCell } from '@/lib/useUserDisplay'
import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client'
import { localTzOffset, parseFormDate } from '@/lib/dates'
-const ACTIONS: AuditAction[] = ['CREATE', 'UPDATE', 'CLOSE']
+// Все 12 audit action types — должны matchить backend (AuditLogger,
+// SchemaDraftService.applyDecision, DraftService.emitDraftEvent).
+// QA report BUG-004: фильтр предлагал только CREATE/UPDATE/CLOSE → DRAFT_*
+// и DEFINITION_* events не были фильтруемы через UI.
+const ACTIONS: AuditAction[] = [
+ 'CREATE',
+ 'UPDATE',
+ 'CLOSE',
+ 'DEFINITION_CREATE',
+ 'DEFINITION_UPDATE',
+ 'DRAFT_CREATE',
+ 'DRAFT_SUBMIT',
+ 'DRAFT_REVIEW',
+ 'DRAFT_APPROVE',
+ 'DRAFT_REJECT',
+ 'DRAFT_CHANGES_REQUESTED',
+ 'DRAFT_WITHDRAW',
+ 'DRAFT_PUBLISH',
+]
const ACTION_VALUES: ReadonlySet = new Set(ACTIONS)
const PAGE_SIZE_OPTIONS = [
diff --git a/ordinis-admin-ui/src/routes/drafts.tsx b/ordinis-admin-ui/src/routes/drafts.tsx
new file mode 100644
index 0000000..48b8292
--- /dev/null
+++ b/ordinis-admin-ui/src/routes/drafts.tsx
@@ -0,0 +1,17 @@
+import { createFileRoute, redirect } from '@tanstack/react-router'
+
+/**
+ * Legacy redirect: /drafts → /my-drafts.
+ *
+ * QA report BUG-003: пользователи интуитивно набирают /drafts, получают
+ * наг 404 «Not Found» plain text. Каноничный роут /my-drafts (имя содержит
+ * «my» чтобы отличать от reviews — pool drafts от других makers).
+ *
+ *
Static redirect (beforeLoad throws redirect) — никакого component'а
+ * не рендерится, чтобы избежать flash empty page между navigation steps.
+ */
+export const Route = createFileRoute('/drafts')({
+ beforeLoad: () => {
+ throw redirect({ to: '/my-drafts' })
+ },
+})
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/VersionController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/VersionController.java
index b188f23..ece8f2b 100644
--- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/VersionController.java
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/VersionController.java
@@ -1,6 +1,7 @@
package cloud.nstart.terravault.ordinis.restapi.web;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.info.BuildProperties;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
@@ -9,6 +10,8 @@ import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
/**
* {@code GET /api/v1/version} — экспонирует server build identity для UI
@@ -39,29 +42,73 @@ import java.util.Optional;
public class VersionController {
private final Optional buildProps;
+ private final String imageTagEnv;
+ /**
+ * Image tag from helm-time env (`ORDINIS_BUILD_IMAGE_TAG`). Format: docker
+ * tag like `v2-31-2-e3c82775`. Maven build runs на main commit (без git tag),
+ * поэтому build-info build.tag всегда empty — в k8s deploy реальный tag
+ * приходит только из helm chart, не из jar metadata.
+ *
+ * Empty default → fallback на BuildProperties.tag (тоже empty в проде,
+ * но valid в локальном dev если кто-то билдит на git tag).
+ */
// BuildProperties может отсутствовать в dev — оптимально через Optional.
- public VersionController(@Autowired(required = false) BuildProperties buildProps) {
+ public VersionController(
+ @Autowired(required = false) BuildProperties buildProps,
+ @Value("${ordinis.build.image-tag:}") String imageTagEnv) {
this.buildProps = Optional.ofNullable(buildProps);
+ this.imageTagEnv = imageTagEnv == null ? "" : imageTagEnv;
}
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public VersionInfo current() {
+ String resolvedTag = resolveTag();
return buildProps.map(b -> new VersionInfo(
b.getVersion(),
Optional.ofNullable(b.get("commit")).orElse("unknown"),
Optional.ofNullable(b.get("branch")).orElse(""),
- Optional.ofNullable(b.get("tag")).orElse(""),
+ resolvedTag,
b.getTime() != null ? b.getTime().toString() : ""
)).orElseGet(() -> new VersionInfo(
"0.0.0-dev",
"unknown",
"",
- "",
+ resolvedTag,
Instant.now().toString()
));
}
+ /**
+ * Resolve human-readable semver tag из docker image tag.
+ *
+ *
Helm passes ROW image.tag like {@code v2-31-2-e3c82775}. Frontend
+ * хочет {@code v2.31.2}. Strips trailing {@code -{8-12 hex}} commit suffix
+ * и заменяет dashes между digits на dots.
+ *
+ *
Examples:
+ *
+ * - {@code v2-31-2-e3c82775} → {@code v2.31.2}
+ * - {@code v2-31-2} → {@code v2.31.2}
+ * - {@code latest} → {@code latest} (passes через без транформации)
+ * - {@code main-abc1234} → {@code main} (branch tag, strips sha)
+ * - empty/null → BuildProperties fallback → empty
+ *
+ */
+ private static final Pattern SHA_SUFFIX = Pattern.compile("-[0-9a-f]{7,12}$");
+
+ String resolveTag() {
+ String raw = imageTagEnv;
+ if (raw == null || raw.isBlank() || raw.equals("latest")) {
+ return buildProps.map(b -> Optional.ofNullable(b.get("tag")).orElse("")).orElse("");
+ }
+ // Strip trailing -{sha} commit suffix
+ Matcher m = SHA_SUFFIX.matcher(raw);
+ String stripped = m.find() ? raw.substring(0, m.start()) : raw;
+ // Convert v2-31-2 → v2.31.2 (only between digits to preserve branch names)
+ return stripped.replaceAll("(?<=\\d)-(?=\\d)", ".");
+ }
+
/**
* Stable JSON shape для UI client.
*