@testsmith/perfornium 0.6.4 → 0.6.6
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/dist/cli/cli.js +16 -1
- package/dist/cli/commands/distributed.js +2 -2
- package/dist/cli/commands/report.js +2 -2
- package/dist/cli/commands/run.js +2 -0
- package/dist/config/parser.js +2 -2
- package/dist/config/types/global-config.d.ts +82 -2
- package/dist/config/types/scenario-config.d.ts +2 -2
- package/dist/config/types/step-types.d.ts +1 -1
- package/dist/core/data/data-manager.d.ts +70 -0
- package/dist/core/data/data-manager.js +186 -0
- package/dist/core/data/data-provider.d.ts +85 -0
- package/dist/core/data/data-provider.js +468 -0
- package/dist/core/data/index.d.ts +8 -0
- package/dist/core/data/index.js +13 -0
- package/dist/core/execution/check-evaluator.d.ts +10 -0
- package/dist/core/execution/check-evaluator.js +79 -0
- package/dist/core/execution/data-extractor.d.ts +6 -0
- package/dist/core/execution/data-extractor.js +70 -0
- package/dist/core/execution/index.d.ts +3 -0
- package/dist/core/execution/index.js +9 -0
- package/dist/core/execution/json-payload-processor.d.ts +7 -0
- package/dist/core/execution/json-payload-processor.js +140 -0
- package/dist/core/factories/index.d.ts +2 -0
- package/dist/core/factories/index.js +7 -0
- package/dist/core/factories/output-handler-factory.d.ts +10 -0
- package/dist/core/factories/output-handler-factory.js +91 -0
- package/dist/core/factories/protocol-handler-factory.d.ts +12 -0
- package/dist/core/factories/protocol-handler-factory.js +96 -0
- package/dist/core/index.d.ts +3 -2
- package/dist/core/index.js +8 -3
- package/dist/core/reporting/dashboard-reporter.d.ts +17 -0
- package/dist/core/reporting/dashboard-reporter.js +127 -0
- package/dist/core/reporting/index.d.ts +1 -0
- package/dist/core/reporting/index.js +5 -0
- package/dist/core/step-executor.d.ts +6 -20
- package/dist/core/step-executor.js +72 -366
- package/dist/core/strategies/index.d.ts +2 -0
- package/dist/core/strategies/index.js +7 -0
- package/dist/core/strategies/scenario-selector.d.ts +13 -0
- package/dist/core/strategies/scenario-selector.js +37 -0
- package/dist/core/strategies/think-time-strategy.d.ts +15 -0
- package/dist/core/strategies/think-time-strategy.js +71 -0
- package/dist/core/test-runner.d.ts +4 -11
- package/dist/core/test-runner.js +105 -312
- package/dist/core/virtual-user.d.ts +7 -37
- package/dist/core/virtual-user.js +29 -269
- package/dist/dashboard/routes/api.d.ts +64 -0
- package/dist/dashboard/routes/api.js +569 -0
- package/dist/dashboard/routes/index.d.ts +2 -0
- package/dist/dashboard/routes/index.js +7 -0
- package/dist/dashboard/routes/static.d.ts +6 -0
- package/dist/dashboard/routes/static.js +76 -0
- package/dist/dashboard/server.d.ts +8 -84
- package/dist/dashboard/server.js +76 -2007
- package/dist/dashboard/services/file-scanner.d.ts +7 -0
- package/dist/dashboard/services/file-scanner.js +114 -0
- package/dist/dashboard/services/index.d.ts +5 -0
- package/dist/dashboard/services/index.js +13 -0
- package/dist/dashboard/services/influxdb-service.d.ts +41 -0
- package/dist/dashboard/services/influxdb-service.js +329 -0
- package/dist/dashboard/services/metrics-parser.d.ts +12 -0
- package/dist/dashboard/services/metrics-parser.js +209 -0
- package/dist/dashboard/services/results-manager.d.ts +17 -0
- package/dist/dashboard/services/results-manager.js +311 -0
- package/dist/dashboard/services/test-executor.d.ts +41 -0
- package/dist/dashboard/services/test-executor.js +250 -0
- package/dist/dashboard/services/workers-manager.d.ts +13 -0
- package/dist/dashboard/services/workers-manager.js +81 -0
- package/dist/dashboard/templates/index.html +122 -0
- package/dist/dashboard/templates/scripts/main.js +3280 -0
- package/dist/dashboard/templates/styles.css +402 -0
- package/dist/dashboard/types.d.ts +168 -0
- package/dist/dashboard/types.js +2 -0
- package/dist/distributed/result-aggregator.js +1 -3
- package/dist/metrics/batch/batch-processor.d.ts +27 -0
- package/dist/metrics/batch/batch-processor.js +85 -0
- package/dist/metrics/batch/index.d.ts +1 -0
- package/dist/metrics/batch/index.js +5 -0
- package/dist/metrics/collector.d.ts +46 -45
- package/dist/metrics/collector.js +179 -640
- package/dist/metrics/core/error-tracker.d.ts +9 -0
- package/dist/metrics/core/error-tracker.js +52 -0
- package/dist/metrics/core/index.d.ts +3 -0
- package/dist/metrics/core/index.js +9 -0
- package/dist/metrics/core/result-storage.d.ts +19 -0
- package/dist/metrics/core/result-storage.js +56 -0
- package/dist/metrics/core/statistics-engine.d.ts +27 -0
- package/dist/metrics/core/statistics-engine.js +91 -0
- package/dist/metrics/output/file-writer.d.ts +19 -0
- package/dist/metrics/output/file-writer.js +129 -0
- package/dist/metrics/output/index.d.ts +2 -0
- package/dist/metrics/output/index.js +10 -0
- package/dist/metrics/output/influxdb-writer.d.ts +89 -0
- package/dist/metrics/output/influxdb-writer.js +404 -0
- package/dist/metrics/realtime/dispatcher.d.ts +18 -0
- package/dist/metrics/realtime/dispatcher.js +45 -0
- package/dist/metrics/realtime/endpoints/graphite.d.ts +3 -0
- package/dist/metrics/realtime/endpoints/graphite.js +61 -0
- package/dist/metrics/realtime/endpoints/influxdb.d.ts +3 -0
- package/dist/metrics/realtime/endpoints/influxdb.js +35 -0
- package/dist/metrics/realtime/endpoints/webhook.d.ts +3 -0
- package/dist/metrics/realtime/endpoints/webhook.js +22 -0
- package/dist/metrics/realtime/endpoints/websocket.d.ts +3 -0
- package/dist/metrics/realtime/endpoints/websocket.js +25 -0
- package/dist/metrics/realtime/index.d.ts +5 -0
- package/dist/metrics/realtime/index.js +13 -0
- package/dist/metrics/reporting/index.d.ts +3 -0
- package/dist/metrics/reporting/index.js +9 -0
- package/dist/metrics/reporting/step-statistics.d.ts +6 -0
- package/dist/metrics/reporting/step-statistics.js +59 -0
- package/dist/metrics/reporting/summary-generator.d.ts +16 -0
- package/dist/metrics/reporting/summary-generator.js +46 -0
- package/dist/metrics/reporting/timeline-calculator.d.ts +7 -0
- package/dist/metrics/reporting/timeline-calculator.js +86 -0
- package/dist/metrics/types.d.ts +58 -0
- package/dist/outputs/csv.d.ts +2 -0
- package/dist/outputs/csv.js +21 -2
- package/dist/outputs/json.js +6 -2
- package/dist/protocols/rest/handler.d.ts +4 -53
- package/dist/protocols/rest/handler.js +73 -454
- package/dist/protocols/rest/request/auth-handler.d.ts +4 -0
- package/dist/protocols/rest/request/auth-handler.js +30 -0
- package/dist/protocols/rest/request/body-processor.d.ts +11 -0
- package/dist/protocols/rest/request/body-processor.js +62 -0
- package/dist/protocols/rest/request/index.d.ts +2 -0
- package/dist/protocols/rest/request/index.js +7 -0
- package/dist/protocols/rest/response/checks.d.ts +6 -0
- package/dist/protocols/rest/response/checks.js +71 -0
- package/dist/protocols/rest/response/index.d.ts +2 -0
- package/dist/protocols/rest/response/index.js +7 -0
- package/dist/protocols/rest/response/size-calculator.d.ts +12 -0
- package/dist/protocols/rest/response/size-calculator.js +64 -0
- package/dist/protocols/web/browser/highlight.d.ts +7 -0
- package/dist/protocols/web/browser/highlight.js +47 -0
- package/dist/protocols/web/browser/index.d.ts +4 -0
- package/dist/protocols/web/browser/index.js +11 -0
- package/dist/protocols/web/browser/manager.d.ts +20 -0
- package/dist/protocols/web/browser/manager.js +189 -0
- package/dist/protocols/web/browser/screenshot.d.ts +8 -0
- package/dist/protocols/web/browser/screenshot.js +69 -0
- package/dist/protocols/web/browser/storage.d.ts +5 -0
- package/dist/protocols/web/browser/storage.js +45 -0
- package/dist/protocols/web/commands/index.d.ts +5 -0
- package/dist/protocols/web/commands/index.js +11 -0
- package/dist/protocols/web/commands/interaction.d.ts +13 -0
- package/dist/protocols/web/commands/interaction.js +68 -0
- package/dist/protocols/web/commands/measurement.d.ts +16 -0
- package/dist/protocols/web/commands/measurement.js +33 -0
- package/dist/protocols/web/commands/navigation.d.ts +11 -0
- package/dist/protocols/web/commands/navigation.js +43 -0
- package/dist/protocols/web/commands/types.d.ts +12 -0
- package/dist/protocols/web/commands/types.js +2 -0
- package/dist/protocols/web/commands/verification.d.ts +12 -0
- package/dist/protocols/web/commands/verification.js +118 -0
- package/dist/protocols/web/handler.d.ts +19 -30
- package/dist/protocols/web/handler.js +164 -651
- package/dist/protocols/web/network/capture.d.ts +19 -0
- package/dist/protocols/web/network/capture.js +225 -0
- package/dist/protocols/web/network/filters.d.ts +5 -0
- package/dist/protocols/web/network/filters.js +49 -0
- package/dist/protocols/web/network/index.d.ts +4 -0
- package/dist/protocols/web/network/index.js +9 -0
- package/dist/protocols/web/network/types.d.ts +13 -0
- package/dist/protocols/web/network/types.js +2 -0
- package/dist/protocols/web/network/utils.d.ts +8 -0
- package/dist/protocols/web/network/utils.js +29 -0
- package/dist/recorder/continue-recorder.d.ts +11 -0
- package/dist/recorder/continue-recorder.js +872 -0
- package/dist/reporting/chart-data/index.d.ts +5 -0
- package/dist/reporting/chart-data/index.js +13 -0
- package/dist/reporting/chart-data/network.d.ts +25 -0
- package/dist/reporting/chart-data/network.js +78 -0
- package/dist/reporting/chart-data/scenario.d.ts +37 -0
- package/dist/reporting/chart-data/scenario.js +76 -0
- package/dist/reporting/chart-data/step-statistics.d.ts +24 -0
- package/dist/reporting/chart-data/step-statistics.js +94 -0
- package/dist/reporting/chart-data/throughput.d.ts +16 -0
- package/dist/reporting/chart-data/throughput.js +24 -0
- package/dist/reporting/chart-data/timeline.d.ts +17 -0
- package/dist/reporting/chart-data/timeline.js +46 -0
- package/dist/reporting/handlebars-helpers.d.ts +1 -0
- package/dist/reporting/handlebars-helpers.js +63 -0
- package/dist/reporting/{enhanced-html-generator.d.ts → html-generator.d.ts} +1 -1
- package/dist/reporting/{enhanced-html-generator.js → html-generator.js} +10 -7
- package/dist/reporting/templates/{enhanced-report.hbs → report.hbs} +9 -9
- package/dist/utils/data-utils.d.ts +17 -0
- package/dist/utils/data-utils.js +129 -0
- package/dist/utils/template.js +2 -2
- package/package.json +5 -2
- package/dist/core/csv-data-provider.d.ts +0 -47
- package/dist/core/csv-data-provider.js +0 -265
- package/dist/reporting/generator.d.ts +0 -42
- package/dist/reporting/generator.js +0 -1217
- package/dist/reporting/templates/html.hbs +0 -2453
|
@@ -0,0 +1,3280 @@
|
|
|
1
|
+
/* global Chart, document, window, location, WebSocket, setTimeout, setInterval, clearInterval, fetch, console, confirm, alert, URL, URLSearchParams, requestAnimationFrame */
|
|
2
|
+
|
|
3
|
+
// State
|
|
4
|
+
let ws, liveTests = {}, results = [], testFiles = [], selectedForCompare = new Set(), charts = {}, runningTestId = null, workersData = null, infraMetrics = {};
|
|
5
|
+
|
|
6
|
+
// Shared crosshair state
|
|
7
|
+
let sharedCrosshair = { x: null, sourceChartId: null, timestamp: null, labelIndex: null };
|
|
8
|
+
|
|
9
|
+
// Get all active Chart.js instances
|
|
10
|
+
function getAllCharts() {
|
|
11
|
+
return Object.values(Chart.instances || {});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Store timestamp arrays for charts using category labels
|
|
15
|
+
const chartTimestamps = {};
|
|
16
|
+
|
|
17
|
+
// Shared crosshair plugin - syncs by x-axis time across all time-based charts
|
|
18
|
+
const sharedCrosshairPlugin = {
|
|
19
|
+
id: 'sharedCrosshair',
|
|
20
|
+
afterEvent(chart, args) {
|
|
21
|
+
const event = args.event;
|
|
22
|
+
// Skip charts with crosshair disabled
|
|
23
|
+
if (chart.options.plugins?.sharedCrosshair?.enabled === false) return;
|
|
24
|
+
|
|
25
|
+
if (event.type === 'mousemove' && args.inChartArea) {
|
|
26
|
+
const xScale = chart.scales.x;
|
|
27
|
+
if (!xScale) return;
|
|
28
|
+
|
|
29
|
+
sharedCrosshair.x = event.x;
|
|
30
|
+
sharedCrosshair.sourceChartId = chart.id;
|
|
31
|
+
|
|
32
|
+
// Get the x-value at the cursor position
|
|
33
|
+
const xValue = xScale.getValueForPixel(event.x);
|
|
34
|
+
|
|
35
|
+
// For scatter/linear charts, xValue is the actual timestamp
|
|
36
|
+
if (xScale.type === 'linear' || xScale.type === 'time' || xScale.type === 'timeseries') {
|
|
37
|
+
sharedCrosshair.timestamp = xValue;
|
|
38
|
+
sharedCrosshair.labelIndex = null;
|
|
39
|
+
}
|
|
40
|
+
// For category charts, find the label index and get timestamp from stored mapping
|
|
41
|
+
if (xScale.type === 'category') {
|
|
42
|
+
const labelIndex = Math.round(xValue);
|
|
43
|
+
sharedCrosshair.labelIndex = labelIndex;
|
|
44
|
+
// Try to get actual timestamp from stored mapping
|
|
45
|
+
const timestamps = chartTimestamps[chart.canvas.id];
|
|
46
|
+
if (timestamps && timestamps[labelIndex] !== undefined) {
|
|
47
|
+
sharedCrosshair.timestamp = timestamps[labelIndex];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Trigger redraw on all charts
|
|
52
|
+
getAllCharts().forEach(c => {
|
|
53
|
+
if (c && c.id !== chart.id && c.options.plugins?.sharedCrosshair?.enabled !== false) {
|
|
54
|
+
c.draw();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
} else if (event.type === 'mouseout') {
|
|
58
|
+
sharedCrosshair.x = null;
|
|
59
|
+
sharedCrosshair.sourceChartId = null;
|
|
60
|
+
sharedCrosshair.timestamp = null;
|
|
61
|
+
sharedCrosshair.labelIndex = null;
|
|
62
|
+
getAllCharts().forEach(c => {
|
|
63
|
+
if (c && c.options.plugins?.sharedCrosshair?.enabled !== false) {
|
|
64
|
+
c.draw();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
afterDraw(chart) {
|
|
70
|
+
// Skip if no crosshair or chart has it disabled
|
|
71
|
+
if (sharedCrosshair.x === null && sharedCrosshair.timestamp === null) return;
|
|
72
|
+
if (chart.options.plugins?.sharedCrosshair?.enabled === false) return;
|
|
73
|
+
|
|
74
|
+
const ctx = chart.ctx;
|
|
75
|
+
const chartArea = chart.chartArea;
|
|
76
|
+
const xScale = chart.scales.x;
|
|
77
|
+
|
|
78
|
+
if (!xScale || !chartArea) return;
|
|
79
|
+
|
|
80
|
+
let xPos;
|
|
81
|
+
|
|
82
|
+
if (chart.id === sharedCrosshair.sourceChartId) {
|
|
83
|
+
// Source chart - use exact pixel position
|
|
84
|
+
xPos = sharedCrosshair.x;
|
|
85
|
+
} else if (sharedCrosshair.timestamp !== null) {
|
|
86
|
+
// Try to sync by timestamp
|
|
87
|
+
if (xScale.type === 'linear' || xScale.type === 'time' || xScale.type === 'timeseries') {
|
|
88
|
+
// Linear/time scale - use timestamp directly
|
|
89
|
+
xPos = xScale.getPixelForValue(sharedCrosshair.timestamp);
|
|
90
|
+
} else if (xScale.type === 'category') {
|
|
91
|
+
// Category scale - find the closest timestamp in stored mapping
|
|
92
|
+
const timestamps = chartTimestamps[chart.canvas.id];
|
|
93
|
+
if (timestamps && timestamps.length > 0) {
|
|
94
|
+
// Find index with closest timestamp
|
|
95
|
+
let closestIdx = 0;
|
|
96
|
+
let closestDiff = Math.abs(timestamps[0] - sharedCrosshair.timestamp);
|
|
97
|
+
for (let i = 1; i < timestamps.length; i++) {
|
|
98
|
+
const diff = Math.abs(timestamps[i] - sharedCrosshair.timestamp);
|
|
99
|
+
if (diff < closestDiff) {
|
|
100
|
+
closestDiff = diff;
|
|
101
|
+
closestIdx = i;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
xPos = xScale.getPixelForValue(closestIdx);
|
|
105
|
+
} else if (sharedCrosshair.labelIndex !== null) {
|
|
106
|
+
// Fallback to label index if no timestamp mapping
|
|
107
|
+
const idx = sharedCrosshair.labelIndex;
|
|
108
|
+
if (idx >= 0 && idx < (chart.data.labels?.length || 0)) {
|
|
109
|
+
xPos = xScale.getPixelForValue(idx);
|
|
110
|
+
} else {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} else if (sharedCrosshair.labelIndex !== null) {
|
|
118
|
+
// Fallback to label index sync
|
|
119
|
+
const idx = sharedCrosshair.labelIndex;
|
|
120
|
+
if (idx >= 0 && idx < (chart.data.labels?.length || 0)) {
|
|
121
|
+
xPos = xScale.getPixelForValue(idx);
|
|
122
|
+
} else {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (xPos === undefined || xPos < chartArea.left || xPos > chartArea.right) return;
|
|
130
|
+
|
|
131
|
+
// Draw vertical crosshair line
|
|
132
|
+
ctx.save();
|
|
133
|
+
ctx.beginPath();
|
|
134
|
+
ctx.moveTo(xPos, chartArea.top);
|
|
135
|
+
ctx.lineTo(xPos, chartArea.bottom);
|
|
136
|
+
ctx.lineWidth = 1;
|
|
137
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
|
138
|
+
ctx.setLineDash([4, 4]);
|
|
139
|
+
ctx.stroke();
|
|
140
|
+
ctx.restore();
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Register crosshair plugin globally
|
|
145
|
+
Chart.register(sharedCrosshairPlugin);
|
|
146
|
+
|
|
147
|
+
// Initialize
|
|
148
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
149
|
+
initWebSocket();
|
|
150
|
+
loadResults();
|
|
151
|
+
loadTests();
|
|
152
|
+
loadWorkers();
|
|
153
|
+
loadInfrastructure();
|
|
154
|
+
setupTabs();
|
|
155
|
+
document.getElementById('compareBtn').addEventListener('click', runComparison);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// WebSocket
|
|
159
|
+
function initWebSocket() {
|
|
160
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
161
|
+
ws = new WebSocket(protocol + '//' + location.host);
|
|
162
|
+
ws.onopen = () => { document.getElementById('connectionStatus').innerHTML = '<span class="live-badge">Dashboard</span>'; };
|
|
163
|
+
ws.onclose = () => { document.getElementById('connectionStatus').innerHTML = '<span style="color: var(--text-secondary); font-size: 12px;">Reconnecting...</span>'; setTimeout(initWebSocket, 3000); };
|
|
164
|
+
ws.onmessage = (e) => handleMessage(JSON.parse(e.data));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function handleMessage(msg) {
|
|
168
|
+
if (msg.type === 'live_tests') { msg.data.forEach(t => liveTests[t.id] = t); renderLive(); }
|
|
169
|
+
else if (msg.type === 'live_update') { liveTests[msg.data.id] = msg.data; renderLive(); }
|
|
170
|
+
else if (msg.type === 'test_complete') { liveTests[msg.data.id] = msg.data; renderLive(); loadResults(); }
|
|
171
|
+
else if (msg.type === 'test_output') { appendConsole(msg.data); }
|
|
172
|
+
else if (msg.type === 'test_finished') { onTestFinished(msg.testId, msg.exitCode); }
|
|
173
|
+
else if (msg.type === 'infra_update') { handleInfraUpdate(msg.data); }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Infrastructure metrics handling
|
|
177
|
+
function handleInfraUpdate(data) {
|
|
178
|
+
const host = data.host;
|
|
179
|
+
if (!infraMetrics[host]) {
|
|
180
|
+
infraMetrics[host] = [];
|
|
181
|
+
}
|
|
182
|
+
infraMetrics[host].push(data);
|
|
183
|
+
// Keep last 120 entries (10 minutes at 5s intervals)
|
|
184
|
+
if (infraMetrics[host].length > 120) {
|
|
185
|
+
infraMetrics[host].shift();
|
|
186
|
+
}
|
|
187
|
+
renderInfrastructure();
|
|
188
|
+
// Also update Results tab if visible
|
|
189
|
+
const resultsPanel = document.getElementById('results');
|
|
190
|
+
if (resultsPanel && resultsPanel.classList.contains('active')) {
|
|
191
|
+
renderResults();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Tests
|
|
196
|
+
async function loadTests() {
|
|
197
|
+
try {
|
|
198
|
+
console.log('Loading tests...');
|
|
199
|
+
const res = await fetch('/api/tests');
|
|
200
|
+
testFiles = await res.json();
|
|
201
|
+
console.log('Loaded tests:', testFiles);
|
|
202
|
+
renderTests();
|
|
203
|
+
} catch (e) { console.error('Failed to load tests:', e); }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function loadWorkers() {
|
|
207
|
+
try {
|
|
208
|
+
const res = await fetch('/api/workers');
|
|
209
|
+
workersData = await res.json();
|
|
210
|
+
const section = document.getElementById('workersSection');
|
|
211
|
+
const info = document.getElementById('workersInfo');
|
|
212
|
+
const headerStatus = document.getElementById('workersStatus');
|
|
213
|
+
if (workersData.available && workersData.workers.length > 0) {
|
|
214
|
+
section.style.display = 'block';
|
|
215
|
+
const totalCapacity = workersData.workers.reduce((sum, w) => sum + (w.capacity || 0), 0);
|
|
216
|
+
const workerCount = workersData.workers.length;
|
|
217
|
+
info.textContent = '(' + workerCount + ' workers, ' + totalCapacity + ' total capacity)';
|
|
218
|
+
// Show workers info in header
|
|
219
|
+
const workerNames = workersData.workers.map(w => w.name || (w.host + ':' + w.port)).join(', ');
|
|
220
|
+
headerStatus.innerHTML = '<span style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; background: linear-gradient(135deg, #9c40ff 0%, #00d4ff 100%); border-radius: 20px; font-size: 12px; color: white; font-weight: 500; cursor: help;" title="' + workerNames + '"><span style="width: 8px; height: 8px; background: white; border-radius: 50%; animation: pulse 1.5s infinite;"></span>' + workerCount + ' Worker' + (workerCount > 1 ? 's' : '') + '</span>';
|
|
221
|
+
} else {
|
|
222
|
+
headerStatus.innerHTML = '';
|
|
223
|
+
}
|
|
224
|
+
} catch (e) { console.error('Failed to load workers:', e); }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Infrastructure
|
|
228
|
+
async function loadInfrastructure() {
|
|
229
|
+
try {
|
|
230
|
+
const res = await fetch('/api/infra');
|
|
231
|
+
const data = await res.json();
|
|
232
|
+
// data is { host: latestMetrics, ... } format, fetch full history for each
|
|
233
|
+
for (const host of Object.keys(data)) {
|
|
234
|
+
if (data[host]) {
|
|
235
|
+
const histRes = await fetch('/api/infra/' + encodeURIComponent(host));
|
|
236
|
+
const histData = await histRes.json();
|
|
237
|
+
infraMetrics[host] = histData.metrics || [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
renderInfrastructure();
|
|
241
|
+
} catch (e) { console.error('Failed to load infrastructure metrics:', e); }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Track rendered infra hosts to avoid DOM rebuilds
|
|
245
|
+
let renderedInfraHosts = new Set();
|
|
246
|
+
|
|
247
|
+
// Create HTML for a single infra host card
|
|
248
|
+
function createInfraHostCard(host) {
|
|
249
|
+
return `
|
|
250
|
+
<div class="card" id="infra-host-${host.replace(/[^a-zA-Z0-9]/g, '_')}">
|
|
251
|
+
<div class="card-header">
|
|
252
|
+
<h3>${host}</h3>
|
|
253
|
+
<span class="live-badge">Connected</span>
|
|
254
|
+
</div>
|
|
255
|
+
<p class="infra-last-update" style="color: var(--text-secondary); font-size: 11px; margin-bottom: 16px;">Last update: -</p>
|
|
256
|
+
|
|
257
|
+
<!-- Current Metrics -->
|
|
258
|
+
<div class="grid-4" style="margin-bottom: 20px;">
|
|
259
|
+
<div class="metric-card">
|
|
260
|
+
<div class="value infra-cpu-value">-%</div>
|
|
261
|
+
<div class="label">CPU</div>
|
|
262
|
+
</div>
|
|
263
|
+
<div class="metric-card">
|
|
264
|
+
<div class="value infra-mem-value">-%</div>
|
|
265
|
+
<div class="label infra-mem-label">Memory</div>
|
|
266
|
+
</div>
|
|
267
|
+
<div class="metric-card">
|
|
268
|
+
<div class="value infra-disk-value">-%</div>
|
|
269
|
+
<div class="label infra-disk-label">Disk</div>
|
|
270
|
+
</div>
|
|
271
|
+
<div class="metric-card">
|
|
272
|
+
<div class="value infra-net-value">-</div>
|
|
273
|
+
<div class="label">Network I/O</div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<!-- Charts -->
|
|
278
|
+
<div class="grid-2" style="gap: 12px;">
|
|
279
|
+
<div class="card expandable" style="background: var(--bg-secondary); border-radius: 8px; padding: 12px; margin-bottom: 0;">
|
|
280
|
+
${expandBtnHtml()}
|
|
281
|
+
<h4 style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">CPU Usage</h4>
|
|
282
|
+
<div class="chart-container" style="height: 120px;"><canvas id="infra-cpu-${host}"></canvas></div>
|
|
283
|
+
</div>
|
|
284
|
+
<div class="card expandable" style="background: var(--bg-secondary); border-radius: 8px; padding: 12px; margin-bottom: 0;">
|
|
285
|
+
${expandBtnHtml()}
|
|
286
|
+
<h4 class="infra-mem-chart-label" style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">Memory Usage</h4>
|
|
287
|
+
<div class="chart-container" style="height: 120px;"><canvas id="infra-mem-${host}"></canvas></div>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="card expandable" style="background: var(--bg-secondary); border-radius: 8px; padding: 12px; margin-bottom: 0;">
|
|
290
|
+
${expandBtnHtml()}
|
|
291
|
+
<h4 style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">Disk Usage</h4>
|
|
292
|
+
<div class="chart-container" style="height: 120px;"><canvas id="infra-disk-${host}"></canvas></div>
|
|
293
|
+
</div>
|
|
294
|
+
<div class="card expandable" style="background: var(--bg-secondary); border-radius: 8px; padding: 12px; margin-bottom: 0;">
|
|
295
|
+
${expandBtnHtml()}
|
|
296
|
+
<h4 style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">Network I/O</h4>
|
|
297
|
+
<div class="chart-container" style="height: 120px;"><canvas id="infra-net-${host}"></canvas></div>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Update metrics display for a host without rebuilding DOM
|
|
305
|
+
function updateInfraHostMetrics(host) {
|
|
306
|
+
const hostId = host.replace(/[^a-zA-Z0-9]/g, '_');
|
|
307
|
+
const card = document.getElementById('infra-host-' + hostId);
|
|
308
|
+
if (!card) return;
|
|
309
|
+
|
|
310
|
+
const history = infraMetrics[host] || [];
|
|
311
|
+
const latest = history[history.length - 1] || {};
|
|
312
|
+
const metrics = latest.metrics || {};
|
|
313
|
+
|
|
314
|
+
const cpu = metrics.cpu?.usage_percent ?? '-';
|
|
315
|
+
const mem = metrics.memory?.usage_percent ?? '-';
|
|
316
|
+
const memUsed = metrics.memory?.used_mb ?? 0;
|
|
317
|
+
const memTotal = metrics.memory?.total_mb ?? 0;
|
|
318
|
+
const disk = metrics.disk?.usage_percent ?? '-';
|
|
319
|
+
const diskUsed = metrics.disk?.used_gb ?? 0;
|
|
320
|
+
const diskTotal = metrics.disk?.total_gb ?? 0;
|
|
321
|
+
const netIn = metrics.network?.bytes_in ?? 0;
|
|
322
|
+
const netOut = metrics.network?.bytes_out ?? 0;
|
|
323
|
+
const interval = latest.interval_seconds || 5;
|
|
324
|
+
const netRate = (netIn + netOut) / interval;
|
|
325
|
+
const lastUpdate = latest.timestamp ? new Date(latest.timestamp).toLocaleTimeString() : '-';
|
|
326
|
+
|
|
327
|
+
// Update text values
|
|
328
|
+
const lastUpdateEl = card.querySelector('.infra-last-update');
|
|
329
|
+
if (lastUpdateEl) lastUpdateEl.textContent = 'Last update: ' + lastUpdate;
|
|
330
|
+
|
|
331
|
+
const cpuEl = card.querySelector('.infra-cpu-value');
|
|
332
|
+
if (cpuEl) {
|
|
333
|
+
cpuEl.textContent = (typeof cpu === 'number' ? cpu.toFixed(1) : cpu) + '%';
|
|
334
|
+
cpuEl.style.color = cpu !== '-' && cpu > 80 ? '#ef4444' : cpu !== '-' && cpu > 60 ? '#eab308' : '';
|
|
335
|
+
cpuEl.style.webkitTextFillColor = cpuEl.style.color || '';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const memEl = card.querySelector('.infra-mem-value');
|
|
339
|
+
if (memEl) {
|
|
340
|
+
memEl.textContent = (typeof mem === 'number' ? mem.toFixed(1) : mem) + '%';
|
|
341
|
+
memEl.style.color = mem !== '-' && mem > 85 ? '#ef4444' : mem !== '-' && mem > 70 ? '#eab308' : '';
|
|
342
|
+
memEl.style.webkitTextFillColor = memEl.style.color || '';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const memLabelEl = card.querySelector('.infra-mem-label');
|
|
346
|
+
if (memLabelEl && memTotal) {
|
|
347
|
+
memLabelEl.textContent = 'Memory';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const memChartLabel = card.querySelector('.infra-mem-chart-label');
|
|
351
|
+
if (memChartLabel && memTotal) {
|
|
352
|
+
memChartLabel.textContent = `Memory Usage (${(memUsed/1024).toFixed(1)}/${(memTotal/1024).toFixed(1)} GB)`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const diskEl = card.querySelector('.infra-disk-value');
|
|
356
|
+
if (diskEl) {
|
|
357
|
+
diskEl.textContent = (typeof disk === 'number' ? disk.toFixed(1) : disk) + '%';
|
|
358
|
+
diskEl.style.color = disk !== '-' && disk > 90 ? '#ef4444' : disk !== '-' && disk > 80 ? '#eab308' : '';
|
|
359
|
+
diskEl.style.webkitTextFillColor = diskEl.style.color || '';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const diskLabelEl = card.querySelector('.infra-disk-label');
|
|
363
|
+
if (diskLabelEl && diskTotal) {
|
|
364
|
+
diskLabelEl.textContent = `Disk (${diskUsed.toFixed(0)}/${diskTotal.toFixed(0)} GB)`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const netEl = card.querySelector('.infra-net-value');
|
|
368
|
+
if (netEl) {
|
|
369
|
+
netEl.textContent = formatBytesPerSec(netRate);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Update charts
|
|
373
|
+
if (history.length >= 2) {
|
|
374
|
+
const labels = history.map(h => {
|
|
375
|
+
const d = new Date(h.timestamp);
|
|
376
|
+
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}.${d.getMilliseconds().toString().padStart(3, '0')}`;
|
|
377
|
+
});
|
|
378
|
+
const infraTimestamps = history.map(h => new Date(h.timestamp).getTime());
|
|
379
|
+
|
|
380
|
+
createOrUpdateChart('infra-cpu-' + host, 'line', labels, [{
|
|
381
|
+
label: 'CPU %',
|
|
382
|
+
data: history.map(h => h.metrics?.cpu?.usage_percent ?? 0),
|
|
383
|
+
borderColor: '#00d4ff',
|
|
384
|
+
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
|
385
|
+
fill: true,
|
|
386
|
+
tension: 0.3
|
|
387
|
+
}], infraTimestamps);
|
|
388
|
+
|
|
389
|
+
createOrUpdateChart('infra-mem-' + host, 'line', labels, [{
|
|
390
|
+
label: 'Memory %',
|
|
391
|
+
data: history.map(h => h.metrics?.memory?.usage_percent ?? 0),
|
|
392
|
+
borderColor: '#9c40ff',
|
|
393
|
+
backgroundColor: 'rgba(156, 64, 255, 0.1)',
|
|
394
|
+
fill: true,
|
|
395
|
+
tension: 0.3
|
|
396
|
+
}], infraTimestamps);
|
|
397
|
+
|
|
398
|
+
createOrUpdateChart('infra-disk-' + host, 'line', labels, [{
|
|
399
|
+
label: 'Disk %',
|
|
400
|
+
data: history.map(h => h.metrics?.disk?.usage_percent ?? 0),
|
|
401
|
+
borderColor: '#22c55e',
|
|
402
|
+
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
|
403
|
+
fill: true,
|
|
404
|
+
tension: 0.3
|
|
405
|
+
}], infraTimestamps);
|
|
406
|
+
|
|
407
|
+
createOrUpdateChart('infra-net-' + host, 'line', labels, [{
|
|
408
|
+
label: 'In',
|
|
409
|
+
data: history.map(h => (h.metrics?.network?.bytes_in ?? 0) / 1024),
|
|
410
|
+
borderColor: '#00d4ff',
|
|
411
|
+
fill: false,
|
|
412
|
+
tension: 0.3
|
|
413
|
+
}, {
|
|
414
|
+
label: 'Out',
|
|
415
|
+
data: history.map(h => (h.metrics?.network?.bytes_out ?? 0) / 1024),
|
|
416
|
+
borderColor: '#ef4444',
|
|
417
|
+
fill: false,
|
|
418
|
+
tension: 0.3
|
|
419
|
+
}], infraTimestamps);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function renderInfrastructure() {
|
|
424
|
+
const container = document.getElementById('infraContainer');
|
|
425
|
+
if (!container) return;
|
|
426
|
+
|
|
427
|
+
const hosts = Object.keys(infraMetrics);
|
|
428
|
+
|
|
429
|
+
// Show empty state if no hosts
|
|
430
|
+
if (!hosts.length) {
|
|
431
|
+
renderedInfraHosts.clear();
|
|
432
|
+
container.innerHTML = `
|
|
433
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
|
434
|
+
<h2 style="margin: 0;">Infrastructure Monitoring</h2>
|
|
435
|
+
<div style="display: flex; gap: 8px;">
|
|
436
|
+
<button onclick="importInfraMetrics('json')" class="btn btn-secondary" style="font-size: 12px; padding: 6px 12px;">
|
|
437
|
+
Import JSON
|
|
438
|
+
</button>
|
|
439
|
+
<button onclick="importInfraMetrics('csv')" class="btn btn-secondary" style="font-size: 12px; padding: 6px 12px;">
|
|
440
|
+
Import CSV
|
|
441
|
+
</button>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
<div class="empty-state">
|
|
445
|
+
<h3>No infrastructure agents connected</h3>
|
|
446
|
+
<p>Send metrics to <code>POST /api/infra</code> to see server monitoring</p>
|
|
447
|
+
<div style="margin-top: 16px; text-align: left; background: var(--bg-secondary); border-radius: 8px; padding: 16px; max-width: 600px;">
|
|
448
|
+
<p style="color: var(--text-secondary); font-size: 12px; margin-bottom: 8px;">Example curl command:</p>
|
|
449
|
+
<code style="font-size: 11px; color: #00d4ff; word-break: break-all;">curl -X POST localhost:3000/api/infra -H "Content-Type: application/json" -d '{"host":"server1","type":"infrastructure_metrics","metrics":{"cpu":{"usage_percent":45},"memory":{"used_mb":8192,"total_mb":16384,"usage_percent":50}}}'</code>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
`;
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Check if we need to rebuild the DOM (new hosts added or hosts removed)
|
|
457
|
+
const currentHostSet = new Set(hosts);
|
|
458
|
+
const needsRebuild = hosts.some(h => !renderedInfraHosts.has(h)) ||
|
|
459
|
+
[...renderedInfraHosts].some(h => !currentHostSet.has(h)) ||
|
|
460
|
+
!container.querySelector('.grid-2');
|
|
461
|
+
|
|
462
|
+
if (needsRebuild) {
|
|
463
|
+
renderedInfraHosts = currentHostSet;
|
|
464
|
+
container.innerHTML = `
|
|
465
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
|
466
|
+
<h2 style="margin: 0;">Infrastructure Monitoring <span id="infraHostCount" style="font-size: 14px; color: var(--text-secondary); font-weight: normal;">(${hosts.length} host${hosts.length > 1 ? 's' : ''})</span></h2>
|
|
467
|
+
<div style="display: flex; gap: 8px;">
|
|
468
|
+
<button onclick="exportAllInfra('json')" class="btn btn-secondary" style="font-size: 12px; padding: 6px 12px;">
|
|
469
|
+
Export JSON
|
|
470
|
+
</button>
|
|
471
|
+
<button onclick="exportAllInfra('csv')" class="btn btn-secondary" style="font-size: 12px; padding: 6px 12px;">
|
|
472
|
+
Export CSV
|
|
473
|
+
</button>
|
|
474
|
+
<button onclick="importInfraMetrics('json')" class="btn btn-secondary" style="font-size: 12px; padding: 6px 12px;">
|
|
475
|
+
Import JSON
|
|
476
|
+
</button>
|
|
477
|
+
<button onclick="importInfraMetrics('csv')" class="btn btn-secondary" style="font-size: 12px; padding: 6px 12px;">
|
|
478
|
+
Import CSV
|
|
479
|
+
</button>
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
<div class="grid-2" id="infraHostsGrid">
|
|
483
|
+
${hosts.map(host => createInfraHostCard(host)).join('')}
|
|
484
|
+
</div>
|
|
485
|
+
`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Update metrics for each host (without rebuilding DOM)
|
|
489
|
+
hosts.forEach(host => updateInfraHostMetrics(host));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function formatBytesPerSec(bytesPerSec) {
|
|
493
|
+
if (!bytesPerSec || bytesPerSec === 0) return '0 B/s';
|
|
494
|
+
const k = 1024;
|
|
495
|
+
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
|
496
|
+
const i = Math.floor(Math.log(bytesPerSec) / Math.log(k));
|
|
497
|
+
return parseFloat((bytesPerSec / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function renderLiveInfrastructure(testId) {
|
|
501
|
+
const hosts = Object.keys(infraMetrics);
|
|
502
|
+
if (!hosts.length) return '';
|
|
503
|
+
|
|
504
|
+
return `
|
|
505
|
+
<div class="card" style="margin-top: 20px;">
|
|
506
|
+
<h3 style="color: #00d4ff;">Infrastructure Metrics</h3>
|
|
507
|
+
<p style="color: var(--text-secondary); font-size: 11px; margin-bottom: 16px;">${hosts.length} host(s) connected</p>
|
|
508
|
+
<div class="grid-${Math.min(hosts.length, 2)}">
|
|
509
|
+
${hosts.map(host => {
|
|
510
|
+
const history = infraMetrics[host] || [];
|
|
511
|
+
const latest = history[history.length - 1] || {};
|
|
512
|
+
const metrics = latest.metrics || {};
|
|
513
|
+
const cpu = metrics.cpu?.usage_percent ?? '-';
|
|
514
|
+
const mem = metrics.memory?.usage_percent ?? '-';
|
|
515
|
+
const disk = metrics.disk?.usage_percent ?? '-';
|
|
516
|
+
const netIn = metrics.network?.bytes_in ?? 0;
|
|
517
|
+
const netOut = metrics.network?.bytes_out ?? 0;
|
|
518
|
+
const interval = latest.interval_seconds || 5;
|
|
519
|
+
const netRate = (netIn + netOut) / interval;
|
|
520
|
+
|
|
521
|
+
return `
|
|
522
|
+
<div style="background: var(--bg-secondary); border-radius: 8px; padding: 16px;">
|
|
523
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
|
524
|
+
<strong>${host}</strong>
|
|
525
|
+
<span class="live-badge" style="font-size: 10px;">Live</span>
|
|
526
|
+
</div>
|
|
527
|
+
<div class="grid-4" style="gap: 8px;">
|
|
528
|
+
<div style="text-align: center;">
|
|
529
|
+
<div style="font-size: 18px; font-weight: bold; color: ${cpu !== '-' && cpu > 80 ? '#ef4444' : cpu !== '-' && cpu > 60 ? '#eab308' : '#00d4ff'};">${typeof cpu === 'number' ? cpu.toFixed(0) : cpu}%</div>
|
|
530
|
+
<div style="font-size: 10px; color: var(--text-secondary);">CPU</div>
|
|
531
|
+
</div>
|
|
532
|
+
<div style="text-align: center;">
|
|
533
|
+
<div style="font-size: 18px; font-weight: bold; color: ${mem !== '-' && mem > 85 ? '#ef4444' : mem !== '-' && mem > 70 ? '#eab308' : '#9c40ff'};">${typeof mem === 'number' ? mem.toFixed(0) : mem}%</div>
|
|
534
|
+
<div style="font-size: 10px; color: var(--text-secondary);">Memory</div>
|
|
535
|
+
</div>
|
|
536
|
+
<div style="text-align: center;">
|
|
537
|
+
<div style="font-size: 18px; font-weight: bold; color: ${disk !== '-' && disk > 90 ? '#ef4444' : '#22c55e'};">${typeof disk === 'number' ? disk.toFixed(0) : disk}%</div>
|
|
538
|
+
<div style="font-size: 10px; color: var(--text-secondary);">Disk</div>
|
|
539
|
+
</div>
|
|
540
|
+
<div style="text-align: center;">
|
|
541
|
+
<div style="font-size: 18px; font-weight: bold;">${formatBytesPerSec(netRate)}</div>
|
|
542
|
+
<div style="font-size: 10px; color: var(--text-secondary);">Net I/O</div>
|
|
543
|
+
</div>
|
|
544
|
+
</div>
|
|
545
|
+
<div class="grid-2" style="margin-top: 12px; gap: 8px;">
|
|
546
|
+
<div style="height: 80px;"><canvas id="live-infra-cpu-${testId}-${host}"></canvas></div>
|
|
547
|
+
<div style="height: 80px;"><canvas id="live-infra-mem-${testId}-${host}"></canvas></div>
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
`;
|
|
551
|
+
}).join('')}
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function renderTests() {
|
|
558
|
+
const container = document.getElementById('testsList');
|
|
559
|
+
console.log('Rendering tests:', testFiles.length, 'files');
|
|
560
|
+
if (!testFiles.length) {
|
|
561
|
+
container.innerHTML = '<div class="empty-state"><h3>No tests found</h3><p>Add test files to your tests/ folder</p></div>';
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
container.innerHTML = testFiles.map((t, idx) => `
|
|
565
|
+
<div class="test-item">
|
|
566
|
+
<div class="test-info">
|
|
567
|
+
<div class="test-name">${t.name}</div>
|
|
568
|
+
<div class="test-path">${t.relativePath}</div>
|
|
569
|
+
</div>
|
|
570
|
+
<span class="test-type ${t.type}">${t.type}</span>
|
|
571
|
+
<button class="btn btn-primary btn-sm" style="margin-left: 12px;" onclick="runTestByIndex(${idx})" ${runningTestId ? 'disabled' : ''}>Run</button>
|
|
572
|
+
</div>
|
|
573
|
+
`).join('');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function runTestByIndex(idx) {
|
|
577
|
+
if (idx >= 0 && idx < testFiles.length) {
|
|
578
|
+
runTest(testFiles[idx].path);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function runTest(testPath) {
|
|
583
|
+
if (runningTestId) return;
|
|
584
|
+
|
|
585
|
+
// Get load override values
|
|
586
|
+
const vus = document.getElementById('loadVus').value;
|
|
587
|
+
const iterations = document.getElementById('loadIterations').value;
|
|
588
|
+
const duration = document.getElementById('loadDuration').value;
|
|
589
|
+
const rampUp = document.getElementById('loadRampUp').value;
|
|
590
|
+
|
|
591
|
+
// Build options object
|
|
592
|
+
const options = { verbose: true };
|
|
593
|
+
if (vus) options.vus = parseInt(vus);
|
|
594
|
+
if (iterations) options.iterations = parseInt(iterations);
|
|
595
|
+
if (duration) options.duration = duration;
|
|
596
|
+
if (rampUp) options.rampUp = rampUp;
|
|
597
|
+
|
|
598
|
+
// Check for headless mode
|
|
599
|
+
const headless = document.getElementById('headlessMode')?.checked;
|
|
600
|
+
if (headless) {
|
|
601
|
+
options.headless = true;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Check for distributed workers
|
|
605
|
+
const useWorkers = document.getElementById('useWorkers')?.checked;
|
|
606
|
+
if (useWorkers && workersData?.workers?.length > 0) {
|
|
607
|
+
options.workers = workersData.workers.map(w => w.host + ':' + w.port).join(',');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Show what's being run
|
|
611
|
+
let loadInfo = '';
|
|
612
|
+
const parts = [];
|
|
613
|
+
if (vus) parts.push('VUs: ' + vus);
|
|
614
|
+
if (iterations) parts.push('Iterations: ' + iterations);
|
|
615
|
+
if (duration) parts.push('Duration: ' + duration);
|
|
616
|
+
if (rampUp) parts.push('Ramp-up: ' + rampUp);
|
|
617
|
+
if (headless) parts.push('Headless');
|
|
618
|
+
if (useWorkers) parts.push('Workers: ' + workersData.workers.length);
|
|
619
|
+
if (parts.length) loadInfo = ' (' + parts.join(', ') + ')';
|
|
620
|
+
|
|
621
|
+
document.getElementById('testConsole').innerHTML = 'Starting test...' + loadInfo + '\n';
|
|
622
|
+
document.getElementById('testRunStatus').innerHTML = '<span class="live-badge">Running</span> <button class="btn btn-danger btn-sm" onclick="stopTest()" style="margin-left: 12px;">Stop Test</button>';
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
const res = await fetch('/api/tests/run', {
|
|
626
|
+
method: 'POST',
|
|
627
|
+
headers: { 'Content-Type': 'application/json' },
|
|
628
|
+
body: JSON.stringify({ testPath, options })
|
|
629
|
+
});
|
|
630
|
+
const data = await res.json();
|
|
631
|
+
runningTestId = data.testId;
|
|
632
|
+
renderTests();
|
|
633
|
+
} catch (e) {
|
|
634
|
+
appendConsole('Error: ' + e.message);
|
|
635
|
+
document.getElementById('testRunStatus').innerHTML = '';
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function stopTest() {
|
|
640
|
+
if (!runningTestId) return;
|
|
641
|
+
try {
|
|
642
|
+
await fetch('/api/tests/stop/' + runningTestId, { method: 'POST' });
|
|
643
|
+
} catch (e) { console.error(e); }
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function appendConsole(text) {
|
|
647
|
+
const consoleEl = document.getElementById('testConsole');
|
|
648
|
+
consoleEl.innerHTML += text;
|
|
649
|
+
consoleEl.scrollTop = consoleEl.scrollHeight;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function onTestFinished(testId, exitCode) {
|
|
653
|
+
if (testId === runningTestId) {
|
|
654
|
+
runningTestId = null;
|
|
655
|
+
const status = exitCode === 0 ? '<span class="status-badge good">Completed</span>' : '<span class="status-badge bad">Failed</span>';
|
|
656
|
+
document.getElementById('testRunStatus').innerHTML = status;
|
|
657
|
+
appendConsole('\n--- Test finished with exit code ' + exitCode + ' ---');
|
|
658
|
+
renderTests();
|
|
659
|
+
loadResults();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Live Tests
|
|
664
|
+
function renderLive() {
|
|
665
|
+
const container = document.getElementById('liveTestsContainer');
|
|
666
|
+
const running = Object.values(liveTests).filter(t => t.status === 'running');
|
|
667
|
+
|
|
668
|
+
if (!running.length) {
|
|
669
|
+
container.innerHTML = '<div class="empty-state"><h3>No tests running</h3><p>Start a test with <code>perfornium run your-test.yml</code> or from the Tests tab</p></div>';
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
container.innerHTML = running.map(test => `
|
|
674
|
+
<div class="card" id="live-${test.id}">
|
|
675
|
+
<div class="card-header">
|
|
676
|
+
<h3>${test.name}</h3>
|
|
677
|
+
<span class="live-badge">Running</span>
|
|
678
|
+
</div>
|
|
679
|
+
|
|
680
|
+
<!-- Primary Metrics Row -->
|
|
681
|
+
<div class="grid-6" style="margin-bottom: 20px;">
|
|
682
|
+
<div class="metric-card"><div class="value">${test.metrics.requests.toLocaleString()}</div><div class="label">Requests</div></div>
|
|
683
|
+
<div class="metric-card"><div class="value">${test.metrics.currentVUs}</div><div class="label">VUs</div></div>
|
|
684
|
+
<div class="metric-card"><div class="value">${test.metrics.avgResponseTime.toFixed(0)}ms</div><div class="label">Avg RT</div></div>
|
|
685
|
+
<div class="metric-card"><div class="value">${(test.history.length > 0 ? test.history[test.history.length-1].rps : 0).toFixed(1)}</div><div class="label">Req/s</div></div>
|
|
686
|
+
<div class="metric-card"><div class="value" style="${test.metrics.errors > 0 ? 'color: #ef4444 !important; -webkit-text-fill-color: #ef4444;' : ''}">${test.metrics.errors}</div><div class="label">Errors</div></div>
|
|
687
|
+
<div class="metric-card"><div class="value">${test.metrics.successRate ? test.metrics.successRate.toFixed(1) : (test.metrics.requests > 0 ? ((test.metrics.requests - test.metrics.errors) / test.metrics.requests * 100).toFixed(1) : 100)}%</div><div class="label">Success</div></div>
|
|
688
|
+
</div>
|
|
689
|
+
|
|
690
|
+
<!-- Response Time Percentiles Row -->
|
|
691
|
+
<div class="card" style="margin-bottom: 20px; padding: 16px;">
|
|
692
|
+
<h3 style="margin-bottom: 12px;">Response Time Percentiles</h3>
|
|
693
|
+
<div class="grid-4">
|
|
694
|
+
<div class="metric-card"><div class="value">${(test.metrics.p50ResponseTime || 0).toFixed(0)}ms</div><div class="label">P50 (Median)</div></div>
|
|
695
|
+
<div class="metric-card"><div class="value">${(test.metrics.p90ResponseTime || 0).toFixed(0)}ms</div><div class="label">P90</div></div>
|
|
696
|
+
<div class="metric-card"><div class="value" style="color: #eab308 !important; -webkit-text-fill-color: #eab308;">${(test.metrics.p95ResponseTime || 0).toFixed(0)}ms</div><div class="label">P95</div></div>
|
|
697
|
+
<div class="metric-card"><div class="value" style="color: #ef4444 !important; -webkit-text-fill-color: #ef4444;">${(test.metrics.p99ResponseTime || 0).toFixed(0)}ms</div><div class="label">P99</div></div>
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
|
|
701
|
+
<!-- Charts -->
|
|
702
|
+
<div class="grid-2">
|
|
703
|
+
<div class="card expandable" style="margin-bottom: 0;">
|
|
704
|
+
${expandBtnHtml()}
|
|
705
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
706
|
+
<h3>Individual Response Times</h3>
|
|
707
|
+
<div style="display: flex; gap: 16px; font-size: 12px;">
|
|
708
|
+
<span style="color: #22c55e;">Success</span>
|
|
709
|
+
<span style="color: #ef4444;">Failed</span>
|
|
710
|
+
<span style="color: var(--text-secondary);">(${test.responseTimes ? test.responseTimes.length : 0} samples)</span>
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
<div class="chart-container"><canvas id="chart-rt-${test.id}"></canvas></div>
|
|
714
|
+
</div>
|
|
715
|
+
<div class="card expandable" style="margin-bottom: 0;">
|
|
716
|
+
${expandBtnHtml()}
|
|
717
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
718
|
+
<h3>Throughput (req/s)</h3>
|
|
719
|
+
<span style="color: #9c40ff; font-size: 12px;">Current: <strong>${(test.history.length > 0 ? test.history[test.history.length-1].rps : 0).toFixed(1)} req/s</strong></span>
|
|
720
|
+
</div>
|
|
721
|
+
<div class="chart-container"><canvas id="chart-rps-${test.id}"></canvas></div>
|
|
722
|
+
</div>
|
|
723
|
+
</div>
|
|
724
|
+
<div class="grid-2" style="margin-top: 20px;">
|
|
725
|
+
<div class="card expandable" style="margin-bottom: 0;">
|
|
726
|
+
${expandBtnHtml()}
|
|
727
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
728
|
+
<h3>Virtual Users</h3>
|
|
729
|
+
<span style="color: #22c55e; font-size: 12px;">Active: <strong>${test.metrics.currentVUs}</strong></span>
|
|
730
|
+
</div>
|
|
731
|
+
<div class="chart-container"><canvas id="chart-vus-${test.id}"></canvas></div>
|
|
732
|
+
</div>
|
|
733
|
+
<div class="card expandable" style="margin-bottom: 0;">
|
|
734
|
+
${expandBtnHtml()}
|
|
735
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
736
|
+
<h3>Cumulative Errors</h3>
|
|
737
|
+
<span style="color: #ef4444; font-size: 12px;">Total: <strong>${test.metrics.errors}</strong></span>
|
|
738
|
+
</div>
|
|
739
|
+
<div class="chart-container"><canvas id="chart-err-${test.id}"></canvas></div>
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
|
|
743
|
+
<!-- Step Performance Statistics -->
|
|
744
|
+
${test.stepStats && test.stepStats.length > 0 ? `
|
|
745
|
+
<div class="card" style="margin-top: 20px;">
|
|
746
|
+
<h3>Step Performance Statistics</h3>
|
|
747
|
+
<div style="overflow-x: auto; margin-top: 12px;">
|
|
748
|
+
<table class="step-stats-table">
|
|
749
|
+
<thead>
|
|
750
|
+
<tr>
|
|
751
|
+
<th>Step Name</th>
|
|
752
|
+
<th>Scenario</th>
|
|
753
|
+
<th>Requests</th>
|
|
754
|
+
<th>Errors</th>
|
|
755
|
+
<th>Success Rate</th>
|
|
756
|
+
<th>Avg RT</th>
|
|
757
|
+
<th>P50</th>
|
|
758
|
+
<th>P95</th>
|
|
759
|
+
<th>P99</th>
|
|
760
|
+
<th>Status</th>
|
|
761
|
+
</tr>
|
|
762
|
+
</thead>
|
|
763
|
+
<tbody>
|
|
764
|
+
${test.stepStats.map(s => `
|
|
765
|
+
<tr>
|
|
766
|
+
<td><strong>${s.stepName}</strong></td>
|
|
767
|
+
<td>${s.scenario}</td>
|
|
768
|
+
<td>${s.requests}</td>
|
|
769
|
+
<td style="${s.errors > 0 ? 'color: #ef4444;' : ''}">${s.errors}</td>
|
|
770
|
+
<td>${s.successRate.toFixed(1)}%</td>
|
|
771
|
+
<td>${s.avgResponseTime}ms</td>
|
|
772
|
+
<td>${s.p50 || 0}ms</td>
|
|
773
|
+
<td>${s.p95 || 0}ms</td>
|
|
774
|
+
<td>${s.p99 || 0}ms</td>
|
|
775
|
+
<td><span class="status-badge ${s.successRate < 90 || (s.p95 || 0) >= 10000 ? 'bad' : s.successRate < 98 || (s.p95 || 0) >= 5000 ? 'warn' : 'good'}">
|
|
776
|
+
${s.successRate < 90 || (s.p95 || 0) >= 10000 ? 'Poor' : s.successRate < 98 || (s.p95 || 0) >= 5000 ? 'Warn' : 'Good'}
|
|
777
|
+
</span></td>
|
|
778
|
+
</tr>
|
|
779
|
+
`).join('')}
|
|
780
|
+
</tbody>
|
|
781
|
+
</table>
|
|
782
|
+
</div>
|
|
783
|
+
</div>
|
|
784
|
+
` : ''}
|
|
785
|
+
|
|
786
|
+
<!-- Top Errors -->
|
|
787
|
+
${test.topErrors && test.topErrors.length > 0 ? `
|
|
788
|
+
<div class="card" style="margin-top: 20px;">
|
|
789
|
+
<h3 style="color: #ef4444;">Top Errors (${test.topErrors.length})</h3>
|
|
790
|
+
<div style="overflow-x: auto; margin-top: 12px;">
|
|
791
|
+
<table class="step-stats-table">
|
|
792
|
+
<thead>
|
|
793
|
+
<tr>
|
|
794
|
+
<th>Count</th>
|
|
795
|
+
<th>Scenario</th>
|
|
796
|
+
<th>Action</th>
|
|
797
|
+
<th>Status</th>
|
|
798
|
+
<th>Error Message</th>
|
|
799
|
+
<th>URL</th>
|
|
800
|
+
</tr>
|
|
801
|
+
</thead>
|
|
802
|
+
<tbody>
|
|
803
|
+
${test.topErrors.map(e => `
|
|
804
|
+
<tr>
|
|
805
|
+
<td style="color: #ef4444; font-weight: bold;">${e.count}</td>
|
|
806
|
+
<td>${e.scenario || '-'}</td>
|
|
807
|
+
<td>${e.action || '-'}</td>
|
|
808
|
+
<td>${e.status !== undefined && e.status !== null ? e.status : '-'}</td>
|
|
809
|
+
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${e.error}">${e.error || '-'}</td>
|
|
810
|
+
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${e.url || ''}">${e.url || '-'}</td>
|
|
811
|
+
</tr>
|
|
812
|
+
`).join('')}
|
|
813
|
+
</tbody>
|
|
814
|
+
</table>
|
|
815
|
+
</div>
|
|
816
|
+
</div>
|
|
817
|
+
` : ''}
|
|
818
|
+
|
|
819
|
+
<!-- Infrastructure Metrics -->
|
|
820
|
+
${renderLiveInfrastructure(test.id)}
|
|
821
|
+
</div>
|
|
822
|
+
`).join('');
|
|
823
|
+
|
|
824
|
+
running.forEach(test => {
|
|
825
|
+
const history = test.history || [];
|
|
826
|
+
const _startTime = test.startTime ? new Date(test.startTime).getTime() : (history.length > 0 ? history[0].timestamp : Date.now()); // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
827
|
+
const labels = history.map(h => {
|
|
828
|
+
const d = new Date(h.timestamp);
|
|
829
|
+
const hh = d.getHours().toString().padStart(2, '0');
|
|
830
|
+
const mm = d.getMinutes().toString().padStart(2, '0');
|
|
831
|
+
const ss = d.getSeconds().toString().padStart(2, '0');
|
|
832
|
+
const ms = d.getMilliseconds().toString().padStart(3, '0');
|
|
833
|
+
return `${hh}:${mm}:${ss}.${ms}`;
|
|
834
|
+
});
|
|
835
|
+
// Store timestamps for crosshair sync
|
|
836
|
+
const historyTimestamps = history.map(h => h.timestamp);
|
|
837
|
+
|
|
838
|
+
// Create scatter plot for individual response times - colored by step
|
|
839
|
+
const responseTimes = test.responseTimes || [];
|
|
840
|
+
const rtStartTime = test.startTime ? new Date(test.startTime).getTime() : (responseTimes.length > 0 ? responseTimes[0].timestamp : Date.now());
|
|
841
|
+
|
|
842
|
+
// Color palette for different steps
|
|
843
|
+
const stepColors = [
|
|
844
|
+
{ bg: 'rgba(34, 197, 94, 0.6)', border: '#22c55e' }, // green
|
|
845
|
+
{ bg: 'rgba(59, 130, 246, 0.6)', border: '#3b82f6' }, // blue
|
|
846
|
+
{ bg: 'rgba(168, 85, 247, 0.6)', border: '#a855f7' }, // purple
|
|
847
|
+
{ bg: 'rgba(245, 158, 11, 0.6)', border: '#f59e0b' }, // amber
|
|
848
|
+
{ bg: 'rgba(236, 72, 153, 0.6)', border: '#ec4899' }, // pink
|
|
849
|
+
{ bg: 'rgba(20, 184, 166, 0.6)', border: '#14b8a6' }, // teal
|
|
850
|
+
{ bg: 'rgba(99, 102, 241, 0.6)', border: '#6366f1' }, // indigo
|
|
851
|
+
{ bg: 'rgba(249, 115, 22, 0.6)', border: '#f97316' }, // orange
|
|
852
|
+
];
|
|
853
|
+
|
|
854
|
+
// Helper to format timestamp as hh:mm:ss.mmm
|
|
855
|
+
const formatLiveTime = (ts) => {
|
|
856
|
+
const d = new Date(ts);
|
|
857
|
+
const hh = d.getHours().toString().padStart(2, '0');
|
|
858
|
+
const mm = d.getMinutes().toString().padStart(2, '0');
|
|
859
|
+
const ss = d.getSeconds().toString().padStart(2, '0');
|
|
860
|
+
const ms = d.getMilliseconds().toString().padStart(3, '0');
|
|
861
|
+
return `${hh}:${mm}:${ss}.${ms}`;
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
// Group response times by step name
|
|
865
|
+
const stepGroups = {};
|
|
866
|
+
const failedData = [];
|
|
867
|
+
responseTimes.forEach(r => {
|
|
868
|
+
const point = { x: r.timestamp, y: r.value, timestamp: r.timestamp };
|
|
869
|
+
if (!r.success) {
|
|
870
|
+
failedData.push(point);
|
|
871
|
+
} else {
|
|
872
|
+
const stepName = r.stepName || 'unknown';
|
|
873
|
+
if (!stepGroups[stepName]) stepGroups[stepName] = [];
|
|
874
|
+
stepGroups[stepName].push(point);
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// Create datasets for each step
|
|
879
|
+
const stepNames = Object.keys(stepGroups);
|
|
880
|
+
const datasets = stepNames.map((name, i) => {
|
|
881
|
+
const colors = stepColors[i % stepColors.length];
|
|
882
|
+
return {
|
|
883
|
+
label: name,
|
|
884
|
+
data: stepGroups[name],
|
|
885
|
+
backgroundColor: colors.bg,
|
|
886
|
+
borderColor: colors.border,
|
|
887
|
+
pointRadius: 3
|
|
888
|
+
};
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
// Add failed requests as a separate dataset (always red)
|
|
892
|
+
if (failedData.length > 0) {
|
|
893
|
+
datasets.push({
|
|
894
|
+
label: 'Failed',
|
|
895
|
+
data: failedData,
|
|
896
|
+
backgroundColor: 'rgba(239, 68, 68, 0.8)',
|
|
897
|
+
borderColor: '#ef4444',
|
|
898
|
+
pointRadius: 4
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
createScatterChart('chart-rt-' + test.id, datasets, rtStartTime, formatLiveTime);
|
|
903
|
+
createOrUpdateChart('chart-rps-' + test.id, 'line', labels, [{
|
|
904
|
+
label: 'Requests/sec', data: history.map(h => h.rps),
|
|
905
|
+
borderColor: '#9c40ff', backgroundColor: 'rgba(156, 64, 255, 0.1)', fill: true, tension: 0.3
|
|
906
|
+
}], historyTimestamps);
|
|
907
|
+
createOrUpdateChart('chart-vus-' + test.id, 'line', labels, [{
|
|
908
|
+
label: 'Virtual Users', data: history.map(h => h.vus),
|
|
909
|
+
borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.1)', fill: true, tension: 0.3, stepped: true
|
|
910
|
+
}], historyTimestamps);
|
|
911
|
+
createOrUpdateChart('chart-err-' + test.id, 'line', labels, [{
|
|
912
|
+
label: 'Errors', data: history.map(h => h.errors),
|
|
913
|
+
borderColor: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.1)', fill: true, tension: 0.3
|
|
914
|
+
}], historyTimestamps);
|
|
915
|
+
|
|
916
|
+
// Create infrastructure charts for each host
|
|
917
|
+
Object.keys(infraMetrics).forEach(host => {
|
|
918
|
+
const infraHistory = infraMetrics[host] || [];
|
|
919
|
+
if (infraHistory.length < 2) return;
|
|
920
|
+
|
|
921
|
+
const infraLabels = infraHistory.slice(-30).map((h, i) => i + 's');
|
|
922
|
+
const recentHistory = infraHistory.slice(-30);
|
|
923
|
+
|
|
924
|
+
createOrUpdateChart('live-infra-cpu-' + test.id + '-' + host, 'line', infraLabels, [{
|
|
925
|
+
label: 'CPU',
|
|
926
|
+
data: recentHistory.map(h => h.metrics?.cpu?.usage_percent ?? 0),
|
|
927
|
+
borderColor: '#00d4ff',
|
|
928
|
+
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
|
929
|
+
fill: true,
|
|
930
|
+
tension: 0.3,
|
|
931
|
+
pointRadius: 0
|
|
932
|
+
}]);
|
|
933
|
+
|
|
934
|
+
createOrUpdateChart('live-infra-mem-' + test.id + '-' + host, 'line', infraLabels, [{
|
|
935
|
+
label: 'Memory',
|
|
936
|
+
data: recentHistory.map(h => h.metrics?.memory?.usage_percent ?? 0),
|
|
937
|
+
borderColor: '#9c40ff',
|
|
938
|
+
backgroundColor: 'rgba(156, 64, 255, 0.1)',
|
|
939
|
+
fill: true,
|
|
940
|
+
tension: 0.3,
|
|
941
|
+
pointRadius: 0
|
|
942
|
+
}]);
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Results
|
|
948
|
+
async function loadResults() {
|
|
949
|
+
try {
|
|
950
|
+
const res = await fetch('/api/results');
|
|
951
|
+
results = await res.json();
|
|
952
|
+
renderResults();
|
|
953
|
+
renderCompareSelect();
|
|
954
|
+
} catch (e) { console.error('Failed to load results:', e); }
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async function deleteResult(id, event) {
|
|
958
|
+
event.stopPropagation();
|
|
959
|
+
if (!confirm('Are you sure you want to delete this result?')) return;
|
|
960
|
+
try {
|
|
961
|
+
const res = await fetch('/api/results/' + id, { method: 'DELETE' });
|
|
962
|
+
if (res.ok) {
|
|
963
|
+
loadResults();
|
|
964
|
+
} else {
|
|
965
|
+
const data = await res.json();
|
|
966
|
+
console.error('Failed to delete result:', data);
|
|
967
|
+
alert('Failed to delete result: ' + (data.details || data.error || 'Unknown error'));
|
|
968
|
+
}
|
|
969
|
+
} catch (e) {
|
|
970
|
+
console.error('Failed to delete result:', e);
|
|
971
|
+
alert('Failed to delete result: ' + e.message);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function renderResults() {
|
|
976
|
+
const container = document.getElementById('resultsContainer');
|
|
977
|
+
if (!results.length) {
|
|
978
|
+
container.innerHTML = `
|
|
979
|
+
${renderResultsInfrastructure()}
|
|
980
|
+
<div class="empty-state"><h3>No test results yet</h3><p>Run a test to see results here</p></div>
|
|
981
|
+
`;
|
|
982
|
+
renderResultsInfraCharts();
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
container.innerHTML = `
|
|
986
|
+
${renderResultsInfrastructure()}
|
|
987
|
+
<div class="card">
|
|
988
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
|
989
|
+
<h3 style="margin: 0;">Test Results</h3>
|
|
990
|
+
<div style="display: flex; gap: 8px;">
|
|
991
|
+
<button onclick="importResult()" class="btn btn-secondary" style="font-size: 12px; padding: 6px 12px;">
|
|
992
|
+
<span style="margin-right: 4px;">↑</span> Import Result
|
|
993
|
+
</button>
|
|
994
|
+
<input type="file" id="importFileInput" accept=".json" style="display: none;" onchange="handleImportFile(event)">
|
|
995
|
+
</div>
|
|
996
|
+
</div>
|
|
997
|
+
<table>
|
|
998
|
+
<thead><tr>
|
|
999
|
+
<th>Test Name</th><th>Date</th><th>Duration</th><th>Requests</th>
|
|
1000
|
+
<th>Avg</th><th>P95</th><th>P99</th>
|
|
1001
|
+
<th>RPS</th><th>Success Rate</th><th></th>
|
|
1002
|
+
</tr></thead>
|
|
1003
|
+
<tbody>
|
|
1004
|
+
${results.map(r => `<tr class="clickable" onclick="showDetail('${encodeURIComponent(r.id)}')">
|
|
1005
|
+
<td><strong>${r.name}</strong></td>
|
|
1006
|
+
<td>${new Date(r.timestamp).toLocaleString()}</td>
|
|
1007
|
+
<td>${formatDuration(r.duration)}</td>
|
|
1008
|
+
<td>${r.summary.total_requests.toLocaleString()}</td>
|
|
1009
|
+
<td>${r.summary.avg_response_time.toFixed(0)}ms</td>
|
|
1010
|
+
<td>${r.summary.p95_response_time.toFixed(0)}ms</td>
|
|
1011
|
+
<td>${r.summary.p99_response_time.toFixed(0)}ms</td>
|
|
1012
|
+
<td>${r.summary.requests_per_second.toFixed(1)}</td>
|
|
1013
|
+
<td><span class="status-badge ${r.summary.success_rate < 95 ? 'bad' : r.summary.success_rate < 99 ? 'warn' : 'good'}">${r.summary.success_rate.toFixed(1)}%</span></td>
|
|
1014
|
+
<td><button class="btn btn-danger btn-sm" onclick="deleteResult('${encodeURIComponent(r.id)}', event)" title="Delete result">✕</button></td>
|
|
1015
|
+
</tr>`).join('')}
|
|
1016
|
+
</tbody>
|
|
1017
|
+
</table>
|
|
1018
|
+
</div>
|
|
1019
|
+
`;
|
|
1020
|
+
renderResultsInfraCharts();
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function renderResultsInfrastructure() {
|
|
1024
|
+
const hosts = Object.keys(infraMetrics);
|
|
1025
|
+
if (!hosts.length) return '';
|
|
1026
|
+
|
|
1027
|
+
return `
|
|
1028
|
+
<div class="card" style="margin-bottom: 20px;">
|
|
1029
|
+
<div class="card-header">
|
|
1030
|
+
<h3 style="color: #00d4ff;">Live Infrastructure</h3>
|
|
1031
|
+
<span class="live-badge">${hosts.length} host(s)</span>
|
|
1032
|
+
</div>
|
|
1033
|
+
<div class="grid-${Math.min(hosts.length, 3)}" style="margin-top: 16px;">
|
|
1034
|
+
${hosts.map(host => {
|
|
1035
|
+
const history = infraMetrics[host] || [];
|
|
1036
|
+
const latest = history[history.length - 1] || {};
|
|
1037
|
+
const metrics = latest.metrics || {};
|
|
1038
|
+
const cpu = metrics.cpu?.usage_percent ?? '-';
|
|
1039
|
+
const mem = metrics.memory?.usage_percent ?? '-';
|
|
1040
|
+
const disk = metrics.disk?.usage_percent ?? '-';
|
|
1041
|
+
const netIn = metrics.network?.bytes_in ?? 0;
|
|
1042
|
+
const netOut = metrics.network?.bytes_out ?? 0;
|
|
1043
|
+
const interval = latest.interval_seconds || 5;
|
|
1044
|
+
const netRate = (netIn + netOut) / interval;
|
|
1045
|
+
const lastUpdate = latest.timestamp ? new Date(latest.timestamp).toLocaleTimeString() : '-';
|
|
1046
|
+
|
|
1047
|
+
return `
|
|
1048
|
+
<div style="background: var(--bg-secondary); border-radius: 8px; padding: 16px;">
|
|
1049
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
|
1050
|
+
<strong>${host}</strong>
|
|
1051
|
+
<span style="color: var(--text-secondary); font-size: 10px;">${lastUpdate}</span>
|
|
1052
|
+
</div>
|
|
1053
|
+
<div class="grid-4" style="gap: 8px; margin-bottom: 12px;">
|
|
1054
|
+
<div style="text-align: center;">
|
|
1055
|
+
<div style="font-size: 18px; font-weight: bold; color: ${cpu !== '-' && cpu > 80 ? '#ef4444' : cpu !== '-' && cpu > 60 ? '#eab308' : '#00d4ff'};">${typeof cpu === 'number' ? cpu.toFixed(0) : cpu}%</div>
|
|
1056
|
+
<div style="font-size: 10px; color: var(--text-secondary);">CPU</div>
|
|
1057
|
+
</div>
|
|
1058
|
+
<div style="text-align: center;">
|
|
1059
|
+
<div style="font-size: 18px; font-weight: bold; color: ${mem !== '-' && mem > 85 ? '#ef4444' : mem !== '-' && mem > 70 ? '#eab308' : '#9c40ff'};">${typeof mem === 'number' ? mem.toFixed(0) : mem}%</div>
|
|
1060
|
+
<div style="font-size: 10px; color: var(--text-secondary);">Memory</div>
|
|
1061
|
+
</div>
|
|
1062
|
+
<div style="text-align: center;">
|
|
1063
|
+
<div style="font-size: 18px; font-weight: bold; color: ${disk !== '-' && disk > 90 ? '#ef4444' : '#22c55e'};">${typeof disk === 'number' ? disk.toFixed(0) : disk}%</div>
|
|
1064
|
+
<div style="font-size: 10px; color: var(--text-secondary);">Disk</div>
|
|
1065
|
+
</div>
|
|
1066
|
+
<div style="text-align: center;">
|
|
1067
|
+
<div style="font-size: 18px; font-weight: bold;">${formatBytesPerSec(netRate)}</div>
|
|
1068
|
+
<div style="font-size: 10px; color: var(--text-secondary);">Net I/O</div>
|
|
1069
|
+
</div>
|
|
1070
|
+
</div>
|
|
1071
|
+
<div class="grid-2" style="gap: 8px;">
|
|
1072
|
+
<div style="height: 60px;"><canvas id="results-infra-cpu-${host.replace(/[^a-zA-Z0-9]/g, '_')}"></canvas></div>
|
|
1073
|
+
<div style="height: 60px;"><canvas id="results-infra-mem-${host.replace(/[^a-zA-Z0-9]/g, '_')}"></canvas></div>
|
|
1074
|
+
</div>
|
|
1075
|
+
</div>
|
|
1076
|
+
`;
|
|
1077
|
+
}).join('')}
|
|
1078
|
+
</div>
|
|
1079
|
+
</div>
|
|
1080
|
+
`;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function renderResultsInfraCharts() {
|
|
1084
|
+
const hosts = Object.keys(infraMetrics);
|
|
1085
|
+
hosts.forEach(host => {
|
|
1086
|
+
const history = infraMetrics[host] || [];
|
|
1087
|
+
if (history.length < 2) return;
|
|
1088
|
+
|
|
1089
|
+
const hostId = host.replace(/[^a-zA-Z0-9]/g, '_');
|
|
1090
|
+
const recentHistory = history.slice(-30);
|
|
1091
|
+
const labels = recentHistory.map((h, i) => i + 's');
|
|
1092
|
+
|
|
1093
|
+
createOrUpdateChart('results-infra-cpu-' + hostId, 'line', labels, [{
|
|
1094
|
+
label: 'CPU',
|
|
1095
|
+
data: recentHistory.map(h => h.metrics?.cpu?.usage_percent ?? 0),
|
|
1096
|
+
borderColor: '#00d4ff',
|
|
1097
|
+
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
|
1098
|
+
fill: true,
|
|
1099
|
+
tension: 0.3,
|
|
1100
|
+
pointRadius: 0
|
|
1101
|
+
}]);
|
|
1102
|
+
|
|
1103
|
+
createOrUpdateChart('results-infra-mem-' + hostId, 'line', labels, [{
|
|
1104
|
+
label: 'Memory',
|
|
1105
|
+
data: recentHistory.map(h => h.metrics?.memory?.usage_percent ?? 0),
|
|
1106
|
+
borderColor: '#9c40ff',
|
|
1107
|
+
backgroundColor: 'rgba(156, 64, 255, 0.1)',
|
|
1108
|
+
fill: true,
|
|
1109
|
+
tension: 0.3,
|
|
1110
|
+
pointRadius: 0
|
|
1111
|
+
}]);
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function renderDetailLiveInfrastructure() {
|
|
1116
|
+
const hosts = Object.keys(infraMetrics);
|
|
1117
|
+
if (!hosts.length) return '';
|
|
1118
|
+
|
|
1119
|
+
return `
|
|
1120
|
+
<div class="card">
|
|
1121
|
+
<div class="card-header">
|
|
1122
|
+
<h3 style="color: #00d4ff;">Live Infrastructure</h3>
|
|
1123
|
+
<span class="live-badge">${hosts.length} host(s)</span>
|
|
1124
|
+
</div>
|
|
1125
|
+
<div class="grid-${Math.min(hosts.length, 2)}" style="margin-top: 16px;">
|
|
1126
|
+
${hosts.map(host => {
|
|
1127
|
+
const history = infraMetrics[host] || [];
|
|
1128
|
+
const latest = history[history.length - 1] || {};
|
|
1129
|
+
const metrics = latest.metrics || {};
|
|
1130
|
+
const cpu = metrics.cpu?.usage_percent ?? '-';
|
|
1131
|
+
const mem = metrics.memory?.usage_percent ?? '-';
|
|
1132
|
+
const disk = metrics.disk?.usage_percent ?? '-';
|
|
1133
|
+
const diskUsed = metrics.disk?.used_gb ?? 0;
|
|
1134
|
+
const diskTotal = metrics.disk?.total_gb ?? 0;
|
|
1135
|
+
const netIn = metrics.network?.bytes_in ?? 0;
|
|
1136
|
+
const netOut = metrics.network?.bytes_out ?? 0;
|
|
1137
|
+
const interval = latest.interval_seconds || 5;
|
|
1138
|
+
const netRate = (netIn + netOut) / interval;
|
|
1139
|
+
const lastUpdate = latest.timestamp ? new Date(latest.timestamp).toLocaleTimeString() : '-';
|
|
1140
|
+
|
|
1141
|
+
return `
|
|
1142
|
+
<div style="background: var(--bg-secondary); border-radius: 8px; padding: 16px;">
|
|
1143
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
|
1144
|
+
<strong>${host}</strong>
|
|
1145
|
+
<span style="color: var(--text-secondary); font-size: 10px;">${lastUpdate}</span>
|
|
1146
|
+
</div>
|
|
1147
|
+
<div class="grid-4" style="gap: 8px; margin-bottom: 12px;">
|
|
1148
|
+
<div style="text-align: center;">
|
|
1149
|
+
<div style="font-size: 20px; font-weight: bold; color: ${cpu !== '-' && cpu > 80 ? '#ef4444' : cpu !== '-' && cpu > 60 ? '#eab308' : '#00d4ff'};">${typeof cpu === 'number' ? cpu.toFixed(0) : cpu}%</div>
|
|
1150
|
+
<div style="font-size: 10px; color: var(--text-secondary);">CPU</div>
|
|
1151
|
+
</div>
|
|
1152
|
+
<div style="text-align: center;">
|
|
1153
|
+
<div style="font-size: 20px; font-weight: bold; color: ${mem !== '-' && mem > 85 ? '#ef4444' : mem !== '-' && mem > 70 ? '#eab308' : '#9c40ff'};">${typeof mem === 'number' ? mem.toFixed(0) : mem}%</div>
|
|
1154
|
+
<div style="font-size: 10px; color: var(--text-secondary);">Memory</div>
|
|
1155
|
+
</div>
|
|
1156
|
+
<div style="text-align: center;">
|
|
1157
|
+
<div style="font-size: 20px; font-weight: bold; color: ${disk !== '-' && disk > 90 ? '#ef4444' : '#22c55e'};">${typeof disk === 'number' ? disk.toFixed(0) : disk}%</div>
|
|
1158
|
+
<div style="font-size: 10px; color: var(--text-secondary);">Disk${diskTotal ? ' (' + diskUsed.toFixed(0) + '/' + diskTotal.toFixed(0) + 'G)' : ''}</div>
|
|
1159
|
+
</div>
|
|
1160
|
+
<div style="text-align: center;">
|
|
1161
|
+
<div style="font-size: 20px; font-weight: bold;">${formatBytesPerSec(netRate)}</div>
|
|
1162
|
+
<div style="font-size: 10px; color: var(--text-secondary);">Net I/O</div>
|
|
1163
|
+
</div>
|
|
1164
|
+
</div>
|
|
1165
|
+
<div class="grid-2" style="gap: 8px;">
|
|
1166
|
+
<div style="height: 80px;"><canvas id="detail-live-infra-cpu-${host.replace(/[^a-zA-Z0-9]/g, '_')}"></canvas></div>
|
|
1167
|
+
<div style="height: 80px;"><canvas id="detail-live-infra-mem-${host.replace(/[^a-zA-Z0-9]/g, '_')}"></canvas></div>
|
|
1168
|
+
</div>
|
|
1169
|
+
</div>
|
|
1170
|
+
`;
|
|
1171
|
+
}).join('')}
|
|
1172
|
+
</div>
|
|
1173
|
+
</div>
|
|
1174
|
+
`;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function renderDetailLiveInfraCharts() {
|
|
1178
|
+
const hosts = Object.keys(infraMetrics);
|
|
1179
|
+
hosts.forEach(host => {
|
|
1180
|
+
const history = infraMetrics[host] || [];
|
|
1181
|
+
if (history.length < 2) return;
|
|
1182
|
+
|
|
1183
|
+
const hostId = host.replace(/[^a-zA-Z0-9]/g, '_');
|
|
1184
|
+
const recentHistory = history.slice(-30);
|
|
1185
|
+
const labels = recentHistory.map((h, i) => i + 's');
|
|
1186
|
+
|
|
1187
|
+
createOrUpdateChart('detail-live-infra-cpu-' + hostId, 'line', labels, [{
|
|
1188
|
+
label: 'CPU',
|
|
1189
|
+
data: recentHistory.map(h => h.metrics?.cpu?.usage_percent ?? 0),
|
|
1190
|
+
borderColor: '#00d4ff',
|
|
1191
|
+
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
|
1192
|
+
fill: true,
|
|
1193
|
+
tension: 0.3,
|
|
1194
|
+
pointRadius: 0
|
|
1195
|
+
}]);
|
|
1196
|
+
|
|
1197
|
+
createOrUpdateChart('detail-live-infra-mem-' + hostId, 'line', labels, [{
|
|
1198
|
+
label: 'Memory',
|
|
1199
|
+
data: recentHistory.map(h => h.metrics?.memory?.usage_percent ?? 0),
|
|
1200
|
+
borderColor: '#9c40ff',
|
|
1201
|
+
backgroundColor: 'rgba(156, 64, 255, 0.1)',
|
|
1202
|
+
fill: true,
|
|
1203
|
+
tension: 0.3,
|
|
1204
|
+
pointRadius: 0
|
|
1205
|
+
}]);
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Detail View - Enhanced with Report-style Charts
|
|
1210
|
+
async function showDetail(id, skipHashUpdate = false) {
|
|
1211
|
+
// Update URL hash unless called from hash change handler
|
|
1212
|
+
if (!skipHashUpdate) {
|
|
1213
|
+
window.history.pushState(null, '', '#results/detail/' + id);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
const res = await fetch('/api/results/' + id);
|
|
1217
|
+
const data = await res.json();
|
|
1218
|
+
|
|
1219
|
+
if (!res.ok || !data.summary) {
|
|
1220
|
+
console.error('Failed to load result:', data);
|
|
1221
|
+
alert('Failed to load result: ' + (data.error || 'Unknown error'));
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Try to fetch infrastructure metrics from InfluxDB if not embedded in result
|
|
1226
|
+
if (!data.infrastructure_metrics || Object.keys(data.infrastructure_metrics).length === 0) {
|
|
1227
|
+
try {
|
|
1228
|
+
const startTime = new Date(data.timestamp);
|
|
1229
|
+
const endTime = new Date(startTime.getTime() + (data.duration * 1000));
|
|
1230
|
+
const infraRes = await fetch(`/api/infra/by-time?start=${startTime.toISOString()}&end=${endTime.toISOString()}`);
|
|
1231
|
+
if (infraRes.ok) {
|
|
1232
|
+
const infraData = await infraRes.json();
|
|
1233
|
+
if (infraData.infrastructure_metrics && Object.keys(infraData.infrastructure_metrics).length > 0) {
|
|
1234
|
+
data.infrastructure_metrics = infraData.infrastructure_metrics;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
} catch (e) {
|
|
1238
|
+
console.log('Could not fetch infra from InfluxDB:', e.message);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Store data for export functionality
|
|
1243
|
+
window.currentDetailData = data;
|
|
1244
|
+
|
|
1245
|
+
const stepStats = data.step_statistics || [];
|
|
1246
|
+
|
|
1247
|
+
document.getElementById('detailContent').innerHTML = `
|
|
1248
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
|
|
1249
|
+
<h2 style="margin: 0;">${data.name}</h2>
|
|
1250
|
+
<div style="display: flex; gap: 8px; align-items: center;">
|
|
1251
|
+
<label style="display: flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer;">
|
|
1252
|
+
<input type="checkbox" id="includeNetworkCalls" ${(data.network_calls?.length || 0) > 0 ? 'checked' : ''}>
|
|
1253
|
+
Include Network Calls
|
|
1254
|
+
</label>
|
|
1255
|
+
<button onclick="exportResult('json')" class="btn btn-primary" style="font-size: 12px; padding: 6px 12px;">
|
|
1256
|
+
<span style="margin-right: 4px;">↓</span> Export (JSON)
|
|
1257
|
+
</button>
|
|
1258
|
+
<button onclick="exportResult('csv')" class="btn btn-secondary" style="font-size: 12px; padding: 6px 12px;">
|
|
1259
|
+
<span style="margin-right: 4px;">↓</span> Export (CSV)
|
|
1260
|
+
</button>
|
|
1261
|
+
${data.infrastructure_metrics && Object.keys(data.infrastructure_metrics).length > 0 ? `
|
|
1262
|
+
<button onclick="exportInfraMetrics('json')" class="btn btn-secondary" style="font-size: 12px; padding: 6px 12px;">
|
|
1263
|
+
<span style="margin-right: 4px;">📊</span> Infra (JSON)
|
|
1264
|
+
</button>
|
|
1265
|
+
` : ''}
|
|
1266
|
+
</div>
|
|
1267
|
+
</div>
|
|
1268
|
+
|
|
1269
|
+
<!-- Summary Metrics -->
|
|
1270
|
+
<div class="grid-6" style="margin-bottom: 24px;">
|
|
1271
|
+
<div class="metric-card"><div class="value">${data.summary.total_requests.toLocaleString()}</div><div class="label">Total Requests</div></div>
|
|
1272
|
+
<div class="metric-card"><div class="value">${data.summary.successful_requests.toLocaleString()}</div><div class="label">Successful</div></div>
|
|
1273
|
+
<div class="metric-card"><div class="value" style="${data.summary.failed_requests > 0 ? 'color:#ef4444!important;-webkit-text-fill-color:#ef4444;' : ''}">${data.summary.failed_requests.toLocaleString()}</div><div class="label">Failed</div></div>
|
|
1274
|
+
<div class="metric-card"><div class="value">${data.summary.requests_per_second.toFixed(2)}</div><div class="label">Requests/sec</div></div>
|
|
1275
|
+
<div class="metric-card"><div class="value">${data.summary.avg_response_time.toFixed(0)}ms</div><div class="label">Avg Response</div></div>
|
|
1276
|
+
<div class="metric-card"><div class="value">${formatDuration(data.duration)}</div><div class="label">Duration</div></div>
|
|
1277
|
+
</div>
|
|
1278
|
+
|
|
1279
|
+
<!-- Response Time Distribution -->
|
|
1280
|
+
<div class="card">
|
|
1281
|
+
<h3>Response Time Distribution</h3>
|
|
1282
|
+
<div class="chart-container tall"><canvas id="detail-distribution"></canvas></div>
|
|
1283
|
+
</div>
|
|
1284
|
+
|
|
1285
|
+
<!-- Response Times Over Time (with percentiles) -->
|
|
1286
|
+
${(data.timeline_data && data.timeline_data.length > 0) ? `
|
|
1287
|
+
<div class="card">
|
|
1288
|
+
<h3>Response Times Over Time</h3>
|
|
1289
|
+
<div class="chart-container tall"><canvas id="detail-rt-over-time"></canvas></div>
|
|
1290
|
+
</div>
|
|
1291
|
+
` : ''}
|
|
1292
|
+
|
|
1293
|
+
<!-- Throughput & VUs Over Time -->
|
|
1294
|
+
${(data.timeline_data && data.timeline_data.length > 0) ? `
|
|
1295
|
+
<div class="grid-2">
|
|
1296
|
+
<div class="card expandable">${expandBtnHtml()}<h3>Throughput Over Time</h3><div class="chart-container"><canvas id="detail-throughput"></canvas></div></div>
|
|
1297
|
+
<div class="card expandable">${expandBtnHtml()}<h3>Active Virtual Users</h3><div class="chart-container"><canvas id="detail-vus"></canvas></div></div>
|
|
1298
|
+
</div>
|
|
1299
|
+
` : ''}
|
|
1300
|
+
|
|
1301
|
+
<!-- Errors & Status Codes Over Time -->
|
|
1302
|
+
${(data.timeline_data && data.timeline_data.length > 0) ? `
|
|
1303
|
+
<div class="grid-2">
|
|
1304
|
+
<div class="card expandable">${expandBtnHtml()}<h3>Errors Over Time</h3><div class="chart-container"><canvas id="detail-errors-time"></canvas></div></div>
|
|
1305
|
+
<div class="card expandable">${expandBtnHtml()}<h3>Response Codes Over Time</h3><div class="chart-container"><canvas id="detail-status-codes"></canvas></div></div>
|
|
1306
|
+
</div>
|
|
1307
|
+
` : ''}
|
|
1308
|
+
|
|
1309
|
+
<!-- Latency Breakdown & Bytes Throughput -->
|
|
1310
|
+
${(data.timeline_data && data.timeline_data.length > 0) ? `
|
|
1311
|
+
<div class="grid-2">
|
|
1312
|
+
<div class="card expandable">${expandBtnHtml()}<h3>Latency Breakdown</h3><div class="chart-container"><canvas id="detail-latency"></canvas></div></div>
|
|
1313
|
+
<div class="card expandable">${expandBtnHtml()}<h3>Bytes Throughput</h3><div class="chart-container"><canvas id="detail-bytes"></canvas></div></div>
|
|
1314
|
+
</div>
|
|
1315
|
+
` : ''}
|
|
1316
|
+
|
|
1317
|
+
<!-- Individual Response Times (colored by step) -->
|
|
1318
|
+
${stepStats.length ? `
|
|
1319
|
+
<div class="card">
|
|
1320
|
+
<h3>Individual Response Times by Step</h3>
|
|
1321
|
+
<div class="chart-container tall"><canvas id="detail-rt-scatter"></canvas></div>
|
|
1322
|
+
</div>
|
|
1323
|
+
` : ''}
|
|
1324
|
+
|
|
1325
|
+
<!-- Throughput Charts -->
|
|
1326
|
+
<div class="grid-2">
|
|
1327
|
+
<div class="card expandable">${expandBtnHtml()}<h3>Response Time Percentiles</h3><div class="chart-container"><canvas id="detail-percentiles"></canvas></div></div>
|
|
1328
|
+
<div class="card expandable">${expandBtnHtml()}<h3>Success vs Failures</h3><div class="chart-container"><canvas id="detail-success"></canvas></div></div>
|
|
1329
|
+
</div>
|
|
1330
|
+
|
|
1331
|
+
<!-- Step Performance -->
|
|
1332
|
+
${stepStats.length ? `
|
|
1333
|
+
<div class="card">
|
|
1334
|
+
<h3>Step Performance Statistics</h3>
|
|
1335
|
+
<div class="grid-2" style="margin-bottom: 20px;">
|
|
1336
|
+
<div class="card expandable" style="margin-bottom: 0;">${expandBtnHtml()}<div class="chart-container tall"><canvas id="detail-step-percentiles"></canvas></div></div>
|
|
1337
|
+
<div class="card expandable" style="margin-bottom: 0;">${expandBtnHtml()}<div class="chart-container tall"><canvas id="detail-step-distribution"></canvas></div></div>
|
|
1338
|
+
</div>
|
|
1339
|
+
<div style="overflow-x: auto;">
|
|
1340
|
+
<table class="step-stats-table">
|
|
1341
|
+
<thead><tr>
|
|
1342
|
+
<th>Step Name</th><th>Scenario</th><th>Requests</th><th>Success Rate</th>
|
|
1343
|
+
<th>Min</th><th>Avg</th><th>P50</th><th>P90</th><th>P95</th><th>P99</th><th>Max</th><th>Status</th>
|
|
1344
|
+
</tr></thead>
|
|
1345
|
+
<tbody>
|
|
1346
|
+
${stepStats.map(s => `<tr>
|
|
1347
|
+
<td><strong>${s.step_name}</strong></td>
|
|
1348
|
+
<td>${s.scenario || '-'}</td>
|
|
1349
|
+
<td>${s.total_requests || 0}</td>
|
|
1350
|
+
<td>${(s.success_rate || 100).toFixed(1)}%</td>
|
|
1351
|
+
<td>${(s.min_response_time || 0).toFixed(0)}ms</td>
|
|
1352
|
+
<td>${(s.avg_response_time || 0).toFixed(0)}ms</td>
|
|
1353
|
+
<td>${(s.percentiles?.['50'] || 0).toFixed(0)}ms</td>
|
|
1354
|
+
<td>${(s.percentiles?.['90'] || 0).toFixed(0)}ms</td>
|
|
1355
|
+
<td>${(s.percentiles?.['95'] || 0).toFixed(0)}ms</td>
|
|
1356
|
+
<td>${(s.percentiles?.['99'] || 0).toFixed(0)}ms</td>
|
|
1357
|
+
<td>${(s.max_response_time || 0).toFixed(0)}ms</td>
|
|
1358
|
+
<td><span class="status-badge ${(s.success_rate || 100) < 90 || (s.percentiles?.['95'] || 0) >= 10000 ? 'bad' : (s.success_rate || 100) < 98 || (s.percentiles?.['95'] || 0) >= 5000 ? 'warn' : 'good'}">
|
|
1359
|
+
${(s.success_rate || 100) < 90 || (s.percentiles?.['95'] || 0) >= 10000 ? 'Poor' : (s.success_rate || 100) < 98 || (s.percentiles?.['95'] || 0) >= 5000 ? 'Warn' : 'Good'}
|
|
1360
|
+
</span></td>
|
|
1361
|
+
</tr>`).join('')}
|
|
1362
|
+
</tbody>
|
|
1363
|
+
</table>
|
|
1364
|
+
</div>
|
|
1365
|
+
</div>
|
|
1366
|
+
` : ''}
|
|
1367
|
+
|
|
1368
|
+
<!-- Response Time Stats Table -->
|
|
1369
|
+
<div class="grid-2">
|
|
1370
|
+
<div class="card">
|
|
1371
|
+
<h3>Response Time Statistics</h3>
|
|
1372
|
+
<table>
|
|
1373
|
+
<tr><td>Minimum</td><td>${data.summary.min_response_time.toFixed(0)}ms</td></tr>
|
|
1374
|
+
<tr><td>Average</td><td>${data.summary.avg_response_time.toFixed(0)}ms</td></tr>
|
|
1375
|
+
<tr><td>Median (P50)</td><td>${data.summary.p50_response_time.toFixed(0)}ms</td></tr>
|
|
1376
|
+
<tr><td>P75</td><td>${data.summary.p75_response_time.toFixed(0)}ms</td></tr>
|
|
1377
|
+
<tr><td>P90</td><td>${data.summary.p90_response_time.toFixed(0)}ms</td></tr>
|
|
1378
|
+
<tr><td>P95</td><td>${data.summary.p95_response_time.toFixed(0)}ms</td></tr>
|
|
1379
|
+
<tr><td>P99</td><td>${data.summary.p99_response_time.toFixed(0)}ms</td></tr>
|
|
1380
|
+
<tr><td>Maximum</td><td>${data.summary.max_response_time.toFixed(0)}ms</td></tr>
|
|
1381
|
+
</table>
|
|
1382
|
+
</div>
|
|
1383
|
+
<div class="card">
|
|
1384
|
+
<h3>Test Summary</h3>
|
|
1385
|
+
<table>
|
|
1386
|
+
<tr><td>Duration</td><td>${formatDuration(data.duration)}</td></tr>
|
|
1387
|
+
<tr><td>Total Requests</td><td>${data.summary.total_requests.toLocaleString()}</td></tr>
|
|
1388
|
+
<tr><td>Throughput</td><td>${data.summary.requests_per_second.toFixed(2)} req/s</td></tr>
|
|
1389
|
+
<tr><td>Success Rate</td><td><span class="status-badge ${data.summary.success_rate < 95 ? 'bad' : data.summary.success_rate < 99 ? 'warn' : 'good'}">${data.summary.success_rate.toFixed(2)}%</span></td></tr>
|
|
1390
|
+
<tr><td>Error Rate</td><td><span class="status-badge ${data.summary.error_rate > 5 ? 'bad' : data.summary.error_rate > 1 ? 'warn' : 'good'}">${data.summary.error_rate.toFixed(2)}%</span></td></tr>
|
|
1391
|
+
<tr><td>Timestamp</td><td>${new Date(data.timestamp).toLocaleString()}</td></tr>
|
|
1392
|
+
</table>
|
|
1393
|
+
</div>
|
|
1394
|
+
</div>
|
|
1395
|
+
|
|
1396
|
+
${data.scenarios && data.scenarios.length ? `
|
|
1397
|
+
<div class="card">
|
|
1398
|
+
<h3>Scenarios</h3>
|
|
1399
|
+
<table>
|
|
1400
|
+
<thead><tr><th>Scenario</th><th>Requests</th><th>Avg Response</th><th>Errors</th></tr></thead>
|
|
1401
|
+
<tbody>
|
|
1402
|
+
${data.scenarios.map(s => `<tr>
|
|
1403
|
+
<td>${s.name}</td>
|
|
1404
|
+
<td>${s.total_requests || s.requests || 0}</td>
|
|
1405
|
+
<td>${(s.avg_response_time || 0).toFixed(0)}ms</td>
|
|
1406
|
+
<td>${s.failed_requests || s.errors || 0}</td>
|
|
1407
|
+
</tr>`).join('')}
|
|
1408
|
+
</tbody>
|
|
1409
|
+
</table>
|
|
1410
|
+
</div>
|
|
1411
|
+
` : ''}
|
|
1412
|
+
|
|
1413
|
+
<!-- Top Errors -->
|
|
1414
|
+
${data.error_details && data.error_details.length > 0 ? `
|
|
1415
|
+
<div class="card">
|
|
1416
|
+
<h3 style="color: #ef4444;">Top Errors (${data.error_details.length})</h3>
|
|
1417
|
+
<div style="overflow-x: auto; margin-top: 12px;">
|
|
1418
|
+
<table class="step-stats-table">
|
|
1419
|
+
<thead>
|
|
1420
|
+
<tr>
|
|
1421
|
+
<th>Count</th>
|
|
1422
|
+
<th>Scenario</th>
|
|
1423
|
+
<th>Action</th>
|
|
1424
|
+
<th>Status</th>
|
|
1425
|
+
<th>Error Message</th>
|
|
1426
|
+
<th>URL</th>
|
|
1427
|
+
</tr>
|
|
1428
|
+
</thead>
|
|
1429
|
+
<tbody>
|
|
1430
|
+
${data.error_details.slice(0, 20).map(e => `
|
|
1431
|
+
<tr>
|
|
1432
|
+
<td style="color: #ef4444; font-weight: bold;">${e.count || 1}</td>
|
|
1433
|
+
<td>${e.scenario || '-'}</td>
|
|
1434
|
+
<td>${e.action || '-'}</td>
|
|
1435
|
+
<td>${e.status !== undefined && e.status !== null ? e.status : '-'}</td>
|
|
1436
|
+
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${e.error || ''}">${e.error || '-'}</td>
|
|
1437
|
+
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${e.request_url || ''}">${e.request_url || '-'}</td>
|
|
1438
|
+
</tr>
|
|
1439
|
+
`).join('')}
|
|
1440
|
+
</tbody>
|
|
1441
|
+
</table>
|
|
1442
|
+
</div>
|
|
1443
|
+
</div>
|
|
1444
|
+
` : ''}
|
|
1445
|
+
|
|
1446
|
+
<!-- Network Calls -->
|
|
1447
|
+
${data.network_calls && data.network_calls.length > 0 ? `
|
|
1448
|
+
<div class="card">
|
|
1449
|
+
<h3 style="color: #00d4ff;">Network Calls (${data.network_calls.length})</h3>
|
|
1450
|
+
|
|
1451
|
+
<!-- Network Charts -->
|
|
1452
|
+
<div style="background: #111827; border-radius: 8px; padding: 16px; margin-bottom: 20px;">
|
|
1453
|
+
<h4 style="color: #9ca3af; margin: 0 0 12px 0; font-size: 14px;">Network Requests Over Time</h4>
|
|
1454
|
+
<div style="height: 250px;"><canvas id="network-scatter-chart"></canvas></div>
|
|
1455
|
+
</div>
|
|
1456
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
|
|
1457
|
+
<div style="background: #111827; border-radius: 8px; padding: 16px;">
|
|
1458
|
+
<h4 style="color: #9ca3af; margin: 0 0 12px 0; font-size: 14px;">Request Timeline (Duration by Request)</h4>
|
|
1459
|
+
<div style="height: 200px;"><canvas id="network-timeline-chart"></canvas></div>
|
|
1460
|
+
</div>
|
|
1461
|
+
<div style="background: #111827; border-radius: 8px; padding: 16px;">
|
|
1462
|
+
<h4 style="color: #9ca3af; margin: 0 0 12px 0; font-size: 14px;">Response Time by Endpoint</h4>
|
|
1463
|
+
<div style="height: 200px;"><canvas id="network-endpoint-chart"></canvas></div>
|
|
1464
|
+
</div>
|
|
1465
|
+
</div>
|
|
1466
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
|
|
1467
|
+
<div style="background: #111827; border-radius: 8px; padding: 16px;">
|
|
1468
|
+
<h4 style="color: #9ca3af; margin: 0 0 12px 0; font-size: 14px;">Status Code Distribution</h4>
|
|
1469
|
+
<div style="height: 180px;"><canvas id="network-status-chart"></canvas></div>
|
|
1470
|
+
</div>
|
|
1471
|
+
<div style="background: #111827; border-radius: 8px; padding: 16px;">
|
|
1472
|
+
<h4 style="color: #9ca3af; margin: 0 0 12px 0; font-size: 14px;">Request Type Distribution</h4>
|
|
1473
|
+
<div style="height: 180px;"><canvas id="network-type-chart"></canvas></div>
|
|
1474
|
+
</div>
|
|
1475
|
+
</div>
|
|
1476
|
+
|
|
1477
|
+
<div style="margin-bottom: 12px;">
|
|
1478
|
+
<input type="text" id="network-filter" placeholder="Filter by URL, method, or status..."
|
|
1479
|
+
style="width: 300px; padding: 8px 12px; border: 1px solid #374151; border-radius: 6px; background: #1f2937; color: #e5e7eb;"
|
|
1480
|
+
onkeyup="filterNetworkCalls()">
|
|
1481
|
+
<select id="network-type-filter" onchange="filterNetworkCalls()"
|
|
1482
|
+
style="margin-left: 8px; padding: 8px 12px; border: 1px solid #374151; border-radius: 6px; background: #1f2937; color: #e5e7eb;">
|
|
1483
|
+
<option value="">All Types</option>
|
|
1484
|
+
<option value="xhr">XHR</option>
|
|
1485
|
+
<option value="fetch">Fetch</option>
|
|
1486
|
+
<option value="document">Document</option>
|
|
1487
|
+
<option value="script">Script</option>
|
|
1488
|
+
<option value="stylesheet">Stylesheet</option>
|
|
1489
|
+
<option value="image">Image</option>
|
|
1490
|
+
</select>
|
|
1491
|
+
<select id="network-status-filter" onchange="filterNetworkCalls()"
|
|
1492
|
+
style="margin-left: 8px; padding: 8px 12px; border: 1px solid #374151; border-radius: 6px; background: #1f2937; color: #e5e7eb;">
|
|
1493
|
+
<option value="">All Status</option>
|
|
1494
|
+
<option value="success">Success (2xx/3xx)</option>
|
|
1495
|
+
<option value="error">Errors (4xx/5xx/0)</option>
|
|
1496
|
+
</select>
|
|
1497
|
+
</div>
|
|
1498
|
+
<div style="overflow-x: auto; max-height: 400px; overflow-y: auto;">
|
|
1499
|
+
<table class="step-stats-table" id="network-calls-table">
|
|
1500
|
+
<thead style="position: sticky; top: 0; background: #1f2937;">
|
|
1501
|
+
<tr>
|
|
1502
|
+
<th style="cursor: pointer;" onclick="sortNetworkTable('method')">Method <span id="sort-method"></span></th>
|
|
1503
|
+
<th style="cursor: pointer;" onclick="sortNetworkTable('url')">URL <span id="sort-url"></span></th>
|
|
1504
|
+
<th style="cursor: pointer;" onclick="sortNetworkTable('status')">Status <span id="sort-status"></span></th>
|
|
1505
|
+
<th style="cursor: pointer;" onclick="sortNetworkTable('type')">Type <span id="sort-type"></span></th>
|
|
1506
|
+
<th style="cursor: pointer;" onclick="sortNetworkTable('duration')">Duration <span id="sort-duration"></span></th>
|
|
1507
|
+
<th style="cursor: pointer;" onclick="sortNetworkTable('size')">Size <span id="sort-size"></span></th>
|
|
1508
|
+
</tr>
|
|
1509
|
+
</thead>
|
|
1510
|
+
<tbody>
|
|
1511
|
+
${data.network_calls.map((c, idx) => `
|
|
1512
|
+
<tr class="network-row" style="cursor: pointer;" onclick="showNetworkDetail(${idx})"
|
|
1513
|
+
data-method="${c.request_method || c.method || ''}"
|
|
1514
|
+
data-url="${c.request_url || c.url || ''}"
|
|
1515
|
+
data-type="${c.resource_type || c.type || ''}"
|
|
1516
|
+
data-status="${c.response_status || c.status || 0}"
|
|
1517
|
+
data-duration="${c.duration || 0}"
|
|
1518
|
+
data-size="${c.response_size || c.size || 0}"
|
|
1519
|
+
data-idx="${idx}">
|
|
1520
|
+
<td><span style="padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: bold; background: ${(c.request_method || c.method) === 'GET' ? '#065f46' : (c.request_method || c.method) === 'POST' ? '#1e40af' : '#6b21a8'}; color: white;">${c.request_method || c.method}</span></td>
|
|
1521
|
+
<td style="max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${c.request_url || c.url || ''}">${c.request_url || c.url || '-'}</td>
|
|
1522
|
+
<td><span class="status-badge ${(c.response_status || c.status || 0) >= 400 || (c.response_status || c.status || 0) === 0 ? 'bad' : 'good'}">${c.response_status || c.status || 0}${c.error ? ' (' + c.error.substring(0, 20) + ')' : ''}</span></td>
|
|
1523
|
+
<td>${c.resource_type || c.type || '-'}</td>
|
|
1524
|
+
<td>${c.duration || 0}ms</td>
|
|
1525
|
+
<td>${c.response_size || c.size || 0}B</td>
|
|
1526
|
+
</tr>
|
|
1527
|
+
`).join('')}
|
|
1528
|
+
</tbody>
|
|
1529
|
+
</table>
|
|
1530
|
+
</div>
|
|
1531
|
+
</div>
|
|
1532
|
+
` : ''}
|
|
1533
|
+
|
|
1534
|
+
<!-- Live Infrastructure -->
|
|
1535
|
+
${renderDetailLiveInfrastructure()}
|
|
1536
|
+
|
|
1537
|
+
<!-- Infrastructure Correlation (Historical) -->
|
|
1538
|
+
${data.infrastructure_metrics && Object.keys(data.infrastructure_metrics).length > 0 ? `
|
|
1539
|
+
<div class="card">
|
|
1540
|
+
<h3 style="color: #9c40ff;">Test Infrastructure (Historical)</h3>
|
|
1541
|
+
<p style="color: var(--text-secondary); font-size: 12px; margin-bottom: 16px;">Server metrics captured during this test execution (${Object.keys(data.infrastructure_metrics).length} host(s))</p>
|
|
1542
|
+
${Object.entries(data.infrastructure_metrics).map(([host, metrics]) => {
|
|
1543
|
+
const avgCpu = metrics.length > 0 ? (metrics.reduce((sum, x) => sum + (x.metrics?.cpu?.usage_percent || 0), 0) / metrics.length).toFixed(1) : '-';
|
|
1544
|
+
const avgMem = metrics.length > 0 ? (metrics.reduce((sum, x) => sum + (x.metrics?.memory?.usage_percent || 0), 0) / metrics.length).toFixed(1) : '-';
|
|
1545
|
+
const maxCpu = metrics.length > 0 ? Math.max(...metrics.map(x => x.metrics?.cpu?.usage_percent || 0)).toFixed(1) : '-';
|
|
1546
|
+
const maxMem = metrics.length > 0 ? Math.max(...metrics.map(x => x.metrics?.memory?.usage_percent || 0)).toFixed(1) : '-';
|
|
1547
|
+
|
|
1548
|
+
return `
|
|
1549
|
+
<div style="background: var(--bg-secondary); border-radius: 8px; padding: 16px; margin-bottom: 16px;">
|
|
1550
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
|
1551
|
+
<strong>${host}</strong>
|
|
1552
|
+
<span style="color: var(--text-secondary); font-size: 11px;">${metrics.length} data points</span>
|
|
1553
|
+
</div>
|
|
1554
|
+
<div class="grid-4" style="margin-bottom: 16px; gap: 12px;">
|
|
1555
|
+
<div class="metric-card">
|
|
1556
|
+
<div class="value">${avgCpu}%</div>
|
|
1557
|
+
<div class="label">Avg CPU</div>
|
|
1558
|
+
</div>
|
|
1559
|
+
<div class="metric-card">
|
|
1560
|
+
<div class="value" style="${parseFloat(maxCpu) > 80 ? 'color:#ef4444!important;-webkit-text-fill-color:#ef4444;' : ''}">${maxCpu}%</div>
|
|
1561
|
+
<div class="label">Max CPU</div>
|
|
1562
|
+
</div>
|
|
1563
|
+
<div class="metric-card">
|
|
1564
|
+
<div class="value">${avgMem}%</div>
|
|
1565
|
+
<div class="label">Avg Memory</div>
|
|
1566
|
+
</div>
|
|
1567
|
+
<div class="metric-card">
|
|
1568
|
+
<div class="value" style="${parseFloat(maxMem) > 85 ? 'color:#ef4444!important;-webkit-text-fill-color:#ef4444;' : ''}">${maxMem}%</div>
|
|
1569
|
+
<div class="label">Max Memory</div>
|
|
1570
|
+
</div>
|
|
1571
|
+
</div>
|
|
1572
|
+
<div class="grid-2" style="gap: 16px;">
|
|
1573
|
+
<div style="height: 150px;"><canvas id="detail-infra-cpu-${host.replace(/[^a-zA-Z0-9]/g, '_')}"></canvas></div>
|
|
1574
|
+
<div style="height: 150px;"><canvas id="detail-infra-mem-${host.replace(/[^a-zA-Z0-9]/g, '_')}"></canvas></div>
|
|
1575
|
+
</div>
|
|
1576
|
+
</div>
|
|
1577
|
+
`;
|
|
1578
|
+
}).join('')}
|
|
1579
|
+
</div>
|
|
1580
|
+
` : ''}
|
|
1581
|
+
|
|
1582
|
+
<!-- Network Detail Modal -->
|
|
1583
|
+
<div id="network-detail-modal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 1000; overflow-y: auto;">
|
|
1584
|
+
<div style="max-width: 900px; margin: 40px auto; background: #1f2937; border-radius: 8px; border: 1px solid #374151;">
|
|
1585
|
+
<div style="padding: 16px 20px; border-bottom: 1px solid #374151; display: flex; justify-content: space-between; align-items: center;">
|
|
1586
|
+
<h3 style="margin: 0; color: #00d4ff;">Request Details</h3>
|
|
1587
|
+
<button onclick="closeNetworkModal()" style="background: none; border: none; color: #9ca3af; font-size: 24px; cursor: pointer;">×</button>
|
|
1588
|
+
</div>
|
|
1589
|
+
<div id="network-detail-content" style="padding: 20px;"></div>
|
|
1590
|
+
</div>
|
|
1591
|
+
</div>
|
|
1592
|
+
`;
|
|
1593
|
+
|
|
1594
|
+
// Store network calls data for detail view
|
|
1595
|
+
window.networkCallsData = data.network_calls || [];
|
|
1596
|
+
|
|
1597
|
+
// Network calls filter function
|
|
1598
|
+
window.filterNetworkCalls = function() {
|
|
1599
|
+
const filter = document.getElementById('network-filter')?.value?.toLowerCase() || '';
|
|
1600
|
+
const typeFilter = document.getElementById('network-type-filter')?.value?.toLowerCase() || '';
|
|
1601
|
+
const statusFilter = document.getElementById('network-status-filter')?.value || '';
|
|
1602
|
+
const rows = document.querySelectorAll('.network-row');
|
|
1603
|
+
rows.forEach(row => {
|
|
1604
|
+
const url = row.getAttribute('data-url')?.toLowerCase() || '';
|
|
1605
|
+
const type = row.getAttribute('data-type')?.toLowerCase() || '';
|
|
1606
|
+
const status = parseInt(row.getAttribute('data-status') || '0');
|
|
1607
|
+
const matchesFilter = !filter || url.includes(filter) || type.includes(filter);
|
|
1608
|
+
const matchesType = !typeFilter || type === typeFilter;
|
|
1609
|
+
const matchesStatus = !statusFilter ||
|
|
1610
|
+
(statusFilter === 'success' && status >= 200 && status < 400) ||
|
|
1611
|
+
(statusFilter === 'error' && (status >= 400 || status === 0));
|
|
1612
|
+
row.style.display = (matchesFilter && matchesType && matchesStatus) ? '' : 'none';
|
|
1613
|
+
});
|
|
1614
|
+
};
|
|
1615
|
+
|
|
1616
|
+
// Network table sorting
|
|
1617
|
+
let networkSortColumn = '';
|
|
1618
|
+
let networkSortAsc = true;
|
|
1619
|
+
window.sortNetworkTable = function(column) {
|
|
1620
|
+
const tbody = document.querySelector('#network-calls-table tbody');
|
|
1621
|
+
if (!tbody) return;
|
|
1622
|
+
|
|
1623
|
+
// Toggle sort direction if same column
|
|
1624
|
+
if (networkSortColumn === column) {
|
|
1625
|
+
networkSortAsc = !networkSortAsc;
|
|
1626
|
+
} else {
|
|
1627
|
+
networkSortColumn = column;
|
|
1628
|
+
networkSortAsc = true;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Update sort indicators
|
|
1632
|
+
['method', 'url', 'status', 'type', 'duration', 'size'].forEach(col => {
|
|
1633
|
+
const span = document.getElementById('sort-' + col);
|
|
1634
|
+
if (span) span.textContent = col === column ? (networkSortAsc ? '\u25B2' : '\u25BC') : '';
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
const rows = Array.from(tbody.querySelectorAll('.network-row'));
|
|
1638
|
+
const numericCols = ['status', 'duration', 'size'];
|
|
1639
|
+
|
|
1640
|
+
rows.sort((a, b) => {
|
|
1641
|
+
let aVal = a.getAttribute('data-' + column) || '';
|
|
1642
|
+
let bVal = b.getAttribute('data-' + column) || '';
|
|
1643
|
+
|
|
1644
|
+
if (numericCols.includes(column)) {
|
|
1645
|
+
aVal = parseFloat(aVal) || 0;
|
|
1646
|
+
bVal = parseFloat(bVal) || 0;
|
|
1647
|
+
return networkSortAsc ? aVal - bVal : bVal - aVal;
|
|
1648
|
+
} else {
|
|
1649
|
+
return networkSortAsc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
rows.forEach(row => tbody.appendChild(row));
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
// Show network call detail modal
|
|
1657
|
+
window.showNetworkDetail = function(idx) {
|
|
1658
|
+
const call = window.networkCallsData[idx];
|
|
1659
|
+
if (!call) return;
|
|
1660
|
+
|
|
1661
|
+
const modal = document.getElementById('network-detail-modal');
|
|
1662
|
+
const content = document.getElementById('network-detail-content');
|
|
1663
|
+
if (!modal || !content) return;
|
|
1664
|
+
|
|
1665
|
+
const method = call.request_method || call.method || 'GET';
|
|
1666
|
+
const url = call.request_url || call.url || '';
|
|
1667
|
+
const status = call.response_status || call.status || 0;
|
|
1668
|
+
const statusText = call.response_status_text || call.statusText || '';
|
|
1669
|
+
const duration = call.duration || 0;
|
|
1670
|
+
const reqHeaders = call.request_headers || call.requestHeaders || {};
|
|
1671
|
+
const reqBody = call.request_body || call.requestBody || '';
|
|
1672
|
+
const resHeaders = call.response_headers || call.responseHeaders || {};
|
|
1673
|
+
const resBody = call.response_body || call.responseBody || '';
|
|
1674
|
+
const error = call.error || '';
|
|
1675
|
+
|
|
1676
|
+
const formatHeaders = (headers) => {
|
|
1677
|
+
if (!headers || Object.keys(headers).length === 0) return '<span style="color: #6b7280;">No headers captured</span>';
|
|
1678
|
+
return Object.entries(headers).map(([k, v]) =>
|
|
1679
|
+
'<div style="margin: 4px 0; word-break: break-all;"><span style="color: #00d4ff;">' + k + ':</span> <span style="color: #e5e7eb; word-break: break-all; overflow-wrap: break-word;">' + v + '</span></div>'
|
|
1680
|
+
).join('');
|
|
1681
|
+
};
|
|
1682
|
+
|
|
1683
|
+
const formatBody = (body) => {
|
|
1684
|
+
if (!body) return '<span style="color: #6b7280;">No body captured</span>';
|
|
1685
|
+
try {
|
|
1686
|
+
const parsed = JSON.parse(body);
|
|
1687
|
+
return '<pre style="margin: 0; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; color: #e5e7eb;">' + JSON.stringify(parsed, null, 2) + '</pre>';
|
|
1688
|
+
} catch {
|
|
1689
|
+
return '<pre style="margin: 0; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; color: #e5e7eb;">' + body + '</pre>';
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
content.innerHTML = `
|
|
1694
|
+
<div style="margin-bottom: 20px;">
|
|
1695
|
+
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
|
|
1696
|
+
<span style="padding: 4px 10px; border-radius: 4px; font-weight: bold; background: ${method === 'GET' ? '#065f46' : method === 'POST' ? '#1e40af' : '#6b21a8'}; color: white;">${method}</span>
|
|
1697
|
+
<span style="color: #e5e7eb; word-break: break-all;">${url}</span>
|
|
1698
|
+
</div>
|
|
1699
|
+
<div style="display: flex; gap: 20px; color: #9ca3af;">
|
|
1700
|
+
<span>Status: <span class="status-badge ${status >= 400 || status === 0 ? 'bad' : 'good'}">${status} ${statusText}</span></span>
|
|
1701
|
+
<span>Duration: <strong style="color: #e5e7eb;">${duration}ms</strong></span>
|
|
1702
|
+
<span>Type: <strong style="color: #e5e7eb;">${call.resource_type || call.type || '-'}</strong></span>
|
|
1703
|
+
</div>
|
|
1704
|
+
${error ? '<div style="margin-top: 12px; padding: 12px; background: #7f1d1d; border-radius: 6px; color: #fecaca;"><strong>Error:</strong> ' + error + '</div>' : ''}
|
|
1705
|
+
</div>
|
|
1706
|
+
|
|
1707
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
|
1708
|
+
<div>
|
|
1709
|
+
<h4 style="color: #00d4ff; margin: 0 0 12px 0; border-bottom: 1px solid #374151; padding-bottom: 8px;">Request</h4>
|
|
1710
|
+
<div style="margin-bottom: 16px;">
|
|
1711
|
+
<h5 style="color: #9ca3af; margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase;">Headers</h5>
|
|
1712
|
+
<div style="background: #111827; border-radius: 6px; padding: 12px; font-family: monospace; font-size: 12px; max-height: 200px; overflow: auto; word-break: break-all;">
|
|
1713
|
+
${formatHeaders(reqHeaders)}
|
|
1714
|
+
</div>
|
|
1715
|
+
</div>
|
|
1716
|
+
<div>
|
|
1717
|
+
<h5 style="color: #9ca3af; margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase;">Body</h5>
|
|
1718
|
+
<div style="background: #111827; border-radius: 6px; padding: 12px; font-family: monospace; font-size: 12px; max-height: 300px; overflow: auto; word-break: break-word;">
|
|
1719
|
+
${formatBody(reqBody)}
|
|
1720
|
+
</div>
|
|
1721
|
+
</div>
|
|
1722
|
+
</div>
|
|
1723
|
+
|
|
1724
|
+
<div>
|
|
1725
|
+
<h4 style="color: #22c55e; margin: 0 0 12px 0; border-bottom: 1px solid #374151; padding-bottom: 8px;">Response</h4>
|
|
1726
|
+
<div style="margin-bottom: 16px;">
|
|
1727
|
+
<h5 style="color: #9ca3af; margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase;">Headers</h5>
|
|
1728
|
+
<div style="background: #111827; border-radius: 6px; padding: 12px; font-family: monospace; font-size: 12px; max-height: 200px; overflow: auto; word-break: break-word;">
|
|
1729
|
+
${formatHeaders(resHeaders)}
|
|
1730
|
+
</div>
|
|
1731
|
+
</div>
|
|
1732
|
+
<div>
|
|
1733
|
+
<h5 style="color: #9ca3af; margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase;">Body</h5>
|
|
1734
|
+
<div style="background: #111827; border-radius: 6px; padding: 12px; font-family: monospace; font-size: 12px; max-height: 300px; overflow: auto; word-break: break-word;">
|
|
1735
|
+
${formatBody(resBody)}
|
|
1736
|
+
</div>
|
|
1737
|
+
</div>
|
|
1738
|
+
</div>
|
|
1739
|
+
</div>
|
|
1740
|
+
`;
|
|
1741
|
+
|
|
1742
|
+
modal.style.display = 'block';
|
|
1743
|
+
};
|
|
1744
|
+
|
|
1745
|
+
window.closeNetworkModal = function() {
|
|
1746
|
+
const modal = document.getElementById('network-detail-modal');
|
|
1747
|
+
if (modal) modal.style.display = 'none';
|
|
1748
|
+
};
|
|
1749
|
+
|
|
1750
|
+
// Close modal on escape key
|
|
1751
|
+
document.addEventListener('keydown', function(e) {
|
|
1752
|
+
if (e.key === 'Escape') window.closeNetworkModal();
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
// Close modal on outside click
|
|
1756
|
+
document.getElementById('network-detail-modal')?.addEventListener('click', function(e) {
|
|
1757
|
+
if (e.target === this) window.closeNetworkModal();
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
showPanel('detail');
|
|
1761
|
+
|
|
1762
|
+
setTimeout(() => {
|
|
1763
|
+
// Render live infrastructure charts
|
|
1764
|
+
renderDetailLiveInfraCharts();
|
|
1765
|
+
|
|
1766
|
+
// Response Time Distribution Histogram - disable crosshair (not time-based)
|
|
1767
|
+
const buckets = generateHistogramBuckets(data.summary);
|
|
1768
|
+
new Chart(document.getElementById('detail-distribution'), {
|
|
1769
|
+
type: 'bar',
|
|
1770
|
+
data: {
|
|
1771
|
+
labels: buckets.labels,
|
|
1772
|
+
datasets: [{
|
|
1773
|
+
label: 'Request Count', data: buckets.values,
|
|
1774
|
+
backgroundColor: 'rgba(0, 212, 255, 0.6)', borderColor: 'rgba(0, 212, 255, 1)', borderWidth: 1, borderRadius: 2
|
|
1775
|
+
}, {
|
|
1776
|
+
label: 'Percentage', data: buckets.percentages, type: 'line',
|
|
1777
|
+
borderColor: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.1)', yAxisID: 'y1', tension: 0.4
|
|
1778
|
+
}]
|
|
1779
|
+
},
|
|
1780
|
+
options: {
|
|
1781
|
+
responsive: true, maintainAspectRatio: false,
|
|
1782
|
+
plugins: { legend: { position: 'top', labels: { color: '#9ca3af' } }, sharedCrosshair: { enabled: false } },
|
|
1783
|
+
scales: {
|
|
1784
|
+
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' }, title: { display: true, text: 'Count', color: '#9ca3af' } },
|
|
1785
|
+
y1: { type: 'linear', display: true, position: 'right', min: 0, max: 100, grid: { drawOnChartArea: false }, ticks: { color: '#9ca3af' }, title: { display: true, text: '%', color: '#9ca3af' } },
|
|
1786
|
+
x: { grid: { display: false }, ticks: { color: '#9ca3af', maxRotation: 45 } }
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
// Percentiles Bar Chart - disable crosshair (not time-based)
|
|
1792
|
+
new Chart(document.getElementById('detail-percentiles'), {
|
|
1793
|
+
type: 'bar',
|
|
1794
|
+
data: {
|
|
1795
|
+
labels: ['Min', 'P50', 'P75', 'P90', 'P95', 'P99', 'Max'],
|
|
1796
|
+
datasets: [{
|
|
1797
|
+
data: [data.summary.min_response_time, data.summary.p50_response_time, data.summary.p75_response_time,
|
|
1798
|
+
data.summary.p90_response_time, data.summary.p95_response_time, data.summary.p99_response_time,
|
|
1799
|
+
data.summary.max_response_time],
|
|
1800
|
+
backgroundColor: ['#22c55e', '#00d4ff', '#00d4ff', '#00d4ff', '#eab308', '#ef4444', '#ef4444'],
|
|
1801
|
+
borderRadius: 4
|
|
1802
|
+
}]
|
|
1803
|
+
},
|
|
1804
|
+
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, sharedCrosshair: { enabled: false } }, scales: { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' } }, x: { grid: { display: false }, ticks: { color: '#9ca3af' } } } }
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
// Success/Failure Donut - disable crosshair (not time-based)
|
|
1808
|
+
new Chart(document.getElementById('detail-success'), {
|
|
1809
|
+
type: 'doughnut',
|
|
1810
|
+
data: {
|
|
1811
|
+
labels: ['Successful', 'Failed'],
|
|
1812
|
+
datasets: [{ data: [data.summary.successful_requests, data.summary.failed_requests], backgroundColor: ['#22c55e', '#ef4444'], borderWidth: 0 }]
|
|
1813
|
+
},
|
|
1814
|
+
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: '#9ca3af' } }, sharedCrosshair: { enabled: false } } }
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
// Timeline-based charts (if timeline data exists)
|
|
1818
|
+
if (data.timeline_data && data.timeline_data.length > 0) {
|
|
1819
|
+
const timeline = data.timeline_data;
|
|
1820
|
+
const timeLabels = timeline.map(t => {
|
|
1821
|
+
const d = new Date(t.timestamp);
|
|
1822
|
+
const h = d.getHours().toString().padStart(2, '0');
|
|
1823
|
+
const m = d.getMinutes().toString().padStart(2, '0');
|
|
1824
|
+
const s = d.getSeconds().toString().padStart(2, '0');
|
|
1825
|
+
const ms = d.getMilliseconds().toString().padStart(3, '0');
|
|
1826
|
+
return `${h}:${m}:${s}.${ms}`;
|
|
1827
|
+
});
|
|
1828
|
+
// Store timestamps for crosshair sync
|
|
1829
|
+
const timelineTimestamps = timeline.map(t => new Date(t.timestamp).getTime());
|
|
1830
|
+
|
|
1831
|
+
// Response Times Over Time (with percentiles)
|
|
1832
|
+
const rtOverTimeCtx = document.getElementById('detail-rt-over-time');
|
|
1833
|
+
if (rtOverTimeCtx) {
|
|
1834
|
+
chartTimestamps['detail-rt-over-time'] = timelineTimestamps;
|
|
1835
|
+
new Chart(rtOverTimeCtx, {
|
|
1836
|
+
type: 'line',
|
|
1837
|
+
data: {
|
|
1838
|
+
labels: timeLabels,
|
|
1839
|
+
datasets: [
|
|
1840
|
+
{ label: 'P50 (Median)', data: timeline.map(t => t.p50_response_time || 0), borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.1)', fill: false, tension: 0.3, pointRadius: 1 },
|
|
1841
|
+
{ label: 'P90', data: timeline.map(t => t.p90_response_time || 0), borderColor: '#00d4ff', backgroundColor: 'rgba(0, 212, 255, 0.1)', fill: false, tension: 0.3, pointRadius: 1 },
|
|
1842
|
+
{ label: 'P95', data: timeline.map(t => t.p95_response_time || 0), borderColor: '#eab308', backgroundColor: 'rgba(234, 179, 8, 0.1)', fill: false, tension: 0.3, pointRadius: 1 },
|
|
1843
|
+
{ label: 'P99', data: timeline.map(t => t.p99_response_time || 0), borderColor: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.1)', fill: false, tension: 0.3, pointRadius: 1 },
|
|
1844
|
+
{ label: 'Avg', data: timeline.map(t => t.avg_response_time || 0), borderColor: '#a855f7', backgroundColor: 'rgba(168, 85, 247, 0.1)', fill: false, tension: 0.3, pointRadius: 1, borderDash: [5, 5] }
|
|
1845
|
+
]
|
|
1846
|
+
},
|
|
1847
|
+
options: {
|
|
1848
|
+
responsive: true, maintainAspectRatio: false,
|
|
1849
|
+
interaction: { mode: 'index', intersect: false },
|
|
1850
|
+
plugins: { legend: { position: 'top', labels: { color: '#9ca3af', usePointStyle: true } } },
|
|
1851
|
+
scales: {
|
|
1852
|
+
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' }, title: { display: true, text: 'Response Time (ms)', color: '#9ca3af' } },
|
|
1853
|
+
x: { grid: { display: false }, ticks: { color: '#9ca3af', maxRotation: 45, maxTicksLimit: 20 } }
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// Throughput Over Time
|
|
1860
|
+
const throughputCtx = document.getElementById('detail-throughput');
|
|
1861
|
+
if (throughputCtx) {
|
|
1862
|
+
chartTimestamps['detail-throughput'] = timelineTimestamps;
|
|
1863
|
+
new Chart(throughputCtx, {
|
|
1864
|
+
type: 'line',
|
|
1865
|
+
data: {
|
|
1866
|
+
labels: timeLabels,
|
|
1867
|
+
datasets: [{
|
|
1868
|
+
label: 'Requests/sec',
|
|
1869
|
+
data: timeline.map(t => t.throughput || 0),
|
|
1870
|
+
borderColor: '#00d4ff',
|
|
1871
|
+
backgroundColor: 'rgba(0, 212, 255, 0.2)',
|
|
1872
|
+
fill: true,
|
|
1873
|
+
tension: 0.3,
|
|
1874
|
+
pointRadius: 1
|
|
1875
|
+
}]
|
|
1876
|
+
},
|
|
1877
|
+
options: {
|
|
1878
|
+
responsive: true, maintainAspectRatio: false,
|
|
1879
|
+
interaction: { mode: 'index', intersect: false },
|
|
1880
|
+
plugins: { legend: { display: false } },
|
|
1881
|
+
scales: {
|
|
1882
|
+
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' }, title: { display: true, text: 'Req/s', color: '#9ca3af' } },
|
|
1883
|
+
x: { grid: { display: false }, ticks: { color: '#9ca3af', maxRotation: 45, maxTicksLimit: 15 } }
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// Active VUs Over Time
|
|
1890
|
+
const vusCtx = document.getElementById('detail-vus');
|
|
1891
|
+
if (vusCtx) {
|
|
1892
|
+
chartTimestamps['detail-vus'] = timelineTimestamps;
|
|
1893
|
+
new Chart(vusCtx, {
|
|
1894
|
+
type: 'line',
|
|
1895
|
+
data: {
|
|
1896
|
+
labels: timeLabels,
|
|
1897
|
+
datasets: [{
|
|
1898
|
+
label: 'Active VUs',
|
|
1899
|
+
data: timeline.map(t => t.active_vus || 0),
|
|
1900
|
+
borderColor: '#a855f7',
|
|
1901
|
+
backgroundColor: 'rgba(168, 85, 247, 0.2)',
|
|
1902
|
+
fill: true,
|
|
1903
|
+
tension: 0.3,
|
|
1904
|
+
pointRadius: 1,
|
|
1905
|
+
stepped: 'before'
|
|
1906
|
+
}]
|
|
1907
|
+
},
|
|
1908
|
+
options: {
|
|
1909
|
+
responsive: true, maintainAspectRatio: false,
|
|
1910
|
+
interaction: { mode: 'index', intersect: false },
|
|
1911
|
+
plugins: { legend: { display: false } },
|
|
1912
|
+
scales: {
|
|
1913
|
+
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af', stepSize: 1 }, title: { display: true, text: 'VUs', color: '#9ca3af' } },
|
|
1914
|
+
x: { grid: { display: false }, ticks: { color: '#9ca3af', maxRotation: 45, maxTicksLimit: 15 } }
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// Errors Over Time
|
|
1921
|
+
const errorsCtx = document.getElementById('detail-errors-time');
|
|
1922
|
+
if (errorsCtx) {
|
|
1923
|
+
chartTimestamps['detail-errors-time'] = timelineTimestamps;
|
|
1924
|
+
new Chart(errorsCtx, {
|
|
1925
|
+
type: 'bar',
|
|
1926
|
+
data: {
|
|
1927
|
+
labels: timeLabels,
|
|
1928
|
+
datasets: [{
|
|
1929
|
+
label: 'Errors',
|
|
1930
|
+
data: timeline.map(t => t.error_count || 0),
|
|
1931
|
+
backgroundColor: 'rgba(239, 68, 68, 0.7)',
|
|
1932
|
+
borderColor: '#ef4444',
|
|
1933
|
+
borderWidth: 1
|
|
1934
|
+
}]
|
|
1935
|
+
},
|
|
1936
|
+
options: {
|
|
1937
|
+
responsive: true, maintainAspectRatio: false,
|
|
1938
|
+
interaction: { mode: 'index', intersect: false },
|
|
1939
|
+
plugins: { legend: { display: false } },
|
|
1940
|
+
scales: {
|
|
1941
|
+
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af', stepSize: 1 }, title: { display: true, text: 'Errors', color: '#9ca3af' } },
|
|
1942
|
+
x: { grid: { display: false }, ticks: { color: '#9ca3af', maxRotation: 45, maxTicksLimit: 15 } }
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// Response Codes Over Time (stacked area)
|
|
1949
|
+
const statusCodesCtx = document.getElementById('detail-status-codes');
|
|
1950
|
+
if (statusCodesCtx) {
|
|
1951
|
+
chartTimestamps['detail-status-codes'] = timelineTimestamps;
|
|
1952
|
+
// Collect all unique status codes
|
|
1953
|
+
const allStatusCodes = new Set();
|
|
1954
|
+
timeline.forEach(t => {
|
|
1955
|
+
if (t.status_codes) Object.keys(t.status_codes).forEach(code => allStatusCodes.add(parseInt(code)));
|
|
1956
|
+
});
|
|
1957
|
+
const statusCodeList = Array.from(allStatusCodes).sort((a, b) => a - b);
|
|
1958
|
+
|
|
1959
|
+
// Color mapping for status codes
|
|
1960
|
+
const getStatusColor = (code) => {
|
|
1961
|
+
if (code >= 200 && code < 300) return { bg: 'rgba(34, 197, 94, 0.7)', border: '#22c55e' }; // Green for 2xx
|
|
1962
|
+
if (code >= 300 && code < 400) return { bg: 'rgba(59, 130, 246, 0.7)', border: '#3b82f6' }; // Blue for 3xx
|
|
1963
|
+
if (code >= 400 && code < 500) return { bg: 'rgba(234, 179, 8, 0.7)', border: '#eab308' }; // Yellow for 4xx
|
|
1964
|
+
if (code >= 500) return { bg: 'rgba(239, 68, 68, 0.7)', border: '#ef4444' }; // Red for 5xx
|
|
1965
|
+
return { bg: 'rgba(156, 163, 175, 0.7)', border: '#9ca3af' }; // Gray for others
|
|
1966
|
+
};
|
|
1967
|
+
|
|
1968
|
+
const statusDatasets = statusCodeList.map(code => {
|
|
1969
|
+
const colors = getStatusColor(code);
|
|
1970
|
+
return {
|
|
1971
|
+
label: code.toString(),
|
|
1972
|
+
data: timeline.map(t => (t.status_codes && t.status_codes[code]) || 0),
|
|
1973
|
+
backgroundColor: colors.bg,
|
|
1974
|
+
borderColor: colors.border,
|
|
1975
|
+
borderWidth: 1,
|
|
1976
|
+
stack: 'status'
|
|
1977
|
+
};
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1980
|
+
new Chart(statusCodesCtx, {
|
|
1981
|
+
type: 'bar',
|
|
1982
|
+
data: { labels: timeLabels, datasets: statusDatasets },
|
|
1983
|
+
options: {
|
|
1984
|
+
responsive: true, maintainAspectRatio: false,
|
|
1985
|
+
interaction: { mode: 'index', intersect: false },
|
|
1986
|
+
plugins: { legend: { position: 'top', labels: { color: '#9ca3af', boxWidth: 12 } } },
|
|
1987
|
+
scales: {
|
|
1988
|
+
y: { stacked: true, beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' }, title: { display: true, text: 'Requests', color: '#9ca3af' } },
|
|
1989
|
+
x: { stacked: true, grid: { display: false }, ticks: { color: '#9ca3af', maxRotation: 45, maxTicksLimit: 15 } }
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
// Latency Breakdown (Connect Time vs TTFB vs Total)
|
|
1996
|
+
const latencyCtx = document.getElementById('detail-latency');
|
|
1997
|
+
if (latencyCtx) {
|
|
1998
|
+
chartTimestamps['detail-latency'] = timelineTimestamps;
|
|
1999
|
+
new Chart(latencyCtx, {
|
|
2000
|
+
type: 'line',
|
|
2001
|
+
data: {
|
|
2002
|
+
labels: timeLabels,
|
|
2003
|
+
datasets: [
|
|
2004
|
+
{ label: 'Connect Time', data: timeline.map(t => t.connect_time_avg || 0), borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.1)', fill: false, tension: 0.3, pointRadius: 1 },
|
|
2005
|
+
{ label: 'Latency (TTFB)', data: timeline.map(t => t.latency_avg || 0), borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', fill: false, tension: 0.3, pointRadius: 1 },
|
|
2006
|
+
{ label: 'Total Response Time', data: timeline.map(t => t.avg_response_time || 0), borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.1)', fill: false, tension: 0.3, pointRadius: 1 }
|
|
2007
|
+
]
|
|
2008
|
+
},
|
|
2009
|
+
options: {
|
|
2010
|
+
responsive: true, maintainAspectRatio: false,
|
|
2011
|
+
interaction: { mode: 'index', intersect: false },
|
|
2012
|
+
plugins: { legend: { position: 'top', labels: { color: '#9ca3af', usePointStyle: true } } },
|
|
2013
|
+
scales: {
|
|
2014
|
+
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' }, title: { display: true, text: 'Time (ms)', color: '#9ca3af' } },
|
|
2015
|
+
x: { grid: { display: false }, ticks: { color: '#9ca3af', maxRotation: 45, maxTicksLimit: 15 } }
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// Bytes Throughput
|
|
2022
|
+
const bytesCtx = document.getElementById('detail-bytes');
|
|
2023
|
+
if (bytesCtx) {
|
|
2024
|
+
chartTimestamps['detail-bytes'] = timelineTimestamps;
|
|
2025
|
+
new Chart(bytesCtx, {
|
|
2026
|
+
type: 'line',
|
|
2027
|
+
data: {
|
|
2028
|
+
labels: timeLabels,
|
|
2029
|
+
datasets: [
|
|
2030
|
+
{ label: 'Bytes Sent', data: timeline.map(t => (t.bytes_sent || 0) / 1024), borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.1)', fill: false, tension: 0.3, pointRadius: 1 },
|
|
2031
|
+
{ label: 'Bytes Received', data: timeline.map(t => (t.bytes_received || 0) / 1024), borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.1)', fill: false, tension: 0.3, pointRadius: 1 }
|
|
2032
|
+
]
|
|
2033
|
+
},
|
|
2034
|
+
options: {
|
|
2035
|
+
responsive: true, maintainAspectRatio: false,
|
|
2036
|
+
interaction: { mode: 'index', intersect: false },
|
|
2037
|
+
plugins: { legend: { position: 'top', labels: { color: '#9ca3af', usePointStyle: true } } },
|
|
2038
|
+
scales: {
|
|
2039
|
+
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' }, title: { display: true, text: 'KB', color: '#9ca3af' } },
|
|
2040
|
+
x: { grid: { display: false }, ticks: { color: '#9ca3af', maxRotation: 45, maxTicksLimit: 15 } }
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// Step Percentiles Chart (if step data exists) - disable crosshair (not time-based)
|
|
2048
|
+
if (stepStats.length) {
|
|
2049
|
+
const sortedSteps = [...stepStats].sort((a, b) => (b.percentiles?.['95'] || 0) - (a.percentiles?.['95'] || 0)).slice(0, 10);
|
|
2050
|
+
new Chart(document.getElementById('detail-step-percentiles'), {
|
|
2051
|
+
type: 'bar',
|
|
2052
|
+
data: {
|
|
2053
|
+
labels: sortedSteps.map(s => s.step_name.substring(0, 20)),
|
|
2054
|
+
datasets: [
|
|
2055
|
+
{ label: 'P50', data: sortedSteps.map(s => s.percentiles?.['50'] || 0), backgroundColor: 'rgba(0, 212, 255, 0.7)' },
|
|
2056
|
+
{ label: 'P95', data: sortedSteps.map(s => s.percentiles?.['95'] || 0), backgroundColor: 'rgba(234, 179, 8, 0.7)' },
|
|
2057
|
+
{ label: 'P99', data: sortedSteps.map(s => s.percentiles?.['99'] || 0), backgroundColor: 'rgba(239, 68, 68, 0.7)' }
|
|
2058
|
+
]
|
|
2059
|
+
},
|
|
2060
|
+
options: {
|
|
2061
|
+
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
|
2062
|
+
plugins: { legend: { position: 'top', labels: { color: '#9ca3af' } }, title: { display: true, text: 'Response Time Percentiles (Slowest Steps)', color: '#9ca3af' }, sharedCrosshair: { enabled: false } },
|
|
2063
|
+
scales: { x: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' }, title: { display: true, text: 'ms', color: '#9ca3af' } }, y: { grid: { display: false }, ticks: { color: '#9ca3af' } } }
|
|
2064
|
+
}
|
|
2065
|
+
});
|
|
2066
|
+
|
|
2067
|
+
// Step Distribution Doughnut - disable crosshair (not time-based)
|
|
2068
|
+
new Chart(document.getElementById('detail-step-distribution'), {
|
|
2069
|
+
type: 'doughnut',
|
|
2070
|
+
data: {
|
|
2071
|
+
labels: stepStats.map(s => s.step_name.substring(0, 15)),
|
|
2072
|
+
datasets: [{ data: stepStats.map(s => s.total_requests || 0), backgroundColor: stepStats.map((_, i) => `hsl(${i * 137.5 % 360}, 70%, 50%)`) }]
|
|
2073
|
+
},
|
|
2074
|
+
options: {
|
|
2075
|
+
responsive: true, maintainAspectRatio: false,
|
|
2076
|
+
plugins: { legend: { position: 'right', labels: { color: '#9ca3af', boxWidth: 12 } }, title: { display: true, text: 'Request Distribution by Step', color: '#9ca3af' }, sharedCrosshair: { enabled: false } }
|
|
2077
|
+
}
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
// Individual Response Times Scatter Chart (colored by step)
|
|
2081
|
+
// Use raw results with actual timestamps if available
|
|
2082
|
+
const rawResults = data.raw?.results || [];
|
|
2083
|
+
|
|
2084
|
+
if (rawResults.length > 0) {
|
|
2085
|
+
const stepColors = [
|
|
2086
|
+
{ bg: 'rgba(34, 197, 94, 0.6)', border: '#22c55e' }, // green
|
|
2087
|
+
{ bg: 'rgba(59, 130, 246, 0.6)', border: '#3b82f6' }, // blue
|
|
2088
|
+
{ bg: 'rgba(168, 85, 247, 0.6)', border: '#a855f7' }, // purple
|
|
2089
|
+
{ bg: 'rgba(245, 158, 11, 0.6)', border: '#f59e0b' }, // amber
|
|
2090
|
+
{ bg: 'rgba(236, 72, 153, 0.6)', border: '#ec4899' }, // pink
|
|
2091
|
+
{ bg: 'rgba(20, 184, 166, 0.6)', border: '#14b8a6' }, // teal
|
|
2092
|
+
{ bg: 'rgba(99, 102, 241, 0.6)', border: '#6366f1' }, // indigo
|
|
2093
|
+
{ bg: 'rgba(249, 115, 22, 0.6)', border: '#f97316' }, // orange
|
|
2094
|
+
];
|
|
2095
|
+
|
|
2096
|
+
// Sample if too many results (limit to 2000 total)
|
|
2097
|
+
let resultsToPlot = rawResults;
|
|
2098
|
+
if (resultsToPlot.length > 2000) {
|
|
2099
|
+
const sampleStep = Math.ceil(resultsToPlot.length / 2000);
|
|
2100
|
+
resultsToPlot = resultsToPlot.filter((_, i) => i % sampleStep === 0);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// Find test start time
|
|
2104
|
+
const startTime = Math.min(...resultsToPlot.map(r => r.timestamp || 0));
|
|
2105
|
+
|
|
2106
|
+
// Helper to format timestamp as hh:mm:ss.mmm
|
|
2107
|
+
const formatTimeMs = (ts) => {
|
|
2108
|
+
const d = new Date(ts);
|
|
2109
|
+
const h = d.getHours().toString().padStart(2, '0');
|
|
2110
|
+
const m = d.getMinutes().toString().padStart(2, '0');
|
|
2111
|
+
const s = d.getSeconds().toString().padStart(2, '0');
|
|
2112
|
+
const ms = d.getMilliseconds().toString().padStart(3, '0');
|
|
2113
|
+
return `${h}:${m}:${s}.${ms}`;
|
|
2114
|
+
};
|
|
2115
|
+
|
|
2116
|
+
// Group results by step name
|
|
2117
|
+
const stepGroups = {};
|
|
2118
|
+
const failedData = [];
|
|
2119
|
+
|
|
2120
|
+
resultsToPlot.forEach(r => {
|
|
2121
|
+
const rt = r.duration || r.response_time || 0;
|
|
2122
|
+
const ts = r.timestamp || 0;
|
|
2123
|
+
const point = { x: ts, y: rt, timestamp: ts };
|
|
2124
|
+
|
|
2125
|
+
if (r.success === false) {
|
|
2126
|
+
failedData.push(point);
|
|
2127
|
+
} else {
|
|
2128
|
+
const stepName = r.step_name || r.action || 'unknown';
|
|
2129
|
+
if (!stepGroups[stepName]) stepGroups[stepName] = [];
|
|
2130
|
+
stepGroups[stepName].push(point);
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
// Create datasets for each step
|
|
2135
|
+
const stepNames = Object.keys(stepGroups);
|
|
2136
|
+
const scatterDatasets = stepNames.map((name, i) => {
|
|
2137
|
+
const colors = stepColors[i % stepColors.length];
|
|
2138
|
+
return {
|
|
2139
|
+
label: name.substring(0, 20),
|
|
2140
|
+
data: stepGroups[name],
|
|
2141
|
+
backgroundColor: colors.bg,
|
|
2142
|
+
borderColor: colors.border,
|
|
2143
|
+
pointRadius: 2
|
|
2144
|
+
};
|
|
2145
|
+
});
|
|
2146
|
+
|
|
2147
|
+
// Add failed requests as separate dataset (always red)
|
|
2148
|
+
if (failedData.length > 0) {
|
|
2149
|
+
scatterDatasets.push({
|
|
2150
|
+
label: 'Failed',
|
|
2151
|
+
data: failedData,
|
|
2152
|
+
backgroundColor: 'rgba(239, 68, 68, 0.8)',
|
|
2153
|
+
borderColor: '#ef4444',
|
|
2154
|
+
pointRadius: 3
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
if (scatterDatasets.length > 0) {
|
|
2159
|
+
createScatterChart('detail-rt-scatter', scatterDatasets, startTime, formatTimeMs);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// Network Charts
|
|
2165
|
+
if (data.network_calls && data.network_calls.length > 0) {
|
|
2166
|
+
const networkCalls = data.network_calls;
|
|
2167
|
+
|
|
2168
|
+
// Helper to format timestamp as hh:mm:ss.mmm
|
|
2169
|
+
const formatNetworkTime = (ts) => {
|
|
2170
|
+
const d = new Date(ts);
|
|
2171
|
+
const h = d.getHours().toString().padStart(2, '0');
|
|
2172
|
+
const m = d.getMinutes().toString().padStart(2, '0');
|
|
2173
|
+
const s = d.getSeconds().toString().padStart(2, '0');
|
|
2174
|
+
const ms = d.getMilliseconds().toString().padStart(3, '0');
|
|
2175
|
+
return `${h}:${m}:${s}.${ms}`;
|
|
2176
|
+
};
|
|
2177
|
+
|
|
2178
|
+
// 0. Scatter Chart - Each request as a dot over time
|
|
2179
|
+
const scatterCtx = document.getElementById('network-scatter-chart');
|
|
2180
|
+
if (scatterCtx) {
|
|
2181
|
+
// Group by resource type for different colors
|
|
2182
|
+
const typeGroups = {};
|
|
2183
|
+
const failedPoints = [];
|
|
2184
|
+
|
|
2185
|
+
networkCalls.forEach(c => {
|
|
2186
|
+
const ts = c.timestamp || c.start_time || 0;
|
|
2187
|
+
const duration = c.duration || 0;
|
|
2188
|
+
const status = c.response_status || c.status || 0;
|
|
2189
|
+
const type = (c.resource_type || c.type || 'other').toLowerCase();
|
|
2190
|
+
const url = c.request_url || c.url || '';
|
|
2191
|
+
|
|
2192
|
+
const point = {
|
|
2193
|
+
x: ts, // Store actual timestamp
|
|
2194
|
+
y: duration,
|
|
2195
|
+
url: url,
|
|
2196
|
+
status: status,
|
|
2197
|
+
type: type,
|
|
2198
|
+
timestamp: ts
|
|
2199
|
+
};
|
|
2200
|
+
|
|
2201
|
+
if (status === 0 || status >= 400) {
|
|
2202
|
+
failedPoints.push(point);
|
|
2203
|
+
} else {
|
|
2204
|
+
if (!typeGroups[type]) typeGroups[type] = [];
|
|
2205
|
+
typeGroups[type].push(point);
|
|
2206
|
+
}
|
|
2207
|
+
});
|
|
2208
|
+
|
|
2209
|
+
const typeColors = {
|
|
2210
|
+
'xhr': { bg: 'rgba(0, 212, 255, 0.6)', border: '#00d4ff' },
|
|
2211
|
+
'fetch': { bg: 'rgba(34, 197, 94, 0.6)', border: '#22c55e' },
|
|
2212
|
+
'document': { bg: 'rgba(168, 85, 247, 0.6)', border: '#a855f7' },
|
|
2213
|
+
'script': { bg: 'rgba(245, 158, 11, 0.6)', border: '#f59e0b' },
|
|
2214
|
+
'stylesheet': { bg: 'rgba(236, 72, 153, 0.6)', border: '#ec4899' },
|
|
2215
|
+
'image': { bg: 'rgba(20, 184, 166, 0.6)', border: '#14b8a6' },
|
|
2216
|
+
'font': { bg: 'rgba(99, 102, 241, 0.6)', border: '#6366f1' },
|
|
2217
|
+
'other': { bg: 'rgba(156, 163, 175, 0.6)', border: '#9ca3af' }
|
|
2218
|
+
};
|
|
2219
|
+
|
|
2220
|
+
const scatterDatasets = Object.entries(typeGroups).map(([type, points]) => {
|
|
2221
|
+
const colors = typeColors[type] || typeColors['other'];
|
|
2222
|
+
return {
|
|
2223
|
+
label: type.toUpperCase(),
|
|
2224
|
+
data: points,
|
|
2225
|
+
backgroundColor: colors.bg,
|
|
2226
|
+
borderColor: colors.border,
|
|
2227
|
+
pointRadius: 4,
|
|
2228
|
+
pointHoverRadius: 6
|
|
2229
|
+
};
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
// Add failed requests as red dots
|
|
2233
|
+
if (failedPoints.length > 0) {
|
|
2234
|
+
scatterDatasets.push({
|
|
2235
|
+
label: 'Failed/Error',
|
|
2236
|
+
data: failedPoints,
|
|
2237
|
+
backgroundColor: 'rgba(239, 68, 68, 0.8)',
|
|
2238
|
+
borderColor: '#ef4444',
|
|
2239
|
+
pointRadius: 5,
|
|
2240
|
+
pointHoverRadius: 7
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
new Chart(scatterCtx, {
|
|
2245
|
+
type: 'scatter',
|
|
2246
|
+
data: { datasets: scatterDatasets },
|
|
2247
|
+
options: {
|
|
2248
|
+
responsive: true,
|
|
2249
|
+
maintainAspectRatio: false,
|
|
2250
|
+
plugins: {
|
|
2251
|
+
legend: { position: 'top', labels: { color: '#9ca3af', boxWidth: 12, padding: 15 } },
|
|
2252
|
+
tooltip: {
|
|
2253
|
+
callbacks: {
|
|
2254
|
+
title: (items) => {
|
|
2255
|
+
const point = items[0].raw;
|
|
2256
|
+
const url = point.url || '';
|
|
2257
|
+
return url.length > 70 ? url.substring(0, 70) + '...' : url;
|
|
2258
|
+
},
|
|
2259
|
+
label: (item) => {
|
|
2260
|
+
const point = item.raw;
|
|
2261
|
+
return `Duration: ${point.y}ms | Status: ${point.status} | Time: ${formatNetworkTime(point.x)}`;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
},
|
|
2266
|
+
scales: {
|
|
2267
|
+
x: {
|
|
2268
|
+
type: 'linear',
|
|
2269
|
+
position: 'bottom',
|
|
2270
|
+
title: { display: true, text: 'Time', color: '#9ca3af' },
|
|
2271
|
+
grid: { color: 'rgba(255,255,255,0.1)' },
|
|
2272
|
+
ticks: {
|
|
2273
|
+
color: '#9ca3af',
|
|
2274
|
+
maxTicksLimit: 10,
|
|
2275
|
+
callback: function(value) {
|
|
2276
|
+
return formatNetworkTime(value);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
},
|
|
2280
|
+
y: {
|
|
2281
|
+
beginAtZero: true,
|
|
2282
|
+
title: { display: true, text: 'Duration (ms)', color: '#9ca3af' },
|
|
2283
|
+
grid: { color: 'rgba(255,255,255,0.1)' },
|
|
2284
|
+
ticks: { color: '#9ca3af' }
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
// 1. Timeline Chart - Duration by request order
|
|
2292
|
+
const timelineCtx = document.getElementById('network-timeline-chart');
|
|
2293
|
+
if (timelineCtx) {
|
|
2294
|
+
const timelineData = networkCalls.slice(0, 50).map((c, i) => ({
|
|
2295
|
+
x: i,
|
|
2296
|
+
y: c.duration || 0,
|
|
2297
|
+
url: c.request_url || c.url || '',
|
|
2298
|
+
status: c.response_status || c.status || 0
|
|
2299
|
+
}));
|
|
2300
|
+
|
|
2301
|
+
new Chart(timelineCtx, {
|
|
2302
|
+
type: 'bar',
|
|
2303
|
+
data: {
|
|
2304
|
+
labels: timelineData.map((_, i) => '#' + (i + 1)),
|
|
2305
|
+
datasets: [{
|
|
2306
|
+
label: 'Duration (ms)',
|
|
2307
|
+
data: timelineData.map(d => d.y),
|
|
2308
|
+
backgroundColor: timelineData.map(d =>
|
|
2309
|
+
d.status >= 400 || d.status === 0 ? 'rgba(239, 68, 68, 0.7)' :
|
|
2310
|
+
d.status >= 300 ? 'rgba(245, 158, 11, 0.7)' : 'rgba(0, 212, 255, 0.7)'
|
|
2311
|
+
),
|
|
2312
|
+
borderRadius: 2
|
|
2313
|
+
}]
|
|
2314
|
+
},
|
|
2315
|
+
options: {
|
|
2316
|
+
responsive: true,
|
|
2317
|
+
maintainAspectRatio: false,
|
|
2318
|
+
plugins: {
|
|
2319
|
+
legend: { display: false },
|
|
2320
|
+
tooltip: {
|
|
2321
|
+
callbacks: {
|
|
2322
|
+
title: (items) => {
|
|
2323
|
+
const idx = items[0].dataIndex;
|
|
2324
|
+
const url = timelineData[idx].url;
|
|
2325
|
+
return url.length > 60 ? url.substring(0, 60) + '...' : url;
|
|
2326
|
+
},
|
|
2327
|
+
label: (item) => {
|
|
2328
|
+
const idx = item.dataIndex;
|
|
2329
|
+
return 'Duration: ' + timelineData[idx].y + 'ms (Status: ' + timelineData[idx].status + ')';
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
},
|
|
2334
|
+
scales: {
|
|
2335
|
+
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' }, title: { display: true, text: 'ms', color: '#9ca3af' } },
|
|
2336
|
+
x: { grid: { display: false }, ticks: { color: '#9ca3af', maxRotation: 0 } }
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
// 2. Endpoint Response Time Chart - Avg duration by endpoint
|
|
2343
|
+
const endpointCtx = document.getElementById('network-endpoint-chart');
|
|
2344
|
+
if (endpointCtx) {
|
|
2345
|
+
const endpointStats = {};
|
|
2346
|
+
networkCalls.forEach(c => {
|
|
2347
|
+
const url = c.request_url || c.url || '';
|
|
2348
|
+
try {
|
|
2349
|
+
const pathname = new URL(url).pathname;
|
|
2350
|
+
if (!endpointStats[pathname]) endpointStats[pathname] = { total: 0, count: 0 };
|
|
2351
|
+
endpointStats[pathname].total += (c.duration || 0);
|
|
2352
|
+
endpointStats[pathname].count++;
|
|
2353
|
+
} catch { /* ignore invalid URLs */ }
|
|
2354
|
+
});
|
|
2355
|
+
|
|
2356
|
+
const endpoints = Object.entries(endpointStats)
|
|
2357
|
+
.map(([path, stats]) => ({ path, avg: stats.total / stats.count, count: stats.count }))
|
|
2358
|
+
.sort((a, b) => b.avg - a.avg)
|
|
2359
|
+
.slice(0, 10);
|
|
2360
|
+
|
|
2361
|
+
new Chart(endpointCtx, {
|
|
2362
|
+
type: 'bar',
|
|
2363
|
+
data: {
|
|
2364
|
+
labels: endpoints.map(e => e.path.length > 25 ? '...' + e.path.slice(-22) : e.path),
|
|
2365
|
+
datasets: [{
|
|
2366
|
+
label: 'Avg Duration (ms)',
|
|
2367
|
+
data: endpoints.map(e => e.avg.toFixed(0)),
|
|
2368
|
+
backgroundColor: 'rgba(34, 197, 94, 0.7)',
|
|
2369
|
+
borderRadius: 2
|
|
2370
|
+
}]
|
|
2371
|
+
},
|
|
2372
|
+
options: {
|
|
2373
|
+
indexAxis: 'y',
|
|
2374
|
+
responsive: true,
|
|
2375
|
+
maintainAspectRatio: false,
|
|
2376
|
+
plugins: {
|
|
2377
|
+
legend: { display: false },
|
|
2378
|
+
tooltip: {
|
|
2379
|
+
callbacks: {
|
|
2380
|
+
title: (items) => endpoints[items[0].dataIndex].path,
|
|
2381
|
+
label: (item) => 'Avg: ' + item.raw + 'ms (' + endpoints[item.dataIndex].count + ' calls)'
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
},
|
|
2385
|
+
scales: {
|
|
2386
|
+
x: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' } },
|
|
2387
|
+
y: { grid: { display: false }, ticks: { color: '#9ca3af', font: { size: 10 } } }
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
});
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// 3. Status Code Distribution
|
|
2394
|
+
const statusCtx = document.getElementById('network-status-chart');
|
|
2395
|
+
if (statusCtx) {
|
|
2396
|
+
const statusCounts = {};
|
|
2397
|
+
networkCalls.forEach(c => {
|
|
2398
|
+
const status = c.response_status || c.status || 0;
|
|
2399
|
+
const category = status === 0 ? 'Failed' :
|
|
2400
|
+
status < 300 ? '2xx Success' :
|
|
2401
|
+
status < 400 ? '3xx Redirect' :
|
|
2402
|
+
status < 500 ? '4xx Client Error' : '5xx Server Error';
|
|
2403
|
+
statusCounts[category] = (statusCounts[category] || 0) + 1;
|
|
2404
|
+
});
|
|
2405
|
+
|
|
2406
|
+
const statusLabels = Object.keys(statusCounts);
|
|
2407
|
+
const statusColors = statusLabels.map(s =>
|
|
2408
|
+
s.includes('Success') ? '#22c55e' :
|
|
2409
|
+
s.includes('Redirect') ? '#eab308' :
|
|
2410
|
+
s.includes('Client') ? '#f97316' :
|
|
2411
|
+
s.includes('Server') ? '#ef4444' : '#6b7280'
|
|
2412
|
+
);
|
|
2413
|
+
|
|
2414
|
+
new Chart(statusCtx, {
|
|
2415
|
+
type: 'doughnut',
|
|
2416
|
+
data: {
|
|
2417
|
+
labels: statusLabels,
|
|
2418
|
+
datasets: [{ data: Object.values(statusCounts), backgroundColor: statusColors, borderWidth: 0 }]
|
|
2419
|
+
},
|
|
2420
|
+
options: {
|
|
2421
|
+
responsive: true,
|
|
2422
|
+
maintainAspectRatio: false,
|
|
2423
|
+
plugins: { legend: { position: 'right', labels: { color: '#9ca3af', boxWidth: 12, padding: 8 } } }
|
|
2424
|
+
}
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// 4. Request Type Distribution
|
|
2429
|
+
const typeCtx = document.getElementById('network-type-chart');
|
|
2430
|
+
if (typeCtx) {
|
|
2431
|
+
const typeCounts = {};
|
|
2432
|
+
networkCalls.forEach(c => {
|
|
2433
|
+
const type = (c.resource_type || c.type || 'other').toLowerCase();
|
|
2434
|
+
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
|
2435
|
+
});
|
|
2436
|
+
|
|
2437
|
+
const typeLabels = Object.keys(typeCounts);
|
|
2438
|
+
const typeColors = ['#00d4ff', '#22c55e', '#a855f7', '#f59e0b', '#ec4899', '#14b8a6', '#6366f1', '#f97316'];
|
|
2439
|
+
|
|
2440
|
+
new Chart(typeCtx, {
|
|
2441
|
+
type: 'doughnut',
|
|
2442
|
+
data: {
|
|
2443
|
+
labels: typeLabels,
|
|
2444
|
+
datasets: [{ data: Object.values(typeCounts), backgroundColor: typeColors.slice(0, typeLabels.length), borderWidth: 0 }]
|
|
2445
|
+
},
|
|
2446
|
+
options: {
|
|
2447
|
+
responsive: true,
|
|
2448
|
+
maintainAspectRatio: false,
|
|
2449
|
+
plugins: { legend: { position: 'right', labels: { color: '#9ca3af', boxWidth: 12, padding: 8 } } }
|
|
2450
|
+
}
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
// Infrastructure Correlation Charts
|
|
2456
|
+
if (data.infrastructure_metrics && Object.keys(data.infrastructure_metrics).length > 0) {
|
|
2457
|
+
Object.entries(data.infrastructure_metrics).forEach(([host, metrics]) => {
|
|
2458
|
+
if (!metrics || metrics.length < 2) return;
|
|
2459
|
+
|
|
2460
|
+
const hostId = host.replace(/[^a-zA-Z0-9]/g, '_');
|
|
2461
|
+
const labels = metrics.map((h, i) => {
|
|
2462
|
+
const elapsed = i * (h.interval_seconds || 5);
|
|
2463
|
+
const mins = Math.floor(elapsed / 60);
|
|
2464
|
+
const secs = elapsed % 60;
|
|
2465
|
+
return mins > 0 ? mins + 'm' + secs + 's' : secs + 's';
|
|
2466
|
+
});
|
|
2467
|
+
|
|
2468
|
+
// CPU Chart
|
|
2469
|
+
const cpuCtx = document.getElementById('detail-infra-cpu-' + hostId);
|
|
2470
|
+
if (cpuCtx) {
|
|
2471
|
+
new Chart(cpuCtx, {
|
|
2472
|
+
type: 'line',
|
|
2473
|
+
data: {
|
|
2474
|
+
labels,
|
|
2475
|
+
datasets: [{
|
|
2476
|
+
label: 'CPU %',
|
|
2477
|
+
data: metrics.map(h => h.metrics?.cpu?.usage_percent ?? 0),
|
|
2478
|
+
borderColor: '#00d4ff',
|
|
2479
|
+
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
|
2480
|
+
fill: true,
|
|
2481
|
+
tension: 0.3
|
|
2482
|
+
}]
|
|
2483
|
+
},
|
|
2484
|
+
options: {
|
|
2485
|
+
responsive: true,
|
|
2486
|
+
maintainAspectRatio: false,
|
|
2487
|
+
plugins: { legend: { display: false } },
|
|
2488
|
+
scales: {
|
|
2489
|
+
y: { beginAtZero: true, max: 100, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' } },
|
|
2490
|
+
x: { grid: { display: false }, ticks: { color: '#9ca3af', maxTicksLimit: 10 } }
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// Memory Chart
|
|
2497
|
+
const memCtx = document.getElementById('detail-infra-mem-' + hostId);
|
|
2498
|
+
if (memCtx) {
|
|
2499
|
+
new Chart(memCtx, {
|
|
2500
|
+
type: 'line',
|
|
2501
|
+
data: {
|
|
2502
|
+
labels,
|
|
2503
|
+
datasets: [{
|
|
2504
|
+
label: 'Memory %',
|
|
2505
|
+
data: metrics.map(h => h.metrics?.memory?.usage_percent ?? 0),
|
|
2506
|
+
borderColor: '#9c40ff',
|
|
2507
|
+
backgroundColor: 'rgba(156, 64, 255, 0.1)',
|
|
2508
|
+
fill: true,
|
|
2509
|
+
tension: 0.3
|
|
2510
|
+
}]
|
|
2511
|
+
},
|
|
2512
|
+
options: {
|
|
2513
|
+
responsive: true,
|
|
2514
|
+
maintainAspectRatio: false,
|
|
2515
|
+
plugins: { legend: { display: false } },
|
|
2516
|
+
scales: {
|
|
2517
|
+
y: { beginAtZero: true, max: 100, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' } },
|
|
2518
|
+
x: { grid: { display: false }, ticks: { color: '#9ca3af', maxTicksLimit: 10 } }
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
}, 100);
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
function generateHistogramBuckets(summary) {
|
|
2529
|
+
const max = summary.max_response_time || 1000;
|
|
2530
|
+
const bucketCount = 15;
|
|
2531
|
+
const bucketSize = Math.ceil(max / bucketCount);
|
|
2532
|
+
const labels = [], values = [], percentages = [];
|
|
2533
|
+
const total = summary.total_requests || 1;
|
|
2534
|
+
|
|
2535
|
+
for (let i = 0; i < bucketCount; i++) {
|
|
2536
|
+
const start = i * bucketSize;
|
|
2537
|
+
const end = (i + 1) * bucketSize;
|
|
2538
|
+
labels.push(start + '-' + end + 'ms');
|
|
2539
|
+
|
|
2540
|
+
// Estimate distribution based on percentiles
|
|
2541
|
+
const mid = (start + end) / 2;
|
|
2542
|
+
let count = 0;
|
|
2543
|
+
if (mid <= summary.p50_response_time) count = Math.floor(total * 0.5 / (bucketCount / 2));
|
|
2544
|
+
else if (mid <= summary.p75_response_time) count = Math.floor(total * 0.25 / (bucketCount / 4));
|
|
2545
|
+
else if (mid <= summary.p90_response_time) count = Math.floor(total * 0.15 / (bucketCount / 6));
|
|
2546
|
+
else if (mid <= summary.p95_response_time) count = Math.floor(total * 0.05 / (bucketCount / 10));
|
|
2547
|
+
else if (mid <= summary.p99_response_time) count = Math.floor(total * 0.04 / (bucketCount / 10));
|
|
2548
|
+
else count = Math.floor(total * 0.01 / (bucketCount / 15));
|
|
2549
|
+
|
|
2550
|
+
values.push(Math.max(0, count));
|
|
2551
|
+
percentages.push((count / total * 100).toFixed(1));
|
|
2552
|
+
}
|
|
2553
|
+
return { labels, values, percentages };
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// Compare
|
|
2557
|
+
function renderCompareSelect() {
|
|
2558
|
+
const container = document.getElementById('compareSelectContainer');
|
|
2559
|
+
if (!results.length) {
|
|
2560
|
+
container.innerHTML = '<p style="color: var(--text-secondary);">No results available</p>';
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
container.innerHTML = `
|
|
2564
|
+
<table>
|
|
2565
|
+
<thead><tr><th style="width:40px;"></th><th>Test Name</th><th>Date</th><th>Avg Response</th><th>P95</th><th>RPS</th></tr></thead>
|
|
2566
|
+
<tbody>
|
|
2567
|
+
${results.map(r => `<tr>
|
|
2568
|
+
<td><input type="checkbox" ${selectedForCompare.has(r.id) ? 'checked' : ''} onchange="toggleCompare('${r.id}')"></td>
|
|
2569
|
+
<td>${r.name}</td>
|
|
2570
|
+
<td>${new Date(r.timestamp).toLocaleString()}</td>
|
|
2571
|
+
<td>${r.summary.avg_response_time.toFixed(0)}ms</td>
|
|
2572
|
+
<td>${r.summary.p95_response_time.toFixed(0)}ms</td>
|
|
2573
|
+
<td>${r.summary.requests_per_second.toFixed(1)}</td>
|
|
2574
|
+
</tr>`).join('')}
|
|
2575
|
+
</tbody>
|
|
2576
|
+
</table>
|
|
2577
|
+
`;
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
function toggleCompare(id) {
|
|
2581
|
+
if (selectedForCompare.has(id)) {
|
|
2582
|
+
selectedForCompare.delete(id);
|
|
2583
|
+
} else {
|
|
2584
|
+
selectedForCompare.add(id);
|
|
2585
|
+
}
|
|
2586
|
+
document.getElementById('compareBtn').disabled = selectedForCompare.size < 2;
|
|
2587
|
+
renderCompareSelect();
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
async function runComparison() {
|
|
2591
|
+
const ids = Array.from(selectedForCompare);
|
|
2592
|
+
const res = await fetch('/api/compare?ids=' + ids.join(','));
|
|
2593
|
+
const data = await res.json();
|
|
2594
|
+
renderComparison(data);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
function renderComparison(data) {
|
|
2598
|
+
const container = document.getElementById('comparisonResults');
|
|
2599
|
+
if (!data.comparison) { container.innerHTML = '<div class="empty-state"><h3>Cannot compare</h3></div>'; return; }
|
|
2600
|
+
|
|
2601
|
+
const { baseline, comparisons, stepComparisons, timelineComparisons } = data.comparison;
|
|
2602
|
+
const allResults = data.results;
|
|
2603
|
+
const colors = ['#00d4ff', '#9c40ff', '#22c55e', '#eab308', '#ef4444', '#f97316', '#8b5cf6', '#06b6d4'];
|
|
2604
|
+
|
|
2605
|
+
// Build step comparison HTML
|
|
2606
|
+
let stepCompareHtml = '';
|
|
2607
|
+
if (stepComparisons && stepComparisons.length > 0) {
|
|
2608
|
+
stepCompareHtml = `
|
|
2609
|
+
<div class="card">
|
|
2610
|
+
<h3>Per-Request Comparison</h3>
|
|
2611
|
+
<div style="overflow-x: auto;">
|
|
2612
|
+
<table class="step-stats-table">
|
|
2613
|
+
<thead>
|
|
2614
|
+
<tr>
|
|
2615
|
+
<th>Request/Step</th>
|
|
2616
|
+
${allResults.map(r => '<th colspan="3" style="text-align:center;border-bottom:1px solid rgba(255,255,255,0.1);">' + r.name.substring(0, 20) + '</th>').join('')}
|
|
2617
|
+
</tr>
|
|
2618
|
+
<tr>
|
|
2619
|
+
<th></th>
|
|
2620
|
+
${allResults.map(() => '<th>Avg RT</th><th>P95</th><th>Success</th>').join('')}
|
|
2621
|
+
</tr>
|
|
2622
|
+
</thead>
|
|
2623
|
+
<tbody>
|
|
2624
|
+
${stepComparisons.map((step) => `
|
|
2625
|
+
<tr>
|
|
2626
|
+
<td><strong>${step.step_name}</strong></td>
|
|
2627
|
+
${step.results.map((r, i) => {
|
|
2628
|
+
if (!r) return '<td colspan="3" style="color:#6b7280;">N/A</td>';
|
|
2629
|
+
const diff = i > 0 && step.diffs ? step.diffs[i-1] : null;
|
|
2630
|
+
return `
|
|
2631
|
+
<td>${r.avg_response_time?.toFixed(0) || 0}ms ${diff ? diffBadge(diff.avg_response_time) : (i === 0 ? '<span style="font-size:9px;color:#6b7280;">(base)</span>' : '')}</td>
|
|
2632
|
+
<td>${r.p95?.toFixed(0) || 0}ms ${diff ? diffBadge(diff.p95) : ''}</td>
|
|
2633
|
+
<td><span class="status-badge ${r.success_rate < 95 ? 'bad' : r.success_rate < 99 ? 'warn' : 'good'}">${(r.success_rate || 0).toFixed(1)}%</span></td>
|
|
2634
|
+
`;
|
|
2635
|
+
}).join('')}
|
|
2636
|
+
</tr>
|
|
2637
|
+
`).join('')}
|
|
2638
|
+
</tbody>
|
|
2639
|
+
</table>
|
|
2640
|
+
</div>
|
|
2641
|
+
</div>
|
|
2642
|
+
`;
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
// Build timeline chart section
|
|
2646
|
+
const hasTimeline = timelineComparisons && timelineComparisons.some(t => t.timeline && t.timeline.length > 0);
|
|
2647
|
+
|
|
2648
|
+
container.innerHTML = `
|
|
2649
|
+
<div class="card">
|
|
2650
|
+
<h3>Comparison: ${allResults.length} Test Runs</h3>
|
|
2651
|
+
<p style="color: var(--text-secondary); margin-bottom: 20px;">Baseline: ${baseline.name} (${new Date(baseline.timestamp).toLocaleString()})</p>
|
|
2652
|
+
|
|
2653
|
+
<div class="grid-2" style="margin-bottom: 20px;">
|
|
2654
|
+
<div class="card" style="margin-bottom: 0;"><h3>Average Response Times</h3><div class="chart-container tall"><canvas id="compare-rt"></canvas></div></div>
|
|
2655
|
+
<div class="card" style="margin-bottom: 0;"><h3>Percentiles Comparison</h3><div class="chart-container tall"><canvas id="compare-percentiles"></canvas></div></div>
|
|
2656
|
+
</div>
|
|
2657
|
+
<div class="grid-2" style="margin-bottom: 20px;">
|
|
2658
|
+
<div class="card" style="margin-bottom: 0;"><h3>Throughput</h3><div class="chart-container"><canvas id="compare-rps"></canvas></div></div>
|
|
2659
|
+
<div class="card" style="margin-bottom: 0;"><h3>Error Rates</h3><div class="chart-container"><canvas id="compare-errors"></canvas></div></div>
|
|
2660
|
+
</div>
|
|
2661
|
+
|
|
2662
|
+
${hasTimeline ? `
|
|
2663
|
+
<div class="card" style="margin-bottom: 20px;">
|
|
2664
|
+
<h3>Response Time Over Time</h3>
|
|
2665
|
+
<p style="color: var(--text-secondary); font-size: 12px; margin-bottom: 12px;">Line graph comparing response times throughout each test run</p>
|
|
2666
|
+
<div class="chart-container" style="height: 350px;"><canvas id="compare-timeline"></canvas></div>
|
|
2667
|
+
</div>
|
|
2668
|
+
` : ''}
|
|
2669
|
+
</div>
|
|
2670
|
+
|
|
2671
|
+
<div class="card">
|
|
2672
|
+
<h3>Overall Metrics Comparison</h3>
|
|
2673
|
+
<div style="overflow-x: auto;">
|
|
2674
|
+
<table>
|
|
2675
|
+
<thead><tr><th>Metric</th>${allResults.map(r => '<th>' + r.name.substring(0, 25) + '</th>').join('')}</tr></thead>
|
|
2676
|
+
<tbody>
|
|
2677
|
+
<tr><td>Avg Response</td>${allResults.map((r, i) => '<td>' + r.summary.avg_response_time.toFixed(0) + 'ms ' + (i > 0 ? diffBadge(comparisons[i-1]?.diff?.avg_response_time) : '<span style="font-size:10px;color:#9ca3af;">(baseline)</span>') + '</td>').join('')}</tr>
|
|
2678
|
+
<tr><td>P50</td>${allResults.map((r, i) => '<td>' + r.summary.p50_response_time.toFixed(0) + 'ms ' + (i > 0 ? diffBadge(comparisons[i-1]?.diff?.p50_response_time) : '') + '</td>').join('')}</tr>
|
|
2679
|
+
<tr><td>P90</td>${allResults.map(r => '<td>' + r.summary.p90_response_time.toFixed(0) + 'ms</td>').join('')}</tr>
|
|
2680
|
+
<tr><td>P95</td>${allResults.map((r, i) => '<td>' + r.summary.p95_response_time.toFixed(0) + 'ms ' + (i > 0 ? diffBadge(comparisons[i-1]?.diff?.p95_response_time) : '') + '</td>').join('')}</tr>
|
|
2681
|
+
<tr><td>P99</td>${allResults.map((r, i) => '<td>' + r.summary.p99_response_time.toFixed(0) + 'ms ' + (i > 0 ? diffBadge(comparisons[i-1]?.diff?.p99_response_time) : '') + '</td>').join('')}</tr>
|
|
2682
|
+
<tr><td>Throughput</td>${allResults.map((r, i) => '<td>' + r.summary.requests_per_second.toFixed(1) + ' req/s ' + (i > 0 ? diffBadge(comparisons[i-1]?.diff?.requests_per_second, true) : '') + '</td>').join('')}</tr>
|
|
2683
|
+
<tr><td>Error Rate</td>${allResults.map(r => '<td><span class="status-badge ' + (r.summary.error_rate > 5 ? 'bad' : r.summary.error_rate > 1 ? 'warn' : 'good') + '">' + r.summary.error_rate.toFixed(2) + '%</span></td>').join('')}</tr>
|
|
2684
|
+
<tr><td>Total Requests</td>${allResults.map(r => '<td>' + (r.summary.total_requests || 0).toLocaleString() + '</td>').join('')}</tr>
|
|
2685
|
+
<tr><td>Duration</td>${allResults.map(r => '<td>' + (r.summary.total_duration || 0).toFixed(1) + 's</td>').join('')}</tr>
|
|
2686
|
+
</tbody>
|
|
2687
|
+
</table>
|
|
2688
|
+
</div>
|
|
2689
|
+
</div>
|
|
2690
|
+
|
|
2691
|
+
${stepCompareHtml}
|
|
2692
|
+
`;
|
|
2693
|
+
|
|
2694
|
+
setTimeout(() => {
|
|
2695
|
+
const labels = allResults.map(r => r.name.substring(0, 15));
|
|
2696
|
+
|
|
2697
|
+
new Chart(document.getElementById('compare-rt'), {
|
|
2698
|
+
type: 'bar',
|
|
2699
|
+
data: { labels, datasets: [{ label: 'Avg Response (ms)', data: allResults.map(r => r.summary.avg_response_time), backgroundColor: colors.slice(0, allResults.length), borderRadius: 4 }] },
|
|
2700
|
+
options: chartOptions('ms')
|
|
2701
|
+
});
|
|
2702
|
+
|
|
2703
|
+
new Chart(document.getElementById('compare-percentiles'), {
|
|
2704
|
+
type: 'bar',
|
|
2705
|
+
data: {
|
|
2706
|
+
labels,
|
|
2707
|
+
datasets: [
|
|
2708
|
+
{ label: 'P50', data: allResults.map(r => r.summary.p50_response_time), backgroundColor: '#22c55e' },
|
|
2709
|
+
{ label: 'P90', data: allResults.map(r => r.summary.p90_response_time), backgroundColor: '#00d4ff' },
|
|
2710
|
+
{ label: 'P95', data: allResults.map(r => r.summary.p95_response_time), backgroundColor: '#eab308' },
|
|
2711
|
+
{ label: 'P99', data: allResults.map(r => r.summary.p99_response_time), backgroundColor: '#ef4444' }
|
|
2712
|
+
]
|
|
2713
|
+
},
|
|
2714
|
+
options: chartOptions('ms')
|
|
2715
|
+
});
|
|
2716
|
+
|
|
2717
|
+
new Chart(document.getElementById('compare-rps'), {
|
|
2718
|
+
type: 'bar',
|
|
2719
|
+
data: { labels, datasets: [{ label: 'Requests/sec', data: allResults.map(r => r.summary.requests_per_second), backgroundColor: colors.slice(0, allResults.length), borderRadius: 4 }] },
|
|
2720
|
+
options: chartOptions('req/s')
|
|
2721
|
+
});
|
|
2722
|
+
|
|
2723
|
+
new Chart(document.getElementById('compare-errors'), {
|
|
2724
|
+
type: 'bar',
|
|
2725
|
+
data: { labels, datasets: [{ label: 'Error Rate (%)', data: allResults.map(r => r.summary.error_rate), backgroundColor: allResults.map(r => r.summary.error_rate > 5 ? '#ef4444' : r.summary.error_rate > 1 ? '#eab308' : '#22c55e'), borderRadius: 4 }] },
|
|
2726
|
+
options: chartOptions('%')
|
|
2727
|
+
});
|
|
2728
|
+
|
|
2729
|
+
// Timeline line chart
|
|
2730
|
+
if (hasTimeline) {
|
|
2731
|
+
const timelineDatasets = timelineComparisons.map((tc, idx) => {
|
|
2732
|
+
const timeline = tc.timeline || [];
|
|
2733
|
+
// Normalize to elapsed seconds from start
|
|
2734
|
+
const startTime = timeline.length > 0 ? timeline[0].timestamp : 0;
|
|
2735
|
+
return {
|
|
2736
|
+
label: tc.name.substring(0, 20),
|
|
2737
|
+
data: timeline.map(t => ({ x: (t.timestamp - startTime) / 1000, y: t.avg_response_time || t.p95 || 0 })),
|
|
2738
|
+
borderColor: colors[idx % colors.length],
|
|
2739
|
+
backgroundColor: colors[idx % colors.length] + '33',
|
|
2740
|
+
fill: false,
|
|
2741
|
+
tension: 0.3,
|
|
2742
|
+
pointRadius: 2,
|
|
2743
|
+
borderWidth: 2
|
|
2744
|
+
};
|
|
2745
|
+
});
|
|
2746
|
+
|
|
2747
|
+
new Chart(document.getElementById('compare-timeline'), {
|
|
2748
|
+
type: 'line',
|
|
2749
|
+
data: { datasets: timelineDatasets },
|
|
2750
|
+
options: {
|
|
2751
|
+
responsive: true,
|
|
2752
|
+
maintainAspectRatio: false,
|
|
2753
|
+
interaction: { intersect: false, mode: 'index' },
|
|
2754
|
+
plugins: {
|
|
2755
|
+
legend: { position: 'bottom', labels: { color: '#9ca3af', usePointStyle: true } },
|
|
2756
|
+
tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(0) + 'ms' } },
|
|
2757
|
+
sharedCrosshair: { enabled: false }
|
|
2758
|
+
},
|
|
2759
|
+
scales: {
|
|
2760
|
+
x: {
|
|
2761
|
+
type: 'linear',
|
|
2762
|
+
title: { display: true, text: 'Elapsed Time (seconds)', color: '#9ca3af' },
|
|
2763
|
+
grid: { color: 'rgba(255,255,255,0.1)' },
|
|
2764
|
+
ticks: { color: '#9ca3af' }
|
|
2765
|
+
},
|
|
2766
|
+
y: {
|
|
2767
|
+
title: { display: true, text: 'Response Time (ms)', color: '#9ca3af' },
|
|
2768
|
+
beginAtZero: true,
|
|
2769
|
+
grid: { color: 'rgba(255,255,255,0.1)' },
|
|
2770
|
+
ticks: { color: '#9ca3af', callback: v => v + ' ms' }
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
}, 100);
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
function diffBadge(diff, higherIsBetter = false) {
|
|
2780
|
+
if (!diff) return '';
|
|
2781
|
+
const improved = higherIsBetter ? parseFloat(diff.change) > 0 : parseFloat(diff.change) < 0;
|
|
2782
|
+
return '<span style="font-size:11px;color:' + (improved ? '#22c55e' : '#ef4444') + ';">' + (parseFloat(diff.change) > 0 ? '+' : '') + diff.change + '</span>';
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
function chartOptions(unit) {
|
|
2786
|
+
return {
|
|
2787
|
+
responsive: true, maintainAspectRatio: false,
|
|
2788
|
+
plugins: { legend: { position: 'bottom', labels: { color: '#9ca3af' } }, sharedCrosshair: { enabled: false } },
|
|
2789
|
+
scales: { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af', callback: v => v + (unit ? ' ' + unit : '') } }, x: { grid: { display: false }, ticks: { color: '#9ca3af' } } }
|
|
2790
|
+
};
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// Scatter chart helper for response times
|
|
2794
|
+
// Optional: startTime and formatFn for time-based x-axis formatting
|
|
2795
|
+
function createScatterChart(id, datasets, startTime, formatFn) {
|
|
2796
|
+
const canvas = document.getElementById(id);
|
|
2797
|
+
if (!canvas) return;
|
|
2798
|
+
|
|
2799
|
+
// If chart exists but canvas was recreated (DOM rebuild), destroy old chart
|
|
2800
|
+
if (charts[id] && charts[id].canvas !== canvas) {
|
|
2801
|
+
charts[id].destroy();
|
|
2802
|
+
delete charts[id];
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
// Default time formatter (hh:mm:ss.mmm)
|
|
2806
|
+
const defaultFormatTime = (ts) => {
|
|
2807
|
+
const d = new Date(ts);
|
|
2808
|
+
const h = d.getHours().toString().padStart(2, '0');
|
|
2809
|
+
const m = d.getMinutes().toString().padStart(2, '0');
|
|
2810
|
+
const s = d.getSeconds().toString().padStart(2, '0');
|
|
2811
|
+
const ms = d.getMilliseconds().toString().padStart(3, '0');
|
|
2812
|
+
return `${h}:${m}:${s}.${ms}`;
|
|
2813
|
+
};
|
|
2814
|
+
|
|
2815
|
+
const formatter = formatFn || defaultFormatTime;
|
|
2816
|
+
const useTimeAxis = startTime !== undefined;
|
|
2817
|
+
|
|
2818
|
+
if (charts[id]) {
|
|
2819
|
+
charts[id].data.datasets = datasets;
|
|
2820
|
+
charts[id].update('none');
|
|
2821
|
+
} else {
|
|
2822
|
+
charts[id] = new Chart(canvas, {
|
|
2823
|
+
type: 'scatter',
|
|
2824
|
+
data: { datasets },
|
|
2825
|
+
options: {
|
|
2826
|
+
responsive: true, maintainAspectRatio: false, animation: false,
|
|
2827
|
+
plugins: {
|
|
2828
|
+
legend: { position: 'top', labels: { color: '#9ca3af', boxWidth: 12 } },
|
|
2829
|
+
tooltip: {
|
|
2830
|
+
callbacks: {
|
|
2831
|
+
label: (ctx) => {
|
|
2832
|
+
const point = ctx.raw;
|
|
2833
|
+
const timeStr = useTimeAxis ? formatter(point.x) : point.x.toFixed(2) + 's';
|
|
2834
|
+
return `${ctx.dataset.label}: ${point.y.toFixed(0)}ms @ ${timeStr}`;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
},
|
|
2839
|
+
scales: {
|
|
2840
|
+
y: {
|
|
2841
|
+
beginAtZero: true,
|
|
2842
|
+
grid: { color: 'rgba(255,255,255,0.1)' },
|
|
2843
|
+
ticks: { color: '#9ca3af' },
|
|
2844
|
+
title: { display: true, text: 'Response Time (ms)', color: '#9ca3af' }
|
|
2845
|
+
},
|
|
2846
|
+
x: {
|
|
2847
|
+
type: useTimeAxis ? 'linear' : 'linear',
|
|
2848
|
+
grid: { color: 'rgba(255,255,255,0.05)' },
|
|
2849
|
+
ticks: {
|
|
2850
|
+
color: '#9ca3af',
|
|
2851
|
+
maxTicksLimit: 12,
|
|
2852
|
+
callback: function(value) {
|
|
2853
|
+
if (useTimeAxis) {
|
|
2854
|
+
return formatter(value);
|
|
2855
|
+
}
|
|
2856
|
+
return value.toFixed(1) + 's';
|
|
2857
|
+
}
|
|
2858
|
+
},
|
|
2859
|
+
title: { display: true, text: 'Time', color: '#9ca3af' }
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
// Chart helper
|
|
2868
|
+
function createOrUpdateChart(id, type, labels, datasets, timestamps) {
|
|
2869
|
+
const canvas = document.getElementById(id);
|
|
2870
|
+
if (!canvas) return;
|
|
2871
|
+
|
|
2872
|
+
// Store timestamps for crosshair sync if provided
|
|
2873
|
+
if (timestamps) {
|
|
2874
|
+
chartTimestamps[id] = timestamps;
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
// If chart exists but canvas was recreated (DOM rebuild), destroy old chart
|
|
2878
|
+
if (charts[id] && charts[id].canvas !== canvas) {
|
|
2879
|
+
charts[id].destroy();
|
|
2880
|
+
delete charts[id];
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
// Check if this is a multi-line chart (response time with percentiles)
|
|
2884
|
+
const showLegend = datasets.length > 1;
|
|
2885
|
+
|
|
2886
|
+
if (charts[id]) {
|
|
2887
|
+
charts[id].data.labels = labels;
|
|
2888
|
+
charts[id].data.datasets = datasets;
|
|
2889
|
+
charts[id].update('none');
|
|
2890
|
+
} else {
|
|
2891
|
+
charts[id] = new Chart(canvas, {
|
|
2892
|
+
type,
|
|
2893
|
+
data: { labels, datasets },
|
|
2894
|
+
options: {
|
|
2895
|
+
responsive: true, maintainAspectRatio: false, animation: false,
|
|
2896
|
+
interaction: { mode: 'index', intersect: false },
|
|
2897
|
+
plugins: {
|
|
2898
|
+
legend: {
|
|
2899
|
+
display: showLegend,
|
|
2900
|
+
position: 'top',
|
|
2901
|
+
labels: { color: '#9ca3af', boxWidth: 12, padding: 8, font: { size: 11 } }
|
|
2902
|
+
}
|
|
2903
|
+
},
|
|
2904
|
+
scales: {
|
|
2905
|
+
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' } },
|
|
2906
|
+
x: { display: true, grid: { display: false }, ticks: { color: '#9ca3af', maxRotation: 0, autoSkip: true, maxTicksLimit: 8 } }
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
// Tabs and URL routing
|
|
2914
|
+
function setupTabs() {
|
|
2915
|
+
document.querySelectorAll('.tab').forEach(tab => {
|
|
2916
|
+
tab.addEventListener('click', () => {
|
|
2917
|
+
const tabName = tab.dataset.tab;
|
|
2918
|
+
// Update URL hash
|
|
2919
|
+
window.location.hash = tabName;
|
|
2920
|
+
});
|
|
2921
|
+
});
|
|
2922
|
+
|
|
2923
|
+
// Handle browser back/forward
|
|
2924
|
+
window.addEventListener('hashchange', handleHashChange);
|
|
2925
|
+
|
|
2926
|
+
// Handle initial hash on page load
|
|
2927
|
+
handleHashChange();
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
function handleHashChange() {
|
|
2931
|
+
let hash = window.location.hash.slice(1); // Remove #
|
|
2932
|
+
|
|
2933
|
+
// Handle detail view routes like #results/detail/abc123
|
|
2934
|
+
if (hash.startsWith('results/detail/')) {
|
|
2935
|
+
const resultId = hash.replace('results/detail/', '');
|
|
2936
|
+
showPanel('results');
|
|
2937
|
+
// Load the detail view after a short delay to ensure panel is visible
|
|
2938
|
+
setTimeout(() => showDetail(resultId, true), 100);
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
// Default to 'tests' if no valid hash
|
|
2943
|
+
const validPanels = ['tests', 'live', 'infra', 'results', 'compare'];
|
|
2944
|
+
if (!validPanels.includes(hash)) {
|
|
2945
|
+
hash = 'tests';
|
|
2946
|
+
window.history.replaceState(null, '', '#' + hash);
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
showPanel(hash);
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
function showPanel(name) {
|
|
2953
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
|
2954
|
+
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === name));
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
// Helpers
|
|
2958
|
+
function formatDuration(ms) {
|
|
2959
|
+
if (!ms) return '-';
|
|
2960
|
+
if (ms < 1000) return ms + 'ms';
|
|
2961
|
+
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
|
2962
|
+
return (ms / 60000).toFixed(1) + 'm';
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
// Infrastructure Export/Import Functions
|
|
2966
|
+
async function exportInfraMetrics(format) {
|
|
2967
|
+
const data = window.currentDetailData;
|
|
2968
|
+
if (!data || !data.infrastructure_metrics) {
|
|
2969
|
+
alert('No infrastructure metrics available to export');
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
const startTime = new Date(data.timestamp);
|
|
2974
|
+
const endTime = new Date(startTime.getTime() + (data.duration * 1000));
|
|
2975
|
+
|
|
2976
|
+
try {
|
|
2977
|
+
const url = `/api/infra/export?format=${format}&start=${startTime.toISOString()}&end=${endTime.toISOString()}`;
|
|
2978
|
+
const res = await fetch(url);
|
|
2979
|
+
if (!res.ok) throw new Error('Export failed');
|
|
2980
|
+
|
|
2981
|
+
const blob = await res.blob();
|
|
2982
|
+
const filename = `infra-${data.name}-${new Date(data.timestamp).toISOString().slice(0, 10)}.${format}`;
|
|
2983
|
+
|
|
2984
|
+
const a = document.createElement('a');
|
|
2985
|
+
a.href = URL.createObjectURL(blob);
|
|
2986
|
+
a.download = filename;
|
|
2987
|
+
a.click();
|
|
2988
|
+
URL.revokeObjectURL(a.href);
|
|
2989
|
+
} catch (e) {
|
|
2990
|
+
alert('Failed to export: ' + e.message);
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
async function importInfraMetrics(format) {
|
|
2995
|
+
const input = document.createElement('input');
|
|
2996
|
+
input.type = 'file';
|
|
2997
|
+
input.accept = format === 'csv' ? '.csv' : '.json';
|
|
2998
|
+
|
|
2999
|
+
input.onchange = async (e) => {
|
|
3000
|
+
const file = e.target.files[0];
|
|
3001
|
+
if (!file) return;
|
|
3002
|
+
|
|
3003
|
+
try {
|
|
3004
|
+
const content = await file.text();
|
|
3005
|
+
const res = await fetch(`/api/infra/import?format=${format}`, {
|
|
3006
|
+
method: 'POST',
|
|
3007
|
+
headers: { 'Content-Type': format === 'csv' ? 'text/csv' : 'application/json' },
|
|
3008
|
+
body: content
|
|
3009
|
+
});
|
|
3010
|
+
|
|
3011
|
+
if (!res.ok) throw new Error('Import failed');
|
|
3012
|
+
const result = await res.json();
|
|
3013
|
+
alert(`Successfully imported ${result.imported} metrics records`);
|
|
3014
|
+
|
|
3015
|
+
// Refresh infrastructure view if visible
|
|
3016
|
+
if (document.getElementById('infra')?.classList.contains('active')) {
|
|
3017
|
+
loadInfrastructure();
|
|
3018
|
+
}
|
|
3019
|
+
} catch (e) {
|
|
3020
|
+
alert('Failed to import: ' + e.message);
|
|
3021
|
+
}
|
|
3022
|
+
};
|
|
3023
|
+
|
|
3024
|
+
input.click();
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
async function exportAllInfra(format) {
|
|
3028
|
+
try {
|
|
3029
|
+
const res = await fetch(`/api/infra/export?format=${format}`);
|
|
3030
|
+
if (!res.ok) throw new Error('Export failed');
|
|
3031
|
+
|
|
3032
|
+
const blob = await res.blob();
|
|
3033
|
+
const filename = `infra-all-${new Date().toISOString().slice(0, 10)}.${format}`;
|
|
3034
|
+
|
|
3035
|
+
const a = document.createElement('a');
|
|
3036
|
+
a.href = URL.createObjectURL(blob);
|
|
3037
|
+
a.download = filename;
|
|
3038
|
+
a.click();
|
|
3039
|
+
URL.revokeObjectURL(a.href);
|
|
3040
|
+
} catch (e) {
|
|
3041
|
+
alert('Failed to export: ' + e.message);
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
// Result Export/Import Functions
|
|
3046
|
+
async function exportResult(format) {
|
|
3047
|
+
const data = window.currentDetailData;
|
|
3048
|
+
if (!data) {
|
|
3049
|
+
alert('No result data available to export');
|
|
3050
|
+
return;
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
try {
|
|
3054
|
+
const includeNetworkCalls = document.getElementById('includeNetworkCalls')?.checked || false;
|
|
3055
|
+
const params = new URLSearchParams({ format });
|
|
3056
|
+
if (includeNetworkCalls) {
|
|
3057
|
+
params.set('includeNetworkCalls', 'true');
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
const res = await fetch(`/api/results/${encodeURIComponent(data.id)}/export?${params}`);
|
|
3061
|
+
if (!res.ok) throw new Error('Export failed');
|
|
3062
|
+
|
|
3063
|
+
const blob = await res.blob();
|
|
3064
|
+
const timestamp = new Date(data.timestamp).toISOString().slice(0, 10);
|
|
3065
|
+
const filename = `${data.name}-${timestamp}.${format}`;
|
|
3066
|
+
|
|
3067
|
+
const a = document.createElement('a');
|
|
3068
|
+
a.href = URL.createObjectURL(blob);
|
|
3069
|
+
a.download = filename;
|
|
3070
|
+
a.click();
|
|
3071
|
+
URL.revokeObjectURL(a.href);
|
|
3072
|
+
} catch (e) {
|
|
3073
|
+
alert('Failed to export: ' + e.message);
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
function importResult() {
|
|
3078
|
+
document.getElementById('importFileInput').click();
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
async function handleImportFile(event) {
|
|
3082
|
+
const file = event.target.files[0];
|
|
3083
|
+
if (!file) return;
|
|
3084
|
+
|
|
3085
|
+
try {
|
|
3086
|
+
const content = await file.text();
|
|
3087
|
+
const res = await fetch('/api/results/import', {
|
|
3088
|
+
method: 'POST',
|
|
3089
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3090
|
+
body: content
|
|
3091
|
+
});
|
|
3092
|
+
|
|
3093
|
+
if (!res.ok) {
|
|
3094
|
+
const err = await res.json();
|
|
3095
|
+
throw new Error(err.error || 'Import failed');
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
const result = await res.json();
|
|
3099
|
+
alert(`Successfully imported result: ${result.name}`);
|
|
3100
|
+
|
|
3101
|
+
// Refresh results list
|
|
3102
|
+
loadResults();
|
|
3103
|
+
|
|
3104
|
+
// Show the imported result
|
|
3105
|
+
if (result.id) {
|
|
3106
|
+
showDetail(encodeURIComponent(result.id));
|
|
3107
|
+
}
|
|
3108
|
+
} catch (e) {
|
|
3109
|
+
alert('Failed to import: ' + e.message);
|
|
3110
|
+
} finally {
|
|
3111
|
+
// Reset file input
|
|
3112
|
+
event.target.value = '';
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
// Chart modal state
|
|
3117
|
+
let chartModal = null;
|
|
3118
|
+
let modalChart = null;
|
|
3119
|
+
let modalSourceCanvasId = null;
|
|
3120
|
+
let modalUpdateInterval = null;
|
|
3121
|
+
|
|
3122
|
+
// Create modal element if it doesn't exist
|
|
3123
|
+
function getChartModal() {
|
|
3124
|
+
if (!chartModal) {
|
|
3125
|
+
chartModal = document.createElement('div');
|
|
3126
|
+
chartModal.className = 'chart-modal-overlay';
|
|
3127
|
+
chartModal.innerHTML = `
|
|
3128
|
+
<div class="chart-modal">
|
|
3129
|
+
<div class="chart-modal-header">
|
|
3130
|
+
<h3 class="chart-modal-title"></h3>
|
|
3131
|
+
<button class="chart-modal-close" onclick="closeChartModal()">
|
|
3132
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
3133
|
+
<path d="M18 6L6 18M6 6l12 12"/>
|
|
3134
|
+
</svg>
|
|
3135
|
+
</button>
|
|
3136
|
+
</div>
|
|
3137
|
+
<div class="chart-modal-body">
|
|
3138
|
+
<div class="chart-container">
|
|
3139
|
+
<canvas id="modal-chart-canvas"></canvas>
|
|
3140
|
+
</div>
|
|
3141
|
+
</div>
|
|
3142
|
+
</div>
|
|
3143
|
+
`;
|
|
3144
|
+
document.body.appendChild(chartModal);
|
|
3145
|
+
|
|
3146
|
+
// Close on overlay click
|
|
3147
|
+
chartModal.addEventListener('click', (e) => {
|
|
3148
|
+
if (e.target === chartModal) closeChartModal();
|
|
3149
|
+
});
|
|
3150
|
+
|
|
3151
|
+
// Close on Escape key
|
|
3152
|
+
document.addEventListener('keydown', (e) => {
|
|
3153
|
+
if (e.key === 'Escape' && chartModal.classList.contains('active')) {
|
|
3154
|
+
closeChartModal();
|
|
3155
|
+
}
|
|
3156
|
+
});
|
|
3157
|
+
}
|
|
3158
|
+
return chartModal;
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
// Sync modal chart with source chart data
|
|
3162
|
+
function syncModalChart() {
|
|
3163
|
+
if (!modalChart || !modalSourceCanvasId) return;
|
|
3164
|
+
|
|
3165
|
+
const sourceCanvas = document.getElementById(modalSourceCanvasId);
|
|
3166
|
+
if (!sourceCanvas) return;
|
|
3167
|
+
|
|
3168
|
+
const sourceChart = Chart.getChart(sourceCanvas);
|
|
3169
|
+
if (!sourceChart) return;
|
|
3170
|
+
|
|
3171
|
+
// Update modal chart data from source
|
|
3172
|
+
modalChart.data.labels = [...sourceChart.data.labels];
|
|
3173
|
+
modalChart.data.datasets = sourceChart.data.datasets.map(ds => ({
|
|
3174
|
+
...ds,
|
|
3175
|
+
data: [...ds.data]
|
|
3176
|
+
}));
|
|
3177
|
+
modalChart.update('none');
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
// Open chart in modal
|
|
3181
|
+
function toggleChartExpand(btn) {
|
|
3182
|
+
const card = btn.closest('.card.expandable');
|
|
3183
|
+
if (!card) return;
|
|
3184
|
+
|
|
3185
|
+
const canvas = card.querySelector('canvas');
|
|
3186
|
+
if (!canvas) return;
|
|
3187
|
+
|
|
3188
|
+
const originalChart = Chart.getChart(canvas);
|
|
3189
|
+
if (!originalChart) return;
|
|
3190
|
+
|
|
3191
|
+
// Store source canvas ID for live updates
|
|
3192
|
+
modalSourceCanvasId = canvas.id;
|
|
3193
|
+
|
|
3194
|
+
// Get chart title from card
|
|
3195
|
+
const titleEl = card.querySelector('h3, h4');
|
|
3196
|
+
const title = titleEl ? titleEl.textContent : 'Chart';
|
|
3197
|
+
|
|
3198
|
+
// Open modal
|
|
3199
|
+
const modal = getChartModal();
|
|
3200
|
+
modal.querySelector('.chart-modal-title').textContent = title;
|
|
3201
|
+
modal.classList.add('active');
|
|
3202
|
+
document.body.style.overflow = 'hidden';
|
|
3203
|
+
|
|
3204
|
+
// Destroy previous modal chart if exists
|
|
3205
|
+
if (modalChart) {
|
|
3206
|
+
modalChart.destroy();
|
|
3207
|
+
modalChart = null;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
// Clear previous update interval
|
|
3211
|
+
if (modalUpdateInterval) {
|
|
3212
|
+
clearInterval(modalUpdateInterval);
|
|
3213
|
+
modalUpdateInterval = null;
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
// Clone the chart configuration
|
|
3217
|
+
const modalCanvas = document.getElementById('modal-chart-canvas');
|
|
3218
|
+
const config = originalChart.config;
|
|
3219
|
+
|
|
3220
|
+
// Deep clone the config to avoid mutating the original
|
|
3221
|
+
const clonedConfig = {
|
|
3222
|
+
type: config.type,
|
|
3223
|
+
data: JSON.parse(JSON.stringify(config.data)),
|
|
3224
|
+
options: JSON.parse(JSON.stringify(config.options || {}))
|
|
3225
|
+
};
|
|
3226
|
+
|
|
3227
|
+
// Ensure responsive options
|
|
3228
|
+
clonedConfig.options.responsive = true;
|
|
3229
|
+
clonedConfig.options.maintainAspectRatio = false;
|
|
3230
|
+
|
|
3231
|
+
// Create chart in modal
|
|
3232
|
+
requestAnimationFrame(() => {
|
|
3233
|
+
modalChart = new Chart(modalCanvas, clonedConfig);
|
|
3234
|
+
|
|
3235
|
+
// Start live update interval (sync every 1 second)
|
|
3236
|
+
modalUpdateInterval = setInterval(syncModalChart, 1000);
|
|
3237
|
+
});
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
// Close chart modal
|
|
3241
|
+
function closeChartModal() {
|
|
3242
|
+
if (chartModal) {
|
|
3243
|
+
chartModal.classList.remove('active');
|
|
3244
|
+
document.body.style.overflow = '';
|
|
3245
|
+
|
|
3246
|
+
// Clear update interval
|
|
3247
|
+
if (modalUpdateInterval) {
|
|
3248
|
+
clearInterval(modalUpdateInterval);
|
|
3249
|
+
modalUpdateInterval = null;
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
modalSourceCanvasId = null;
|
|
3253
|
+
|
|
3254
|
+
// Destroy modal chart after transition
|
|
3255
|
+
setTimeout(() => {
|
|
3256
|
+
if (modalChart) {
|
|
3257
|
+
modalChart.destroy();
|
|
3258
|
+
modalChart = null;
|
|
3259
|
+
}
|
|
3260
|
+
}, 200);
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
// Helper to create expand button HTML
|
|
3265
|
+
function expandBtnHtml() {
|
|
3266
|
+
return '<button class="expand-btn" onclick="toggleChartExpand(this)" title="Toggle full width"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg></button>';
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
// Export functions for onclick handlers
|
|
3270
|
+
window.runTestByIndex = runTestByIndex;
|
|
3271
|
+
window.stopTest = stopTest;
|
|
3272
|
+
window.deleteResult = deleteResult;
|
|
3273
|
+
window.toggleCompare = toggleCompare;
|
|
3274
|
+
window.exportInfraMetrics = exportInfraMetrics;
|
|
3275
|
+
window.importInfraMetrics = importInfraMetrics;
|
|
3276
|
+
window.exportAllInfra = exportAllInfra;
|
|
3277
|
+
window.exportResult = exportResult;
|
|
3278
|
+
window.importResult = importResult;
|
|
3279
|
+
window.handleImportFile = handleImportFile;
|
|
3280
|
+
window.toggleChartExpand = toggleChartExpand;
|