@nodefony/devkit 10.0.0-beta.3 → 10.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -4
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
- package/dist/index.js +24 -11
- package/dist/nodefony/config/config.js +16 -137
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/controllers/DevkitController.js +7 -14
- package/dist/nodefony/controllers/McpController.js +25 -226
- package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
- package/dist/nodefony/mcp/observe.js +235 -0
- package/dist/nodefony/mcp/profileReport.js +242 -0
- package/dist/nodefony/mcp/profileRoute.js +521 -0
- package/dist/nodefony/mcp/shotClient.js +84 -0
- package/dist/nodefony/mcp/shutdown.js +44 -0
- package/dist/nodefony/mcp/tools.js +696 -0
- package/dist/nodefony/service/DevkitService.js +93 -29
- package/dist/nodefony/src/errors/DevkitError.js +1 -2
- package/dist/nodefony/src/profileSession.js +14 -0
- package/dist/types/index.d.ts +15 -1
- package/dist/types/nodefony/config/config.d.ts +1 -1
- package/dist/types/nodefony/controllers/McpController.d.ts +11 -26
- package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
- package/dist/types/nodefony/mcp/observe.d.ts +83 -0
- package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
- package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
- package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
- package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
- package/dist/types/nodefony/mcp/tools.d.ts +109 -0
- package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
- package/dist/types/nodefony/src/profileSession.d.ts +12 -0
- package/docs/index.md +160 -11
- package/package.json +16 -11
- package/skills/nodefony-add-crud/SKILL.md +5 -0
- package/skills/nodefony-add-service/SKILL.md +3 -3
- package/skills/nodefony-authentication/SKILL.md +5 -5
- package/skills/nodefony-browser/SKILL.md +20 -0
- package/skills/nodefony-browser/references/socket.md +13 -4
- package/skills/nodefony-browser/scripts/inspect.mjs +1 -1
- package/skills/nodefony-browser/scripts/lib/probes.mjs +53 -0
- package/skills/nodefony-browser/scripts/socket.mjs +70 -4
- package/skills/nodefony-browser/scripts/watch.mjs +101 -0
- package/skills/nodefony-dev/SKILL.md +31 -1
- package/skills/nodefony-dev/scripts/docs.mjs +3 -3
- package/skills/nodefony-devops/SKILL.md +85 -64
- package/skills/nodefony-devops/references/dimensionnement.md +122 -0
- package/skills/nodefony-devops/references/frontal.md +277 -47
- package/skills/nodefony-devops/references/kubernetes.md +41 -14
- package/skills/nodefony-devops/references/secrets.md +13 -2
- package/skills/nodefony-devops/references/variables.md +14 -12
- package/skills/nodefony-protect-route/SKILL.md +6 -4
- package/skills/nodefony-security-review/scripts/review-diff.mjs +4 -4
- package/skills/nodefony-troubleshoot/SKILL.md +86 -12
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { EVENT_LOOP_RESOLUTION_MS } from "nodefony";
|
|
2
|
+
import { ACTION_SELF_PHASE } from "@nodefony/http";
|
|
3
|
+
// nodefony/mcp/profileReport.ts
|
|
4
|
+
/**
|
|
5
|
+
* Le rapport de profil tel qu'un AGENT de code le lit — rendu partagé par les
|
|
6
|
+
* outils qui observent l'application (`nodefony_observe`) ou la sollicitent.
|
|
7
|
+
*
|
|
8
|
+
* L'ordre est celui de la décision : un verdict d'abord, puis ce qu'il faut
|
|
9
|
+
* corriger (une anomalie par ligne, nommée par route, contrôleur et forme SQL),
|
|
10
|
+
* les tableaux ensuite, et les données brutes en dernier pour comparer deux
|
|
11
|
+
* appels. Chaque section est bornée : le texte entier reste sous la limite que
|
|
12
|
+
* la porte impose à une réponse d'outil.
|
|
13
|
+
*/
|
|
14
|
+
/** p95 à partir duquel une route est signalée lente (développement, machine locale). */
|
|
15
|
+
const SLOW_ROUTE_P95_MS = 500;
|
|
16
|
+
/**
|
|
17
|
+
* Pire blocage de la boucle d'événements à partir duquel on le signale : au-delà,
|
|
18
|
+
* TOUTES les requêtes en cours ont attendu, quelle que soit leur route.
|
|
19
|
+
*/
|
|
20
|
+
const EVENT_LOOP_BLOCK_MS = 100;
|
|
21
|
+
/** Lignes maximales par section. */
|
|
22
|
+
const MAX_ANOMALIES = 10;
|
|
23
|
+
const MAX_ROUTES = 20;
|
|
24
|
+
const MAX_SLOW_QUERIES = 10;
|
|
25
|
+
/** Requêtes en erreur dont le journal est montré, par route. */
|
|
26
|
+
const LOGS_PER_ROUTE = 3;
|
|
27
|
+
/** Longueur maximale d'une forme SQL citée. */
|
|
28
|
+
const SHAPE_MAX = 300;
|
|
29
|
+
/** Nombre lisible : une décimale sous 10, entier au-delà. */
|
|
30
|
+
function ms(value) {
|
|
31
|
+
return value < 10 ? value.toFixed(1) : String(Math.round(value));
|
|
32
|
+
}
|
|
33
|
+
function cut(text, max) {
|
|
34
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
35
|
+
}
|
|
36
|
+
function routeLabel(r) {
|
|
37
|
+
const target = r.controller !== null && r.action !== null ? ` (${r.controller}.${r.action})` : "";
|
|
38
|
+
return `\`${r.route}\`${target}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Dénominateur de tout ce qui se dit « par requête » : les requêtes servies,
|
|
42
|
+
* la population sur laquelle l'agrégation a cumulé phases et SQL.
|
|
43
|
+
*/
|
|
44
|
+
function served(r) {
|
|
45
|
+
return Math.max(1, r.served);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Les trois postes les plus lourds d'une route, en ms par requête.
|
|
49
|
+
* La phase `action` est écartée : elle CONTIENT les autres, `action.self` dit
|
|
50
|
+
* ce qui reste à l'action une fois retirés rendu, initialisation et SQL.
|
|
51
|
+
*/
|
|
52
|
+
function topPhases(r) {
|
|
53
|
+
const parts = [];
|
|
54
|
+
for (const [name, total] of Object.entries(r.phases)) {
|
|
55
|
+
if (name === "action") continue;
|
|
56
|
+
parts.push([name, total.totalMs / served(r)]);
|
|
57
|
+
}
|
|
58
|
+
if (r.queries.totalMs > 0) parts.push(["SQL (cumul)", r.queries.totalMs / served(r)]);
|
|
59
|
+
parts.sort((a, b) => b[1] - a[1]);
|
|
60
|
+
return parts.slice(0, 3).map(([name, v]) => `${name} ${ms(v)} ms`).join(" · ");
|
|
61
|
+
}
|
|
62
|
+
/** Les anomalies à corriger, les plus coûteuses d'abord. */
|
|
63
|
+
function anomalies(report, ctx) {
|
|
64
|
+
const lines = [];
|
|
65
|
+
for (const n of report.nPlusOne) lines.push(`N+1 — route ${routeLabel(n)} : \`${cut(n.shape, SHAPE_MAX)}\` répétée ×${n.maxRepeats} dans une requête (${n.requests} requête(s) touchée(s), ${ms(n.totalMs)} ms cumulés). Exemple : requestId ${n.sampleRequestId}. Remède habituel : charger la relation en une requête (\`relations\`, \`\$in\`).`);
|
|
66
|
+
for (const r of report.routes) {
|
|
67
|
+
if (r.status.s5xx === 0 && r.errors === 0) continue;
|
|
68
|
+
const ids = report.errors.filter((e) => e.route === r.route).slice(0, 3);
|
|
69
|
+
const message = ids.find((e) => e.error !== null)?.error;
|
|
70
|
+
lines.push(`Erreurs — route ${routeLabel(r)} : ${r.errors} sur ${r.count}` + (message === void 0 ? "" : ` — « ${message} »`) + (ids.length === 0 ? "." : `. requestId : ${ids.map((e) => e.requestId).join(", ")}` + (ctx.errorLogs === null ? ". Journal d'une requête : `nodefony_admin_call { namespace: \"syslog\", path: \"logs\", query: { requestId } }`." : " — journal ci-dessous.")));
|
|
71
|
+
}
|
|
72
|
+
for (const r of report.routes) {
|
|
73
|
+
if (r.p95Ms === null || r.p95Ms < 500) continue;
|
|
74
|
+
lines.push(`Lente — route ${routeLabel(r)} : p95 ${ms(r.p95Ms)} ms ; par requête : ${topPhases(r)}.`);
|
|
75
|
+
}
|
|
76
|
+
if (ctx.eventLoop !== null && ctx.eventLoop.maxMs >= 100) lines.push(`Boucle d'événements bloquée — jusqu'à ${ms(ctx.eventLoop.maxMs)} ms d'un tenant (retard moyen ${ms(ctx.eventLoop.meanMs)} ms) : un traitement synchrone a gelé le process, et toutes les requêtes en cours ont attendu, quelle que soit leur route.` + (ctx.uptimeSec < 60 ? ` Le process a démarré il y a ${Math.round(ctx.uptimeSec)} s : ce blocage peut venir du démarrage (compilation, préparation du front) — rappeler l'outil plus tard pour trancher.` : ""));
|
|
77
|
+
return lines;
|
|
78
|
+
}
|
|
79
|
+
/** Durée lisible : en ms sous la seconde, en secondes au-delà. */
|
|
80
|
+
function duration(valueMs) {
|
|
81
|
+
return valueMs < 1e3 ? `${Math.round(valueMs)} ms` : `${ms(valueMs / 1e3)} s`;
|
|
82
|
+
}
|
|
83
|
+
/** Nom d'une identité de tir, tel que le rapport le cite. */
|
|
84
|
+
function identityLabel(identity) {
|
|
85
|
+
if (identity.kind === "anonymous") return identity.why === null ? "anonyme" : `anonyme (${identity.why})`;
|
|
86
|
+
const roles = identity.roles.length === 0 ? "aucun rôle" : identity.roles.join(", ");
|
|
87
|
+
return `\`${identity.identifier}\` (${roles})` + (identity.impersonated ? ", à la place du propriétaire du jeton" : "");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Ce qui fait qu'un tir ne mesure pas ce qui était demandé : refus, redirections,
|
|
91
|
+
* profils non retrouvés, budget épuisé. Dit EN TÊTE, avant les chiffres.
|
|
92
|
+
*/
|
|
93
|
+
function shotGaps(shot, ctx) {
|
|
94
|
+
const out = [];
|
|
95
|
+
const c = shot.client;
|
|
96
|
+
const refused = c.unauthorized + c.forbidden;
|
|
97
|
+
if (refused > 0) out.push(`${refused}/${shot.sent} requête(s) REFUSÉE(S) (401×${c.unauthorized}, 403×${c.forbidden}) en tant que ${identityLabel(shot.identity)} — un refus n'est pas une mesure : ` + (shot.identity.kind === "anonymous" ? "jouer un compte exige un jeton MCP portant le scope admin:write (`npx nodefony security:mcp:token --scope \"admin:read admin:write\" --write`)" : "ce compte n'a pas les rôles exigés, ou la zone de ce chemin n'accepte pas la session (zone à jeton) — essayer `as` avec un compte autorisé"));
|
|
98
|
+
if (c.redirected > 0) out.push(`${c.redirected}/${shot.sent} requête(s) REDIRIGÉE(S)` + (c.location === null ? "" : ` vers ${c.location}`) + " — les durées mesurent la redirection, pas la page (page protégée vue sans session ?)");
|
|
99
|
+
if (c.failed > 0) out.push(`${c.failed} requête(s) sans réponse` + (c.failure === null ? "" : ` (${c.failure})`));
|
|
100
|
+
if (shot.missing > 0) out.push(`${shot.missing} réponse(s) reçue(s) dont le profil n'a pas été retrouvé : les chiffres portent sur les autres`);
|
|
101
|
+
if (shot.truncated && !ctx.cancelled && !ctx.interrupted) out.push(`tir arrêté au bout de ${ms(shot.budgetMs / 1e3)} s : ${shot.sent} requête(s) envoyée(s) sur ${shot.requested} — réduire count, ou la route est trop lente pour ce volume`);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/** Ce qui rend l'observation incomplète — à dire avant toute conclusion. */
|
|
105
|
+
function gaps(report, ctx) {
|
|
106
|
+
const out = [];
|
|
107
|
+
const shot = ctx.shot;
|
|
108
|
+
if (shot !== void 0) out.push(...shotGaps(shot, ctx));
|
|
109
|
+
if (report.requests === 0 && shot !== void 0) out.push("aucune réponse profilée" + (shot.client.failure === null ? "" : ` : ${shot.client.failure}`));
|
|
110
|
+
else if (report.requests === 0) out.push("aucune requête observée : l'application n'a reçu aucun trafic pendant la fenêtre (cet outil n'en génère pas — lancer la page, les tests ou une charge pendant l'observation)");
|
|
111
|
+
if (ctx.lost > 0) out.push(`${ctx.lost} profil(s) perdu(s) avant lecture (tampon du profileur débordé) : les chiffres portent sur un échantillon`);
|
|
112
|
+
if (report.untimed > 0) out.push(`${report.untimed} requête(s) sans durée (mesure des phases désactivée, ou requête interrompue)`);
|
|
113
|
+
if (ctx.interrupted) out.push(`${shot === void 0 ? "observation interrompue" : "tir interrompu"} après ${ms(ctx.elapsedMs / 1e3)} s : le serveur s'arrête (rechargement après une modification de fichier ?) — rappeler ` + (shot === void 0 ? "nodefony_observe" : "nodefony_profile_route") + " une fois le serveur redémarré, sans modifier de fichier pendant la mesure");
|
|
114
|
+
if (ctx.cancelled) out.push(shot === void 0 ? `observation abandonnée après ${ms(ctx.elapsedMs / 1e3)} s sur ${ms(ctx.windowMs / 1e3)} s` : `tir abandonné après ${ms(ctx.elapsedMs / 1e3)} s : ${shot.sent} requête(s) envoyée(s) sur ${shot.requested}`);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
/** La raison commune quand TOUTES les lectures de journal ont échoué, sinon `null`. */
|
|
118
|
+
function sameUnavailableReason(logs) {
|
|
119
|
+
if (logs === null || logs.size === 0) return null;
|
|
120
|
+
let reason = null;
|
|
121
|
+
for (const one of logs.values()) {
|
|
122
|
+
if (one.kind !== "unavailable") return null;
|
|
123
|
+
if (reason !== null && one.reason !== reason) return null;
|
|
124
|
+
reason = one.reason;
|
|
125
|
+
}
|
|
126
|
+
return reason;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Rend le rapport complet pour l'agent.
|
|
130
|
+
*
|
|
131
|
+
* @param report - l'agrégation des profils observés.
|
|
132
|
+
* @param ctx - le déroulé de l'observation.
|
|
133
|
+
* @returns le texte, borné.
|
|
134
|
+
*/
|
|
135
|
+
function renderProfileReport(report, ctx) {
|
|
136
|
+
const found = anomalies(report, ctx);
|
|
137
|
+
const missing = gaps(report, ctx);
|
|
138
|
+
const out = [];
|
|
139
|
+
const servedTotal = report.routes.reduce((n, r) => n + r.served, 0);
|
|
140
|
+
const verdict = found.length > 0 ? `anomalies (${found.length})` : servedTotal > 0 ? "ok" : "rien à juger";
|
|
141
|
+
out.push(`Verdict : ${verdict}` + (missing.length > 0 ? " — observation INCOMPLÈTE" : " — observation complète"));
|
|
142
|
+
for (const m of missing) out.push(`⚠️ ${m}.`);
|
|
143
|
+
const shot = ctx.shot;
|
|
144
|
+
if (shot !== void 0) {
|
|
145
|
+
const w = shot.warmup;
|
|
146
|
+
const c = shot.client;
|
|
147
|
+
out.push(`Tir : ${shot.sent}/${shot.requested} requête(s) ${shot.method} ${shot.path} · concurrence ${shot.concurrency} · ${duration(ctx.elapsedMs)} · ` + (w.count === 0 ? "sans chauffe" : `chauffe de ${w.count} requête(s) hors mesures (p50 ${w.p50Ms === null ? "—" : ms(w.p50Ms)} ms, max ${w.maxMs === null ? "—" : ms(w.maxMs)} ms)`), `Joué en tant que : ${identityLabel(shot.identity)}`, `Reçu par le client : 2xx ${c.ok} · 3xx ${c.redirected} · 401 ${c.unauthorized} · 403 ${c.forbidden} · autres 4xx ${c.clientError} · 5xx ${c.serverError} · sans réponse ${c.failed}`);
|
|
148
|
+
} else out.push(`Fenêtre : ${ms(ctx.elapsedMs / 1e3)} s sur ${ms(ctx.windowMs / 1e3)} s · ${ctx.ticks} relevé(s) · ${report.requests} requête(s) (${report.http} HTTP, ${report.ws} WebSocket) · ${ctx.lost} perdue(s) · ${report.filteredTooling} de l'outillage écartée(s)` + (ctx.route === null ? "" : ` · restreinte à la route \`${ctx.route}\` (${ctx.filteredRoute} autre(s) écartée(s))`));
|
|
149
|
+
const process = [`pid ${ctx.pid}, démarré il y a ${Math.round(ctx.uptimeSec)} s`];
|
|
150
|
+
if (ctx.eventLoop === null && ctx.shot !== void 0) process.push(`boucle d'événements : non mesurée (tir trop court pour la sonde, qui échantillonne toutes les ${EVENT_LOOP_RESOLUTION_MS} ms)`);
|
|
151
|
+
else if (ctx.eventLoop !== null) process.push(`boucle d'événements : retard moyen ${ms(ctx.eventLoop.meanMs)} ms, pire blocage ${ms(ctx.eventLoop.maxMs)} ms`);
|
|
152
|
+
if (ctx.heapDeltaBytes !== null) {
|
|
153
|
+
const mo = ctx.heapDeltaBytes / 1048576;
|
|
154
|
+
process.push(`tas ${mo >= 0 ? "+" : ""}${mo.toFixed(1)} Mo (indicatif, sans GC)`);
|
|
155
|
+
}
|
|
156
|
+
out.push(`Process : ${process.join(" · ")}`);
|
|
157
|
+
const refused = report.routes.filter((r) => r.status.unauthorized + r.status.forbidden > 0);
|
|
158
|
+
if (refused.length > 0 && shot === void 0) out.push("Refus (hors percentiles) : " + refused.slice(0, 5).map((r) => `\`${r.route}\` 401×${r.status.unauthorized} 403×${r.status.forbidden}`).join(" · ") + " — une route protégée appelée sans jeton se mesure en refus, pas en temps de réponse.");
|
|
159
|
+
if (found.length > 0) {
|
|
160
|
+
out.push("", "Anomalies (les plus coûteuses d'abord) :");
|
|
161
|
+
found.slice(0, MAX_ANOMALIES).forEach((line, i) => out.push(`${i + 1}. ${line}`));
|
|
162
|
+
if (found.length > MAX_ANOMALIES) out.push(`… et ${found.length - MAX_ANOMALIES} autre(s), dans les données ci-dessous.`);
|
|
163
|
+
}
|
|
164
|
+
const closed = sameUnavailableReason(ctx.errorLogs);
|
|
165
|
+
if (closed !== null) out.push("", `Journal des requêtes en erreur : non lu — ${closed}.`);
|
|
166
|
+
else if (ctx.errorLogs !== null && ctx.errorLogs.size > 0) {
|
|
167
|
+
out.push("", "Journal des requêtes en erreur (syslog de l'application, lu au relevé qui les a vues) :");
|
|
168
|
+
const shown = /* @__PURE__ */ new Map();
|
|
169
|
+
for (const e of report.errors) {
|
|
170
|
+
const logs = ctx.errorLogs.get(e.requestId);
|
|
171
|
+
if (logs === void 0) continue;
|
|
172
|
+
const already = shown.get(e.route) ?? 0;
|
|
173
|
+
if (already >= LOGS_PER_ROUTE) continue;
|
|
174
|
+
shown.set(e.route, already + 1);
|
|
175
|
+
out.push(`- requestId ${e.requestId} — route \`${e.route}\`, statut ${e.status ?? "—"} :`);
|
|
176
|
+
if (logs.kind === "unavailable") out.push(` (journal non lu : ${logs.reason})`);
|
|
177
|
+
else if (logs.lines.length === 0) out.push((logs.hidden ?? 0) > 0 ? ` (${logs.hidden} ligne(s) de détail écartée(s), aucune de niveau WARNING ou plus grave)` : " (aucune ligne retrouvée : déjà évincée du tampon de relecture du syslog, ou rien journalisé)");
|
|
178
|
+
else for (const line of logs.lines) out.push(` ${line}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (report.routes.length > 0) {
|
|
182
|
+
out.push("", "Routes (p95 décroissant ; durées sur les requêtes servies) :", "route | type | n | p50 | p95 | max | 2xx/3xx/4xx/5xx | SQL par requête moy/max | temps par requête");
|
|
183
|
+
for (const r of report.routes.slice(0, MAX_ROUTES)) {
|
|
184
|
+
const s = r.status;
|
|
185
|
+
const sqlAvg = r.queries.total / served(r);
|
|
186
|
+
out.push([
|
|
187
|
+
routeLabel(r),
|
|
188
|
+
r.kind,
|
|
189
|
+
r.count,
|
|
190
|
+
r.p50Ms === null ? "—" : `${ms(r.p50Ms)} ms`,
|
|
191
|
+
r.p95Ms === null ? "—" : `${ms(r.p95Ms)} ms`,
|
|
192
|
+
r.maxMs === null ? "—" : `${ms(r.maxMs)} ms`,
|
|
193
|
+
`${s.s2xx}/${s.s3xx}/${s.s4xx}/${s.s5xx}`,
|
|
194
|
+
`${sqlAvg.toFixed(1)}/${r.queries.perRequestMax}`,
|
|
195
|
+
topPhases(r) || "—"
|
|
196
|
+
].join(" | "));
|
|
197
|
+
}
|
|
198
|
+
if (report.routes.length > MAX_ROUTES) out.push(`… ${report.routes.length - MAX_ROUTES} route(s) de plus.`);
|
|
199
|
+
}
|
|
200
|
+
if (report.slowQueries.length > 0) {
|
|
201
|
+
out.push("", "Requêtes SQL les plus coûteuses (par forme, valeurs masquées) :");
|
|
202
|
+
for (const q of report.slowQueries.slice(0, MAX_SLOW_QUERIES)) out.push(`- ${ms(q.totalMs)} ms cumulés · ×${q.count} · max ${ms(q.maxMs)} ms · ${q.routes.map((r) => `\`${r}\``).join(", ")} — \`${cut(q.shape, SHAPE_MAX)}\``);
|
|
203
|
+
if (report.sqlShapesDropped > 0) out.push(`… ${report.sqlShapesDropped} forme(s) de plus, comptées sans détail.`);
|
|
204
|
+
}
|
|
205
|
+
out.push("", "Données (JSON, pour comparer deux appels) :", JSON.stringify({
|
|
206
|
+
requests: report.requests,
|
|
207
|
+
lost: ctx.lost,
|
|
208
|
+
routes: report.routes.slice(0, MAX_ROUTES).map((r) => ({
|
|
209
|
+
route: r.route,
|
|
210
|
+
kind: r.kind,
|
|
211
|
+
n: r.count,
|
|
212
|
+
p50: r.p50Ms,
|
|
213
|
+
p95: r.p95Ms,
|
|
214
|
+
s5xx: r.status.s5xx,
|
|
215
|
+
sqlPerReq: Math.round(r.queries.total / served(r) * 10) / 10,
|
|
216
|
+
actionSelfMs: r.phases[ACTION_SELF_PHASE] === void 0 ? null : Math.round(r.phases[ACTION_SELF_PHASE].totalMs / served(r) * 100) / 100
|
|
217
|
+
})),
|
|
218
|
+
nPlusOne: report.nPlusOne.slice(0, MAX_ANOMALIES).map((n) => ({
|
|
219
|
+
route: n.route,
|
|
220
|
+
repeats: n.maxRepeats,
|
|
221
|
+
shape: cut(n.shape, SHAPE_MAX)
|
|
222
|
+
}))
|
|
223
|
+
}));
|
|
224
|
+
return out.join("\n");
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* La ligne d'un relevé intermédiaire, envoyée en progression à l'agent.
|
|
228
|
+
*
|
|
229
|
+
* @param report - l'agrégation à cet instant.
|
|
230
|
+
* @param elapsedSec - secondes écoulées.
|
|
231
|
+
* @param windowSec - fenêtre demandée.
|
|
232
|
+
* @param lost - profils perdus jusqu'ici.
|
|
233
|
+
*/
|
|
234
|
+
function progressLine(report, elapsedSec, windowSec, lost) {
|
|
235
|
+
let p95 = null;
|
|
236
|
+
for (const r of report.routes) if (r.p95Ms !== null && (p95 === null || r.p95Ms > p95)) p95 = r.p95Ms;
|
|
237
|
+
let errors = 0;
|
|
238
|
+
for (const r of report.routes) errors += r.status.s5xx;
|
|
239
|
+
const n1 = report.nPlusOne.at(0);
|
|
240
|
+
return `t=${Math.round(elapsedSec)}/${windowSec} s — ${report.requests} requête(s)` + (report.ws > 0 ? ` (dont ${report.ws} WebSocket)` : "") + `, p95 max ${p95 === null ? "—" : `${ms(p95)} ms`}, ${report.nPlusOne.length} N+1` + (n1 === void 0 ? "" : ` (${n1.route})`) + `, ${errors} 5xx, ${lost} perdue(s)`;
|
|
241
|
+
}
|
|
242
|
+
export { EVENT_LOOP_BLOCK_MS, SLOW_ROUTE_P95_MS, progressLine, renderProfileReport };
|