трейс и первая оптимизация
This commit is contained in:
@@ -16,6 +16,7 @@ import { IMyRequest } from './models/IMyRequest';
|
|||||||
import { DataTable, IDataTableColumn } from '@tnsu/ui-kit';
|
import { DataTable, IDataTableColumn } from '@tnsu/ui-kit';
|
||||||
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
|
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
|
||||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||||
|
import { perfScope } from '../../shared/perfTrace';
|
||||||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||||
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
@@ -398,7 +399,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
|
|
||||||
const load = async (forceRender = false): Promise<void> => {
|
const load = async (forceRender = false): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const fresh = await fetchPage();
|
const fresh = await perfScope(`таблица ${view} стр.${page}`, fetchPage);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
// Перерисовываем, только если данные реально изменились — иначе при
|
// Перерисовываем, только если данные реально изменились — иначе при
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getLinkedTypes, hydratePendingAuthors, searchMyRequests } from './myReq
|
|||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
import { IRequestType, StatusBadge } from '../../shared/approval';
|
import { IRequestType, StatusBadge } from '../../shared/approval';
|
||||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||||
|
import { perfScope } from '../../shared/perfTrace';
|
||||||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||||
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
|
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
@@ -185,7 +186,9 @@ export const SearchView: React.FC<ISearchViewProps> = ({
|
|||||||
|
|
||||||
const load = async (forceRender = false): Promise<void> => {
|
const load = async (forceRender = false): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const fresh = await searchMyRequests(context, types, debouncedQuery);
|
const fresh = await perfScope('вкладка «Поиск»', () =>
|
||||||
|
searchMyRequests(context, types, debouncedQuery)
|
||||||
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
applyKnownAuthors(fresh.rows, authors.current);
|
applyKnownAuthors(fresh.rows, authors.current);
|
||||||
const prev = readPage(cacheKey);
|
const prev = readPage(cacheKey);
|
||||||
|
|||||||
@@ -11,15 +11,22 @@ import '@pnp/sp/search';
|
|||||||
// но в рантайме не зарегистрирован.
|
// но в рантайме не зарегистрирован.
|
||||||
import '@pnp/sp/batching';
|
import '@pnp/sp/batching';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
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 { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
||||||
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
||||||
|
import { perfScope, traced } from '../../shared/perfTrace';
|
||||||
// Список данных «... — данные» → список согласования «... — согласование»
|
|
||||||
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
|
|
||||||
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
|
||||||
|
|
||||||
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
|
// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может
|
||||||
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
|
// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход
|
||||||
@@ -86,7 +93,11 @@ export function currentUserEmail(context: WebPartContext): string {
|
|||||||
|
|
||||||
// Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению).
|
// Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению).
|
||||||
export async function getLinkedTypes(context: WebPartContext): Promise<IRequestType[]> {
|
export async function getLinkedTypes(context: WebPartContext): Promise<IRequestType[]> {
|
||||||
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);
|
const linked = types.filter((t) => !!t.listId && !!t.siteUrl);
|
||||||
traceLinkedTypes(types, linked);
|
traceLinkedTypes(types, linked);
|
||||||
return linked;
|
return linked;
|
||||||
@@ -195,28 +206,38 @@ async function buildPendingIndex(
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Резолв Title/адреса списка согласования остаётся ПОПАРТИЦИОННЫМ REST-ом:
|
// Адреса списков согласования всех партиций разом: не больше ОДНОГО
|
||||||
// approval-route не хранит на него ссылку (список согласования ищется только
|
// /_api/web/lists на сайт, а после первого раза — вообще без сети (адреса
|
||||||
// по имени, см. shared/listTitles.ts), значит его АДРЕС узнать больше неоткуда.
|
// кешируются на сессию, см. shared/approval/approvalListLocator.ts). Раньше
|
||||||
// Но это лёгкий $select без постраничного перебора — не то же самое, что
|
// здесь был отдельный REST-запрос на КАЖДУЮ партицию, и он повторялся при
|
||||||
// полноценный Search, который эти вызовы как раз заменяют.
|
// каждой загрузке очереди — при 11 партициях это 11 запросов на проход, а
|
||||||
const resolved = await Promise.all(
|
// проходов за одно открытие вкладки два (счётчики меню + сама таблица).
|
||||||
flat.map(({ type, target }) =>
|
const located = await traced('адреса списков согласования', () =>
|
||||||
resolveApprovalSearchTarget(context, type, target)
|
locateApprovalLists(
|
||||||
|
context,
|
||||||
|
flat.map(({ target }) => ({ listId: target.listId, siteUrl: target.siteUrl, title: target.title }))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const okEntries: IApprovalSearchTarget[] = [];
|
const okEntries: IApprovalSearchTarget[] = [];
|
||||||
resolved.forEach((r) => {
|
flat.forEach(({ type, target }) => {
|
||||||
const debugType = r.kind === 'ok' ? r.value.type : r.type;
|
const debug = debugByType.get(type.id);
|
||||||
const debug = debugByType.get(debugType.id);
|
const lookup = located.get(approvalListKey(target.siteUrl, target.listId));
|
||||||
if (!debug) return;
|
if (!debug || !lookup) return;
|
||||||
if (r.kind === 'ok') {
|
|
||||||
okEntries.push(r.value);
|
if (lookup.kind === 'ok') {
|
||||||
} else if (r.kind === 'skip') {
|
okEntries.push({
|
||||||
if (!debug.approvalSkippedReason) debug.approvalSkippedReason = r.reason;
|
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 {
|
} 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([
|
const [hitsByTarget, recentByTarget] = await Promise.all([
|
||||||
okEntries.length > 0
|
okEntries.length > 0
|
||||||
? searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses)
|
? traced('Search: очередь согласования', () =>
|
||||||
|
searchAcrossApprovalLists(context, okEntries, tokenEmail, statuses)
|
||||||
|
)
|
||||||
: Promise.resolve(new Map<string, number[]>()),
|
: Promise.resolve(new Map<string, number[]>()),
|
||||||
// Довесок нужен только режиму «чья очередь сейчас»: при текстовом поиске
|
// Довесок нужен только режиму «чья очередь сейчас»: при текстовом поиске
|
||||||
// важна полнота по архиву, а не мгновенная свежесть (см. scope='involved').
|
// важна полнота по архиву, а не мгновенная свежесть (см. scope='involved').
|
||||||
okEntries.length > 0 && scope === 'myTurn'
|
okEntries.length > 0 && scope === 'myTurn'
|
||||||
? fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses)
|
? traced('REST-довесок по свежести (очередь)', () =>
|
||||||
|
fetchRecentApprovalRows(context, okEntries, tokenEmail, statuses)
|
||||||
|
)
|
||||||
: Promise.resolve(new Map<string, Array<Record<string, unknown>>>()),
|
: Promise.resolve(new Map<string, Array<Record<string, unknown>>>()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const parts = await Promise.all(
|
const parts = await traced('дочитывание строк очереди (REST)', () =>
|
||||||
okEntries.map((entry) =>
|
Promise.all(
|
||||||
finalizeApprovalTarget(
|
okEntries.map((entry) =>
|
||||||
context,
|
finalizeApprovalTarget(
|
||||||
entry,
|
context,
|
||||||
hitsByTarget.get(targetKey(entry)) ?? [],
|
entry,
|
||||||
recentByTarget.get(targetKey(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)!) };
|
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<string, Promise<IPendingIndex>>();
|
||||||
|
|
||||||
|
function pendingIndexKey(scope: ApprovalScope, statuses: string[]): string {
|
||||||
|
return `${scope}|${[...statuses].sort().join(',')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sharedPendingIndex(
|
||||||
|
context: WebPartContext,
|
||||||
|
scope: ApprovalScope,
|
||||||
|
statuses: string[]
|
||||||
|
): Promise<IPendingIndex> {
|
||||||
|
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
|
// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20
|
||||||
// заявок отдать), автор дочитывается только для них.
|
// заявок отдать), автор дочитывается только для них.
|
||||||
export async function getApprovalQueue(
|
export async function getApprovalQueue(
|
||||||
@@ -288,12 +364,29 @@ export async function getApprovalQueue(
|
|||||||
kind: ApprovalQueueKind,
|
kind: ApprovalQueueKind,
|
||||||
page: number = 1
|
page: number = 1
|
||||||
): Promise<IPendingApprovalPage> {
|
): Promise<IPendingApprovalPage> {
|
||||||
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);
|
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,
|
context: WebPartContext,
|
||||||
kind: ApprovalQueueKind
|
kind: ApprovalQueueKind
|
||||||
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
|
): 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 };
|
return { rows: index, types, debug };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +411,7 @@ export async function getPendingApprovalIndex(
|
|||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
scope: ApprovalScope
|
scope: ApprovalScope
|
||||||
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
|
): 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 };
|
return { rows: index, types, debug };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,14 +431,14 @@ export interface INavCounts {
|
|||||||
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
|
// своим запросом — но параллельно, и ровно тем же вызовом, что наполняет саму
|
||||||
// вкладку: иначе счётчик и список могли бы разойтись.
|
// вкладку: иначе счётчик и список могли бы разойтись.
|
||||||
export async function getNavCounts(context: WebPartContext): Promise<INavCounts> {
|
export async function getNavCounts(context: WebPartContext): Promise<INavCounts> {
|
||||||
return getNavCountsInner(context);
|
return perfScope('счётчики меню', () => getNavCountsInner(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getNavCountsInner(context: WebPartContext): Promise<INavCounts> {
|
async function getNavCountsInner(context: WebPartContext): Promise<INavCounts> {
|
||||||
const types = await getLinkedTypes(context);
|
const types = await getLinkedTypes(context);
|
||||||
|
|
||||||
const [turn, rework] = await Promise.all([
|
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),
|
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -383,67 +476,6 @@ interface IApprovalSearchTarget {
|
|||||||
approvalListAbsUrl: string;
|
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<ApprovalTargetResolution> {
|
|
||||||
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 {
|
function targetKey(entry: IApprovalSearchTarget): string {
|
||||||
return entry.approvalListAbsUrl.toLowerCase();
|
return entry.approvalListAbsUrl.toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -570,16 +602,17 @@ async function fetchRecentApprovalRows(
|
|||||||
try {
|
try {
|
||||||
const sp = spForSite(siteUrl, context);
|
const sp = spForSite(siteUrl, context);
|
||||||
|
|
||||||
// ensureUser — ОДИН раз на сайт. Раньше он вызывался внутри обработки
|
// ensureUser — один раз на сайт И один раз на сессию: результат кешируется
|
||||||
|
// (см. shared/approval/siteUser.ts). Раньше он вызывался внутри обработки
|
||||||
// каждой партиции, то есть повторялся N раз с тем же результатом, и это
|
// каждой партиции, то есть повторялся N раз с тем же результатом, и это
|
||||||
// POST, а не дешёвый GET.
|
// POST, а не дешёвый GET; теперь его не платят даже повторные загрузки.
|
||||||
//
|
//
|
||||||
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
|
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
|
||||||
// SharePoint REST — поэтому резолвим email в числовой Id (как и везде в
|
// SharePoint REST — поэтому резолвим email в числовой Id (как и везде в
|
||||||
// проекте) и фильтруем по TansuSiteApproverId.
|
// проекте) и фильтруем по TansuSiteApproverId.
|
||||||
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
|
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
|
||||||
const approver = await sp.web.ensureUser(claimsLogin);
|
const approverId = await ensureUserIdFor(context, siteUrl, claimsLogin);
|
||||||
const filter = `TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`;
|
const filter = `TansuSiteApproverId eq ${approverId} and Modified ge '${cutoffIso}'${statusFilter}`;
|
||||||
|
|
||||||
const [batchedSP, execute] = sp.batched();
|
const [batchedSP, execute] = sp.batched();
|
||||||
// Обработчики навешиваем СРАЗУ при создании промиса, до execute(): иначе
|
// Обработчики навешиваем СРАЗУ при создании промиса, до execute(): иначе
|
||||||
@@ -725,7 +758,14 @@ async function hydrateAuthors(
|
|||||||
rows: IMyRequest[]
|
rows: IMyRequest[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (rows.length === 0) return;
|
if (rows.length === 0) return;
|
||||||
|
return traced('дочитывание автора (REST)', () => hydrateAuthorsInner(context, types, rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateAuthorsInner(
|
||||||
|
context: WebPartContext,
|
||||||
|
types: IRequestType[],
|
||||||
|
rows: IMyRequest[]
|
||||||
|
): Promise<void> {
|
||||||
const typeById = new Map<number, IRequestType>();
|
const typeById = new Map<number, IRequestType>();
|
||||||
types.forEach((t) => typeById.set(t.id, t));
|
types.forEach((t) => typeById.set(t.id, t));
|
||||||
|
|
||||||
@@ -848,11 +888,6 @@ async function fetchAllSearchRows(
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function siteOrigin(siteUrl: string): string {
|
|
||||||
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
|
|
||||||
return match ? match[0] : siteUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listAbsUrl(type: IRequestType): string {
|
function listAbsUrl(type: IRequestType): string {
|
||||||
return `${siteOrigin(type.siteUrl as string)}${type.listId}`;
|
return `${siteOrigin(type.siteUrl as string)}${type.listId}`;
|
||||||
}
|
}
|
||||||
@@ -1106,11 +1141,14 @@ async function getOwnRowsForType(context: WebPartContext, type: IRequestType): P
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const sp = spForSite(target.siteUrl, context);
|
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
|
const rows = (await sp.web
|
||||||
.getList(target.listId)
|
.getList(target.listId)
|
||||||
.items.filter(`AuthorId eq ${me.Id}`)
|
.items.filter(`AuthorId eq ${myId}`)
|
||||||
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
.expand('Author')
|
.expand('Author')
|
||||||
.orderBy('Created', false)
|
.orderBy('Created', false)
|
||||||
@@ -1158,12 +1196,12 @@ async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: I
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const sp = spForSite(target.siteUrl, context);
|
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 cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
||||||
|
|
||||||
const rows = (await sp.web
|
const rows = (await sp.web
|
||||||
.getList(target.listId)
|
.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')
|
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
.expand('Author')
|
.expand('Author')
|
||||||
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
|
||||||
@@ -1235,21 +1273,23 @@ export async function searchOwnRequests(
|
|||||||
if (text) clauses.push(text);
|
if (text) clauses.push(text);
|
||||||
const kql = clauses.join(' AND ');
|
const kql = clauses.join(' AND ');
|
||||||
|
|
||||||
const results = await sp.search({
|
const results = await traced('Search: свои заявки', () =>
|
||||||
Querytext: kql,
|
sp.search({
|
||||||
RowLimit: PAGE_SIZE,
|
Querytext: kql,
|
||||||
StartRow: (page - 1) * PAGE_SIZE,
|
RowLimit: PAGE_SIZE,
|
||||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
StartRow: (page - 1) * PAGE_SIZE,
|
||||||
TrimDuplicates: false,
|
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
||||||
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
|
TrimDuplicates: false,
|
||||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
|
||||||
});
|
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const totalRows = results.TotalRows || 0;
|
const totalRows = results.TotalRows || 0;
|
||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
const byKey =
|
const byKey =
|
||||||
entries.length > 0
|
entries.length > 0
|
||||||
? await loadByIds(context, entries, 'own')
|
? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own'))
|
||||||
: new Map<string, IMyRequest>();
|
: new Map<string, IMyRequest>();
|
||||||
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
|
||||||
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
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);
|
if (text) clauses.push(text);
|
||||||
const kql = clauses.join(' AND ');
|
const kql = clauses.join(' AND ');
|
||||||
|
|
||||||
const results = await sp.search({
|
const results = await traced('Search: свои заявки', () =>
|
||||||
Querytext: kql,
|
sp.search({
|
||||||
RowLimit: PAGE_SIZE,
|
Querytext: kql,
|
||||||
StartRow: (page - 1) * PAGE_SIZE,
|
RowLimit: PAGE_SIZE,
|
||||||
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
StartRow: (page - 1) * PAGE_SIZE,
|
||||||
TrimDuplicates: false,
|
SelectProperties: ['ListItemID', 'Title', 'Path'],
|
||||||
// Свежие сверху — как в searchOwnRequests.
|
TrimDuplicates: false,
|
||||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
// Свежие сверху — как в searchOwnRequests.
|
||||||
});
|
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const totalRows = results.TotalRows || 0;
|
const totalRows = results.TotalRows || 0;
|
||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
const byKey =
|
const byKey =
|
||||||
entries.length > 0
|
entries.length > 0
|
||||||
? await loadByIds(context, entries, 'own')
|
? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own'))
|
||||||
: new Map<string, IMyRequest>();
|
: new Map<string, IMyRequest>();
|
||||||
const searchRows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
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 };
|
return { rows: merged, totalRows: totalRows + onlyFresh.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Список согласования по типу заявки: Title списка данных → Title списка
|
|
||||||
// согласования по конвенции имён (та же логика, что в finalizeApprovalTarget).
|
|
||||||
// undefined — список заведён не по конвенции, маршрута для него не найти.
|
|
||||||
async function approvalListFor(
|
|
||||||
context: WebPartContext,
|
|
||||||
target: IListTarget
|
|
||||||
): Promise<IList | undefined> {
|
|
||||||
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 заявок, у каждой обычно единицы шагов — запас многократный.
|
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
|
||||||
const DECISION_COMMENT_ROW_LIMIT = 2000;
|
const DECISION_COMMENT_ROW_LIMIT = 2000;
|
||||||
@@ -1367,7 +1395,17 @@ export async function hydrateDecisionComments(
|
|||||||
decisionStatus: string
|
decisionStatus: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (rows.length === 0) return;
|
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<void> {
|
||||||
const typeById = new Map<number, IRequestType>();
|
const typeById = new Map<number, IRequestType>();
|
||||||
types.forEach((t) => typeById.set(t.id, t));
|
types.forEach((t) => typeById.set(t.id, t));
|
||||||
|
|
||||||
@@ -1387,11 +1425,26 @@ export async function hydrateDecisionComments(
|
|||||||
rowsByPartition.set(key, bucket);
|
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(
|
await Promise.all(
|
||||||
Array.from(rowsByPartition.values()).map(async ({ target, typeName, rows: typeRows }) => {
|
Array.from(rowsByPartition.values()).map(async ({ target, typeName, rows: typeRows }) => {
|
||||||
try {
|
try {
|
||||||
const approvalList = await approvalListFor(context, target);
|
const lookup = located.get(approvalListKey(target.siteUrl, target.listId));
|
||||||
if (!approvalList) return;
|
if (!lookup || lookup.kind !== 'ok') return;
|
||||||
|
const approvalList = spForSite(target.siteUrl, context).web.lists.getByTitle(
|
||||||
|
lookup.value.approvalListTitle
|
||||||
|
);
|
||||||
|
|
||||||
// TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает
|
// TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает
|
||||||
// сильнее `or», без скобок фильтр разобрался бы как
|
// сильнее `or», без скобок фильтр разобрался бы как
|
||||||
@@ -1523,7 +1576,10 @@ export async function searchOwnAll(
|
|||||||
traceSearchHits('Свои заявки', kql, types, results, listAbsUrl);
|
traceSearchHits('Свои заявки', kql, types, results, listAbsUrl);
|
||||||
|
|
||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
const byKey =
|
||||||
|
entries.length > 0
|
||||||
|
? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own'))
|
||||||
|
: new Map<string, IMyRequest>();
|
||||||
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
|
||||||
|
|
||||||
traceResultRows('Свои заявки', types, entries, rows);
|
traceResultRows('Свои заявки', types, entries, rows);
|
||||||
|
|||||||
@@ -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<string, IApprovalListLocation>();
|
||||||
|
|
||||||
|
/** Забыть одну партицию — вызывать, когда чтение по закешированному адресу упало. */
|
||||||
|
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<string, string>;
|
||||||
|
titleByUrl: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ISiteListMap> {
|
||||||
|
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<string, string>();
|
||||||
|
const titleByUrl = new Map<string, string>();
|
||||||
|
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<Map<string, ApprovalListLookup>> {
|
||||||
|
const result = new Map<string, ApprovalListLookup>();
|
||||||
|
|
||||||
|
const missingBySite = new Map<string, IDataListRef[]>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ export async function getCurrentListPartition(
|
|||||||
// а не молча повторять один и тот же провал до конца сессии.
|
// а не молча повторять один и тот же провал до конца сессии.
|
||||||
let requestTypesPromise: Promise<IRequestType[]> | undefined;
|
let requestTypesPromise: Promise<IRequestType[]> | undefined;
|
||||||
|
|
||||||
async function getRequestTypesCached(context: WebPartContext): Promise<IRequestType[]> {
|
export async function getRequestTypesCached(context: WebPartContext): Promise<IRequestType[]> {
|
||||||
if (!requestTypesPromise) {
|
if (!requestTypesPromise) {
|
||||||
requestTypesPromise = getRequestTypes(context).catch((e) => {
|
requestTypesPromise = getRequestTypes(context).catch((e) => {
|
||||||
requestTypesPromise = undefined;
|
requestTypesPromise = undefined;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
getRequestTypes,
|
getRequestTypes,
|
||||||
|
getRequestTypesCached,
|
||||||
getRequestTypeIdByPageUrl,
|
getRequestTypeIdByPageUrl,
|
||||||
getListPartitions,
|
getListPartitions,
|
||||||
getCurrentListPartition,
|
getCurrentListPartition,
|
||||||
@@ -40,6 +41,9 @@ export {
|
|||||||
registerCurrentPartition,
|
registerCurrentPartition,
|
||||||
} from './listPartitions';
|
} from './listPartitions';
|
||||||
export { resolveWriteListId } from './writeListId';
|
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 { useRequestTypeId } from './useRequestTypeId';
|
||||||
export type { IRequestTypeIdState } from './useRequestTypeId';
|
export type { IRequestTypeIdState } from './useRequestTypeId';
|
||||||
export { ApprovalListService } from './approvalList.service';
|
export { ApprovalListService } from './approvalList.service';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
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';
|
import { ICurrentListPartition, IListPartition } from './models/IApprovalRouteApi';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +36,10 @@ export async function initListPartitions(context: WebPartContext): Promise<void>
|
|||||||
try {
|
try {
|
||||||
// Серии — по числовому id, а имена списков в формах — по слагу (pageUrl),
|
// Серии — по числовому 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();
|
partitionsByTypeId.clear();
|
||||||
typeIdByPageUrl.clear();
|
typeIdByPageUrl.clear();
|
||||||
|
|||||||
@@ -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<string, Promise<number>>();
|
||||||
|
|
||||||
|
function remember(key: string, load: () => Promise<number>): Promise<number> {
|
||||||
|
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<number> {
|
||||||
|
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<number> {
|
||||||
|
return remember(`ensure|${siteUrl.toLowerCase()}|${claimsLogin.toLowerCase()}`, async () => {
|
||||||
|
const user = await spForSite(siteUrl, context).web.ensureUser(claimsLogin);
|
||||||
|
return user.Id;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<string, IStat>();
|
||||||
|
|
||||||
|
// Глубина открытых 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<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
if (!perfEnabled()) return fn();
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
record(label, performance.now() - start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Верхнеуровневая операция (открытие вкладки, счётчики меню, открытие заявки).
|
||||||
|
* Когда она завершается, печатается сводная таблица по всем меткам внутри.
|
||||||
|
*
|
||||||
|
* Смотреть надо на ДВА числа сразу: «сумма» показывает, куда ушла работа, а
|
||||||
|
* «стена» — сколько на самом деле ждал пользователь. Этапы идут параллельно,
|
||||||
|
* поэтому сумма бывает в разы больше стены, и оптимизировать по одной только
|
||||||
|
* сумме — значит чинить не то.
|
||||||
|
*/
|
||||||
|
export async function perfScope<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
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 = [];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user