@loadstrike/loadstrike-sdk 0.1.0

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.
@@ -0,0 +1,1250 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.buildDotnetTxtReport = buildDotnetTxtReport;
7
+ exports.buildDotnetCsvReport = buildDotnetCsvReport;
8
+ exports.buildDotnetMarkdownReport = buildDotnetMarkdownReport;
9
+ exports.buildDotnetHtmlReport = buildDotnetHtmlReport;
10
+ const node_fs_1 = require("node:fs");
11
+ const node_os_1 = __importDefault(require("node:os"));
12
+ const node_path_1 = require("node:path");
13
+ const REPORT_EOL = node_os_1.default.EOL;
14
+ const REPORT_FALLBACK_SVG = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#64b5ff'/><stop offset='1' stop-color='#2f66db'/></linearGradient></defs><rect width='128' height='128' rx='24' fill='#081325'/><path d='M24 94L56 24l16 34 14-22 18 58h-16l-7-23-9 13-10-21-14 31H24z' fill='url(#g)'/></svg>";
15
+ const REPORT_LOGO_CACHE = new Map();
16
+ function appendReportLine(parts, text = "") {
17
+ parts.push(text, REPORT_EOL);
18
+ }
19
+ function reportLines(lines) {
20
+ return `${lines.join(REPORT_EOL)}${REPORT_EOL}`;
21
+ }
22
+ function reportValue(source, ...keys) {
23
+ if (!source || typeof source !== "object") {
24
+ return undefined;
25
+ }
26
+ const record = source;
27
+ for (const key of keys) {
28
+ if (record[key] !== undefined && record[key] !== null) {
29
+ return record[key];
30
+ }
31
+ }
32
+ return undefined;
33
+ }
34
+ function reportObject(source, ...keys) {
35
+ const value = reportValue(source, ...keys);
36
+ return value && typeof value === "object" && !Array.isArray(value)
37
+ ? value
38
+ : {};
39
+ }
40
+ function reportArray(source, ...keys) {
41
+ const value = reportValue(source, ...keys);
42
+ return Array.isArray(value) ? value : [];
43
+ }
44
+ function asString(value) {
45
+ return value == null ? "" : String(value);
46
+ }
47
+ function asInt(value) {
48
+ if (typeof value === "boolean") {
49
+ return value ? 1 : 0;
50
+ }
51
+ if (typeof value === "number" && Number.isFinite(value)) {
52
+ return Math.trunc(value);
53
+ }
54
+ const parsed = Number.parseInt(asString(value), 10);
55
+ return Number.isFinite(parsed) ? parsed : 0;
56
+ }
57
+ function asFloat(value) {
58
+ if (typeof value === "boolean") {
59
+ return value ? 1 : 0;
60
+ }
61
+ if (typeof value === "number" && Number.isFinite(value)) {
62
+ return value;
63
+ }
64
+ const parsed = Number.parseFloat(asString(value));
65
+ return Number.isFinite(parsed) ? parsed : 0;
66
+ }
67
+ function formatCellValue(value) {
68
+ if (value == null) {
69
+ return "";
70
+ }
71
+ if (typeof value === "boolean") {
72
+ return value ? "True" : "False";
73
+ }
74
+ return String(value);
75
+ }
76
+ function escapeHtml(value) {
77
+ return formatCellValue(value)
78
+ .split("&").join("&amp;")
79
+ .split("<").join("&lt;")
80
+ .split(">").join("&gt;")
81
+ .split("\"").join("&quot;")
82
+ .split("'").join("&#39;");
83
+ }
84
+ function escapeCsv(value) {
85
+ const text = asString(value);
86
+ return /[,"\n\r]/.test(text)
87
+ ? `"${text.split("\"").join("\"\"")}"`
88
+ : text;
89
+ }
90
+ function formatReportNumber(value) {
91
+ if (value == null) {
92
+ return "";
93
+ }
94
+ return asFloat(value).toFixed(3).replace(/\.?0+$/, "");
95
+ }
96
+ function parseDotnetTimeSpan(value) {
97
+ let text = value.trim();
98
+ if (!text) {
99
+ return undefined;
100
+ }
101
+ let sign = 1;
102
+ if (text.startsWith("+") || text.startsWith("-")) {
103
+ sign = text.startsWith("-") ? -1 : 1;
104
+ text = text.slice(1);
105
+ }
106
+ let days = 0;
107
+ if (text.includes(".") && text.split(":").length === 3 && text.indexOf(".") < text.indexOf(":")) {
108
+ const dayIndex = text.indexOf(".");
109
+ const dayText = text.slice(0, dayIndex);
110
+ if (!/^\d+$/.test(dayText)) {
111
+ return undefined;
112
+ }
113
+ days = Number.parseInt(dayText, 10);
114
+ text = text.slice(dayIndex + 1);
115
+ }
116
+ const parts = text.split(":");
117
+ if (parts.length !== 3) {
118
+ return undefined;
119
+ }
120
+ const [hoursText, minutesText, secondsAndFraction] = parts;
121
+ let secondsText = secondsAndFraction;
122
+ let fractionText = "";
123
+ if (secondsAndFraction.includes(".")) {
124
+ [secondsText, fractionText] = secondsAndFraction.split(".", 2);
125
+ }
126
+ if (!/^\d+$/.test(hoursText) || !/^\d+$/.test(minutesText) || !/^\d+$/.test(secondsText)) {
127
+ return undefined;
128
+ }
129
+ const hours = Number.parseInt(hoursText, 10);
130
+ const minutes = Number.parseInt(minutesText, 10);
131
+ const seconds = Number.parseInt(secondsText, 10);
132
+ const ticks = fractionText ? Number.parseInt((fractionText + "0000000").slice(0, 7), 10) : 0;
133
+ const milliseconds = ((((days * 24) + hours) * 60 + minutes) * 60 + seconds) * 1000 + (ticks / 10000);
134
+ return sign * milliseconds;
135
+ }
136
+ function coerceReportDurationMs(value) {
137
+ if (typeof value === "string") {
138
+ const parsed = parseDotnetTimeSpan(value);
139
+ if (parsed !== undefined) {
140
+ return parsed;
141
+ }
142
+ }
143
+ return Math.max(asFloat(value), 0);
144
+ }
145
+ function reportDurationSeconds(value) {
146
+ return Math.max(coerceReportDurationMs(value) / 1000, 0);
147
+ }
148
+ function formatDotnetTimeSpan(value) {
149
+ const totalTicks = Math.round(coerceReportDurationMs(value) * 10000);
150
+ const sign = totalTicks < 0 ? "-" : "";
151
+ let remaining = Math.abs(totalTicks);
152
+ const ticksPerDay = 24 * 60 * 60 * 10000000;
153
+ const ticksPerHour = 60 * 60 * 10000000;
154
+ const ticksPerMinute = 60 * 10000000;
155
+ const ticksPerSecond = 10000000;
156
+ const days = Math.floor(remaining / ticksPerDay);
157
+ remaining %= ticksPerDay;
158
+ const hours = Math.floor(remaining / ticksPerHour);
159
+ remaining %= ticksPerHour;
160
+ const minutes = Math.floor(remaining / ticksPerMinute);
161
+ remaining %= ticksPerMinute;
162
+ const seconds = Math.floor(remaining / ticksPerSecond);
163
+ const ticks = remaining % ticksPerSecond;
164
+ const base = `${sign}${days > 0 ? `${days}.` : ""}${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
165
+ return ticks === 0 ? base : `${base}.${String(ticks).padStart(7, "0")}`;
166
+ }
167
+ function loadStrikeNodeTypeTag(value) {
168
+ if (value && typeof value === "object" && !Array.isArray(value)) {
169
+ const record = value;
170
+ if (record.tag !== undefined) {
171
+ return asInt(record.tag);
172
+ }
173
+ if (record.Tag !== undefined) {
174
+ return asInt(record.Tag);
175
+ }
176
+ }
177
+ const text = asString(value).trim();
178
+ switch (text) {
179
+ case "SingleNode":
180
+ return 0;
181
+ case "Coordinator":
182
+ return 1;
183
+ case "Agent":
184
+ return 2;
185
+ default:
186
+ return asInt(value);
187
+ }
188
+ }
189
+ function parseUtcDate(value) {
190
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
191
+ return value;
192
+ }
193
+ const text = asString(value).trim();
194
+ if (!text) {
195
+ return undefined;
196
+ }
197
+ const exactMatch = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,7}))?Z$/.exec(text);
198
+ if (exactMatch) {
199
+ const [, base, fraction = ""] = exactMatch;
200
+ const milliseconds = fraction ? fraction.padEnd(7, "0").slice(0, 3) : "000";
201
+ return new Date(`${base}.${milliseconds}Z`);
202
+ }
203
+ const parsed = new Date(text);
204
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
205
+ }
206
+ function formatDotnetDateTime(value) {
207
+ const text = asString(value).trim();
208
+ const exactMatch = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,7}))?Z$/.exec(text);
209
+ if (exactMatch) {
210
+ const [, base, fraction = ""] = exactMatch;
211
+ return `${base}.${fraction.padEnd(7, "0") || "0000000"}Z`;
212
+ }
213
+ const date = parseUtcDate(value);
214
+ if (!date) {
215
+ return "";
216
+ }
217
+ const year = date.getUTCFullYear();
218
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
219
+ const day = String(date.getUTCDate()).padStart(2, "0");
220
+ const hours = String(date.getUTCHours()).padStart(2, "0");
221
+ const minutes = String(date.getUTCMinutes()).padStart(2, "0");
222
+ const seconds = String(date.getUTCSeconds()).padStart(2, "0");
223
+ const fraction = `${String(date.getUTCMilliseconds()).padStart(3, "0")}0000`;
224
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${fraction}Z`;
225
+ }
226
+ function escapeJsonForHtmlScript(value) {
227
+ return value.split("</").join("<\\/").split("<!--").join("<\\!--");
228
+ }
229
+ function buildReportLogoDataUri(resourceName) {
230
+ const cached = REPORT_LOGO_CACHE.get(resourceName);
231
+ if (cached) {
232
+ return cached;
233
+ }
234
+ const filename = resourceName.includes("dark") ? "logo-dark-theme.png" : "logo-light-theme.png";
235
+ const candidates = [
236
+ (0, node_path_1.resolve)(process.cwd(), "LoadStrike", "images", filename),
237
+ (0, node_path_1.resolve)(process.cwd(), "..", "LoadStrike", "images", filename),
238
+ (0, node_path_1.resolve)(process.cwd(), "..", "..", "LoadStrike", "images", filename),
239
+ (0, node_path_1.resolve)(process.cwd(), "..", "..", "..", "LoadStrike", "images", filename),
240
+ (0, node_path_1.resolve)(process.cwd(), "..", "..", "..", "..", "LoadStrike", "images", filename),
241
+ (0, node_path_1.resolve)(process.cwd(), "assets", filename),
242
+ (0, node_path_1.resolve)(process.cwd(), "src", "assets", filename),
243
+ (0, node_path_1.resolve)(process.cwd(), "sdk", "ts", "src", "assets", filename)
244
+ ];
245
+ for (const candidate of candidates) {
246
+ try {
247
+ if ((0, node_fs_1.existsSync)(candidate)) {
248
+ const dataUri = `data:image/png;base64,${(0, node_fs_1.readFileSync)(candidate).toString("base64")}`;
249
+ REPORT_LOGO_CACHE.set(resourceName, dataUri);
250
+ return dataUri;
251
+ }
252
+ }
253
+ catch {
254
+ // ignore and continue
255
+ }
256
+ }
257
+ const fallback = `data:image/svg+xml;base64,${Buffer.from(REPORT_FALLBACK_SVG, "utf8").toString("base64")}`;
258
+ REPORT_LOGO_CACHE.set(resourceName, fallback);
259
+ return fallback;
260
+ }
261
+ function getReportLogoLightDataUri() {
262
+ return buildReportLogoDataUri("LoadStrike.Assets.logo.light.png");
263
+ }
264
+ function getReportLogoDarkDataUri() {
265
+ return buildReportLogoDataUri("LoadStrike.Assets.logo.dark.png");
266
+ }
267
+ function sortBySortIndex(rows) {
268
+ return [...rows].sort((left, right) => asInt(reportValue(left, "sortIndex", "SortIndex")) - asInt(reportValue(right, "sortIndex", "SortIndex")));
269
+ }
270
+ function buildDotnetTableHtml(rows, wrapInCard = true) {
271
+ if (!rows.length) {
272
+ return wrapInCard
273
+ ? `<div class="card"><div class="table-wrap"><table><tbody><tr><td>No rows.</td></tr></tbody></table></div></div>${REPORT_EOL}`
274
+ : `<div class="table-wrap"><table><tbody><tr><td>No rows.</td></tr></tbody></table></div>${REPORT_EOL}`;
275
+ }
276
+ const headers = [];
277
+ for (const row of rows) {
278
+ for (const key of Object.keys(row)) {
279
+ if (!headers.includes(key)) {
280
+ headers.push(key);
281
+ }
282
+ }
283
+ }
284
+ const parts = [];
285
+ if (wrapInCard) {
286
+ appendReportLine(parts, "<div class=\"card\">");
287
+ }
288
+ appendReportLine(parts, "<div class=\"table-wrap\">");
289
+ appendReportLine(parts, "<table>");
290
+ appendReportLine(parts, "<thead><tr>");
291
+ for (const header of headers) {
292
+ appendReportLine(parts, `<th>${escapeHtml(header)}</th>`);
293
+ }
294
+ appendReportLine(parts, "</tr></thead><tbody>");
295
+ for (const row of rows) {
296
+ appendReportLine(parts, "<tr>");
297
+ for (const header of headers) {
298
+ appendReportLine(parts, `<td>${escapeHtml(row[header])}</td>`);
299
+ }
300
+ appendReportLine(parts, "</tr>");
301
+ }
302
+ appendReportLine(parts, "</tbody></table></div>");
303
+ if (wrapInCard) {
304
+ appendReportLine(parts, "</div>");
305
+ }
306
+ return parts.join("");
307
+ }
308
+ function buildFailedStatusRows(scenarios) {
309
+ const rows = [];
310
+ for (const scenario of scenarios) {
311
+ const scenarioName = asString(reportValue(scenario, "scenarioName", "ScenarioName"));
312
+ const fail = reportObject(scenario, "fail", "Fail");
313
+ for (const code of reportArray(fail, "statusCodes", "StatusCodes")) {
314
+ const count = asInt(reportValue(code, "count", "Count"));
315
+ const isError = Boolean(reportValue(code, "isError", "IsError"));
316
+ if (count <= 0 && !isError) {
317
+ continue;
318
+ }
319
+ rows.push({
320
+ Scope: "Scenario",
321
+ Scenario: scenarioName,
322
+ Step: "",
323
+ StatusCode: asString(reportValue(code, "statusCode", "StatusCode")),
324
+ Message: asString(reportValue(code, "message", "Message")),
325
+ Count: count,
326
+ Percent: asInt(reportValue(code, "percent", "Percent")),
327
+ IsError: isError
328
+ });
329
+ }
330
+ for (const step of sortBySortIndex(reportArray(scenario, "stepStats", "StepStats"))) {
331
+ const stepName = asString(reportValue(step, "stepName", "StepName"));
332
+ const stepFail = reportObject(step, "fail", "Fail");
333
+ for (const code of reportArray(stepFail, "statusCodes", "StatusCodes")) {
334
+ const count = asInt(reportValue(code, "count", "Count"));
335
+ const isError = Boolean(reportValue(code, "isError", "IsError"));
336
+ if (count <= 0 && !isError) {
337
+ continue;
338
+ }
339
+ rows.push({
340
+ Scope: "Step",
341
+ Scenario: scenarioName,
342
+ Step: stepName,
343
+ StatusCode: asString(reportValue(code, "statusCode", "StatusCode")),
344
+ Message: asString(reportValue(code, "message", "Message")),
345
+ Count: count,
346
+ Percent: asInt(reportValue(code, "percent", "Percent")),
347
+ IsError: isError
348
+ });
349
+ }
350
+ }
351
+ }
352
+ return rows;
353
+ }
354
+ function readReportRowText(row, ...keys) {
355
+ for (const key of keys) {
356
+ if (row[key] !== undefined && row[key] !== null) {
357
+ return asString(row[key]);
358
+ }
359
+ }
360
+ return "";
361
+ }
362
+ function buildFailedEventRows(plugins) {
363
+ const rows = [];
364
+ for (const plugin of plugins) {
365
+ const pluginName = asString(reportValue(plugin, "pluginName", "PluginName"));
366
+ for (const table of reportArray(plugin, "tables", "Tables")) {
367
+ const tableName = asString(reportValue(table, "tableName", "TableName"));
368
+ const lowerPlugin = pluginName.toLowerCase();
369
+ const lowerTable = tableName.toLowerCase();
370
+ const isFailedTable = lowerPlugin.includes("fail")
371
+ || lowerPlugin.includes("timeout")
372
+ || lowerTable.includes("fail")
373
+ || lowerTable.includes("timeout");
374
+ if (!isFailedTable) {
375
+ continue;
376
+ }
377
+ for (const row of reportArray(table, "rows", "Rows")) {
378
+ rows.push({
379
+ Category: `${pluginName}: ${tableName}`,
380
+ OccurredUtc: readReportRowText(row, "OccurredUtc"),
381
+ Scenario: readReportRowText(row, "Scenario", "scenarioName"),
382
+ Step: readReportRowText(row, "Step", "stepName"),
383
+ Source: readReportRowText(row, "Source"),
384
+ Destination: readReportRowText(row, "Destination"),
385
+ StatusCode: readReportRowText(row, "StatusCode"),
386
+ Message: readReportRowText(row, "Message", "message"),
387
+ TrackingId: readReportRowText(row, "TrackingId"),
388
+ EventId: readReportRowText(row, "EventId"),
389
+ LatencyMs: readReportRowText(row, "LatencyMs")
390
+ });
391
+ }
392
+ }
393
+ }
394
+ return rows;
395
+ }
396
+ function tryParseReportFloat(value) {
397
+ const parsed = Number.parseFloat(asString(value));
398
+ return Number.isFinite(parsed) ? parsed : undefined;
399
+ }
400
+ function meanNumeric(values) {
401
+ if (!values.length) {
402
+ return 0;
403
+ }
404
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
405
+ }
406
+ function percentileNumeric(values, percentileValue) {
407
+ if (!values.length) {
408
+ return 0;
409
+ }
410
+ const ordered = [...values].sort((left, right) => left - right);
411
+ const index = Math.min(Math.max(Math.ceil(percentileValue * ordered.length) - 1, 0), ordered.length - 1);
412
+ return ordered[index];
413
+ }
414
+ function buildGroupedCorrelationChartPayloads(rows) {
415
+ const grouped = new Map();
416
+ for (const row of rows) {
417
+ const gatherField = readReportRowText(row, "GatherByField") || "<none>";
418
+ const gatherValue = readReportRowText(row, "GatherByValue") || "<missing>";
419
+ const key = `${gatherField}\u0000${gatherValue}`;
420
+ const existing = grouped.get(key);
421
+ if (existing) {
422
+ existing.rows.push(row);
423
+ }
424
+ else {
425
+ grouped.set(key, { key: [gatherField, gatherValue], rows: [row] });
426
+ }
427
+ }
428
+ return [...grouped.values()]
429
+ .filter((group) => group.rows.some((row) => tryParseReportFloat(row.LatencyP50Ms) !== undefined
430
+ || tryParseReportFloat(row.LatencyP80Ms) !== undefined
431
+ || tryParseReportFloat(row.LatencyP85Ms) !== undefined
432
+ || tryParseReportFloat(row.LatencyP90Ms) !== undefined
433
+ || tryParseReportFloat(row.LatencyP95Ms) !== undefined
434
+ || tryParseReportFloat(row.LatencyP99Ms) !== undefined))
435
+ .sort((left, right) => left.key[0] === right.key[0]
436
+ ? left.key[1].localeCompare(right.key[1])
437
+ : left.key[0].localeCompare(right.key[0]))
438
+ .map((group, index) => ({
439
+ title: `${group.key[0]}: ${group.key[1]}`,
440
+ subtitle: group.rows.length > 1 ? `Averaged across ${group.rows.length} grouped rows.` : "Single grouped row.",
441
+ chart: {
442
+ labels: ["P50", "P80", "P85", "P90", "P95", "P99"],
443
+ series: [
444
+ {
445
+ name: "Latency",
446
+ color: ["#38bdf8", "#22c55e", "#f59e0b", "#a855f7", "#f43f5e", "#06b6d4", "#14b8a6", "#eab308", "#818cf8", "#84cc16"][index % 10],
447
+ values: [
448
+ meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP50Ms)).filter((value) => value !== undefined)),
449
+ meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP80Ms)).filter((value) => value !== undefined)),
450
+ meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP85Ms)).filter((value) => value !== undefined)),
451
+ meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP90Ms)).filter((value) => value !== undefined)),
452
+ meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP95Ms)).filter((value) => value !== undefined)),
453
+ meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP99Ms)).filter((value) => value !== undefined))
454
+ ]
455
+ }
456
+ ]
457
+ }
458
+ }));
459
+ }
460
+ function buildUngroupedCorrelationChartPayload(rows) {
461
+ const grouped = new Map();
462
+ for (const row of rows) {
463
+ const scenario = readReportRowText(row, "Scenario");
464
+ const destination = readReportRowText(row, "Destination");
465
+ const statusCode = readReportRowText(row, "StatusCode");
466
+ const latency = tryParseReportFloat(row.LatencyMs);
467
+ if (!scenario || !destination || !statusCode || latency === undefined) {
468
+ continue;
469
+ }
470
+ const key = `${scenario}\u0000${destination}\u0000${statusCode}`;
471
+ const existing = grouped.get(key);
472
+ if (existing) {
473
+ existing.latencies.push(latency);
474
+ }
475
+ else {
476
+ grouped.set(key, { scenario, destination, statusCode, latencies: [latency] });
477
+ }
478
+ }
479
+ const nameCount = new Map();
480
+ const series = [...grouped.values()]
481
+ .sort((left, right) => {
482
+ const leftKey = `${left.scenario}|${left.destination}|${left.statusCode}`;
483
+ const rightKey = `${right.scenario}|${right.destination}|${right.statusCode}`;
484
+ return leftKey.localeCompare(rightKey);
485
+ })
486
+ .map((row, index) => {
487
+ const baseName = row.statusCode.trim() || "status";
488
+ const key = baseName.toLowerCase();
489
+ const count = nameCount.get(key) ?? 0;
490
+ nameCount.set(key, count + 1);
491
+ return {
492
+ name: count === 0 ? baseName : `${baseName}-${count + 1}`,
493
+ color: ["#38bdf8", "#22c55e", "#f59e0b", "#a855f7", "#f43f5e", "#06b6d4", "#14b8a6", "#eab308", "#818cf8", "#84cc16"][index % 10],
494
+ values: [
495
+ percentileNumeric(row.latencies, 0.50),
496
+ percentileNumeric(row.latencies, 0.80),
497
+ percentileNumeric(row.latencies, 0.85),
498
+ percentileNumeric(row.latencies, 0.90),
499
+ percentileNumeric(row.latencies, 0.95),
500
+ percentileNumeric(row.latencies, 0.99)
501
+ ]
502
+ };
503
+ });
504
+ return {
505
+ labels: ["P50", "P80", "P85", "P90", "P95", "P99"],
506
+ series
507
+ };
508
+ }
509
+ function classifyStatusCodeBucket(statusCode) {
510
+ const text = asString(statusCode).trim();
511
+ if (text.length >= 3 && /\d/.test(text[0])) {
512
+ switch (text[0]) {
513
+ case "2":
514
+ return "2xx";
515
+ case "3":
516
+ return "3xx";
517
+ case "4":
518
+ return "4xx";
519
+ case "5":
520
+ return "5xx";
521
+ default:
522
+ return "Other";
523
+ }
524
+ }
525
+ return "Other";
526
+ }
527
+ function formatDotnetPercent(value) {
528
+ return value.toFixed(2).replace(/\.?0+$/, "");
529
+ }
530
+ function buildSummaryLatencyRow(scenarioName, resultName, measurement) {
531
+ const request = reportObject(measurement, "request", "Request");
532
+ const latency = reportObject(measurement, "latency", "Latency");
533
+ return {
534
+ Scenario: scenarioName,
535
+ Result: resultName,
536
+ Count: asInt(reportValue(request, "count", "Count")),
537
+ LatencyMinMs: formatReportNumber(reportValue(latency, "minMs", "MinMs")),
538
+ LatencyMeanMs: formatReportNumber(reportValue(latency, "meanMs", "MeanMs")),
539
+ LatencyP50Ms: formatReportNumber(reportValue(latency, "percent50", "Percent50")),
540
+ LatencyP75Ms: formatReportNumber(reportValue(latency, "percent75", "Percent75")),
541
+ LatencyP95Ms: formatReportNumber(reportValue(latency, "percent95", "Percent95")),
542
+ LatencyP99Ms: formatReportNumber(reportValue(latency, "percent99", "Percent99")),
543
+ LatencyMaxMs: formatReportNumber(reportValue(latency, "maxMs", "MaxMs")),
544
+ LatencyStdDev: formatReportNumber(reportValue(latency, "stdDev", "StdDev"))
545
+ };
546
+ }
547
+ function buildMeasurementRow(scope, scenarioName, stepName, resultName, measurement) {
548
+ const request = reportObject(measurement, "request", "Request");
549
+ const latency = reportObject(measurement, "latency", "Latency");
550
+ const latencyCount = reportObject(latency, "latencyCount", "LatencyCount");
551
+ const dataTransfer = reportObject(measurement, "dataTransfer", "DataTransfer");
552
+ return {
553
+ Scope: scope,
554
+ Scenario: scenarioName,
555
+ Step: stepName,
556
+ Result: resultName,
557
+ Count: asInt(reportValue(request, "count", "Count")),
558
+ Percent: asInt(reportValue(request, "percent", "Percent")),
559
+ RPS: formatReportNumber(reportValue(request, "rps", "RPS")),
560
+ LatencyMinMs: formatReportNumber(reportValue(latency, "minMs", "MinMs")),
561
+ LatencyMeanMs: formatReportNumber(reportValue(latency, "meanMs", "MeanMs")),
562
+ LatencyP50Ms: formatReportNumber(reportValue(latency, "percent50", "Percent50")),
563
+ LatencyP75Ms: formatReportNumber(reportValue(latency, "percent75", "Percent75")),
564
+ LatencyP95Ms: formatReportNumber(reportValue(latency, "percent95", "Percent95")),
565
+ LatencyP99Ms: formatReportNumber(reportValue(latency, "percent99", "Percent99")),
566
+ LatencyMaxMs: formatReportNumber(reportValue(latency, "maxMs", "MaxMs")),
567
+ LatencyStdDev: formatReportNumber(reportValue(latency, "stdDev", "StdDev")),
568
+ "Latency<=800ms": asInt(reportValue(latencyCount, "lessOrEq800", "LessOrEq800")),
569
+ "Latency800-1200ms": asInt(reportValue(latencyCount, "more800Less1200", "More800Less1200")),
570
+ "Latency>=1200ms": asInt(reportValue(latencyCount, "moreOrEq1200", "MoreOrEq1200")),
571
+ BytesTotal: asInt(reportValue(dataTransfer, "allBytes", "AllBytes")),
572
+ BytesMin: asInt(reportValue(dataTransfer, "minBytes", "MinBytes")),
573
+ BytesMean: asInt(reportValue(dataTransfer, "meanBytes", "MeanBytes")),
574
+ BytesP50: asInt(reportValue(dataTransfer, "percent50", "Percent50")),
575
+ BytesP75: asInt(reportValue(dataTransfer, "percent75", "Percent75")),
576
+ BytesP95: asInt(reportValue(dataTransfer, "percent95", "Percent95")),
577
+ BytesP99: asInt(reportValue(dataTransfer, "percent99", "Percent99")),
578
+ BytesMax: asInt(reportValue(dataTransfer, "maxBytes", "MaxBytes")),
579
+ BytesStdDev: formatReportNumber(reportValue(dataTransfer, "stdDev", "StdDev"))
580
+ };
581
+ }
582
+ function appendChartCard(parts, id, title) {
583
+ appendReportLine(parts, `<div class="chart-card"><h3>${escapeHtml(title)}</h3><canvas id="${escapeHtml(id)}" class="chart-canvas"></canvas></div>`);
584
+ }
585
+ function hasChartPointData(points) {
586
+ return Array.isArray(points) && points.length > 0;
587
+ }
588
+ function hasPieChartData(points) {
589
+ return Array.isArray(points) && points.some((point) => Math.abs(asFloat(reportValue(point, "value", "Value"))) > 0);
590
+ }
591
+ function hasLatencyTrendData(chart) {
592
+ const labels = reportArray(chart, "labels", "Labels");
593
+ const series = reportArray(chart, "series", "Series");
594
+ return labels.length > 0 && series.some((entry) => reportArray(entry, "values", "Values").some((value) => Number.isFinite(asFloat(value))));
595
+ }
596
+ function hasNonEmptyHints(plugin) {
597
+ return reportArray(plugin, "hints", "Hints").some((hint) => asString(hint).trim().length > 0);
598
+ }
599
+ function buildDotnetScenarioRows(nodeStats) {
600
+ return sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats")).map((scenario) => ({
601
+ Scenario: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
602
+ Simulation: asString(reportValue(reportObject(scenario, "loadSimulationStats", "LoadSimulationStats"), "simulationName", "SimulationName")),
603
+ SimulationValue: asInt(reportValue(reportObject(scenario, "loadSimulationStats", "LoadSimulationStats"), "value", "Value")),
604
+ Requests: asInt(reportValue(scenario, "allRequestCount", "AllRequestCount")),
605
+ OK: asInt(reportValue(scenario, "allOkCount", "AllOkCount")),
606
+ FAIL: asInt(reportValue(scenario, "allFailCount", "AllFailCount")),
607
+ Duration: formatDotnetTimeSpan(reportValue(scenario, "duration", "Duration", "durationMs", "DurationMs")),
608
+ RPS: formatReportNumber(reportDurationSeconds(reportValue(scenario, "duration", "Duration", "durationMs", "DurationMs")) <= 0 ? 0 : asInt(reportValue(scenario, "allRequestCount", "AllRequestCount")) / reportDurationSeconds(reportValue(scenario, "duration", "Duration", "durationMs", "DurationMs"))),
609
+ LatencyP95Ms: formatReportNumber(Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent95", "Percent95")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent95", "Percent95")))),
610
+ LatencyP99Ms: formatReportNumber(Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent99", "Percent99")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent99", "Percent99")))),
611
+ CurrentOperation: asString(reportValue(scenario, "currentOperation", "CurrentOperation"))
612
+ }));
613
+ }
614
+ function buildDotnetStepRows(nodeStats) {
615
+ const rows = [];
616
+ for (const scenario of sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats"))) {
617
+ for (const step of sortBySortIndex(reportArray(scenario, "stepStats", "StepStats"))) {
618
+ rows.push({
619
+ Scenario: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
620
+ Step: asString(reportValue(step, "stepName", "StepName")),
621
+ Requests: asInt(reportValue(reportObject(reportObject(step, "ok", "Ok"), "request", "Request"), "count", "Count")) + asInt(reportValue(reportObject(reportObject(step, "fail", "Fail"), "request", "Request"), "count", "Count")),
622
+ OK: asInt(reportValue(reportObject(reportObject(step, "ok", "Ok"), "request", "Request"), "count", "Count")),
623
+ FAIL: asInt(reportValue(reportObject(reportObject(step, "fail", "Fail"), "request", "Request"), "count", "Count")),
624
+ OK_RPS: formatReportNumber(reportValue(reportObject(reportObject(step, "ok", "Ok"), "request", "Request"), "rps", "RPS")),
625
+ FAIL_RPS: formatReportNumber(reportValue(reportObject(reportObject(step, "fail", "Fail"), "request", "Request"), "rps", "RPS")),
626
+ LatencyMeanMs: formatReportNumber(Math.max(asFloat(reportValue(reportObject(reportObject(step, "ok", "Ok"), "latency", "Latency"), "meanMs", "MeanMs")), asFloat(reportValue(reportObject(reportObject(step, "fail", "Fail"), "latency", "Latency"), "meanMs", "MeanMs")))),
627
+ P95LatencyMs: formatReportNumber(Math.max(asFloat(reportValue(reportObject(reportObject(step, "ok", "Ok"), "latency", "Latency"), "percent95", "Percent95")), asFloat(reportValue(reportObject(reportObject(step, "fail", "Fail"), "latency", "Latency"), "percent95", "Percent95"))))
628
+ });
629
+ }
630
+ }
631
+ return rows;
632
+ }
633
+ function buildDotnetScenarioMeasurementRows(nodeStats) {
634
+ const rows = [];
635
+ for (const scenario of sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats"))) {
636
+ const scenarioName = asString(reportValue(scenario, "scenarioName", "ScenarioName"));
637
+ rows.push(buildMeasurementRow("Scenario", scenarioName, "", "OK", reportObject(scenario, "ok", "Ok")));
638
+ rows.push(buildMeasurementRow("Scenario", scenarioName, "", "FAIL", reportObject(scenario, "fail", "Fail")));
639
+ }
640
+ return rows;
641
+ }
642
+ function buildDotnetStepMeasurementRows(nodeStats) {
643
+ const rows = [];
644
+ for (const scenario of sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats"))) {
645
+ const scenarioName = asString(reportValue(scenario, "scenarioName", "ScenarioName"));
646
+ for (const step of sortBySortIndex(reportArray(scenario, "stepStats", "StepStats"))) {
647
+ const stepName = asString(reportValue(step, "stepName", "StepName"));
648
+ rows.push(buildMeasurementRow("Step", scenarioName, stepName, "OK", reportObject(step, "ok", "Ok")));
649
+ rows.push(buildMeasurementRow("Step", scenarioName, stepName, "FAIL", reportObject(step, "fail", "Fail")));
650
+ }
651
+ }
652
+ return rows;
653
+ }
654
+ function buildDotnetStatusCodeRows(nodeStats) {
655
+ const rows = [];
656
+ for (const scenario of sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats"))) {
657
+ const scenarioName = asString(reportValue(scenario, "scenarioName", "ScenarioName"));
658
+ for (const code of reportArray(reportObject(scenario, "ok", "Ok"), "statusCodes", "StatusCodes")) {
659
+ rows.push({ Scope: "Scenario", Scenario: scenarioName, Step: "", Result: "OK", StatusCode: asString(reportValue(code, "statusCode", "StatusCode")), Message: asString(reportValue(code, "message", "Message")), Count: asInt(reportValue(code, "count", "Count")), Percent: asInt(reportValue(code, "percent", "Percent")), IsError: Boolean(reportValue(code, "isError", "IsError")) });
660
+ }
661
+ for (const code of reportArray(reportObject(scenario, "fail", "Fail"), "statusCodes", "StatusCodes")) {
662
+ rows.push({ Scope: "Scenario", Scenario: scenarioName, Step: "", Result: "FAIL", StatusCode: asString(reportValue(code, "statusCode", "StatusCode")), Message: asString(reportValue(code, "message", "Message")), Count: asInt(reportValue(code, "count", "Count")), Percent: asInt(reportValue(code, "percent", "Percent")), IsError: Boolean(reportValue(code, "isError", "IsError")) });
663
+ }
664
+ for (const step of sortBySortIndex(reportArray(scenario, "stepStats", "StepStats"))) {
665
+ const stepName = asString(reportValue(step, "stepName", "StepName"));
666
+ for (const code of reportArray(reportObject(step, "ok", "Ok"), "statusCodes", "StatusCodes")) {
667
+ rows.push({ Scope: "Step", Scenario: scenarioName, Step: stepName, Result: "OK", StatusCode: asString(reportValue(code, "statusCode", "StatusCode")), Message: asString(reportValue(code, "message", "Message")), Count: asInt(reportValue(code, "count", "Count")), Percent: asInt(reportValue(code, "percent", "Percent")), IsError: Boolean(reportValue(code, "isError", "IsError")) });
668
+ }
669
+ for (const code of reportArray(reportObject(step, "fail", "Fail"), "statusCodes", "StatusCodes")) {
670
+ rows.push({ Scope: "Step", Scenario: scenarioName, Step: stepName, Result: "FAIL", StatusCode: asString(reportValue(code, "statusCode", "StatusCode")), Message: asString(reportValue(code, "message", "Message")), Count: asInt(reportValue(code, "count", "Count")), Percent: asInt(reportValue(code, "percent", "Percent")), IsError: Boolean(reportValue(code, "isError", "IsError")) });
671
+ }
672
+ }
673
+ }
674
+ return rows;
675
+ }
676
+ function buildDotnetThresholdRows(nodeStats) {
677
+ return reportArray(nodeStats, "thresholds", "Thresholds").map((threshold) => ({
678
+ Scenario: asString(reportValue(threshold, "scenarioName", "ScenarioName")),
679
+ Step: asString(reportValue(threshold, "stepName", "StepName")),
680
+ Check: asString(reportValue(threshold, "checkExpression", "CheckExpression")),
681
+ Failed: Boolean(reportValue(threshold, "isFailed", "IsFailed")),
682
+ ErrorCount: asInt(reportValue(threshold, "errorCount", "ErrorCount")),
683
+ Exception: asString(reportValue(threshold, "exceptionMessage", "ExceptionMessage", "exceptionMsg", "ExceptionMsg"))
684
+ }));
685
+ }
686
+ function buildDotnetMetricRows(nodeStats) {
687
+ const metrics = reportObject(nodeStats, "metrics", "Metrics");
688
+ const rows = [];
689
+ for (const counter of reportArray(metrics, "counters", "Counters")) {
690
+ rows.push({
691
+ Type: "Counter",
692
+ Scenario: asString(reportValue(counter, "scenarioName", "ScenarioName")),
693
+ Name: asString(reportValue(counter, "metricName", "MetricName")),
694
+ Unit: asString(reportValue(counter, "unitOfMeasure", "UnitOfMeasure")),
695
+ Value: asInt(reportValue(counter, "value", "Value"))
696
+ });
697
+ }
698
+ for (const gauge of reportArray(metrics, "gauges", "Gauges")) {
699
+ rows.push({
700
+ Type: "Gauge",
701
+ Scenario: asString(reportValue(gauge, "scenarioName", "ScenarioName")),
702
+ Name: asString(reportValue(gauge, "metricName", "MetricName")),
703
+ Unit: asString(reportValue(gauge, "unitOfMeasure", "UnitOfMeasure")),
704
+ Value: formatReportNumber(reportValue(gauge, "value", "Value"))
705
+ });
706
+ }
707
+ return rows;
708
+ }
709
+ function buildDotnetStatusCodeClassChart(scenarios) {
710
+ const buckets = { "2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0, Other: 0 };
711
+ for (const scenario of scenarios) {
712
+ for (const bucket of [reportObject(scenario, "ok", "Ok"), reportObject(scenario, "fail", "Fail")]) {
713
+ for (const code of reportArray(bucket, "statusCodes", "StatusCodes")) {
714
+ buckets[classifyStatusCodeBucket(reportValue(code, "statusCode", "StatusCode"))] += asInt(reportValue(code, "count", "Count"));
715
+ }
716
+ }
717
+ }
718
+ return [
719
+ { label: "2xx", value: buckets["2xx"], color: "#16a34a" },
720
+ { label: "3xx", value: buckets["3xx"], color: "#0ea5e9" },
721
+ { label: "4xx", value: buckets["4xx"], color: "#f59e0b" },
722
+ { label: "5xx", value: buckets["5xx"], color: "#dc2626" },
723
+ { label: "Other", value: buckets.Other, color: "#8b5cf6" }
724
+ ].filter((entry) => entry.value > 0);
725
+ }
726
+ function buildDotnetChartData(nodeStats) {
727
+ const scenarios = sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats"));
728
+ return {
729
+ overallOutcome: [
730
+ { label: "OK", value: asInt(reportValue(nodeStats, "allOkCount", "AllOkCount")), color: "#18a957" },
731
+ { label: "FAIL", value: asInt(reportValue(nodeStats, "allFailCount", "AllFailCount")), color: "#d14343" }
732
+ ],
733
+ scenarioRequests: scenarios.map((scenario) => ({
734
+ label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
735
+ value: asInt(reportValue(scenario, "allRequestCount", "AllRequestCount")),
736
+ color: "#3b82f6"
737
+ })),
738
+ scenarioP95Latency: scenarios.map((scenario) => ({
739
+ label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
740
+ value: Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent95", "Percent95")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent95", "Percent95"))),
741
+ color: "#8b5cf6"
742
+ })),
743
+ scenarioRps: scenarios.map((scenario) => ({
744
+ label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
745
+ value: reportDurationSeconds(reportValue(scenario, "duration", "Duration", "durationMs", "DurationMs")) <= 0
746
+ ? 0
747
+ : asInt(reportValue(scenario, "allRequestCount", "AllRequestCount")) / reportDurationSeconds(reportValue(scenario, "duration", "Duration", "durationMs", "DurationMs")),
748
+ color: "#10b981"
749
+ })),
750
+ scenarioFailRate: scenarios.map((scenario) => ({
751
+ label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
752
+ value: asInt(reportValue(scenario, "allRequestCount", "AllRequestCount")) <= 0
753
+ ? 0
754
+ : (asInt(reportValue(scenario, "allFailCount", "AllFailCount")) * 100 / asInt(reportValue(scenario, "allRequestCount", "AllRequestCount"))),
755
+ color: "#ef4444"
756
+ })),
757
+ scenarioBytes: scenarios.map((scenario) => ({
758
+ label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
759
+ value: asInt(reportValue(scenario, "allBytes", "AllBytes")),
760
+ color: "#0ea5e9"
761
+ })),
762
+ statusCodeClasses: buildDotnetStatusCodeClassChart(scenarios),
763
+ scenarioLatencyTrend: {
764
+ labels: scenarios.map((scenario) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
765
+ series: [
766
+ { name: "P50", color: "#38bdf8", values: scenarios.map((scenario) => Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent50", "Percent50")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent50", "Percent50")))) },
767
+ { name: "P75", color: "#22c55e", values: scenarios.map((scenario) => Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent75", "Percent75")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent75", "Percent75")))) },
768
+ { name: "P95", color: "#f59e0b", values: scenarios.map((scenario) => Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent95", "Percent95")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent95", "Percent95")))) },
769
+ { name: "P99", color: "#f43f5e", values: scenarios.map((scenario) => Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent99", "Percent99")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent99", "Percent99")))) }
770
+ ]
771
+ }
772
+ };
773
+ }
774
+ function buildDotnetSummaryHtml(nodeStats) {
775
+ const scenarios = sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats"));
776
+ const successRate = asInt(reportValue(nodeStats, "allRequestCount", "AllRequestCount")) <= 0
777
+ ? 0
778
+ : (asInt(reportValue(nodeStats, "allOkCount", "AllOkCount")) * 100 / asInt(reportValue(nodeStats, "allRequestCount", "AllRequestCount")));
779
+ const failRate = asInt(reportValue(nodeStats, "allRequestCount", "AllRequestCount")) <= 0
780
+ ? 0
781
+ : (asInt(reportValue(nodeStats, "allFailCount", "AllFailCount")) * 100 / asInt(reportValue(nodeStats, "allRequestCount", "AllRequestCount")));
782
+ const overallRps = reportDurationSeconds(reportValue(nodeStats, "duration", "Duration", "durationMs", "DurationMs")) <= 0
783
+ ? 0
784
+ : asInt(reportValue(nodeStats, "allRequestCount", "AllRequestCount")) / reportDurationSeconds(reportValue(nodeStats, "duration", "Duration", "durationMs", "DurationMs"));
785
+ const topScenario = scenarios.reduce((winner, scenario) => {
786
+ if (!winner) {
787
+ return scenario;
788
+ }
789
+ return asInt(reportValue(scenario, "allRequestCount", "AllRequestCount")) > asInt(reportValue(winner, "allRequestCount", "AllRequestCount"))
790
+ ? scenario
791
+ : winner;
792
+ }, undefined);
793
+ const latencyRows = scenarios.flatMap((scenario) => [
794
+ buildSummaryLatencyRow(asString(reportValue(scenario, "scenarioName", "ScenarioName")), "OK", reportObject(scenario, "ok", "Ok")),
795
+ buildSummaryLatencyRow(asString(reportValue(scenario, "scenarioName", "ScenarioName")), "FAIL", reportObject(scenario, "fail", "Fail"))
796
+ ]);
797
+ const chartData = buildDotnetChartData(nodeStats);
798
+ const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
799
+ const nodeInfo = reportObject(nodeStats, "nodeInfo", "NodeInfo");
800
+ const parts = [];
801
+ appendReportLine(parts, "<div class=\"card-grid\">");
802
+ appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Total Requests</div><div class="stat-value">${asInt(reportValue(nodeStats, "allRequestCount", "AllRequestCount"))}</div></div>`);
803
+ appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Success</div><div class="stat-value value-ok">${asInt(reportValue(nodeStats, "allOkCount", "AllOkCount"))} (${formatDotnetPercent(successRate)}%)</div></div>`);
804
+ appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Fail</div><div class="stat-value value-fail">${asInt(reportValue(nodeStats, "allFailCount", "AllFailCount"))} (${formatDotnetPercent(failRate)}%)</div></div>`);
805
+ appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Overall RPS</div><div class="stat-value">${formatReportNumber(overallRps)}</div></div>`);
806
+ appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Duration</div><div class="stat-value">${escapeHtml(formatDotnetTimeSpan(reportValue(nodeStats, "duration", "Duration", "durationMs", "DurationMs")))}</div></div>`);
807
+ appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Total Bytes</div><div class="stat-value">${asInt(reportValue(nodeStats, "allBytes", "AllBytes"))}</div></div>`);
808
+ appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Top Scenario</div><div class="stat-value">${escapeHtml(topScenario ? reportValue(topScenario, "scenarioName", "ScenarioName") : "n/a")}</div></div>`);
809
+ appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Node</div><div class="stat-value">${loadStrikeNodeTypeTag(reportValue(nodeInfo, "nodeType", "NodeType"))}</div></div>`);
810
+ appendReportLine(parts, "</div>");
811
+ const charts = [];
812
+ if (hasPieChartData(chartData.overallOutcome)) {
813
+ appendChartCard(charts, "chart-outcome", "Success vs Fail");
814
+ }
815
+ if (hasChartPointData(chartData.scenarioRequests)) {
816
+ appendChartCard(charts, "chart-scenario-requests", "Requests by Scenario");
817
+ }
818
+ if (hasChartPointData(chartData.scenarioP95Latency)) {
819
+ appendChartCard(charts, "chart-scenario-p95", "P95 Latency by Scenario (ms)");
820
+ }
821
+ if (hasChartPointData(chartData.scenarioRps)) {
822
+ appendChartCard(charts, "chart-scenario-rps", "RPS by Scenario");
823
+ }
824
+ if (hasChartPointData(chartData.scenarioFailRate)) {
825
+ appendChartCard(charts, "chart-scenario-fail-rate", "Failure Rate by Scenario (%)");
826
+ }
827
+ if (hasChartPointData(chartData.scenarioBytes)) {
828
+ appendChartCard(charts, "chart-scenario-bytes", "Bytes by Scenario");
829
+ }
830
+ if (hasPieChartData(chartData.statusCodeClasses)) {
831
+ appendChartCard(charts, "chart-status-code-classes", "Status Code Class Mix");
832
+ }
833
+ if (hasLatencyTrendData(chartData.scenarioLatencyTrend)) {
834
+ appendChartCard(charts, "chart-scenario-latency-lines", "Latency Trend by Scenario (ms)");
835
+ }
836
+ if (charts.length > 0) {
837
+ appendReportLine(parts, "<div class=\"card\">");
838
+ appendReportLine(parts, "<h2>Charts</h2>");
839
+ appendReportLine(parts, "<div class=\"chart-grid\">");
840
+ parts.push(...charts);
841
+ appendReportLine(parts, "</div>");
842
+ appendReportLine(parts, "</div>");
843
+ }
844
+ if (latencyRows.length > 0) {
845
+ appendReportLine(parts, "<div class=\"card\">");
846
+ appendReportLine(parts, "<h2>Latency by Scenario (Table)</h2>");
847
+ parts.push(buildDotnetTableHtml(latencyRows, false));
848
+ appendReportLine(parts);
849
+ appendReportLine(parts, "</div>");
850
+ }
851
+ appendReportLine(parts, "<div class=\"card\">");
852
+ appendReportLine(parts, "<h2>Environment</h2>");
853
+ appendReportLine(parts, "<div class=\"table-wrap\"><table><tbody>");
854
+ appendReportLine(parts, `<tr><th>Cluster Id</th><td>${escapeHtml(reportValue(testInfo, "clusterId", "ClusterId"))}</td></tr>`);
855
+ appendReportLine(parts, `<tr><th>Created (UTC)</th><td>${escapeHtml(formatDotnetDateTime(reportValue(testInfo, "createdUtc", "CreatedUtc", "created", "Created")))}</td></tr>`);
856
+ appendReportLine(parts, `<tr><th>Machine</th><td>${escapeHtml(reportValue(nodeInfo, "machineName", "MachineName"))}</td></tr>`);
857
+ appendReportLine(parts, `<tr><th>OS</th><td>${escapeHtml(reportValue(nodeInfo, "os", "OS"))}</td></tr>`);
858
+ appendReportLine(parts, `<tr><th>DotNet</th><td>${escapeHtml(reportValue(nodeInfo, "dotNetVersion", "DotNetVersion"))}</td></tr>`);
859
+ appendReportLine(parts, `<tr><th>Processor</th><td>${escapeHtml(reportValue(nodeInfo, "processor", "Processor"))}</td></tr>`);
860
+ appendReportLine(parts, `<tr><th>Cores</th><td>${asInt(reportValue(nodeInfo, "coresCount", "CoresCount"))}</td></tr>`);
861
+ appendReportLine(parts, `<tr><th>Operation</th><td>${escapeHtml(reportValue(nodeInfo, "currentOperation", "CurrentOperation"))}</td></tr>`);
862
+ appendReportLine(parts, "</tbody></table></div>");
863
+ appendReportLine(parts, "</div>");
864
+ return parts.join("");
865
+ }
866
+ function buildDotnetScenarioHtml(nodeStats) {
867
+ return buildDotnetTableHtml(buildDotnetScenarioRows(nodeStats));
868
+ }
869
+ function buildDotnetStepHtml(nodeStats) {
870
+ return buildDotnetTableHtml(buildDotnetStepRows(nodeStats));
871
+ }
872
+ function buildDotnetScenarioMeasurementHtml(nodeStats) {
873
+ return buildDotnetTableHtml(buildDotnetScenarioMeasurementRows(nodeStats));
874
+ }
875
+ function buildDotnetStepMeasurementHtml(nodeStats) {
876
+ return buildDotnetTableHtml(buildDotnetStepMeasurementRows(nodeStats));
877
+ }
878
+ function buildDotnetStatusCodeHtml(nodeStats) {
879
+ return buildDotnetTableHtml(buildDotnetStatusCodeRows(nodeStats));
880
+ }
881
+ function buildDotnetFailedResponseHtml(nodeStats) {
882
+ const failedStatusRows = buildFailedStatusRows(sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats")));
883
+ const failedEventRows = buildFailedEventRows(reportArray(nodeStats, "pluginsData", "PluginsData"));
884
+ return buildDotnetFailedResponseContent(failedStatusRows, failedEventRows);
885
+ }
886
+ function buildDotnetFailedResponseContent(failedStatusRows, failedEventRows) {
887
+ const parts = [];
888
+ if (failedStatusRows.length > 0) {
889
+ appendReportLine(parts, "<div class=\"card\">");
890
+ appendReportLine(parts, "<h2>Failed Status Codes</h2>");
891
+ parts.push(buildDotnetTableHtml(failedStatusRows, false));
892
+ appendReportLine(parts);
893
+ appendReportLine(parts, "</div>");
894
+ }
895
+ if (failedEventRows.length > 0) {
896
+ appendReportLine(parts, "<div class=\"card\">");
897
+ appendReportLine(parts, "<h2>Failed and Timed Out Rows</h2>");
898
+ parts.push(buildDotnetTableHtml(failedEventRows, false));
899
+ appendReportLine(parts);
900
+ appendReportLine(parts, "</div>");
901
+ }
902
+ return parts.join("");
903
+ }
904
+ function buildDotnetThresholdHtml(nodeStats) {
905
+ return buildDotnetTableHtml(buildDotnetThresholdRows(nodeStats));
906
+ }
907
+ function buildDotnetMetricHtml(nodeStats) {
908
+ return buildDotnetTableHtml(buildDotnetMetricRows(nodeStats));
909
+ }
910
+ function buildDotnetGroupedCorrelationSummaryHtml(rows, groupedChartKey) {
911
+ const parts = [];
912
+ const payloads = buildGroupedCorrelationChartPayloads(rows);
913
+ parts.push(buildDotnetTableHtml(rows));
914
+ if (!payloads.length) {
915
+ return parts.join("");
916
+ }
917
+ appendReportLine(parts);
918
+ appendReportLine(parts, "<div class=\"card\"><h2>Grouped Correlation Percentile Trends</h2><p>Each chart is one GatherBy value. The line shows latency progression across P50 to P99.</p></div>");
919
+ appendReportLine(parts, "<div class=\"chart-grid correlation-chart-grid\">");
920
+ payloads.forEach((payload, index) => {
921
+ const chartKey = `${groupedChartKey}-value-${index}`;
922
+ const chartJson = escapeJsonForHtmlScript(JSON.stringify(payload.chart));
923
+ appendReportLine(parts, `<script type="application/json" data-grouped-correlation-chart="${escapeHtml(chartKey)}">${chartJson}</script>`);
924
+ appendReportLine(parts, `<div class="chart-card correlation-chart-card"><h3>${escapeHtml(payload.title)}</h3><p>${escapeHtml(payload.subtitle)}</p><canvas class="chart-canvas grouped-correlation-canvas" data-grouped-key="${escapeHtml(chartKey)}"></canvas></div>`);
925
+ });
926
+ appendReportLine(parts, "</div>");
927
+ return parts.join("");
928
+ }
929
+ function buildDotnetUngroupedCorrelationSummaryHtml(rows, groupedChartKey) {
930
+ const chart = buildUngroupedCorrelationChartPayload(rows);
931
+ const parts = [];
932
+ parts.push(buildDotnetTableHtml(rows));
933
+ if (hasLatencyTrendData(chart)) {
934
+ const chartJson = escapeJsonForHtmlScript(JSON.stringify(chart));
935
+ appendReportLine(parts);
936
+ appendReportLine(parts, "<div class=\"card\"><h2>Ungrouped Correlation Percentile Trends</h2><p>All percentile lines are shown in one graph for direct comparison.</p></div>");
937
+ appendReportLine(parts, `<script type="application/json" data-ungrouped-correlation="${escapeHtml(groupedChartKey)}">${chartJson}</script>`);
938
+ appendReportLine(parts, "<div class=\"chart-grid correlation-chart-grid\">");
939
+ appendReportLine(parts, `<div class="chart-card correlation-chart-card"><h3>Ungrouped Correlation Percentiles</h3><canvas class="chart-canvas ungrouped-correlation-canvas" data-ungrouped-key="${escapeHtml(groupedChartKey)}"></canvas></div>`);
940
+ appendReportLine(parts, "</div>");
941
+ }
942
+ return parts.join("");
943
+ }
944
+ function buildDotnetPluginHints(plugin) {
945
+ if (!hasNonEmptyHints(plugin)) {
946
+ return "";
947
+ }
948
+ const hints = reportArray(plugin, "hints", "Hints").map((hint) => asString(hint).trim()).filter((hint) => hint.length > 0);
949
+ return `<div class="card"><strong>Hints</strong><ul>${hints.map((hint) => `<li>${escapeHtml(hint)}</li>`).join("")}</ul></div>`;
950
+ }
951
+ function buildDotnetHtmlTabs(nodeStats) {
952
+ const tabs = [["summary", "Summary", buildDotnetSummaryHtml(nodeStats)]];
953
+ const scenarioRows = buildDotnetScenarioRows(nodeStats);
954
+ if (scenarioRows.length) {
955
+ tabs.push(["scenarios", "Scenarios", buildDotnetTableHtml(scenarioRows)]);
956
+ }
957
+ const scenarioMeasurementRows = buildDotnetScenarioMeasurementRows(nodeStats);
958
+ if (scenarioMeasurementRows.length) {
959
+ tabs.push(["scenario-measurements", "Scenario Measurements", buildDotnetTableHtml(scenarioMeasurementRows)]);
960
+ }
961
+ const stepRows = buildDotnetStepRows(nodeStats);
962
+ if (stepRows.length) {
963
+ tabs.push(["steps", "Steps", buildDotnetTableHtml(stepRows)]);
964
+ }
965
+ const stepMeasurementRows = buildDotnetStepMeasurementRows(nodeStats);
966
+ if (stepMeasurementRows.length) {
967
+ tabs.push(["step-measurements", "Step Measurements", buildDotnetTableHtml(stepMeasurementRows)]);
968
+ }
969
+ const statusCodeRows = buildDotnetStatusCodeRows(nodeStats);
970
+ if (statusCodeRows.length) {
971
+ tabs.push(["status-codes", "Status Codes", buildDotnetTableHtml(statusCodeRows)]);
972
+ }
973
+ const failedStatusRows = buildFailedStatusRows(sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats")));
974
+ const failedEventRows = buildFailedEventRows(reportArray(nodeStats, "pluginsData", "PluginsData"));
975
+ if (failedStatusRows.length || failedEventRows.length) {
976
+ tabs.push(["failed-responses", "Failed Responses", buildDotnetFailedResponseContent(failedStatusRows, failedEventRows)]);
977
+ }
978
+ const thresholdRows = buildDotnetThresholdRows(nodeStats);
979
+ if (thresholdRows.length) {
980
+ tabs.push(["thresholds", "Thresholds", buildDotnetTableHtml(thresholdRows)]);
981
+ }
982
+ const metricRows = buildDotnetMetricRows(nodeStats);
983
+ if (metricRows.length) {
984
+ tabs.push(["metrics", "Metrics", buildDotnetTableHtml(metricRows)]);
985
+ }
986
+ for (const plugin of reportArray(nodeStats, "pluginsData", "PluginsData")) {
987
+ const pluginName = asString(reportValue(plugin, "pluginName", "PluginName"));
988
+ const hints = buildDotnetPluginHints(plugin);
989
+ const lowerPlugin = pluginName.toLowerCase();
990
+ const skipMergedPluginTab = lowerPlugin.includes("failed") || lowerPlugin.includes("correlation");
991
+ const tables = reportArray(plugin, "tables", "Tables");
992
+ if (!tables.length) {
993
+ if (skipMergedPluginTab) {
994
+ continue;
995
+ }
996
+ if (!hints) {
997
+ continue;
998
+ }
999
+ tabs.push([`plugin-${tabs.length}`, pluginName, hints]);
1000
+ continue;
1001
+ }
1002
+ for (const table of tables) {
1003
+ const tableName = asString(reportValue(table, "tableName", "TableName"));
1004
+ const lowerTable = tableName.toLowerCase();
1005
+ const bodyRows = reportArray(table, "rows", "Rows").map((row) => ({ ...row }));
1006
+ if (lowerPlugin.includes("failed") || lowerTable.includes("failed")) {
1007
+ continue;
1008
+ }
1009
+ if (!bodyRows.length) {
1010
+ continue;
1011
+ }
1012
+ let title = `${pluginName}: ${tableName}`;
1013
+ let body = buildDotnetTableHtml(bodyRows);
1014
+ if (lowerPlugin.includes("correlation") && lowerTable.includes("grouped correlation summary")) {
1015
+ title = "Grouped Correlation Summary";
1016
+ body = buildDotnetGroupedCorrelationSummaryHtml(bodyRows, `grouped-correlation-${tabs.length}`);
1017
+ }
1018
+ else if (lowerPlugin.includes("correlation") && lowerTable.includes("ungrouped correlation rows")) {
1019
+ title = "Ungrouped Corelation Summary";
1020
+ body = buildDotnetUngroupedCorrelationSummaryHtml(bodyRows, `ungrouped-correlation-${tabs.length}`);
1021
+ }
1022
+ tabs.push([`plugin-${tabs.length}`, title, `${hints}${body}`]);
1023
+ }
1024
+ }
1025
+ return tabs;
1026
+ }
1027
+ function buildDotnetTxtReport(nodeStats) {
1028
+ const scenarios = sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats"));
1029
+ const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
1030
+ const nodeInfo = reportObject(nodeStats, "nodeInfo", "NodeInfo");
1031
+ const lines = [
1032
+ `TestSuite: ${asString(reportValue(testInfo, "testSuite", "TestSuite"))}`,
1033
+ `TestName: ${asString(reportValue(testInfo, "testName", "TestName"))}`,
1034
+ `SessionId: ${asString(reportValue(testInfo, "sessionId", "SessionId"))}`,
1035
+ `NodeType: ${loadStrikeNodeTypeTag(reportValue(nodeInfo, "nodeType", "NodeType"))}`,
1036
+ `Duration: ${formatDotnetTimeSpan(reportValue(nodeStats, "duration", "Duration", "durationMs", "DurationMs"))}`,
1037
+ `Requests: ${asInt(reportValue(nodeStats, "allRequestCount", "AllRequestCount"))} OK: ${asInt(reportValue(nodeStats, "allOkCount", "AllOkCount"))} FAIL: ${asInt(reportValue(nodeStats, "allFailCount", "AllFailCount"))}`,
1038
+ "",
1039
+ "Scenarios:"
1040
+ ];
1041
+ for (const scenario of scenarios) {
1042
+ lines.push(`- ${asString(reportValue(scenario, "scenarioName", "ScenarioName"))}: req=${asInt(reportValue(scenario, "allRequestCount", "AllRequestCount"))} ok=${asInt(reportValue(scenario, "allOkCount", "AllOkCount"))} fail=${asInt(reportValue(scenario, "allFailCount", "AllFailCount"))} duration=${formatDotnetTimeSpan(reportValue(scenario, "duration", "Duration", "durationMs", "DurationMs"))}`);
1043
+ }
1044
+ if (scenarios.some((scenario) => reportArray(scenario, "stepStats", "StepStats").length > 0)) {
1045
+ lines.push("", "Steps:");
1046
+ for (const scenario of scenarios) {
1047
+ for (const step of sortBySortIndex(reportArray(scenario, "stepStats", "StepStats"))) {
1048
+ const ok = asInt(reportValue(reportObject(reportObject(step, "ok", "Ok"), "request", "Request"), "count", "Count"));
1049
+ const fail = asInt(reportValue(reportObject(reportObject(step, "fail", "Fail"), "request", "Request"), "count", "Count"));
1050
+ lines.push(`- ${asString(reportValue(scenario, "scenarioName", "ScenarioName"))}.${asString(reportValue(step, "stepName", "StepName"))}: req=${ok + fail} ok=${ok} fail=${fail}`);
1051
+ }
1052
+ }
1053
+ }
1054
+ const thresholds = reportArray(nodeStats, "thresholds", "Thresholds");
1055
+ if (thresholds.length) {
1056
+ lines.push("", "Thresholds:");
1057
+ for (const threshold of thresholds) {
1058
+ lines.push(`- scenario=${asString(reportValue(threshold, "scenarioName", "ScenarioName"))} step=${asString(reportValue(threshold, "stepName", "StepName"))} check=${asString(reportValue(threshold, "checkExpression", "CheckExpression"))} failed=${formatCellValue(Boolean(reportValue(threshold, "isFailed", "IsFailed")))} errors=${asInt(reportValue(threshold, "errorCount", "ErrorCount"))} message=${asString(reportValue(threshold, "exceptionMessage", "ExceptionMessage", "exceptionMsg", "ExceptionMsg"))}`);
1059
+ }
1060
+ }
1061
+ const plugins = reportArray(nodeStats, "pluginsData", "PluginsData");
1062
+ if (plugins.length) {
1063
+ lines.push("", "Plugin Tables:");
1064
+ for (const plugin of plugins) {
1065
+ lines.push(`- ${asString(reportValue(plugin, "pluginName", "PluginName"))}`);
1066
+ for (const table of reportArray(plugin, "tables", "Tables")) {
1067
+ lines.push(` Table: ${asString(reportValue(table, "tableName", "TableName"))} rows=${reportArray(table, "rows", "Rows").length}`);
1068
+ }
1069
+ }
1070
+ }
1071
+ return reportLines(lines);
1072
+ }
1073
+ function buildDotnetCsvReport(nodeStats) {
1074
+ const lines = ["ScenarioName,Requests,Ok,Fail,DurationSeconds,Rps"];
1075
+ for (const scenario of sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats"))) {
1076
+ const durationSeconds = reportDurationSeconds(reportValue(scenario, "duration", "Duration", "durationMs", "DurationMs"));
1077
+ const requests = asInt(reportValue(scenario, "allRequestCount", "AllRequestCount"));
1078
+ lines.push(`${escapeCsv(reportValue(scenario, "scenarioName", "ScenarioName"))},${requests},${asInt(reportValue(scenario, "allOkCount", "AllOkCount"))},${asInt(reportValue(scenario, "allFailCount", "AllFailCount"))},${formatReportNumber(durationSeconds)},${formatReportNumber(durationSeconds <= 0 ? 0 : requests / durationSeconds)}`);
1079
+ }
1080
+ return reportLines(lines);
1081
+ }
1082
+ function buildDotnetMarkdownReport(nodeStats) {
1083
+ const scenarios = sortBySortIndex(reportArray(nodeStats, "scenarioStats", "ScenarioStats"));
1084
+ const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
1085
+ const lines = [
1086
+ `# ${asString(reportValue(testInfo, "testSuite", "TestSuite"))} / ${asString(reportValue(testInfo, "testName", "TestName"))}`,
1087
+ "",
1088
+ `- Session: \`${asString(reportValue(testInfo, "sessionId", "SessionId"))}\``,
1089
+ `- Duration: \`${formatDotnetTimeSpan(reportValue(nodeStats, "duration", "Duration", "durationMs", "DurationMs"))}\``,
1090
+ `- Total Requests: \`${asInt(reportValue(nodeStats, "allRequestCount", "AllRequestCount"))}\``,
1091
+ `- OK: \`${asInt(reportValue(nodeStats, "allOkCount", "AllOkCount"))}\``,
1092
+ `- FAIL: \`${asInt(reportValue(nodeStats, "allFailCount", "AllFailCount"))}\``,
1093
+ "",
1094
+ "## Scenarios",
1095
+ "",
1096
+ "| Scenario | Requests | OK | FAIL | Duration |",
1097
+ "|---|---:|---:|---:|---:|"
1098
+ ];
1099
+ for (const scenario of scenarios) {
1100
+ lines.push(`| ${asString(reportValue(scenario, "scenarioName", "ScenarioName"))} | ${asInt(reportValue(scenario, "allRequestCount", "AllRequestCount"))} | ${asInt(reportValue(scenario, "allOkCount", "AllOkCount"))} | ${asInt(reportValue(scenario, "allFailCount", "AllFailCount"))} | ${formatReportNumber(reportDurationSeconds(reportValue(scenario, "duration", "Duration", "durationMs", "DurationMs")))}s |`);
1101
+ }
1102
+ if (scenarios.some((scenario) => reportArray(scenario, "stepStats", "StepStats").length > 0)) {
1103
+ lines.push("", "## Steps", "", "| Scenario | Step | Requests | OK | FAIL |", "|---|---|---:|---:|---:|");
1104
+ for (const scenario of scenarios) {
1105
+ for (const step of sortBySortIndex(reportArray(scenario, "stepStats", "StepStats"))) {
1106
+ const ok = asInt(reportValue(reportObject(reportObject(step, "ok", "Ok"), "request", "Request"), "count", "Count"));
1107
+ const fail = asInt(reportValue(reportObject(reportObject(step, "fail", "Fail"), "request", "Request"), "count", "Count"));
1108
+ lines.push(`| ${asString(reportValue(scenario, "scenarioName", "ScenarioName"))} | ${asString(reportValue(step, "stepName", "StepName"))} | ${ok + fail} | ${ok} | ${fail} |`);
1109
+ }
1110
+ }
1111
+ }
1112
+ const thresholds = reportArray(nodeStats, "thresholds", "Thresholds");
1113
+ if (thresholds.length) {
1114
+ lines.push("", "## Thresholds", "", "| Scenario | Step | Check | Failed | Errors | Exception |", "|---|---|---|---:|---:|---|");
1115
+ for (const threshold of thresholds) {
1116
+ lines.push(`| ${asString(reportValue(threshold, "scenarioName", "ScenarioName"))} | ${asString(reportValue(threshold, "stepName", "StepName"))} | ${asString(reportValue(threshold, "checkExpression", "CheckExpression"))} | ${formatCellValue(Boolean(reportValue(threshold, "isFailed", "IsFailed")))} | ${asInt(reportValue(threshold, "errorCount", "ErrorCount"))} | ${asString(reportValue(threshold, "exceptionMessage", "ExceptionMessage", "exceptionMsg", "ExceptionMsg"))} |`);
1117
+ }
1118
+ }
1119
+ return reportLines(lines);
1120
+ }
1121
+ function buildDotnetHtmlReport(nodeStats) {
1122
+ const tabs = buildDotnetHtmlTabs(nodeStats);
1123
+ const buttonsHtml = tabs.map(([tabId, title]) => `<button class="tab-btn" data-tab="${tabId}">${escapeHtml(title)}</button>${REPORT_EOL}`).join("");
1124
+ const sectionsHtml = tabs.map(([tabId, , html]) => `<section id="${tabId}" class="tab">${html}</section>${REPORT_EOL}`).join("");
1125
+ const chartDataJson = JSON.stringify(buildDotnetChartData(nodeStats));
1126
+ const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
1127
+ const template = `<!doctype html>
1128
+ <html lang="en">
1129
+ <head>
1130
+ <meta charset="utf-8" />
1131
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1132
+ <title>LoadStrike Report</title>
1133
+ <style>
1134
+ :root{--bg:#f4f7fc;--bgTop:#fff3d0;--bgRight:#dbe8ff;--bgBottom:#edf2fb;--panel:#ffffff;--panelAlt:#eef3fb;--line:#d3deed;--text:#0f172a;--muted:#4b5563;--accent:#2563eb;--ok:#138a4a;--fail:#c63636;--warn:#b7791f;--chip:#edf3fc;--card:#ffffff;--stat:#f5f9ff;--chartPanel:#0f172a;--chartGrid:#334155;--chartAxis:#b5c2d3;--chartLabel:#dbe6f4;--chartDot:#0f172a;--chartPieCenter:#0f172a;--chartPieCenterText:#e5eefc;--chartLegendText:#e6edf3;--shadow:0 10px 24px rgba(15,23,42,.1)}
1135
+ body[data-theme='dark']{--bg:#0d1117;--bgTop:#18243b;--bgRight:#102235;--bgBottom:#0b0f14;--panel:#111827;--panelAlt:#0f172a;--line:#263241;--text:#e6edf3;--muted:#9fb0c3;--accent:#2563eb;--ok:#18a957;--fail:#d14343;--warn:#f59e0b;--chip:rgba(31,41,55,.45);--card:linear-gradient(180deg,rgba(17,24,39,.92) 0,rgba(13,19,32,.92) 100%);--stat:rgba(20,30,47,.65);--chartPanel:rgba(15,23,42,.72);--chartGrid:#334155;--chartAxis:#b5c2d3;--chartLabel:#dbe6f4;--chartDot:#0f172a;--chartPieCenter:#0f172a;--chartPieCenterText:#e5eefc;--chartLegendText:#e6edf3;--shadow:0 10px 24px rgba(0,0,0,.2)}
1136
+ body{margin:0;background:radial-gradient(1200px 700px at 10% -10%,var(--bgTop) 0,transparent 58%),radial-gradient(900px 620px at 100% 0,var(--bgRight) 0,transparent 57%),var(--bgBottom);color:var(--text);font-family:'Segoe UI',Tahoma,sans-serif}
1137
+ .wrap{max-width:1440px;margin:0 auto;padding:20px}
1138
+ .report-brand{display:flex;align-items:center;gap:16px;margin:0 0 10px 0;overflow:visible;padding-top:8px;flex-wrap:wrap}
1139
+ .report-logo-slot{width:380px;height:184px;max-width:100%;flex:0 0 auto;display:flex;align-items:flex-start;justify-content:flex-start;overflow:visible}
1140
+ .report-logo{width:100%;height:100%;object-fit:contain;object-position:left top;border:none;background:transparent;box-shadow:none;border-radius:0;padding:0;margin:0;overflow:visible;transform:scale(var(--report-logo-scale,1));transform-origin:left top;transition:transform .2s ease}
1141
+ body[data-theme='dark'] .report-logo{--report-logo-scale:1.175}
1142
+ .theme-toggle-report{position:fixed;top:12px;right:12px;z-index:1200;display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;appearance:none;border:1px solid var(--line);background:var(--chip);color:var(--text);padding:0;border-radius:999px;font-size:18px;font-weight:700;cursor:pointer;transition:all .2s ease}
1143
+ .theme-toggle-report:hover{border-color:var(--accent);transform:translateY(-1px)}
1144
+ .theme-toggle-report span{line-height:1;pointer-events:none}
1145
+ h1{margin:0 0 10px 0;font-size:28px;letter-spacing:.2px}
1146
+ h2{margin:0 0 12px 0;font-size:18px}
1147
+ h3{margin:0 0 10px 0;font-size:15px;color:var(--text)}
1148
+ .meta{display:flex;gap:14px;flex-wrap:wrap;color:var(--muted);font-size:13px;margin-bottom:16px}
1149
+ .meta span{background:var(--chip);border:1px solid var(--line);padding:6px 10px;border-radius:999px}
1150
+ .report-layout{display:grid;grid-template-columns:280px minmax(0,1fr);gap:14px;align-items:start}
1151
+ .tabs-pane{position:sticky;top:12px;max-height:calc(100vh - 24px);overflow:auto;overscroll-behavior:contain;padding-right:4px;cursor:grab}
1152
+ .tabs-pane.panning{cursor:grabbing;user-select:none}
1153
+ .tabs{display:flex;flex-direction:column;gap:8px;margin:12px 0 14px 0}
1154
+ .tab-btn{background:var(--panelAlt);color:var(--text);border:1px solid var(--line);padding:8px 12px;border-radius:8px;cursor:pointer;transition:all .2s ease;text-align:left;width:100%}
1155
+ .tab-btn:hover{border-color:var(--accent);background:var(--chip)}
1156
+ .tab-btn.active{background:linear-gradient(180deg,#2f66db 0,#2754b8 100%);border-color:#3f73e0}
1157
+ .tabs-content{min-width:0}
1158
+ .tab{display:none}
1159
+ .tab.active{display:block}
1160
+ table{width:100%;border-collapse:collapse;font-size:12px}
1161
+ th,td{border:1px solid var(--line);padding:7px 8px;text-align:left;vertical-align:top}
1162
+ th{background:var(--panelAlt);position:sticky;top:0;z-index:1}
1163
+ .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px;box-shadow:var(--shadow)}
1164
+ .card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-bottom:14px}
1165
+ .stat-card{padding:10px 12px;border-radius:10px;border:1px solid var(--line);background:var(--stat)}
1166
+ .stat-label{font-size:12px;color:var(--muted)}
1167
+ .stat-value{font-size:22px;font-weight:700;margin-top:4px}
1168
+ .value-ok{color:var(--ok)}
1169
+ .value-fail{color:var(--fail)}
1170
+ .chart-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:12px}
1171
+ .chart-card{padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--chartPanel)}
1172
+ .chart-canvas{width:100%;height:260px;display:block}
1173
+ .correlation-chart-grid{grid-template-columns:repeat(auto-fit,minmax(420px,720px));justify-content:center}
1174
+ .correlation-chart-card{max-width:720px;width:100%}
1175
+ .correlation-chart-card .chart-canvas{height:320px}
1176
+ .chart-card h3,.chart-card p{color:var(--chartLegendText)}
1177
+ .table-wrap{overflow:auto;max-height:70vh}
1178
+ @media (max-width:980px){.wrap{padding:14px}.report-brand{margin-bottom:8px;padding-top:4px}.report-logo-slot{width:300px;height:146px}.theme-toggle-report{top:10px;right:10px}.report-layout{grid-template-columns:1fr}.tabs-pane{position:static;max-height:none;cursor:auto}.tabs{flex-direction:row;flex-wrap:wrap}.tab-btn{width:auto}.chart-canvas{height:220px}.correlation-chart-grid{grid-template-columns:1fr;justify-content:stretch}.correlation-chart-card{max-width:none}.correlation-chart-card .chart-canvas{height:280px}.stat-value{font-size:18px}}
1179
+ </style>
1180
+ </head>
1181
+ <body data-theme="light">
1182
+ <div class="wrap">
1183
+ <button type="button" class="theme-toggle-report" data-report-theme-toggle aria-label="Switch to dark theme" aria-pressed="false" title="Switch to dark theme"><span aria-hidden="true">&#x263E;</span></button>
1184
+ <div class="report-brand">
1185
+ <div class="report-logo-slot">
1186
+ <img src="__LOGO_LIGHT__" alt="LoadStrike logo" class="report-logo" data-report-logo data-logo-light="__LOGO_LIGHT__" data-logo-dark="__LOGO_DARK__" />
1187
+ </div>
1188
+ <h1>LoadStrike Report</h1>
1189
+ </div>
1190
+ <div class="meta">
1191
+ <span>Suite: <strong>__TEST_SUITE__</strong></span>
1192
+ <span>Name: <strong>__TEST_NAME__</strong></span>
1193
+ <span>Session: <strong>__SESSION_ID__</strong></span>
1194
+ <span>Duration: <strong>__DURATION__</strong></span>
1195
+ </div>
1196
+ <div class="report-layout">
1197
+ <aside class="tabs-pane" id="tab-pane">
1198
+ <div class="tabs">
1199
+ __BUTTONS__</div>
1200
+ </aside>
1201
+ <div class="tabs-content">
1202
+ __SECTIONS__</div>
1203
+ </div>
1204
+ </div>
1205
+ <script>
1206
+ const reportCharts=__CHART_DATA__;
1207
+ const btns=[...document.querySelectorAll('.tab-btn')];
1208
+ const tabSections=[...document.querySelectorAll('.tab')];
1209
+ const tabsPane=document.getElementById('tab-pane');
1210
+ const linePalette=['#38bdf8','#22c55e','#f59e0b','#a855f7','#f43f5e','#14b8a6','#eab308','#818cf8','#06b6d4','#84cc16'];
1211
+ const reportThemeKey='loadstrike-report-theme';
1212
+ const reportThemeToggle=document.querySelector('[data-report-theme-toggle]');
1213
+ const reportLogo=document.querySelector('[data-report-logo]');
1214
+ function applyReportTheme(theme){const normalized=theme==='dark'?'dark':'light';document.body.setAttribute('data-theme',normalized);if(reportLogo){const lightLogo=reportLogo.dataset.logoLight||reportLogo.getAttribute('src');const darkLogo=reportLogo.dataset.logoDark||reportLogo.getAttribute('src');reportLogo.setAttribute('src',normalized==='dark'?darkLogo:lightLogo);}if(reportThemeToggle){const darkActive=normalized==='dark';const nextLabel=darkActive?'light':'dark';reportThemeToggle.innerHTML=darkActive?'&#x2600;':'&#x263E;';reportThemeToggle.setAttribute('aria-pressed',darkActive?'true':'false');reportThemeToggle.setAttribute('aria-label','Switch to '+nextLabel+' theme');reportThemeToggle.setAttribute('title','Switch to '+nextLabel+' theme');}}
1215
+ function show(id){btns.forEach(b=>b.classList.toggle('active',b.dataset.tab===id));tabSections.forEach(t=>t.classList.toggle('active',t.id===id));}
1216
+ function formatMetric(v){if(!Number.isFinite(v))return '0';return Math.abs(v)>=100?v.toFixed(0):v.toFixed(2);}
1217
+ function setupCanvas(canvas){const dpr=window.devicePixelRatio||1;const w=Math.max(320,canvas.clientWidth||320);const h=Math.max(220,canvas.clientHeight||220);canvas.width=Math.floor(w*dpr);canvas.height=Math.floor(h*dpr);const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);return {ctx,w,h};}
1218
+ function drawNoData(ctx,w,h,msg){ctx.fillStyle='#9fb0c3';ctx.font='13px Segoe UI';ctx.textAlign='center';ctx.fillText(msg,w/2,h/2);}
1219
+ function drawBar(canvasId,points){const canvas=document.getElementById(canvasId);if(!canvas)return;const c=setupCanvas(canvas);const ctx=c.ctx,w=c.w,h=c.h;ctx.clearRect(0,0,w,h);if(!points||points.length===0){drawNoData(ctx,w,h,'No data');return;}const left=46,right=14,top=16,bottom=62;const pw=w-left-right;const ph=h-top-bottom;const max=Math.max(...points.map(p=>p.value),1);ctx.strokeStyle='#334155';ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=top+(ph*(i/4));ctx.beginPath();ctx.moveTo(left,y);ctx.lineTo(w-right,y);ctx.stroke();}const slot=pw/points.length;const bar=Math.max(8,slot*0.58);ctx.font='11px Segoe UI';for(let i=0;i<points.length;i++){const p=points[i];const x=left+i*slot+(slot-bar)/2;const bh=(p.value/max)*ph;const y=top+ph-bh;ctx.fillStyle=p.color||'#3b82f6';ctx.fillRect(x,y,bar,bh);ctx.fillStyle='#dbe6f4';ctx.textAlign='center';ctx.fillText(formatMetric(p.value),x+bar/2,Math.max(12,y-4));ctx.save();ctx.translate(x+bar/2,h-bottom+14);ctx.rotate(-0.6);ctx.fillStyle='#b5c2d3';ctx.fillText((p.label||'').slice(0,26),0,0);ctx.restore();}ctx.fillStyle='#b5c2d3';ctx.textAlign='right';for(let i=0;i<=4;i++){const value=max*(1-i/4);const y=top+(ph*(i/4))+4;ctx.fillText(formatMetric(value),left-6,y);}}
1220
+ function drawPie(canvasId,points){const canvas=document.getElementById(canvasId);if(!canvas)return;const c=setupCanvas(canvas);const ctx=c.ctx,w=c.w,h=c.h;ctx.clearRect(0,0,w,h);if(!points||points.length===0){drawNoData(ctx,w,h,'No data');return;}const total=points.reduce((s,p)=>s+(p.value||0),0);if(total<=0){drawNoData(ctx,w,h,'No data');return;}const cx=w*0.35,cy=h*0.5,r=Math.min(w,h)*0.28;let angle=-Math.PI/2;for(const p of points){const val=Math.max(0,p.value||0);const delta=(val/total)*Math.PI*2;ctx.beginPath();ctx.moveTo(cx,cy);ctx.arc(cx,cy,r,angle,angle+delta);ctx.closePath();ctx.fillStyle=p.color||'#3b82f6';ctx.fill();angle+=delta;}ctx.fillStyle='#0f172a';ctx.beginPath();ctx.arc(cx,cy,r*0.54,0,Math.PI*2);ctx.fill();ctx.fillStyle='#e5eefc';ctx.font='bold 18px Segoe UI';ctx.textAlign='center';ctx.fillText(total.toString(),cx,cy+6);ctx.font='12px Segoe UI';ctx.fillStyle='#9fb0c3';ctx.fillText('requests',cx,cy+24);ctx.textAlign='left';let y=cy-r+10;for(const p of points){ctx.fillStyle=p.color||'#3b82f6';ctx.fillRect(w*0.64,y-10,12,12);ctx.fillStyle='#e6edf3';ctx.font='12px Segoe UI';const pct=total<=0?0:((p.value/total)*100);ctx.fillText(\`\${p.label}: \${p.value} (\${pct.toFixed(1)}%)\`,w*0.64+18,y);y+=20;}}
1221
+ function drawLatencyLine(canvasRef,chart){const canvas=typeof canvasRef==='string'?document.getElementById(canvasRef):canvasRef;if(!canvas)return;const c=setupCanvas(canvas);const ctx=c.ctx,w=c.w,h=c.h;ctx.clearRect(0,0,w,h);if(!chart||!Array.isArray(chart.labels)||chart.labels.length===0||!Array.isArray(chart.series)||chart.series.length===0){drawNoData(ctx,w,h,'No latency data');return;}const labels=chart.labels;const seriesList=chart.series;const allValues=seriesList.flatMap(s=>(s.values||[]).filter(v=>Number.isFinite(v)));if(allValues.length===0){drawNoData(ctx,w,h,'No latency data');return;}const shortAxisLabels=labels.every(label=>((label||'').toString().length<=4));const rotateAxisLabels=!shortAxisLabels&&labels.length>4;const left=52,right=18,bottom=rotateAxisLabels?78:52;const legendItemWidth=150,legendLineHeight=14,legendTop=12;const plotWidth=Math.max(120,w-left-right);const legendCols=Math.max(1,Math.floor(plotWidth/legendItemWidth));const legendRows=Math.max(1,Math.ceil(seriesList.length/legendCols));const legendHeight=legendRows*legendLineHeight;const top=legendTop+legendHeight+16;const pw=w-left-right;const ph=h-top-bottom;if(ph<=20){drawNoData(ctx,w,h,'No latency data');return;}const max=Math.max(...allValues,1);const scaleMax=max*1.08;ctx.strokeStyle='#334155';ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=top+(ph*(i/4));ctx.beginPath();ctx.moveTo(left,y);ctx.lineTo(w-right,y);ctx.stroke();}ctx.fillStyle='#b5c2d3';ctx.font='11px Segoe UI';ctx.textAlign='right';for(let i=0;i<=4;i++){const value=max*(1-i/4);const y=top+(ph*(i/4))+4;ctx.fillText(formatMetric(value),left-6,y);}const xStep=labels.length<=1?0:pw/(labels.length-1);const xAt=i=>labels.length<=1?left+(pw/2):left+(xStep*i);const overlapOffset=new Map();const epsilon=Math.max(max*0.0005,0.001);for(let pointIndex=0;pointIndex<labels.length;pointIndex++){const points=[];for(let seriesIndex=0;seriesIndex<seriesList.length;seriesIndex++){const value=(seriesList[seriesIndex].values||[])[pointIndex];if(Number.isFinite(value)){points.push({seriesIndex,value});}}points.sort((a,b)=>a.value===b.value?a.seriesIndex-b.seriesIndex:a.value-b.value);let start=0;while(start<points.length){let end=start+1;while(end<points.length&&Math.abs(points[end].value-points[start].value)<=epsilon){end++;}const count=end-start;if(count>1){const mid=(count-1)/2;for(let k=0;k<count;k++){overlapOffset.set(points[start+k].seriesIndex+'|'+pointIndex,(k-mid)*3);}}start=end;}}const dashPatterns=[[0,0],[7,4],[2,3],[10,3,2,3]];for(let seriesIndex=0;seriesIndex<seriesList.length;seriesIndex++){const series=seriesList[seriesIndex];ctx.beginPath();ctx.strokeStyle=series.color||'#38bdf8';ctx.lineWidth=2.2;const dash=dashPatterns[seriesIndex%dashPatterns.length];if(dash[0]===0){ctx.setLineDash([]);}else{ctx.setLineDash(dash);}let started=false;(series.values||[]).forEach((value,index)=>{if(!Number.isFinite(value)){started=false;return;}const x=xAt(index);const offset=overlapOffset.get(seriesIndex+'|'+index)||0;const y=top+ph-((value/scaleMax)*ph)+offset;if(!started){ctx.moveTo(x,y);started=true;}else{ctx.lineTo(x,y);}});ctx.stroke();ctx.setLineDash([]);(series.values||[]).forEach((value,index)=>{if(!Number.isFinite(value))return;const x=xAt(index);const offset=overlapOffset.get(seriesIndex+'|'+index)||0;const y=top+ph-((value/scaleMax)*ph)+offset;ctx.beginPath();ctx.fillStyle='#0f172a';ctx.arc(x,y,3.2,0,Math.PI*2);ctx.fill();ctx.beginPath();ctx.strokeStyle=series.color||'#38bdf8';ctx.lineWidth=2;ctx.arc(x,y,3.2,0,Math.PI*2);ctx.stroke();});}ctx.fillStyle='#b5c2d3';ctx.font='10px Segoe UI';ctx.textAlign='center';labels.forEach((label,index)=>{const x=xAt(index);const text=(label||'').toString().slice(0,34);if(rotateAxisLabels){ctx.save();ctx.translate(x,h-bottom+12);ctx.rotate(-0.6);ctx.fillText(text,0,0);ctx.restore();}else{ctx.fillText(text,x,h-bottom+16);}});let lx=left,ly=legendTop+2;for(let seriesIndex=0;seriesIndex<seriesList.length;seriesIndex++){const series=seriesList[seriesIndex];const rawName=(series.name||'Series').toString();const legendName=rawName.length>30?rawName.slice(0,27)+'...':rawName;ctx.strokeStyle=series.color||'#38bdf8';ctx.lineWidth=2.2;const dash=dashPatterns[seriesIndex%dashPatterns.length];if(dash[0]===0){ctx.setLineDash([]);}else{ctx.setLineDash(dash);}ctx.beginPath();ctx.moveTo(lx,ly+1.5);ctx.lineTo(lx+12,ly+1.5);ctx.stroke();ctx.setLineDash([]);ctx.fillStyle='#b5c2d3';ctx.font='11px Segoe UI';ctx.textAlign='left';ctx.fillText(legendName,lx+16,ly+4);lx+=legendItemWidth;if(lx>w-right-legendItemWidth){lx=left;ly+=legendLineHeight;}}}
1222
+ function renderCharts(){drawPie('chart-outcome',reportCharts.overallOutcome);drawBar('chart-scenario-requests',reportCharts.scenarioRequests);drawBar('chart-scenario-p95',reportCharts.scenarioP95Latency);drawBar('chart-scenario-rps',reportCharts.scenarioRps);drawBar('chart-scenario-fail-rate',reportCharts.scenarioFailRate);drawBar('chart-scenario-bytes',reportCharts.scenarioBytes);drawPie('chart-status-code-classes',reportCharts.statusCodeClasses);drawLatencyLine('chart-scenario-latency-lines',reportCharts.scenarioLatencyTrend);}
1223
+ function getGroupedCorrelationChart(key){const node=document.querySelector('script[type="application/json"][data-grouped-correlation-chart="'+key+'"]');if(!node)return {labels:[],series:[]};try{return JSON.parse(node.textContent||'{"labels":[],"series":[]}');}catch{return {labels:[],series:[]};}}
1224
+ function getUngroupedCorrelationChart(key){const node=document.querySelector('script[type="application/json"][data-ungrouped-correlation="'+key+'"]');if(!node)return {labels:[],series:[]};try{return JSON.parse(node.textContent||'{"labels":[],"series":[]}');}catch{return {labels:[],series:[]};}}
1225
+ function normalizeUngroupedCorrelationChart(chart){if(!chart||!Array.isArray(chart.labels)||!Array.isArray(chart.series))return {labels:[],series:[]};const compactName=input=>{const raw=(input||'Series').toString();const parts=raw.split('|').map(x=>x.trim()).filter(x=>x.length>0);const tail=parts.length>0?parts[parts.length-1]:raw.trim();if(tail.length===0)return 'Series';return tail.length>22?tail.slice(0,19)+'...':tail;};const labels=chart.labels||[];const series=chart.series||[];const normalizedSeries=series.map((item,index)=>({name:compactName(item&&item.name),color:(item&&item.color)||linePalette[index%linePalette.length],values:Array.isArray(item&&item.values)?item.values.map(v=>Number.isFinite(v)?v:NaN):[]})).filter(s=>s.values.some(v=>Number.isFinite(v)));const isPercentile=value=>/^p\\d+$/i.test((value||'').toString().trim());const labelsArePercentiles=labels.length>0&&labels.every(isPercentile);if(labelsArePercentiles){return {labels,series:normalizedSeries};}const seriesArePercentiles=normalizedSeries.length>0&&normalizedSeries.every(s=>isPercentile(s.name));if(!seriesArePercentiles||labels.length===0){return {labels,series:normalizedSeries};}const percentileLabels=normalizedSeries.map(s=>s.name.toUpperCase());const reshaped=labels.map((label,labelIndex)=>{const values=normalizedSeries.map(s=>{const value=s.values[labelIndex];return Number.isFinite(value)?value:NaN;});return {name:compactName(label||('Series '+(labelIndex+1))),color:linePalette[labelIndex%linePalette.length],values};}).filter(s=>s.values.some(v=>Number.isFinite(v)));if(reshaped.length===0){return {labels:percentileLabels,series:[]};}return {labels:percentileLabels,series:reshaped};}
1226
+ function renderGroupedCorrelationCharts(){const canvases=[...document.querySelectorAll('.grouped-correlation-canvas')];if(canvases.length===0)return;canvases.forEach(canvas=>{const key=canvas.dataset.groupedKey||'';const chart=getGroupedCorrelationChart(key);drawLatencyLine(canvas,chart);});}
1227
+ function renderUngroupedCorrelationCharts(){const canvases=[...document.querySelectorAll('.ungrouped-correlation-canvas')];if(canvases.length===0)return;canvases.forEach(canvas=>{const key=canvas.dataset.ungroupedKey||'';const rawChart=getUngroupedCorrelationChart(key);const chart=normalizeUngroupedCorrelationChart(rawChart);drawLatencyLine(canvas,chart);});}
1228
+ function initPanePan(){if(!tabsPane)return;let activePointerId=null;let startY=0;let startScroll=0;tabsPane.addEventListener('pointerdown',event=>{if(event.button!==0)return;if(event.target&&event.target.closest&&event.target.closest('button,a,input,textarea,select,label'))return;activePointerId=event.pointerId;startY=event.clientY;startScroll=tabsPane.scrollTop;tabsPane.classList.add('panning');tabsPane.setPointerCapture(event.pointerId);});tabsPane.addEventListener('pointermove',event=>{if(activePointerId!==event.pointerId)return;const delta=event.clientY-startY;tabsPane.scrollTop=startScroll-delta;});const stopPan=event=>{if(activePointerId!==event.pointerId)return;activePointerId=null;tabsPane.classList.remove('panning');};tabsPane.addEventListener('pointerup',stopPan);tabsPane.addEventListener('pointercancel',stopPan);tabsPane.addEventListener('lostpointercapture',()=>{activePointerId=null;tabsPane.classList.remove('panning');});}
1229
+ function renderAllCharts(){renderCharts();renderUngroupedCorrelationCharts();renderGroupedCorrelationCharts();}
1230
+ const storedReportTheme=(()=>{try{return localStorage.getItem(reportThemeKey);}catch{return null;}})();
1231
+ applyReportTheme(storedReportTheme==='dark'?'dark':'light');
1232
+ if(reportThemeToggle){reportThemeToggle.addEventListener('click',()=>{const next=document.body.getAttribute('data-theme')==='dark'?'light':'dark';applyReportTheme(next);try{localStorage.setItem(reportThemeKey,next);}catch{}renderAllCharts();});}
1233
+ btns.forEach(b=>b.addEventListener('click',()=>show(b.dataset.tab)));
1234
+ if(btns.length>0){show(btns[0].dataset.tab);}renderAllCharts();initPanePan();window.addEventListener('resize',renderAllCharts);
1235
+ </script>
1236
+ </body>
1237
+ </html>`;
1238
+ return template
1239
+ .split("\n").join(REPORT_EOL)
1240
+ .split("__LOGO_LIGHT__").join(escapeHtml(getReportLogoLightDataUri()))
1241
+ .split("__LOGO_DARK__").join(escapeHtml(getReportLogoDarkDataUri()))
1242
+ .split("__TEST_SUITE__").join(escapeHtml(reportValue(testInfo, "testSuite", "TestSuite")))
1243
+ .split("__TEST_NAME__").join(escapeHtml(reportValue(testInfo, "testName", "TestName")))
1244
+ .split("__SESSION_ID__").join(escapeHtml(reportValue(testInfo, "sessionId", "SessionId")))
1245
+ .split("__DURATION__").join(escapeHtml(formatDotnetTimeSpan(reportValue(nodeStats, "duration", "Duration", "durationMs", "DurationMs"))))
1246
+ .split("__BUTTONS__").join(buttonsHtml)
1247
+ .split("__SECTIONS__").join(sectionsHtml)
1248
+ .split("__CHART_DATA__").join(chartDataJson)
1249
+ .concat(REPORT_EOL);
1250
+ }