From c6c549eae920eea3b97ef9d237f837299c398e2e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 20 Aug 2026 16:40:05 +0500 Subject: [PATCH] =?UTF-8?q?=D1=82=D1=80=D0=B5=D0=B9=D1=81=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B2=D0=B0=D1=8F=20=D0=BE=D0=BF=D1=82=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/RequestsTableView.tsx | 3 +- .../features/myRequests/SearchView.tsx | 5 +- .../features/myRequests/myRequests.service.ts | 356 ++++++++++-------- .../shared/approval/approvalListLocator.ts | 207 ++++++++++ .../shared/approval/approvalRoute.service.ts | 2 +- .../requestPortal/shared/approval/index.ts | 4 + .../shared/approval/listPartitions.ts | 7 +- .../requestPortal/shared/approval/siteUser.ts | 65 ++++ .../requestPortal/shared/perfTrace.ts | 196 ++++++++++ 9 files changed, 690 insertions(+), 155 deletions(-) create mode 100644 src/webparts/requestPortal/shared/approval/approvalListLocator.ts create mode 100644 src/webparts/requestPortal/shared/approval/siteUser.ts create mode 100644 src/webparts/requestPortal/shared/perfTrace.ts diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 9aa8f71..5630086 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -16,6 +16,7 @@ import { IMyRequest } from './models/IMyRequest'; import { DataTable, IDataTableColumn } from '@tnsu/ui-kit'; import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; +import { perfScope } from '../../shared/perfTrace'; import { useRoutedQuery } from '../../shared/usePersistedQuery'; import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; import { Pager } from './Pager'; @@ -398,7 +399,7 @@ export const RequestsTableView: React.FC = ({ const load = async (forceRender = false): Promise => { try { - const fresh = await fetchPage(); + const fresh = await perfScope(`таблица ${view} стр.${page}`, fetchPage); if (cancelled) return; // Перерисовываем, только если данные реально изменились — иначе при diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index 0411d1a..85bd51f 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -4,6 +4,7 @@ import { getLinkedTypes, hydratePendingAuthors, searchMyRequests } from './myReq import { IMyRequest } from './models/IMyRequest'; import { IRequestType, StatusBadge } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; +import { perfScope } from '../../shared/perfTrace'; import { useRoutedQuery } from '../../shared/usePersistedQuery'; import { pageKey, pageSignature, readPage, writePage } from './pageCache'; import { Pager } from './Pager'; @@ -185,7 +186,9 @@ export const SearchView: React.FC = ({ const load = async (forceRender = false): Promise => { try { - const fresh = await searchMyRequests(context, types, debouncedQuery); + const fresh = await perfScope('вкладка «Поиск»', () => + searchMyRequests(context, types, debouncedQuery) + ); if (cancelled) return; applyKnownAuthors(fresh.rows, authors.current); const prev = readPage(cacheKey); diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 05c3d96..836eacb 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -11,15 +11,22 @@ import '@pnp/sp/search'; // но в рантайме не зарегистрирован. import '@pnp/sp/batching'; import { WebPartContext } from '@microsoft/sp-webpart-base'; -import { getRequestTypes, IRequestType, partitionsForTypeId, REQUEST_STATUS } from '../../shared/approval'; +import { + currentUserIdFor, + ensureUserIdFor, + getRequestTypesCached, + IRequestType, + approvalListKey, + locateApprovalLists, + partitionsForTypeId, + REQUEST_STATUS, + siteOrigin, +} from '../../shared/approval'; import { effectiveUserEmail } from '../../shared/impersonation'; import { IMyRequest } from './models/IMyRequest'; import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug'; import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace'; - -// Список данных «... — данные» → список согласования «... — согласование» -// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json). -const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; +import { perfScope, traced } from '../../shared/perfTrace'; // Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может // ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход @@ -86,7 +93,11 @@ export function currentUserEmail(context: WebPartContext): string { // Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению). export async function getLinkedTypes(context: WebPartContext): Promise { - const types = await getRequestTypes(context); + // Кешированный вариант: getLinkedTypes зовут и счётчики меню, и каждая + // таблица, и «Поиск» — на одно открытие портала это было 4-5 одинаковых + // AAD-запросов к /api/request-types. Ответ меняется только когда админ + // заводит тип заявки, то есть заведомо не в пределах загрузки страницы. + const types = await getRequestTypesCached(context); const linked = types.filter((t) => !!t.listId && !!t.siteUrl); traceLinkedTypes(types, linked); return linked; @@ -195,28 +206,38 @@ async function buildPendingIndex( [] ); - // Резолв Title/адреса списка согласования остаётся ПОПАРТИЦИОННЫМ REST-ом: - // approval-route не хранит на него ссылку (список согласования ищется только - // по имени, см. shared/listTitles.ts), значит его АДРЕС узнать больше неоткуда. - // Но это лёгкий $select без постраничного перебора — не то же самое, что - // полноценный Search, который эти вызовы как раз заменяют. - const resolved = await Promise.all( - flat.map(({ type, target }) => - resolveApprovalSearchTarget(context, type, target) + // Адреса списков согласования всех партиций разом: не больше ОДНОГО + // /_api/web/lists на сайт, а после первого раза — вообще без сети (адреса + // кешируются на сессию, см. shared/approval/approvalListLocator.ts). Раньше + // здесь был отдельный REST-запрос на КАЖДУЮ партицию, и он повторялся при + // каждой загрузке очереди — при 11 партициях это 11 запросов на проход, а + // проходов за одно открытие вкладки два (счётчики меню + сама таблица). + const located = await traced('адреса списков согласования', () => + locateApprovalLists( + context, + flat.map(({ target }) => ({ listId: target.listId, siteUrl: target.siteUrl, title: target.title })) ) ); const okEntries: IApprovalSearchTarget[] = []; - resolved.forEach((r) => { - const debugType = r.kind === 'ok' ? r.value.type : r.type; - const debug = debugByType.get(debugType.id); - if (!debug) return; - if (r.kind === 'ok') { - okEntries.push(r.value); - } else if (r.kind === 'skip') { - if (!debug.approvalSkippedReason) debug.approvalSkippedReason = r.reason; + flat.forEach(({ type, target }) => { + const debug = debugByType.get(type.id); + const lookup = located.get(approvalListKey(target.siteUrl, target.listId)); + if (!debug || !lookup) return; + + if (lookup.kind === 'ok') { + okEntries.push({ + type, + target, + // getByTitle — только построение url, отдельного запроса не стоит. + approvalList: spForSite(target.siteUrl, context).web.lists.getByTitle(lookup.value.approvalListTitle), + approvalListTitle: lookup.value.approvalListTitle, + approvalListAbsUrl: lookup.value.approvalListAbsUrl, + }); + } else if (lookup.kind === 'skip') { + if (!debug.approvalSkippedReason) debug.approvalSkippedReason = lookup.reason; } else { - if (!debug.approvalError) debug.approvalError = r.message; + if (!debug.approvalError) debug.approvalError = lookup.message; } }); @@ -225,22 +246,28 @@ async function buildPendingIndex( // поэтому идут параллельно, а не один за другим. const [hitsByTarget, recentByTarget] = await Promise.all([ okEntries.length > 0 - ? searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses) + ? traced('Search: очередь согласования', () => + searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses) + ) : Promise.resolve(new Map()), // Довесок нужен только режиму «чья очередь сейчас»: при текстовом поиске // важна полнота по архиву, а не мгновенная свежесть (см. scope='involved'). okEntries.length > 0 && scope === 'myTurn' - ? fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses) + ? traced('REST-довесок по свежести (очередь)', () => + fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses) + ) : Promise.resolve(new Map>>()), ]); - const parts = await Promise.all( - okEntries.map((entry) => - finalizeApprovalTarget( - context, - entry, - hitsByTarget.get(targetKey(entry)) ?? [], - recentByTarget.get(targetKey(entry)) ?? [] + const parts = await traced('дочитывание строк очереди (REST)', () => + Promise.all( + okEntries.map((entry) => + finalizeApprovalTarget( + context, + entry, + hitsByTarget.get(targetKey(entry)) ?? [], + recentByTarget.get(targetKey(entry)) ?? [] + ) ) ) ); @@ -281,6 +308,55 @@ async function buildPendingIndex( return { types, index, debug: types.map((t) => debugByType.get(t.id)!) }; } +/** Результат buildPendingIndex — вынесен в тип, потому что им теперь делятся вызывающие. */ +interface IPendingIndex { + types: IRequestType[]; + index: IMyRequest[]; + debug: IMyRequestsTypeDebug[]; +} + +// Уже идущие сборки индекса, по ключу «область + набор статусов». +// +// При открытии портала счётчики меню (getNavCounts) и сама таблица очереди +// стартуют практически одновременно и просят ОДИН И ТОТ ЖЕ набор — раньше это +// значило два полных прохода: два резолва адресов списков, два Search, два +// довеска по свежести. Второй вызов теперь подхватывает промис первого. +// +// Держим ТОЛЬКО пока запрос в полёте: как только он завершился, запись +// удаляется. Кешировать результат по времени нельзя — «Обновить» и событие +// rp-requests-changed обязаны читать данные заново. +const pendingIndexInFlight = new Map>(); + +function pendingIndexKey(scope: ApprovalScope, statuses: string[]): string { + return `${scope}|${[...statuses].sort().join(',')}`; +} + +async function sharedPendingIndex( + context: WebPartContext, + scope: ApprovalScope, + statuses: string[] +): Promise { + const key = pendingIndexKey(scope, statuses); + const running = pendingIndexInFlight.get(key); + if (running) return running; + + const forget = (): void => { + pendingIndexInFlight.delete(key); + }; + const promise = buildPendingIndex(context, scope, statuses).then( + (r) => { + forget(); + return r; + }, + (e) => { + forget(); + throw e; + } + ); + pendingIndexInFlight.set(key, promise); + return promise; +} + // Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20 // заявок отдать), автор дочитывается только для них. export async function getApprovalQueue( @@ -288,12 +364,29 @@ export async function getApprovalQueue( kind: ApprovalQueueKind, page: number = 1 ): Promise { - const { types, index, debug } = await buildPendingIndex(context, 'myTurn', QUEUE_STATUSES[kind]); + // Очереди действий («На согласовании» и «На исполнении») собираются ОДНОЙ + // выборкой по обоим статусам, а разносятся уже по myStepStatus — ровно так же, + // как это делают счётчики меню (см. getNavCountsInner). Смысл: это тот же + // самый набор, и просить его двумя разными запросами значит дважды пройти по + // всем спискам согласования. История решений ('approved'/'rejected'/ + // 'executed') в эту пару не входит и берётся своим запросом. + const shared = MY_TURN_STATUSES.indexOf(QUEUE_STATUSES[kind][0]) >= 0; + const { types, index, debug } = await sharedPendingIndex( + context, + 'myTurn', + shared ? MY_TURN_STATUSES : QUEUE_STATUSES[kind] + ); - const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE); + // Отбор по СВЕЖЕМУ статусу своей строки, а не по тому, что отдал индекс. + // Индекс мог отстать, и заявка, по которой решение уже принято, не должна + // висеть во вкладке «ждут вашего согласования» — по этой же причине + // finalizeApprovalTarget перечитывает статус REST-ом. + const rows = shared ? index.filter((r) => r.myStepStatus === QUEUE_STATUSES[kind][0]) : index; + + const pageRows = rows.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE); await hydrateAuthors(context, types, pageRows); - return { rows: pageRows, totalRows: index.length, debug }; + return { rows: pageRows, totalRows: rows.length, debug }; } // С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая @@ -308,7 +401,7 @@ export async function getApprovalQueueIndex( context: WebPartContext, kind: ApprovalQueueKind ): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> { - const { types, index, debug } = await buildPendingIndex(context, 'involved', QUEUE_STATUSES[kind]); + const { types, index, debug } = await sharedPendingIndex(context, 'involved', QUEUE_STATUSES[kind]); return { rows: index, types, debug }; } @@ -318,7 +411,7 @@ export async function getPendingApprovalIndex( context: WebPartContext, scope: ApprovalScope ): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> { - const { types, index, debug } = await buildPendingIndex(context, scope, []); + const { types, index, debug } = await sharedPendingIndex(context, scope, []); return { rows: index, types, debug }; } @@ -338,14 +431,14 @@ export interface INavCounts { // своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму // вкладку: иначе счётчик и список могли бы разойтись. export async function getNavCounts(context: WebPartContext): Promise { - return getNavCountsInner(context); + return perfScope('счётчики меню', () => getNavCountsInner(context)); } async function getNavCountsInner(context: WebPartContext): Promise { const types = await getLinkedTypes(context); const [turn, rework] = await Promise.all([ - buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES), + sharedPendingIndex(context, 'myTurn', MY_TURN_STATUSES), getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1), ]); @@ -383,67 +476,6 @@ interface IApprovalSearchTarget { approvalListAbsUrl: string; } -type ApprovalTargetResolution = - | { kind: 'ok'; value: IApprovalSearchTarget } - | { kind: 'skip'; type: IRequestType; target: IListTarget; reason: string } - | { kind: 'error'; type: IRequestType; target: IListTarget; message: string }; - -/** - * Резолвит список согласования одной партиции: Title (по конвенции из Title - * списка данных) и его абсолютный адрес пути. - * - * Title списка данных — из target.title (бесплатно, из bulk-ответа - * /api/request-types/lists, см. IListTarget.title), REST — только если реестр - * партиций был недоступен. - * - * Адрес списка согласования — ВСЕГДА REST-ом: approval-route не хранит на него - * ссылку (список согласования ищется только по имени, см. shared/listTitles.ts), - * сам список создаётся SharePoint под произвольным номером, и его адрес нельзя - * вывести из адреса списка данных. Но это лёгкий $select без постраничного - * перебора — не то же самое, что полноценный Search, который эти вызовы - * заменяют (см. searchAcrossApprovalLists). - */ -async function resolveApprovalSearchTarget( - context: WebPartContext, - type: IRequestType, - target: IListTarget -): Promise { - try { - const sp = spForSite(target.siteUrl, context); - const list = sp.web.getList(target.listId); - - const dataTitle = - target.title ?? - ((await list.select('Title')()).Title as string) ?? - ''; - if (!DATA_LIST_SUFFIX.test(dataTitle)) { - return { - kind: 'skip', - type, - target, - reason: `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`, - }; - } - const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`; - const approvalList = sp.web.lists.getByTitle(approvalListTitle); - - const rootFolder = (await approvalList.select('RootFolder/ServerRelativeUrl').expand('RootFolder')()) as { - RootFolder?: { ServerRelativeUrl?: string }; - }; - const relUrl = rootFolder.RootFolder?.ServerRelativeUrl; - if (!relUrl) { - return { kind: 'error', type, target, message: `Список согласования «${approvalListTitle}» не найден` }; - } - // Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с - // путями элементов …/Lists/List10 (см. listPathPrefix). - const approvalListAbsUrl = `${siteOrigin(target.siteUrl)}${relUrl.replace(/\/+$/, '')}/`; - - return { kind: 'ok', value: { type, target, approvalList, approvalListTitle, approvalListAbsUrl } }; - } catch (e) { - return { kind: 'error', type, target, message: (e as Error).message }; - } -} - function targetKey(entry: IApprovalSearchTarget): string { return entry.approvalListAbsUrl.toLowerCase(); } @@ -570,16 +602,17 @@ async function fetchRecentApprovalRows( try { const sp = spForSite(siteUrl, context); - // ensureUser — ОДИН раз на сайт. Раньше он вызывался внутри обработки + // ensureUser — один раз на сайт И один раз на сессию: результат кешируется + // (см. shared/approval/siteUser.ts). Раньше он вызывался внутри обработки // каждой партиции, то есть повторялся N раз с тем же результатом, и это - // POST, а не дешёвый GET. + // POST, а не дешёвый GET; теперь его не платят даже повторные загрузки. // // TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в // SharePoint REST — поэтому резолвим email в числовой Id (как и везде в // проекте) и фильтруем по TansuSiteApproverId. const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; - const approver = await sp.web.ensureUser(claimsLogin); - const filter = `TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`; + const approverId = await ensureUserIdFor(context, siteUrl, claimsLogin); + const filter = `TansuSiteApproverId eq ${approverId} and Modified ge '${cutoffIso}'${statusFilter}`; const [batchedSP, execute] = sp.batched(); // Обработчики навешиваем СРАЗУ при создании промиса, до execute(): иначе @@ -725,7 +758,14 @@ async function hydrateAuthors( rows: IMyRequest[] ): Promise { if (rows.length === 0) return; + return traced('дочитывание автора (REST)', () => hydrateAuthorsInner(context, types, rows)); +} +async function hydrateAuthorsInner( + context: WebPartContext, + types: IRequestType[], + rows: IMyRequest[] +): Promise { const typeById = new Map(); types.forEach((t) => typeById.set(t.id, t)); @@ -848,11 +888,6 @@ async function fetchAllSearchRows( return rows; } -function siteOrigin(siteUrl: string): string { - const match = /^https?:\/\/[^/]+/.exec(siteUrl); - return match ? match[0] : siteUrl; -} - function listAbsUrl(type: IRequestType): string { return `${siteOrigin(type.siteUrl as string)}${type.listId}`; } @@ -1106,11 +1141,14 @@ async function getOwnRowsForType(context: WebPartContext, type: IRequestType): P try { const sp = spForSite(target.siteUrl, context); - const me = await sp.web.currentUser.select('Id')(); + // Кешированный Id (см. shared/approval/siteUser.ts): эта функция зовётся в + // цикле по типам заявок, и без кеша тот же GET /_api/web/currentuser уходил + // бы столько раз, сколько у портала типов. + const myId = await currentUserIdFor(context, target.siteUrl); const rows = (await sp.web .getList(target.listId) - .items.filter(`AuthorId eq ${me.Id}`) + .items.filter(`AuthorId eq ${myId}`) .select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') .expand('Author') .orderBy('Created', false) @@ -1158,12 +1196,12 @@ async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: I try { const sp = spForSite(target.siteUrl, context); - const me = await sp.web.currentUser.select('Id')(); + const myId = await currentUserIdFor(context, target.siteUrl); const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString(); const rows = (await sp.web .getList(target.listId) - .items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`) + .items.filter(`AuthorId eq ${myId} and Modified ge '${cutoffIso}'`) .select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') .expand('Author') .top(OWN_REST_TOP)()) as Array>; @@ -1235,21 +1273,23 @@ export async function searchOwnRequests( if (text) clauses.push(text); const kql = clauses.join(' AND '); - const results = await sp.search({ - Querytext: kql, - RowLimit: PAGE_SIZE, - StartRow: (page - 1) * PAGE_SIZE, - SelectProperties: ['ListItemID', 'Title', 'Path'], - TrimDuplicates: false, - // Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате). - SortList: [{ Property: 'Created', Direction: 1 }], - }); + const results = await traced('Search: свои заявки', () => + sp.search({ + Querytext: kql, + RowLimit: PAGE_SIZE, + StartRow: (page - 1) * PAGE_SIZE, + SelectProperties: ['ListItemID', 'Title', 'Path'], + TrimDuplicates: false, + // Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате). + SortList: [{ Property: 'Created', Direction: 1 }], + }) + ); const totalRows = results.TotalRows || 0; const entries = hitsToEntries(results, types); const byKey = entries.length > 0 - ? await loadByIds(context, entries, 'own') + ? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own')) : new Map(); // Порядок задаёт релевантность поиска, а не порядок ответов REST. const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r); @@ -1284,21 +1324,23 @@ export async function getOwnRequestsByStatus( if (text) clauses.push(text); const kql = clauses.join(' AND '); - const results = await sp.search({ - Querytext: kql, - RowLimit: PAGE_SIZE, - StartRow: (page - 1) * PAGE_SIZE, - SelectProperties: ['ListItemID', 'Title', 'Path'], - TrimDuplicates: false, - // Свежие сверху — как в searchOwnRequests. - SortList: [{ Property: 'Created', Direction: 1 }], - }); + const results = await traced('Search: свои заявки', () => + sp.search({ + Querytext: kql, + RowLimit: PAGE_SIZE, + StartRow: (page - 1) * PAGE_SIZE, + SelectProperties: ['ListItemID', 'Title', 'Path'], + TrimDuplicates: false, + // Свежие сверху — как в searchOwnRequests. + SortList: [{ Property: 'Created', Direction: 1 }], + }) + ); const totalRows = results.TotalRows || 0; const entries = hitsToEntries(results, types); const byKey = entries.length > 0 - ? await loadByIds(context, entries, 'own') + ? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own')) : new Map(); const searchRows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r); @@ -1326,20 +1368,6 @@ export async function getOwnRequestsByStatus( return { rows: merged, totalRows: totalRows + onlyFresh.length }; } -// Список согласования по типу заявки: Title списка данных → Title списка -// согласования по конвенции имён (та же логика, что в finalizeApprovalTarget). -// undefined — список заведён не по конвенции, маршрута для него не найти. -async function approvalListFor( - context: WebPartContext, - target: IListTarget -): Promise { - const sp = spForSite(target.siteUrl, context); - const info = (await sp.web.getList(target.listId).select('Title')()) as { Title?: string }; - const dataTitle = info.Title || ''; - if (!DATA_LIST_SUFFIX.test(dataTitle)) return undefined; - return sp.web.lists.getByTitle(`${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`); -} - // Сколько строк маршрута максимум забираем за раз при дочитывании комментариев: // на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный. const DECISION_COMMENT_ROW_LIMIT = 2000; @@ -1367,7 +1395,17 @@ export async function hydrateDecisionComments( decisionStatus: string ): Promise { if (rows.length === 0) return; + return traced('комментарии к решению (REST)', () => + hydrateDecisionCommentsInner(context, types, rows, decisionStatus) + ); +} +async function hydrateDecisionCommentsInner( + context: WebPartContext, + types: IRequestType[], + rows: IMyRequest[], + decisionStatus: string +): Promise { const typeById = new Map(); types.forEach((t) => typeById.set(t.id, t)); @@ -1387,11 +1425,26 @@ export async function hydrateDecisionComments( rowsByPartition.set(key, bucket); }); + // Адреса/имена списков согласования — одним резолвом на все партиции сразу и, + // как правило, вовсе без сети: их уже сложил в кеш buildPendingIndex + // (см. shared/approval/approvalListLocator.ts). Раньше здесь был отдельный + // REST за Title списка данных на КАЖДУЮ партицию страницы. + const located = await locateApprovalLists( + context, + Array.from(rowsByPartition.values()).map(({ target }) => ({ + listId: target.listId, + siteUrl: target.siteUrl, + })) + ); + await Promise.all( Array.from(rowsByPartition.values()).map(async ({ target, typeName, rows: typeRows }) => { try { - const approvalList = await approvalListFor(context, target); - if (!approvalList) return; + const lookup = located.get(approvalListKey(target.siteUrl, target.listId)); + if (!lookup || lookup.kind !== 'ok') return; + const approvalList = spForSite(target.siteUrl, context).web.lists.getByTitle( + lookup.value.approvalListTitle + ); // TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает // сильнее `or», без скобок фильтр разобрался бы как @@ -1523,7 +1576,10 @@ export async function searchOwnAll( traceSearchHits('Свои заявки', kql, types, results, listAbsUrl); const entries = hitsToEntries(results, types); - const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); + const byKey = + entries.length > 0 + ? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own')) + : new Map(); const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r); traceResultRows('Свои заявки', types, entries, rows); diff --git a/src/webparts/requestPortal/shared/approval/approvalListLocator.ts b/src/webparts/requestPortal/shared/approval/approvalListLocator.ts new file mode 100644 index 0000000..5435a49 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/approvalListLocator.ts @@ -0,0 +1,207 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { spForSite } from '../pnp/pnpjs'; +import '@pnp/sp/webs'; +import '@pnp/sp/lists'; + +/** + * Где лежит список согласования каждой партиции — Title и абсолютный адрес. + * + * Зачем отдельный модуль. Адрес списка «X — согласование» нельзя вывести из + * адреса списка «X — данные»: approval-route хранит ссылку только на список + * данных, а сам список согласования SharePoint создаёт под произвольным + * номером (…/Lists/List37). Раньше это означало ОТДЕЛЬНЫЙ REST-запрос на + * КАЖДУЮ партицию при каждой загрузке очереди согласования, а партиций с + * архивацией уже больше десятка (трассировка 2026-08-18: 11 партиций). + * + * Здесь то же самое стоит ОДИН запрос на сайт: /_api/web/lists отдаёт Title и + * RootFolder всех списков веба разом, дальше сопоставление идёт в памяти. И + * этот запрос делается один раз за сессию — адрес списка не меняется, пока + * список существует (партиции только добавляются, а переименование Title не + * трогает ServerRelativeUrl). + * + * Кешируются ТОЛЬКО успехи. Промах кешировать нельзя: новая партиция появляется + * асинхронно (см. getCurrentListPartition — он ставит создание следующей пары + * списков в очередь), и закешированное «не найдено» скрывало бы заявки нового + * списка до перезагрузки страницы. + */ + +// Конвенция именования пары списков — та же, что в shared/listTitles.ts и в +// админке approval-route. Меняя здесь, менять и там. +const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; + +// Потолок на число списков, которое забираем с сайта за раз. Списков на сайте +// портала примерно два на партицию плюс системные — запас многократный. При +// упоре в потолок пишем предупреждение: молча «не найденная» партиция выглядела +// бы как пропавшие заявки. +const SITE_LISTS_TOP = 5000; + +/** Список данных партиции: откуда читаем и что о нём уже известно бесплатно. */ +export interface IDataListRef { + /** Серверный относительный url списка ДАННЫХ. */ + listId: string; + siteUrl: string; + /** Title списка данных из реестра партиций, если реестр загрузился. */ + title?: string; +} + +export interface IApprovalListLocation { + approvalListTitle: string; + /** Абсолютный префикс пути элементов списка согласования, СО слешем на конце. */ + approvalListAbsUrl: string; +} + +export type ApprovalListLookup = + | { kind: 'ok'; value: IApprovalListLocation } + | { kind: 'skip'; reason: string } + | { kind: 'error'; message: string }; + +/** Ключ и кеша, и возвращаемой карты: партиция однозначна парой сайт+список. */ +export function approvalListKey(siteUrl: string, listId: string): string { + return `${siteUrl}|${listId}`.toLowerCase(); +} + +const cache = new Map(); + +/** Забыть одну партицию — вызывать, когда чтение по закешированному адресу упало. */ +export function forgetApprovalList(siteUrl: string, listId: string): void { + cache.delete(approvalListKey(siteUrl, listId)); +} + +/** Полный сброс — на случай перезагрузки реестра партиций. */ +export function resetApprovalListCache(): void { + cache.clear(); +} + +/** Схема одного списка в ответе /_api/web/lists. */ +interface ISiteListRow { + Title?: string; + RootFolder?: { ServerRelativeUrl?: string }; +} + +/** Title и адреса всех списков одного веба — в обе стороны. */ +interface ISiteListMap { + urlByTitle: Map; + titleByUrl: Map; +} + +export function siteOrigin(siteUrl: string): string { + const match = /^https?:\/\/[^/]+/.exec(siteUrl); + return match ? match[0] : siteUrl; +} + +// Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с +// путями элементов …/Lists/List10 (та же причина, что у listPathPrefix +// в features/myRequests/myRequests.service.ts). +function absPrefix(siteUrl: string, serverRelativeUrl: string): string { + return `${siteOrigin(siteUrl)}${serverRelativeUrl.replace(/\/+$/, '')}/`; +} + +async function loadSiteLists(context: WebPartContext, siteUrl: string): Promise { + const sp = spForSite(siteUrl, context); + const rows = (await sp.web.lists + .select('Title', 'RootFolder/ServerRelativeUrl') + .expand('RootFolder') + .top(SITE_LISTS_TOP)()) as ISiteListRow[]; + + if (rows.length === SITE_LISTS_TOP) { + console.warn( + `[Списки согласования] ${siteUrl}: сайт отдал ${SITE_LISTS_TOP} списков — часть партиций может не найтись` + ); + } + + const urlByTitle = new Map(); + const titleByUrl = new Map(); + rows.forEach((r) => { + const title = r.Title || ''; + const relUrl = r.RootFolder?.ServerRelativeUrl || ''; + if (!title || !relUrl) return; + urlByTitle.set(title.toLowerCase(), relUrl); + // По адресу — чтобы узнать Title списка ДАННЫХ, когда реестр партиций не + // загрузился и ref.title пуст. Раньше на это уходил ещё один REST на партицию. + titleByUrl.set(relUrl.replace(/\/+$/, '').toLowerCase(), title); + }); + + return { urlByTitle, titleByUrl }; +} + +function locate(ref: IDataListRef, site: ISiteListMap): ApprovalListLookup { + const dataTitle = ref.title ?? site.titleByUrl.get(ref.listId.replace(/\/+$/, '').toLowerCase()) ?? ''; + if (!dataTitle) { + return { + kind: 'error', + message: `Список данных «${ref.listId}» не найден среди списков сайта ${ref.siteUrl} (удалён или нет прав)` + }; + } + if (!DATA_LIST_SUFFIX.test(dataTitle)) { + return { kind: 'skip', reason: `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем` }; + } + + const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`; + const relUrl = site.urlByTitle.get(approvalListTitle.toLowerCase()); + if (!relUrl) { + // Коллекция /_api/web/lists секьюрити-тримится, поэтому «списка нет» и + // «нет прав» здесь неотличимы — говорим про оба случая сразу. + return { + kind: 'error', + message: `Список согласования «${approvalListTitle}» не найден на ${ref.siteUrl} (нет такого списка или нет прав на него)` + }; + } + + return { + kind: 'ok', + value: { approvalListTitle, approvalListAbsUrl: absPrefix(ref.siteUrl, relUrl) } + }; +} + +/** + * Найти списки согласования сразу для всех переданных партиций. + * + * Сеть — не больше одного запроса на САЙТ, и только за теми партициями, которых + * ещё нет в кеше. Отказ по одному сайту не лишает результата партиции других + * сайтов: они резолвятся независимо. + */ +export async function locateApprovalLists( + context: WebPartContext, + refs: IDataListRef[] +): Promise> { + const result = new Map(); + + const missingBySite = new Map(); + refs.forEach((ref) => { + const key = approvalListKey(ref.siteUrl, ref.listId); + const hit = cache.get(key); + if (hit) { + result.set(key, { kind: 'ok', value: hit }); + return; + } + const bucket = missingBySite.get(ref.siteUrl.toLowerCase()) ?? []; + bucket.push(ref); + missingBySite.set(ref.siteUrl.toLowerCase(), bucket); + }); + + await Promise.all( + Array.from(missingBySite.values()).map(async (siteRefs) => { + const siteUrl = siteRefs[0].siteUrl; + let site: ISiteListMap; + try { + site = await loadSiteLists(context, siteUrl); + } catch (e) { + const message = `Не удалось прочитать списки сайта ${siteUrl}: ${(e as Error).message}`; + siteRefs.forEach((ref) => result.set(approvalListKey(ref.siteUrl, ref.listId), { kind: 'error', message })); + return; + } + + siteRefs.forEach((ref) => { + const key = approvalListKey(ref.siteUrl, ref.listId); + const lookup = locate(ref, site); + // Кешируем только успехи: партиция могла быть создана секунду назад и + // ещё не попасть в ответ — закешированный промах спрятал бы её заявки + // до перезагрузки страницы. + if (lookup.kind === 'ok') cache.set(key, lookup.value); + result.set(key, lookup); + }); + }) + ); + + return result; +} diff --git a/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts b/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts index cd44e43..6321ca7 100644 --- a/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts @@ -87,7 +87,7 @@ export async function getCurrentListPartition( // а не молча повторять один и тот же провал до конца сессии. let requestTypesPromise: Promise | undefined; -async function getRequestTypesCached(context: WebPartContext): Promise { +export async function getRequestTypesCached(context: WebPartContext): Promise { if (!requestTypesPromise) { requestTypesPromise = getRequestTypes(context).catch((e) => { requestTypesPromise = undefined; diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index ebb26d8..3fc9d9f 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -24,6 +24,7 @@ export type { export { getRequestTypes, + getRequestTypesCached, getRequestTypeIdByPageUrl, getListPartitions, getCurrentListPartition, @@ -40,6 +41,9 @@ export { registerCurrentPartition, } from './listPartitions'; export { resolveWriteListId } from './writeListId'; +export { locateApprovalLists, approvalListKey, forgetApprovalList, siteOrigin } from './approvalListLocator'; +export type { IDataListRef, IApprovalListLocation, ApprovalListLookup } from './approvalListLocator'; +export { currentUserIdFor, ensureUserIdFor } from './siteUser'; export { useRequestTypeId } from './useRequestTypeId'; export type { IRequestTypeIdState } from './useRequestTypeId'; export { ApprovalListService } from './approvalList.service'; diff --git a/src/webparts/requestPortal/shared/approval/listPartitions.ts b/src/webparts/requestPortal/shared/approval/listPartitions.ts index f2107f5..4114f95 100644 --- a/src/webparts/requestPortal/shared/approval/listPartitions.ts +++ b/src/webparts/requestPortal/shared/approval/listPartitions.ts @@ -1,5 +1,5 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; -import { getListPartitions, getRequestTypes } from './approvalRoute.service'; +import { getListPartitions, getRequestTypesCached } from './approvalRoute.service'; import { ICurrentListPartition, IListPartition } from './models/IApprovalRouteApi'; /** @@ -36,7 +36,10 @@ export async function initListPartitions(context: WebPartContext): Promise try { // Серии — по числовому id, а имена списков в формах — по слагу (pageUrl), // поэтому нужен и справочник типов, чтобы связать одно с другим. - const [series, types] = await Promise.all([getListPartitions(context), getRequestTypes(context)]); + // Кешированный вариант: тот же ответ нужен потом и getLinkedTypes («Мои + // заявки»), и useRequestTypeId в каждой форме — здесь он заодно прогревает + // общий кеш, чтобы позже за ним никто не ходил повторно. + const [series, types] = await Promise.all([getListPartitions(context), getRequestTypesCached(context)]); partitionsByTypeId.clear(); typeIdByPageUrl.clear(); diff --git a/src/webparts/requestPortal/shared/approval/siteUser.ts b/src/webparts/requestPortal/shared/approval/siteUser.ts new file mode 100644 index 0000000..3cafabe --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/siteUser.ts @@ -0,0 +1,65 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { spForSite } from '../pnp/pnpjs'; +import '@pnp/sp/webs'; +import '@pnp/sp/site-users/web'; + +/** + * Числовой Id пользователя НА КОНКРЕТНОМ САЙТЕ — с кешом на сессию. + * + * Зачем: почти все фильтры SharePoint REST по Person-полям строятся по числовому + * Id (`AuthorId eq N`, `TansuSiteApproverId eq N`) — фильтр по EMail у Person-поля + * ненадёжен. Id свой на каждом сайте, поэтому его приходится резолвить, но + * в пределах сессии он не меняется. + * + * Раньше резолв стоял ВНУТРИ функций, которые вызываются в цикле по типам заявок + * (getOwnRowsForType / getRecentlyChangedOwnRowsForType): при восьми типах это + * восемь одинаковых GET /_api/web/currentuser подряд, и каждый — лишний + * round-trip ПЕРЕД основным запросом. ensureUser в очереди согласования — то же + * самое, только POST. + * + * Ошибку НЕ кешируем: упавший на холодном старте запрос должен повториться, + * а не залипнуть до конца сессии (тот же принцип, что у requestTypesPromise + * в approvalRoute.service.ts). + */ + +const byKey = new Map>(); + +function remember(key: string, load: () => Promise): Promise { + const pending = byKey.get(key); + if (pending) return pending; + + const promise = load().catch((e) => { + byKey.delete(key); + throw e; + }); + byKey.set(key, promise); + return promise; +} + +/** Полный сброс — на случай смены личности (отладочная подмена) или реинициализации. */ +export function resetSiteUserCache(): void { + byKey.clear(); +} + +/** Id ТЕКУЩЕГО (реального) пользователя на сайте. */ +export async function currentUserIdFor(context: WebPartContext, siteUrl: string): Promise { + return remember(`current|${siteUrl.toLowerCase()}`, async () => { + const me = await spForSite(siteUrl, context).web.currentUser.select('Id')(); + return me.Id as number; + }); +} + +/** + * Id пользователя по claims-логину. ensureUser — это POST (он при необходимости + * заводит пользователя в коллекции сайта), поэтому повторять его особенно жаль. + */ +export async function ensureUserIdFor( + context: WebPartContext, + siteUrl: string, + claimsLogin: string +): Promise { + return remember(`ensure|${siteUrl.toLowerCase()}|${claimsLogin.toLowerCase()}`, async () => { + const user = await spForSite(siteUrl, context).web.ensureUser(claimsLogin); + return user.Id; + }); +} diff --git a/src/webparts/requestPortal/shared/perfTrace.ts b/src/webparts/requestPortal/shared/perfTrace.ts new file mode 100644 index 0000000..daca7c6 --- /dev/null +++ b/src/webparts/requestPortal/shared/perfTrace.ts @@ -0,0 +1,196 @@ +/** + * Замер времени ЭТАПОВ загрузки — «на что именно ушло ожидание», а не просто + * «страница долго грузится». + * + * Зачем отдельно от вкладки Network: браузер показывает запросы, но не + * логические этапы. Из списка URL не видно, что десяток одинаковых + * `.../rootfolder` — это резолв адресов списков согласования по партициям, а + * два одинаковых Search-запроса подряд — это один и тот же индекс, собранный + * дважды (счётчиками меню и самой таблицей). Метки дают этот срез. + * + * Включение/выключение БЕЗ пересборки — из консоли браузера: + * window.rpPerf = true // включить + * window.rpPerf = false // выключить + * + * Флаг ЗАПОМИНАЕТСЯ в localStorage: выставил один раз — работает и после + * перезагрузки страницы, и завтра. Разбор производительности обычно идёт + * несколькими заходами с F5 между ними, и переписывать флаг в консоль каждый + * раз — ровно та мелочь, из-за которой замером перестают пользоваться. + * + * Сделано через свойство-аксессор на window, а не через отдельную функцию + * setPerf(): в консоли естественно написать `window.rpPerf = true`, и это + * должно работать. Сеттер кладёт значение в localStorage, геттер отдаёт + * текущее — снаружи флаг выглядит обычным полем. + * + * По умолчанию выключено: предыдущая версия этого модуля (коммит 7bd540e) + * писала в консоль всегда, без флага, и её пришлось снимать целиком вместе со + * всеми обёртками. С флагом обёртки могут жить в коде постоянно — выключенный + * traced() не делает ничего, кроме вызова переданной функции. + */ + +const DEFAULT_PERF: boolean = false; + +const PREFIX = '[Перф]'; + +// Ключ в localStorage совпадает с именем флага — чтобы в DevTools → Application +// его можно было найти и снять, не зная про этот модуль. +const STORAGE_KEY = 'rpPerf'; + +/** Накопленное по одной метке за текущую верхнеуровневую операцию. */ +interface IStat { + calls: number; + totalMs: number; + maxMs: number; +} + +const stats = new Map(); + +// Глубина открытых perfScope. Сводка печатается, когда закрылась ПОСЛЕДНЯЯ +// внешняя операция: этапы почти всегда идут параллельно (Promise.all), и +// печатать таблицу на каждом вложенном завершении значило бы печатать её +// десятками кусков. Если две верхнеуровневые операции наложились по времени +// (типичный случай: счётчики меню и загрузка таблицы стартуют вместе), их +// метки попадут в одну таблицу — это и нужно, там как раз видны дубли. +let openScopes = 0; +let scopeStart = 0; +let scopeLabels: string[] = []; + +// localStorage может быть недоступен (приватный режим, политика браузера, +// iframe с ограничениями) — отладочный флаг не повод падать, поэтому обе +// операции обёрнуты и молча откатываются к значению в памяти. +function readStoredFlag(): boolean | undefined { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw === null) return undefined; + return raw === 'true'; + } catch { + return undefined; + } +} + +function writeStoredFlag(value: boolean): void { + try { + window.localStorage.setItem(STORAGE_KEY, value ? 'true' : 'false'); + } catch { + // Значение всё равно применится — просто не переживёт перезагрузку. + } +} + +let enabled: boolean = readStoredFlag() ?? DEFAULT_PERF; + +function perfEnabled(): boolean { + return enabled; +} + +/** + * Вешает window.rpPerf как свойство с геттером и сеттером. + * + * Вызывается один раз при загрузке модуля. Значение, которое кто-то успел + * присвоить ДО этого (например, в снипете, выполненном раньше), не теряем — + * подхватываем и запоминаем. + */ +function installFlag(): void { + const w = window as unknown as { rpPerf?: boolean }; + + const existing = Object.getOwnPropertyDescriptor(w, 'rpPerf'); + if (existing && existing.get) return; + + const preset = typeof w.rpPerf === 'boolean' ? w.rpPerf : undefined; + + Object.defineProperty(w, 'rpPerf', { + configurable: true, + get: (): boolean => enabled, + set: (value: unknown): void => { + enabled = !!value; + writeStoredFlag(enabled); + console.log(`${PREFIX} замер ${enabled ? 'включён' : 'выключен'} (запомнено до отмены)`); + } + }); + + if (preset !== undefined) { + enabled = preset; + writeStoredFlag(preset); + } else if (enabled) { + // Иначе легко забыть, что флаг остался включённым с прошлого раза, и + // потом гадать, откуда в консоли таблицы. + console.log(`${PREFIX} замер включён (запомнен ранее). Выключить: window.rpPerf = false`); + } +} + +installFlag(); + +function record(label: string, ms: number): void { + const stat = stats.get(label) ?? { calls: 0, totalMs: 0, maxMs: 0 }; + stat.calls += 1; + stat.totalMs += ms; + stat.maxMs = Math.max(stat.maxMs, ms); + stats.set(label, stat); +} + +/** + * Обернуть один этап замером. Метку делай осмысленной и повторяемой: одинаковые + * этапы разных партиций должны попадать в ОДНУ строку сводки (число вызовов + * важнее, чем какая именно партиция), поэтому в метку не надо подставлять + * listId — только имя этапа и, при необходимости, тип заявки. + */ +export async function traced(label: string, fn: () => Promise): Promise { + if (!perfEnabled()) return fn(); + + const start = performance.now(); + try { + return await fn(); + } finally { + record(label, performance.now() - start); + } +} + +/** + * Верхнеуровневая операция (открытие вкладки, счётчики меню, открытие заявки). + * Когда она завершается, печатается сводная таблица по всем меткам внутри. + * + * Смотреть надо на ДВА числа сразу: «сумма» показывает, куда ушла работа, а + * «стена» — сколько на самом деле ждал пользователь. Этапы идут параллельно, + * поэтому сумма бывает в разы больше стены, и оптимизировать по одной только + * сумме — значит чинить не то. + */ +export async function perfScope(label: string, fn: () => Promise): Promise { + if (!perfEnabled()) return fn(); + + if (openScopes === 0) { + stats.clear(); + scopeStart = performance.now(); + scopeLabels = []; + } + openScopes += 1; + scopeLabels.push(label); + + try { + return await fn(); + } finally { + openScopes -= 1; + if (openScopes === 0) print(performance.now() - scopeStart); + } +} + +function print(wallMs: number): void { + if (stats.size === 0) return; + + const rows = Array.from(stats.entries()) + .map(([label, s]) => ({ + 'этап': label, + 'вызовов': s.calls, + 'сумма, мс': Math.round(s.totalMs), + 'макс, мс': Math.round(s.maxMs) + })) + .sort((a, b) => b['сумма, мс'] - a['сумма, мс']); + + const total = rows.reduce((acc, r) => acc + r['сумма, мс'], 0); + + console.log( + `${PREFIX} ${scopeLabels.join(' + ')}: стена ${Math.round(wallMs)} мс, сумма по этапам ${total} мс` + ); + console.table(rows); + + stats.clear(); + scopeLabels = []; +}