@loadstrike/loadstrike-sdk 1.0.30401 → 1.0.31601

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.
@@ -1,6 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { deriveScenarioRates } from "./report-history.js";
5
+ import { emptyLocalReportInput } from "./local-report-input.js";
6
+ import { REPORT_SVG_CSS, REPORT_SVG_SCRIPT } from "./reporting-svg.js";
4
7
  const REPORT_EOL = "\n";
5
8
  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>";
6
9
  const REPORT_LOGO_CACHE = new Map();
@@ -38,8 +41,12 @@ function reportValue(source, ...keys) {
38
41
  }
39
42
  const record = source;
40
43
  for (const key of keys) {
41
- if (record[key] !== undefined && record[key] !== null) {
42
- return record[key];
44
+ const descriptor = Object.getOwnPropertyDescriptor(record, key);
45
+ if (descriptor
46
+ && "value" in descriptor
47
+ && descriptor.value !== undefined
48
+ && descriptor.value !== null) {
49
+ return descriptor.value;
43
50
  }
44
51
  }
45
52
  return undefined;
@@ -284,6 +291,40 @@ function loadStrikeNodeTypeTag(value) {
284
291
  return asInt(value);
285
292
  }
286
293
  }
294
+ function loadStrikeNodeTypeLabel(value) {
295
+ if (value && typeof value === "object" && !Array.isArray(value)) {
296
+ const record = value;
297
+ if (record.tag !== undefined || record.Tag !== undefined) {
298
+ return ["Single node", "Coordinator", "Agent"][loadStrikeNodeTypeTag(value)]
299
+ ?? "Unknown";
300
+ }
301
+ return "Unknown";
302
+ }
303
+ const text = asString(value).trim();
304
+ return {
305
+ SingleNode: "Single node",
306
+ Coordinator: "Coordinator",
307
+ Agent: "Agent",
308
+ "0": "Single node",
309
+ "1": "Coordinator",
310
+ "2": "Agent"
311
+ }[text] ?? (text || "Unknown");
312
+ }
313
+ function loadStrikeRuntimeVersionLabel(nodeInfo) {
314
+ for (const [camelKey, pascalKey] of [
315
+ ["runtimeVersion", "RuntimeVersion"],
316
+ ["pythonVersion", "PythonVersion"],
317
+ ["nodeVersion", "NodeVersion"],
318
+ ["dotNetVersion", "DotNetVersion"],
319
+ ["engineVersion", "EngineVersion"]
320
+ ]) {
321
+ const value = asString(reportValue(nodeInfo, camelKey, pascalKey)).trim();
322
+ if (value) {
323
+ return value;
324
+ }
325
+ }
326
+ return "Unavailable";
327
+ }
287
328
  function parseUtcDate(value) {
288
329
  if (value instanceof Date && !Number.isNaN(value.getTime())) {
289
330
  return value;
@@ -322,7 +363,22 @@ function formatDotnetDateTime(value) {
322
363
  return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${fraction}Z`;
323
364
  }
324
365
  function escapeJsonForHtmlScript(value) {
325
- return value.split("</").join("<\\/").split("<!--").join("<\\!--");
366
+ return value.replace(/[<>&\u2028\u2029]/g, (character) => {
367
+ switch (character) {
368
+ case "<":
369
+ return "\\u003c";
370
+ case ">":
371
+ return "\\u003e";
372
+ case "&":
373
+ return "\\u0026";
374
+ case "\u2028":
375
+ return "\\u2028";
376
+ case "\u2029":
377
+ return "\\u2029";
378
+ default:
379
+ return character;
380
+ }
381
+ });
326
382
  }
327
383
  function buildReportLogoDataUri(resourceName) {
328
384
  const cached = REPORT_LOGO_CACHE.get(resourceName);
@@ -398,6 +454,9 @@ function buildDotnetTableHtml(rows, wrapInCard = true) {
398
454
  return parts.join("");
399
455
  }
400
456
  function formatReportTableHeader(header) {
457
+ if (header === "UnmatchedDestination") {
458
+ return "Unmatched Destination";
459
+ }
401
460
  if (header === "LatencyStdDev") {
402
461
  return "LatencyStdDev (ms)";
403
462
  }
@@ -495,21 +554,14 @@ function buildFailedEventRows(plugins) {
495
554
  }
496
555
  function tryParseReportFloat(value) {
497
556
  const parsed = Number.parseFloat(asString(value));
498
- return Number.isFinite(parsed) ? parsed : undefined;
499
- }
500
- function meanNumeric(values) {
501
- if (!values.length) {
502
- return 0;
503
- }
504
- return values.reduce((sum, value) => sum + value, 0) / values.length;
557
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
505
558
  }
506
- function percentileNumeric(values, percentileValue) {
507
- if (!values.length) {
559
+ function percentileFromOrdered(values, percentileValue) {
560
+ if (values.length === 0) {
508
561
  return 0;
509
562
  }
510
- const ordered = [...values].sort((left, right) => left - right);
511
- const index = Math.min(Math.max(Math.ceil(percentileValue * ordered.length) - 1, 0), ordered.length - 1);
512
- return ordered[index];
563
+ const index = Math.min(Math.max(Math.ceil(percentileValue * values.length) - 1, 0), values.length - 1);
564
+ return values[index];
513
565
  }
514
566
  function buildGroupedCorrelationChartPayloads(rows) {
515
567
  const grouped = new Map();
@@ -527,33 +579,48 @@ function buildGroupedCorrelationChartPayloads(rows) {
527
579
  }
528
580
  return [...grouped.values()]
529
581
  .filter((group) => group.rows.some((row) => tryParseReportFloat(row.LatencyP50Ms) !== undefined
582
+ || tryParseReportFloat(row.LatencyP75Ms) !== undefined
530
583
  || tryParseReportFloat(row.LatencyP80Ms) !== undefined
531
584
  || tryParseReportFloat(row.LatencyP85Ms) !== undefined
532
585
  || tryParseReportFloat(row.LatencyP90Ms) !== undefined
533
586
  || tryParseReportFloat(row.LatencyP95Ms) !== undefined
534
- || tryParseReportFloat(row.LatencyP99Ms) !== undefined))
587
+ || tryParseReportFloat(row.LatencyP99Ms) !== undefined
588
+ || tryParseReportFloat(row.LatencyMaxMs ?? row.LatencyMax) !== undefined))
535
589
  .sort((left, right) => left.key[0] === right.key[0]
536
590
  ? left.key[1].localeCompare(right.key[1])
537
591
  : left.key[0].localeCompare(right.key[0]))
538
592
  .map((group, index) => ({
539
593
  title: `${group.key[0]}: ${group.key[1]}`,
540
- subtitle: group.rows.length > 1 ? `Averaged across ${group.rows.length} grouped rows.` : "Single grouped row.",
594
+ subtitle: group.rows.length > 1
595
+ ? `${group.rows.length} distinct scenario and destination rows.`
596
+ : "Single grouped row.",
541
597
  chart: {
542
- labels: ["P50", "P80", "P85", "P90", "P95", "P99"],
543
- series: [
544
- {
545
- name: "Latency",
546
- color: ["#38bdf8", "#22c55e", "#f59e0b", "#a855f7", "#f43f5e", "#06b6d4", "#14b8a6", "#eab308", "#818cf8", "#84cc16"][index % 10],
598
+ labels: ["P50", "P75", "P80", "P85", "P90", "P95", "P99", "Max"],
599
+ series: [...group.rows]
600
+ .sort((left, right) => {
601
+ const leftIdentity = `${readReportRowText(left, "Scenario")}|${readReportRowText(left, "Destination")}`;
602
+ const rightIdentity = `${readReportRowText(right, "Scenario")}|${readReportRowText(right, "Destination")}`;
603
+ return leftIdentity.localeCompare(rightIdentity);
604
+ })
605
+ .map((row, rowIndex) => {
606
+ const scenario = readReportRowText(row, "Scenario") || "<scenario unavailable>";
607
+ const destination = readReportRowText(row, "Destination") || "<destination unavailable>";
608
+ const name = `${scenario} | ${destination}${rowIndex > 0 ? ` (row ${rowIndex + 1})` : ""}`;
609
+ return {
610
+ name,
611
+ color: ["#38bdf8", "#22c55e", "#f59e0b", "#a855f7", "#f43f5e", "#06b6d4", "#14b8a6", "#eab308", "#818cf8", "#84cc16"][(index + rowIndex) % 10],
547
612
  values: [
548
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP50Ms)).filter((value) => value !== undefined)),
549
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP80Ms)).filter((value) => value !== undefined)),
550
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP85Ms)).filter((value) => value !== undefined)),
551
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP90Ms)).filter((value) => value !== undefined)),
552
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP95Ms)).filter((value) => value !== undefined)),
553
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP99Ms)).filter((value) => value !== undefined))
613
+ tryParseReportFloat(row.LatencyP50Ms) ?? null,
614
+ tryParseReportFloat(row.LatencyP75Ms) ?? null,
615
+ tryParseReportFloat(row.LatencyP80Ms) ?? null,
616
+ tryParseReportFloat(row.LatencyP85Ms) ?? null,
617
+ tryParseReportFloat(row.LatencyP90Ms) ?? null,
618
+ tryParseReportFloat(row.LatencyP95Ms) ?? null,
619
+ tryParseReportFloat(row.LatencyP99Ms) ?? null,
620
+ tryParseReportFloat(row.LatencyMaxMs ?? row.LatencyMax) ?? null
554
621
  ]
555
- }
556
- ]
622
+ };
623
+ })
557
624
  }
558
625
  }));
559
626
  }
@@ -576,7 +643,6 @@ function buildUngroupedCorrelationChartPayload(rows) {
576
643
  grouped.set(key, { scenario, destination, statusCode, latencies: [latency] });
577
644
  }
578
645
  }
579
- const nameCount = new Map();
580
646
  const series = [...grouped.values()]
581
647
  .sort((left, right) => {
582
648
  const leftKey = `${left.scenario}|${left.destination}|${left.statusCode}`;
@@ -584,25 +650,24 @@ function buildUngroupedCorrelationChartPayload(rows) {
584
650
  return leftKey.localeCompare(rightKey);
585
651
  })
586
652
  .map((row, index) => {
587
- const baseName = row.statusCode.trim() || "status";
588
- const key = baseName.toLowerCase();
589
- const count = nameCount.get(key) ?? 0;
590
- nameCount.set(key, count + 1);
653
+ const orderedLatencies = [...row.latencies].sort((left, right) => left - right);
591
654
  return {
592
- name: count === 0 ? baseName : `${baseName}-${count + 1}`,
655
+ name: `${row.scenario} | ${row.destination} | status ${row.statusCode}`,
593
656
  color: ["#38bdf8", "#22c55e", "#f59e0b", "#a855f7", "#f43f5e", "#06b6d4", "#14b8a6", "#eab308", "#818cf8", "#84cc16"][index % 10],
594
657
  values: [
595
- percentileNumeric(row.latencies, 0.50),
596
- percentileNumeric(row.latencies, 0.80),
597
- percentileNumeric(row.latencies, 0.85),
598
- percentileNumeric(row.latencies, 0.90),
599
- percentileNumeric(row.latencies, 0.95),
600
- percentileNumeric(row.latencies, 0.99)
658
+ percentileFromOrdered(orderedLatencies, 0.50),
659
+ percentileFromOrdered(orderedLatencies, 0.75),
660
+ percentileFromOrdered(orderedLatencies, 0.80),
661
+ percentileFromOrdered(orderedLatencies, 0.85),
662
+ percentileFromOrdered(orderedLatencies, 0.90),
663
+ percentileFromOrdered(orderedLatencies, 0.95),
664
+ percentileFromOrdered(orderedLatencies, 0.99),
665
+ orderedLatencies[orderedLatencies.length - 1]
601
666
  ]
602
667
  };
603
668
  });
604
669
  return {
605
- labels: ["P50", "P80", "P85", "P90", "P95", "P99"],
670
+ labels: ["P50", "P75", "P80", "P85", "P90", "P95", "P99", "Max"],
606
671
  series
607
672
  };
608
673
  }
@@ -738,8 +803,34 @@ function hasMeasurementData(measurement) {
738
803
  asString(reportValue(code, "statusCode", "StatusCode")).trim().length > 0 ||
739
804
  asString(reportValue(code, "message", "Message")).trim().length > 0);
740
805
  }
741
- function appendChartCard(parts, id, title) {
742
- appendReportLine(parts, `<div class="chart-card"><h3>${escapeHtml(title)}</h3><canvas id="${escapeHtml(id)}" class="chart-canvas"></canvas></div>`);
806
+ function appendChartCard(parts, id, title, source, kind, unit, seriesLabel, compatibilityClass = "", extraHostAttributes = "") {
807
+ const cardClass = compatibilityClass.includes("correlation")
808
+ ? "chart-card correlation-chart-card"
809
+ : "chart-card";
810
+ appendReportLine(parts, `<div class="${cardClass}" data-chart-title="${escapeHtml(title)}"><h3>${escapeHtml(title)}</h3>`);
811
+ appendReportLine(parts, "<div class=\"chart-actions\" aria-label=\"Chart controls\">");
812
+ for (const [action, label] of [
813
+ ["zoom-in", "Zoom in"],
814
+ ["zoom-out", "Zoom out"],
815
+ ["pan-left", "Pan left"],
816
+ ["pan-right", "Pan right"],
817
+ ["reset", "Reset"],
818
+ ["expand", "Expand"]
819
+ ]) {
820
+ appendReportLine(parts, `<button type="button" class="chart-action" data-chart-action="${action}" aria-label="${label}">${label}</button>`);
821
+ }
822
+ appendReportLine(parts, "</div>");
823
+ appendReportLine(parts, `<div class="chart-host" data-loadstrike-chart-engine="svg-v2" data-chart-source="${escapeHtml(source)}" data-chart-kind="${escapeHtml(kind)}" data-chart-unit="${escapeHtml(unit)}" data-series-label="${escapeHtml(seriesLabel)}" data-chart-title="${escapeHtml(title)}"${extraHostAttributes ? ` ${extraHostAttributes}` : ""}>`);
824
+ appendReportLine(parts, `<svg id="${escapeHtml(id)}" class="chart-canvas${compatibilityClass ? ` ${escapeHtml(compatibilityClass)}` : ""}" role="img" aria-label="${escapeHtml(title)}" tabindex="0" viewBox="0 0 720 320" preserveAspectRatio="xMidYMid meet"><title>${escapeHtml(title)}</title></svg>`);
825
+ appendReportLine(parts, "<div class=\"chart-tooltip\" data-chart-tooltip role=\"status\" aria-live=\"polite\" hidden></div>");
826
+ appendReportLine(parts, "<div class=\"chart-legend\" data-chart-legend aria-label=\"Chart legend\"></div>");
827
+ appendReportLine(parts, "</div></div>");
828
+ }
829
+ function appendChartCollectionTools(parts) {
830
+ appendReportLine(parts, "<div class=\"chart-collection-tools\">");
831
+ appendReportLine(parts, "<label>Search charts<input type=\"search\" data-chart-search placeholder=\"Filter by chart title\" /></label>");
832
+ appendReportLine(parts, "<label>Chart size<select data-chart-grid-size><option value=\"compact\">Compact</option><option value=\"comfortable\" selected>Comfortable</option><option value=\"spacious\">Spacious</option></select></label>");
833
+ appendReportLine(parts, "</div>");
743
834
  }
744
835
  function hasChartPointData(points) {
745
836
  return Array.isArray(points) && points.length > 0;
@@ -750,7 +841,7 @@ function hasPieChartData(points) {
750
841
  function hasLatencyTrendData(chart) {
751
842
  const labels = reportArray(chart, "labels", "Labels");
752
843
  const series = reportArray(chart, "series", "Series");
753
- return labels.length > 0 && series.some((entry) => reportArray(entry, "values", "Values").some((value) => Number.isFinite(asFloat(value))));
844
+ return labels.length > 0 && series.some((entry) => reportArray(entry, "values", "Values").some((value) => value !== null && value !== undefined && Number.isFinite(Number(value)) && Number(value) >= 0));
754
845
  }
755
846
  function hasNonEmptyHints(plugin) {
756
847
  return reportArray(plugin, "hints", "Hints").some((hint) => asString(hint).trim().length > 0);
@@ -893,11 +984,169 @@ function buildDotnetStatusCodeClassChart(scenarios) {
893
984
  { label: "Other", value: buckets.Other, color: "#8b5cf6" }
894
985
  ].filter((entry) => entry.value > 0);
895
986
  }
896
- function buildDotnetChartData(nodeStats) {
987
+ function genuineCombinedMeasurement(source) {
988
+ const value = reportValue(source, "allMeasurement", "AllMeasurement");
989
+ return value && typeof value === "object" && !Array.isArray(value)
990
+ ? value
991
+ : undefined;
992
+ }
993
+ function measurementHasObservations(measurement) {
994
+ if (!measurement) {
995
+ return false;
996
+ }
997
+ const request = reportObject(measurement, "request", "Request");
998
+ if (asInt(reportValue(request, "count", "Count")) > 0) {
999
+ return true;
1000
+ }
1001
+ const count64 = asString(reportValue(measurement, "count64", "Count64")).trim();
1002
+ return /^\d+$/.test(count64) && /[1-9]/.test(count64);
1003
+ }
1004
+ function validMeasurementLatency(measurement, camelKey, pascalKey) {
1005
+ if (!measurement) {
1006
+ return null;
1007
+ }
1008
+ if (!measurementHasObservations(measurement)) {
1009
+ return null;
1010
+ }
1011
+ const value = Number(reportValue(reportObject(measurement, "latency", "Latency"), camelKey, pascalKey));
1012
+ return Number.isFinite(value) && value >= 0 ? value : null;
1013
+ }
1014
+ function measurementUsesApproximateHistogram(measurement) {
1015
+ if (!measurement) {
1016
+ return false;
1017
+ }
1018
+ const mode = asString(reportValue(measurement, "distributionMode", "DistributionMode")).toLocaleLowerCase();
1019
+ const maxRelativeError = asFloat(reportValue(measurement, "maxRelativeError", "MaxRelativeError"));
1020
+ return maxRelativeError > 0
1021
+ || mode.includes("quantized")
1022
+ || mode.includes("approx");
1023
+ }
1024
+ function buildOutcomeLatencySeries(scenarios, percentileName, camelKey, pascalKey) {
1025
+ return [
1026
+ {
1027
+ outcome: "All",
1028
+ color: "#8b5cf6",
1029
+ measurement: (scenario) => genuineCombinedMeasurement(scenario)
1030
+ },
1031
+ {
1032
+ outcome: "OK",
1033
+ color: "#18a957",
1034
+ measurement: (scenario) => reportObject(scenario, "ok", "Ok")
1035
+ },
1036
+ {
1037
+ outcome: "Failed",
1038
+ color: "#d14343",
1039
+ measurement: (scenario) => reportObject(scenario, "fail", "Fail")
1040
+ }
1041
+ ].map((candidate) => {
1042
+ const measurements = scenarios.map(candidate.measurement);
1043
+ const values = measurements.map((measurement) => validMeasurementLatency(measurement, camelKey, pascalKey));
1044
+ const approximate = measurements.some((measurement, index) => values[index] !== null && measurementUsesApproximateHistogram(measurement));
1045
+ return {
1046
+ name: `${candidate.outcome} ${percentileName}${approximate ? " (approx.)" : ""}`,
1047
+ color: candidate.color,
1048
+ values
1049
+ };
1050
+ }).filter((series) => series.values.some((value) => value !== null));
1051
+ }
1052
+ function buildHistoryChartData(points) {
1053
+ if (!points.length) {
1054
+ return {
1055
+ cumulativeRequestsHistory: { labels: [], series: [] },
1056
+ achievedRequestRateHistory: { labels: [], series: [] },
1057
+ cumulativeBytesHistory: { labels: [], series: [] },
1058
+ historyLatencyCharts: []
1059
+ };
1060
+ }
1061
+ const ordered = [...points].sort((left, right) => left.elapsedSeconds - right.elapsedSeconds);
1062
+ const labels = ordered.map((point) => `${Number(point.elapsedSeconds.toFixed(3))}s`);
1063
+ const scenarioNames = [...new Set(ordered.flatMap((point) => [...point.scenarios]
1064
+ .sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0))
1065
+ .map((scenario) => scenario.scenarioName)))];
1066
+ const palette = ["#38bdf8", "#22c55e", "#f59e0b", "#a855f7", "#f43f5e", "#14b8a6", "#eab308", "#818cf8", "#06b6d4", "#84cc16"];
1067
+ const findScenario = (point, scenarioName) => point.scenarios.find((scenario) => scenario.scenarioName === scenarioName);
1068
+ const totalCount = (scenario) => scenario?.all?.count ?? ((scenario?.ok?.count ?? 0) + (scenario?.failed?.count ?? 0));
1069
+ const totalBytes = (scenario) => scenario?.all?.bytes ?? ((scenario?.ok?.bytes ?? 0) + (scenario?.failed?.bytes ?? 0));
1070
+ const cumulativeRequestsHistory = {
1071
+ labels,
1072
+ series: scenarioNames.map((scenarioName, index) => ({
1073
+ name: scenarioName,
1074
+ color: palette[index % palette.length],
1075
+ values: ordered.map((point) => {
1076
+ const scenario = findScenario(point, scenarioName);
1077
+ return scenario ? totalCount(scenario) : null;
1078
+ })
1079
+ }))
1080
+ };
1081
+ const cumulativeBytesHistory = {
1082
+ labels,
1083
+ series: scenarioNames.map((scenarioName, index) => ({
1084
+ name: scenarioName,
1085
+ color: palette[index % palette.length],
1086
+ values: ordered.map((point) => {
1087
+ const scenario = findScenario(point, scenarioName);
1088
+ return scenario ? totalBytes(scenario) : null;
1089
+ })
1090
+ }))
1091
+ };
1092
+ const ratesByScenario = new Map(deriveScenarioRates(ordered).map((series) => [series.scenarioName, series.values]));
1093
+ const achievedRequestRateHistory = {
1094
+ labels,
1095
+ series: scenarioNames.map((scenarioName, index) => ({
1096
+ name: scenarioName,
1097
+ color: palette[index % palette.length],
1098
+ values: ratesByScenario.get(scenarioName) ?? labels.map(() => null)
1099
+ }))
1100
+ };
1101
+ const outcomes = [
1102
+ ["All", (scenario) => scenario.all],
1103
+ ["OK", (scenario) => scenario.ok],
1104
+ ["Failed", (scenario) => scenario.failed]
1105
+ ];
1106
+ const percentiles = [
1107
+ ["P50", "percent50Ms"],
1108
+ ["P75", "percent75Ms"],
1109
+ ["P95", "percent95Ms"],
1110
+ ["P99", "percent99Ms"]
1111
+ ];
1112
+ const historyLatencyCharts = scenarioNames.map((scenarioName, scenarioIndex) => {
1113
+ let seriesIndex = 0;
1114
+ const series = outcomes.flatMap(([outcomeName, measurementSelector]) => percentiles.map(([percentileName, percentileKey]) => {
1115
+ const measurements = ordered.map((point) => {
1116
+ const scenario = findScenario(point, scenarioName);
1117
+ return scenario ? measurementSelector(scenario) : undefined;
1118
+ });
1119
+ const values = measurements.map((measurement) => {
1120
+ const value = measurement?.[percentileKey];
1121
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
1122
+ });
1123
+ const approximate = measurements.some((measurement, index) => values[index] !== null && measurement?.approximate === true);
1124
+ return {
1125
+ name: `${outcomeName} ${percentileName}${approximate ? " (approx.)" : ""}`,
1126
+ color: palette[(scenarioIndex + seriesIndex++) % palette.length],
1127
+ values
1128
+ };
1129
+ }).filter((series) => series.values.some((value) => value !== null)));
1130
+ return {
1131
+ title: `Cumulative Latency - ${scenarioName}`,
1132
+ chart: { labels, series }
1133
+ };
1134
+ }).filter((item) => item.chart.series.length > 0);
1135
+ return {
1136
+ cumulativeRequestsHistory,
1137
+ achievedRequestRateHistory,
1138
+ cumulativeBytesHistory,
1139
+ historyLatencyCharts
1140
+ };
1141
+ }
1142
+ function buildDotnetChartData(nodeStats, localReportInput) {
897
1143
  const scenarios = reportScenarios(nodeStats);
898
- const combinedScenarios = scenarios
899
- .map((scenario) => ({ scenario, measurement: combinedMeasurement(scenario) }))
1144
+ const genuineCombinedScenarios = scenarios
1145
+ .map((scenario) => ({ scenario, measurement: genuineCombinedMeasurement(scenario) }))
900
1146
  .filter((item) => item.measurement !== undefined);
1147
+ const history = localReportInput.history.status === "available"
1148
+ ? buildHistoryChartData(localReportInput.history.points)
1149
+ : buildHistoryChartData([]);
901
1150
  return {
902
1151
  overallOutcome: [
903
1152
  { label: "OK", value: reportTotalOkCount(nodeStats, scenarios), color: "#18a957" },
@@ -908,11 +1157,15 @@ function buildDotnetChartData(nodeStats) {
908
1157
  value: reportRequestCountValue(scenario),
909
1158
  color: "#3b82f6"
910
1159
  })),
911
- scenarioP95Latency: combinedScenarios.map(({ scenario, measurement }) => ({
1160
+ scenarioP95Latency: genuineCombinedScenarios.map(({ scenario, measurement }) => ({
912
1161
  label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
913
1162
  value: asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent95", "Percent95")),
914
1163
  color: "#8b5cf6"
915
1164
  })),
1165
+ scenarioP95LatencyByOutcome: {
1166
+ labels: scenarios.map((scenario) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
1167
+ series: buildOutcomeLatencySeries(scenarios, "P95", "percent95", "Percent95")
1168
+ },
916
1169
  scenarioRps: scenarios.map((scenario) => ({
917
1170
  label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
918
1171
  value: reportDurationSeconds(reportDurationValue(scenario)) <= 0
@@ -934,17 +1187,18 @@ function buildDotnetChartData(nodeStats) {
934
1187
  })),
935
1188
  statusCodeClasses: buildDotnetStatusCodeClassChart(scenarios),
936
1189
  scenarioLatencyTrend: {
937
- labels: combinedScenarios.map(({ scenario }) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
1190
+ labels: scenarios.map((scenario) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
938
1191
  series: [
939
- { name: "P50", color: "#38bdf8", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent50", "Percent50"))) },
940
- { name: "P75", color: "#22c55e", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent75", "Percent75"))) },
941
- { name: "P95", color: "#f59e0b", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent95", "Percent95"))) },
942
- { name: "P99", color: "#f43f5e", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent99", "Percent99"))) }
1192
+ ...buildOutcomeLatencySeries(scenarios, "P50", "percent50", "Percent50"),
1193
+ ...buildOutcomeLatencySeries(scenarios, "P75", "percent75", "Percent75"),
1194
+ ...buildOutcomeLatencySeries(scenarios, "P95", "percent95", "Percent95"),
1195
+ ...buildOutcomeLatencySeries(scenarios, "P99", "percent99", "Percent99")
943
1196
  ]
944
- }
1197
+ },
1198
+ ...history
945
1199
  };
946
1200
  }
947
- function buildDotnetSummaryHtml(nodeStats) {
1201
+ function buildDotnetSummaryHtml(nodeStats, localReportInput) {
948
1202
  const scenarios = reportScenarios(nodeStats);
949
1203
  const allRequests = reportTotalRequestCount(nodeStats, scenarios);
950
1204
  const allOk = reportTotalOkCount(nodeStats, scenarios);
@@ -966,11 +1220,19 @@ function buildDotnetSummaryHtml(nodeStats) {
966
1220
  ? scenario
967
1221
  : winner;
968
1222
  }, undefined);
969
- const latencyRows = scenarios.flatMap((scenario) => [
970
- buildSummaryLatencyRow(asString(reportValue(scenario, "scenarioName", "ScenarioName")), "OK", reportObject(scenario, "ok", "Ok")),
971
- buildSummaryLatencyRow(asString(reportValue(scenario, "scenarioName", "ScenarioName")), "FAIL", reportObject(scenario, "fail", "Fail"))
972
- ]);
973
- const chartData = buildDotnetChartData(nodeStats);
1223
+ const latencyRows = [];
1224
+ for (const scenario of scenarios) {
1225
+ const scenarioName = asString(reportValue(scenario, "scenarioName", "ScenarioName"));
1226
+ const ok = reportObject(scenario, "ok", "Ok");
1227
+ const fail = reportObject(scenario, "fail", "Fail");
1228
+ if (measurementHasObservations(ok)) {
1229
+ latencyRows.push(buildSummaryLatencyRow(scenarioName, "OK", ok));
1230
+ }
1231
+ if (measurementHasObservations(fail)) {
1232
+ latencyRows.push(buildSummaryLatencyRow(scenarioName, "FAIL", fail));
1233
+ }
1234
+ }
1235
+ const chartData = buildDotnetChartData(nodeStats, localReportInput);
974
1236
  const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
975
1237
  const nodeInfo = reportObject(nodeStats, "nodeInfo", "NodeInfo");
976
1238
  const totalBytes = reportTotalBytes(nodeStats, scenarios);
@@ -983,39 +1245,62 @@ function buildDotnetSummaryHtml(nodeStats) {
983
1245
  appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Duration</div><div class="stat-value">${escapeHtml(formatDotnetTimeSpan(reportDurationValue(nodeStats)))}</div></div>`);
984
1246
  appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Total Bytes</div><div class="stat-value">${totalBytes}</div></div>`);
985
1247
  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>`);
986
- appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Node</div><div class="stat-value">${loadStrikeNodeTypeTag(reportValue(nodeInfo, "nodeType", "NodeType"))}</div></div>`);
1248
+ appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Node</div><div class="stat-value">${escapeHtml(loadStrikeNodeTypeLabel(reportValue(nodeInfo, "nodeType", "NodeType")))}</div></div>`);
987
1249
  appendReportLine(parts, "</div>");
1250
+ const hasApproximateLatencySeries = [
1251
+ ...reportArray(reportObject(chartData, "scenarioP95LatencyByOutcome"), "series"),
1252
+ ...reportArray(reportObject(chartData, "scenarioLatencyTrend"), "series"),
1253
+ ...reportArray(chartData, "historyLatencyCharts")
1254
+ .flatMap((item) => reportArray(reportObject(item, "chart"), "series"))
1255
+ ].some((series) => asString(reportValue(series, "name")).endsWith(" (approx.)"));
1256
+ if (hasApproximateLatencySeries) {
1257
+ appendReportLine(parts, "<p class=\"meta-note\">Series marked '(approx.)' use bounded native histogram estimates; completed request and transferred-byte totals remain exact.</p>");
1258
+ }
988
1259
  const charts = [];
989
1260
  if (hasPieChartData(chartData.overallOutcome)) {
990
- appendChartCard(charts, "chart-outcome", "Success vs Fail");
1261
+ appendChartCard(charts, "chart-outcome", "Success vs Fail", "overallOutcome", "donut", "requests", "Outcome");
991
1262
  }
992
1263
  if (hasChartPointData(chartData.scenarioRequests)) {
993
- appendChartCard(charts, "chart-scenario-requests", "Requests by Scenario");
1264
+ appendChartCard(charts, "chart-scenario-requests", "Requests by Scenario", "scenarioRequests", "bar", "requests", "Requests");
994
1265
  }
995
- if (hasChartPointData(chartData.scenarioP95Latency)) {
996
- appendChartCard(charts, "chart-scenario-p95", "P95 Latency by Scenario (ms)");
1266
+ if (hasLatencyTrendData(chartData.scenarioP95LatencyByOutcome)) {
1267
+ appendChartCard(charts, "chart-scenario-p95", "P95 Latency by Scenario (ms)", "scenarioP95LatencyByOutcome", "bar", "ms", "P95");
997
1268
  }
998
1269
  if (hasChartPointData(chartData.scenarioRps)) {
999
- appendChartCard(charts, "chart-scenario-rps", "RPS by Scenario");
1270
+ appendChartCard(charts, "chart-scenario-rps", "RPS by Scenario", "scenarioRps", "bar", "req/s", "Rate");
1000
1271
  }
1001
1272
  if (hasChartPointData(chartData.scenarioFailRate)) {
1002
- appendChartCard(charts, "chart-scenario-fail-rate", "Failure Rate by Scenario (%)");
1273
+ appendChartCard(charts, "chart-scenario-fail-rate", "Failure Rate by Scenario (%)", "scenarioFailRate", "bar", "%", "Failure rate");
1003
1274
  }
1004
1275
  if (hasChartPointData(chartData.scenarioBytes)) {
1005
- appendChartCard(charts, "chart-scenario-bytes", "Bytes by Scenario");
1276
+ appendChartCard(charts, "chart-scenario-bytes", "Bytes by Scenario", "scenarioBytes", "bar", "bytes", "Transferred");
1006
1277
  }
1007
1278
  if (hasPieChartData(chartData.statusCodeClasses)) {
1008
- appendChartCard(charts, "chart-status-code-classes", "Status Code Class Mix");
1279
+ appendChartCard(charts, "chart-status-code-classes", "Status Code Class Mix", "statusCodeClasses", "donut", "responses", "Status");
1009
1280
  }
1010
1281
  if (hasLatencyTrendData(chartData.scenarioLatencyTrend)) {
1011
- appendChartCard(charts, "chart-scenario-latency-lines", "Latency Trend by Scenario (ms)");
1282
+ appendChartCard(charts, "chart-scenario-latency-lines", "Latency Percentile Profiles by Scenario (ms)", "scenarioLatencyTrend", "line", "ms", "Latency");
1283
+ }
1284
+ if (hasLatencyTrendData(chartData.cumulativeRequestsHistory)) {
1285
+ appendChartCard(charts, "chart-history-requests", "Cumulative Completed Requests by Scenario", "cumulativeRequestsHistory", "line", "requests", "Requests");
1286
+ }
1287
+ if (hasLatencyTrendData(chartData.achievedRequestRateHistory)) {
1288
+ appendChartCard(charts, "chart-history-rate", "Achieved Request Rate by Scenario", "achievedRequestRateHistory", "line", "req/s", "Rate");
1289
+ }
1290
+ if (hasLatencyTrendData(chartData.cumulativeBytesHistory)) {
1291
+ appendChartCard(charts, "chart-history-bytes", "Cumulative Transferred Bytes by Scenario", "cumulativeBytesHistory", "line", "bytes", "Transferred");
1292
+ }
1293
+ for (const [historyIndex, historyChart] of chartData.historyLatencyCharts.entries()) {
1294
+ appendChartCard(charts, `chart-history-latency-${historyIndex}`, asString(historyChart.title), "historyLatencyCharts", "line", "ms", "Latency", "", `data-chart-index="${historyIndex}"`);
1012
1295
  }
1013
1296
  if (charts.length > 0) {
1014
- appendReportLine(parts, "<div class=\"card\">");
1297
+ appendReportLine(parts, "<div class=\"card chart-collection\" data-chart-collection data-grid-size=\"comfortable\">");
1015
1298
  appendReportLine(parts, "<h2>Charts</h2>");
1299
+ appendChartCollectionTools(parts);
1016
1300
  appendReportLine(parts, "<div class=\"chart-grid\">");
1017
1301
  parts.push(...charts);
1018
1302
  appendReportLine(parts, "</div>");
1303
+ appendReportLine(parts, "<div class=\"chart-empty\" data-chart-empty>No charts match this search.</div>");
1019
1304
  appendReportLine(parts, "</div>");
1020
1305
  }
1021
1306
  if (latencyRows.length > 0) {
@@ -1032,7 +1317,7 @@ function buildDotnetSummaryHtml(nodeStats) {
1032
1317
  appendReportLine(parts, `<tr><th>Created (UTC)</th><td>${escapeHtml(formatDotnetDateTime(reportValue(testInfo, "createdUtc", "CreatedUtc", "created", "Created")))}</td></tr>`);
1033
1318
  appendReportLine(parts, `<tr><th>Machine</th><td>${escapeHtml(reportValue(nodeInfo, "machineName", "MachineName"))}</td></tr>`);
1034
1319
  appendReportLine(parts, `<tr><th>OS</th><td>${escapeHtml(reportValue(nodeInfo, "os", "OS"))}</td></tr>`);
1035
- appendReportLine(parts, `<tr><th>DotNet</th><td>${escapeHtml(reportValue(nodeInfo, "dotNetVersion", "DotNetVersion"))}</td></tr>`);
1320
+ appendReportLine(parts, `<tr><th>Runtime</th><td>${escapeHtml(loadStrikeRuntimeVersionLabel(nodeInfo))}</td></tr>`);
1036
1321
  appendReportLine(parts, `<tr><th>Processor</th><td>${escapeHtml(reportValue(nodeInfo, "processor", "Processor"))}</td></tr>`);
1037
1322
  appendReportLine(parts, `<tr><th>Cores</th><td>${asInt(reportValue(nodeInfo, "coresCount", "CoresCount"))}</td></tr>`);
1038
1323
  appendReportLine(parts, `<tr><th>Operation</th><td>${escapeHtml(reportValue(nodeInfo, "currentOperation", "CurrentOperation"))}</td></tr>`);
@@ -1084,8 +1369,30 @@ function buildDotnetThresholdHtml(nodeStats) {
1084
1369
  function buildDotnetMetricHtml(nodeStats) {
1085
1370
  return buildDotnetTableHtml(buildDotnetMetricRows(nodeStats));
1086
1371
  }
1087
- function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1088
- const warnings = reportArray(nodeStats, "generatorWarnings", "GeneratorWarnings");
1372
+ function reportHistoryUnavailableReason(localReportInput) {
1373
+ if (localReportInput.history.status === "available") {
1374
+ return "";
1375
+ }
1376
+ switch (localReportInput.history.reasonCategory) {
1377
+ case "capture_failure":
1378
+ return "History capture became unavailable.";
1379
+ case "budget_pressure":
1380
+ return "History was omitted because the report history budget was exceeded.";
1381
+ case "distributed_temporal_aggregation_unavailable":
1382
+ return "Temporal aggregation is unavailable for distributed results.";
1383
+ }
1384
+ }
1385
+ function buildDotnetGeneratorDeliveryHtml(nodeStats, localReportInput = emptyLocalReportInput()) {
1386
+ const warnings = [
1387
+ ...reportArray(nodeStats, "generatorWarnings", "GeneratorWarnings"),
1388
+ ...(localReportInput.history.status === "unavailable"
1389
+ ? [{
1390
+ code: "report_history_unavailable",
1391
+ count64: "1",
1392
+ reasonCategory: localReportInput.history.reasonCategory
1393
+ }]
1394
+ : [])
1395
+ ];
1089
1396
  const stats = reportObject(nodeStats, "schedulerStats", "SchedulerStats");
1090
1397
  const topLevelSegments = reportArray(nodeStats, "schedulerSegments", "SchedulerSegments");
1091
1398
  const segments = topLevelSegments.length > 0
@@ -1100,6 +1407,9 @@ function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1100
1407
  appendReportLine(parts, "<div class=\"card\">");
1101
1408
  appendReportLine(parts, "<h2>Generator Delivery</h2>");
1102
1409
  appendReportLine(parts, "<p>Application failures remain separate from generator and reporting warnings. Dropped arrivals, unavailable worker slots, and raw observation loss are not synthetic SUT errors.</p>");
1410
+ if (localReportInput.history.status === "unavailable") {
1411
+ appendReportLine(parts, `<p>${escapeHtml(reportHistoryUnavailableReason(localReportInput))}</p>`);
1412
+ }
1103
1413
  appendReportLine(parts, "<div class=\"card-grid\">");
1104
1414
  for (const [label, value] of [
1105
1415
  ["Configured Max In Flight", asInt(reportValue(stats, "configuredMaxInFlight", "ConfiguredMaxInFlight"))],
@@ -1116,7 +1426,7 @@ function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1116
1426
  }
1117
1427
  appendReportLine(parts, "</div></div>");
1118
1428
  if (warnings.length) {
1119
- appendReportLine(parts, "<div class=\"card\"><h2>Generator Warnings</h2>");
1429
+ appendReportLine(parts, "<div class=\"card\"><h2>Generator and Reporting Warnings</h2>");
1120
1430
  parts.push(buildDotnetTableHtml(warnings, false));
1121
1431
  appendReportLine(parts, "</div>");
1122
1432
  }
@@ -1141,7 +1451,7 @@ function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1141
1451
  }
1142
1452
  return parts.join("");
1143
1453
  }
1144
- function hasDotnetGeneratorDeliveryData(nodeStats) {
1454
+ function hasDotnetGeneratorDeliveryData(nodeStats, localReportInput = emptyLocalReportInput()) {
1145
1455
  const schedulerStats = reportObject(nodeStats, "schedulerStats", "SchedulerStats");
1146
1456
  const observationStats = reportObject(nodeStats, "observationDeliveryStats", "ObservationDeliveryStats");
1147
1457
  const hasNonZeroDecimal = (value) => {
@@ -1149,6 +1459,7 @@ function hasDotnetGeneratorDeliveryData(nodeStats) {
1149
1459
  return text.trim().length > 0 && text !== "0";
1150
1460
  };
1151
1461
  return reportArray(nodeStats, "generatorWarnings", "GeneratorWarnings").length > 0
1462
+ || localReportInput.history.status === "unavailable"
1152
1463
  || reportArray(nodeStats, "schedulerSegments", "SchedulerSegments").length > 0
1153
1464
  || reportArray(schedulerStats, "segments", "Segments").length > 0
1154
1465
  || asInt(reportValue(schedulerStats, "configuredMaxInFlight", "ConfiguredMaxInFlight")) > 0
@@ -1168,15 +1479,19 @@ function buildDotnetGroupedCorrelationSummaryHtml(rows, groupedChartKey) {
1168
1479
  return parts.join("");
1169
1480
  }
1170
1481
  appendReportLine(parts);
1171
- 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>");
1482
+ appendReportLine(parts, "<div class=\"card\"><h2>Grouped Correlation Percentile Trends</h2><p>Each chart is one GatherBy value. Every scenario and destination row remains a separate percentile series from P50 through Max.</p></div>");
1483
+ appendReportLine(parts, "<div class=\"card chart-collection\" data-chart-collection data-grid-size=\"comfortable\">");
1484
+ appendChartCollectionTools(parts);
1172
1485
  appendReportLine(parts, "<div class=\"chart-grid correlation-chart-grid\">");
1173
1486
  payloads.forEach((payload, index) => {
1174
1487
  const chartKey = `${groupedChartKey}-value-${index}`;
1175
1488
  const chartJson = escapeJsonForHtmlScript(JSON.stringify(payload.chart));
1176
1489
  appendReportLine(parts, `<script type="application/json" data-grouped-correlation-chart="${escapeHtml(chartKey)}">${chartJson}</script>`);
1177
- 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>`);
1490
+ appendChartCard(parts, `${chartKey}-chart`, payload.title, "grouped-correlation", "line", "ms", "Latency", "grouped-correlation-canvas", `data-grouped-key="${escapeHtml(chartKey)}"`);
1178
1491
  });
1179
1492
  appendReportLine(parts, "</div>");
1493
+ appendReportLine(parts, "<div class=\"chart-empty\" data-chart-empty>No charts match this search.</div>");
1494
+ appendReportLine(parts, "</div>");
1180
1495
  return parts.join("");
1181
1496
  }
1182
1497
  function buildDotnetUngroupedCorrelationSummaryHtml(rows, groupedChartKey) {
@@ -1188,8 +1503,12 @@ function buildDotnetUngroupedCorrelationSummaryHtml(rows, groupedChartKey) {
1188
1503
  appendReportLine(parts);
1189
1504
  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>");
1190
1505
  appendReportLine(parts, `<script type="application/json" data-ungrouped-correlation="${escapeHtml(groupedChartKey)}">${chartJson}</script>`);
1506
+ appendReportLine(parts, "<div class=\"card chart-collection\" data-chart-collection data-grid-size=\"comfortable\">");
1507
+ appendChartCollectionTools(parts);
1191
1508
  appendReportLine(parts, "<div class=\"chart-grid correlation-chart-grid\">");
1192
- 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>`);
1509
+ appendChartCard(parts, `${groupedChartKey}-chart`, "Ungrouped Correlation Percentiles", "ungrouped-correlation", "line", "ms", "Latency", "ungrouped-correlation-canvas", `data-ungrouped-key="${escapeHtml(groupedChartKey)}"`);
1510
+ appendReportLine(parts, "</div>");
1511
+ appendReportLine(parts, "<div class=\"chart-empty\" data-chart-empty>No charts match this search.</div>");
1193
1512
  appendReportLine(parts, "</div>");
1194
1513
  }
1195
1514
  return parts.join("");
@@ -1201,8 +1520,8 @@ function buildDotnetPluginHints(plugin) {
1201
1520
  const hints = reportArray(plugin, "hints", "Hints").map((hint) => asString(hint).trim()).filter((hint) => hint.length > 0);
1202
1521
  return `<div class="card"><strong>Hints</strong><ul>${hints.map((hint) => `<li>${escapeHtml(hint)}</li>`).join("")}</ul></div>`;
1203
1522
  }
1204
- function buildDotnetHtmlTabs(nodeStats) {
1205
- const tabs = [["summary", "Summary", buildDotnetSummaryHtml(nodeStats)]];
1523
+ function buildDotnetHtmlTabs(nodeStats, localReportInput = emptyLocalReportInput()) {
1524
+ const tabs = [["summary", "Summary", buildDotnetSummaryHtml(nodeStats, localReportInput)]];
1206
1525
  const scenarioRows = buildDotnetScenarioRows(nodeStats);
1207
1526
  if (scenarioRows.length) {
1208
1527
  tabs.push(["scenarios", "Scenarios", buildDotnetTableHtml(scenarioRows)]);
@@ -1236,8 +1555,8 @@ function buildDotnetHtmlTabs(nodeStats) {
1236
1555
  if (metricRows.length) {
1237
1556
  tabs.push(["metrics", "Metrics", buildDotnetTableHtml(metricRows)]);
1238
1557
  }
1239
- if (hasDotnetGeneratorDeliveryData(nodeStats)) {
1240
- tabs.push(["generator-delivery", "Generator Delivery", buildDotnetGeneratorDeliveryHtml(nodeStats)]);
1558
+ if (hasDotnetGeneratorDeliveryData(nodeStats, localReportInput)) {
1559
+ tabs.push(["generator-delivery", "Generator Delivery", buildDotnetGeneratorDeliveryHtml(nodeStats, localReportInput)]);
1241
1560
  }
1242
1561
  for (const plugin of reportArray(nodeStats, "pluginsData", "PluginsData")) {
1243
1562
  const pluginName = asString(reportValue(plugin, "pluginName", "PluginName"));
@@ -1272,7 +1591,7 @@ function buildDotnetHtmlTabs(nodeStats) {
1272
1591
  body = buildDotnetGroupedCorrelationSummaryHtml(bodyRows, `grouped-correlation-${tabs.length}`);
1273
1592
  }
1274
1593
  else if (lowerPlugin.includes("correlation") && lowerTable.includes("ungrouped correlation rows")) {
1275
- title = "Ungrouped Corelation Summary";
1594
+ title = "Ungrouped Correlation Summary";
1276
1595
  body = buildDotnetUngroupedCorrelationSummaryHtml(bodyRows, `ungrouped-correlation-${tabs.length}`);
1277
1596
  }
1278
1597
  tabs.push([`plugin-${tabs.length}`, title, `${hints}${body}`]);
@@ -1384,41 +1703,47 @@ export function buildDotnetMarkdownReport(nodeStats) {
1384
1703
  return reportLines(lines);
1385
1704
  }
1386
1705
  /**
1387
- * Exposes the build dotnet html report operation. Use this when interacting with the SDK through this surface.
1706
+ * Builds the portable single-file HTML report. The optional second argument is
1707
+ * an internal report-only carrier used by the native runner and is never added
1708
+ * to public result, sink, observation, or cluster payloads.
1388
1709
  */
1389
- export function buildDotnetHtmlReport(nodeStats) {
1390
- const tabs = buildDotnetHtmlTabs(nodeStats);
1391
- const buttonsHtml = tabs.map(([tabId, title]) => `<button class="tab-btn" data-tab="${tabId}">${escapeHtml(title)}</button>${REPORT_EOL}`).join("");
1392
- const sectionsHtml = tabs.map(([tabId, , html]) => `<section id="${tabId}" class="tab">${html}</section>${REPORT_EOL}`).join("");
1393
- const chartDataJson = JSON.stringify(buildDotnetChartData(nodeStats));
1710
+ export function buildDotnetHtmlReport(nodeStats, localReportInput = emptyLocalReportInput()) {
1711
+ const tabs = buildDotnetHtmlTabs(nodeStats, localReportInput);
1712
+ const buttonsHtml = tabs
1713
+ .map(([tabId, title]) => `<button class="tab-btn" data-tab="${escapeHtml(tabId)}">${escapeHtml(title)}</button>${REPORT_EOL}`)
1714
+ .join("");
1715
+ const sectionsHtml = tabs
1716
+ .map(([tabId, , html]) => `<section id="${escapeHtml(tabId)}" class="tab">${html}</section>${REPORT_EOL}`)
1717
+ .join("");
1718
+ const chartDataJson = escapeJsonForHtmlScript(JSON.stringify(buildDotnetChartData(nodeStats, localReportInput)));
1394
1719
  const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
1395
1720
  const template = `<!doctype html>
1396
1721
  <html lang="en">
1397
1722
  <head>
1398
1723
  <meta charset="utf-8" />
1399
1724
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1725
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'" />
1400
1726
  <title>LoadStrike Report</title>
1401
1727
  <style>
1402
- :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)}
1403
- 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)}
1728
+ :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;--chartLegendText:#e6edf3;--shadow:0 10px 24px rgba(15,23,42,.1)}
1729
+ 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);--chartLegendText:#e6edf3;--shadow:0 10px 24px rgba(0,0,0,.2)}
1404
1730
  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}
1405
1731
  .wrap{max-width:1440px;margin:0 auto;padding:20px}
1406
- .report-brand{display:flex;align-items:center;gap:16px;margin:0 0 10px 0;overflow:visible;padding-top:8px;flex-wrap:wrap}
1732
+ .report-brand{display:flex;align-items:center;gap:16px;margin:0 0 10px;padding-top:8px;overflow:visible;flex-wrap:wrap}
1407
1733
  .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}
1408
- .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}
1734
+ .report-logo{width:100%;height:100%;object-fit:contain;object-position:left top;border:0;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}
1409
1735
  body[data-theme='dark'] .report-logo{--report-logo-scale:1.175}
1410
1736
  .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}
1411
1737
  .theme-toggle-report:hover{border-color:var(--accent);transform:translateY(-1px)}
1412
- .theme-toggle-report span{line-height:1;pointer-events:none}
1413
- h1{margin:0 0 10px 0;font-size:28px;letter-spacing:.2px}
1414
- h2{margin:0 0 12px 0;font-size:18px}
1415
- h3{margin:0 0 10px 0;font-size:15px;color:var(--text)}
1738
+ h1{margin:0 0 10px;font-size:28px;letter-spacing:.2px}
1739
+ h2{margin:0 0 12px;font-size:18px}
1740
+ h3{margin:0 0 10px;font-size:15px;color:var(--text)}
1416
1741
  .meta{display:flex;gap:14px;flex-wrap:wrap;color:var(--muted);font-size:13px;margin-bottom:16px}
1417
1742
  .meta span{background:var(--chip);border:1px solid var(--line);padding:6px 10px;border-radius:999px}
1418
1743
  .report-layout{display:grid;grid-template-columns:280px minmax(0,1fr);gap:14px;align-items:start}
1419
1744
  .tabs-pane{position:sticky;top:12px;max-height:calc(100vh - 24px);overflow:auto;overscroll-behavior:contain;padding-right:4px;cursor:grab}
1420
1745
  .tabs-pane.panning{cursor:grabbing;user-select:none}
1421
- .tabs{display:flex;flex-direction:column;gap:8px;margin:12px 0 14px 0}
1746
+ .tabs{display:flex;flex-direction:column;gap:8px;margin:12px 0 14px}
1422
1747
  .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%}
1423
1748
  .tab-btn:hover{border-color:var(--accent);background:var(--chip)}
1424
1749
  .tab-btn.active{background:linear-gradient(180deg,#2f66db 0,#2754b8 100%);border-color:#3f73e0}
@@ -1437,22 +1762,19 @@ th{background:var(--panelAlt);position:sticky;top:0;z-index:1}
1437
1762
  .value-fail{color:var(--fail)}
1438
1763
  .chart-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:12px}
1439
1764
  .chart-card{padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--chartPanel)}
1440
- .chart-canvas{width:100%;height:260px;display:block}
1441
- .correlation-chart-grid{grid-template-columns:repeat(auto-fit,minmax(420px,720px));justify-content:center}
1765
+ .correlation-chart-grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,420px),720px));justify-content:center}
1442
1766
  .correlation-chart-card{max-width:720px;width:100%}
1443
- .correlation-chart-card .chart-canvas{height:320px}
1444
1767
  .chart-card h3,.chart-card p{color:var(--chartLegendText)}
1445
1768
  .table-wrap{overflow:auto;max-height:70vh}
1446
- @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}}
1769
+ ${REPORT_SVG_CSS}
1770
+ @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}.correlation-chart-grid{grid-template-columns:1fr;justify-content:stretch}.correlation-chart-card{max-width:none}.stat-value{font-size:18px}}
1447
1771
  </style>
1448
1772
  </head>
1449
1773
  <body data-theme="light">
1450
1774
  <div class="wrap">
1451
1775
  <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>
1452
1776
  <div class="report-brand">
1453
- <div class="report-logo-slot">
1454
- <img src="__LOGO_LIGHT__" alt="LoadStrike logo" class="report-logo" data-report-logo data-logo-light="__LOGO_LIGHT__" data-logo-dark="__LOGO_DARK__" />
1455
- </div>
1777
+ <div class="report-logo-slot"><img src="__LOGO_LIGHT__" alt="LoadStrike logo" class="report-logo" data-report-logo data-logo-light="__LOGO_LIGHT__" data-logo-dark="__LOGO_DARK__" /></div>
1456
1778
  <h1>LoadStrike Report</h1>
1457
1779
  </div>
1458
1780
  <div class="meta">
@@ -1462,44 +1784,18 @@ th{background:var(--panelAlt);position:sticky;top:0;z-index:1}
1462
1784
  <span>Duration: <strong>__DURATION__</strong></span>
1463
1785
  </div>
1464
1786
  <div class="report-layout">
1465
- <aside class="tabs-pane" id="tab-pane">
1466
- <div class="tabs">
1467
- __BUTTONS__</div>
1468
- </aside>
1787
+ <aside class="tabs-pane" id="tab-pane"><div class="tabs">
1788
+ __BUTTONS__</div></aside>
1469
1789
  <div class="tabs-content">
1470
1790
  __SECTIONS__</div>
1471
1791
  </div>
1472
1792
  </div>
1793
+ <div class="chart-modal" data-chart-fullscreen-overlay role="dialog" aria-modal="true" aria-label="Expanded LoadStrike chart" aria-hidden="true">
1794
+ <div class="chart-modal-panel"><div class="chart-modal-head"><button type="button" class="chart-modal-close" data-chart-modal-close aria-label="Close expanded chart">Close</button></div><div data-chart-modal-panel></div></div>
1795
+ </div>
1473
1796
  <script>
1474
1797
  const reportCharts=__CHART_DATA__;
1475
- const btns=[...document.querySelectorAll('.tab-btn')];
1476
- const tabSections=[...document.querySelectorAll('.tab')];
1477
- const tabsPane=document.getElementById('tab-pane');
1478
- const linePalette=['#38bdf8','#22c55e','#f59e0b','#a855f7','#f43f5e','#14b8a6','#eab308','#818cf8','#06b6d4','#84cc16'];
1479
- const reportThemeKey='loadstrike-report-theme';
1480
- const reportThemeToggle=document.querySelector('[data-report-theme-toggle]');
1481
- const reportLogo=document.querySelector('[data-report-logo]');
1482
- 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');}}
1483
- function show(id){btns.forEach(b=>b.classList.toggle('active',b.dataset.tab===id));tabSections.forEach(t=>t.classList.toggle('active',t.id===id));}
1484
- function formatMetric(v){if(!Number.isFinite(v))return '0';return Math.abs(v)>=100?v.toFixed(0):v.toFixed(2);}
1485
- function setupCanvas(canvas){const dpr=window.devicePixelRatio||1;const rect=canvas.getBoundingClientRect();const w=Math.max(1,Math.round(rect.width||canvas.clientWidth||320));const h=Math.max(1,Math.round(rect.height||canvas.clientHeight||220));canvas.width=Math.max(1,Math.round(w*dpr));canvas.height=Math.max(1,Math.round(h*dpr));const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);return {ctx,w,h};}
1486
- function drawNoData(ctx,w,h,msg){ctx.fillStyle='#9fb0c3';ctx.font='13px Segoe UI';ctx.textAlign='center';ctx.fillText(msg,w/2,h/2);}
1487
- 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);}}
1488
- 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;}}
1489
- 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;}}}
1490
- 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);}
1491
- 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:[]};}}
1492
- 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:[]};}}
1493
- 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};}
1494
- 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);});}
1495
- 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);});}
1496
- 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');});}
1497
- function renderAllCharts(){renderCharts();renderUngroupedCorrelationCharts();renderGroupedCorrelationCharts();}
1498
- const storedReportTheme=(()=>{try{return localStorage.getItem(reportThemeKey);}catch{return null;}})();
1499
- applyReportTheme(storedReportTheme==='dark'?'dark':'light');
1500
- if(reportThemeToggle){reportThemeToggle.addEventListener('click',()=>{const next=document.body.getAttribute('data-theme')==='dark'?'light':'dark';applyReportTheme(next);try{localStorage.setItem(reportThemeKey,next);}catch{}renderAllCharts();});}
1501
- btns.forEach(b=>b.addEventListener('click',()=>{show(b.dataset.tab);requestAnimationFrame(renderAllCharts);}));
1502
- if(btns.length>0){show(btns[0].dataset.tab);}renderAllCharts();initPanePan();window.addEventListener('resize',renderAllCharts);
1798
+ ${REPORT_SVG_SCRIPT.trimStart()}
1503
1799
  </script>
1504
1800
  </body>
1505
1801
  </html>`;