@loadstrike/loadstrike-sdk 1.0.31001 → 1.0.31601

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