From ff7e72e0fd3df32f96a39965a4832efa6602b080 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Fri, 15 May 2026 22:02:29 +0300 Subject: [PATCH] fix(qa-report): version tag, /drafts redirect, audit filter complete, i18n cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA report v2.31.x batch: - **VersionController.tag**: было 'none' (maven build на main без git tag) → UI показывал «dev · 6077d4df». Helm передаёт image.tag через env ORDINIS_BUILD_IMAGE_TAG, controller парсит «v2-31-2-e3c82775» → «v2.31.2». Helm chart change в ordinis-infra (separate MR). - **/drafts redirect** (BUG-003): пустой 404 «Not Found» plain text → теперь static redirect на /my-drafts. - **Audit action filter** (BUG-004): дропдаун содержал только 3 значения (CREATE/UPDATE/CLOSE), но в таблице 11 типов (DRAFT_*, DEFINITION_*). Frontend AuditAction enum + ACTIONS list синхронизированы с backend AuditLogger/SchemaDraftService/DraftService. - **i18n cleanup** (BUG-006/007/UX-005): - myDrafts.description: «Все ваши submissions с pending/approved...» → «Все ваши черновики с историей: ожидает рассмотрения, одобрен...» - reviews.description: «Pending drafts от makers, ждут approve/reject» → «Черновики от авторов, ждут одобрения или отклонения. Общая очередь...» - reviews.queueTotal: «N pending drafts» → «N черновиков в очереди» - myDrafts.col.reviewer: «Reviewer» → «Рецензент» (RU only) - notifications chip labels: 'submit/approved/rejected' → 'отправлен/ одобрен/отклонён' - audit.action.* expanded к 13 entries для каждого backend action type, с понятными лейблами «Черновик одобрен» вместо «DRAFT_APPROVE» - **WhatsNewModal markdown** (UX-006): тексты вроде «**График доставки webhook'ов**» рендерились с буквальными звёздочками. Mini inline renderer для **bold**, *italic*, `code` — без full markdown library. --- ordinis-admin-ui/src/api/client.ts | 21 +++++++- .../notifications/NotificationsDrawer.tsx | 20 ++++--- .../src/components/version/WhatsNewModal.tsx | 29 +++++++++- ordinis-admin-ui/src/i18n.ts | 48 ++++++++++++----- ordinis-admin-ui/src/routeTree.gen.ts | 21 ++++++++ ordinis-admin-ui/src/routes/audit.tsx | 20 ++++++- ordinis-admin-ui/src/routes/drafts.tsx | 17 ++++++ .../restapi/web/VersionController.java | 53 +++++++++++++++++-- 8 files changed, 201 insertions(+), 28 deletions(-) create mode 100644 ordinis-admin-ui/src/routes/drafts.tsx 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. *