e34d290865
Tests / frontend-test (push) Successful in 1m24s
Логи, ошибки и запись экрана портала заявок в центральный мониторинг (monitoring.tnsu.kz).
**Что поменялось**
- `@tnsu/monitoring-web` 0.3.1 из реестра tansu.
- `RequestPortalWebPart.onInit`: `initMonitoring({ service: 'request-portal', getAccessToken: spfxTokenProvider(this.context.aadTokenProviderFactory) })` — до `installErrorLogging`. Один на страницу (экземпляров веб-части бывает несколько) — это делает сама библиотека.
- `config/package-solution.json`: запрос `Logs.Write` к регистрации мониторинга `9232d9e2-…` (рядом с уже запрошенным `Reports.Read` той же регистрации).
- Хэш коммита сборки (`git.commit` в логах, «Сборка» в плеере): `config/webpack-patch.json` ссылается на патч из пакета (`node_modules/@tnsu/monitoring-web/spfx/webpack-patch.cjs`), он подставляет `__TNSU_GIT_COMMIT__` из `GIT_COMMIT`, `GITHUB_SHA` или `git rev-parse`. Пайплайн менять не нужно.
**Что пишется**
- Необработанные ошибки и отказы промисов, `console.error` (в том числе вывод `installErrorLogging`), `log.*`.
- Запись экрана (rrweb) — **ничего не маскируется**, записывается вся страница SharePoint, не только веб-часть. Неудачные запросы — вкладка «Сеть» в плеере.
- В workbench / при `gulp serve` окружение `development` — в админке мониторинга такие записи по умолчанию скрыты.
- Нет связи с мониторингом час — тост «Ошибка №… Обратитесь к администратору» (не чаще 3 раз в сутки).
**Проверено:** продакшен-сборка (`heft build --production`) проходит, патч применяется, хэш коммита попадает в бандл веб-части.
**После выкатки .sppkg** администратор один раз одобряет запрос: SharePoint admin center → Advanced → API access → `Logs.Write` → Approve. Пока не одобрено, токена нет и данные копятся в браузере (до 50 МБ), портал работает как обычно.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: #29
293 lines
16 KiB
TypeScript
293 lines
16 KiB
TypeScript
import * as React from 'react';
|
||
import * as ReactDom from 'react-dom';
|
||
import { Version } from '@microsoft/sp-core-library';
|
||
import {
|
||
type IPropertyPaneConfiguration,
|
||
PropertyPaneDropdown,
|
||
PropertyPaneTextField,
|
||
PropertyPaneToggle
|
||
} from '@microsoft/sp-property-pane';
|
||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
||
import PnPTelemetry from '@pnp/telemetry-js';
|
||
import { initMonitoring, spfxTokenProvider } from '@tnsu/monitoring-web';
|
||
|
||
import * as strings from 'RequestPortalWebPartStrings';
|
||
import RequestPortal from './components/RequestPortal';
|
||
import { IRequestPortalProps } from './components/IRequestPortalProps';
|
||
import { initSP } from './shared/pnp/pnpjs';
|
||
import { installErrorLogging } from './shared/diagnostics';
|
||
import { setImpersonatedIdentity } from './shared/impersonation';
|
||
import {
|
||
APPROVAL_API_BASE_URL_LOCAL,
|
||
APPROVAL_API_BASE_URL_PROD,
|
||
EDO_API_BASE_URL_LOCAL,
|
||
EDO_API_BASE_URL_PROD,
|
||
EDO_API_BASE_URL_STAGE,
|
||
setApprovalApiBaseUrl,
|
||
setEdoApiBaseUrl,
|
||
setNotificationSettings,
|
||
setChatSelfNotify
|
||
} from './shared/config';
|
||
import { initListPartitions } from './shared/approval';
|
||
import { ErrorOverlay } from './shared/ui';
|
||
import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser';
|
||
|
||
export interface IRequestPortalWebPartProps {
|
||
/**
|
||
* Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто.
|
||
* Одно поле на оба случая: e-mail, введённый напрямую, хранится как есть;
|
||
* ФИО, найденное поиском по имени (см. propertyPane/DebugUserField), после
|
||
* резолва заменяется собой же на найденный e-mail — отдельное поле под
|
||
* «сырой» ввод не нужно, DebugUserField ведёт его в своём локальном состоянии.
|
||
*/
|
||
debugUserQuery: string;
|
||
debugUserDisplayName: string;
|
||
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
|
||
approvalApiBaseUrl: string;
|
||
/**
|
||
* С каким ЭДО работаем при отправке документов на подписание — локальным или
|
||
* стейджинговым. У веб-частей, добавленных до появления свойства, оно пустое — это стейдж.
|
||
*/
|
||
edoApiBaseUrl: string;
|
||
/**
|
||
* Уведомления согласующим (поток Power Automate). По умолчанию включены:
|
||
* у веб-частей, добавленных до появления свойства, оно undefined — это тоже «вкл».
|
||
*/
|
||
notificationsEnabled?: boolean;
|
||
/** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */
|
||
notifyOnlyTo?: string;
|
||
/**
|
||
* Для проверки чата: письма и о СВОИХ сообщениях, и можно отметить себя через «@».
|
||
* По умолчанию выключено (undefined — тоже «выкл»).
|
||
*/
|
||
chatSelfNotify?: boolean;
|
||
}
|
||
|
||
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
||
|
||
private _isDarkTheme: boolean = false;
|
||
private _environmentMessage: string = '';
|
||
|
||
public render(): void {
|
||
// Именно здесь, а не в onInit: свойство меняется в панели без перезапуска
|
||
// веб-части, и render вызывается на каждое такое изменение.
|
||
setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName);
|
||
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
||
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
||
this.properties.edoApiBaseUrl = this.properties.edoApiBaseUrl || EDO_API_BASE_URL_PROD;
|
||
setEdoApiBaseUrl(this.properties.edoApiBaseUrl);
|
||
this._applyNotificationSettings();
|
||
|
||
const element: React.ReactElement = React.createElement(
|
||
ErrorOverlay,
|
||
undefined,
|
||
React.createElement<IRequestPortalProps>(RequestPortal, {
|
||
// key на подменённом e-mail: смена подмены в property pane должна
|
||
// пересобрать раздел заново — иначе счётчики в боковом меню и открытые
|
||
// таблицы остались бы от прежнего пользователя (их эффекты завязаны на
|
||
// монтирование/context, а не на identity подмены).
|
||
key: this.properties.debugUserQuery || '__self__',
|
||
isDarkTheme: this._isDarkTheme,
|
||
environmentMessage: this._environmentMessage,
|
||
hasTeamsContext: !!this.context.sdks.microsoftTeams,
|
||
userDisplayName: this.context.pageContext.user.displayName,
|
||
context: this.context
|
||
})
|
||
);
|
||
|
||
ReactDom.render(element, this.domElement);
|
||
}
|
||
|
||
protected onInit(): Promise<void> {
|
||
// Логи, ошибки, console.error и запись экрана в мониторинг (monitoring.tnsu.kz). Токен — молча через
|
||
// AAD веб-части (разрешение Logs.Write в package-solution.json); при отладке в workbench окружение — development.
|
||
// Один на страницу: повторный вызов (несколько веб-частей) библиотека игнорирует сама.
|
||
initMonitoring({ service: 'request-portal', getAccessToken: spfxTokenProvider(this.context.aadTokenProviderFactory) });
|
||
installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения
|
||
// Телеметрия PnP (её включает @pnp/spfx-controls-react) шлёт fetch на pnptelemetryproxy.azurewebsites.net
|
||
// без catch: где адрес закрыт прокси или блокировщиком, это необработанный отказ промиса в мониторинге.
|
||
PnPTelemetry.getInstance().optOut();
|
||
initSP(this.context); // единый клиент PnPjs для записи в списки
|
||
|
||
// Адрес approval-route должен быть выставлен ДО загрузки партиций — они
|
||
// берутся именно оттуда. В render() он ставится снова (свойство меняется в
|
||
// панели без перезапуска), но render случается позже onInit.
|
||
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
||
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
||
this.properties.edoApiBaseUrl = this.properties.edoApiBaseUrl || EDO_API_BASE_URL_PROD;
|
||
setEdoApiBaseUrl(this.properties.edoApiBaseUrl);
|
||
this._applyNotificationSettings();
|
||
|
||
// Партиции грузим здесь, а не по требованию из форм: имена списков нужны
|
||
// формам синхронно (см. shared/listTitles.ts), а формы — лениво подгружаемые
|
||
// чанки, монтируются заведомо позже onInit. initListPartitions не бросает:
|
||
// при недоступном approval-route портал работает по конвенции имён.
|
||
//
|
||
// НЕ ждём эту загрузку здесь (не Promise.all, а просто вызов) — onInit
|
||
// блокирует ПЕРВЫЙ render() всего портала (SPFx не рисует веб-часть, пока
|
||
// его промис не разрешится), а два новых AAD-запроса к внешнему api.tnsu.kz
|
||
// не обязаны попадать в этот путь: раньше onInit резолвился почти мгновенно
|
||
// (только _getEnvironmentMessage, без сети), и весь портал — включая
|
||
// «Мои заявки» и «Новая заявка», которым партиции вообще не нужны сразу —
|
||
// ждал открытия форм. Партиции при этом гарантированно успевают раньше,
|
||
// чем смонтируется первая форма (ленивый чанк грузится дольше).
|
||
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
|
||
void initListPartitions(this.context);
|
||
return this._getEnvironmentMessage().then(message => {
|
||
this._environmentMessage = message;
|
||
});
|
||
}
|
||
|
||
private _applyNotificationSettings(): void {
|
||
setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || '');
|
||
setChatSelfNotify(this.properties.chatSelfNotify === true);
|
||
}
|
||
|
||
private _getEnvironmentMessage(): Promise<string> {
|
||
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
|
||
return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
|
||
.then(context => {
|
||
let environmentMessage: string;
|
||
switch (context.app.host.name) {
|
||
case 'Office': // running in Office
|
||
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
|
||
break;
|
||
case 'Outlook': // running in Outlook
|
||
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOutlook : strings.AppOutlookEnvironment;
|
||
break;
|
||
case 'Teams': // running in Teams
|
||
case 'TeamsModern':
|
||
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentTeams : strings.AppTeamsTabEnvironment;
|
||
break;
|
||
default:
|
||
environmentMessage = strings.UnknownEnvironment;
|
||
}
|
||
|
||
return environmentMessage;
|
||
});
|
||
}
|
||
|
||
return Promise.resolve(this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentSharePoint : strings.AppSharePointEnvironment);
|
||
}
|
||
|
||
protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
|
||
if (!currentTheme) {
|
||
return;
|
||
}
|
||
|
||
this._isDarkTheme = !!currentTheme.isInverted;
|
||
const {
|
||
semanticColors
|
||
} = currentTheme;
|
||
|
||
if (semanticColors) {
|
||
this.domElement.style.setProperty('--bodyText', semanticColors.bodyText || null);
|
||
this.domElement.style.setProperty('--link', semanticColors.link || null);
|
||
this.domElement.style.setProperty('--linkHovered', semanticColors.linkHovered || null);
|
||
}
|
||
|
||
}
|
||
|
||
protected onDispose(): void {
|
||
ReactDom.unmountComponentAtNode(this.domElement);
|
||
}
|
||
|
||
protected get dataVersion(): Version {
|
||
return Version.parse('1.0');
|
||
}
|
||
|
||
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
|
||
return {
|
||
pages: [
|
||
{
|
||
header: {
|
||
description: strings.PropertyPaneDescription
|
||
},
|
||
groups: [
|
||
{
|
||
groupName: strings.BasicGroupName,
|
||
groupFields: [
|
||
PropertyPaneDebugUser('debugUserQuery', {
|
||
label: strings.DebugUserFieldLabel,
|
||
description: strings.DebugUserFieldDescription,
|
||
context: this.context,
|
||
initialQuery: this.properties.debugUserQuery,
|
||
onResolved: (query, email, displayName) => {
|
||
// Сохраняем ТОЛЬКО e-mail (введённый напрямую или найденный по
|
||
// ФИО) — свойство одно и то же используется как e-mail для
|
||
// подмены (см. render() выше), так что писать в него сырое ФИО
|
||
// нельзя. Пустой query — поле очистили, подмену выключаем.
|
||
// А вот «email пуст, но query не пуст» — ФИО ещё не нашлось
|
||
// (недописано/не существует): ничего не трогаем, иначе неудачный
|
||
// промежуточный поиск молча выключил бы уже работающую подмену.
|
||
if (email) {
|
||
this.properties.debugUserQuery = email;
|
||
this.properties.debugUserDisplayName = displayName;
|
||
this.render();
|
||
} else if (!query) {
|
||
this.properties.debugUserQuery = '';
|
||
this.properties.debugUserDisplayName = '';
|
||
this.render();
|
||
}
|
||
}
|
||
})
|
||
]
|
||
},
|
||
{
|
||
groupName: 'Approval Route API',
|
||
groupFields: [
|
||
PropertyPaneDropdown('approvalApiBaseUrl', {
|
||
label: 'Api Base Url',
|
||
selectedKey: this.properties.approvalApiBaseUrl,
|
||
options: [
|
||
{ key: APPROVAL_API_BASE_URL_PROD, text: `Прод (${APPROVAL_API_BASE_URL_PROD})` },
|
||
{ key: APPROVAL_API_BASE_URL_LOCAL, text: `Локально (${APPROVAL_API_BASE_URL_LOCAL})` }
|
||
]
|
||
})
|
||
]
|
||
},
|
||
{
|
||
groupName: 'ЭДО (подписание)',
|
||
groupFields: [
|
||
PropertyPaneDropdown('edoApiBaseUrl', {
|
||
label: 'С каким ЭДО работать',
|
||
selectedKey: this.properties.edoApiBaseUrl || EDO_API_BASE_URL_PROD,
|
||
options: [
|
||
{ key: EDO_API_BASE_URL_PROD, text: `Прод (${EDO_API_BASE_URL_PROD})` },
|
||
{ key: EDO_API_BASE_URL_STAGE, text: `Стейдж (${EDO_API_BASE_URL_STAGE})` },
|
||
{ key: EDO_API_BASE_URL_LOCAL, text: `Локально (${EDO_API_BASE_URL_LOCAL})` }
|
||
]
|
||
})
|
||
]
|
||
},
|
||
{
|
||
groupName: 'Уведомления',
|
||
groupFields: [
|
||
PropertyPaneToggle('notificationsEnabled', {
|
||
label: 'Уведомления согласующим',
|
||
onText: 'Включены',
|
||
offText: 'Выключены',
|
||
checked: this.properties.notificationsEnabled !== false
|
||
}),
|
||
PropertyPaneTextField('notifyOnlyTo', {
|
||
label: 'Слать только на адрес',
|
||
description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.',
|
||
placeholder: 'user@tnsu.kz',
|
||
disabled: this.properties.notificationsEnabled === false
|
||
}),
|
||
PropertyPaneToggle('chatSelfNotify', {
|
||
label: 'Чат: уведомлять о своих сообщениях (для теста)',
|
||
onText: 'Да — и можно отметить себя через «@»',
|
||
offText: 'Нет',
|
||
checked: this.properties.chatSelfNotify === true
|
||
})
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
};
|
||
}
|
||
}
|