Merge branch 'fix/qa-report-i18n-batch' into 'main'
fix(qa-report): version tag display, /drafts redirect, audit filter, i18n cleanup See merge request 2-6/2-6-4/terravault/ordinis!226
This commit is contained in:
@@ -213,7 +213,26 @@ export type BulkCloseResponse = {
|
|||||||
errors: { businessKey: string; reason: string }[]
|
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 = {
|
export type AuditEntry = {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -220,17 +220,21 @@ function badgeVariant(
|
|||||||
* Event type → short russian label для chip. Server-side resolution лучше,
|
* Event type → short russian label для chip. Server-side resolution лучше,
|
||||||
* но пока mapping inline здесь — backend payloadPreview уже несёт полный
|
* но пока mapping inline здесь — backend payloadPreview уже несёт полный
|
||||||
* человеческий текст, chip это просто категория.
|
* человеческий текст, chip это просто категория.
|
||||||
|
*
|
||||||
|
* <p>UX-005 fix: были английские labels (submit/approved/rejected) — chip
|
||||||
|
* читается носителями русского как «Draft approved 15.05.2026», что выглядит
|
||||||
|
* как baked-in EN текст. Теперь короткие русские.
|
||||||
*/
|
*/
|
||||||
function humanEventType(eventType: string): string {
|
function humanEventType(eventType: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
RecordDraftSubmitted: 'submit',
|
RecordDraftSubmitted: 'отправлен',
|
||||||
RecordDraftApproved: 'approved',
|
RecordDraftApproved: 'одобрен',
|
||||||
RecordDraftRejected: 'rejected',
|
RecordDraftRejected: 'отклонён',
|
||||||
RecordDraftWithdrawn: 'withdrawn',
|
RecordDraftWithdrawn: 'отозван',
|
||||||
SchemaDraftSubmitted: 'schema submit',
|
SchemaDraftSubmitted: 'схема: отправлена',
|
||||||
SchemaDraftApproved: 'schema approved',
|
SchemaDraftApproved: 'схема: одобрена',
|
||||||
SchemaDraftRejected: 'schema rejected',
|
SchemaDraftRejected: 'схема: отклонена',
|
||||||
SchemaDraftPublished: 'schema published',
|
SchemaDraftPublished: 'схема: опубликована',
|
||||||
}
|
}
|
||||||
return map[eventType] ?? eventType
|
return map[eventType] ?? eventType
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type * as React from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { SparkleIcon } from '@phosphor-icons/react'
|
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 для трёх правил.
|
||||||
|
*
|
||||||
|
* <p>Поддерживает: {@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(<strong key={key++}>{m[2]}</strong>)
|
||||||
|
else if (m[3] !== undefined) parts.push(<code key={key++} className="text-mono text-cap bg-surface-2 px-1 rounded-sm">{m[3]}</code>)
|
||||||
|
else if (m[4] !== undefined) parts.push(<em key={key++}>{m[4]}</em>)
|
||||||
|
lastIndex = pattern.lastIndex
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) parts.push(text.slice(lastIndex))
|
||||||
|
return parts.length > 0 ? parts : text
|
||||||
|
}
|
||||||
|
|
||||||
function SectionBlock({ section }: { section: ChangelogSection }) {
|
function SectionBlock({ section }: { section: ChangelogSection }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -151,7 +178,7 @@ function SectionBlock({ section }: { section: ChangelogSection }) {
|
|||||||
{item.scope && (
|
{item.scope && (
|
||||||
<span className="text-mono text-cap text-accent mr-1.5">{item.scope}:</span>
|
<span className="text-mono text-cap text-accent mr-1.5">{item.scope}:</span>
|
||||||
)}
|
)}
|
||||||
<span>{item.description}</span>
|
<span>{renderInlineMarkdown(item.description)}</span>
|
||||||
{item.commit && item.commitUrl && (
|
{item.commit && item.commitUrl && (
|
||||||
<a
|
<a
|
||||||
href={item.commitUrl}
|
href={item.commitUrl}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ i18n
|
|||||||
'nav.reviews': 'Согласования',
|
'nav.reviews': 'Согласования',
|
||||||
'nav.myDrafts': 'Мои черновики',
|
'nav.myDrafts': 'Мои черновики',
|
||||||
'myDrafts.title': 'Мои черновики',
|
'myDrafts.title': 'Мои черновики',
|
||||||
'myDrafts.description': 'Все ваши submissions с историей: pending, approved, rejected, withdrawn.',
|
'myDrafts.description': 'Все ваши черновики с историей: ожидает рассмотрения, одобрен, отклонён, отозван.',
|
||||||
'myDrafts.empty': 'Вы пока не отправляли черновиков.',
|
'myDrafts.empty': 'Вы пока не отправляли черновиков.',
|
||||||
'myDrafts.total_one': '{{count}} черновик',
|
'myDrafts.total_one': '{{count}} черновик',
|
||||||
'myDrafts.total_few': '{{count}} черновика',
|
'myDrafts.total_few': '{{count}} черновика',
|
||||||
@@ -66,18 +66,18 @@ i18n
|
|||||||
'myDrafts.col.status': 'Статус',
|
'myDrafts.col.status': 'Статус',
|
||||||
'myDrafts.col.submitted': 'Отправлено',
|
'myDrafts.col.submitted': 'Отправлено',
|
||||||
'myDrafts.col.reviewed': 'Решено',
|
'myDrafts.col.reviewed': 'Решено',
|
||||||
'myDrafts.col.reviewer': 'Reviewer',
|
'myDrafts.col.reviewer': 'Рецензент',
|
||||||
'myDrafts.col.comment': 'Комментарий',
|
'myDrafts.col.comment': 'Комментарий',
|
||||||
'myDrafts.col.actions': 'Действия',
|
'myDrafts.col.actions': 'Действия',
|
||||||
'myDrafts.action.withdraw': 'Отозвать',
|
'myDrafts.action.withdraw': 'Отозвать',
|
||||||
'myDrafts.confirmWithdraw': 'Отозвать draft? Это безвозвратно — статус станет WITHDRAWN.',
|
'myDrafts.confirmWithdraw': 'Отозвать draft? Это безвозвратно — статус станет WITHDRAWN.',
|
||||||
'reviews.title': 'Очередь согласований',
|
'reviews.title': 'Очередь согласований',
|
||||||
'reviews.description': 'Pending drafts от makers, ждут approve/reject. Pool model: первый approver обрабатывает.',
|
'reviews.description': 'Черновики от авторов, ждут одобрения или отклонения. Общая очередь: первый рецензент закрывает заявку.',
|
||||||
'reviews.empty': 'Нет pending согласований. Все обработаны 👍',
|
'reviews.empty': 'Нет черновиков на согласование. Все обработаны 👍',
|
||||||
'reviews.queueTotal_one': '{{count}} pending draft',
|
'reviews.queueTotal_one': '{{count}} черновик в очереди',
|
||||||
'reviews.queueTotal_few': '{{count}} pending draft',
|
'reviews.queueTotal_few': '{{count}} черновика в очереди',
|
||||||
'reviews.queueTotal_many': '{{count}} pending drafts',
|
'reviews.queueTotal_many': '{{count}} черновиков в очереди',
|
||||||
'reviews.queueTotal_other': '{{count}} pending drafts',
|
'reviews.queueTotal_other': '{{count}} черновиков в очереди',
|
||||||
'reviews.col.dict': 'Справочник',
|
'reviews.col.dict': 'Справочник',
|
||||||
'reviews.col.bk': 'Business key',
|
'reviews.col.bk': 'Business key',
|
||||||
'reviews.col.op': 'Операция',
|
'reviews.col.op': 'Операция',
|
||||||
@@ -284,9 +284,19 @@ i18n
|
|||||||
'audit.action.expand': 'Раскрыть',
|
'audit.action.expand': 'Раскрыть',
|
||||||
'audit.action.export': 'Экспорт CSV',
|
'audit.action.export': 'Экспорт CSV',
|
||||||
'audit.empty': 'Нет записей по выбранным фильтрам',
|
'audit.empty': 'Нет записей по выбранным фильтрам',
|
||||||
'audit.action.CREATE': 'Создание',
|
'audit.action.CREATE': 'Создание записи',
|
||||||
'audit.action.UPDATE': 'Изменение',
|
'audit.action.UPDATE': 'Изменение записи',
|
||||||
'audit.action.CLOSE': 'Закрытие',
|
'audit.action.CLOSE': 'Закрытие записи',
|
||||||
|
'audit.action.DEFINITION_CREATE': 'Создание справочника',
|
||||||
|
'audit.action.DEFINITION_UPDATE': 'Изменение схемы',
|
||||||
|
'audit.action.DRAFT_CREATE': 'Черновик создан',
|
||||||
|
'audit.action.DRAFT_SUBMIT': 'Черновик отправлен',
|
||||||
|
'audit.action.DRAFT_REVIEW': 'Черновик в очереди',
|
||||||
|
'audit.action.DRAFT_APPROVE': 'Черновик одобрен',
|
||||||
|
'audit.action.DRAFT_REJECT': 'Черновик отклонён',
|
||||||
|
'audit.action.DRAFT_CHANGES_REQUESTED': 'Запрошены правки',
|
||||||
|
'audit.action.DRAFT_WITHDRAW': 'Черновик отозван',
|
||||||
|
'audit.action.DRAFT_PUBLISH': 'Черновик опубликован',
|
||||||
'audit.diff.before': 'Было',
|
'audit.diff.before': 'Было',
|
||||||
'audit.diff.after': 'Стало',
|
'audit.diff.after': 'Стало',
|
||||||
'audit.page.of': 'Стр. {{cur}} из {{total}}',
|
'audit.page.of': 'Стр. {{cur}} из {{total}}',
|
||||||
@@ -1099,9 +1109,19 @@ i18n
|
|||||||
'audit.action.expand': 'Expand',
|
'audit.action.expand': 'Expand',
|
||||||
'audit.action.export': 'Export CSV',
|
'audit.action.export': 'Export CSV',
|
||||||
'audit.empty': 'No entries match the filters',
|
'audit.empty': 'No entries match the filters',
|
||||||
'audit.action.CREATE': 'Create',
|
'audit.action.CREATE': 'Record created',
|
||||||
'audit.action.UPDATE': 'Update',
|
'audit.action.UPDATE': 'Record updated',
|
||||||
'audit.action.CLOSE': 'Close',
|
'audit.action.CLOSE': 'Record closed',
|
||||||
|
'audit.action.DEFINITION_CREATE': 'Dictionary created',
|
||||||
|
'audit.action.DEFINITION_UPDATE': 'Schema updated',
|
||||||
|
'audit.action.DRAFT_CREATE': 'Draft created',
|
||||||
|
'audit.action.DRAFT_SUBMIT': 'Draft submitted',
|
||||||
|
'audit.action.DRAFT_REVIEW': 'Draft in queue',
|
||||||
|
'audit.action.DRAFT_APPROVE': 'Draft approved',
|
||||||
|
'audit.action.DRAFT_REJECT': 'Draft rejected',
|
||||||
|
'audit.action.DRAFT_CHANGES_REQUESTED': 'Changes requested',
|
||||||
|
'audit.action.DRAFT_WITHDRAW': 'Draft withdrawn',
|
||||||
|
'audit.action.DRAFT_PUBLISH': 'Draft published',
|
||||||
'audit.diff.before': 'Before',
|
'audit.diff.before': 'Before',
|
||||||
'audit.diff.after': 'After',
|
'audit.diff.after': 'After',
|
||||||
'audit.page.of': 'Page {{cur}} of {{total}}',
|
'audit.page.of': 'Page {{cur}} of {{total}}',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Route as ReviewsRouteImport } from './routes/reviews'
|
|||||||
import { Route as OutboxRouteImport } from './routes/outbox'
|
import { Route as OutboxRouteImport } from './routes/outbox'
|
||||||
import { Route as MyDraftsRouteImport } from './routes/my-drafts'
|
import { Route as MyDraftsRouteImport } from './routes/my-drafts'
|
||||||
import { Route as GraphRouteImport } from './routes/graph'
|
import { Route as GraphRouteImport } from './routes/graph'
|
||||||
|
import { Route as DraftsRouteImport } from './routes/drafts'
|
||||||
import { Route as DictionariesRouteImport } from './routes/dictionaries'
|
import { Route as DictionariesRouteImport } from './routes/dictionaries'
|
||||||
import { Route as AuditRouteImport } from './routes/audit'
|
import { Route as AuditRouteImport } from './routes/audit'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
@@ -54,6 +55,11 @@ const GraphRoute = GraphRouteImport.update({
|
|||||||
path: '/graph',
|
path: '/graph',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DraftsRoute = DraftsRouteImport.update({
|
||||||
|
id: '/drafts',
|
||||||
|
path: '/drafts',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const DictionariesRoute = DictionariesRouteImport.update({
|
const DictionariesRoute = DictionariesRouteImport.update({
|
||||||
id: '/dictionaries',
|
id: '/dictionaries',
|
||||||
path: '/dictionaries',
|
path: '/dictionaries',
|
||||||
@@ -100,6 +106,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/audit': typeof AuditRoute
|
'/audit': typeof AuditRoute
|
||||||
'/dictionaries': typeof DictionariesRouteWithChildren
|
'/dictionaries': typeof DictionariesRouteWithChildren
|
||||||
|
'/drafts': typeof DraftsRoute
|
||||||
'/graph': typeof GraphRoute
|
'/graph': typeof GraphRoute
|
||||||
'/my-drafts': typeof MyDraftsRoute
|
'/my-drafts': typeof MyDraftsRoute
|
||||||
'/outbox': typeof OutboxRoute
|
'/outbox': typeof OutboxRoute
|
||||||
@@ -115,6 +122,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/audit': typeof AuditRoute
|
'/audit': typeof AuditRoute
|
||||||
|
'/drafts': typeof DraftsRoute
|
||||||
'/graph': typeof GraphRoute
|
'/graph': typeof GraphRoute
|
||||||
'/my-drafts': typeof MyDraftsRoute
|
'/my-drafts': typeof MyDraftsRoute
|
||||||
'/outbox': typeof OutboxRoute
|
'/outbox': typeof OutboxRoute
|
||||||
@@ -131,6 +139,7 @@ export interface FileRoutesById {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/audit': typeof AuditRoute
|
'/audit': typeof AuditRoute
|
||||||
'/dictionaries': typeof DictionariesRouteWithChildren
|
'/dictionaries': typeof DictionariesRouteWithChildren
|
||||||
|
'/drafts': typeof DraftsRoute
|
||||||
'/graph': typeof GraphRoute
|
'/graph': typeof GraphRoute
|
||||||
'/my-drafts': typeof MyDraftsRoute
|
'/my-drafts': typeof MyDraftsRoute
|
||||||
'/outbox': typeof OutboxRoute
|
'/outbox': typeof OutboxRoute
|
||||||
@@ -149,6 +158,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/audit'
|
| '/audit'
|
||||||
| '/dictionaries'
|
| '/dictionaries'
|
||||||
|
| '/drafts'
|
||||||
| '/graph'
|
| '/graph'
|
||||||
| '/my-drafts'
|
| '/my-drafts'
|
||||||
| '/outbox'
|
| '/outbox'
|
||||||
@@ -164,6 +174,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/audit'
|
| '/audit'
|
||||||
|
| '/drafts'
|
||||||
| '/graph'
|
| '/graph'
|
||||||
| '/my-drafts'
|
| '/my-drafts'
|
||||||
| '/outbox'
|
| '/outbox'
|
||||||
@@ -179,6 +190,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/audit'
|
| '/audit'
|
||||||
| '/dictionaries'
|
| '/dictionaries'
|
||||||
|
| '/drafts'
|
||||||
| '/graph'
|
| '/graph'
|
||||||
| '/my-drafts'
|
| '/my-drafts'
|
||||||
| '/outbox'
|
| '/outbox'
|
||||||
@@ -196,6 +208,7 @@ export interface RootRouteChildren {
|
|||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AuditRoute: typeof AuditRoute
|
AuditRoute: typeof AuditRoute
|
||||||
DictionariesRoute: typeof DictionariesRouteWithChildren
|
DictionariesRoute: typeof DictionariesRouteWithChildren
|
||||||
|
DraftsRoute: typeof DraftsRoute
|
||||||
GraphRoute: typeof GraphRoute
|
GraphRoute: typeof GraphRoute
|
||||||
MyDraftsRoute: typeof MyDraftsRoute
|
MyDraftsRoute: typeof MyDraftsRoute
|
||||||
OutboxRoute: typeof OutboxRoute
|
OutboxRoute: typeof OutboxRoute
|
||||||
@@ -249,6 +262,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof GraphRouteImport
|
preLoaderRoute: typeof GraphRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/drafts': {
|
||||||
|
id: '/drafts'
|
||||||
|
path: '/drafts'
|
||||||
|
fullPath: '/drafts'
|
||||||
|
preLoaderRoute: typeof DraftsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/dictionaries': {
|
'/dictionaries': {
|
||||||
id: '/dictionaries'
|
id: '/dictionaries'
|
||||||
path: '/dictionaries'
|
path: '/dictionaries'
|
||||||
@@ -340,6 +360,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AuditRoute: AuditRoute,
|
AuditRoute: AuditRoute,
|
||||||
DictionariesRoute: DictionariesRouteWithChildren,
|
DictionariesRoute: DictionariesRouteWithChildren,
|
||||||
|
DraftsRoute: DraftsRoute,
|
||||||
GraphRoute: GraphRoute,
|
GraphRoute: GraphRoute,
|
||||||
MyDraftsRoute: MyDraftsRoute,
|
MyDraftsRoute: MyDraftsRoute,
|
||||||
OutboxRoute: OutboxRoute,
|
OutboxRoute: OutboxRoute,
|
||||||
|
|||||||
@@ -32,7 +32,25 @@ import { UserCell } from '@/lib/useUserDisplay'
|
|||||||
import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client'
|
import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client'
|
||||||
import { localTzOffset, parseFormDate } from '@/lib/dates'
|
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<AuditAction> = new Set(ACTIONS)
|
const ACTION_VALUES: ReadonlySet<AuditAction> = new Set(ACTIONS)
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [
|
const PAGE_SIZE_OPTIONS = [
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy redirect: /drafts → /my-drafts.
|
||||||
|
*
|
||||||
|
* <p>QA report BUG-003: пользователи интуитивно набирают /drafts, получают
|
||||||
|
* наг 404 «Not Found» plain text. Каноничный роут /my-drafts (имя содержит
|
||||||
|
* «my» чтобы отличать от reviews — pool drafts от других makers).
|
||||||
|
*
|
||||||
|
* <p>Static redirect (beforeLoad throws redirect) — никакого component'а
|
||||||
|
* не рендерится, чтобы избежать flash empty page между navigation steps.
|
||||||
|
*/
|
||||||
|
export const Route = createFileRoute('/drafts')({
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: '/my-drafts' })
|
||||||
|
},
|
||||||
|
})
|
||||||
+50
-3
@@ -1,6 +1,7 @@
|
|||||||
package cloud.nstart.terravault.ordinis.restapi.web;
|
package cloud.nstart.terravault.ordinis.restapi.web;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.info.BuildProperties;
|
import org.springframework.boot.info.BuildProperties;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -9,6 +10,8 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@code GET /api/v1/version} — экспонирует server build identity для UI
|
* {@code GET /api/v1/version} — экспонирует server build identity для UI
|
||||||
@@ -39,29 +42,73 @@ import java.util.Optional;
|
|||||||
public class VersionController {
|
public class VersionController {
|
||||||
|
|
||||||
private final Optional<BuildProperties> buildProps;
|
private final Optional<BuildProperties> 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.
|
||||||
|
*
|
||||||
|
* <p>Empty default → fallback на BuildProperties.tag (тоже empty в проде,
|
||||||
|
* но valid в локальном dev если кто-то билдит на git tag).
|
||||||
|
*/
|
||||||
// BuildProperties может отсутствовать в dev — оптимально через Optional.
|
// 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.buildProps = Optional.ofNullable(buildProps);
|
||||||
|
this.imageTagEnv = imageTagEnv == null ? "" : imageTagEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
|
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public VersionInfo current() {
|
public VersionInfo current() {
|
||||||
|
String resolvedTag = resolveTag();
|
||||||
return buildProps.map(b -> new VersionInfo(
|
return buildProps.map(b -> new VersionInfo(
|
||||||
b.getVersion(),
|
b.getVersion(),
|
||||||
Optional.ofNullable(b.get("commit")).orElse("unknown"),
|
Optional.ofNullable(b.get("commit")).orElse("unknown"),
|
||||||
Optional.ofNullable(b.get("branch")).orElse(""),
|
Optional.ofNullable(b.get("branch")).orElse(""),
|
||||||
Optional.ofNullable(b.get("tag")).orElse(""),
|
resolvedTag,
|
||||||
b.getTime() != null ? b.getTime().toString() : ""
|
b.getTime() != null ? b.getTime().toString() : ""
|
||||||
)).orElseGet(() -> new VersionInfo(
|
)).orElseGet(() -> new VersionInfo(
|
||||||
"0.0.0-dev",
|
"0.0.0-dev",
|
||||||
"unknown",
|
"unknown",
|
||||||
"",
|
"",
|
||||||
"",
|
resolvedTag,
|
||||||
Instant.now().toString()
|
Instant.now().toString()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve human-readable semver tag из docker image tag.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>Examples:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code v2-31-2-e3c82775} → {@code v2.31.2}</li>
|
||||||
|
* <li>{@code v2-31-2} → {@code v2.31.2}</li>
|
||||||
|
* <li>{@code latest} → {@code latest} (passes через без транформации)</li>
|
||||||
|
* <li>{@code main-abc1234} → {@code main} (branch tag, strips sha)</li>
|
||||||
|
* <li>empty/null → BuildProperties fallback → empty</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
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.
|
* Stable JSON shape для UI client.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user