Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
@@ -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;
|
||||
|
||||
async function getRequestTypesCached(context: WebPartContext): Promise<IRequestType[]> {
|
||||
export async function getRequestTypesCached(context: WebPartContext): Promise<IRequestType[]> {
|
||||
if (!requestTypesPromise) {
|
||||
requestTypesPromise = getRequestTypes(context).catch((e) => {
|
||||
requestTypesPromise = undefined;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<void>
|
||||
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();
|
||||
|
||||
@@ -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,488 @@
|
||||
import { TimelinePipe } from '@pnp/core';
|
||||
import { Queryable } from '@pnp/queryable';
|
||||
|
||||
/**
|
||||
* Замер загрузки: «на что именно ушло ожидание», а не просто «страница долго
|
||||
* грузится».
|
||||
*
|
||||
* Пишет ДВА среза сразу, и они дополняют друг друга:
|
||||
* • ЭТАПЫ — логические шаги, размеченные вручную через traced()/perfScope().
|
||||
* Из списка URL не видно, что десяток одинаковых `.../rootfolder` —
|
||||
* это резолв адресов списков согласования по партициям.
|
||||
* • ЗАПРОСЫ — КАЖДЫЙ HTTP-вызов PnPjs, сгруппированный по виду операции.
|
||||
* Снимается автоматически (см. PerfObserver ниже, подключён в
|
||||
* shared/pnp/pnpjs.ts), поэтому покрывает ВСЕ разделы портала —
|
||||
* включая формы и панель заявки, где ручной разметки нет.
|
||||
*
|
||||
* Складывать эти два среза между собой нельзя: запросы лежат ВНУТРИ этапов,
|
||||
* сумма получилась бы двойной. Поэтому и таблицы две, с отдельными итогами.
|
||||
*
|
||||
* Как читать: «ОЖИДАНИЕ» — сколько реально прошло от начала операции до конца,
|
||||
* то есть ровно то, что чувствует пользователь. Сумма времён отдельных шагов
|
||||
* всегда БОЛЬШЕ: шаги идут параллельно (Promise.all) и их времена накладываются.
|
||||
* Поэтому большая сумма у шага сама по себе ещё ничего не значит — важно, лежит
|
||||
* ли он на последовательной цепочке. Признак: сложил несколько шагов и получил
|
||||
* примерно ожидание — значит они идут друг за другом, и сокращать надо их.
|
||||
*
|
||||
* Включение/выключение БЕЗ пересборки — из консоли браузера:
|
||||
* window.rpPerf = true // включить
|
||||
* window.rpPerf = false // выключить
|
||||
* window.rpPerfLast // последняя сводка объектом (скопировать/отдать)
|
||||
*
|
||||
* Флаг ЗАПОМИНАЕТСЯ в localStorage: выставил один раз — работает и после
|
||||
* перезагрузки. Разбор производительности идёт несколькими заходами с F5 между
|
||||
* ними, и переписывать флаг каждый раз — ровно та мелочь, из-за которой замером
|
||||
* перестают пользоваться. Сделано свойством-аксессором на window, а не функцией
|
||||
* setPerf(): в консоли естественно написать `window.rpPerf = true`.
|
||||
*
|
||||
* По умолчанию выключено: предыдущая версия модуля (коммит 7bd540e) писала в
|
||||
* консоль всегда, без флага, и её пришлось снимать целиком вместе со всеми
|
||||
* обёртками. С флагом обёртки живут в коде постоянно — выключенный traced() не
|
||||
* делает ничего, кроме вызова переданной функции.
|
||||
*/
|
||||
|
||||
const DEFAULT_PERF: boolean = false;
|
||||
|
||||
const PREFIX = '[Перф]';
|
||||
|
||||
// Ключ в localStorage совпадает с именем флага — чтобы в DevTools → Application
|
||||
// его можно было найти и снять, не зная про этот модуль.
|
||||
const STORAGE_KEY = 'rpPerf';
|
||||
|
||||
// Через сколько тишины считать операцию законченной и печатать сводку, если её
|
||||
// никто не обрамил perfScope. Нужно для разделов без ручной разметки (панель
|
||||
// заявки, формы): там просто идут запросы, и «загрузка закончилась» видно только
|
||||
// по тому, что новых запросов больше нет.
|
||||
const IDLE_FLUSH_MS = 900;
|
||||
|
||||
// Сколько самых тяжёлых меток показывать прямо в строке. Больше трёх — и строка
|
||||
// перестаёт читаться с одного взгляда, а ради этого всё и затевалось;
|
||||
// остальное смотрится через window.rpPerfDetail().
|
||||
const TOP_IN_LINE = 3;
|
||||
|
||||
/** Накопленное по одной метке. */
|
||||
interface IStat {
|
||||
calls: number;
|
||||
totalMs: number;
|
||||
maxMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сэмплы одной метки по всем прогонам — база для МЕДИАНЫ, а не среднего.
|
||||
*
|
||||
* Среднее одним выбросом (случайный 429 с ретраем, холодный кеш на первом
|
||||
* заходе) утаскивает за собой всю картину надолго — а именно выбросы здесь
|
||||
* основной случай, не редкость. Медиана его игнорирует: важнее «как обычно»,
|
||||
* а не «как в сумме с той единственной аномалией».
|
||||
*/
|
||||
interface ILabelSamples {
|
||||
/** totalMs метки за каждый прогон, где она встретилась (метка не обязана быть в каждом). */
|
||||
totalMs: number[];
|
||||
calls: number[];
|
||||
/** Худший случай за всё время — в отличие от totalMs, это не медиана, а running max. */
|
||||
maxMs: number;
|
||||
}
|
||||
|
||||
// Сколько последних прогонов метки хранить ради медианы. Не безлимитный рост:
|
||||
// в долгой сессии старые сэмплы устаревают быстрее, чем на них смотрят, а лимит
|
||||
// защищает от утечки памяти на вкладке, оставленной открытой на весь день.
|
||||
const MAX_SAMPLES = 50;
|
||||
|
||||
function pushSample(list: number[], value: number): void {
|
||||
list.push(value);
|
||||
if (list.length > MAX_SAMPLES) list.shift();
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
/** Накопленное по одной операции за ВСЕ её прогоны — база для медианы. */
|
||||
interface IRunHistory {
|
||||
wallSamples: number[];
|
||||
stages: Map<string, ILabelSamples>;
|
||||
requests: Map<string, ILabelSamples>;
|
||||
}
|
||||
|
||||
// Текущий (ещё не напечатанный) прогон.
|
||||
const stages = new Map<string, IStat>();
|
||||
const requests = new Map<string, IStat>();
|
||||
|
||||
// История по операциям — живёт до перезагрузки страницы или window.rpPerfReset().
|
||||
const history = new Map<string, IRunHistory>();
|
||||
|
||||
// Глубина открытых perfScope. Сводка печатается, когда закрылась ПОСЛЕДНЯЯ
|
||||
// внешняя операция: шаги почти всегда идут параллельно, и печатать таблицу на
|
||||
// каждом вложенном завершении значило бы печатать её десятками кусков. Если две
|
||||
// верхнеуровневые операции наложились (типичный случай: счётчики меню и загрузка
|
||||
// таблицы стартуют вместе), их метки попадут в одну таблицу — это и нужно, там
|
||||
// как раз видны дубли.
|
||||
let openScopes = 0;
|
||||
let scopeLabels: string[] = [];
|
||||
|
||||
// Границы текущего окна накопления — для «ОЖИДАНИЯ». Начало ставит открытие
|
||||
// scope либо первая запись, конец двигает каждая следующая.
|
||||
let windowStart = 0;
|
||||
let windowEnd = 0;
|
||||
|
||||
let idleTimer: number | undefined;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Флаг
|
||||
|
||||
function readStoredFlag(): boolean | undefined {
|
||||
// localStorage может быть недоступен (приватный режим, политика браузера,
|
||||
// ограниченный iframe) — отладочный флаг не повод падать.
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
if (!enabled) {
|
||||
// Тишина СРАЗУ, а не после того, что уже накопилось. Без этого operация,
|
||||
// стартовавшая ДО выключения (perfScope с сетевым шагом внутри) или
|
||||
// ждущая debounce-таймера (см. scheduleIdleFlush), всё равно печатала
|
||||
// итог ПОСЛЕ выключения — выглядело так, будто window.rpPerf = false
|
||||
// не действует.
|
||||
if (idleTimer !== undefined) {
|
||||
window.clearTimeout(idleTimer);
|
||||
idleTimer = undefined;
|
||||
}
|
||||
reset();
|
||||
}
|
||||
console.log(`${PREFIX} замер ${enabled ? 'включён' : 'выключен'} (запомнено до отмены)`);
|
||||
}
|
||||
});
|
||||
|
||||
if (preset !== undefined) {
|
||||
enabled = preset;
|
||||
writeStoredFlag(preset);
|
||||
} else if (enabled) {
|
||||
// Иначе легко забыть, что флаг остался включённым с прошлого раза, и потом
|
||||
// гадать, откуда в консоли таблицы.
|
||||
console.log(`${PREFIX} замер включён (запомнен ранее). Выключить: window.rpPerf = false`);
|
||||
}
|
||||
}
|
||||
|
||||
installFlag();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Накопление
|
||||
|
||||
function record(into: Map<string, IStat>, label: string, ms: number): void {
|
||||
const now = performance.now();
|
||||
if (windowStart === 0) windowStart = now - ms;
|
||||
windowEnd = now;
|
||||
|
||||
const stat = into.get(label) ?? { calls: 0, totalMs: 0, maxMs: 0 };
|
||||
stat.calls += 1;
|
||||
stat.totalMs += ms;
|
||||
stat.maxMs = Math.max(stat.maxMs, ms);
|
||||
into.set(label, stat);
|
||||
|
||||
scheduleIdleFlush();
|
||||
}
|
||||
|
||||
// Разделы без ручной разметки (панель заявки, формы) сводку сами не закрывают —
|
||||
// закрываем по тишине. Пока открыт perfScope, решает он.
|
||||
function scheduleIdleFlush(): void {
|
||||
if (idleTimer !== undefined) window.clearTimeout(idleTimer);
|
||||
idleTimer = window.setTimeout(() => {
|
||||
idleTimer = undefined;
|
||||
if (openScopes === 0 && perfEnabled()) print();
|
||||
}, IDLE_FLUSH_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Разметка этапов
|
||||
|
||||
/**
|
||||
* Обернуть один этап замером. Метку делай осмысленной и повторяемой: одинаковые
|
||||
* этапы разных партиций должны попадать в ОДНУ строку сводки (число вызовов
|
||||
* важнее, чем какая именно партиция), поэтому в метку не надо подставлять
|
||||
* 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(stages, label, performance.now() - start);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Верхнеуровневая операция (открытие вкладки, счётчики меню). Когда она
|
||||
* завершается, печатается сводка по всему, что накопилось внутри.
|
||||
*/
|
||||
export async function perfScope<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
if (!perfEnabled()) return fn();
|
||||
|
||||
if (openScopes === 0) {
|
||||
reset();
|
||||
windowStart = performance.now();
|
||||
}
|
||||
openScopes += 1;
|
||||
scopeLabels.push(label);
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
openScopes -= 1;
|
||||
// perfEnabled() — на выходе, а не только на входе: операцию могли выключить,
|
||||
// пока она ещё выполнялась (см. setter window.rpPerf выше), и тогда печатать
|
||||
// её итог уже не нужно — accumulated-данные к этому моменту сам setter уже
|
||||
// сбросил.
|
||||
if (openScopes === 0 && perfEnabled()) print();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Автоматический учёт КАЖДОГО запроса PnPjs
|
||||
|
||||
// Вид операции по url — иначе каждая партиция со своим адресом и своим $filter
|
||||
// даёт отдельную строку и таблица нечитаема. Нужна именно ОПЕРАЦИЯ, а не адрес.
|
||||
function classify(rawUrl: string): string {
|
||||
let path: string;
|
||||
let search: string;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
path = parsed.pathname;
|
||||
search = parsed.search;
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
if (/\/_api\/search\/(post)?query/i.test(path)) return 'search/query';
|
||||
if (/\/_api\/\$batch/i.test(path)) return '$batch (пакет)';
|
||||
if (/\/_api\/contextinfo/i.test(path)) return 'contextinfo (дайджест)';
|
||||
|
||||
const api = path.split(/\/_api\//i)[1];
|
||||
if (!api) return path;
|
||||
|
||||
// Аргументы (getByTitle('…'), getList('/sites/…/Lists/List7')) и id элементов
|
||||
// схлопываем — иначе каждая партиция даст свою строку.
|
||||
const op = api.replace(/\((?:[^()]|\([^()]*\))*\)/g, '()').replace(/\/\d+(?=\/|$)/g, '/N');
|
||||
|
||||
// Резолв адреса списка согласования выделяем отдельно — ради него всё и
|
||||
// затевалось, и в общей куче «web/lists()» его было бы не видно.
|
||||
if (/rootfolder/i.test(op) || /rootfolder/i.test(search)) return `адрес списка (RootFolder) — ${op}`;
|
||||
if (/^web\/lists$/i.test(op)) return 'web/lists (все списки веба)';
|
||||
|
||||
return op;
|
||||
}
|
||||
|
||||
interface ITimedQueryable {
|
||||
__rpStart?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior PnPjs: засекает время каждого запроса.
|
||||
*
|
||||
* Подключается один раз в shared/pnp/pnpjs.ts и за счёт этого покрывает ВСЕ
|
||||
* разделы портала, а не только размеченные вручную. Когда замер выключен,
|
||||
* наблюдатели всё равно висят, но не делают ничего дороже чтения флага.
|
||||
*/
|
||||
export function PerfObserver(): TimelinePipe<Queryable> {
|
||||
return (instance: Queryable) => {
|
||||
instance.on.pre(async function (url, init, result) {
|
||||
if (perfEnabled()) (this as unknown as ITimedQueryable).__rpStart = performance.now();
|
||||
return [url, init, result];
|
||||
});
|
||||
|
||||
instance.on.post(async function (url, result) {
|
||||
const start = (this as unknown as ITimedQueryable).__rpStart;
|
||||
if (perfEnabled() && start !== undefined) {
|
||||
record(requests, classify(url.toString()), performance.now() - start);
|
||||
}
|
||||
return [url, result];
|
||||
});
|
||||
|
||||
return instance;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Печать
|
||||
|
||||
function reset(): void {
|
||||
stages.clear();
|
||||
requests.clear();
|
||||
scopeLabels = [];
|
||||
windowStart = 0;
|
||||
windowEnd = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключ, под которым прогоны копятся в историю.
|
||||
*
|
||||
* Номер страницы из метки убираем: пролистать вкладку по страницам — самый
|
||||
* удобный способ набрать несколько замеров одной и той же операции, и считать
|
||||
* медиану надо по ним ВМЕСТЕ. Иначе каждая страница завела бы свою историю из
|
||||
* одного прогона, и медиане не по чему было бы считаться.
|
||||
*/
|
||||
function historyKey(title: string): string {
|
||||
return title.replace(/\s*стр\.\d+/g, '').trim();
|
||||
}
|
||||
|
||||
function recordSamples(target: Map<string, ILabelSamples>, source: Map<string, IStat>): void {
|
||||
source.forEach((s, label) => {
|
||||
const t = target.get(label) ?? { totalMs: [], calls: [], maxMs: 0 };
|
||||
pushSample(t.totalMs, s.totalMs);
|
||||
pushSample(t.calls, s.calls);
|
||||
t.maxMs = Math.max(t.maxMs, s.maxMs);
|
||||
target.set(label, t);
|
||||
});
|
||||
}
|
||||
|
||||
function heaviest(source: Map<string, IStat>, take: number): string {
|
||||
return Array.from(source.entries())
|
||||
.sort((a, b) => b[1].totalMs - a[1].totalMs)
|
||||
.slice(0, take)
|
||||
.map(([label, s]) => `${label} ${Math.round(s.totalMs)}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function totalCalls(source: Map<string, IStat>): number {
|
||||
return Array.from(source.values()).reduce((acc, s) => acc + s.calls, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна строка на загрузку — и всё.
|
||||
*
|
||||
* Раньше здесь печатались две таблицы. Их было невозможно ни окинуть взглядом
|
||||
* при листании страниц, ни скопировать: console.table при переносе из консоли
|
||||
* превращается в кашу из колонок. Полная раскладка никуда не делась — она за
|
||||
* window.rpPerfDetail(), и её зовут тогда, когда строка показала, что смотреть
|
||||
* есть на что.
|
||||
*
|
||||
* Медиана в строке важнее текущего значения: одиночный сетевой замер шумит
|
||||
* настолько, что по нему нельзя сказать, стало лучше или просто повезло, а
|
||||
* среднее в этом же шуме утаскивает один выброс (холодный кеш, ретрай на 429)
|
||||
* далеко за пределы того, что происходит обычно.
|
||||
*/
|
||||
function print(): void {
|
||||
if (idleTimer !== undefined) {
|
||||
window.clearTimeout(idleTimer);
|
||||
idleTimer = undefined;
|
||||
}
|
||||
if (stages.size === 0 && requests.size === 0) return;
|
||||
|
||||
const wallMs = (openScopes > 0 ? performance.now() : windowEnd) - windowStart;
|
||||
const title = scopeLabels.length > 0 ? scopeLabels.join(' + ') : 'без разметки';
|
||||
const key = historyKey(title);
|
||||
|
||||
const hist: IRunHistory = history.get(key) ?? {
|
||||
wallSamples: [],
|
||||
stages: new Map<string, ILabelSamples>(),
|
||||
requests: new Map<string, ILabelSamples>()
|
||||
};
|
||||
pushSample(hist.wallSamples, wallMs);
|
||||
recordSamples(hist.stages, stages);
|
||||
recordSamples(hist.requests, requests);
|
||||
history.set(key, hist);
|
||||
|
||||
// Дольше всего — по этапам, если разметка есть; иначе по видам запросов
|
||||
// (разделы без ручной разметки: панель заявки, формы).
|
||||
const top = heaviest(stages.size > 0 ? stages : requests, TOP_IN_LINE);
|
||||
|
||||
console.log(
|
||||
`${PREFIX} ${key} | ожидание ${Math.round(wallMs)} мс` +
|
||||
` (мед. ${Math.round(median(hist.wallSamples))} за ${hist.wallSamples.length})` +
|
||||
` | запросов ${totalCalls(requests)}` +
|
||||
(top ? ` | дольше всего: ${top}` : '')
|
||||
);
|
||||
|
||||
reset();
|
||||
}
|
||||
|
||||
/** Строки полной раскладки по одной операции — медианы за все её прогоны. */
|
||||
function detailLines(key: string, hist: IRunHistory): string[] {
|
||||
const lines = [
|
||||
`${PREFIX} ${key} - прогонов ${hist.wallSamples.length}, ожидание медиана ${Math.round(median(hist.wallSamples))} мс`
|
||||
];
|
||||
|
||||
const dump = (name: string, source: Map<string, ILabelSamples>): void => {
|
||||
if (source.size === 0) return;
|
||||
lines.push(` ${name} (медиана за прогон)`);
|
||||
Array.from(source.entries())
|
||||
.sort((a, b) => median(b[1].totalMs) - median(a[1].totalMs))
|
||||
.forEach(([label, s]) => {
|
||||
lines.push(
|
||||
` ${Math.round(median(s.totalMs))} мс, вызовов ${Math.round(median(s.calls))}, макс ${Math.round(s.maxMs)} - ${label}`
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
dump('ЭТАПЫ', hist.stages);
|
||||
dump('ЗАПРОСЫ', hist.requests);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная раскладка по всем измеренным операциям — window.rpPerfDetail().
|
||||
*
|
||||
* Возвращает текст, а не только печатает: в консоли Chrome его удобно забрать
|
||||
* целиком через copy(rpPerfDetail()).
|
||||
*/
|
||||
function installDetailApi(): void {
|
||||
const w = window as unknown as { rpPerfDetail?: () => string; rpPerfReset?: () => void };
|
||||
|
||||
w.rpPerfDetail = (): string => {
|
||||
if (history.size === 0) return `${PREFIX} замеров пока нет`;
|
||||
const text = Array.from(history.entries())
|
||||
.reduce<string[]>((acc, [key, hist]) => acc.concat(detailLines(key, hist)), [])
|
||||
.join(String.fromCharCode(10));
|
||||
console.log(text);
|
||||
return text;
|
||||
};
|
||||
|
||||
// Старые прогоны остаются в окне (см. MAX_SAMPLES) и тянут медиану назад —
|
||||
// «стало быстрее» проявится в ней только после того, как их наберётся
|
||||
// больше половины окна. Перед сравнением «до/после» серию надо обнулять.
|
||||
w.rpPerfReset = (): void => {
|
||||
history.clear();
|
||||
reset();
|
||||
console.log(`${PREFIX} история замеров очищена`);
|
||||
};
|
||||
}
|
||||
|
||||
installDetailApi();
|
||||
@@ -7,6 +7,13 @@ import '@pnp/sp/site-users/web';
|
||||
import { BrowserFetchWithRetry } from '@pnp/queryable';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { DATA_SITE_URL } from '../config';
|
||||
import { PerfObserver } from '../perfTrace';
|
||||
|
||||
// PerfObserver подключён к ОБОИМ клиентам ниже: это единственное место, через
|
||||
// которое проходит любой SharePoint-запрос портала, поэтому отладочный замер
|
||||
// (window.rpPerf, см. shared/perfTrace.ts) покрывает сразу все разделы — и
|
||||
// списки заявок, и формы, и панель заявки, — а не только те, где расставлены
|
||||
// метки этапов вручную. Выключенный замер не делает ничего дороже чтения флага.
|
||||
|
||||
// Мягкая ретрай-политика вместо стандартной PnPjs (retries: 3, interval: 200).
|
||||
// Дефолт слишком напорист: при отказе (429 «слишком много запросов», 503) он
|
||||
@@ -25,7 +32,7 @@ let _sp: SPFI | undefined;
|
||||
* списки живут там, где бы ни стоял сам web part. Авторизация берётся из context.
|
||||
*/
|
||||
export function initSP(context: WebPartContext): void {
|
||||
_sp = spfi(DATA_SITE_URL).using(SPFx(context), BrowserFetchWithRetry(RETRY));
|
||||
_sp = spfi(DATA_SITE_URL).using(SPFx(context), BrowserFetchWithRetry(RETRY), PerfObserver());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +43,7 @@ export function initSP(context: WebPartContext): void {
|
||||
* отправки (у behavior replace: true по умолчанию).
|
||||
*/
|
||||
export function spForSite(siteUrl: string, context: WebPartContext): SPFI {
|
||||
return spfi(siteUrl).using(SPFx(context), BrowserFetchWithRetry(RETRY));
|
||||
return spfi(siteUrl).using(SPFx(context), BrowserFetchWithRetry(RETRY), PerfObserver());
|
||||
}
|
||||
|
||||
/** Получить инициализированный клиент. Бросает, если initSP не был вызван. */
|
||||
|
||||
Reference in New Issue
Block a user