@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.
@@ -8,6 +8,9 @@ exports.buildDotnetHtmlReport = buildDotnetHtmlReport;
8
8
  const node_fs_1 = require("node:fs");
9
9
  const node_path_1 = require("node:path");
10
10
  const node_url_1 = require("node:url");
11
+ const report_history_js_1 = require("./report-history.js");
12
+ const local_report_input_js_1 = require("./local-report-input.js");
13
+ const reporting_svg_js_1 = require("./reporting-svg.js");
11
14
  const REPORT_EOL = "\n";
12
15
  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>";
13
16
  const REPORT_LOGO_CACHE = new Map();
@@ -45,8 +48,12 @@ function reportValue(source, ...keys) {
45
48
  }
46
49
  const record = source;
47
50
  for (const key of keys) {
48
- if (record[key] !== undefined && record[key] !== null) {
49
- return record[key];
51
+ const descriptor = Object.getOwnPropertyDescriptor(record, key);
52
+ if (descriptor
53
+ && "value" in descriptor
54
+ && descriptor.value !== undefined
55
+ && descriptor.value !== null) {
56
+ return descriptor.value;
50
57
  }
51
58
  }
52
59
  return undefined;
@@ -291,6 +298,40 @@ function loadStrikeNodeTypeTag(value) {
291
298
  return asInt(value);
292
299
  }
293
300
  }
301
+ function loadStrikeNodeTypeLabel(value) {
302
+ if (value && typeof value === "object" && !Array.isArray(value)) {
303
+ const record = value;
304
+ if (record.tag !== undefined || record.Tag !== undefined) {
305
+ return ["Single node", "Coordinator", "Agent"][loadStrikeNodeTypeTag(value)]
306
+ ?? "Unknown";
307
+ }
308
+ return "Unknown";
309
+ }
310
+ const text = asString(value).trim();
311
+ return {
312
+ SingleNode: "Single node",
313
+ Coordinator: "Coordinator",
314
+ Agent: "Agent",
315
+ "0": "Single node",
316
+ "1": "Coordinator",
317
+ "2": "Agent"
318
+ }[text] ?? (text || "Unknown");
319
+ }
320
+ function loadStrikeRuntimeVersionLabel(nodeInfo) {
321
+ for (const [camelKey, pascalKey] of [
322
+ ["runtimeVersion", "RuntimeVersion"],
323
+ ["pythonVersion", "PythonVersion"],
324
+ ["nodeVersion", "NodeVersion"],
325
+ ["dotNetVersion", "DotNetVersion"],
326
+ ["engineVersion", "EngineVersion"]
327
+ ]) {
328
+ const value = asString(reportValue(nodeInfo, camelKey, pascalKey)).trim();
329
+ if (value) {
330
+ return value;
331
+ }
332
+ }
333
+ return "Unavailable";
334
+ }
294
335
  function parseUtcDate(value) {
295
336
  if (value instanceof Date && !Number.isNaN(value.getTime())) {
296
337
  return value;
@@ -329,7 +370,22 @@ function formatDotnetDateTime(value) {
329
370
  return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${fraction}Z`;
330
371
  }
331
372
  function escapeJsonForHtmlScript(value) {
332
- return value.split("</").join("<\\/").split("<!--").join("<\\!--");
373
+ return value.replace(/[<>&\u2028\u2029]/g, (character) => {
374
+ switch (character) {
375
+ case "<":
376
+ return "\\u003c";
377
+ case ">":
378
+ return "\\u003e";
379
+ case "&":
380
+ return "\\u0026";
381
+ case "\u2028":
382
+ return "\\u2028";
383
+ case "\u2029":
384
+ return "\\u2029";
385
+ default:
386
+ return character;
387
+ }
388
+ });
333
389
  }
334
390
  function buildReportLogoDataUri(resourceName) {
335
391
  const cached = REPORT_LOGO_CACHE.get(resourceName);
@@ -405,6 +461,9 @@ function buildDotnetTableHtml(rows, wrapInCard = true) {
405
461
  return parts.join("");
406
462
  }
407
463
  function formatReportTableHeader(header) {
464
+ if (header === "UnmatchedDestination") {
465
+ return "Unmatched Destination";
466
+ }
408
467
  if (header === "LatencyStdDev") {
409
468
  return "LatencyStdDev (ms)";
410
469
  }
@@ -502,21 +561,14 @@ function buildFailedEventRows(plugins) {
502
561
  }
503
562
  function tryParseReportFloat(value) {
504
563
  const parsed = Number.parseFloat(asString(value));
505
- return Number.isFinite(parsed) ? parsed : undefined;
506
- }
507
- function meanNumeric(values) {
508
- if (!values.length) {
509
- return 0;
510
- }
511
- return values.reduce((sum, value) => sum + value, 0) / values.length;
564
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
512
565
  }
513
- function percentileNumeric(values, percentileValue) {
514
- if (!values.length) {
566
+ function percentileFromOrdered(values, percentileValue) {
567
+ if (values.length === 0) {
515
568
  return 0;
516
569
  }
517
- const ordered = [...values].sort((left, right) => left - right);
518
- const index = Math.min(Math.max(Math.ceil(percentileValue * ordered.length) - 1, 0), ordered.length - 1);
519
- return ordered[index];
570
+ const index = Math.min(Math.max(Math.ceil(percentileValue * values.length) - 1, 0), values.length - 1);
571
+ return values[index];
520
572
  }
521
573
  function buildGroupedCorrelationChartPayloads(rows) {
522
574
  const grouped = new Map();
@@ -534,33 +586,48 @@ function buildGroupedCorrelationChartPayloads(rows) {
534
586
  }
535
587
  return [...grouped.values()]
536
588
  .filter((group) => group.rows.some((row) => tryParseReportFloat(row.LatencyP50Ms) !== undefined
589
+ || tryParseReportFloat(row.LatencyP75Ms) !== undefined
537
590
  || tryParseReportFloat(row.LatencyP80Ms) !== undefined
538
591
  || tryParseReportFloat(row.LatencyP85Ms) !== undefined
539
592
  || tryParseReportFloat(row.LatencyP90Ms) !== undefined
540
593
  || tryParseReportFloat(row.LatencyP95Ms) !== undefined
541
- || tryParseReportFloat(row.LatencyP99Ms) !== undefined))
594
+ || tryParseReportFloat(row.LatencyP99Ms) !== undefined
595
+ || tryParseReportFloat(row.LatencyMaxMs ?? row.LatencyMax) !== undefined))
542
596
  .sort((left, right) => left.key[0] === right.key[0]
543
597
  ? left.key[1].localeCompare(right.key[1])
544
598
  : left.key[0].localeCompare(right.key[0]))
545
599
  .map((group, index) => ({
546
600
  title: `${group.key[0]}: ${group.key[1]}`,
547
- subtitle: group.rows.length > 1 ? `Averaged across ${group.rows.length} grouped rows.` : "Single grouped row.",
601
+ subtitle: group.rows.length > 1
602
+ ? `${group.rows.length} distinct scenario and destination rows.`
603
+ : "Single grouped row.",
548
604
  chart: {
549
- labels: ["P50", "P80", "P85", "P90", "P95", "P99"],
550
- series: [
551
- {
552
- name: "Latency",
553
- color: ["#38bdf8", "#22c55e", "#f59e0b", "#a855f7", "#f43f5e", "#06b6d4", "#14b8a6", "#eab308", "#818cf8", "#84cc16"][index % 10],
605
+ labels: ["P50", "P75", "P80", "P85", "P90", "P95", "P99", "Max"],
606
+ series: [...group.rows]
607
+ .sort((left, right) => {
608
+ const leftIdentity = `${readReportRowText(left, "Scenario")}|${readReportRowText(left, "Destination")}`;
609
+ const rightIdentity = `${readReportRowText(right, "Scenario")}|${readReportRowText(right, "Destination")}`;
610
+ return leftIdentity.localeCompare(rightIdentity);
611
+ })
612
+ .map((row, rowIndex) => {
613
+ const scenario = readReportRowText(row, "Scenario") || "<scenario unavailable>";
614
+ const destination = readReportRowText(row, "Destination") || "<destination unavailable>";
615
+ const name = `${scenario} | ${destination}${rowIndex > 0 ? ` (row ${rowIndex + 1})` : ""}`;
616
+ return {
617
+ name,
618
+ color: ["#38bdf8", "#22c55e", "#f59e0b", "#a855f7", "#f43f5e", "#06b6d4", "#14b8a6", "#eab308", "#818cf8", "#84cc16"][(index + rowIndex) % 10],
554
619
  values: [
555
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP50Ms)).filter((value) => value !== undefined)),
556
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP80Ms)).filter((value) => value !== undefined)),
557
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP85Ms)).filter((value) => value !== undefined)),
558
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP90Ms)).filter((value) => value !== undefined)),
559
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP95Ms)).filter((value) => value !== undefined)),
560
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP99Ms)).filter((value) => value !== undefined))
620
+ tryParseReportFloat(row.LatencyP50Ms) ?? null,
621
+ tryParseReportFloat(row.LatencyP75Ms) ?? null,
622
+ tryParseReportFloat(row.LatencyP80Ms) ?? null,
623
+ tryParseReportFloat(row.LatencyP85Ms) ?? null,
624
+ tryParseReportFloat(row.LatencyP90Ms) ?? null,
625
+ tryParseReportFloat(row.LatencyP95Ms) ?? null,
626
+ tryParseReportFloat(row.LatencyP99Ms) ?? null,
627
+ tryParseReportFloat(row.LatencyMaxMs ?? row.LatencyMax) ?? null
561
628
  ]
562
- }
563
- ]
629
+ };
630
+ })
564
631
  }
565
632
  }));
566
633
  }
@@ -583,7 +650,6 @@ function buildUngroupedCorrelationChartPayload(rows) {
583
650
  grouped.set(key, { scenario, destination, statusCode, latencies: [latency] });
584
651
  }
585
652
  }
586
- const nameCount = new Map();
587
653
  const series = [...grouped.values()]
588
654
  .sort((left, right) => {
589
655
  const leftKey = `${left.scenario}|${left.destination}|${left.statusCode}`;
@@ -591,25 +657,24 @@ function buildUngroupedCorrelationChartPayload(rows) {
591
657
  return leftKey.localeCompare(rightKey);
592
658
  })
593
659
  .map((row, index) => {
594
- const baseName = row.statusCode.trim() || "status";
595
- const key = baseName.toLowerCase();
596
- const count = nameCount.get(key) ?? 0;
597
- nameCount.set(key, count + 1);
660
+ const orderedLatencies = [...row.latencies].sort((left, right) => left - right);
598
661
  return {
599
- name: count === 0 ? baseName : `${baseName}-${count + 1}`,
662
+ name: `${row.scenario} | ${row.destination} | status ${row.statusCode}`,
600
663
  color: ["#38bdf8", "#22c55e", "#f59e0b", "#a855f7", "#f43f5e", "#06b6d4", "#14b8a6", "#eab308", "#818cf8", "#84cc16"][index % 10],
601
664
  values: [
602
- percentileNumeric(row.latencies, 0.50),
603
- percentileNumeric(row.latencies, 0.80),
604
- percentileNumeric(row.latencies, 0.85),
605
- percentileNumeric(row.latencies, 0.90),
606
- percentileNumeric(row.latencies, 0.95),
607
- percentileNumeric(row.latencies, 0.99)
665
+ percentileFromOrdered(orderedLatencies, 0.50),
666
+ percentileFromOrdered(orderedLatencies, 0.75),
667
+ percentileFromOrdered(orderedLatencies, 0.80),
668
+ percentileFromOrdered(orderedLatencies, 0.85),
669
+ percentileFromOrdered(orderedLatencies, 0.90),
670
+ percentileFromOrdered(orderedLatencies, 0.95),
671
+ percentileFromOrdered(orderedLatencies, 0.99),
672
+ orderedLatencies[orderedLatencies.length - 1]
608
673
  ]
609
674
  };
610
675
  });
611
676
  return {
612
- labels: ["P50", "P80", "P85", "P90", "P95", "P99"],
677
+ labels: ["P50", "P75", "P80", "P85", "P90", "P95", "P99", "Max"],
613
678
  series
614
679
  };
615
680
  }
@@ -745,8 +810,34 @@ function hasMeasurementData(measurement) {
745
810
  asString(reportValue(code, "statusCode", "StatusCode")).trim().length > 0 ||
746
811
  asString(reportValue(code, "message", "Message")).trim().length > 0);
747
812
  }
748
- function appendChartCard(parts, id, title) {
749
- appendReportLine(parts, `<div class="chart-card"><h3>${escapeHtml(title)}</h3><canvas id="${escapeHtml(id)}" class="chart-canvas"></canvas></div>`);
813
+ function appendChartCard(parts, id, title, source, kind, unit, seriesLabel, compatibilityClass = "", extraHostAttributes = "") {
814
+ const cardClass = compatibilityClass.includes("correlation")
815
+ ? "chart-card correlation-chart-card"
816
+ : "chart-card";
817
+ appendReportLine(parts, `<div class="${cardClass}" data-chart-title="${escapeHtml(title)}"><h3>${escapeHtml(title)}</h3>`);
818
+ appendReportLine(parts, "<div class=\"chart-actions\" aria-label=\"Chart controls\">");
819
+ for (const [action, label] of [
820
+ ["zoom-in", "Zoom in"],
821
+ ["zoom-out", "Zoom out"],
822
+ ["pan-left", "Pan left"],
823
+ ["pan-right", "Pan right"],
824
+ ["reset", "Reset"],
825
+ ["expand", "Expand"]
826
+ ]) {
827
+ appendReportLine(parts, `<button type="button" class="chart-action" data-chart-action="${action}" aria-label="${label}">${label}</button>`);
828
+ }
829
+ appendReportLine(parts, "</div>");
830
+ 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}` : ""}>`);
831
+ 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>`);
832
+ appendReportLine(parts, "<div class=\"chart-tooltip\" data-chart-tooltip role=\"status\" aria-live=\"polite\" hidden></div>");
833
+ appendReportLine(parts, "<div class=\"chart-legend\" data-chart-legend aria-label=\"Chart legend\"></div>");
834
+ appendReportLine(parts, "</div></div>");
835
+ }
836
+ function appendChartCollectionTools(parts) {
837
+ appendReportLine(parts, "<div class=\"chart-collection-tools\">");
838
+ appendReportLine(parts, "<label>Search charts<input type=\"search\" data-chart-search placeholder=\"Filter by chart title\" /></label>");
839
+ 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>");
840
+ appendReportLine(parts, "</div>");
750
841
  }
751
842
  function hasChartPointData(points) {
752
843
  return Array.isArray(points) && points.length > 0;
@@ -757,7 +848,7 @@ function hasPieChartData(points) {
757
848
  function hasLatencyTrendData(chart) {
758
849
  const labels = reportArray(chart, "labels", "Labels");
759
850
  const series = reportArray(chart, "series", "Series");
760
- return labels.length > 0 && series.some((entry) => reportArray(entry, "values", "Values").some((value) => Number.isFinite(asFloat(value))));
851
+ return labels.length > 0 && series.some((entry) => reportArray(entry, "values", "Values").some((value) => value !== null && value !== undefined && Number.isFinite(Number(value)) && Number(value) >= 0));
761
852
  }
762
853
  function hasNonEmptyHints(plugin) {
763
854
  return reportArray(plugin, "hints", "Hints").some((hint) => asString(hint).trim().length > 0);
@@ -900,11 +991,169 @@ function buildDotnetStatusCodeClassChart(scenarios) {
900
991
  { label: "Other", value: buckets.Other, color: "#8b5cf6" }
901
992
  ].filter((entry) => entry.value > 0);
902
993
  }
903
- function buildDotnetChartData(nodeStats) {
994
+ function genuineCombinedMeasurement(source) {
995
+ const value = reportValue(source, "allMeasurement", "AllMeasurement");
996
+ return value && typeof value === "object" && !Array.isArray(value)
997
+ ? value
998
+ : undefined;
999
+ }
1000
+ function measurementHasObservations(measurement) {
1001
+ if (!measurement) {
1002
+ return false;
1003
+ }
1004
+ const request = reportObject(measurement, "request", "Request");
1005
+ if (asInt(reportValue(request, "count", "Count")) > 0) {
1006
+ return true;
1007
+ }
1008
+ const count64 = asString(reportValue(measurement, "count64", "Count64")).trim();
1009
+ return /^\d+$/.test(count64) && /[1-9]/.test(count64);
1010
+ }
1011
+ function validMeasurementLatency(measurement, camelKey, pascalKey) {
1012
+ if (!measurement) {
1013
+ return null;
1014
+ }
1015
+ if (!measurementHasObservations(measurement)) {
1016
+ return null;
1017
+ }
1018
+ const value = Number(reportValue(reportObject(measurement, "latency", "Latency"), camelKey, pascalKey));
1019
+ return Number.isFinite(value) && value >= 0 ? value : null;
1020
+ }
1021
+ function measurementUsesApproximateHistogram(measurement) {
1022
+ if (!measurement) {
1023
+ return false;
1024
+ }
1025
+ const mode = asString(reportValue(measurement, "distributionMode", "DistributionMode")).toLocaleLowerCase();
1026
+ const maxRelativeError = asFloat(reportValue(measurement, "maxRelativeError", "MaxRelativeError"));
1027
+ return maxRelativeError > 0
1028
+ || mode.includes("quantized")
1029
+ || mode.includes("approx");
1030
+ }
1031
+ function buildOutcomeLatencySeries(scenarios, percentileName, camelKey, pascalKey) {
1032
+ return [
1033
+ {
1034
+ outcome: "All",
1035
+ color: "#8b5cf6",
1036
+ measurement: (scenario) => genuineCombinedMeasurement(scenario)
1037
+ },
1038
+ {
1039
+ outcome: "OK",
1040
+ color: "#18a957",
1041
+ measurement: (scenario) => reportObject(scenario, "ok", "Ok")
1042
+ },
1043
+ {
1044
+ outcome: "Failed",
1045
+ color: "#d14343",
1046
+ measurement: (scenario) => reportObject(scenario, "fail", "Fail")
1047
+ }
1048
+ ].map((candidate) => {
1049
+ const measurements = scenarios.map(candidate.measurement);
1050
+ const values = measurements.map((measurement) => validMeasurementLatency(measurement, camelKey, pascalKey));
1051
+ const approximate = measurements.some((measurement, index) => values[index] !== null && measurementUsesApproximateHistogram(measurement));
1052
+ return {
1053
+ name: `${candidate.outcome} ${percentileName}${approximate ? " (approx.)" : ""}`,
1054
+ color: candidate.color,
1055
+ values
1056
+ };
1057
+ }).filter((series) => series.values.some((value) => value !== null));
1058
+ }
1059
+ function buildHistoryChartData(points) {
1060
+ if (!points.length) {
1061
+ return {
1062
+ cumulativeRequestsHistory: { labels: [], series: [] },
1063
+ achievedRequestRateHistory: { labels: [], series: [] },
1064
+ cumulativeBytesHistory: { labels: [], series: [] },
1065
+ historyLatencyCharts: []
1066
+ };
1067
+ }
1068
+ const ordered = [...points].sort((left, right) => left.elapsedSeconds - right.elapsedSeconds);
1069
+ const labels = ordered.map((point) => `${Number(point.elapsedSeconds.toFixed(3))}s`);
1070
+ const scenarioNames = [...new Set(ordered.flatMap((point) => [...point.scenarios]
1071
+ .sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0))
1072
+ .map((scenario) => scenario.scenarioName)))];
1073
+ const palette = ["#38bdf8", "#22c55e", "#f59e0b", "#a855f7", "#f43f5e", "#14b8a6", "#eab308", "#818cf8", "#06b6d4", "#84cc16"];
1074
+ const findScenario = (point, scenarioName) => point.scenarios.find((scenario) => scenario.scenarioName === scenarioName);
1075
+ const totalCount = (scenario) => scenario?.all?.count ?? ((scenario?.ok?.count ?? 0) + (scenario?.failed?.count ?? 0));
1076
+ const totalBytes = (scenario) => scenario?.all?.bytes ?? ((scenario?.ok?.bytes ?? 0) + (scenario?.failed?.bytes ?? 0));
1077
+ const cumulativeRequestsHistory = {
1078
+ labels,
1079
+ series: scenarioNames.map((scenarioName, index) => ({
1080
+ name: scenarioName,
1081
+ color: palette[index % palette.length],
1082
+ values: ordered.map((point) => {
1083
+ const scenario = findScenario(point, scenarioName);
1084
+ return scenario ? totalCount(scenario) : null;
1085
+ })
1086
+ }))
1087
+ };
1088
+ const cumulativeBytesHistory = {
1089
+ labels,
1090
+ series: scenarioNames.map((scenarioName, index) => ({
1091
+ name: scenarioName,
1092
+ color: palette[index % palette.length],
1093
+ values: ordered.map((point) => {
1094
+ const scenario = findScenario(point, scenarioName);
1095
+ return scenario ? totalBytes(scenario) : null;
1096
+ })
1097
+ }))
1098
+ };
1099
+ const ratesByScenario = new Map((0, report_history_js_1.deriveScenarioRates)(ordered).map((series) => [series.scenarioName, series.values]));
1100
+ const achievedRequestRateHistory = {
1101
+ labels,
1102
+ series: scenarioNames.map((scenarioName, index) => ({
1103
+ name: scenarioName,
1104
+ color: palette[index % palette.length],
1105
+ values: ratesByScenario.get(scenarioName) ?? labels.map(() => null)
1106
+ }))
1107
+ };
1108
+ const outcomes = [
1109
+ ["All", (scenario) => scenario.all],
1110
+ ["OK", (scenario) => scenario.ok],
1111
+ ["Failed", (scenario) => scenario.failed]
1112
+ ];
1113
+ const percentiles = [
1114
+ ["P50", "percent50Ms"],
1115
+ ["P75", "percent75Ms"],
1116
+ ["P95", "percent95Ms"],
1117
+ ["P99", "percent99Ms"]
1118
+ ];
1119
+ const historyLatencyCharts = scenarioNames.map((scenarioName, scenarioIndex) => {
1120
+ let seriesIndex = 0;
1121
+ const series = outcomes.flatMap(([outcomeName, measurementSelector]) => percentiles.map(([percentileName, percentileKey]) => {
1122
+ const measurements = ordered.map((point) => {
1123
+ const scenario = findScenario(point, scenarioName);
1124
+ return scenario ? measurementSelector(scenario) : undefined;
1125
+ });
1126
+ const values = measurements.map((measurement) => {
1127
+ const value = measurement?.[percentileKey];
1128
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
1129
+ });
1130
+ const approximate = measurements.some((measurement, index) => values[index] !== null && measurement?.approximate === true);
1131
+ return {
1132
+ name: `${outcomeName} ${percentileName}${approximate ? " (approx.)" : ""}`,
1133
+ color: palette[(scenarioIndex + seriesIndex++) % palette.length],
1134
+ values
1135
+ };
1136
+ }).filter((series) => series.values.some((value) => value !== null)));
1137
+ return {
1138
+ title: `Cumulative Latency - ${scenarioName}`,
1139
+ chart: { labels, series }
1140
+ };
1141
+ }).filter((item) => item.chart.series.length > 0);
1142
+ return {
1143
+ cumulativeRequestsHistory,
1144
+ achievedRequestRateHistory,
1145
+ cumulativeBytesHistory,
1146
+ historyLatencyCharts
1147
+ };
1148
+ }
1149
+ function buildDotnetChartData(nodeStats, localReportInput) {
904
1150
  const scenarios = reportScenarios(nodeStats);
905
- const combinedScenarios = scenarios
906
- .map((scenario) => ({ scenario, measurement: combinedMeasurement(scenario) }))
1151
+ const genuineCombinedScenarios = scenarios
1152
+ .map((scenario) => ({ scenario, measurement: genuineCombinedMeasurement(scenario) }))
907
1153
  .filter((item) => item.measurement !== undefined);
1154
+ const history = localReportInput.history.status === "available"
1155
+ ? buildHistoryChartData(localReportInput.history.points)
1156
+ : buildHistoryChartData([]);
908
1157
  return {
909
1158
  overallOutcome: [
910
1159
  { label: "OK", value: reportTotalOkCount(nodeStats, scenarios), color: "#18a957" },
@@ -915,11 +1164,15 @@ function buildDotnetChartData(nodeStats) {
915
1164
  value: reportRequestCountValue(scenario),
916
1165
  color: "#3b82f6"
917
1166
  })),
918
- scenarioP95Latency: combinedScenarios.map(({ scenario, measurement }) => ({
1167
+ scenarioP95Latency: genuineCombinedScenarios.map(({ scenario, measurement }) => ({
919
1168
  label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
920
1169
  value: asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent95", "Percent95")),
921
1170
  color: "#8b5cf6"
922
1171
  })),
1172
+ scenarioP95LatencyByOutcome: {
1173
+ labels: scenarios.map((scenario) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
1174
+ series: buildOutcomeLatencySeries(scenarios, "P95", "percent95", "Percent95")
1175
+ },
923
1176
  scenarioRps: scenarios.map((scenario) => ({
924
1177
  label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
925
1178
  value: reportDurationSeconds(reportDurationValue(scenario)) <= 0
@@ -941,17 +1194,18 @@ function buildDotnetChartData(nodeStats) {
941
1194
  })),
942
1195
  statusCodeClasses: buildDotnetStatusCodeClassChart(scenarios),
943
1196
  scenarioLatencyTrend: {
944
- labels: combinedScenarios.map(({ scenario }) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
1197
+ labels: scenarios.map((scenario) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
945
1198
  series: [
946
- { name: "P50", color: "#38bdf8", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent50", "Percent50"))) },
947
- { name: "P75", color: "#22c55e", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent75", "Percent75"))) },
948
- { name: "P95", color: "#f59e0b", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent95", "Percent95"))) },
949
- { name: "P99", color: "#f43f5e", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent99", "Percent99"))) }
1199
+ ...buildOutcomeLatencySeries(scenarios, "P50", "percent50", "Percent50"),
1200
+ ...buildOutcomeLatencySeries(scenarios, "P75", "percent75", "Percent75"),
1201
+ ...buildOutcomeLatencySeries(scenarios, "P95", "percent95", "Percent95"),
1202
+ ...buildOutcomeLatencySeries(scenarios, "P99", "percent99", "Percent99")
950
1203
  ]
951
- }
1204
+ },
1205
+ ...history
952
1206
  };
953
1207
  }
954
- function buildDotnetSummaryHtml(nodeStats) {
1208
+ function buildDotnetSummaryHtml(nodeStats, localReportInput) {
955
1209
  const scenarios = reportScenarios(nodeStats);
956
1210
  const allRequests = reportTotalRequestCount(nodeStats, scenarios);
957
1211
  const allOk = reportTotalOkCount(nodeStats, scenarios);
@@ -973,11 +1227,19 @@ function buildDotnetSummaryHtml(nodeStats) {
973
1227
  ? scenario
974
1228
  : winner;
975
1229
  }, undefined);
976
- const latencyRows = scenarios.flatMap((scenario) => [
977
- buildSummaryLatencyRow(asString(reportValue(scenario, "scenarioName", "ScenarioName")), "OK", reportObject(scenario, "ok", "Ok")),
978
- buildSummaryLatencyRow(asString(reportValue(scenario, "scenarioName", "ScenarioName")), "FAIL", reportObject(scenario, "fail", "Fail"))
979
- ]);
980
- const chartData = buildDotnetChartData(nodeStats);
1230
+ const latencyRows = [];
1231
+ for (const scenario of scenarios) {
1232
+ const scenarioName = asString(reportValue(scenario, "scenarioName", "ScenarioName"));
1233
+ const ok = reportObject(scenario, "ok", "Ok");
1234
+ const fail = reportObject(scenario, "fail", "Fail");
1235
+ if (measurementHasObservations(ok)) {
1236
+ latencyRows.push(buildSummaryLatencyRow(scenarioName, "OK", ok));
1237
+ }
1238
+ if (measurementHasObservations(fail)) {
1239
+ latencyRows.push(buildSummaryLatencyRow(scenarioName, "FAIL", fail));
1240
+ }
1241
+ }
1242
+ const chartData = buildDotnetChartData(nodeStats, localReportInput);
981
1243
  const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
982
1244
  const nodeInfo = reportObject(nodeStats, "nodeInfo", "NodeInfo");
983
1245
  const totalBytes = reportTotalBytes(nodeStats, scenarios);
@@ -990,39 +1252,62 @@ function buildDotnetSummaryHtml(nodeStats) {
990
1252
  appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Duration</div><div class="stat-value">${escapeHtml(formatDotnetTimeSpan(reportDurationValue(nodeStats)))}</div></div>`);
991
1253
  appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Total Bytes</div><div class="stat-value">${totalBytes}</div></div>`);
992
1254
  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>`);
993
- appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Node</div><div class="stat-value">${loadStrikeNodeTypeTag(reportValue(nodeInfo, "nodeType", "NodeType"))}</div></div>`);
1255
+ appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Node</div><div class="stat-value">${escapeHtml(loadStrikeNodeTypeLabel(reportValue(nodeInfo, "nodeType", "NodeType")))}</div></div>`);
994
1256
  appendReportLine(parts, "</div>");
1257
+ const hasApproximateLatencySeries = [
1258
+ ...reportArray(reportObject(chartData, "scenarioP95LatencyByOutcome"), "series"),
1259
+ ...reportArray(reportObject(chartData, "scenarioLatencyTrend"), "series"),
1260
+ ...reportArray(chartData, "historyLatencyCharts")
1261
+ .flatMap((item) => reportArray(reportObject(item, "chart"), "series"))
1262
+ ].some((series) => asString(reportValue(series, "name")).endsWith(" (approx.)"));
1263
+ if (hasApproximateLatencySeries) {
1264
+ appendReportLine(parts, "<p class=\"meta-note\">Series marked '(approx.)' use bounded native histogram estimates; completed request and transferred-byte totals remain exact.</p>");
1265
+ }
995
1266
  const charts = [];
996
1267
  if (hasPieChartData(chartData.overallOutcome)) {
997
- appendChartCard(charts, "chart-outcome", "Success vs Fail");
1268
+ appendChartCard(charts, "chart-outcome", "Success vs Fail", "overallOutcome", "donut", "requests", "Outcome");
998
1269
  }
999
1270
  if (hasChartPointData(chartData.scenarioRequests)) {
1000
- appendChartCard(charts, "chart-scenario-requests", "Requests by Scenario");
1271
+ appendChartCard(charts, "chart-scenario-requests", "Requests by Scenario", "scenarioRequests", "bar", "requests", "Requests");
1001
1272
  }
1002
- if (hasChartPointData(chartData.scenarioP95Latency)) {
1003
- appendChartCard(charts, "chart-scenario-p95", "P95 Latency by Scenario (ms)");
1273
+ if (hasLatencyTrendData(chartData.scenarioP95LatencyByOutcome)) {
1274
+ appendChartCard(charts, "chart-scenario-p95", "P95 Latency by Scenario (ms)", "scenarioP95LatencyByOutcome", "bar", "ms", "P95");
1004
1275
  }
1005
1276
  if (hasChartPointData(chartData.scenarioRps)) {
1006
- appendChartCard(charts, "chart-scenario-rps", "RPS by Scenario");
1277
+ appendChartCard(charts, "chart-scenario-rps", "RPS by Scenario", "scenarioRps", "bar", "req/s", "Rate");
1007
1278
  }
1008
1279
  if (hasChartPointData(chartData.scenarioFailRate)) {
1009
- appendChartCard(charts, "chart-scenario-fail-rate", "Failure Rate by Scenario (%)");
1280
+ appendChartCard(charts, "chart-scenario-fail-rate", "Failure Rate by Scenario (%)", "scenarioFailRate", "bar", "%", "Failure rate");
1010
1281
  }
1011
1282
  if (hasChartPointData(chartData.scenarioBytes)) {
1012
- appendChartCard(charts, "chart-scenario-bytes", "Bytes by Scenario");
1283
+ appendChartCard(charts, "chart-scenario-bytes", "Bytes by Scenario", "scenarioBytes", "bar", "bytes", "Transferred");
1013
1284
  }
1014
1285
  if (hasPieChartData(chartData.statusCodeClasses)) {
1015
- appendChartCard(charts, "chart-status-code-classes", "Status Code Class Mix");
1286
+ appendChartCard(charts, "chart-status-code-classes", "Status Code Class Mix", "statusCodeClasses", "donut", "responses", "Status");
1016
1287
  }
1017
1288
  if (hasLatencyTrendData(chartData.scenarioLatencyTrend)) {
1018
- appendChartCard(charts, "chart-scenario-latency-lines", "Latency Trend by Scenario (ms)");
1289
+ appendChartCard(charts, "chart-scenario-latency-lines", "Latency Percentile Profiles by Scenario (ms)", "scenarioLatencyTrend", "line", "ms", "Latency");
1290
+ }
1291
+ if (hasLatencyTrendData(chartData.cumulativeRequestsHistory)) {
1292
+ appendChartCard(charts, "chart-history-requests", "Cumulative Completed Requests by Scenario", "cumulativeRequestsHistory", "line", "requests", "Requests");
1293
+ }
1294
+ if (hasLatencyTrendData(chartData.achievedRequestRateHistory)) {
1295
+ appendChartCard(charts, "chart-history-rate", "Achieved Request Rate by Scenario", "achievedRequestRateHistory", "line", "req/s", "Rate");
1296
+ }
1297
+ if (hasLatencyTrendData(chartData.cumulativeBytesHistory)) {
1298
+ appendChartCard(charts, "chart-history-bytes", "Cumulative Transferred Bytes by Scenario", "cumulativeBytesHistory", "line", "bytes", "Transferred");
1299
+ }
1300
+ for (const [historyIndex, historyChart] of chartData.historyLatencyCharts.entries()) {
1301
+ appendChartCard(charts, `chart-history-latency-${historyIndex}`, asString(historyChart.title), "historyLatencyCharts", "line", "ms", "Latency", "", `data-chart-index="${historyIndex}"`);
1019
1302
  }
1020
1303
  if (charts.length > 0) {
1021
- appendReportLine(parts, "<div class=\"card\">");
1304
+ appendReportLine(parts, "<div class=\"card chart-collection\" data-chart-collection data-grid-size=\"comfortable\">");
1022
1305
  appendReportLine(parts, "<h2>Charts</h2>");
1306
+ appendChartCollectionTools(parts);
1023
1307
  appendReportLine(parts, "<div class=\"chart-grid\">");
1024
1308
  parts.push(...charts);
1025
1309
  appendReportLine(parts, "</div>");
1310
+ appendReportLine(parts, "<div class=\"chart-empty\" data-chart-empty>No charts match this search.</div>");
1026
1311
  appendReportLine(parts, "</div>");
1027
1312
  }
1028
1313
  if (latencyRows.length > 0) {
@@ -1039,7 +1324,7 @@ function buildDotnetSummaryHtml(nodeStats) {
1039
1324
  appendReportLine(parts, `<tr><th>Created (UTC)</th><td>${escapeHtml(formatDotnetDateTime(reportValue(testInfo, "createdUtc", "CreatedUtc", "created", "Created")))}</td></tr>`);
1040
1325
  appendReportLine(parts, `<tr><th>Machine</th><td>${escapeHtml(reportValue(nodeInfo, "machineName", "MachineName"))}</td></tr>`);
1041
1326
  appendReportLine(parts, `<tr><th>OS</th><td>${escapeHtml(reportValue(nodeInfo, "os", "OS"))}</td></tr>`);
1042
- appendReportLine(parts, `<tr><th>DotNet</th><td>${escapeHtml(reportValue(nodeInfo, "dotNetVersion", "DotNetVersion"))}</td></tr>`);
1327
+ appendReportLine(parts, `<tr><th>Runtime</th><td>${escapeHtml(loadStrikeRuntimeVersionLabel(nodeInfo))}</td></tr>`);
1043
1328
  appendReportLine(parts, `<tr><th>Processor</th><td>${escapeHtml(reportValue(nodeInfo, "processor", "Processor"))}</td></tr>`);
1044
1329
  appendReportLine(parts, `<tr><th>Cores</th><td>${asInt(reportValue(nodeInfo, "coresCount", "CoresCount"))}</td></tr>`);
1045
1330
  appendReportLine(parts, `<tr><th>Operation</th><td>${escapeHtml(reportValue(nodeInfo, "currentOperation", "CurrentOperation"))}</td></tr>`);
@@ -1091,8 +1376,30 @@ function buildDotnetThresholdHtml(nodeStats) {
1091
1376
  function buildDotnetMetricHtml(nodeStats) {
1092
1377
  return buildDotnetTableHtml(buildDotnetMetricRows(nodeStats));
1093
1378
  }
1094
- function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1095
- const warnings = reportArray(nodeStats, "generatorWarnings", "GeneratorWarnings");
1379
+ function reportHistoryUnavailableReason(localReportInput) {
1380
+ if (localReportInput.history.status === "available") {
1381
+ return "";
1382
+ }
1383
+ switch (localReportInput.history.reasonCategory) {
1384
+ case "capture_failure":
1385
+ return "History capture became unavailable.";
1386
+ case "budget_pressure":
1387
+ return "History was omitted because the report history budget was exceeded.";
1388
+ case "distributed_temporal_aggregation_unavailable":
1389
+ return "Temporal aggregation is unavailable for distributed results.";
1390
+ }
1391
+ }
1392
+ function buildDotnetGeneratorDeliveryHtml(nodeStats, localReportInput = (0, local_report_input_js_1.emptyLocalReportInput)()) {
1393
+ const warnings = [
1394
+ ...reportArray(nodeStats, "generatorWarnings", "GeneratorWarnings"),
1395
+ ...(localReportInput.history.status === "unavailable"
1396
+ ? [{
1397
+ code: "report_history_unavailable",
1398
+ count64: "1",
1399
+ reasonCategory: localReportInput.history.reasonCategory
1400
+ }]
1401
+ : [])
1402
+ ];
1096
1403
  const stats = reportObject(nodeStats, "schedulerStats", "SchedulerStats");
1097
1404
  const topLevelSegments = reportArray(nodeStats, "schedulerSegments", "SchedulerSegments");
1098
1405
  const segments = topLevelSegments.length > 0
@@ -1107,6 +1414,9 @@ function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1107
1414
  appendReportLine(parts, "<div class=\"card\">");
1108
1415
  appendReportLine(parts, "<h2>Generator Delivery</h2>");
1109
1416
  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>");
1417
+ if (localReportInput.history.status === "unavailable") {
1418
+ appendReportLine(parts, `<p>${escapeHtml(reportHistoryUnavailableReason(localReportInput))}</p>`);
1419
+ }
1110
1420
  appendReportLine(parts, "<div class=\"card-grid\">");
1111
1421
  for (const [label, value] of [
1112
1422
  ["Configured Max In Flight", asInt(reportValue(stats, "configuredMaxInFlight", "ConfiguredMaxInFlight"))],
@@ -1123,7 +1433,7 @@ function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1123
1433
  }
1124
1434
  appendReportLine(parts, "</div></div>");
1125
1435
  if (warnings.length) {
1126
- appendReportLine(parts, "<div class=\"card\"><h2>Generator Warnings</h2>");
1436
+ appendReportLine(parts, "<div class=\"card\"><h2>Generator and Reporting Warnings</h2>");
1127
1437
  parts.push(buildDotnetTableHtml(warnings, false));
1128
1438
  appendReportLine(parts, "</div>");
1129
1439
  }
@@ -1148,7 +1458,7 @@ function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1148
1458
  }
1149
1459
  return parts.join("");
1150
1460
  }
1151
- function hasDotnetGeneratorDeliveryData(nodeStats) {
1461
+ function hasDotnetGeneratorDeliveryData(nodeStats, localReportInput = (0, local_report_input_js_1.emptyLocalReportInput)()) {
1152
1462
  const schedulerStats = reportObject(nodeStats, "schedulerStats", "SchedulerStats");
1153
1463
  const observationStats = reportObject(nodeStats, "observationDeliveryStats", "ObservationDeliveryStats");
1154
1464
  const hasNonZeroDecimal = (value) => {
@@ -1156,6 +1466,7 @@ function hasDotnetGeneratorDeliveryData(nodeStats) {
1156
1466
  return text.trim().length > 0 && text !== "0";
1157
1467
  };
1158
1468
  return reportArray(nodeStats, "generatorWarnings", "GeneratorWarnings").length > 0
1469
+ || localReportInput.history.status === "unavailable"
1159
1470
  || reportArray(nodeStats, "schedulerSegments", "SchedulerSegments").length > 0
1160
1471
  || reportArray(schedulerStats, "segments", "Segments").length > 0
1161
1472
  || asInt(reportValue(schedulerStats, "configuredMaxInFlight", "ConfiguredMaxInFlight")) > 0
@@ -1175,15 +1486,19 @@ function buildDotnetGroupedCorrelationSummaryHtml(rows, groupedChartKey) {
1175
1486
  return parts.join("");
1176
1487
  }
1177
1488
  appendReportLine(parts);
1178
- 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>");
1489
+ 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>");
1490
+ appendReportLine(parts, "<div class=\"card chart-collection\" data-chart-collection data-grid-size=\"comfortable\">");
1491
+ appendChartCollectionTools(parts);
1179
1492
  appendReportLine(parts, "<div class=\"chart-grid correlation-chart-grid\">");
1180
1493
  payloads.forEach((payload, index) => {
1181
1494
  const chartKey = `${groupedChartKey}-value-${index}`;
1182
1495
  const chartJson = escapeJsonForHtmlScript(JSON.stringify(payload.chart));
1183
1496
  appendReportLine(parts, `<script type="application/json" data-grouped-correlation-chart="${escapeHtml(chartKey)}">${chartJson}</script>`);
1184
- 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>`);
1497
+ appendChartCard(parts, `${chartKey}-chart`, payload.title, "grouped-correlation", "line", "ms", "Latency", "grouped-correlation-canvas", `data-grouped-key="${escapeHtml(chartKey)}"`);
1185
1498
  });
1186
1499
  appendReportLine(parts, "</div>");
1500
+ appendReportLine(parts, "<div class=\"chart-empty\" data-chart-empty>No charts match this search.</div>");
1501
+ appendReportLine(parts, "</div>");
1187
1502
  return parts.join("");
1188
1503
  }
1189
1504
  function buildDotnetUngroupedCorrelationSummaryHtml(rows, groupedChartKey) {
@@ -1195,8 +1510,12 @@ function buildDotnetUngroupedCorrelationSummaryHtml(rows, groupedChartKey) {
1195
1510
  appendReportLine(parts);
1196
1511
  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>");
1197
1512
  appendReportLine(parts, `<script type="application/json" data-ungrouped-correlation="${escapeHtml(groupedChartKey)}">${chartJson}</script>`);
1513
+ appendReportLine(parts, "<div class=\"card chart-collection\" data-chart-collection data-grid-size=\"comfortable\">");
1514
+ appendChartCollectionTools(parts);
1198
1515
  appendReportLine(parts, "<div class=\"chart-grid correlation-chart-grid\">");
1199
- 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>`);
1516
+ appendChartCard(parts, `${groupedChartKey}-chart`, "Ungrouped Correlation Percentiles", "ungrouped-correlation", "line", "ms", "Latency", "ungrouped-correlation-canvas", `data-ungrouped-key="${escapeHtml(groupedChartKey)}"`);
1517
+ appendReportLine(parts, "</div>");
1518
+ appendReportLine(parts, "<div class=\"chart-empty\" data-chart-empty>No charts match this search.</div>");
1200
1519
  appendReportLine(parts, "</div>");
1201
1520
  }
1202
1521
  return parts.join("");
@@ -1208,8 +1527,8 @@ function buildDotnetPluginHints(plugin) {
1208
1527
  const hints = reportArray(plugin, "hints", "Hints").map((hint) => asString(hint).trim()).filter((hint) => hint.length > 0);
1209
1528
  return `<div class="card"><strong>Hints</strong><ul>${hints.map((hint) => `<li>${escapeHtml(hint)}</li>`).join("")}</ul></div>`;
1210
1529
  }
1211
- function buildDotnetHtmlTabs(nodeStats) {
1212
- const tabs = [["summary", "Summary", buildDotnetSummaryHtml(nodeStats)]];
1530
+ function buildDotnetHtmlTabs(nodeStats, localReportInput = (0, local_report_input_js_1.emptyLocalReportInput)()) {
1531
+ const tabs = [["summary", "Summary", buildDotnetSummaryHtml(nodeStats, localReportInput)]];
1213
1532
  const scenarioRows = buildDotnetScenarioRows(nodeStats);
1214
1533
  if (scenarioRows.length) {
1215
1534
  tabs.push(["scenarios", "Scenarios", buildDotnetTableHtml(scenarioRows)]);
@@ -1243,8 +1562,8 @@ function buildDotnetHtmlTabs(nodeStats) {
1243
1562
  if (metricRows.length) {
1244
1563
  tabs.push(["metrics", "Metrics", buildDotnetTableHtml(metricRows)]);
1245
1564
  }
1246
- if (hasDotnetGeneratorDeliveryData(nodeStats)) {
1247
- tabs.push(["generator-delivery", "Generator Delivery", buildDotnetGeneratorDeliveryHtml(nodeStats)]);
1565
+ if (hasDotnetGeneratorDeliveryData(nodeStats, localReportInput)) {
1566
+ tabs.push(["generator-delivery", "Generator Delivery", buildDotnetGeneratorDeliveryHtml(nodeStats, localReportInput)]);
1248
1567
  }
1249
1568
  for (const plugin of reportArray(nodeStats, "pluginsData", "PluginsData")) {
1250
1569
  const pluginName = asString(reportValue(plugin, "pluginName", "PluginName"));
@@ -1279,7 +1598,7 @@ function buildDotnetHtmlTabs(nodeStats) {
1279
1598
  body = buildDotnetGroupedCorrelationSummaryHtml(bodyRows, `grouped-correlation-${tabs.length}`);
1280
1599
  }
1281
1600
  else if (lowerPlugin.includes("correlation") && lowerTable.includes("ungrouped correlation rows")) {
1282
- title = "Ungrouped Corelation Summary";
1601
+ title = "Ungrouped Correlation Summary";
1283
1602
  body = buildDotnetUngroupedCorrelationSummaryHtml(bodyRows, `ungrouped-correlation-${tabs.length}`);
1284
1603
  }
1285
1604
  tabs.push([`plugin-${tabs.length}`, title, `${hints}${body}`]);
@@ -1391,41 +1710,47 @@ function buildDotnetMarkdownReport(nodeStats) {
1391
1710
  return reportLines(lines);
1392
1711
  }
1393
1712
  /**
1394
- * Exposes the build dotnet html report operation. Use this when interacting with the SDK through this surface.
1713
+ * Builds the portable single-file HTML report. The optional second argument is
1714
+ * an internal report-only carrier used by the native runner and is never added
1715
+ * to public result, sink, observation, or cluster payloads.
1395
1716
  */
1396
- function buildDotnetHtmlReport(nodeStats) {
1397
- const tabs = buildDotnetHtmlTabs(nodeStats);
1398
- const buttonsHtml = tabs.map(([tabId, title]) => `<button class="tab-btn" data-tab="${tabId}">${escapeHtml(title)}</button>${REPORT_EOL}`).join("");
1399
- const sectionsHtml = tabs.map(([tabId, , html]) => `<section id="${tabId}" class="tab">${html}</section>${REPORT_EOL}`).join("");
1400
- const chartDataJson = JSON.stringify(buildDotnetChartData(nodeStats));
1717
+ function buildDotnetHtmlReport(nodeStats, localReportInput = (0, local_report_input_js_1.emptyLocalReportInput)()) {
1718
+ const tabs = buildDotnetHtmlTabs(nodeStats, localReportInput);
1719
+ const buttonsHtml = tabs
1720
+ .map(([tabId, title]) => `<button class="tab-btn" data-tab="${escapeHtml(tabId)}">${escapeHtml(title)}</button>${REPORT_EOL}`)
1721
+ .join("");
1722
+ const sectionsHtml = tabs
1723
+ .map(([tabId, , html]) => `<section id="${escapeHtml(tabId)}" class="tab">${html}</section>${REPORT_EOL}`)
1724
+ .join("");
1725
+ const chartDataJson = escapeJsonForHtmlScript(JSON.stringify(buildDotnetChartData(nodeStats, localReportInput)));
1401
1726
  const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
1402
1727
  const template = `<!doctype html>
1403
1728
  <html lang="en">
1404
1729
  <head>
1405
1730
  <meta charset="utf-8" />
1406
1731
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1732
+ <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'" />
1407
1733
  <title>LoadStrike Report</title>
1408
1734
  <style>
1409
- :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)}
1410
- 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)}
1735
+ :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)}
1736
+ 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)}
1411
1737
  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}
1412
1738
  .wrap{max-width:1440px;margin:0 auto;padding:20px}
1413
- .report-brand{display:flex;align-items:center;gap:16px;margin:0 0 10px 0;overflow:visible;padding-top:8px;flex-wrap:wrap}
1739
+ .report-brand{display:flex;align-items:center;gap:16px;margin:0 0 10px;padding-top:8px;overflow:visible;flex-wrap:wrap}
1414
1740
  .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}
1415
- .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}
1741
+ .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}
1416
1742
  body[data-theme='dark'] .report-logo{--report-logo-scale:1.175}
1417
1743
  .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}
1418
1744
  .theme-toggle-report:hover{border-color:var(--accent);transform:translateY(-1px)}
1419
- .theme-toggle-report span{line-height:1;pointer-events:none}
1420
- h1{margin:0 0 10px 0;font-size:28px;letter-spacing:.2px}
1421
- h2{margin:0 0 12px 0;font-size:18px}
1422
- h3{margin:0 0 10px 0;font-size:15px;color:var(--text)}
1745
+ h1{margin:0 0 10px;font-size:28px;letter-spacing:.2px}
1746
+ h2{margin:0 0 12px;font-size:18px}
1747
+ h3{margin:0 0 10px;font-size:15px;color:var(--text)}
1423
1748
  .meta{display:flex;gap:14px;flex-wrap:wrap;color:var(--muted);font-size:13px;margin-bottom:16px}
1424
1749
  .meta span{background:var(--chip);border:1px solid var(--line);padding:6px 10px;border-radius:999px}
1425
1750
  .report-layout{display:grid;grid-template-columns:280px minmax(0,1fr);gap:14px;align-items:start}
1426
1751
  .tabs-pane{position:sticky;top:12px;max-height:calc(100vh - 24px);overflow:auto;overscroll-behavior:contain;padding-right:4px;cursor:grab}
1427
1752
  .tabs-pane.panning{cursor:grabbing;user-select:none}
1428
- .tabs{display:flex;flex-direction:column;gap:8px;margin:12px 0 14px 0}
1753
+ .tabs{display:flex;flex-direction:column;gap:8px;margin:12px 0 14px}
1429
1754
  .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%}
1430
1755
  .tab-btn:hover{border-color:var(--accent);background:var(--chip)}
1431
1756
  .tab-btn.active{background:linear-gradient(180deg,#2f66db 0,#2754b8 100%);border-color:#3f73e0}
@@ -1444,22 +1769,19 @@ th{background:var(--panelAlt);position:sticky;top:0;z-index:1}
1444
1769
  .value-fail{color:var(--fail)}
1445
1770
  .chart-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:12px}
1446
1771
  .chart-card{padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--chartPanel)}
1447
- .chart-canvas{width:100%;height:260px;display:block}
1448
- .correlation-chart-grid{grid-template-columns:repeat(auto-fit,minmax(420px,720px));justify-content:center}
1772
+ .correlation-chart-grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,420px),720px));justify-content:center}
1449
1773
  .correlation-chart-card{max-width:720px;width:100%}
1450
- .correlation-chart-card .chart-canvas{height:320px}
1451
1774
  .chart-card h3,.chart-card p{color:var(--chartLegendText)}
1452
1775
  .table-wrap{overflow:auto;max-height:70vh}
1453
- @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}}
1776
+ ${reporting_svg_js_1.REPORT_SVG_CSS}
1777
+ @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}}
1454
1778
  </style>
1455
1779
  </head>
1456
1780
  <body data-theme="light">
1457
1781
  <div class="wrap">
1458
1782
  <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>
1459
1783
  <div class="report-brand">
1460
- <div class="report-logo-slot">
1461
- <img src="__LOGO_LIGHT__" alt="LoadStrike logo" class="report-logo" data-report-logo data-logo-light="__LOGO_LIGHT__" data-logo-dark="__LOGO_DARK__" />
1462
- </div>
1784
+ <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>
1463
1785
  <h1>LoadStrike Report</h1>
1464
1786
  </div>
1465
1787
  <div class="meta">
@@ -1469,44 +1791,18 @@ th{background:var(--panelAlt);position:sticky;top:0;z-index:1}
1469
1791
  <span>Duration: <strong>__DURATION__</strong></span>
1470
1792
  </div>
1471
1793
  <div class="report-layout">
1472
- <aside class="tabs-pane" id="tab-pane">
1473
- <div class="tabs">
1474
- __BUTTONS__</div>
1475
- </aside>
1794
+ <aside class="tabs-pane" id="tab-pane"><div class="tabs">
1795
+ __BUTTONS__</div></aside>
1476
1796
  <div class="tabs-content">
1477
1797
  __SECTIONS__</div>
1478
1798
  </div>
1479
1799
  </div>
1800
+ <div class="chart-modal" data-chart-fullscreen-overlay role="dialog" aria-modal="true" aria-label="Expanded LoadStrike chart" aria-hidden="true">
1801
+ <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>
1802
+ </div>
1480
1803
  <script>
1481
1804
  const reportCharts=__CHART_DATA__;
1482
- const btns=[...document.querySelectorAll('.tab-btn')];
1483
- const tabSections=[...document.querySelectorAll('.tab')];
1484
- const tabsPane=document.getElementById('tab-pane');
1485
- const linePalette=['#38bdf8','#22c55e','#f59e0b','#a855f7','#f43f5e','#14b8a6','#eab308','#818cf8','#06b6d4','#84cc16'];
1486
- const reportThemeKey='loadstrike-report-theme';
1487
- const reportThemeToggle=document.querySelector('[data-report-theme-toggle]');
1488
- const reportLogo=document.querySelector('[data-report-logo]');
1489
- 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');}}
1490
- function show(id){btns.forEach(b=>b.classList.toggle('active',b.dataset.tab===id));tabSections.forEach(t=>t.classList.toggle('active',t.id===id));}
1491
- function formatMetric(v){if(!Number.isFinite(v))return '0';return Math.abs(v)>=100?v.toFixed(0):v.toFixed(2);}
1492
- 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};}
1493
- function drawNoData(ctx,w,h,msg){ctx.fillStyle='#9fb0c3';ctx.font='13px Segoe UI';ctx.textAlign='center';ctx.fillText(msg,w/2,h/2);}
1494
- 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);}}
1495
- 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;}}
1496
- 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;}}}
1497
- 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);}
1498
- 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:[]};}}
1499
- 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:[]};}}
1500
- 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};}
1501
- 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);});}
1502
- 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);});}
1503
- 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');});}
1504
- function renderAllCharts(){renderCharts();renderUngroupedCorrelationCharts();renderGroupedCorrelationCharts();}
1505
- const storedReportTheme=(()=>{try{return localStorage.getItem(reportThemeKey);}catch{return null;}})();
1506
- applyReportTheme(storedReportTheme==='dark'?'dark':'light');
1507
- if(reportThemeToggle){reportThemeToggle.addEventListener('click',()=>{const next=document.body.getAttribute('data-theme')==='dark'?'light':'dark';applyReportTheme(next);try{localStorage.setItem(reportThemeKey,next);}catch{}renderAllCharts();});}
1508
- btns.forEach(b=>b.addEventListener('click',()=>{show(b.dataset.tab);requestAnimationFrame(renderAllCharts);}));
1509
- if(btns.length>0){show(btns[0].dataset.tab);}renderAllCharts();initPanePan();window.addEventListener('resize',renderAllCharts);
1805
+ ${reporting_svg_js_1.REPORT_SVG_SCRIPT.trimStart()}
1510
1806
  </script>
1511
1807
  </body>
1512
1808
  </html>`;