From b2e7fd84c96dfcef184354929fa792ab2065d9c7 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Fri, 8 May 2026 15:11:25 +0300 Subject: [PATCH] feat(approval): Approval Workflow v2 Phase 4 monitoring + pending-review badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — 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. --- ordinis-admin-ui/src/api/queries.ts | 22 +++++ ordinis-admin-ui/src/i18n.ts | 4 + .../src/routes/dictionaries.$name.tsx | 30 +++++- .../domain/draft/RecordDraftRepository.java | 7 ++ .../restapi/service/draft/DraftService.java | 96 +++++++++++++++++-- .../service/draft/DraftServiceTest.java | 7 ++ 6 files changed, 158 insertions(+), 8 deletions(-) diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 6b2182c..ad1a56e 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -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 => { + const { data } = await apiClient.get( + `/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({ diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 0914e45..0a6f0c4 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -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', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index c89b5f7..e05dc34 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -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() + 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() { /> - {r.businessKey} +
+ {r.businessKey} + {pendingByKey.has(r.businessKey) ? ( + + {t('dict.pendingReview.label')} + + ) : null} +
{recordDisplayName( diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/RecordDraftRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/RecordDraftRepository.java index 5157665..c147093 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/RecordDraftRepository.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/RecordDraftRepository.java @@ -47,4 +47,11 @@ public interface RecordDraftRepository extends JpaRepository ORDER BY d.submittedAt DESC """) Page findByMaker(@Param("makerId") String makerId, Pageable pageable); + + /** Global pending queue depth для Micrometer Gauge (Phase 4 monitoring). */ + @Query(""" + SELECT COUNT(d) FROM RecordDraft d + WHERE d.status = cloud.nstart.terravault.ordinis.domain.draft.DraftStatus.PENDING + """) + long countPending(); } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java index 146fb3f..fdf60d7 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java @@ -11,6 +11,11 @@ import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.Timer; +import jakarta.annotation.PostConstruct; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.io.ParseException; import org.locationtech.jts.io.WKTReader; @@ -24,8 +29,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; import java.time.OffsetDateTime; import java.util.List; +import java.util.Optional; import java.util.UUID; /** @@ -53,25 +60,51 @@ public class DraftService { private final DictionaryDefinitionService definitionService; private final DictionaryRecordService recordService; private final ScopeContext scopeContext; + /** Phase 4 monitoring. Optional — null в test ctor'ах (no metrics in unit tests). */ + private final Optional meterRegistry; @org.springframework.beans.factory.annotation.Autowired public DraftService( RecordDraftRepository draftRepo, DictionaryDefinitionService definitionService, DictionaryRecordService recordService, - ScopeContext scopeContext) { + ScopeContext scopeContext, + Optional meterRegistry) { this.draftRepo = draftRepo; this.definitionService = definitionService; this.recordService = recordService; this.scopeContext = scopeContext; + this.meterRegistry = meterRegistry; } - /** Test-friendly ctor — без recordService для unit tests submit/list flow. */ + /** Test-friendly ctor — без recordService + без metrics для unit tests submit/list flow. */ public DraftService( RecordDraftRepository draftRepo, DictionaryDefinitionService definitionService, ScopeContext scopeContext) { - this(draftRepo, definitionService, null, scopeContext); + this(draftRepo, definitionService, null, scopeContext, Optional.empty()); + } + + /** Test-friendly ctor — с recordService но без metrics. Phase 1 + Phase 4 ctor compat. */ + public DraftService( + RecordDraftRepository draftRepo, + DictionaryDefinitionService definitionService, + DictionaryRecordService recordService, + ScopeContext scopeContext) { + this(draftRepo, definitionService, recordService, scopeContext, Optional.empty()); + } + + /** + * Регистрирует Gauge для pending queue depth. Polled на scrape — Micrometer + * вызывает {@link RecordDraftRepository#countPending()} раз в Prometheus tick. + * Phase 4 monitoring: queue depth → soak observability + alerting. + */ + @PostConstruct + void registerGauges() { + meterRegistry.ifPresent(reg -> + Gauge.builder("nsi_draft_pending_total", draftRepo, RecordDraftRepository::countPending) + .description("Approval workflow: pending drafts ожидающих review (D3 pool model)") + .register(reg)); } /** @@ -108,6 +141,7 @@ public class DraftService { RecordDraft saved = draftRepo.saveAndFlush(draft); log.info("Draft submitted: dict={} bk={} op={} maker={}", dictionaryName, req.businessKey(), req.operation(), makerId); + countSubmit(dictionaryName, req.operation(), "ok"); return saved; } catch (DataIntegrityViolationException ex) { // SQLSTATE 23P01 = exclusion_violation (record_drafts_one_pending_per_key). @@ -115,10 +149,12 @@ public class DraftService { // иначе скрываем баги схемы (Phase 3 lessons learned: enum case mismatch // прятался в маске draft_already_pending). if (isExclusionViolation(ex)) { + countSubmit(dictionaryName, req.operation(), "already_pending"); throw OrdinisException.conflict( "draft_already_pending", "На запись business_key=" + req.businessKey() + " уже есть pending draft."); } + countSubmit(dictionaryName, req.operation(), "error"); throw ex; } } @@ -203,12 +239,14 @@ public class DraftService { // Mark approved (kept в таблице — D4 audit trail). Delete не делаем — // draft.id остаётся ссылочно валидным (audit log → draft_id). + OffsetDateTime now = OffsetDateTime.now(); draft.setStatus(DraftStatus.APPROVED); draft.setReviewerId(reviewerId); - draft.setReviewedAt(OffsetDateTime.now()); + draft.setReviewedAt(now); draft.setReviewComment(reviewComment); RecordDraft saved = draftRepo.saveAndFlush(draft); + recordDecision(def.getName(), draft.getOperation(), "approved", draft.getSubmittedAt(), now); log.info("Draft approved: id={} dict={} bk={} op={} reviewer={}", draft.getId(), def.getName(), draft.getBusinessKey(), draft.getOperation(), reviewerId); @@ -240,11 +278,14 @@ public class DraftService { "Maker '" + reviewerId + "' не может reject свой draft."); } + OffsetDateTime now = OffsetDateTime.now(); draft.setStatus(DraftStatus.REJECTED); draft.setReviewerId(reviewerId); - draft.setReviewedAt(OffsetDateTime.now()); + draft.setReviewedAt(now); draft.setReviewComment(reviewComment); RecordDraft saved = draftRepo.saveAndFlush(draft); + DictionaryDefinition def = definitionService.findById(draft.getDictionaryId()); + recordDecision(def.getName(), draft.getOperation(), "rejected", draft.getSubmittedAt(), now); log.info("Draft rejected: id={} reviewer={} reason='{}'", draftId, reviewerId, reviewComment); return saved; @@ -272,13 +313,56 @@ public class DraftService { + ", maker: " + draft.getMakerId()); } + OffsetDateTime now = OffsetDateTime.now(); draft.setStatus(DraftStatus.WITHDRAWN); - draft.setReviewedAt(OffsetDateTime.now()); + draft.setReviewedAt(now); RecordDraft saved = draftRepo.saveAndFlush(draft); + DictionaryDefinition def = definitionService.findById(draft.getDictionaryId()); + recordDecision(def.getName(), draft.getOperation(), "withdrawn", draft.getSubmittedAt(), now); log.info("Draft withdrawn by maker: id={} maker={}", draftId, userId); return saved; } + // === Phase 4 metrics helpers === + + /** + * Counter: nsi_draft_submit_total{dictionary, operation, outcome}. + * Outcome: ok / already_pending / error. Помогает алертить аномальный + * процент already_pending (concurrent edits) или error (schema bugs). + */ + private void countSubmit(String dictName, DraftOperation op, String outcome) { + meterRegistry.ifPresent(reg -> + reg.counter("nsi_draft_submit_total", + Tags.of("dictionary", dictName, "operation", op.name(), "outcome", outcome)) + .increment()); + } + + /** + * Counter: nsi_draft_decision_total{dictionary, operation, outcome=approved|rejected|withdrawn}. + * Timer: nsi_draft_review_duration_seconds — distribution time-to-decide. + * Outcome=withdrawn ≠ review (maker сам отозвал), но включаем — нужно для + * full lifecycle visibility и сравнения throughput. + */ + private void recordDecision( + String dictName, DraftOperation op, String outcome, + OffsetDateTime submittedAt, OffsetDateTime decidedAt) { + meterRegistry.ifPresent(reg -> { + Tags tags = Tags.of("dictionary", dictName, "operation", op.name(), "outcome", outcome); + reg.counter("nsi_draft_decision_total", tags).increment(); + if (submittedAt != null && decidedAt != null) { + Duration d = Duration.between(submittedAt.toInstant(), decidedAt.toInstant()); + if (!d.isNegative()) { + Timer t = Timer.builder("nsi_draft_review_duration_seconds") + .description("Approval workflow: время от submit до decision") + .tags(tags) + .publishPercentileHistogram() + .register(reg); + t.record(d); + } + } + }); + } + /** Drafts автора: tracking "что я отправил" в admin UI. */ @Transactional(readOnly = true) public Page listByMaker(String makerId, Pageable pageable) { diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java index 9131135..f752bf7 100644 --- a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java @@ -361,6 +361,13 @@ class DraftServiceTest { .toList(); } + @Override + public long countPending() { + return store.values().stream() + .filter(d -> d.getStatus() == DraftStatus.PENDING) + .count(); + } + @Override public org.springframework.data.domain.Page findByMaker( String makerId, org.springframework.data.domain.Pageable pageable) {