@loadstrike/loadstrike-sdk 1.0.31001 → 1.0.32601

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.
Files changed (45) hide show
  1. package/README.md +16 -2
  2. package/dist/cjs/internal/prometheus-remote-write.js +37 -0
  3. package/dist/cjs/internal/reporting-sink-http-error.js +17 -0
  4. package/dist/cjs/internal/vendor-metric-payloads.js +390 -0
  5. package/dist/cjs/iteration-observations.js +24 -8
  6. package/dist/cjs/local-report-input.js +21 -0
  7. package/dist/cjs/local.js +48 -63
  8. package/dist/cjs/report-history.js +421 -0
  9. package/dist/cjs/reporting-containment.js +242 -0
  10. package/dist/cjs/reporting-svg.js +116 -0
  11. package/dist/cjs/reporting.js +413 -136
  12. package/dist/cjs/runtime.js +237 -8
  13. package/dist/cjs/sinks.js +1337 -38
  14. package/dist/cjs/transports.js +1339 -151
  15. package/dist/esm/internal/prometheus-remote-write.js +31 -0
  16. package/dist/esm/internal/reporting-sink-http-error.js +13 -0
  17. package/dist/esm/internal/vendor-metric-payloads.js +382 -0
  18. package/dist/esm/iteration-observations.js +24 -8
  19. package/dist/esm/local-report-input.js +17 -0
  20. package/dist/esm/local.js +49 -64
  21. package/dist/esm/report-history.js +413 -0
  22. package/dist/esm/reporting-containment.js +238 -0
  23. package/dist/esm/reporting-svg.js +113 -0
  24. package/dist/esm/reporting.js +413 -136
  25. package/dist/esm/runtime.js +239 -10
  26. package/dist/esm/sinks.js +1334 -35
  27. package/dist/esm/transports.js +1335 -151
  28. package/dist/types/contracts.d.ts +1 -0
  29. package/dist/types/index.d.ts +1 -1
  30. package/dist/types/internal/prometheus-remote-write.d.ts +2 -0
  31. package/dist/types/internal/reporting-sink-http-error.d.ts +6 -0
  32. package/dist/types/internal/vendor-metric-payloads.d.ts +48 -0
  33. package/dist/types/local-report-input.d.ts +6 -0
  34. package/dist/types/local.d.ts +0 -6
  35. package/dist/types/report-history.d.ts +124 -0
  36. package/dist/types/reporting-containment.d.ts +2 -0
  37. package/dist/types/reporting-svg.d.ts +2 -0
  38. package/dist/types/reporting.d.ts +6 -3
  39. package/dist/types/runtime.d.ts +24 -0
  40. package/dist/types/sinks.d.ts +134 -17
  41. package/dist/types/transports.d.ts +2 -0
  42. package/package.json +9 -3
  43. package/dist/cjs/internal-build.js +0 -4
  44. package/dist/esm/internal-build.js +0 -1
  45. package/dist/types/internal-build.d.ts +0 -1
@@ -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();
@@ -295,6 +298,40 @@ function loadStrikeNodeTypeTag(value) {
295
298
  return asInt(value);
296
299
  }
297
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
+ }
298
335
  function parseUtcDate(value) {
299
336
  if (value instanceof Date && !Number.isNaN(value.getTime())) {
300
337
  return value;
@@ -424,6 +461,9 @@ function buildDotnetTableHtml(rows, wrapInCard = true) {
424
461
  return parts.join("");
425
462
  }
426
463
  function formatReportTableHeader(header) {
464
+ if (header === "UnmatchedDestination") {
465
+ return "Unmatched Destination";
466
+ }
427
467
  if (header === "LatencyStdDev") {
428
468
  return "LatencyStdDev (ms)";
429
469
  }
@@ -521,21 +561,14 @@ function buildFailedEventRows(plugins) {
521
561
  }
522
562
  function tryParseReportFloat(value) {
523
563
  const parsed = Number.parseFloat(asString(value));
524
- return Number.isFinite(parsed) ? parsed : undefined;
564
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
525
565
  }
526
- function meanNumeric(values) {
527
- if (!values.length) {
566
+ function percentileFromOrdered(values, percentileValue) {
567
+ if (values.length === 0) {
528
568
  return 0;
529
569
  }
530
- return values.reduce((sum, value) => sum + value, 0) / values.length;
531
- }
532
- function percentileNumeric(values, percentileValue) {
533
- if (!values.length) {
534
- return 0;
535
- }
536
- const ordered = [...values].sort((left, right) => left - right);
537
- const index = Math.min(Math.max(Math.ceil(percentileValue * ordered.length) - 1, 0), ordered.length - 1);
538
- return ordered[index];
570
+ const index = Math.min(Math.max(Math.ceil(percentileValue * values.length) - 1, 0), values.length - 1);
571
+ return values[index];
539
572
  }
540
573
  function buildGroupedCorrelationChartPayloads(rows) {
541
574
  const grouped = new Map();
@@ -553,33 +586,48 @@ function buildGroupedCorrelationChartPayloads(rows) {
553
586
  }
554
587
  return [...grouped.values()]
555
588
  .filter((group) => group.rows.some((row) => tryParseReportFloat(row.LatencyP50Ms) !== undefined
589
+ || tryParseReportFloat(row.LatencyP75Ms) !== undefined
556
590
  || tryParseReportFloat(row.LatencyP80Ms) !== undefined
557
591
  || tryParseReportFloat(row.LatencyP85Ms) !== undefined
558
592
  || tryParseReportFloat(row.LatencyP90Ms) !== undefined
559
593
  || tryParseReportFloat(row.LatencyP95Ms) !== undefined
560
- || tryParseReportFloat(row.LatencyP99Ms) !== undefined))
594
+ || tryParseReportFloat(row.LatencyP99Ms) !== undefined
595
+ || tryParseReportFloat(row.LatencyMaxMs ?? row.LatencyMax) !== undefined))
561
596
  .sort((left, right) => left.key[0] === right.key[0]
562
597
  ? left.key[1].localeCompare(right.key[1])
563
598
  : left.key[0].localeCompare(right.key[0]))
564
599
  .map((group, index) => ({
565
600
  title: `${group.key[0]}: ${group.key[1]}`,
566
- 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.",
567
604
  chart: {
568
- labels: ["P50", "P80", "P85", "P90", "P95", "P99"],
569
- series: [
570
- {
571
- name: "Latency",
572
- 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],
573
619
  values: [
574
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP50Ms)).filter((value) => value !== undefined)),
575
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP80Ms)).filter((value) => value !== undefined)),
576
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP85Ms)).filter((value) => value !== undefined)),
577
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP90Ms)).filter((value) => value !== undefined)),
578
- meanNumeric(group.rows.map((row) => tryParseReportFloat(row.LatencyP95Ms)).filter((value) => value !== undefined)),
579
- 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
580
628
  ]
581
- }
582
- ]
629
+ };
630
+ })
583
631
  }
584
632
  }));
585
633
  }
@@ -602,7 +650,6 @@ function buildUngroupedCorrelationChartPayload(rows) {
602
650
  grouped.set(key, { scenario, destination, statusCode, latencies: [latency] });
603
651
  }
604
652
  }
605
- const nameCount = new Map();
606
653
  const series = [...grouped.values()]
607
654
  .sort((left, right) => {
608
655
  const leftKey = `${left.scenario}|${left.destination}|${left.statusCode}`;
@@ -610,25 +657,24 @@ function buildUngroupedCorrelationChartPayload(rows) {
610
657
  return leftKey.localeCompare(rightKey);
611
658
  })
612
659
  .map((row, index) => {
613
- const baseName = row.statusCode.trim() || "status";
614
- const key = baseName.toLowerCase();
615
- const count = nameCount.get(key) ?? 0;
616
- nameCount.set(key, count + 1);
660
+ const orderedLatencies = [...row.latencies].sort((left, right) => left - right);
617
661
  return {
618
- name: count === 0 ? baseName : `${baseName}-${count + 1}`,
662
+ name: `${row.scenario} | ${row.destination} | status ${row.statusCode}`,
619
663
  color: ["#38bdf8", "#22c55e", "#f59e0b", "#a855f7", "#f43f5e", "#06b6d4", "#14b8a6", "#eab308", "#818cf8", "#84cc16"][index % 10],
620
664
  values: [
621
- percentileNumeric(row.latencies, 0.50),
622
- percentileNumeric(row.latencies, 0.80),
623
- percentileNumeric(row.latencies, 0.85),
624
- percentileNumeric(row.latencies, 0.90),
625
- percentileNumeric(row.latencies, 0.95),
626
- 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]
627
673
  ]
628
674
  };
629
675
  });
630
676
  return {
631
- labels: ["P50", "P80", "P85", "P90", "P95", "P99"],
677
+ labels: ["P50", "P75", "P80", "P85", "P90", "P95", "P99", "Max"],
632
678
  series
633
679
  };
634
680
  }
@@ -764,8 +810,34 @@ function hasMeasurementData(measurement) {
764
810
  asString(reportValue(code, "statusCode", "StatusCode")).trim().length > 0 ||
765
811
  asString(reportValue(code, "message", "Message")).trim().length > 0);
766
812
  }
767
- function appendChartCard(parts, id, title) {
768
- 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>");
769
841
  }
770
842
  function hasChartPointData(points) {
771
843
  return Array.isArray(points) && points.length > 0;
@@ -776,7 +848,7 @@ function hasPieChartData(points) {
776
848
  function hasLatencyTrendData(chart) {
777
849
  const labels = reportArray(chart, "labels", "Labels");
778
850
  const series = reportArray(chart, "series", "Series");
779
- 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));
780
852
  }
781
853
  function hasNonEmptyHints(plugin) {
782
854
  return reportArray(plugin, "hints", "Hints").some((hint) => asString(hint).trim().length > 0);
@@ -919,11 +991,169 @@ function buildDotnetStatusCodeClassChart(scenarios) {
919
991
  { label: "Other", value: buckets.Other, color: "#8b5cf6" }
920
992
  ].filter((entry) => entry.value > 0);
921
993
  }
922
- 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) {
923
1150
  const scenarios = reportScenarios(nodeStats);
924
- const combinedScenarios = scenarios
925
- .map((scenario) => ({ scenario, measurement: combinedMeasurement(scenario) }))
1151
+ const genuineCombinedScenarios = scenarios
1152
+ .map((scenario) => ({ scenario, measurement: genuineCombinedMeasurement(scenario) }))
926
1153
  .filter((item) => item.measurement !== undefined);
1154
+ const history = localReportInput.history.status === "available"
1155
+ ? buildHistoryChartData(localReportInput.history.points)
1156
+ : buildHistoryChartData([]);
927
1157
  return {
928
1158
  overallOutcome: [
929
1159
  { label: "OK", value: reportTotalOkCount(nodeStats, scenarios), color: "#18a957" },
@@ -934,11 +1164,15 @@ function buildDotnetChartData(nodeStats) {
934
1164
  value: reportRequestCountValue(scenario),
935
1165
  color: "#3b82f6"
936
1166
  })),
937
- scenarioP95Latency: combinedScenarios.map(({ scenario, measurement }) => ({
1167
+ scenarioP95Latency: genuineCombinedScenarios.map(({ scenario, measurement }) => ({
938
1168
  label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
939
1169
  value: asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent95", "Percent95")),
940
1170
  color: "#8b5cf6"
941
1171
  })),
1172
+ scenarioP95LatencyByOutcome: {
1173
+ labels: scenarios.map((scenario) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
1174
+ series: buildOutcomeLatencySeries(scenarios, "P95", "percent95", "Percent95")
1175
+ },
942
1176
  scenarioRps: scenarios.map((scenario) => ({
943
1177
  label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
944
1178
  value: reportDurationSeconds(reportDurationValue(scenario)) <= 0
@@ -960,17 +1194,18 @@ function buildDotnetChartData(nodeStats) {
960
1194
  })),
961
1195
  statusCodeClasses: buildDotnetStatusCodeClassChart(scenarios),
962
1196
  scenarioLatencyTrend: {
963
- labels: combinedScenarios.map(({ scenario }) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
1197
+ labels: scenarios.map((scenario) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
964
1198
  series: [
965
- { name: "P50", color: "#38bdf8", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent50", "Percent50"))) },
966
- { name: "P75", color: "#22c55e", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent75", "Percent75"))) },
967
- { name: "P95", color: "#f59e0b", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent95", "Percent95"))) },
968
- { 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")
969
1203
  ]
970
- }
1204
+ },
1205
+ ...history
971
1206
  };
972
1207
  }
973
- function buildDotnetSummaryHtml(nodeStats) {
1208
+ function buildDotnetSummaryHtml(nodeStats, localReportInput) {
974
1209
  const scenarios = reportScenarios(nodeStats);
975
1210
  const allRequests = reportTotalRequestCount(nodeStats, scenarios);
976
1211
  const allOk = reportTotalOkCount(nodeStats, scenarios);
@@ -992,11 +1227,19 @@ function buildDotnetSummaryHtml(nodeStats) {
992
1227
  ? scenario
993
1228
  : winner;
994
1229
  }, undefined);
995
- const latencyRows = scenarios.flatMap((scenario) => [
996
- buildSummaryLatencyRow(asString(reportValue(scenario, "scenarioName", "ScenarioName")), "OK", reportObject(scenario, "ok", "Ok")),
997
- buildSummaryLatencyRow(asString(reportValue(scenario, "scenarioName", "ScenarioName")), "FAIL", reportObject(scenario, "fail", "Fail"))
998
- ]);
999
- 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);
1000
1243
  const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
1001
1244
  const nodeInfo = reportObject(nodeStats, "nodeInfo", "NodeInfo");
1002
1245
  const totalBytes = reportTotalBytes(nodeStats, scenarios);
@@ -1009,39 +1252,62 @@ function buildDotnetSummaryHtml(nodeStats) {
1009
1252
  appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Duration</div><div class="stat-value">${escapeHtml(formatDotnetTimeSpan(reportDurationValue(nodeStats)))}</div></div>`);
1010
1253
  appendReportLine(parts, `<div class="stat-card"><div class="stat-label">Total Bytes</div><div class="stat-value">${totalBytes}</div></div>`);
1011
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>`);
1012
- 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>`);
1013
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
+ }
1014
1266
  const charts = [];
1015
1267
  if (hasPieChartData(chartData.overallOutcome)) {
1016
- appendChartCard(charts, "chart-outcome", "Success vs Fail");
1268
+ appendChartCard(charts, "chart-outcome", "Success vs Fail", "overallOutcome", "donut", "requests", "Outcome");
1017
1269
  }
1018
1270
  if (hasChartPointData(chartData.scenarioRequests)) {
1019
- appendChartCard(charts, "chart-scenario-requests", "Requests by Scenario");
1271
+ appendChartCard(charts, "chart-scenario-requests", "Requests by Scenario", "scenarioRequests", "bar", "requests", "Requests");
1020
1272
  }
1021
- if (hasChartPointData(chartData.scenarioP95Latency)) {
1022
- 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");
1023
1275
  }
1024
1276
  if (hasChartPointData(chartData.scenarioRps)) {
1025
- appendChartCard(charts, "chart-scenario-rps", "RPS by Scenario");
1277
+ appendChartCard(charts, "chart-scenario-rps", "RPS by Scenario", "scenarioRps", "bar", "req/s", "Rate");
1026
1278
  }
1027
1279
  if (hasChartPointData(chartData.scenarioFailRate)) {
1028
- 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");
1029
1281
  }
1030
1282
  if (hasChartPointData(chartData.scenarioBytes)) {
1031
- appendChartCard(charts, "chart-scenario-bytes", "Bytes by Scenario");
1283
+ appendChartCard(charts, "chart-scenario-bytes", "Bytes by Scenario", "scenarioBytes", "bar", "bytes", "Transferred");
1032
1284
  }
1033
1285
  if (hasPieChartData(chartData.statusCodeClasses)) {
1034
- 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");
1035
1287
  }
1036
1288
  if (hasLatencyTrendData(chartData.scenarioLatencyTrend)) {
1037
- 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}"`);
1038
1302
  }
1039
1303
  if (charts.length > 0) {
1040
- appendReportLine(parts, "<div class=\"card\">");
1304
+ appendReportLine(parts, "<div class=\"card chart-collection\" data-chart-collection data-grid-size=\"comfortable\">");
1041
1305
  appendReportLine(parts, "<h2>Charts</h2>");
1306
+ appendChartCollectionTools(parts);
1042
1307
  appendReportLine(parts, "<div class=\"chart-grid\">");
1043
1308
  parts.push(...charts);
1044
1309
  appendReportLine(parts, "</div>");
1310
+ appendReportLine(parts, "<div class=\"chart-empty\" data-chart-empty>No charts match this search.</div>");
1045
1311
  appendReportLine(parts, "</div>");
1046
1312
  }
1047
1313
  if (latencyRows.length > 0) {
@@ -1058,7 +1324,7 @@ function buildDotnetSummaryHtml(nodeStats) {
1058
1324
  appendReportLine(parts, `<tr><th>Created (UTC)</th><td>${escapeHtml(formatDotnetDateTime(reportValue(testInfo, "createdUtc", "CreatedUtc", "created", "Created")))}</td></tr>`);
1059
1325
  appendReportLine(parts, `<tr><th>Machine</th><td>${escapeHtml(reportValue(nodeInfo, "machineName", "MachineName"))}</td></tr>`);
1060
1326
  appendReportLine(parts, `<tr><th>OS</th><td>${escapeHtml(reportValue(nodeInfo, "os", "OS"))}</td></tr>`);
1061
- 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>`);
1062
1328
  appendReportLine(parts, `<tr><th>Processor</th><td>${escapeHtml(reportValue(nodeInfo, "processor", "Processor"))}</td></tr>`);
1063
1329
  appendReportLine(parts, `<tr><th>Cores</th><td>${asInt(reportValue(nodeInfo, "coresCount", "CoresCount"))}</td></tr>`);
1064
1330
  appendReportLine(parts, `<tr><th>Operation</th><td>${escapeHtml(reportValue(nodeInfo, "currentOperation", "CurrentOperation"))}</td></tr>`);
@@ -1110,8 +1376,30 @@ function buildDotnetThresholdHtml(nodeStats) {
1110
1376
  function buildDotnetMetricHtml(nodeStats) {
1111
1377
  return buildDotnetTableHtml(buildDotnetMetricRows(nodeStats));
1112
1378
  }
1113
- function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1114
- 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
+ ];
1115
1403
  const stats = reportObject(nodeStats, "schedulerStats", "SchedulerStats");
1116
1404
  const topLevelSegments = reportArray(nodeStats, "schedulerSegments", "SchedulerSegments");
1117
1405
  const segments = topLevelSegments.length > 0
@@ -1126,6 +1414,9 @@ function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1126
1414
  appendReportLine(parts, "<div class=\"card\">");
1127
1415
  appendReportLine(parts, "<h2>Generator Delivery</h2>");
1128
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
+ }
1129
1420
  appendReportLine(parts, "<div class=\"card-grid\">");
1130
1421
  for (const [label, value] of [
1131
1422
  ["Configured Max In Flight", asInt(reportValue(stats, "configuredMaxInFlight", "ConfiguredMaxInFlight"))],
@@ -1142,7 +1433,7 @@ function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1142
1433
  }
1143
1434
  appendReportLine(parts, "</div></div>");
1144
1435
  if (warnings.length) {
1145
- appendReportLine(parts, "<div class=\"card\"><h2>Generator Warnings</h2>");
1436
+ appendReportLine(parts, "<div class=\"card\"><h2>Generator and Reporting Warnings</h2>");
1146
1437
  parts.push(buildDotnetTableHtml(warnings, false));
1147
1438
  appendReportLine(parts, "</div>");
1148
1439
  }
@@ -1167,7 +1458,7 @@ function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1167
1458
  }
1168
1459
  return parts.join("");
1169
1460
  }
1170
- function hasDotnetGeneratorDeliveryData(nodeStats) {
1461
+ function hasDotnetGeneratorDeliveryData(nodeStats, localReportInput = (0, local_report_input_js_1.emptyLocalReportInput)()) {
1171
1462
  const schedulerStats = reportObject(nodeStats, "schedulerStats", "SchedulerStats");
1172
1463
  const observationStats = reportObject(nodeStats, "observationDeliveryStats", "ObservationDeliveryStats");
1173
1464
  const hasNonZeroDecimal = (value) => {
@@ -1175,6 +1466,7 @@ function hasDotnetGeneratorDeliveryData(nodeStats) {
1175
1466
  return text.trim().length > 0 && text !== "0";
1176
1467
  };
1177
1468
  return reportArray(nodeStats, "generatorWarnings", "GeneratorWarnings").length > 0
1469
+ || localReportInput.history.status === "unavailable"
1178
1470
  || reportArray(nodeStats, "schedulerSegments", "SchedulerSegments").length > 0
1179
1471
  || reportArray(schedulerStats, "segments", "Segments").length > 0
1180
1472
  || asInt(reportValue(schedulerStats, "configuredMaxInFlight", "ConfiguredMaxInFlight")) > 0
@@ -1194,15 +1486,19 @@ function buildDotnetGroupedCorrelationSummaryHtml(rows, groupedChartKey) {
1194
1486
  return parts.join("");
1195
1487
  }
1196
1488
  appendReportLine(parts);
1197
- 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);
1198
1492
  appendReportLine(parts, "<div class=\"chart-grid correlation-chart-grid\">");
1199
1493
  payloads.forEach((payload, index) => {
1200
1494
  const chartKey = `${groupedChartKey}-value-${index}`;
1201
1495
  const chartJson = escapeJsonForHtmlScript(JSON.stringify(payload.chart));
1202
1496
  appendReportLine(parts, `<script type="application/json" data-grouped-correlation-chart="${escapeHtml(chartKey)}">${chartJson}</script>`);
1203
- 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)}"`);
1204
1498
  });
1205
1499
  appendReportLine(parts, "</div>");
1500
+ appendReportLine(parts, "<div class=\"chart-empty\" data-chart-empty>No charts match this search.</div>");
1501
+ appendReportLine(parts, "</div>");
1206
1502
  return parts.join("");
1207
1503
  }
1208
1504
  function buildDotnetUngroupedCorrelationSummaryHtml(rows, groupedChartKey) {
@@ -1214,8 +1510,12 @@ function buildDotnetUngroupedCorrelationSummaryHtml(rows, groupedChartKey) {
1214
1510
  appendReportLine(parts);
1215
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>");
1216
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);
1217
1515
  appendReportLine(parts, "<div class=\"chart-grid correlation-chart-grid\">");
1218
- 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>");
1219
1519
  appendReportLine(parts, "</div>");
1220
1520
  }
1221
1521
  return parts.join("");
@@ -1227,8 +1527,8 @@ function buildDotnetPluginHints(plugin) {
1227
1527
  const hints = reportArray(plugin, "hints", "Hints").map((hint) => asString(hint).trim()).filter((hint) => hint.length > 0);
1228
1528
  return `<div class="card"><strong>Hints</strong><ul>${hints.map((hint) => `<li>${escapeHtml(hint)}</li>`).join("")}</ul></div>`;
1229
1529
  }
1230
- function buildDotnetHtmlTabs(nodeStats) {
1231
- 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)]];
1232
1532
  const scenarioRows = buildDotnetScenarioRows(nodeStats);
1233
1533
  if (scenarioRows.length) {
1234
1534
  tabs.push(["scenarios", "Scenarios", buildDotnetTableHtml(scenarioRows)]);
@@ -1262,8 +1562,8 @@ function buildDotnetHtmlTabs(nodeStats) {
1262
1562
  if (metricRows.length) {
1263
1563
  tabs.push(["metrics", "Metrics", buildDotnetTableHtml(metricRows)]);
1264
1564
  }
1265
- if (hasDotnetGeneratorDeliveryData(nodeStats)) {
1266
- tabs.push(["generator-delivery", "Generator Delivery", buildDotnetGeneratorDeliveryHtml(nodeStats)]);
1565
+ if (hasDotnetGeneratorDeliveryData(nodeStats, localReportInput)) {
1566
+ tabs.push(["generator-delivery", "Generator Delivery", buildDotnetGeneratorDeliveryHtml(nodeStats, localReportInput)]);
1267
1567
  }
1268
1568
  for (const plugin of reportArray(nodeStats, "pluginsData", "PluginsData")) {
1269
1569
  const pluginName = asString(reportValue(plugin, "pluginName", "PluginName"));
@@ -1410,41 +1710,47 @@ function buildDotnetMarkdownReport(nodeStats) {
1410
1710
  return reportLines(lines);
1411
1711
  }
1412
1712
  /**
1413
- * 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.
1414
1716
  */
1415
- function buildDotnetHtmlReport(nodeStats) {
1416
- const tabs = buildDotnetHtmlTabs(nodeStats);
1417
- const buttonsHtml = tabs.map(([tabId, title]) => `<button class="tab-btn" data-tab="${escapeHtml(tabId)}">${escapeHtml(title)}</button>${REPORT_EOL}`).join("");
1418
- const sectionsHtml = tabs.map(([tabId, , html]) => `<section id="${escapeHtml(tabId)}" class="tab">${html}</section>${REPORT_EOL}`).join("");
1419
- const chartDataJson = escapeJsonForHtmlScript(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)));
1420
1726
  const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
1421
1727
  const template = `<!doctype html>
1422
1728
  <html lang="en">
1423
1729
  <head>
1424
1730
  <meta charset="utf-8" />
1425
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'" />
1426
1733
  <title>LoadStrike Report</title>
1427
1734
  <style>
1428
- :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)}
1429
- 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)}
1430
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}
1431
1738
  .wrap{max-width:1440px;margin:0 auto;padding:20px}
1432
- .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}
1433
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}
1434
- .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}
1435
1742
  body[data-theme='dark'] .report-logo{--report-logo-scale:1.175}
1436
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}
1437
1744
  .theme-toggle-report:hover{border-color:var(--accent);transform:translateY(-1px)}
1438
- .theme-toggle-report span{line-height:1;pointer-events:none}
1439
- h1{margin:0 0 10px 0;font-size:28px;letter-spacing:.2px}
1440
- h2{margin:0 0 12px 0;font-size:18px}
1441
- 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)}
1442
1748
  .meta{display:flex;gap:14px;flex-wrap:wrap;color:var(--muted);font-size:13px;margin-bottom:16px}
1443
1749
  .meta span{background:var(--chip);border:1px solid var(--line);padding:6px 10px;border-radius:999px}
1444
1750
  .report-layout{display:grid;grid-template-columns:280px minmax(0,1fr);gap:14px;align-items:start}
1445
1751
  .tabs-pane{position:sticky;top:12px;max-height:calc(100vh - 24px);overflow:auto;overscroll-behavior:contain;padding-right:4px;cursor:grab}
1446
1752
  .tabs-pane.panning{cursor:grabbing;user-select:none}
1447
- .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}
1448
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%}
1449
1755
  .tab-btn:hover{border-color:var(--accent);background:var(--chip)}
1450
1756
  .tab-btn.active{background:linear-gradient(180deg,#2f66db 0,#2754b8 100%);border-color:#3f73e0}
@@ -1463,22 +1769,19 @@ th{background:var(--panelAlt);position:sticky;top:0;z-index:1}
1463
1769
  .value-fail{color:var(--fail)}
1464
1770
  .chart-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:12px}
1465
1771
  .chart-card{padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--chartPanel)}
1466
- .chart-canvas{width:100%;height:260px;display:block}
1467
- .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}
1468
1773
  .correlation-chart-card{max-width:720px;width:100%}
1469
- .correlation-chart-card .chart-canvas{height:320px}
1470
1774
  .chart-card h3,.chart-card p{color:var(--chartLegendText)}
1471
1775
  .table-wrap{overflow:auto;max-height:70vh}
1472
- @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}}
1473
1778
  </style>
1474
1779
  </head>
1475
1780
  <body data-theme="light">
1476
1781
  <div class="wrap">
1477
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>
1478
1783
  <div class="report-brand">
1479
- <div class="report-logo-slot">
1480
- <img src="__LOGO_LIGHT__" alt="LoadStrike logo" class="report-logo" data-report-logo data-logo-light="__LOGO_LIGHT__" data-logo-dark="__LOGO_DARK__" />
1481
- </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>
1482
1785
  <h1>LoadStrike Report</h1>
1483
1786
  </div>
1484
1787
  <div class="meta">
@@ -1488,44 +1791,18 @@ th{background:var(--panelAlt);position:sticky;top:0;z-index:1}
1488
1791
  <span>Duration: <strong>__DURATION__</strong></span>
1489
1792
  </div>
1490
1793
  <div class="report-layout">
1491
- <aside class="tabs-pane" id="tab-pane">
1492
- <div class="tabs">
1493
- __BUTTONS__</div>
1494
- </aside>
1794
+ <aside class="tabs-pane" id="tab-pane"><div class="tabs">
1795
+ __BUTTONS__</div></aside>
1495
1796
  <div class="tabs-content">
1496
1797
  __SECTIONS__</div>
1497
1798
  </div>
1498
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>
1499
1803
  <script>
1500
1804
  const reportCharts=__CHART_DATA__;
1501
- const btns=[...document.querySelectorAll('.tab-btn')];
1502
- const tabSections=[...document.querySelectorAll('.tab')];
1503
- const tabsPane=document.getElementById('tab-pane');
1504
- const linePalette=['#38bdf8','#22c55e','#f59e0b','#a855f7','#f43f5e','#14b8a6','#eab308','#818cf8','#06b6d4','#84cc16'];
1505
- const reportThemeKey='loadstrike-report-theme';
1506
- const reportThemeToggle=document.querySelector('[data-report-theme-toggle]');
1507
- const reportLogo=document.querySelector('[data-report-logo]');
1508
- 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');}}
1509
- function show(id){btns.forEach(b=>b.classList.toggle('active',b.dataset.tab===id));tabSections.forEach(t=>t.classList.toggle('active',t.id===id));}
1510
- function formatMetric(v){if(!Number.isFinite(v))return '0';return Math.abs(v)>=100?v.toFixed(0):v.toFixed(2);}
1511
- 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};}
1512
- function drawNoData(ctx,w,h,msg){ctx.fillStyle='#9fb0c3';ctx.font='13px Segoe UI';ctx.textAlign='center';ctx.fillText(msg,w/2,h/2);}
1513
- 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);}}
1514
- 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;}}
1515
- 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;}}}
1516
- 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);}
1517
- 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:[]};}}
1518
- 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:[]};}}
1519
- 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};}
1520
- 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);});}
1521
- 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);});}
1522
- 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');});}
1523
- function renderAllCharts(){renderCharts();renderUngroupedCorrelationCharts();renderGroupedCorrelationCharts();}
1524
- const storedReportTheme=(()=>{try{return localStorage.getItem(reportThemeKey);}catch{return null;}})();
1525
- applyReportTheme(storedReportTheme==='dark'?'dark':'light');
1526
- if(reportThemeToggle){reportThemeToggle.addEventListener('click',()=>{const next=document.body.getAttribute('data-theme')==='dark'?'light':'dark';applyReportTheme(next);try{localStorage.setItem(reportThemeKey,next);}catch{}renderAllCharts();});}
1527
- btns.forEach(b=>b.addEventListener('click',()=>{show(b.dataset.tab);requestAnimationFrame(renderAllCharts);}));
1528
- if(btns.length>0){show(btns[0].dataset.tab);}renderAllCharts();initPanePan();window.addEventListener('resize',renderAllCharts);
1805
+ ${reporting_svg_js_1.REPORT_SVG_SCRIPT.trimStart()}
1529
1806
  </script>
1530
1807
  </body>
1531
1808
  </html>`;