@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.
Files changed (194) hide show
  1. package/dist/cli/cli.js +16 -1
  2. package/dist/cli/commands/distributed.js +2 -2
  3. package/dist/cli/commands/report.js +2 -2
  4. package/dist/cli/commands/run.js +2 -0
  5. package/dist/config/parser.js +2 -2
  6. package/dist/config/types/global-config.d.ts +82 -2
  7. package/dist/config/types/scenario-config.d.ts +2 -2
  8. package/dist/config/types/step-types.d.ts +1 -1
  9. package/dist/core/data/data-manager.d.ts +70 -0
  10. package/dist/core/data/data-manager.js +186 -0
  11. package/dist/core/data/data-provider.d.ts +85 -0
  12. package/dist/core/data/data-provider.js +468 -0
  13. package/dist/core/data/index.d.ts +8 -0
  14. package/dist/core/data/index.js +13 -0
  15. package/dist/core/execution/check-evaluator.d.ts +10 -0
  16. package/dist/core/execution/check-evaluator.js +79 -0
  17. package/dist/core/execution/data-extractor.d.ts +6 -0
  18. package/dist/core/execution/data-extractor.js +70 -0
  19. package/dist/core/execution/index.d.ts +3 -0
  20. package/dist/core/execution/index.js +9 -0
  21. package/dist/core/execution/json-payload-processor.d.ts +7 -0
  22. package/dist/core/execution/json-payload-processor.js +140 -0
  23. package/dist/core/factories/index.d.ts +2 -0
  24. package/dist/core/factories/index.js +7 -0
  25. package/dist/core/factories/output-handler-factory.d.ts +10 -0
  26. package/dist/core/factories/output-handler-factory.js +91 -0
  27. package/dist/core/factories/protocol-handler-factory.d.ts +12 -0
  28. package/dist/core/factories/protocol-handler-factory.js +96 -0
  29. package/dist/core/index.d.ts +3 -2
  30. package/dist/core/index.js +8 -3
  31. package/dist/core/reporting/dashboard-reporter.d.ts +17 -0
  32. package/dist/core/reporting/dashboard-reporter.js +127 -0
  33. package/dist/core/reporting/index.d.ts +1 -0
  34. package/dist/core/reporting/index.js +5 -0
  35. package/dist/core/step-executor.d.ts +6 -20
  36. package/dist/core/step-executor.js +72 -366
  37. package/dist/core/strategies/index.d.ts +2 -0
  38. package/dist/core/strategies/index.js +7 -0
  39. package/dist/core/strategies/scenario-selector.d.ts +13 -0
  40. package/dist/core/strategies/scenario-selector.js +37 -0
  41. package/dist/core/strategies/think-time-strategy.d.ts +15 -0
  42. package/dist/core/strategies/think-time-strategy.js +71 -0
  43. package/dist/core/test-runner.d.ts +4 -11
  44. package/dist/core/test-runner.js +105 -312
  45. package/dist/core/virtual-user.d.ts +7 -37
  46. package/dist/core/virtual-user.js +29 -269
  47. package/dist/dashboard/routes/api.d.ts +64 -0
  48. package/dist/dashboard/routes/api.js +569 -0
  49. package/dist/dashboard/routes/index.d.ts +2 -0
  50. package/dist/dashboard/routes/index.js +7 -0
  51. package/dist/dashboard/routes/static.d.ts +6 -0
  52. package/dist/dashboard/routes/static.js +76 -0
  53. package/dist/dashboard/server.d.ts +8 -84
  54. package/dist/dashboard/server.js +76 -2007
  55. package/dist/dashboard/services/file-scanner.d.ts +7 -0
  56. package/dist/dashboard/services/file-scanner.js +114 -0
  57. package/dist/dashboard/services/index.d.ts +5 -0
  58. package/dist/dashboard/services/index.js +13 -0
  59. package/dist/dashboard/services/influxdb-service.d.ts +41 -0
  60. package/dist/dashboard/services/influxdb-service.js +329 -0
  61. package/dist/dashboard/services/metrics-parser.d.ts +12 -0
  62. package/dist/dashboard/services/metrics-parser.js +209 -0
  63. package/dist/dashboard/services/results-manager.d.ts +17 -0
  64. package/dist/dashboard/services/results-manager.js +311 -0
  65. package/dist/dashboard/services/test-executor.d.ts +41 -0
  66. package/dist/dashboard/services/test-executor.js +250 -0
  67. package/dist/dashboard/services/workers-manager.d.ts +13 -0
  68. package/dist/dashboard/services/workers-manager.js +81 -0
  69. package/dist/dashboard/templates/index.html +122 -0
  70. package/dist/dashboard/templates/scripts/main.js +3280 -0
  71. package/dist/dashboard/templates/styles.css +402 -0
  72. package/dist/dashboard/types.d.ts +168 -0
  73. package/dist/dashboard/types.js +2 -0
  74. package/dist/distributed/result-aggregator.js +1 -3
  75. package/dist/metrics/batch/batch-processor.d.ts +27 -0
  76. package/dist/metrics/batch/batch-processor.js +85 -0
  77. package/dist/metrics/batch/index.d.ts +1 -0
  78. package/dist/metrics/batch/index.js +5 -0
  79. package/dist/metrics/collector.d.ts +46 -45
  80. package/dist/metrics/collector.js +179 -640
  81. package/dist/metrics/core/error-tracker.d.ts +9 -0
  82. package/dist/metrics/core/error-tracker.js +52 -0
  83. package/dist/metrics/core/index.d.ts +3 -0
  84. package/dist/metrics/core/index.js +9 -0
  85. package/dist/metrics/core/result-storage.d.ts +19 -0
  86. package/dist/metrics/core/result-storage.js +56 -0
  87. package/dist/metrics/core/statistics-engine.d.ts +27 -0
  88. package/dist/metrics/core/statistics-engine.js +91 -0
  89. package/dist/metrics/output/file-writer.d.ts +19 -0
  90. package/dist/metrics/output/file-writer.js +129 -0
  91. package/dist/metrics/output/index.d.ts +2 -0
  92. package/dist/metrics/output/index.js +10 -0
  93. package/dist/metrics/output/influxdb-writer.d.ts +89 -0
  94. package/dist/metrics/output/influxdb-writer.js +404 -0
  95. package/dist/metrics/realtime/dispatcher.d.ts +18 -0
  96. package/dist/metrics/realtime/dispatcher.js +45 -0
  97. package/dist/metrics/realtime/endpoints/graphite.d.ts +3 -0
  98. package/dist/metrics/realtime/endpoints/graphite.js +61 -0
  99. package/dist/metrics/realtime/endpoints/influxdb.d.ts +3 -0
  100. package/dist/metrics/realtime/endpoints/influxdb.js +35 -0
  101. package/dist/metrics/realtime/endpoints/webhook.d.ts +3 -0
  102. package/dist/metrics/realtime/endpoints/webhook.js +22 -0
  103. package/dist/metrics/realtime/endpoints/websocket.d.ts +3 -0
  104. package/dist/metrics/realtime/endpoints/websocket.js +25 -0
  105. package/dist/metrics/realtime/index.d.ts +5 -0
  106. package/dist/metrics/realtime/index.js +13 -0
  107. package/dist/metrics/reporting/index.d.ts +3 -0
  108. package/dist/metrics/reporting/index.js +9 -0
  109. package/dist/metrics/reporting/step-statistics.d.ts +6 -0
  110. package/dist/metrics/reporting/step-statistics.js +59 -0
  111. package/dist/metrics/reporting/summary-generator.d.ts +16 -0
  112. package/dist/metrics/reporting/summary-generator.js +46 -0
  113. package/dist/metrics/reporting/timeline-calculator.d.ts +7 -0
  114. package/dist/metrics/reporting/timeline-calculator.js +86 -0
  115. package/dist/metrics/types.d.ts +58 -0
  116. package/dist/outputs/csv.d.ts +2 -0
  117. package/dist/outputs/csv.js +21 -2
  118. package/dist/outputs/json.js +6 -2
  119. package/dist/protocols/rest/handler.d.ts +4 -53
  120. package/dist/protocols/rest/handler.js +73 -454
  121. package/dist/protocols/rest/request/auth-handler.d.ts +4 -0
  122. package/dist/protocols/rest/request/auth-handler.js +30 -0
  123. package/dist/protocols/rest/request/body-processor.d.ts +11 -0
  124. package/dist/protocols/rest/request/body-processor.js +62 -0
  125. package/dist/protocols/rest/request/index.d.ts +2 -0
  126. package/dist/protocols/rest/request/index.js +7 -0
  127. package/dist/protocols/rest/response/checks.d.ts +6 -0
  128. package/dist/protocols/rest/response/checks.js +71 -0
  129. package/dist/protocols/rest/response/index.d.ts +2 -0
  130. package/dist/protocols/rest/response/index.js +7 -0
  131. package/dist/protocols/rest/response/size-calculator.d.ts +12 -0
  132. package/dist/protocols/rest/response/size-calculator.js +64 -0
  133. package/dist/protocols/web/browser/highlight.d.ts +7 -0
  134. package/dist/protocols/web/browser/highlight.js +47 -0
  135. package/dist/protocols/web/browser/index.d.ts +4 -0
  136. package/dist/protocols/web/browser/index.js +11 -0
  137. package/dist/protocols/web/browser/manager.d.ts +20 -0
  138. package/dist/protocols/web/browser/manager.js +189 -0
  139. package/dist/protocols/web/browser/screenshot.d.ts +8 -0
  140. package/dist/protocols/web/browser/screenshot.js +69 -0
  141. package/dist/protocols/web/browser/storage.d.ts +5 -0
  142. package/dist/protocols/web/browser/storage.js +45 -0
  143. package/dist/protocols/web/commands/index.d.ts +5 -0
  144. package/dist/protocols/web/commands/index.js +11 -0
  145. package/dist/protocols/web/commands/interaction.d.ts +13 -0
  146. package/dist/protocols/web/commands/interaction.js +68 -0
  147. package/dist/protocols/web/commands/measurement.d.ts +16 -0
  148. package/dist/protocols/web/commands/measurement.js +33 -0
  149. package/dist/protocols/web/commands/navigation.d.ts +11 -0
  150. package/dist/protocols/web/commands/navigation.js +43 -0
  151. package/dist/protocols/web/commands/types.d.ts +12 -0
  152. package/dist/protocols/web/commands/types.js +2 -0
  153. package/dist/protocols/web/commands/verification.d.ts +12 -0
  154. package/dist/protocols/web/commands/verification.js +118 -0
  155. package/dist/protocols/web/handler.d.ts +19 -30
  156. package/dist/protocols/web/handler.js +164 -651
  157. package/dist/protocols/web/network/capture.d.ts +19 -0
  158. package/dist/protocols/web/network/capture.js +225 -0
  159. package/dist/protocols/web/network/filters.d.ts +5 -0
  160. package/dist/protocols/web/network/filters.js +49 -0
  161. package/dist/protocols/web/network/index.d.ts +4 -0
  162. package/dist/protocols/web/network/index.js +9 -0
  163. package/dist/protocols/web/network/types.d.ts +13 -0
  164. package/dist/protocols/web/network/types.js +2 -0
  165. package/dist/protocols/web/network/utils.d.ts +8 -0
  166. package/dist/protocols/web/network/utils.js +29 -0
  167. package/dist/recorder/continue-recorder.d.ts +11 -0
  168. package/dist/recorder/continue-recorder.js +872 -0
  169. package/dist/reporting/chart-data/index.d.ts +5 -0
  170. package/dist/reporting/chart-data/index.js +13 -0
  171. package/dist/reporting/chart-data/network.d.ts +25 -0
  172. package/dist/reporting/chart-data/network.js +78 -0
  173. package/dist/reporting/chart-data/scenario.d.ts +37 -0
  174. package/dist/reporting/chart-data/scenario.js +76 -0
  175. package/dist/reporting/chart-data/step-statistics.d.ts +24 -0
  176. package/dist/reporting/chart-data/step-statistics.js +94 -0
  177. package/dist/reporting/chart-data/throughput.d.ts +16 -0
  178. package/dist/reporting/chart-data/throughput.js +24 -0
  179. package/dist/reporting/chart-data/timeline.d.ts +17 -0
  180. package/dist/reporting/chart-data/timeline.js +46 -0
  181. package/dist/reporting/handlebars-helpers.d.ts +1 -0
  182. package/dist/reporting/handlebars-helpers.js +63 -0
  183. package/dist/reporting/{enhanced-html-generator.d.ts → html-generator.d.ts} +1 -1
  184. package/dist/reporting/{enhanced-html-generator.js → html-generator.js} +10 -7
  185. package/dist/reporting/templates/{enhanced-report.hbs → report.hbs} +9 -9
  186. package/dist/utils/data-utils.d.ts +17 -0
  187. package/dist/utils/data-utils.js +129 -0
  188. package/dist/utils/template.js +2 -2
  189. package/package.json +5 -2
  190. package/dist/core/csv-data-provider.d.ts +0 -47
  191. package/dist/core/csv-data-provider.js +0 -265
  192. package/dist/reporting/generator.d.ts +0 -42
  193. package/dist/reporting/generator.js +0 -1217
  194. 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;">&#8593;</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">&#10005;</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;">&#8595;</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;">&#8595;</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;">&#128202;</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;">&times;</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;