@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.
- package/README.md +2 -0
- package/dist/cjs/local-report-input.js +21 -0
- package/dist/cjs/report-history.js +421 -0
- package/dist/cjs/reporting-svg.js +116 -0
- package/dist/cjs/reporting.js +413 -136
- package/dist/cjs/runtime.js +154 -5
- package/dist/esm/local-report-input.js +17 -0
- package/dist/esm/report-history.js +413 -0
- package/dist/esm/reporting-svg.js +113 -0
- package/dist/esm/reporting.js +413 -136
- package/dist/esm/runtime.js +154 -5
- package/dist/types/local-report-input.d.ts +6 -0
- package/dist/types/report-history.d.ts +124 -0
- package/dist/types/reporting-svg.d.ts +2 -0
- package/dist/types/reporting.d.ts +6 -3
- package/dist/types/runtime.d.ts +23 -0
- package/package.json +1 -1
package/dist/esm/reporting.js
CHANGED
|
@@ -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
|
|
520
|
-
if (
|
|
559
|
+
function percentileFromOrdered(values, percentileValue) {
|
|
560
|
+
if (values.length === 0) {
|
|
521
561
|
return 0;
|
|
522
562
|
}
|
|
523
|
-
|
|
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
|
|
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
|
-
|
|
565
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
918
|
-
.map((scenario) => ({ scenario, measurement:
|
|
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:
|
|
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:
|
|
1190
|
+
labels: scenarios.map((scenario) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
|
|
957
1191
|
series: [
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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 =
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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">${
|
|
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 (
|
|
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
|
|
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>
|
|
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
|
|
1107
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
1411
|
-
|
|
1412
|
-
|
|
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;--
|
|
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);--
|
|
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
|
|
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:
|
|
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
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
|
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-
|
|
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
|
-
|
|
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">☾</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
|
-
|
|
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
|
-
|
|
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?'☀':'☾';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>`;
|