feat(approval): Approval Workflow v2 Phase 4 monitoring + pending-review badges

Approval Workflow v2 — Phase 4: monitoring + UX visibility.

Backend metrics (Micrometer → Prometheus):
- Gauge nsi_draft_pending_total — global queue depth (polled на scrape).
- Counter nsi_draft_submit_total{dictionary,operation,outcome=ok|already_pending|error}
  — submit attempts с разбивкой по outcome. Phase 3 lessons: error outcome
  выявит schema bugs которые ранее маскировались.
- Counter nsi_draft_decision_total{dictionary,operation,outcome=approved|rejected|withdrawn}
  — counts decisions, разбит по типу.
- Timer nsi_draft_review_duration_seconds — time-to-decide histogram (от
  submit до approve/reject/withdraw). publishPercentileHistogram() — для
  Prometheus histogram_quantile p95/p99.

DraftService:
- Optional<MeterRegistry> — null-safe в test ctor'ах.
- @PostConstruct registerGauges() — Gauge.builder polled от
  RecordDraftRepository.countPending().
- recordDecision() helper в approve/reject/withdraw — записывает Timer +
  Counter одной точкой.
- 4-arg ctor compat для Phase 1 unit tests.

UI (admin):
- useDictPendingDrafts — per-dict pending list, refetch 30s, gated на
  detailQuery.data.approvalRequired (no-op для не-approval dicts).
- "На review" badge в businessKey cell records table — моментальная
  видимость что есть pending draft. Open /reviews для act'а.
- i18n RU/EN: dict.pendingReview.label + tooltip.

Domain:
- RecordDraftRepository.countPending() — для Gauge polled count.

Tests: ordinis-rest-api unit 119/119, ordinis-app e2e 28/28, vitest 89/89.
This commit is contained in:
Zimin A.N.
2026-05-08 15:11:25 +03:00
parent d229c545f9
commit b2e7fd84c9
6 changed files with 158 additions and 8 deletions
+22
View File
@@ -408,6 +408,28 @@ export const reviewQueueQuery = (page = 0, size = 50) =>
export const useReviewQueue = (page = 0, size = 50) =>
useQuery(reviewQueueQuery(page, size))
/**
* Pending drafts на конкретный dict — для бейджа "На review" в records list.
* Лёгкий poll каждые 30s. Backend возвращает только PENDING (фильтрация в SQL).
*/
export const dictPendingDraftsQuery = (dictName: string) =>
queryOptions({
queryKey: ['drafts', 'by-dict', dictName] as const,
queryFn: async (): Promise<DraftResponse[]> => {
const { data } = await apiClient.get<DraftResponse[]>(
`/dictionaries/${encodeURIComponent(dictName)}/records/drafts`,
)
return data
},
refetchInterval: 30_000,
})
export const useDictPendingDrafts = (dictName: string | undefined) =>
useQuery({
...dictPendingDraftsQuery(dictName ?? ''),
enabled: Boolean(dictName),
})
/** Single draft by id — for diff viewer drawer. */
export const draftQuery = (id: string) =>
queryOptions({
+4
View File
@@ -179,6 +179,8 @@ i18n
'dict.col.validFrom': 'Действует с',
'dict.col.locale': 'Локаль',
'dict.col.actions': 'Действия',
'dict.pendingReview.label': 'На review',
'dict.pendingReview.tooltip': 'Есть pending draft на эту запись. Откройте /reviews для approve/reject.',
'dict.list.records': 'записей',
'dict.action.create': 'Создать запись',
'dict.action.edit': 'Изменить',
@@ -577,6 +579,8 @@ i18n
'dict.col.validFrom': 'Valid from',
'dict.col.locale': 'Locale',
'dict.col.actions': 'Actions',
'dict.pendingReview.label': 'Pending review',
'dict.pendingReview.tooltip': 'A draft is pending for this record. Open /reviews to approve/reject.',
'dict.list.records': 'records',
'dict.action.create': 'Create record',
'dict.action.edit': 'Edit',
@@ -23,7 +23,13 @@ import {
TextInput,
} from '@nstart/ui'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
import { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries'
import {
useDictionaryDetail,
useDictPendingDrafts,
useRecordRaw,
useRecords,
type RecordsFilter,
} from '@/api/queries'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
@@ -117,6 +123,19 @@ function DictionaryDetail() {
}
: undefined
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
/**
* Approval Workflow v2 Phase 4: pending drafts на этот dict — для бейджа
* "На review" в каждой строке records table. Polled 30s. Bypass'нем если
* dict не approval-required (нет смысла грузить).
*/
const pendingDraftsQuery = useDictPendingDrafts(
detailQuery.data?.approvalRequired ? name : undefined,
)
const pendingByKey = useMemo(() => {
const set = new Set<string>()
for (const d of pendingDraftsQuery.data ?? []) set.add(d.businessKey)
return set
}, [pendingDraftsQuery.data])
const createMut = useCreateRecord(name)
const updateMut = useUpdateRecord(name)
const closeMut = useCloseRecord(name)
@@ -672,7 +691,14 @@ function DictionaryDetail() {
/>
</TableCell>
<TableCell>
<span className="font-mono text-2xs">{r.businessKey}</span>
<div className="flex items-center gap-1.5">
<span className="font-mono text-2xs">{r.businessKey}</span>
{pendingByKey.has(r.businessKey) ? (
<span title={t('dict.pendingReview.tooltip')}>
<Badge variant="warning">{t('dict.pendingReview.label')}</Badge>
</span>
) : null}
</div>
</TableCell>
<TableCell>
{recordDisplayName(