трейсы
This commit is contained in:
@@ -439,13 +439,13 @@ async function getNavCountsInner(context: WebPartContext): Promise<INavCounts> {
|
|||||||
|
|
||||||
const [turn, rework] = await Promise.all([
|
const [turn, rework] = await Promise.all([
|
||||||
sharedPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
|
sharedPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
|
||||||
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
|
countOwnRequestsByStatus(context, types, REQUEST_STATUS.returned),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
approvals: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onApproval).length,
|
approvals: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onApproval).length,
|
||||||
execution: turn.index.filter((r) => r.myStepStatus === REQUEST_STATUS.onExecution).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 это не отдаётся).
|
// комментарий у функции: через Lookup-expand это не отдаётся).
|
||||||
|
//
|
||||||
|
// Автор берётся ЗДЕСЬ ЖЕ через expand. Раньше он дочитывался отдельным
|
||||||
|
// проходом (hydrateAuthors) и только по строкам видимой страницы — чтобы не
|
||||||
|
// тянуть его на весь индекс. Но экономилась при этом не сеть, а payload:
|
||||||
|
// запрос к списку данных всё равно делается, и expand не добавляет к нему
|
||||||
|
// ни одного round-trip'а, тогда как отдельный проход стоил целый — замер
|
||||||
|
// 2026-08-20 показал 262 мс ровно на критическом пути вкладки
|
||||||
|
// «На согласовании» (Search → строки очереди → автор).
|
||||||
const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
|
const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or ');
|
||||||
const ticketRows = (await list.items
|
const ticketRows = (await list.items
|
||||||
.filter(ticketFilter)
|
.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<Record<string, unknown>>;
|
.top(ticketIds.length)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
debug.rows = ticketRows.map((r) => ({
|
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),
|
myStepStatus: stepStatusByTicket.get(r.Id as number),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -749,16 +758,23 @@ async function finalizeApprovalTarget(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа —
|
// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа — только
|
||||||
// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по
|
// по переданным строкам. Мутирует переданные объекты (проставляет
|
||||||
// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName).
|
// authorEmail/authorName).
|
||||||
|
//
|
||||||
|
// Строки, у которых автор УЖЕ известен, пропускаются: и 'own' (loadByIds), и
|
||||||
|
// 'approval' (finalizeApprovalTarget) теперь получают его тем же запросом, что
|
||||||
|
// и остальные поля, поэтому на обычных путях сюда доходить нечему и запрос не
|
||||||
|
// уходит вовсе. Функция оставлена страховкой на случай источника строк без
|
||||||
|
// автора — молча показывать пустого инициатора хуже, чем один лишний запрос.
|
||||||
async function hydrateAuthors(
|
async function hydrateAuthors(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
types: IRequestType[],
|
types: IRequestType[],
|
||||||
rows: IMyRequest[]
|
rows: IMyRequest[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (rows.length === 0) return;
|
const missing = rows.filter((r) => !r.authorName && !r.authorEmail);
|
||||||
return traced('дочитывание автора (REST)', () => hydrateAuthorsInner(context, types, rows));
|
if (missing.length === 0) return;
|
||||||
|
return traced('дочитывание автора (REST)', () => hydrateAuthorsInner(context, types, missing));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateAuthorsInner(
|
async function hydrateAuthorsInner(
|
||||||
@@ -862,6 +878,10 @@ async function fetchAllSearchRows(
|
|||||||
let startRow = 0;
|
let startRow = 0;
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
// Постраничной метки здесь больше нет: замер 2026-08-20 показал, что запрос
|
||||||
|
// ровно один (847 мс у вызывающего против 846 мс у страницы), то есть
|
||||||
|
// медленный сам Search, а не перебор StartRow. Вопрос закрыт, а метка
|
||||||
|
// занимала место в строке сводки, дублируя метку вызывающего.
|
||||||
const results = await sp.search({
|
const results = await sp.search({
|
||||||
Querytext: kql,
|
Querytext: kql,
|
||||||
RowLimit: SEARCH_PAGE_SIZE,
|
RowLimit: SEARCH_PAGE_SIZE,
|
||||||
@@ -1310,6 +1330,36 @@ export async function getOwnRequestsByStatus(
|
|||||||
status: string,
|
status: string,
|
||||||
page: number,
|
page: number,
|
||||||
query: string = ''
|
query: string = ''
|
||||||
|
): Promise<IOwnRequestsPage> {
|
||||||
|
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<number> {
|
||||||
|
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<IOwnRequestsPage> {
|
): Promise<IOwnRequestsPage> {
|
||||||
if (types.length === 0) return { rows: [], totalRows: 0 };
|
if (types.length === 0) return { rows: [], totalRows: 0 };
|
||||||
|
|
||||||
@@ -1339,7 +1389,7 @@ export async function getOwnRequestsByStatus(
|
|||||||
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
|
withRows && entries.length > 0
|
||||||
? await traced('дочитывание полей по Id (REST)', () => 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);
|
||||||
@@ -1357,7 +1407,10 @@ export async function getOwnRequestsByStatus(
|
|||||||
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
|
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
|
||||||
.filter((r) => r.status === status);
|
.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 onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
|
||||||
|
|
||||||
const merged = onlyFresh
|
const merged = onlyFresh
|
||||||
|
|||||||
@@ -1,31 +1,44 @@
|
|||||||
|
import { TimelinePipe } from '@pnp/core';
|
||||||
|
import { Queryable } from '@pnp/queryable';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Замер времени ЭТАПОВ загрузки — «на что именно ушло ожидание», а не просто
|
* Замер загрузки: «на что именно ушло ожидание», а не просто «страница долго
|
||||||
* «страница долго грузится».
|
* грузится».
|
||||||
*
|
*
|
||||||
* Зачем отдельно от вкладки Network: браузер показывает запросы, но не
|
* Пишет ДВА среза сразу, и они дополняют друг друга:
|
||||||
* логические этапы. Из списка URL не видно, что десяток одинаковых
|
* • ЭТАПЫ — логические шаги, размеченные вручную через traced()/perfScope().
|
||||||
* `.../rootfolder` — это резолв адресов списков согласования по партициям, а
|
* Из списка URL не видно, что десяток одинаковых `.../rootfolder` —
|
||||||
* два одинаковых Search-запроса подряд — это один и тот же индекс, собранный
|
* это резолв адресов списков согласования по партициям.
|
||||||
* дважды (счётчиками меню и самой таблицей). Метки дают этот срез.
|
* • ЗАПРОСЫ — КАЖДЫЙ HTTP-вызов PnPjs, сгруппированный по виду операции.
|
||||||
|
* Снимается автоматически (см. PerfObserver ниже, подключён в
|
||||||
|
* shared/pnp/pnpjs.ts), поэтому покрывает ВСЕ разделы портала —
|
||||||
|
* включая формы и панель заявки, где ручной разметки нет.
|
||||||
|
*
|
||||||
|
* Складывать эти два среза между собой нельзя: запросы лежат ВНУТРИ этапов,
|
||||||
|
* сумма получилась бы двойной. Поэтому и таблицы две, с отдельными итогами.
|
||||||
|
*
|
||||||
|
* Как читать: «ОЖИДАНИЕ» — сколько реально прошло от начала операции до конца,
|
||||||
|
* то есть ровно то, что чувствует пользователь. Сумма времён отдельных шагов
|
||||||
|
* всегда БОЛЬШЕ: шаги идут параллельно (Promise.all) и их времена накладываются.
|
||||||
|
* Поэтому большая сумма у шага сама по себе ещё ничего не значит — важно, лежит
|
||||||
|
* ли он на последовательной цепочке. Признак: сложил несколько шагов и получил
|
||||||
|
* примерно ожидание — значит они идут друг за другом, и сокращать надо их.
|
||||||
*
|
*
|
||||||
* Включение/выключение БЕЗ пересборки — из консоли браузера:
|
* Включение/выключение БЕЗ пересборки — из консоли браузера:
|
||||||
* window.rpPerf = true // включить
|
* window.rpPerf = true // включить
|
||||||
* window.rpPerf = false // выключить
|
* window.rpPerf = false // выключить
|
||||||
|
* window.rpPerfLast // последняя сводка объектом (скопировать/отдать)
|
||||||
*
|
*
|
||||||
* Флаг ЗАПОМИНАЕТСЯ в localStorage: выставил один раз — работает и после
|
* Флаг ЗАПОМИНАЕТСЯ в localStorage: выставил один раз — работает и после
|
||||||
* перезагрузки страницы, и завтра. Разбор производительности обычно идёт
|
* перезагрузки. Разбор производительности идёт несколькими заходами с F5 между
|
||||||
* несколькими заходами с F5 между ними, и переписывать флаг в консоль каждый
|
* ними, и переписывать флаг каждый раз — ровно та мелочь, из-за которой замером
|
||||||
* раз — ровно та мелочь, из-за которой замером перестают пользоваться.
|
* перестают пользоваться. Сделано свойством-аксессором на window, а не функцией
|
||||||
|
* setPerf(): в консоли естественно написать `window.rpPerf = true`.
|
||||||
*
|
*
|
||||||
* Сделано через свойство-аксессор на window, а не через отдельную функцию
|
* По умолчанию выключено: предыдущая версия модуля (коммит 7bd540e) писала в
|
||||||
* setPerf(): в консоли естественно написать `window.rpPerf = true`, и это
|
* консоль всегда, без флага, и её пришлось снимать целиком вместе со всеми
|
||||||
* должно работать. Сеттер кладёт значение в localStorage, геттер отдаёт
|
* обёртками. С флагом обёртки живут в коде постоянно — выключенный traced() не
|
||||||
* текущее — снаружи флаг выглядит обычным полем.
|
* делает ничего, кроме вызова переданной функции.
|
||||||
*
|
|
||||||
* По умолчанию выключено: предыдущая версия этого модуля (коммит 7bd540e)
|
|
||||||
* писала в консоль всегда, без флага, и её пришлось снимать целиком вместе со
|
|
||||||
* всеми обёртками. С флагом обёртки могут жить в коде постоянно — выключенный
|
|
||||||
* traced() не делает ничего, кроме вызова переданной функции.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DEFAULT_PERF: boolean = false;
|
const DEFAULT_PERF: boolean = false;
|
||||||
@@ -36,29 +49,61 @@ const PREFIX = '[Перф]';
|
|||||||
// его можно было найти и снять, не зная про этот модуль.
|
// его можно было найти и снять, не зная про этот модуль.
|
||||||
const STORAGE_KEY = 'rpPerf';
|
const STORAGE_KEY = 'rpPerf';
|
||||||
|
|
||||||
/** Накопленное по одной метке за текущую верхнеуровневую операцию. */
|
// Через сколько тишины считать операцию законченной и печатать сводку, если её
|
||||||
|
// никто не обрамил perfScope. Нужно для разделов без ручной разметки (панель
|
||||||
|
// заявки, формы): там просто идут запросы, и «загрузка закончилась» видно только
|
||||||
|
// по тому, что новых запросов больше нет.
|
||||||
|
const IDLE_FLUSH_MS = 900;
|
||||||
|
|
||||||
|
// Сколько самых тяжёлых меток показывать прямо в строке. Больше трёх — и строка
|
||||||
|
// перестаёт читаться с одного взгляда, а ради этого всё и затевалось;
|
||||||
|
// остальное смотрится через window.rpPerfDetail().
|
||||||
|
const TOP_IN_LINE = 3;
|
||||||
|
|
||||||
|
/** Накопленное по одной метке. */
|
||||||
interface IStat {
|
interface IStat {
|
||||||
calls: number;
|
calls: number;
|
||||||
totalMs: number;
|
totalMs: number;
|
||||||
maxMs: number;
|
maxMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = new Map<string, IStat>();
|
/** Накопленное по одной операции за ВСЕ её прогоны — база для среднего. */
|
||||||
|
interface IRunHistory {
|
||||||
|
runs: number;
|
||||||
|
wallTotalMs: number;
|
||||||
|
stages: Map<string, IStat>;
|
||||||
|
requests: Map<string, IStat>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текущий (ещё не напечатанный) прогон.
|
||||||
|
const stages = new Map<string, IStat>();
|
||||||
|
const requests = new Map<string, IStat>();
|
||||||
|
|
||||||
|
// История по операциям — живёт до перезагрузки страницы или window.rpPerfReset().
|
||||||
|
const history = new Map<string, IRunHistory>();
|
||||||
|
|
||||||
// Глубина открытых perfScope. Сводка печатается, когда закрылась ПОСЛЕДНЯЯ
|
// Глубина открытых perfScope. Сводка печатается, когда закрылась ПОСЛЕДНЯЯ
|
||||||
// внешняя операция: этапы почти всегда идут параллельно (Promise.all), и
|
// внешняя операция: шаги почти всегда идут параллельно, и печатать таблицу на
|
||||||
// печатать таблицу на каждом вложенном завершении значило бы печатать её
|
// каждом вложенном завершении значило бы печатать её десятками кусков. Если две
|
||||||
// десятками кусков. Если две верхнеуровневые операции наложились по времени
|
// верхнеуровневые операции наложились (типичный случай: счётчики меню и загрузка
|
||||||
// (типичный случай: счётчики меню и загрузка таблицы стартуют вместе), их
|
// таблицы стартуют вместе), их метки попадут в одну таблицу — это и нужно, там
|
||||||
// метки попадут в одну таблицу — это и нужно, там как раз видны дубли.
|
// как раз видны дубли.
|
||||||
let openScopes = 0;
|
let openScopes = 0;
|
||||||
let scopeStart = 0;
|
|
||||||
let scopeLabels: string[] = [];
|
let scopeLabels: string[] = [];
|
||||||
|
|
||||||
// localStorage может быть недоступен (приватный режим, политика браузера,
|
// Границы текущего окна накопления — для «ОЖИДАНИЯ». Начало ставит открытие
|
||||||
// iframe с ограничениями) — отладочный флаг не повод падать, поэтому обе
|
// scope либо первая запись, конец двигает каждая следующая.
|
||||||
// операции обёрнуты и молча откатываются к значению в памяти.
|
let windowStart = 0;
|
||||||
|
let windowEnd = 0;
|
||||||
|
|
||||||
|
let idleTimer: number | undefined;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Флаг
|
||||||
|
|
||||||
function readStoredFlag(): boolean | undefined {
|
function readStoredFlag(): boolean | undefined {
|
||||||
|
// localStorage может быть недоступен (приватный режим, политика браузера,
|
||||||
|
// ограниченный iframe) — отладочный флаг не повод падать.
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
if (raw === null) return undefined;
|
if (raw === null) return undefined;
|
||||||
@@ -82,19 +127,13 @@ function perfEnabled(): boolean {
|
|||||||
return enabled;
|
return enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Вешает window.rpPerf как свойство с геттером и сеттером.
|
|
||||||
*
|
|
||||||
* Вызывается один раз при загрузке модуля. Значение, которое кто-то успел
|
|
||||||
* присвоить ДО этого (например, в снипете, выполненном раньше), не теряем —
|
|
||||||
* подхватываем и запоминаем.
|
|
||||||
*/
|
|
||||||
function installFlag(): void {
|
function installFlag(): void {
|
||||||
const w = window as unknown as { rpPerf?: boolean };
|
const w = window as unknown as { rpPerf?: boolean };
|
||||||
|
|
||||||
const existing = Object.getOwnPropertyDescriptor(w, 'rpPerf');
|
const existing = Object.getOwnPropertyDescriptor(w, 'rpPerf');
|
||||||
if (existing && existing.get) return;
|
if (existing && existing.get) return;
|
||||||
|
|
||||||
|
// Значение, присвоенное ДО загрузки этого чанка, не теряем.
|
||||||
const preset = typeof w.rpPerf === 'boolean' ? w.rpPerf : undefined;
|
const preset = typeof w.rpPerf === 'boolean' ? w.rpPerf : undefined;
|
||||||
|
|
||||||
Object.defineProperty(w, 'rpPerf', {
|
Object.defineProperty(w, 'rpPerf', {
|
||||||
@@ -111,27 +150,49 @@ function installFlag(): void {
|
|||||||
enabled = preset;
|
enabled = preset;
|
||||||
writeStoredFlag(preset);
|
writeStoredFlag(preset);
|
||||||
} else if (enabled) {
|
} else if (enabled) {
|
||||||
// Иначе легко забыть, что флаг остался включённым с прошлого раза, и
|
// Иначе легко забыть, что флаг остался включённым с прошлого раза, и потом
|
||||||
// потом гадать, откуда в консоли таблицы.
|
// гадать, откуда в консоли таблицы.
|
||||||
console.log(`${PREFIX} замер включён (запомнен ранее). Выключить: window.rpPerf = false`);
|
console.log(`${PREFIX} замер включён (запомнен ранее). Выключить: window.rpPerf = false`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
installFlag();
|
installFlag();
|
||||||
|
|
||||||
function record(label: string, ms: number): void {
|
// ---------------------------------------------------------------------------
|
||||||
const stat = stats.get(label) ?? { calls: 0, totalMs: 0, maxMs: 0 };
|
// Накопление
|
||||||
|
|
||||||
|
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.calls += 1;
|
||||||
stat.totalMs += ms;
|
stat.totalMs += ms;
|
||||||
stat.maxMs = Math.max(stat.maxMs, 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<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
export async function traced<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||||
if (!perfEnabled()) return fn();
|
if (!perfEnabled()) return fn();
|
||||||
@@ -140,26 +201,20 @@ export async function traced<T>(label: string, fn: () => Promise<T>): Promise<T>
|
|||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
record(label, performance.now() - start);
|
record(stages, label, performance.now() - start);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Верхнеуровневая операция (открытие вкладки, счётчики меню, открытие заявки).
|
* Верхнеуровневая операция (открытие вкладки, счётчики меню). Когда она
|
||||||
* Когда она завершается, печатается сводная таблица по всем меткам внутри.
|
* завершается, печатается сводка по всему, что накопилось внутри.
|
||||||
*
|
|
||||||
* Смотреть надо на ДВА числа сразу: «сумма» показывает, куда ушла работа, а
|
|
||||||
* «стена» — сколько на самом деле ждал пользователь. Этапы идут параллельно,
|
|
||||||
* поэтому сумма бывает в разы больше стены, и оптимизировать по одной только
|
|
||||||
* сумме — значит чинить не то.
|
|
||||||
*/
|
*/
|
||||||
export async function perfScope<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
export async function perfScope<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||||
if (!perfEnabled()) return fn();
|
if (!perfEnabled()) return fn();
|
||||||
|
|
||||||
if (openScopes === 0) {
|
if (openScopes === 0) {
|
||||||
stats.clear();
|
reset();
|
||||||
scopeStart = performance.now();
|
windowStart = performance.now();
|
||||||
scopeLabels = [];
|
|
||||||
}
|
}
|
||||||
openScopes += 1;
|
openScopes += 1;
|
||||||
scopeLabels.push(label);
|
scopeLabels.push(label);
|
||||||
@@ -168,29 +223,216 @@ export async function perfScope<T>(label: string, fn: () => Promise<T>): Promise
|
|||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
openScopes -= 1;
|
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())
|
// Вид операции по url — иначе каждая партиция со своим адресом и своим $filter
|
||||||
.map(([label, s]) => ({
|
// даёт отдельную строку и таблица нечитаема. Нужна именно ОПЕРАЦИЯ, а не адрес.
|
||||||
'этап': label,
|
function classify(rawUrl: string): string {
|
||||||
'вызовов': s.calls,
|
let path: string;
|
||||||
'сумма, мс': Math.round(s.totalMs),
|
let search: string;
|
||||||
'макс, мс': Math.round(s.maxMs)
|
try {
|
||||||
}))
|
const parsed = new URL(rawUrl);
|
||||||
.sort((a, b) => b['сумма, мс'] - a['сумма, мс']);
|
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<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 mergeInto(target: Map<string, IStat>, source: Map<string, IStat>): 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<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(), и её зовут тогда, когда строка показала, что смотреть
|
||||||
|
* есть на что.
|
||||||
|
*
|
||||||
|
* Среднее в строке важнее текущего значения: одиночный сетевой замер шумит
|
||||||
|
* настолько, что по нему нельзя сказать, стало лучше или просто повезло.
|
||||||
|
*/
|
||||||
|
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<string, IStat>(),
|
||||||
|
requests: new Map<string, IStat>()
|
||||||
|
};
|
||||||
|
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(
|
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();
|
reset();
|
||||||
scopeLabels = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Строки полной раскладки по одной операции — средние за все её прогоны. */
|
||||||
|
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<string, IStat>): 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<string[]>((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();
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import '@pnp/sp/site-users/web';
|
|||||||
import { BrowserFetchWithRetry } from '@pnp/queryable';
|
import { BrowserFetchWithRetry } from '@pnp/queryable';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { DATA_SITE_URL } from '../config';
|
import { DATA_SITE_URL } from '../config';
|
||||||
|
import { PerfObserver } from '../perfTrace';
|
||||||
|
|
||||||
|
// PerfObserver подключён к ОБОИМ клиентам ниже: это единственное место, через
|
||||||
|
// которое проходит любой SharePoint-запрос портала, поэтому отладочный замер
|
||||||
|
// (window.rpPerf, см. shared/perfTrace.ts) покрывает сразу все разделы — и
|
||||||
|
// списки заявок, и формы, и панель заявки, — а не только те, где расставлены
|
||||||
|
// метки этапов вручную. Выключенный замер не делает ничего дороже чтения флага.
|
||||||
|
|
||||||
// Мягкая ретрай-политика вместо стандартной PnPjs (retries: 3, interval: 200).
|
// Мягкая ретрай-политика вместо стандартной PnPjs (retries: 3, interval: 200).
|
||||||
// Дефолт слишком напорист: при отказе (429 «слишком много запросов», 503) он
|
// Дефолт слишком напорист: при отказе (429 «слишком много запросов», 503) он
|
||||||
@@ -25,7 +32,7 @@ let _sp: SPFI | undefined;
|
|||||||
* списки живут там, где бы ни стоял сам web part. Авторизация берётся из context.
|
* списки живут там, где бы ни стоял сам web part. Авторизация берётся из context.
|
||||||
*/
|
*/
|
||||||
export function initSP(context: WebPartContext): void {
|
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 по умолчанию).
|
* отправки (у behavior replace: true по умолчанию).
|
||||||
*/
|
*/
|
||||||
export function spForSite(siteUrl: string, context: WebPartContext): SPFI {
|
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 не был вызван. */
|
/** Получить инициализированный клиент. Бросает, если initSP не был вызван. */
|
||||||
|
|||||||
Reference in New Issue
Block a user