1 Commits

Author SHA1 Message Date
gorbenko.pavel e34d290865 Мониторинг: логи, ошибки и запись экрана портала заявок (@tnsu/monitoring-web) (#29)
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
2026-10-30 14:24:58 +00:00
5 changed files with 90 additions and 5 deletions
+5 -4
View File
@@ -17,7 +17,8 @@
"@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/monitoring-web": "^0.3.1",
"@pnp/telemetry-js": "2.0.0",
"@tnsu/monitoring-web": "^0.3.4",
"@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0",
"react": "17.0.1",
@@ -9909,9 +9910,9 @@
"license": "0BSD"
},
"node_modules/@tnsu/monitoring-web": {
"version": "0.3.1",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fmonitoring-web/-/0.3.1/monitoring-web-0.3.1.tgz",
"integrity": "sha512-HcHP8Utb9gXBRt4nEC4RW5UrqT8ssIs7Prs2LSQaE/oy3ybFNiqpLp3XbmGx1ssCArZcwBozov0MbIHpC9dVgg==",
"version": "0.3.4",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fmonitoring-web/-/0.3.4/monitoring-web-0.3.4.tgz",
"integrity": "sha512-tQe0ZM1tBVO3XtbNFliRaaooqvCX3gb/Q/j2lV6+rYCXprodBZByQ6PLrCu/P0me/rW8KdQ02hu7avyLXXfkoQ==",
"license": "UNLICENSED",
"dependencies": {
"@rrweb/rrweb-plugin-console-record": "^2.2.0",
+2 -1
View File
@@ -21,7 +21,8 @@
"@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/monitoring-web": "^0.3.1",
"@pnp/telemetry-js": "2.0.0",
"@tnsu/monitoring-web": "^0.3.4",
"@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0",
"react": "17.0.1",
@@ -9,6 +9,7 @@ import {
} 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';
@@ -104,6 +105,9 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
// Один на страницу: повторный вызов (несколько веб-частей) библиотека игнорирует сама.
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 должен быть выставлен ДО загрузки партиций — они
@@ -1,3 +1,4 @@
import { log } from '@tnsu/monitoring-web';
import { getSP } from '../pnp/pnpjs';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
@@ -25,6 +26,14 @@ export interface IPendingRow extends IApprovalRow {
isDelegator: boolean;
}
// Решение → текст события в логах мониторинга.
const DECISION_MESSAGE: Record<ApproverDecision, string> = {
approve: 'Заявка согласована',
return: 'Заявка возвращена на доработку',
reject: 'Заявка отклонена',
execute: 'Заявка выполнена исполнителем',
};
// Решение → статус его строки в списке согласования.
const ROW_STATUS: Record<ApproverDecision, string> = {
approve: REQUEST_STATUS.approved,
@@ -336,6 +345,7 @@ export class ApproverService {
const rows = await this.approval.getForRequest(requestId);
const requestStatus = this.computeRequestStatus(rows);
await this.updateRequestStatus(requestId, requestStatus);
await this.logDecision(requestId, rowId, decision, comment, requestStatus);
emitRequestsChanged();
return requestStatus;
}
@@ -364,6 +374,42 @@ export class ApproverService {
return REQUEST_STATUS.execution;
}
/**
* Решение — в логи мониторинга, с кодом и названием заявки, чтобы её было видно прямо в списке логов.
* Код и название дочитываются отдельно; не получилось — пишем без них: решение уже применено.
*/
private async logDecision(
requestId: number,
rowId: number,
decision: ApproverDecision,
comment: string,
requestStatus: string
): Promise<void> {
let code = '';
let title = '';
try {
const item = (await this.dataList().items.getById(requestId).select('Title', 'RequestUniqueCode')()) as {
Title?: string;
RequestUniqueCode?: string;
};
code = item.RequestUniqueCode || '';
title = item.Title || '';
} catch {
// без кода и названия — лог всё равно нужен
}
const what = [code, title].filter(Boolean).join(' · ') || `№${requestId}`;
log.info(`${DECISION_MESSAGE[decision]}: ${what}`, {
'request.id': requestId,
'request.code': code,
'request.title': title,
'request.list': this.dataListRef,
'request.status': requestStatus,
'approval.decision': decision,
'approval.rowId': rowId,
'approval.comment': comment.slice(0, 500),
});
}
private dataList(): IList {
return this.dataListRef.startsWith('/')
? getSP().web.getList(this.dataListRef)
@@ -2,10 +2,13 @@ import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import { IList } from '@pnp/sp/lists';
import { log } from '@tnsu/monitoring-web';
import { getSP } from './pnp/pnpjs';
import { buildRequestUniqueCode } from './requestCode';
import { dataListTitle } from './listTitles';
import { RequestTypeId } from '../models/types';
import { REQUEST_TYPES } from '../data/requests';
import { isSubmittedStatus } from './approval/models/statuses';
/**
* Список данных, в который фактически пишем: listId (конкретная партиция —
@@ -50,6 +53,7 @@ export async function saveRequestItem(
if (existingId) {
await list.items.getById(existingId).update(item);
logSaved(formId, existingId, undefined, item, false);
return { id: existingId };
}
@@ -58,5 +62,34 @@ export async function saveRequestItem(
// Код известен только после создания элемента (нужен его Id).
const code = buildRequestUniqueCode(codePrefix, id, new Date());
await list.items.getById(id).update({ RequestUniqueCode: code });
logSaved(formId, id, code, item, true);
return { id, code };
}
/**
* События для мониторинга: какая заявка создана и отправлена на согласование (пользователь — из токена).
* В тексте — вид, код и название заявки, чтобы её было видно прямо в списке логов. Решения согласующих
* пишет approverDecision.service.ts.
*/
function logSaved(
formId: RequestTypeId,
id: number,
code: string | undefined,
item: Record<string, unknown>,
created: boolean
): void {
const typeTitle = REQUEST_TYPES.find((t) => t.id === formId)?.title ?? formId;
const title = typeof item.Title === 'string' ? item.Title : '';
const status = typeof item.RequestStatus === 'string' ? item.RequestStatus : '';
const what = [typeTitle, code, title].filter(Boolean).join(' · ');
const attributes = {
'request.type': formId,
'request.typeTitle': typeTitle,
'request.id': id,
'request.code': code ?? '',
'request.title': title,
'request.status': status,
};
if (created) log.info(`Создана заявка: ${what}`, attributes);
if (isSubmittedStatus(status)) log.info(`Заявка отправлена на согласование: ${what}`, attributes);
}