From a4e6e097f3e93c92c52c0db7cee3cc834fb370b9 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 20 Aug 2026 17:10:54 +0500 Subject: [PATCH] =?UTF-8?q?=D1=82=D1=80=D0=B5=D0=B9=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/myRequests.service.ts | 75 +++- .../requestPortal/shared/perfTrace.ts | 382 ++++++++++++++---- .../requestPortal/shared/pnp/pnpjs.ts | 11 +- 3 files changed, 385 insertions(+), 83 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 836eacb..58d06d3 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -439,13 +439,13 @@ async function getNavCountsInner(context: WebPartContext): Promise { const [turn, rework] = await Promise.all([ sharedPendingIndex(context, 'myTurn', MY_TURN_STATUSES), - getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1), + countOwnRequestsByStatus(context, types, REQUEST_STATUS.returned), ]); return { approvals: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onApproval).length, execution: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onExecution).length, - rework: rework.totalRows, + rework, }; } @@ -727,14 +727,23 @@ async function finalizeApprovalTarget( // Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см. // комментарий у функции: через Lookup-expand это не отдаётся). + // + // Автор берётся ЗДЕСЬ ЖЕ через expand. Раньше он дочитывался отдельным + // проходом (hydrateAuthors) и только по строкам видимой страницы — чтобы не + // тянуть его на весь индекс. Но экономилась при этом не сеть, а payload: + // запрос к списку данных всё равно делается, и expand не добавляет к нему + // ни одного round-trip'а, тогда как отдельный проход стоил целый — замер + // 2026-08-20 показал 262 мс ровно на критическом пути вкладки + // «На согласовании» (Search → строки очереди → автор). const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or '); const ticketRows = (await list.items .filter(ticketFilter) - .select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created') + .select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') + .expand('Author') .top(ticketIds.length)()) as Array>; debug.rows = ticketRows.map((r) => ({ - ...toMyRequest(r, undefined, type, 'approval', target), + ...toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'approval', target), myStepStatus: stepStatusByTicket.get(r.Id as number), })); @@ -749,16 +758,23 @@ async function finalizeApprovalTarget( } } -// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа — -// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по -// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName). +// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа — только +// по переданным строкам. Мутирует переданные объекты (проставляет +// authorEmail/authorName). +// +// Строки, у которых автор УЖЕ известен, пропускаются: и 'own' (loadByIds), и +// 'approval' (finalizeApprovalTarget) теперь получают его тем же запросом, что +// и остальные поля, поэтому на обычных путях сюда доходить нечему и запрос не +// уходит вовсе. Функция оставлена страховкой на случай источника строк без +// автора — молча показывать пустого инициатора хуже, чем один лишний запрос. async function hydrateAuthors( context: WebPartContext, types: IRequestType[], rows: IMyRequest[] ): Promise { - if (rows.length === 0) return; - return traced('дочитывание автора (REST)', () => hydrateAuthorsInner(context, types, rows)); + const missing = rows.filter((r) => !r.authorName && !r.authorEmail); + if (missing.length === 0) return; + return traced('дочитывание автора (REST)', () => hydrateAuthorsInner(context, types, missing)); } async function hydrateAuthorsInner( @@ -862,6 +878,10 @@ async function fetchAllSearchRows( let startRow = 0; for (;;) { + // Постраничной метки здесь больше нет: замер 2026-08-20 показал, что запрос + // ровно один (847 мс у вызывающего против 846 мс у страницы), то есть + // медленный сам Search, а не перебор StartRow. Вопрос закрыт, а метка + // занимала место в строке сводки, дублируя метку вызывающего. const results = await sp.search({ Querytext: kql, RowLimit: SEARCH_PAGE_SIZE, @@ -1310,6 +1330,36 @@ export async function getOwnRequestsByStatus( status: string, page: number, query: string = '' +): Promise { + return ownRequestsByStatus(context, types, status, page, query, true); +} + +/** + * Только СКОЛЬКО таких заявок — для счётчика «Доработка» в боковом меню. + * + * Отличие от getOwnRequestsByStatus одно: не дочитываются поля найденных заявок + * (loadByIds). Счётчику они не нужны — ему нужен totalRows, — а замер 2026-08-20 + * показал, что этот шаг стоит ~200-300 мс и идёт СТРОГО ПОСЛЕ Search, то есть + * целиком лежит на критическом пути открытия портала. + * + * Условия отбора и довесок по свежести — те же самые, поэтому счётчик и вкладка + * по-прежнему не расходятся (ради чего они и считаются одним кодом). + */ +export async function countOwnRequestsByStatus( + context: WebPartContext, + types: IRequestType[], + status: string +): Promise { + return (await ownRequestsByStatus(context, types, status, 1, '', false)).totalRows; +} + +async function ownRequestsByStatus( + context: WebPartContext, + types: IRequestType[], + status: string, + page: number, + query: string, + withRows: boolean ): Promise { if (types.length === 0) return { rows: [], totalRows: 0 }; @@ -1339,7 +1389,7 @@ export async function getOwnRequestsByStatus( const totalRows = results.TotalRows || 0; const entries = hitsToEntries(results, types); const byKey = - entries.length > 0 + withRows && entries.length > 0 ? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own')) : new Map(); const searchRows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r); @@ -1357,7 +1407,10 @@ export async function getOwnRequestsByStatus( .reduce((acc, r) => acc.concat(r), [] as IMyRequest[]) .filter((r) => r.status === status); - const searchKeys = new Set(searchRows.map((r) => rowKey(r))); + // Ключи для дедупа берём из ХИТОВ индекса, а не из дочитанных строк: пара + // «партиция + Id» известна уже по ответу Search, и режиму счётчика (withRows + // = false) не приходится ради этого дочитывать поля. + const searchKeys = new Set(entries.map((e) => requestKey(e.target.listId, e.id))); const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r))); const merged = onlyFresh diff --git a/src/webparts/requestPortal/shared/perfTrace.ts b/src/webparts/requestPortal/shared/perfTrace.ts index daca7c6..b8f1792 100644 --- a/src/webparts/requestPortal/shared/perfTrace.ts +++ b/src/webparts/requestPortal/shared/perfTrace.ts @@ -1,31 +1,44 @@ +import { TimelinePipe } from '@pnp/core'; +import { Queryable } from '@pnp/queryable'; + /** - * Замер времени ЭТАПОВ загрузки — «на что именно ушло ожидание», а не просто - * «страница долго грузится». + * Замер загрузки: «на что именно ушло ожидание», а не просто «страница долго + * грузится». * - * Зачем отдельно от вкладки Network: браузер показывает запросы, но не - * логические этапы. Из списка URL не видно, что десяток одинаковых - * `.../rootfolder` — это резолв адресов списков согласования по партициям, а - * два одинаковых Search-запроса подряд — это один и тот же индекс, собранный - * дважды (счётчиками меню и самой таблицей). Метки дают этот срез. + * Пишет ДВА среза сразу, и они дополняют друг друга: + * • ЭТАПЫ — логические шаги, размеченные вручную через traced()/perfScope(). + * Из списка URL не видно, что десяток одинаковых `.../rootfolder` — + * это резолв адресов списков согласования по партициям. + * • ЗАПРОСЫ — КАЖДЫЙ HTTP-вызов PnPjs, сгруппированный по виду операции. + * Снимается автоматически (см. PerfObserver ниже, подключён в + * shared/pnp/pnpjs.ts), поэтому покрывает ВСЕ разделы портала — + * включая формы и панель заявки, где ручной разметки нет. + * + * Складывать эти два среза между собой нельзя: запросы лежат ВНУТРИ этапов, + * сумма получилась бы двойной. Поэтому и таблицы две, с отдельными итогами. + * + * Как читать: «ОЖИДАНИЕ» — сколько реально прошло от начала операции до конца, + * то есть ровно то, что чувствует пользователь. Сумма времён отдельных шагов + * всегда БОЛЬШЕ: шаги идут параллельно (Promise.all) и их времена накладываются. + * Поэтому большая сумма у шага сама по себе ещё ничего не значит — важно, лежит + * ли он на последовательной цепочке. Признак: сложил несколько шагов и получил + * примерно ожидание — значит они идут друг за другом, и сокращать надо их. * * Включение/выключение БЕЗ пересборки — из консоли браузера: * window.rpPerf = true // включить * window.rpPerf = false // выключить + * window.rpPerfLast // последняя сводка объектом (скопировать/отдать) * * Флаг ЗАПОМИНАЕТСЯ в localStorage: выставил один раз — работает и после - * перезагрузки страницы, и завтра. Разбор производительности обычно идёт - * несколькими заходами с F5 между ними, и переписывать флаг в консоль каждый - * раз — ровно та мелочь, из-за которой замером перестают пользоваться. + * перезагрузки. Разбор производительности идёт несколькими заходами с F5 между + * ними, и переписывать флаг каждый раз — ровно та мелочь, из-за которой замером + * перестают пользоваться. Сделано свойством-аксессором на window, а не функцией + * setPerf(): в консоли естественно написать `window.rpPerf = true`. * - * Сделано через свойство-аксессор на window, а не через отдельную функцию - * setPerf(): в консоли естественно написать `window.rpPerf = true`, и это - * должно работать. Сеттер кладёт значение в localStorage, геттер отдаёт - * текущее — снаружи флаг выглядит обычным полем. - * - * По умолчанию выключено: предыдущая версия этого модуля (коммит 7bd540e) - * писала в консоль всегда, без флага, и её пришлось снимать целиком вместе со - * всеми обёртками. С флагом обёртки могут жить в коде постоянно — выключенный - * traced() не делает ничего, кроме вызова переданной функции. + * По умолчанию выключено: предыдущая версия модуля (коммит 7bd540e) писала в + * консоль всегда, без флага, и её пришлось снимать целиком вместе со всеми + * обёртками. С флагом обёртки живут в коде постоянно — выключенный traced() не + * делает ничего, кроме вызова переданной функции. */ const DEFAULT_PERF: boolean = false; @@ -36,29 +49,61 @@ const PREFIX = '[Перф]'; // его можно было найти и снять, не зная про этот модуль. 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; } -const stats = new Map(); +/** Накопленное по одной операции за ВСЕ её прогоны — база для среднего. */ +interface IRunHistory { + runs: number; + wallTotalMs: number; + stages: Map; + requests: Map; +} + +// Текущий (ещё не напечатанный) прогон. +const stages = new Map(); +const requests = new Map(); + +// История по операциям — живёт до перезагрузки страницы или window.rpPerfReset(). +const history = new Map(); // Глубина открытых perfScope. Сводка печатается, когда закрылась ПОСЛЕДНЯЯ -// внешняя операция: этапы почти всегда идут параллельно (Promise.all), и -// печатать таблицу на каждом вложенном завершении значило бы печатать её -// десятками кусков. Если две верхнеуровневые операции наложились по времени -// (типичный случай: счётчики меню и загрузка таблицы стартуют вместе), их -// метки попадут в одну таблицу — это и нужно, там как раз видны дубли. +// внешняя операция: шаги почти всегда идут параллельно, и печатать таблицу на +// каждом вложенном завершении значило бы печатать её десятками кусков. Если две +// верхнеуровневые операции наложились (типичный случай: счётчики меню и загрузка +// таблицы стартуют вместе), их метки попадут в одну таблицу — это и нужно, там +// как раз видны дубли. let openScopes = 0; -let scopeStart = 0; let scopeLabels: string[] = []; -// localStorage может быть недоступен (приватный режим, политика браузера, -// iframe с ограничениями) — отладочный флаг не повод падать, поэтому обе -// операции обёрнуты и молча откатываются к значению в памяти. +// Границы текущего окна накопления — для «ОЖИДАНИЯ». Начало ставит открытие +// 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; @@ -82,19 +127,13 @@ 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', { @@ -111,27 +150,49 @@ function installFlag(): void { 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 }; +// --------------------------------------------------------------------------- +// Накопление + +function record(into: Map, 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); - stats.set(label, stat); + into.set(label, stat); + + scheduleIdleFlush(); } +// Разделы без ручной разметки (панель заявки, формы) сводку сами не закрывают — +// закрываем по тишине. Пока открыт perfScope, решает он. +function scheduleIdleFlush(): void { + if (idleTimer !== undefined) window.clearTimeout(idleTimer); + idleTimer = window.setTimeout(() => { + idleTimer = undefined; + if (openScopes === 0) print(); + }, IDLE_FLUSH_MS); +} + +// --------------------------------------------------------------------------- +// Разметка этапов + /** * Обернуть один этап замером. Метку делай осмысленной и повторяемой: одинаковые * этапы разных партиций должны попадать в ОДНУ строку сводки (число вызовов * важнее, чем какая именно партиция), поэтому в метку не надо подставлять - * listId — только имя этапа и, при необходимости, тип заявки. + * listId — только имя этапа. */ export async function traced(label: string, fn: () => Promise): Promise { if (!perfEnabled()) return fn(); @@ -140,26 +201,20 @@ export async function traced(label: string, fn: () => Promise): Promise try { return await fn(); } finally { - record(label, performance.now() - start); + record(stages, label, performance.now() - start); } } /** - * Верхнеуровневая операция (открытие вкладки, счётчики меню, открытие заявки). - * Когда она завершается, печатается сводная таблица по всем меткам внутри. - * - * Смотреть надо на ДВА числа сразу: «сумма» показывает, куда ушла работа, а - * «стена» — сколько на самом деле ждал пользователь. Этапы идут параллельно, - * поэтому сумма бывает в разы больше стены, и оптимизировать по одной только - * сумме — значит чинить не то. + * Верхнеуровневая операция (открытие вкладки, счётчики меню). Когда она + * завершается, печатается сводка по всему, что накопилось внутри. */ export async function perfScope(label: string, fn: () => Promise): Promise { if (!perfEnabled()) return fn(); if (openScopes === 0) { - stats.clear(); - scopeStart = performance.now(); - scopeLabels = []; + reset(); + windowStart = performance.now(); } openScopes += 1; scopeLabels.push(label); @@ -168,29 +223,216 @@ export async function perfScope(label: string, fn: () => Promise): Promise return await fn(); } finally { openScopes -= 1; - if (openScopes === 0) print(performance.now() - scopeStart); + if (openScopes === 0) print(); } } -function print(wallMs: number): void { - if (stats.size === 0) return; +// --------------------------------------------------------------------------- +// Автоматический учёт КАЖДОГО запроса PnPjs - 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['сумма, мс']); +// Вид операции по 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; + } - const total = rows.reduce((acc, r) => acc + r['сумма, мс'], 0); + 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 { + 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 mergeInto(target: Map, source: Map): void { + source.forEach((s, label) => { + const t = target.get(label) ?? { calls: 0, totalMs: 0, maxMs: 0 }; + t.calls += s.calls; + t.totalMs += s.totalMs; + t.maxMs = Math.max(t.maxMs, s.maxMs); + target.set(label, t); + }); +} + +function heaviest(source: Map, 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): number { + return Array.from(source.values()).reduce((acc, s) => acc + s.calls, 0); +} + +/** + * Одна строка на загрузку — и всё. + * + * Раньше здесь печатались две таблицы. Их было невозможно ни окинуть взглядом + * при листании страниц, ни скопировать: console.table при переносе из консоли + * превращается в кашу из колонок. Полная раскладка никуда не делась — она за + * window.rpPerfDetail(), и её зовут тогда, когда строка показала, что смотреть + * есть на что. + * + * Среднее в строке важнее текущего значения: одиночный сетевой замер шумит + * настолько, что по нему нельзя сказать, стало лучше или просто повезло. + */ +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) ?? { + runs: 0, + wallTotalMs: 0, + stages: new Map(), + requests: new Map() + }; + hist.runs += 1; + hist.wallTotalMs += wallMs; + mergeInto(hist.stages, stages); + mergeInto(hist.requests, requests); + history.set(key, hist); + + // Дольше всего — по этапам, если разметка есть; иначе по видам запросов + // (разделы без ручной разметки: панель заявки, формы). + const top = heaviest(stages.size > 0 ? stages : requests, TOP_IN_LINE); console.log( - `${PREFIX} ${scopeLabels.join(' + ')}: стена ${Math.round(wallMs)} мс, сумма по этапам ${total} мс` + `${PREFIX} ${key} | ожидание ${Math.round(wallMs)} мс` + + ` (сред. ${Math.round(hist.wallTotalMs / hist.runs)} за ${hist.runs})` + + ` | запросов ${totalCalls(requests)}` + + (top ? ` | дольше всего: ${top}` : '') ); - console.table(rows); - stats.clear(); - scopeLabels = []; + reset(); } + +/** Строки полной раскладки по одной операции — средние за все её прогоны. */ +function detailLines(key: string, hist: IRunHistory): string[] { + const lines = [`${PREFIX} ${key} - прогонов ${hist.runs}, ожидание в среднем ${Math.round(hist.wallTotalMs / hist.runs)} мс`]; + + const dump = (name: string, source: Map): void => { + if (source.size === 0) return; + lines.push(` ${name} (в среднем за прогон)`); + Array.from(source.entries()) + .sort((a, b) => b[1].totalMs - a[1].totalMs) + .forEach(([label, s]) => { + lines.push( + ` ${Math.round(s.totalMs / hist.runs)} мс, вызовов ${(s.calls / hist.runs).toFixed(1)}, макс ${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((acc, [key, hist]) => acc.concat(detailLines(key, hist)), []) + .join(String.fromCharCode(10)); + console.log(text); + return text; + }; + + // Старые прогоны тянут среднее назад, и «стало быстрее» проявится в нём + // только через несколько заходов — перед сравнением «до/после» серию надо + // обнулять. + w.rpPerfReset = (): void => { + history.clear(); + reset(); + console.log(`${PREFIX} история замеров очищена`); + }; +} + +installDetailApi(); diff --git a/src/webparts/requestPortal/shared/pnp/pnpjs.ts b/src/webparts/requestPortal/shared/pnp/pnpjs.ts index 4480b2b..03533ef 100644 --- a/src/webparts/requestPortal/shared/pnp/pnpjs.ts +++ b/src/webparts/requestPortal/shared/pnp/pnpjs.ts @@ -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 не был вызван. */