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