@testsmith/perfornium 0.6.3 → 0.6.5

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 (191) hide show
  1. package/dist/cli/commands/distributed.js +2 -2
  2. package/dist/cli/commands/report.js +2 -2
  3. package/dist/cli/commands/run.js +2 -0
  4. package/dist/config/parser.js +2 -2
  5. package/dist/config/types/global-config.d.ts +82 -2
  6. package/dist/config/types/scenario-config.d.ts +2 -2
  7. package/dist/core/data/data-manager.d.ts +70 -0
  8. package/dist/core/data/data-manager.js +186 -0
  9. package/dist/core/data/data-provider.d.ts +85 -0
  10. package/dist/core/data/data-provider.js +468 -0
  11. package/dist/core/data/index.d.ts +8 -0
  12. package/dist/core/data/index.js +13 -0
  13. package/dist/core/execution/check-evaluator.d.ts +10 -0
  14. package/dist/core/execution/check-evaluator.js +79 -0
  15. package/dist/core/execution/data-extractor.d.ts +6 -0
  16. package/dist/core/execution/data-extractor.js +70 -0
  17. package/dist/core/execution/index.d.ts +3 -0
  18. package/dist/core/execution/index.js +9 -0
  19. package/dist/core/execution/json-payload-processor.d.ts +7 -0
  20. package/dist/core/execution/json-payload-processor.js +140 -0
  21. package/dist/core/factories/index.d.ts +2 -0
  22. package/dist/core/factories/index.js +7 -0
  23. package/dist/core/factories/output-handler-factory.d.ts +10 -0
  24. package/dist/core/factories/output-handler-factory.js +91 -0
  25. package/dist/core/factories/protocol-handler-factory.d.ts +12 -0
  26. package/dist/core/factories/protocol-handler-factory.js +96 -0
  27. package/dist/core/index.d.ts +3 -2
  28. package/dist/core/index.js +8 -3
  29. package/dist/core/reporting/dashboard-reporter.d.ts +17 -0
  30. package/dist/core/reporting/dashboard-reporter.js +127 -0
  31. package/dist/core/reporting/index.d.ts +1 -0
  32. package/dist/core/reporting/index.js +5 -0
  33. package/dist/core/step-executor.d.ts +6 -20
  34. package/dist/core/step-executor.js +72 -366
  35. package/dist/core/strategies/index.d.ts +2 -0
  36. package/dist/core/strategies/index.js +7 -0
  37. package/dist/core/strategies/scenario-selector.d.ts +13 -0
  38. package/dist/core/strategies/scenario-selector.js +37 -0
  39. package/dist/core/strategies/think-time-strategy.d.ts +15 -0
  40. package/dist/core/strategies/think-time-strategy.js +71 -0
  41. package/dist/core/test-runner.d.ts +4 -11
  42. package/dist/core/test-runner.js +105 -312
  43. package/dist/core/virtual-user.d.ts +7 -37
  44. package/dist/core/virtual-user.js +29 -269
  45. package/dist/dashboard/routes/api.d.ts +64 -0
  46. package/dist/dashboard/routes/api.js +569 -0
  47. package/dist/dashboard/routes/index.d.ts +2 -0
  48. package/dist/dashboard/routes/index.js +7 -0
  49. package/dist/dashboard/routes/static.d.ts +6 -0
  50. package/dist/dashboard/routes/static.js +76 -0
  51. package/dist/dashboard/server.d.ts +8 -84
  52. package/dist/dashboard/server.js +76 -2007
  53. package/dist/dashboard/services/file-scanner.d.ts +7 -0
  54. package/dist/dashboard/services/file-scanner.js +114 -0
  55. package/dist/dashboard/services/index.d.ts +5 -0
  56. package/dist/dashboard/services/index.js +13 -0
  57. package/dist/dashboard/services/influxdb-service.d.ts +41 -0
  58. package/dist/dashboard/services/influxdb-service.js +329 -0
  59. package/dist/dashboard/services/metrics-parser.d.ts +12 -0
  60. package/dist/dashboard/services/metrics-parser.js +209 -0
  61. package/dist/dashboard/services/results-manager.d.ts +17 -0
  62. package/dist/dashboard/services/results-manager.js +311 -0
  63. package/dist/dashboard/services/test-executor.d.ts +41 -0
  64. package/dist/dashboard/services/test-executor.js +250 -0
  65. package/dist/dashboard/services/workers-manager.d.ts +13 -0
  66. package/dist/dashboard/services/workers-manager.js +81 -0
  67. package/dist/dashboard/templates/index.html +122 -0
  68. package/dist/dashboard/templates/scripts/main.js +3280 -0
  69. package/dist/dashboard/templates/styles.css +402 -0
  70. package/dist/dashboard/types.d.ts +168 -0
  71. package/dist/dashboard/types.js +2 -0
  72. package/dist/distributed/result-aggregator.js +1 -3
  73. package/dist/metrics/batch/batch-processor.d.ts +27 -0
  74. package/dist/metrics/batch/batch-processor.js +85 -0
  75. package/dist/metrics/batch/index.d.ts +1 -0
  76. package/dist/metrics/batch/index.js +5 -0
  77. package/dist/metrics/collector.d.ts +46 -45
  78. package/dist/metrics/collector.js +179 -640
  79. package/dist/metrics/core/error-tracker.d.ts +9 -0
  80. package/dist/metrics/core/error-tracker.js +52 -0
  81. package/dist/metrics/core/index.d.ts +3 -0
  82. package/dist/metrics/core/index.js +9 -0
  83. package/dist/metrics/core/result-storage.d.ts +19 -0
  84. package/dist/metrics/core/result-storage.js +56 -0
  85. package/dist/metrics/core/statistics-engine.d.ts +27 -0
  86. package/dist/metrics/core/statistics-engine.js +91 -0
  87. package/dist/metrics/output/file-writer.d.ts +19 -0
  88. package/dist/metrics/output/file-writer.js +129 -0
  89. package/dist/metrics/output/index.d.ts +2 -0
  90. package/dist/metrics/output/index.js +10 -0
  91. package/dist/metrics/output/influxdb-writer.d.ts +89 -0
  92. package/dist/metrics/output/influxdb-writer.js +404 -0
  93. package/dist/metrics/realtime/dispatcher.d.ts +18 -0
  94. package/dist/metrics/realtime/dispatcher.js +45 -0
  95. package/dist/metrics/realtime/endpoints/graphite.d.ts +3 -0
  96. package/dist/metrics/realtime/endpoints/graphite.js +61 -0
  97. package/dist/metrics/realtime/endpoints/influxdb.d.ts +3 -0
  98. package/dist/metrics/realtime/endpoints/influxdb.js +35 -0
  99. package/dist/metrics/realtime/endpoints/webhook.d.ts +3 -0
  100. package/dist/metrics/realtime/endpoints/webhook.js +22 -0
  101. package/dist/metrics/realtime/endpoints/websocket.d.ts +3 -0
  102. package/dist/metrics/realtime/endpoints/websocket.js +25 -0
  103. package/dist/metrics/realtime/index.d.ts +5 -0
  104. package/dist/metrics/realtime/index.js +13 -0
  105. package/dist/metrics/reporting/index.d.ts +3 -0
  106. package/dist/metrics/reporting/index.js +9 -0
  107. package/dist/metrics/reporting/step-statistics.d.ts +6 -0
  108. package/dist/metrics/reporting/step-statistics.js +59 -0
  109. package/dist/metrics/reporting/summary-generator.d.ts +16 -0
  110. package/dist/metrics/reporting/summary-generator.js +46 -0
  111. package/dist/metrics/reporting/timeline-calculator.d.ts +7 -0
  112. package/dist/metrics/reporting/timeline-calculator.js +86 -0
  113. package/dist/metrics/types.d.ts +58 -0
  114. package/dist/outputs/csv.d.ts +2 -0
  115. package/dist/outputs/csv.js +21 -2
  116. package/dist/outputs/json.js +6 -2
  117. package/dist/protocols/rest/handler.d.ts +4 -53
  118. package/dist/protocols/rest/handler.js +73 -454
  119. package/dist/protocols/rest/request/auth-handler.d.ts +4 -0
  120. package/dist/protocols/rest/request/auth-handler.js +30 -0
  121. package/dist/protocols/rest/request/body-processor.d.ts +11 -0
  122. package/dist/protocols/rest/request/body-processor.js +62 -0
  123. package/dist/protocols/rest/request/index.d.ts +2 -0
  124. package/dist/protocols/rest/request/index.js +7 -0
  125. package/dist/protocols/rest/response/checks.d.ts +6 -0
  126. package/dist/protocols/rest/response/checks.js +71 -0
  127. package/dist/protocols/rest/response/index.d.ts +2 -0
  128. package/dist/protocols/rest/response/index.js +7 -0
  129. package/dist/protocols/rest/response/size-calculator.d.ts +12 -0
  130. package/dist/protocols/rest/response/size-calculator.js +64 -0
  131. package/dist/protocols/web/browser/highlight.d.ts +7 -0
  132. package/dist/protocols/web/browser/highlight.js +47 -0
  133. package/dist/protocols/web/browser/index.d.ts +4 -0
  134. package/dist/protocols/web/browser/index.js +11 -0
  135. package/dist/protocols/web/browser/manager.d.ts +20 -0
  136. package/dist/protocols/web/browser/manager.js +189 -0
  137. package/dist/protocols/web/browser/screenshot.d.ts +8 -0
  138. package/dist/protocols/web/browser/screenshot.js +69 -0
  139. package/dist/protocols/web/browser/storage.d.ts +5 -0
  140. package/dist/protocols/web/browser/storage.js +45 -0
  141. package/dist/protocols/web/commands/index.d.ts +5 -0
  142. package/dist/protocols/web/commands/index.js +11 -0
  143. package/dist/protocols/web/commands/interaction.d.ts +13 -0
  144. package/dist/protocols/web/commands/interaction.js +68 -0
  145. package/dist/protocols/web/commands/measurement.d.ts +16 -0
  146. package/dist/protocols/web/commands/measurement.js +33 -0
  147. package/dist/protocols/web/commands/navigation.d.ts +11 -0
  148. package/dist/protocols/web/commands/navigation.js +43 -0
  149. package/dist/protocols/web/commands/types.d.ts +12 -0
  150. package/dist/protocols/web/commands/types.js +2 -0
  151. package/dist/protocols/web/commands/verification.d.ts +11 -0
  152. package/dist/protocols/web/commands/verification.js +98 -0
  153. package/dist/protocols/web/handler.d.ts +19 -30
  154. package/dist/protocols/web/handler.js +160 -650
  155. package/dist/protocols/web/network/capture.d.ts +19 -0
  156. package/dist/protocols/web/network/capture.js +225 -0
  157. package/dist/protocols/web/network/filters.d.ts +5 -0
  158. package/dist/protocols/web/network/filters.js +49 -0
  159. package/dist/protocols/web/network/index.d.ts +4 -0
  160. package/dist/protocols/web/network/index.js +9 -0
  161. package/dist/protocols/web/network/types.d.ts +13 -0
  162. package/dist/protocols/web/network/types.js +2 -0
  163. package/dist/protocols/web/network/utils.d.ts +8 -0
  164. package/dist/protocols/web/network/utils.js +29 -0
  165. package/dist/recorder/native-recorder.js +2 -1
  166. package/dist/reporting/chart-data/index.d.ts +5 -0
  167. package/dist/reporting/chart-data/index.js +13 -0
  168. package/dist/reporting/chart-data/network.d.ts +25 -0
  169. package/dist/reporting/chart-data/network.js +78 -0
  170. package/dist/reporting/chart-data/scenario.d.ts +37 -0
  171. package/dist/reporting/chart-data/scenario.js +76 -0
  172. package/dist/reporting/chart-data/step-statistics.d.ts +24 -0
  173. package/dist/reporting/chart-data/step-statistics.js +94 -0
  174. package/dist/reporting/chart-data/throughput.d.ts +16 -0
  175. package/dist/reporting/chart-data/throughput.js +24 -0
  176. package/dist/reporting/chart-data/timeline.d.ts +17 -0
  177. package/dist/reporting/chart-data/timeline.js +46 -0
  178. package/dist/reporting/handlebars-helpers.d.ts +1 -0
  179. package/dist/reporting/handlebars-helpers.js +63 -0
  180. package/dist/reporting/{enhanced-html-generator.d.ts → html-generator.d.ts} +1 -1
  181. package/dist/reporting/{enhanced-html-generator.js → html-generator.js} +10 -7
  182. package/dist/reporting/templates/{enhanced-report.hbs → report.hbs} +9 -9
  183. package/dist/utils/data-utils.d.ts +17 -0
  184. package/dist/utils/data-utils.js +129 -0
  185. package/dist/utils/template.js +2 -2
  186. package/package.json +5 -2
  187. package/dist/core/csv-data-provider.d.ts +0 -47
  188. package/dist/core/csv-data-provider.js +0 -265
  189. package/dist/reporting/generator.d.ts +0 -42
  190. package/dist/reporting/generator.js +0 -1217
  191. package/dist/reporting/templates/html.hbs +0 -2453
@@ -37,24 +37,49 @@ exports.DashboardServer = void 0;
37
37
  exports.getDashboard = getDashboard;
38
38
  exports.setDashboard = setDashboard;
39
39
  const http = __importStar(require("http"));
40
- const fs = __importStar(require("fs/promises"));
41
- const path = __importStar(require("path"));
42
40
  const ws_1 = require("ws");
43
- const child_process_1 = require("child_process");
44
41
  const logger_1 = require("../utils/logger");
42
+ const services_1 = require("./services");
43
+ const influxdb_service_1 = require("./services/influxdb-service");
44
+ const routes_1 = require("./routes");
45
45
  class DashboardServer {
46
46
  constructor(options) {
47
47
  this.server = null;
48
48
  this.wss = null;
49
49
  this.clients = new Set();
50
50
  this.liveTests = new Map();
51
- this.runningProcesses = new Map();
52
51
  this.options = {
53
52
  ...options,
54
53
  testsDir: options.testsDir || process.cwd()
55
54
  };
55
+ // Initialize services
56
+ this.fileScanner = new services_1.FileScanner(this.options.testsDir);
57
+ this.resultsManager = new services_1.ResultsManager(this.options.resultsDir);
58
+ this.workersManager = new services_1.WorkersManager(this.options.testsDir, this.options.resultsDir, this.options.workersFile);
59
+ this.influxService = new influxdb_service_1.InfluxDBService();
60
+ // Initialize routes first so we can get infra snapshot
61
+ this.staticRoutes = new routes_1.StaticRoutes();
62
+ this.apiRoutes = new routes_1.ApiRoutes(this.fileScanner, this.resultsManager, null, // Will be set after testExecutor is created
63
+ this.workersManager, this.liveTests, {
64
+ onInfraUpdate: (data) => this.broadcast({ type: 'infra_update', data })
65
+ }, this.influxService);
66
+ // Initialize test executor with callbacks (including getInfraSnapshot from apiRoutes)
67
+ // Check if InfluxDB is configured (token provided) - if so, don't limit response times
68
+ const influxEnabled = !!(process.env.INFLUXDB_TOKEN);
69
+ this.testExecutor = new services_1.TestExecutor(this.options.testsDir, this.options.resultsDir, this.liveTests, {
70
+ onOutput: (testId, data) => this.broadcast({ type: 'test_output', testId, data }),
71
+ onLiveUpdate: (test) => this.broadcast({ type: 'live_update', data: test }),
72
+ onNetworkUpdate: (testId, data) => this.broadcast({ type: 'network_update', testId, data }),
73
+ onTestComplete: (test) => this.broadcast({ type: 'test_complete', data: test }),
74
+ onTestFinished: (testId, exitCode) => this.broadcast({ type: 'test_finished', testId, exitCode }),
75
+ getInfraSnapshot: () => this.apiRoutes.getInfraSnapshot()
76
+ }, { influxEnabled });
77
+ // Now set the testExecutor in apiRoutes
78
+ this.apiRoutes.testExecutor = this.testExecutor;
56
79
  }
57
80
  async start() {
81
+ // Initialize InfluxDB connection
82
+ await this.apiRoutes.initialize();
58
83
  this.server = http.createServer((req, res) => this.handleRequest(req, res));
59
84
  this.wss = new ws_1.WebSocketServer({ server: this.server });
60
85
  this.wss.on('connection', (ws) => {
@@ -77,11 +102,7 @@ class DashboardServer {
77
102
  });
78
103
  }
79
104
  async stop() {
80
- // Kill any running test processes
81
- for (const [id, proc] of this.runningProcesses) {
82
- proc.process.kill();
83
- this.runningProcesses.delete(id);
84
- }
105
+ this.testExecutor.killAllProcesses();
85
106
  if (this.wss)
86
107
  this.wss.close();
87
108
  if (this.server)
@@ -148,2039 +169,87 @@ class DashboardServer {
148
169
  }
149
170
  try {
150
171
  if (url.pathname === '/api/results') {
151
- await this.handleGetResults(res);
172
+ await this.apiRoutes.handleGetResults(res);
173
+ }
174
+ else if (url.pathname === '/api/results/import' && req.method === 'POST') {
175
+ await this.apiRoutes.handleImportResult(req, res);
176
+ }
177
+ else if (url.pathname.match(/^\/api\/results\/.*\/export$/)) {
178
+ const id = url.pathname.replace('/api/results/', '').replace('/export', '');
179
+ await this.apiRoutes.handleExportResult(res, id, url);
152
180
  }
153
181
  else if (url.pathname.startsWith('/api/results/') && req.method === 'DELETE') {
154
182
  const id = url.pathname.replace('/api/results/', '');
155
- await this.handleDeleteResult(res, id);
183
+ await this.apiRoutes.handleDeleteResult(res, id);
156
184
  }
157
185
  else if (url.pathname.startsWith('/api/results/')) {
158
186
  const id = url.pathname.replace('/api/results/', '');
159
- await this.handleGetResult(res, id);
187
+ await this.apiRoutes.handleGetResult(res, id);
160
188
  }
161
189
  else if (url.pathname === '/api/compare') {
162
190
  const ids = url.searchParams.get('ids')?.split(',') || [];
163
- await this.handleCompare(res, ids);
191
+ await this.apiRoutes.handleCompare(res, ids);
164
192
  }
165
193
  else if (url.pathname === '/api/live') {
166
- this.handleGetLive(res);
194
+ this.apiRoutes.handleGetLive(res);
167
195
  }
168
196
  else if (url.pathname === '/api/tests') {
169
- await this.handleGetTests(res);
197
+ await this.apiRoutes.handleGetTests(res);
170
198
  }
171
199
  else if (url.pathname === '/api/tests/run' && req.method === 'POST') {
172
- await this.handleRunTest(req, res);
200
+ await this.apiRoutes.handleRunTest(req, res);
173
201
  }
174
202
  else if (url.pathname.startsWith('/api/tests/stop/') && req.method === 'POST') {
175
203
  const id = url.pathname.replace('/api/tests/stop/', '');
176
- this.handleStopTest(res, id);
204
+ this.apiRoutes.handleStopTest(res, id);
177
205
  }
178
206
  else if (url.pathname === '/api/workers') {
179
- await this.handleGetWorkers(res);
180
- }
181
- else {
182
- await this.serveStatic(req, res, url.pathname);
183
- }
184
- }
185
- catch (error) {
186
- res.writeHead(500, { 'Content-Type': 'application/json' });
187
- res.end(JSON.stringify({ error: error.message }));
188
- }
189
- }
190
- async handleGetResults(res) {
191
- const results = await this.scanResults();
192
- res.writeHead(200, { 'Content-Type': 'application/json' });
193
- res.end(JSON.stringify(results));
194
- }
195
- async handleGetResult(res, id) {
196
- const fullResult = await this.loadFullResult(id);
197
- if (!fullResult) {
198
- res.writeHead(404, { 'Content-Type': 'application/json' });
199
- res.end(JSON.stringify({ error: 'Result not found' }));
200
- return;
201
- }
202
- res.writeHead(200, { 'Content-Type': 'application/json' });
203
- res.end(JSON.stringify(fullResult));
204
- }
205
- async handleDeleteResult(res, id) {
206
- try {
207
- const decodedId = decodeURIComponent(id);
208
- const filePath = path.join(this.options.resultsDir, `${decodedId}.json`);
209
- logger_1.logger.debug(`Deleting result file: ${filePath}`);
210
- await fs.unlink(filePath);
211
- logger_1.logger.info(`Deleted result: ${decodedId}`);
212
- res.writeHead(200, { 'Content-Type': 'application/json' });
213
- res.end(JSON.stringify({ status: 'deleted', id: decodedId }));
214
- }
215
- catch (e) {
216
- logger_1.logger.error(`Failed to delete result ${id}:`, e.message);
217
- res.writeHead(404, { 'Content-Type': 'application/json' });
218
- res.end(JSON.stringify({ error: 'Result not found', details: e.message }));
219
- }
220
- }
221
- async handleCompare(res, ids) {
222
- const results = await Promise.all(ids.map(id => this.loadFullResult(id)));
223
- const validResults = results.filter(r => r !== null);
224
- res.writeHead(200, { 'Content-Type': 'application/json' });
225
- res.end(JSON.stringify({
226
- results: validResults,
227
- comparison: this.generateComparison(validResults)
228
- }));
229
- }
230
- handleGetLive(res) {
231
- res.writeHead(200, { 'Content-Type': 'application/json' });
232
- res.end(JSON.stringify(Array.from(this.liveTests.values())));
233
- }
234
- async handleGetTests(res) {
235
- const tests = await this.scanTestFiles();
236
- res.writeHead(200, { 'Content-Type': 'application/json' });
237
- res.end(JSON.stringify(tests));
238
- }
239
- async handleGetWorkers(res) {
240
- try {
241
- // Try explicit workers file first, then check common locations
242
- let workersFile = this.options.workersFile;
243
- if (!workersFile) {
244
- // Auto-detect workers.json in common locations
245
- const searchPaths = [
246
- path.join(this.options.testsDir || '.', 'config', 'workers.json'),
247
- path.join(this.options.testsDir || '.', 'workers.json'),
248
- path.join(this.options.resultsDir, '..', 'config', 'workers.json'),
249
- path.join(process.cwd(), 'config', 'workers.json'),
250
- path.join(process.cwd(), 'workers.json')
251
- ];
252
- for (const searchPath of searchPaths) {
253
- try {
254
- await fs.access(searchPath);
255
- workersFile = searchPath;
256
- break;
257
- }
258
- catch {
259
- // File doesn't exist, try next
260
- }
261
- }
262
- }
263
- if (!workersFile) {
264
- res.writeHead(200, { 'Content-Type': 'application/json' });
265
- res.end(JSON.stringify({ available: false, workers: [] }));
266
- return;
267
- }
268
- const content = await fs.readFile(workersFile, 'utf-8');
269
- const workers = JSON.parse(content);
270
- res.writeHead(200, { 'Content-Type': 'application/json' });
271
- res.end(JSON.stringify({ available: true, workers, file: workersFile }));
272
- }
273
- catch (e) {
274
- res.writeHead(200, { 'Content-Type': 'application/json' });
275
- res.end(JSON.stringify({ available: false, workers: [], error: e.message }));
276
- }
277
- }
278
- async handleRunTest(req, res) {
279
- const body = await this.readBody(req);
280
- const { testPath, options } = JSON.parse(body);
281
- // Normalize the test path to use native separators for the OS
282
- const normalizedTestPath = path.normalize(testPath);
283
- const testId = `run-${Date.now()}`;
284
- const testName = path.basename(normalizedTestPath).replace(/\.(yml|yaml|json)$/, '');
285
- const args = ['run', normalizedTestPath];
286
- if (options?.verbose)
287
- args.push('-v');
288
- if (options?.report)
289
- args.push('-r');
290
- // Always save results to the dashboard's results directory
291
- args.push('-o', options?.output || this.options.resultsDir);
292
- // Load pattern overrides
293
- if (options?.vus)
294
- args.push('--vus', options.vus.toString());
295
- if (options?.iterations)
296
- args.push('--iterations', options.iterations.toString());
297
- if (options?.duration)
298
- args.push('--duration', options.duration);
299
- if (options?.rampUp)
300
- args.push('--ramp-up', options.rampUp);
301
- // Headless mode override for web tests
302
- if (options?.headless)
303
- args.push('--global', 'browser.headless=true');
304
- // Distributed workers
305
- if (options?.workers)
306
- args.push('--workers', options.workers);
307
- // Initialize live test tracking for dashboard-spawned tests
308
- const liveTest = {
309
- id: testId,
310
- name: testName,
311
- startTime: new Date(),
312
- status: 'running',
313
- metrics: { requests: 0, errors: 0, avgResponseTime: 0, currentVUs: 0 },
314
- stepStats: [],
315
- responseTimes: [],
316
- topErrors: [],
317
- history: []
318
- };
319
- this.liveTests.set(testId, liveTest);
320
- this.broadcast({ type: 'live_update', data: liveTest });
321
- // Use the CLI from the dist folder (../cli/cli.js from dist/dashboard/)
322
- const cliPath = path.join(__dirname, '../cli/cli.js');
323
- const proc = (0, child_process_1.spawn)('node', [cliPath, ...args], {
324
- cwd: this.options.testsDir,
325
- env: { ...process.env, FORCE_COLOR: '0' }
326
- });
327
- const runningProc = { process: proc, testId, output: [] };
328
- this.runningProcesses.set(testId, runningProc);
329
- proc.stdout?.on('data', (data) => {
330
- const chunk = data.toString();
331
- runningProc.output.push(chunk);
332
- this.broadcast({ type: 'test_output', testId, data: chunk });
333
- // Parse each line for live metrics (chunk may contain multiple lines)
334
- const lines = chunk.split('\n');
335
- for (const line of lines) {
336
- if (line.trim()) {
337
- this.parseOutputForMetrics(testId, line);
338
- }
207
+ await this.apiRoutes.handleGetWorkers(res);
339
208
  }
340
- });
341
- proc.stderr?.on('data', (data) => {
342
- const line = data.toString();
343
- runningProc.output.push(line);
344
- this.broadcast({ type: 'test_output', testId, data: line });
345
- });
346
- proc.on('close', (code) => {
347
- this.runningProcesses.delete(testId);
348
- // Mark test as completed in liveTests
349
- const test = this.liveTests.get(testId);
350
- if (test) {
351
- test.status = code === 0 ? 'completed' : 'failed';
352
- this.broadcast({ type: 'test_complete', data: test });
353
- setTimeout(() => this.liveTests.delete(testId), 30000);
209
+ else if (url.pathname === '/api/metrics/runs') {
210
+ await this.apiRoutes.handleGetTestRuns(res);
354
211
  }
355
- this.broadcast({ type: 'test_finished', testId, exitCode: code });
356
- });
357
- res.writeHead(200, { 'Content-Type': 'application/json' });
358
- res.end(JSON.stringify({ testId, status: 'started' }));
359
- }
360
- parseOutputForMetrics(testId, line) {
361
- const test = this.liveTests.get(testId);
362
- if (!test)
363
- return;
364
- // Parse [RT] JSON data for individual response times
365
- const rtMatch = line.match(/\[RT\]\s*(.+)/);
366
- if (rtMatch) {
367
- try {
368
- const rtData = JSON.parse(rtMatch[1]);
369
- const newRTs = rtData.map((r) => ({
370
- timestamp: r.t,
371
- value: r.v,
372
- success: r.s === 1,
373
- stepName: r.n || 'unknown'
374
- }));
375
- test.responseTimes = [...test.responseTimes, ...newRTs].slice(-500); // Keep last 500
376
- this.broadcast({ type: 'live_update', data: test });
212
+ else if (url.pathname === '/api/metrics/query') {
213
+ await this.apiRoutes.handleGetTestMetrics(res, url);
377
214
  }
378
- catch (e) {
379
- // Ignore JSON parse errors
215
+ else if (url.pathname === '/api/metrics/export') {
216
+ await this.apiRoutes.handleExportTestData(res, url);
380
217
  }
381
- return;
382
- }
383
- // Parse [STEPS] JSON data for step statistics
384
- const stepsMatch = line.match(/\[STEPS\]\s*(.+)/);
385
- if (stepsMatch) {
386
- try {
387
- const stepData = JSON.parse(stepsMatch[1]);
388
- test.stepStats = stepData.map((s) => ({
389
- stepName: s.n,
390
- scenario: s.s,
391
- requests: s.r,
392
- errors: s.e,
393
- avgResponseTime: s.a,
394
- p50: s.p50,
395
- p95: s.p95,
396
- p99: s.p99,
397
- successRate: s.sr
398
- }));
399
- this.broadcast({ type: 'live_update', data: test });
218
+ else if (url.pathname === '/api/metrics/status') {
219
+ await this.apiRoutes.handleGetTestMetricsStatus(res);
400
220
  }
401
- catch (e) {
402
- // Ignore JSON parse errors
221
+ else if (url.pathname === '/api/infra/export') {
222
+ await this.apiRoutes.handleExportInfra(req, res, url);
403
223
  }
404
- return;
405
- }
406
- // Parse [ERRORS] JSON data for top errors
407
- const topErrorsMatch = line.match(/\[ERRORS\]\s*(.+)/);
408
- if (topErrorsMatch) {
409
- try {
410
- const errorData = JSON.parse(topErrorsMatch[1]);
411
- test.topErrors = errorData.map((e) => ({
412
- scenario: e.scenario,
413
- action: e.action,
414
- status: e.status,
415
- error: e.error,
416
- url: e.url,
417
- count: e.count
418
- }));
419
- this.broadcast({ type: 'live_update', data: test });
224
+ else if (url.pathname === '/api/infra/import' && req.method === 'POST') {
225
+ await this.apiRoutes.handleImportInfra(req, res, url);
420
226
  }
421
- catch (e) {
422
- // Ignore JSON parse errors
227
+ else if (url.pathname === '/api/infra/status') {
228
+ await this.apiRoutes.handleGetInfraStatus(res);
423
229
  }
424
- return;
425
- }
426
- // Parse the extended [PROGRESS] format with percentiles
427
- // Format: [PROGRESS] VUs: 5 | Requests: 100 | Errors: 2 | Avg RT: 150ms | RPS: 10.5 | P50: 100ms | P90: 200ms | P95: 300ms | P99: 500ms | Success: 98.5%
428
- const progressLineMatch = line.match(/\[PROGRESS\]\s*VUs:\s*(\d+)\s*\|\s*Requests:\s*(\d+)\s*\|\s*Errors:\s*(\d+)\s*\|\s*Avg RT:\s*(\d+(?:\.\d+)?)\s*ms\s*\|\s*RPS:\s*(\d+(?:\.\d+)?)/i);
429
- if (progressLineMatch) {
430
- test.metrics.currentVUs = parseInt(progressLineMatch[1]);
431
- test.metrics.requests = parseInt(progressLineMatch[2]);
432
- test.metrics.errors = parseInt(progressLineMatch[3]);
433
- test.metrics.avgResponseTime = parseFloat(progressLineMatch[4]);
434
- test.metrics.requestsPerSecond = parseFloat(progressLineMatch[5]);
435
- // Parse percentiles if present
436
- const p50Match = line.match(/P50:\s*(\d+(?:\.\d+)?)\s*ms/i);
437
- const p90Match = line.match(/P90:\s*(\d+(?:\.\d+)?)\s*ms/i);
438
- const p95Match = line.match(/P95:\s*(\d+(?:\.\d+)?)\s*ms/i);
439
- const p99Match = line.match(/P99:\s*(\d+(?:\.\d+)?)\s*ms/i);
440
- const successMatch = line.match(/Success:\s*(\d+(?:\.\d+)?)\s*%/i);
441
- if (p50Match)
442
- test.metrics.p50ResponseTime = parseFloat(p50Match[1]);
443
- if (p90Match)
444
- test.metrics.p90ResponseTime = parseFloat(p90Match[1]);
445
- if (p95Match)
446
- test.metrics.p95ResponseTime = parseFloat(p95Match[1]);
447
- if (p99Match)
448
- test.metrics.p99ResponseTime = parseFloat(p99Match[1]);
449
- if (successMatch)
450
- test.metrics.successRate = parseFloat(successMatch[1]);
451
- // Add to history
452
- const now = Date.now();
453
- test.history.push({
454
- timestamp: now,
455
- requests: test.metrics.requests,
456
- errors: test.metrics.errors,
457
- avgResponseTime: test.metrics.avgResponseTime,
458
- p95ResponseTime: test.metrics.p95ResponseTime || 0,
459
- p99ResponseTime: test.metrics.p99ResponseTime || 0,
460
- vus: test.metrics.currentVUs,
461
- rps: test.metrics.requestsPerSecond || 0
462
- });
463
- if (test.history.length > 120)
464
- test.history.shift();
465
- this.broadcast({ type: 'live_update', data: test });
466
- return;
467
- }
468
- // Fallback: Parse various loose output formats for metrics
469
- const vusMatch = line.match(/VUs?[:\s]+(\d+)/i);
470
- const requestsMatch = line.match(/(?:total\s+)?requests?[:\s]+(\d+)/i);
471
- const errorsMatch = line.match(/(?:failed|errors?)[:\s]+(\d+)/i);
472
- const avgRtMatch = line.match(/(?:avg|average)\s*(?:rt|response\s*time)?[:\s]+(\d+(?:\.\d+)?)\s*ms/i);
473
- const rpsMatch = line.match(/(?:rps|req\/s|requests\/s(?:ec)?)[:\s]+(\d+(?:\.\d+)?)/i);
474
- let updated = false;
475
- if (vusMatch) {
476
- test.metrics.currentVUs = parseInt(vusMatch[1]);
477
- updated = true;
478
- }
479
- if (requestsMatch) {
480
- test.metrics.requests = parseInt(requestsMatch[1]);
481
- updated = true;
482
- }
483
- if (errorsMatch) {
484
- test.metrics.errors = parseInt(errorsMatch[1]);
485
- updated = true;
486
- }
487
- if (avgRtMatch) {
488
- test.metrics.avgResponseTime = parseFloat(avgRtMatch[1]);
489
- updated = true;
490
- }
491
- if (rpsMatch) {
492
- test.metrics.requestsPerSecond = parseFloat(rpsMatch[1]);
493
- updated = true;
494
- }
495
- if (updated) {
496
- // Add to history
497
- const now = Date.now();
498
- const lastHistory = test.history[test.history.length - 1];
499
- const rps = lastHistory && (now - lastHistory.timestamp) > 0
500
- ? (test.metrics.requests - lastHistory.requests) / ((now - lastHistory.timestamp) / 1000)
501
- : (test.metrics.requestsPerSecond || 0);
502
- test.history.push({
503
- timestamp: now,
504
- requests: test.metrics.requests,
505
- errors: test.metrics.errors,
506
- avgResponseTime: test.metrics.avgResponseTime,
507
- p95ResponseTime: test.metrics.p95ResponseTime || 0,
508
- p99ResponseTime: test.metrics.p99ResponseTime || 0,
509
- vus: test.metrics.currentVUs,
510
- rps: Math.max(0, rps)
511
- });
512
- if (test.history.length > 120)
513
- test.history.shift();
514
- this.broadcast({ type: 'live_update', data: test });
515
- }
516
- }
517
- handleStopTest(res, testId) {
518
- const proc = this.runningProcesses.get(testId);
519
- if (proc) {
520
- proc.process.kill('SIGTERM');
521
- this.runningProcesses.delete(testId);
522
- res.writeHead(200, { 'Content-Type': 'application/json' });
523
- res.end(JSON.stringify({ status: 'stopped' }));
524
- }
525
- else {
526
- res.writeHead(404, { 'Content-Type': 'application/json' });
527
- res.end(JSON.stringify({ error: 'Test not found' }));
528
- }
529
- }
530
- async readBody(req) {
531
- return new Promise((resolve, reject) => {
532
- let body = '';
533
- req.on('data', chunk => body += chunk);
534
- req.on('end', () => resolve(body));
535
- req.on('error', reject);
536
- });
537
- }
538
- async scanTestFiles() {
539
- const tests = [];
540
- // Only scan dedicated test directories
541
- const searchDirs = [
542
- path.join(this.options.testsDir, 'tests'),
543
- path.join(this.options.testsDir, 'tmp/tests')
544
- ];
545
- for (const dir of searchDirs) {
546
- try {
547
- await this.scanDirForTests(dir, tests, this.options.testsDir);
230
+ else if (url.pathname === '/api/infra/by-time') {
231
+ const start = url.searchParams.get('start') || '';
232
+ const end = url.searchParams.get('end') || '';
233
+ await this.apiRoutes.handleGetInfraByTestRun(res, start, end);
548
234
  }
549
- catch (e) {
550
- // Directory might not exist
235
+ else if (url.pathname === '/api/infra' && req.method === 'POST') {
236
+ await this.apiRoutes.handleInfraMetrics(req, res);
551
237
  }
552
- }
553
- return tests.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
554
- }
555
- async scanDirForTests(dir, tests, baseDir) {
556
- try {
557
- const entries = await fs.readdir(dir, { withFileTypes: true });
558
- for (const entry of entries) {
559
- const fullPath = path.join(dir, entry.name);
560
- if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' &&
561
- entry.name !== 'data' && entry.name !== 'config' && entry.name !== 'environments') {
562
- await this.scanDirForTests(fullPath, tests, baseDir);
563
- }
564
- else if (entry.isFile() && (entry.name.endsWith('.yml') || entry.name.endsWith('.yaml') || entry.name.endsWith('.json'))) {
565
- // Skip obvious non-test files
566
- if (entry.name.includes('package') || entry.name.includes('tsconfig') ||
567
- entry.name.includes('env') || entry.name === 'CNAME' ||
568
- entry.name.includes('credentials') || entry.name.includes('config'))
569
- continue;
570
- try {
571
- const content = await fs.readFile(fullPath, 'utf-8');
572
- // Only include files that look like actual test configs (have scenarios or steps)
573
- if (!content.includes('scenarios:') && !content.includes('steps:') &&
574
- !content.includes('"scenarios"') && !content.includes('"steps"')) {
575
- continue;
576
- }
577
- const stat = await fs.stat(fullPath);
578
- const relativePath = path.relative(baseDir, fullPath);
579
- // Detect test type from content or path (use forward slashes for cross-platform matching)
580
- const normalizedPath = fullPath.replace(/\\/g, '/');
581
- let testType = 'api';
582
- if (content.includes('protocol: web') || content.includes('playwright') || normalizedPath.includes('/web/')) {
583
- testType = 'web';
584
- }
585
- else if (content.includes('protocol: http') || content.includes('protocol: https') || normalizedPath.includes('/api/')) {
586
- testType = 'api';
587
- }
588
- // Normalize paths to forward slashes for cross-platform compatibility
589
- const normalizedRelativePath = relativePath.replace(/\\/g, '/');
590
- tests.push({
591
- name: entry.name.replace(/\.(yml|yaml|json)$/, ''),
592
- path: fullPath, // Keep native path for filesystem operations
593
- relativePath: normalizedRelativePath, // Use forward slashes for display/URLs
594
- type: testType,
595
- lastModified: stat.mtime.toISOString()
596
- });
597
- }
598
- catch (e) {
599
- // Skip unreadable files
600
- }
601
- }
238
+ else if (url.pathname === '/api/infra') {
239
+ await this.apiRoutes.handleGetInfra(res);
602
240
  }
603
- }
604
- catch (e) {
605
- // Skip inaccessible directories
606
- }
607
- }
608
- async scanResults() {
609
- const results = [];
610
- try {
611
- const files = await fs.readdir(this.options.resultsDir);
612
- const excludePatterns = ['metrics', 'live-results', 'summary-incremental'];
613
- const jsonFiles = files.filter(f => f.endsWith('.json') && !excludePatterns.some(p => f.includes(p)));
614
- for (const file of jsonFiles) {
615
- try {
616
- const filePath = path.join(this.options.resultsDir, file);
617
- const content = await fs.readFile(filePath, 'utf-8');
618
- const data = JSON.parse(content);
619
- const stat = await fs.stat(filePath);
620
- results.push({
621
- id: file.replace('.json', ''),
622
- name: data.name || data.test_name || file.replace('.json', ''),
623
- timestamp: data.timestamp || stat.mtime.toISOString(),
624
- duration: data.duration || data.total_duration || 0,
625
- summary: this.extractSummary(data),
626
- scenarios: data.scenarios || [],
627
- step_statistics: data.step_statistics || data.summary?.step_statistics || []
628
- });
629
- }
630
- catch (e) {
631
- // Skip invalid files
632
- }
241
+ else if (url.pathname.startsWith('/api/infra/')) {
242
+ const host = decodeURIComponent(url.pathname.replace('/api/infra/', ''));
243
+ await this.apiRoutes.handleGetInfra(res, host);
633
244
  }
634
- }
635
- catch (e) {
636
- // Results dir might not exist
637
- }
638
- results.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
639
- return results;
640
- }
641
- extractSummary(data) {
642
- const s = data.summary || data;
643
- const percentiles = s.percentiles || {};
644
- const totalReq = s.total_requests || 0;
645
- const failedReq = s.failed_requests || 0;
646
- return {
647
- total_requests: totalReq,
648
- successful_requests: s.successful_requests || (totalReq - failedReq),
649
- failed_requests: failedReq,
650
- avg_response_time: s.avg_response_time || s.mean_response_time || 0,
651
- min_response_time: s.min_response_time || 0,
652
- max_response_time: s.max_response_time || 0,
653
- p50_response_time: percentiles['50'] || s.p50_response_time || s.median_response_time || 0,
654
- p75_response_time: percentiles['75'] || s.p75_response_time || 0,
655
- p90_response_time: percentiles['90'] || s.p90_response_time || 0,
656
- p95_response_time: percentiles['95'] || s.p95_response_time || 0,
657
- p99_response_time: percentiles['99'] || s.p99_response_time || 0,
658
- requests_per_second: s.requests_per_second || s.throughput || 0,
659
- error_rate: s.error_rate ?? (failedReq / Math.max(1, totalReq) * 100),
660
- success_rate: s.success_rate ?? ((totalReq - failedReq) / Math.max(1, totalReq) * 100)
661
- };
662
- }
663
- async loadFullResult(id) {
664
- try {
665
- const decodedId = decodeURIComponent(id);
666
- const filePath = path.join(this.options.resultsDir, `${decodedId}.json`);
667
- logger_1.logger.debug(`Loading result from: ${filePath}`);
668
- const content = await fs.readFile(filePath, 'utf-8');
669
- const data = JSON.parse(content);
670
- const stat = await fs.stat(filePath);
671
- return {
672
- id: decodedId,
673
- name: data.name || data.test_name || decodedId,
674
- timestamp: data.timestamp || stat.mtime.toISOString(),
675
- duration: data.duration || data.total_duration || 0,
676
- summary: this.extractSummary(data),
677
- scenarios: data.scenarios || [],
678
- step_statistics: data.step_statistics || data.summary?.step_statistics || [],
679
- timeline_data: data.timeline_data || data.summary?.timeline_data || [],
680
- vu_ramp_up: data.vu_ramp_up || data.summary?.vu_ramp_up || [],
681
- response_time_distribution: data.response_time_distribution || [],
682
- timeseries: data.timeseries || data.time_series || [],
683
- error_details: data.error_details || data.summary?.error_details || [],
684
- raw: data
685
- };
686
- }
687
- catch (e) {
688
- logger_1.logger.error(`Failed to load result ${id}:`, e.message);
689
- return null;
690
- }
691
- }
692
- generateComparison(results) {
693
- const valid = results.filter(r => r !== null);
694
- if (valid.length < 2)
695
- return null;
696
- const baseline = valid[0];
697
- const comparisons = valid.slice(1).map(result => ({
698
- id: result.id,
699
- name: result.name,
700
- timestamp: result.timestamp,
701
- diff: {
702
- avg_response_time: this.calcDiff(baseline.summary.avg_response_time, result.summary.avg_response_time),
703
- p50_response_time: this.calcDiff(baseline.summary.p50_response_time, result.summary.p50_response_time),
704
- p95_response_time: this.calcDiff(baseline.summary.p95_response_time, result.summary.p95_response_time),
705
- p99_response_time: this.calcDiff(baseline.summary.p99_response_time, result.summary.p99_response_time),
706
- requests_per_second: this.calcDiff(baseline.summary.requests_per_second, result.summary.requests_per_second, true),
707
- error_rate: {
708
- value: result.summary.error_rate,
709
- baseline: baseline.summary.error_rate,
710
- change: (result.summary.error_rate - baseline.summary.error_rate).toFixed(2) + '%',
711
- improved: result.summary.error_rate < baseline.summary.error_rate
712
- }
713
- }
714
- }));
715
- // Generate step-level comparisons
716
- const stepComparisons = this.generateStepComparisons(valid);
717
- // Get timeline data for line graphs
718
- const timelineComparisons = valid.map(result => ({
719
- id: result.id,
720
- name: result.name,
721
- timeline: result.timeline_data || []
722
- }));
723
- return {
724
- baseline: { id: baseline.id, name: baseline.name, timestamp: baseline.timestamp },
725
- comparisons,
726
- stepComparisons,
727
- timelineComparisons
728
- };
729
- }
730
- generateStepComparisons(results) {
731
- // Collect all unique step names across all results
732
- const allSteps = new Set();
733
- results.forEach(result => {
734
- (result.step_statistics || []).forEach((step) => {
735
- allSteps.add(step.step_name);
736
- });
737
- });
738
- // For each step, gather metrics from all results
739
- const stepComparisons = [];
740
- allSteps.forEach(stepName => {
741
- const stepData = {
742
- step_name: stepName,
743
- results: results.map(result => {
744
- const step = (result.step_statistics || []).find((s) => s.step_name === stepName);
745
- if (!step)
746
- return null;
747
- return {
748
- testId: result.id,
749
- testName: result.name,
750
- total_requests: step.total_requests,
751
- failed_requests: step.failed_requests,
752
- success_rate: step.success_rate,
753
- avg_response_time: step.avg_response_time,
754
- min_response_time: step.min_response_time,
755
- max_response_time: step.max_response_time,
756
- p50: step.percentiles?.[50] || step.p50 || 0,
757
- p95: step.percentiles?.[95] || step.p95 || 0,
758
- p99: step.percentiles?.[99] || step.p99 || 0
759
- };
760
- })
761
- };
762
- // Calculate diffs from baseline (first result)
763
- const baseline = stepData.results[0];
764
- if (baseline) {
765
- stepData.diffs = stepData.results.slice(1).map((current) => {
766
- if (!current)
767
- return null;
768
- return {
769
- avg_response_time: this.calcDiff(baseline.avg_response_time, current.avg_response_time),
770
- p95: this.calcDiff(baseline.p95, current.p95),
771
- p99: this.calcDiff(baseline.p99, current.p99),
772
- success_rate: {
773
- value: current.success_rate,
774
- baseline: baseline.success_rate,
775
- change: (current.success_rate - baseline.success_rate).toFixed(2) + '%',
776
- improved: current.success_rate > baseline.success_rate
777
- }
778
- };
779
- });
780
- }
781
- stepComparisons.push(stepData);
782
- });
783
- return stepComparisons;
784
- }
785
- calcDiff(baseline, current, higherIsBetter = false) {
786
- const change = baseline ? ((current - baseline) / baseline * 100) : 0;
787
- return {
788
- value: current,
789
- baseline,
790
- change: change.toFixed(2) + '%',
791
- improved: higherIsBetter ? current > baseline : current < baseline
792
- };
793
- }
794
- async serveStatic(req, res, pathname) {
795
- if (pathname === '/' || pathname === '/index.html') {
796
- res.writeHead(200, { 'Content-Type': 'text/html' });
797
- res.end(this.getDashboardHTML());
798
- return;
799
- }
800
- res.writeHead(404, { 'Content-Type': 'text/plain' });
801
- res.end('Not found');
802
- }
803
- getDashboardHTML() {
804
- return `<!DOCTYPE html>
805
- <html lang="en">
806
- <head>
807
- <meta charset="UTF-8">
808
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
809
- <title>Perfornium Dashboard</title>
810
- <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Cdefs%3E%3ClinearGradient id='grad' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2300d4ff'/%3E%3Cstop offset='100%25' stop-color='%239c40ff'/%3E%3C/linearGradient%3E%3ClinearGradient id='grad2' x1='0%25' y1='100%25' x2='100%25' y2='0%25'%3E%3Cstop offset='0%25' stop-color='%2300d4ff'/%3E%3Cstop offset='100%25' stop-color='%239c40ff'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect x='4' y='4' width='120' height='120' rx='24' fill='%230f0f23'/%3E%3Crect x='32' y='28' width='12' height='72' rx='6' fill='url(%23grad)'/%3E%3Cpath d='M 38 28 L 62 28 C 88 28 88 60 62 60 L 38 60' fill='none' stroke='url(%23grad)' stroke-width='12' stroke-linecap='round' stroke-linejoin='round'/%3E%3Crect x='76' y='68' width='8' height='32' rx='4' fill='url(%23grad2)' opacity='0.9'/%3E%3Crect x='88' y='54' width='8' height='46' rx='4' fill='url(%23grad)' opacity='0.9'/%3E%3C/svg%3E">
811
- <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
812
- <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
813
- <style>
814
- :root {
815
- --bg-primary: #0f0f23;
816
- --bg-secondary: #1a1a2e;
817
- --bg-card: rgba(255, 255, 255, 0.03);
818
- --border: rgba(255, 255, 255, 0.1);
819
- --text-primary: #e2e8f0;
820
- --text-secondary: #9ca3af;
821
- --accent-cyan: #00d4ff;
822
- --accent-purple: #9c40ff;
823
- --success: #22c55e;
824
- --warning: #eab308;
825
- --error: #ef4444;
826
- }
827
- * { margin: 0; padding: 0; box-sizing: border-box; }
828
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-primary); color: var(--text-primary); min-height: 100vh; }
829
-
830
- .header { background: linear-gradient(135deg, rgba(0, 212, 255, 0.1), rgba(156, 64, 255, 0.1)); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; z-index: 100; backdrop-filter: blur(10px); }
831
- .logo { display: flex; align-items: center; gap: 12px; font-size: 22px; font-weight: 700; color: white; }
832
- .logo svg { width: 36px; height: 36px; }
833
-
834
- .container { max-width: 1800px; margin: 0 auto; padding: 24px; }
835
-
836
- .tabs { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
837
- .tab { padding: 10px 20px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; font-size: 14px; font-weight: 500; }
838
- .tab:hover { border-color: var(--accent-cyan); color: white; }
839
- .tab.active { background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); border-color: transparent; color: white; }
840
-
841
- .panel { display: none; }
842
- .panel.active { display: block; }
843
-
844
- .grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
845
- .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
846
- .grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
847
- .grid-6 { display: grid; grid-template-columns: repeat(6, 1fr); gap: 16px; }
848
- @media (max-width: 1400px) { .grid-6 { grid-template-columns: repeat(3, 1fr); } }
849
- @media (max-width: 1200px) { .grid-3 { grid-template-columns: repeat(2, 1fr); } }
850
- @media (max-width: 900px) { .grid-2, .grid-3 { grid-template-columns: 1fr; } .grid-4, .grid-6 { grid-template-columns: repeat(2, 1fr); } }
851
-
852
- .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 20px; }
853
- .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
854
- .card h3 { font-size: 16px; font-weight: 600; color: var(--accent-cyan); }
855
- .card-full { grid-column: 1 / -1; }
856
-
857
- .metric-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 10px; padding: 16px; text-align: center; }
858
- .metric-card .value { font-size: 28px; font-weight: 700; background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
859
- .metric-card .label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
860
- .metric-card .change { font-size: 11px; margin-top: 4px; }
861
- .metric-card .change.up { color: var(--error); }
862
- .metric-card .change.down { color: var(--success); }
863
-
864
- .chart-container { position: relative; height: 280px; }
865
- .chart-container.tall { height: 380px; }
866
- .chart-container.short { height: 200px; }
867
-
868
- .live-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; background: rgba(34, 197, 94, 0.2); border-radius: 20px; font-size: 12px; color: var(--success); font-weight: 500; }
869
- .live-badge::before { content: ''; width: 8px; height: 8px; background: var(--success); border-radius: 50%; animation: pulse 1.5s infinite; }
870
- @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(0.9); } }
871
-
872
- .status-badge { padding: 4px 10px; border-radius: 4px; font-size: 12px; font-weight: 500; }
873
- .status-badge.good { background: rgba(34, 197, 94, 0.2); color: var(--success); }
874
- .status-badge.warn { background: rgba(234, 179, 8, 0.2); color: var(--warning); }
875
- .status-badge.bad { background: rgba(239, 68, 68, 0.2); color: var(--error); }
876
-
877
- table { width: 100%; border-collapse: collapse; }
878
- th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); }
879
- th { color: var(--text-secondary); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
880
- tr:hover { background: rgba(255, 255, 255, 0.02); }
881
- .clickable { color: var(--accent-cyan); cursor: pointer; }
882
- .clickable:hover { text-decoration: underline; }
883
-
884
- .btn { padding: 10px 20px; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 14px; }
885
- .btn-primary { background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); color: white; }
886
- .btn-primary:hover { opacity: 0.9; transform: translateY(-1px); }
887
- .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
888
- .btn-secondary { background: var(--bg-secondary); border: 1px solid var(--border); color: white; }
889
- .btn-danger { background: var(--error); color: white; }
890
- .btn-sm { padding: 6px 12px; font-size: 12px; }
891
-
892
- .empty-state { text-align: center; padding: 60px 20px; color: var(--text-secondary); }
893
- .empty-state h3 { color: var(--text-primary); margin-bottom: 8px; font-size: 18px; }
894
- .empty-state code { background: var(--bg-secondary); padding: 2px 8px; border-radius: 4px; font-size: 13px; }
895
-
896
- .progress-bar { height: 8px; background: var(--bg-secondary); border-radius: 4px; overflow: hidden; }
897
- .progress-bar .fill { height: 100%; background: linear-gradient(90deg, var(--accent-cyan), var(--accent-purple)); transition: width 0.3s; }
898
-
899
- .test-list { max-height: 500px; overflow-y: auto; }
900
- .test-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--border); transition: background 0.2s; }
901
- .test-item:hover { background: rgba(255, 255, 255, 0.02); }
902
- .test-item .test-info { flex: 1; }
903
- .test-item .test-name { font-weight: 500; color: var(--text-primary); }
904
- .test-item .test-path { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
905
- .test-type { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; text-transform: uppercase; }
906
- .test-type.api { background: rgba(0, 212, 255, 0.2); color: var(--accent-cyan); }
907
- .test-type.web { background: rgba(156, 64, 255, 0.2); color: var(--accent-purple); }
908
-
909
- .console-output { background: #0d1117; border: 1px solid var(--border); border-radius: 8px; padding: 16px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 12px; max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }
910
- .console-output .line { padding: 2px 0; }
911
- .console-output .error { color: var(--error); }
912
- .console-output .success { color: var(--success); }
913
-
914
- .section-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
915
- .section-tab { padding: 8px 16px; background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 13px; font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -9px; transition: all 0.2s; }
916
- .section-tab:hover { color: var(--text-primary); }
917
- .section-tab.active { color: var(--accent-cyan); border-bottom-color: var(--accent-cyan); }
918
-
919
- .step-stats-table { font-size: 13px; }
920
- .step-stats-table th { font-size: 10px; padding: 8px 6px; white-space: nowrap; }
921
- .step-stats-table th:nth-child(n+4) { text-align: right; }
922
- .step-stats-table td { padding: 8px 6px; }
923
- .step-stats-table td:nth-child(n+4) { text-align: right; font-family: 'Monaco', 'Menlo', monospace; font-size: 12px; }
924
-
925
- .back-btn { margin-bottom: 20px; }
926
- </style>
927
- </head>
928
- <body>
929
- <div class="header">
930
- <div class="logo">
931
- <svg viewBox="0 0 128 128"><defs><linearGradient id="lg1" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#9c40ff"/></linearGradient><linearGradient id="lg2" x1="0%" y1="100%" x2="100%" y2="0%"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#9c40ff"/></linearGradient></defs><rect x="4" y="4" width="120" height="120" rx="24" fill="#0f0f23"/><rect x="32" y="28" width="12" height="72" rx="6" fill="url(#lg1)"/><path d="M 38 28 L 62 28 C 88 28 88 60 62 60 L 38 60" fill="none" stroke="url(#lg1)" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/><rect x="76" y="68" width="8" height="32" rx="4" fill="url(#lg2)" opacity="0.9"/><rect x="88" y="54" width="8" height="46" rx="4" fill="url(#lg1)" opacity="0.9"/></svg>
932
- Perfornium Dashboard
933
- </div>
934
- <div style="display: flex; align-items: center; gap: 16px;">
935
- <div id="workersStatus"></div>
936
- <div id="connectionStatus"></div>
937
- </div>
938
- </div>
939
-
940
- <div class="container">
941
- <div class="tabs">
942
- <div class="tab active" data-tab="tests">Tests</div>
943
- <div class="tab" data-tab="live">Live</div>
944
- <div class="tab" data-tab="results">Results</div>
945
- <div class="tab" data-tab="compare">Compare</div>
946
- </div>
947
-
948
- <!-- Tests Panel -->
949
- <div id="tests" class="panel active">
950
- <div class="grid-3">
951
- <div class="card">
952
- <div class="card-header">
953
- <h3>Available Tests</h3>
954
- <button class="btn btn-secondary btn-sm" onclick="loadTests()">Refresh</button>
955
- </div>
956
- <div class="test-list" id="testsList"></div>
957
- </div>
958
- <div class="card">
959
- <h3>Load Override</h3>
960
- <p style="color: var(--text-secondary); font-size: 12px; margin-bottom: 16px;">Override load settings when running tests (leave empty to use test defaults)</p>
961
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
962
- <div>
963
- <label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Virtual Users</label>
964
- <input type="number" id="loadVus" placeholder="e.g., 10" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: white; font-size: 14px;">
965
- </div>
966
- <div>
967
- <label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Iterations</label>
968
- <input type="number" id="loadIterations" placeholder="e.g., 5" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: white; font-size: 14px;">
969
- </div>
970
- <div>
971
- <label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Duration</label>
972
- <input type="text" id="loadDuration" placeholder="e.g., 30s, 1m" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: white; font-size: 14px;">
973
- </div>
974
- <div>
975
- <label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Ramp-up</label>
976
- <input type="text" id="loadRampUp" placeholder="e.g., 10s" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: white; font-size: 14px;">
977
- </div>
978
- </div>
979
- <div style="margin-top: 16px;">
980
- <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
981
- <input type="checkbox" id="headlessMode" style="width: 16px; height: 16px; cursor: pointer;">
982
- <span style="font-size: 14px;">Headless Mode</span>
983
- <span style="color: var(--text-secondary); font-size: 12px;">(web tests only)</span>
984
- </label>
985
- </div>
986
- <div id="workersSection" style="margin-top: 12px; display: none;">
987
- <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
988
- <input type="checkbox" id="useWorkers" style="width: 16px; height: 16px; cursor: pointer;">
989
- <span style="font-size: 14px;">Use Distributed Workers</span>
990
- <span id="workersInfo" style="color: var(--text-secondary); font-size: 12px;"></span>
991
- </label>
992
- </div>
993
- <p style="color: var(--text-secondary); font-size: 11px; margin-top: 12px;">Note: Duration overrides iterations. Leave both empty for test default.</p>
994
- </div>
995
- <div class="card">
996
- <h3>Test Console</h3>
997
- <div id="testRunStatus" style="margin-bottom: 16px;"></div>
998
- <div class="console-output" id="testConsole">Ready to run tests...</div>
999
- </div>
1000
- </div>
1001
- </div>
1002
-
1003
- <!-- Live Tests Panel -->
1004
- <div id="live" class="panel">
1005
- <div id="liveTestsContainer"></div>
1006
- </div>
1007
-
1008
- <!-- Results Panel -->
1009
- <div id="results" class="panel">
1010
- <div id="resultsContainer"></div>
1011
- </div>
1012
-
1013
- <!-- Compare Panel -->
1014
- <div id="compare" class="panel">
1015
- <div class="card">
1016
- <h3>Select Tests to Compare</h3>
1017
- <div id="compareSelectContainer"></div>
1018
- <button class="btn btn-primary" id="compareBtn" disabled style="margin-top: 16px;">Compare Selected</button>
1019
- </div>
1020
- <div id="comparisonResults"></div>
1021
- </div>
1022
-
1023
- <!-- Detail Panel -->
1024
- <div id="detail" class="panel">
1025
- <button class="btn btn-secondary back-btn" onclick="showPanel('results')">← Back to Results</button>
1026
- <div id="detailContent"></div>
1027
- </div>
1028
- </div>
1029
-
1030
- <script>
1031
- // State
1032
- let ws, liveTests = {}, results = [], testFiles = [], selectedForCompare = new Set(), charts = {}, runningTestId = null, workersData = null;
1033
-
1034
- // Initialize
1035
- document.addEventListener('DOMContentLoaded', () => {
1036
- initWebSocket();
1037
- loadResults();
1038
- loadTests();
1039
- loadWorkers();
1040
- setupTabs();
1041
- document.getElementById('compareBtn').addEventListener('click', runComparison);
1042
- });
1043
-
1044
- // WebSocket
1045
- function initWebSocket() {
1046
- const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
1047
- ws = new WebSocket(protocol + '//' + location.host);
1048
- ws.onopen = () => { document.getElementById('connectionStatus').innerHTML = '<span class="live-badge">Dashboard</span>'; };
1049
- ws.onclose = () => { document.getElementById('connectionStatus').innerHTML = '<span style="color: var(--text-secondary); font-size: 12px;">Reconnecting...</span>'; setTimeout(initWebSocket, 3000); };
1050
- ws.onmessage = (e) => handleMessage(JSON.parse(e.data));
1051
- }
1052
-
1053
- function handleMessage(msg) {
1054
- if (msg.type === 'live_tests') { msg.data.forEach(t => liveTests[t.id] = t); renderLive(); }
1055
- else if (msg.type === 'live_update') { liveTests[msg.data.id] = msg.data; renderLive(); }
1056
- else if (msg.type === 'test_complete') { liveTests[msg.data.id] = msg.data; renderLive(); loadResults(); }
1057
- else if (msg.type === 'test_output') { appendConsole(msg.data); }
1058
- else if (msg.type === 'test_finished') { onTestFinished(msg.testId, msg.exitCode); }
1059
- }
1060
-
1061
- // Tests
1062
- async function loadTests() {
1063
- try {
1064
- console.log('Loading tests...');
1065
- const res = await fetch('/api/tests');
1066
- testFiles = await res.json();
1067
- console.log('Loaded tests:', testFiles);
1068
- renderTests();
1069
- } catch (e) { console.error('Failed to load tests:', e); }
1070
- }
1071
-
1072
- async function loadWorkers() {
1073
- try {
1074
- const res = await fetch('/api/workers');
1075
- workersData = await res.json();
1076
- const section = document.getElementById('workersSection');
1077
- const info = document.getElementById('workersInfo');
1078
- const headerStatus = document.getElementById('workersStatus');
1079
- if (workersData.available && workersData.workers.length > 0) {
1080
- section.style.display = 'block';
1081
- const totalCapacity = workersData.workers.reduce((sum, w) => sum + (w.capacity || 0), 0);
1082
- const workerCount = workersData.workers.length;
1083
- info.textContent = '(' + workerCount + ' workers, ' + totalCapacity + ' total capacity)';
1084
- // Show workers info in header
1085
- const workerNames = workersData.workers.map(w => w.name || (w.host + ':' + w.port)).join(', ');
1086
- 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>';
1087
- } else {
1088
- headerStatus.innerHTML = '';
1089
- }
1090
- } catch (e) { console.error('Failed to load workers:', e); }
1091
- }
1092
-
1093
- function renderTests() {
1094
- const container = document.getElementById('testsList');
1095
- console.log('Rendering tests:', testFiles.length, 'files');
1096
- if (!testFiles.length) {
1097
- container.innerHTML = '<div class="empty-state"><h3>No tests found</h3><p>Add test files to your tests/ folder</p></div>';
1098
- return;
1099
- }
1100
- container.innerHTML = testFiles.map((t, idx) => \`
1101
- <div class="test-item">
1102
- <div class="test-info">
1103
- <div class="test-name">\${t.name}</div>
1104
- <div class="test-path">\${t.relativePath}</div>
1105
- </div>
1106
- <span class="test-type \${t.type}">\${t.type}</span>
1107
- <button class="btn btn-primary btn-sm" style="margin-left: 12px;" onclick="runTestByIndex(\${idx})" \${runningTestId ? 'disabled' : ''}>Run</button>
1108
- </div>
1109
- \`).join('');
1110
- }
1111
-
1112
- function runTestByIndex(idx) {
1113
- if (idx >= 0 && idx < testFiles.length) {
1114
- runTest(testFiles[idx].path);
1115
- }
1116
- }
1117
-
1118
- async function runTest(testPath) {
1119
- if (runningTestId) return;
1120
-
1121
- // Get load override values
1122
- const vus = document.getElementById('loadVus').value;
1123
- const iterations = document.getElementById('loadIterations').value;
1124
- const duration = document.getElementById('loadDuration').value;
1125
- const rampUp = document.getElementById('loadRampUp').value;
1126
-
1127
- // Build options object
1128
- const options = { verbose: true };
1129
- if (vus) options.vus = parseInt(vus);
1130
- if (iterations) options.iterations = parseInt(iterations);
1131
- if (duration) options.duration = duration;
1132
- if (rampUp) options.rampUp = rampUp;
1133
-
1134
- // Check for headless mode
1135
- const headless = document.getElementById('headlessMode')?.checked;
1136
- if (headless) {
1137
- options.headless = true;
1138
- }
1139
-
1140
- // Check for distributed workers
1141
- const useWorkers = document.getElementById('useWorkers')?.checked;
1142
- if (useWorkers && workersData?.workers?.length > 0) {
1143
- options.workers = workersData.workers.map(w => w.host + ':' + w.port).join(',');
1144
- }
1145
-
1146
- // Show what's being run
1147
- let loadInfo = '';
1148
- const parts = [];
1149
- if (vus) parts.push('VUs: ' + vus);
1150
- if (iterations) parts.push('Iterations: ' + iterations);
1151
- if (duration) parts.push('Duration: ' + duration);
1152
- if (rampUp) parts.push('Ramp-up: ' + rampUp);
1153
- if (headless) parts.push('Headless');
1154
- if (useWorkers) parts.push('Workers: ' + workersData.workers.length);
1155
- if (parts.length) loadInfo = ' (' + parts.join(', ') + ')';
1156
-
1157
- document.getElementById('testConsole').innerHTML = 'Starting test...' + loadInfo + '\\n';
1158
- 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>';
1159
-
1160
- try {
1161
- const res = await fetch('/api/tests/run', {
1162
- method: 'POST',
1163
- headers: { 'Content-Type': 'application/json' },
1164
- body: JSON.stringify({ testPath, options })
1165
- });
1166
- const data = await res.json();
1167
- runningTestId = data.testId;
1168
- renderTests();
1169
- } catch (e) {
1170
- appendConsole('Error: ' + e.message);
1171
- document.getElementById('testRunStatus').innerHTML = '';
1172
- }
1173
- }
1174
-
1175
- async function stopTest() {
1176
- if (!runningTestId) return;
1177
- try {
1178
- await fetch('/api/tests/stop/' + runningTestId, { method: 'POST' });
1179
- } catch (e) { console.error(e); }
1180
- }
1181
-
1182
- function appendConsole(text) {
1183
- const console = document.getElementById('testConsole');
1184
- console.innerHTML += text;
1185
- console.scrollTop = console.scrollHeight;
1186
- }
1187
-
1188
- function onTestFinished(testId, exitCode) {
1189
- if (testId === runningTestId) {
1190
- runningTestId = null;
1191
- const status = exitCode === 0 ? '<span class="status-badge good">Completed</span>' : '<span class="status-badge bad">Failed</span>';
1192
- document.getElementById('testRunStatus').innerHTML = status;
1193
- appendConsole('\\n--- Test finished with exit code ' + exitCode + ' ---');
1194
- renderTests();
1195
- loadResults();
1196
- }
1197
- }
1198
-
1199
- // Live Tests
1200
- function renderLive() {
1201
- const container = document.getElementById('liveTestsContainer');
1202
- const running = Object.values(liveTests).filter(t => t.status === 'running');
1203
-
1204
- if (!running.length) {
1205
- 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>';
1206
- return;
1207
- }
1208
-
1209
- container.innerHTML = running.map(test => \`
1210
- <div class="card" id="live-\${test.id}">
1211
- <div class="card-header">
1212
- <h3>\${test.name}</h3>
1213
- <span class="live-badge">Running</span>
1214
- </div>
1215
-
1216
- <!-- Primary Metrics Row -->
1217
- <div class="grid-6" style="margin-bottom: 20px;">
1218
- <div class="metric-card"><div class="value">\${test.metrics.requests.toLocaleString()}</div><div class="label">Requests</div></div>
1219
- <div class="metric-card"><div class="value">\${test.metrics.currentVUs}</div><div class="label">VUs</div></div>
1220
- <div class="metric-card"><div class="value">\${test.metrics.avgResponseTime.toFixed(0)}ms</div><div class="label">Avg RT</div></div>
1221
- <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>
1222
- <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>
1223
- <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>
1224
- </div>
1225
-
1226
- <!-- Response Time Percentiles Row -->
1227
- <div class="card" style="margin-bottom: 20px; padding: 16px;">
1228
- <h3 style="margin-bottom: 12px;">Response Time Percentiles</h3>
1229
- <div class="grid-4">
1230
- <div class="metric-card"><div class="value">\${(test.metrics.p50ResponseTime || 0).toFixed(0)}ms</div><div class="label">P50 (Median)</div></div>
1231
- <div class="metric-card"><div class="value">\${(test.metrics.p90ResponseTime || 0).toFixed(0)}ms</div><div class="label">P90</div></div>
1232
- <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>
1233
- <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>
1234
- </div>
1235
- </div>
1236
-
1237
- <!-- Charts -->
1238
- <div class="grid-2">
1239
- <div class="card" style="margin-bottom: 0;">
1240
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
1241
- <h3>Individual Response Times</h3>
1242
- <div style="display: flex; gap: 16px; font-size: 12px;">
1243
- <span style="color: #22c55e;">Success</span>
1244
- <span style="color: #ef4444;">Failed</span>
1245
- <span style="color: var(--text-secondary);">(\${test.responseTimes ? test.responseTimes.length : 0} samples)</span>
1246
- </div>
1247
- </div>
1248
- <div class="chart-container"><canvas id="chart-rt-\${test.id}"></canvas></div>
1249
- </div>
1250
- <div class="card" style="margin-bottom: 0;">
1251
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
1252
- <h3>Throughput (req/s)</h3>
1253
- <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>
1254
- </div>
1255
- <div class="chart-container"><canvas id="chart-rps-\${test.id}"></canvas></div>
1256
- </div>
1257
- </div>
1258
- <div class="grid-2" style="margin-top: 20px;">
1259
- <div class="card" style="margin-bottom: 0;">
1260
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
1261
- <h3>Virtual Users</h3>
1262
- <span style="color: #22c55e; font-size: 12px;">Active: <strong>\${test.metrics.currentVUs}</strong></span>
1263
- </div>
1264
- <div class="chart-container"><canvas id="chart-vus-\${test.id}"></canvas></div>
1265
- </div>
1266
- <div class="card" style="margin-bottom: 0;">
1267
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
1268
- <h3>Cumulative Errors</h3>
1269
- <span style="color: #ef4444; font-size: 12px;">Total: <strong>\${test.metrics.errors}</strong></span>
1270
- </div>
1271
- <div class="chart-container"><canvas id="chart-err-\${test.id}"></canvas></div>
1272
- </div>
1273
- </div>
1274
-
1275
- <!-- Step Performance Statistics -->
1276
- \${test.stepStats && test.stepStats.length > 0 ? \`
1277
- <div class="card" style="margin-top: 20px;">
1278
- <h3>Step Performance Statistics</h3>
1279
- <div style="overflow-x: auto; margin-top: 12px;">
1280
- <table class="step-stats-table">
1281
- <thead>
1282
- <tr>
1283
- <th>Step Name</th>
1284
- <th>Scenario</th>
1285
- <th>Requests</th>
1286
- <th>Errors</th>
1287
- <th>Success Rate</th>
1288
- <th>Avg RT</th>
1289
- <th>P50</th>
1290
- <th>P95</th>
1291
- <th>P99</th>
1292
- <th>Status</th>
1293
- </tr>
1294
- </thead>
1295
- <tbody>
1296
- \${test.stepStats.map(s => \`
1297
- <tr>
1298
- <td><strong>\${s.stepName}</strong></td>
1299
- <td>\${s.scenario}</td>
1300
- <td>\${s.requests}</td>
1301
- <td style="\${s.errors > 0 ? 'color: #ef4444;' : ''}">\${s.errors}</td>
1302
- <td>\${s.successRate.toFixed(1)}%</td>
1303
- <td>\${s.avgResponseTime}ms</td>
1304
- <td>\${s.p50 || 0}ms</td>
1305
- <td>\${s.p95 || 0}ms</td>
1306
- <td>\${s.p99 || 0}ms</td>
1307
- <td><span class="status-badge \${s.successRate < 90 || (s.p95 || 0) >= 10000 ? 'bad' : s.successRate < 98 || (s.p95 || 0) >= 5000 ? 'warn' : 'good'}">
1308
- \${s.successRate < 90 || (s.p95 || 0) >= 10000 ? 'Poor' : s.successRate < 98 || (s.p95 || 0) >= 5000 ? 'Warn' : 'Good'}
1309
- </span></td>
1310
- </tr>
1311
- \`).join('')}
1312
- </tbody>
1313
- </table>
1314
- </div>
1315
- </div>
1316
- \` : ''}
1317
-
1318
- <!-- Top Errors -->
1319
- \${test.topErrors && test.topErrors.length > 0 ? \`
1320
- <div class="card" style="margin-top: 20px;">
1321
- <h3 style="color: #ef4444;">Top Errors (${`\${test.topErrors.length}`})</h3>
1322
- <div style="overflow-x: auto; margin-top: 12px;">
1323
- <table class="step-stats-table">
1324
- <thead>
1325
- <tr>
1326
- <th>Count</th>
1327
- <th>Scenario</th>
1328
- <th>Action</th>
1329
- <th>Status</th>
1330
- <th>Error Message</th>
1331
- <th>URL</th>
1332
- </tr>
1333
- </thead>
1334
- <tbody>
1335
- \${test.topErrors.map(e => \`
1336
- <tr>
1337
- <td style="color: #ef4444; font-weight: bold;">\${e.count}</td>
1338
- <td>\${e.scenario || '-'}</td>
1339
- <td>\${e.action || '-'}</td>
1340
- <td>\${e.status || '-'}</td>
1341
- <td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="\${e.error}">\${e.error || '-'}</td>
1342
- <td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="\${e.url || ''}">\${e.url || '-'}</td>
1343
- </tr>
1344
- \`).join('')}
1345
- </tbody>
1346
- </table>
1347
- </div>
1348
- </div>
1349
- \` : ''}
1350
- </div>
1351
- \`).join('');
1352
-
1353
- running.forEach(test => {
1354
- const history = test.history || [];
1355
- const startTime = test.startTime ? new Date(test.startTime).getTime() : (history.length > 0 ? history[0].timestamp : Date.now());
1356
- const labels = history.map(h => {
1357
- const elapsed = Math.round((h.timestamp - startTime) / 1000);
1358
- const mins = Math.floor(elapsed / 60);
1359
- const secs = elapsed % 60;
1360
- return mins > 0 ? mins + 'm' + secs + 's' : secs + 's';
1361
- });
1362
-
1363
- // Create scatter plot for individual response times - colored by step
1364
- const responseTimes = test.responseTimes || [];
1365
- const rtStartTime = test.startTime ? new Date(test.startTime).getTime() : (responseTimes.length > 0 ? responseTimes[0].timestamp : Date.now());
1366
-
1367
- // Color palette for different steps
1368
- const stepColors = [
1369
- { bg: 'rgba(34, 197, 94, 0.6)', border: '#22c55e' }, // green
1370
- { bg: 'rgba(59, 130, 246, 0.6)', border: '#3b82f6' }, // blue
1371
- { bg: 'rgba(168, 85, 247, 0.6)', border: '#a855f7' }, // purple
1372
- { bg: 'rgba(245, 158, 11, 0.6)', border: '#f59e0b' }, // amber
1373
- { bg: 'rgba(236, 72, 153, 0.6)', border: '#ec4899' }, // pink
1374
- { bg: 'rgba(20, 184, 166, 0.6)', border: '#14b8a6' }, // teal
1375
- { bg: 'rgba(99, 102, 241, 0.6)', border: '#6366f1' }, // indigo
1376
- { bg: 'rgba(249, 115, 22, 0.6)', border: '#f97316' }, // orange
1377
- ];
1378
-
1379
- // Group response times by step name
1380
- const stepGroups = {};
1381
- const failedData = [];
1382
- responseTimes.forEach(r => {
1383
- const point = { x: (r.timestamp - rtStartTime) / 1000, y: r.value };
1384
- if (!r.success) {
1385
- failedData.push(point);
1386
- } else {
1387
- const stepName = r.stepName || 'unknown';
1388
- if (!stepGroups[stepName]) stepGroups[stepName] = [];
1389
- stepGroups[stepName].push(point);
1390
- }
1391
- });
1392
-
1393
- // Create datasets for each step
1394
- const stepNames = Object.keys(stepGroups);
1395
- const datasets = stepNames.map((name, i) => {
1396
- const colors = stepColors[i % stepColors.length];
1397
- return {
1398
- label: name,
1399
- data: stepGroups[name],
1400
- backgroundColor: colors.bg,
1401
- borderColor: colors.border,
1402
- pointRadius: 3
1403
- };
1404
- });
1405
-
1406
- // Add failed requests as a separate dataset (always red)
1407
- if (failedData.length > 0) {
1408
- datasets.push({
1409
- label: 'Failed',
1410
- data: failedData,
1411
- backgroundColor: 'rgba(239, 68, 68, 0.8)',
1412
- borderColor: '#ef4444',
1413
- pointRadius: 4
1414
- });
1415
- }
1416
-
1417
- createScatterChart('chart-rt-' + test.id, datasets);
1418
- createOrUpdateChart('chart-rps-' + test.id, 'line', labels, [{
1419
- label: 'Requests/sec', data: history.map(h => h.rps),
1420
- borderColor: '#9c40ff', backgroundColor: 'rgba(156, 64, 255, 0.1)', fill: true, tension: 0.3
1421
- }]);
1422
- createOrUpdateChart('chart-vus-' + test.id, 'line', labels, [{
1423
- label: 'Virtual Users', data: history.map(h => h.vus),
1424
- borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.1)', fill: true, tension: 0.3, stepped: true
1425
- }]);
1426
- createOrUpdateChart('chart-err-' + test.id, 'line', labels, [{
1427
- label: 'Errors', data: history.map(h => h.errors),
1428
- borderColor: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.1)', fill: true, tension: 0.3
1429
- }]);
1430
- });
1431
- }
1432
-
1433
- // Results
1434
- async function loadResults() {
1435
- try {
1436
- const res = await fetch('/api/results');
1437
- results = await res.json();
1438
- renderResults();
1439
- renderCompareSelect();
1440
- } catch (e) { console.error('Failed to load results:', e); }
1441
- }
1442
-
1443
- async function deleteResult(id, event) {
1444
- event.stopPropagation();
1445
- if (!confirm('Are you sure you want to delete this result?')) return;
1446
- try {
1447
- const res = await fetch('/api/results/' + id, { method: 'DELETE' });
1448
- if (res.ok) {
1449
- loadResults();
1450
- } else {
1451
- const data = await res.json();
1452
- console.error('Failed to delete result:', data);
1453
- alert('Failed to delete result: ' + (data.details || data.error || 'Unknown error'));
1454
- }
1455
- } catch (e) {
1456
- console.error('Failed to delete result:', e);
1457
- alert('Failed to delete result: ' + e.message);
1458
- }
1459
- }
1460
-
1461
- function renderResults() {
1462
- const container = document.getElementById('resultsContainer');
1463
- if (!results.length) {
1464
- container.innerHTML = '<div class="empty-state"><h3>No test results yet</h3><p>Run a test to see results here</p></div>';
1465
- return;
1466
- }
1467
- container.innerHTML = \`
1468
- <div class="card">
1469
- <table>
1470
- <thead><tr>
1471
- <th>Test Name</th><th>Date</th><th>Duration</th><th>Requests</th>
1472
- <th>Avg</th><th>P95</th><th>P99</th>
1473
- <th>RPS</th><th>Success Rate</th><th></th>
1474
- </tr></thead>
1475
- <tbody>
1476
- \${results.map(r => \`<tr class="clickable" onclick="showDetail('\${encodeURIComponent(r.id)}')">
1477
- <td><strong>\${r.name}</strong></td>
1478
- <td>\${new Date(r.timestamp).toLocaleString()}</td>
1479
- <td>\${formatDuration(r.duration)}</td>
1480
- <td>\${r.summary.total_requests.toLocaleString()}</td>
1481
- <td>\${r.summary.avg_response_time.toFixed(0)}ms</td>
1482
- <td>\${r.summary.p95_response_time.toFixed(0)}ms</td>
1483
- <td>\${r.summary.p99_response_time.toFixed(0)}ms</td>
1484
- <td>\${r.summary.requests_per_second.toFixed(1)}</td>
1485
- <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>
1486
- <td><button class="btn btn-danger btn-sm" onclick="deleteResult('\${encodeURIComponent(r.id)}', event)" title="Delete result">✕</button></td>
1487
- </tr>\`).join('')}
1488
- </tbody>
1489
- </table>
1490
- </div>
1491
- \`;
1492
- }
1493
-
1494
- // Detail View - Enhanced with Report-style Charts
1495
- async function showDetail(id) {
1496
- const res = await fetch('/api/results/' + id);
1497
- const data = await res.json();
1498
-
1499
- if (!res.ok || !data.summary) {
1500
- console.error('Failed to load result:', data);
1501
- alert('Failed to load result: ' + (data.error || 'Unknown error'));
1502
- return;
1503
- }
1504
-
1505
- const stepStats = data.step_statistics || [];
1506
- const timelineData = data.timeline_data || [];
1507
- const vuRampup = data.vu_ramp_up || [];
1508
-
1509
- document.getElementById('detailContent').innerHTML = \`
1510
- <h2 style="margin-bottom: 24px;">\${data.name}</h2>
1511
-
1512
- <!-- Summary Metrics -->
1513
- <div class="grid-6" style="margin-bottom: 24px;">
1514
- <div class="metric-card"><div class="value">\${data.summary.total_requests.toLocaleString()}</div><div class="label">Total Requests</div></div>
1515
- <div class="metric-card"><div class="value">\${data.summary.successful_requests.toLocaleString()}</div><div class="label">Successful</div></div>
1516
- <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>
1517
- <div class="metric-card"><div class="value">\${data.summary.requests_per_second.toFixed(2)}</div><div class="label">Requests/sec</div></div>
1518
- <div class="metric-card"><div class="value">\${data.summary.avg_response_time.toFixed(0)}ms</div><div class="label">Avg Response</div></div>
1519
- <div class="metric-card"><div class="value">\${formatDuration(data.duration)}</div><div class="label">Duration</div></div>
1520
- </div>
1521
-
1522
- <!-- Response Time Distribution -->
1523
- <div class="card">
1524
- <h3>Response Time Distribution</h3>
1525
- <div class="chart-container tall"><canvas id="detail-distribution"></canvas></div>
1526
- </div>
1527
-
1528
- <!-- Individual Response Times (colored by step) -->
1529
- \${stepStats.length ? \`
1530
- <div class="card">
1531
- <h3>Individual Response Times by Step</h3>
1532
- <div class="chart-container tall"><canvas id="detail-rt-scatter"></canvas></div>
1533
- </div>
1534
- \` : ''}
1535
-
1536
- <!-- Throughput Charts -->
1537
- <div class="grid-2">
1538
- <div class="card"><h3>Response Time Percentiles</h3><div class="chart-container"><canvas id="detail-percentiles"></canvas></div></div>
1539
- <div class="card"><h3>Success vs Failures</h3><div class="chart-container"><canvas id="detail-success"></canvas></div></div>
1540
- </div>
1541
-
1542
- <!-- Step Performance -->
1543
- \${stepStats.length ? \`
1544
- <div class="card">
1545
- <h3>Step Performance Statistics</h3>
1546
- <div class="grid-2" style="margin-bottom: 20px;">
1547
- <div class="chart-container tall"><canvas id="detail-step-percentiles"></canvas></div>
1548
- <div class="chart-container tall"><canvas id="detail-step-distribution"></canvas></div>
1549
- </div>
1550
- <div style="overflow-x: auto;">
1551
- <table class="step-stats-table">
1552
- <thead><tr>
1553
- <th>Step Name</th><th>Scenario</th><th>Requests</th><th>Success Rate</th>
1554
- <th>Min</th><th>Avg</th><th>P50</th><th>P90</th><th>P95</th><th>P99</th><th>Max</th><th>Status</th>
1555
- </tr></thead>
1556
- <tbody>
1557
- \${stepStats.map(s => \`<tr>
1558
- <td><strong>\${s.step_name}</strong></td>
1559
- <td>\${s.scenario || '-'}</td>
1560
- <td>\${s.total_requests || 0}</td>
1561
- <td>\${(s.success_rate || 100).toFixed(1)}%</td>
1562
- <td>\${(s.min_response_time || 0).toFixed(0)}ms</td>
1563
- <td>\${(s.avg_response_time || 0).toFixed(0)}ms</td>
1564
- <td>\${(s.percentiles?.['50'] || 0).toFixed(0)}ms</td>
1565
- <td>\${(s.percentiles?.['90'] || 0).toFixed(0)}ms</td>
1566
- <td>\${(s.percentiles?.['95'] || 0).toFixed(0)}ms</td>
1567
- <td>\${(s.percentiles?.['99'] || 0).toFixed(0)}ms</td>
1568
- <td>\${(s.max_response_time || 0).toFixed(0)}ms</td>
1569
- <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'}">
1570
- \${(s.success_rate || 100) < 90 || (s.percentiles?.['95'] || 0) >= 10000 ? 'Poor' : (s.success_rate || 100) < 98 || (s.percentiles?.['95'] || 0) >= 5000 ? 'Warn' : 'Good'}
1571
- </span></td>
1572
- </tr>\`).join('')}
1573
- </tbody>
1574
- </table>
1575
- </div>
1576
- </div>
1577
- \` : ''}
1578
-
1579
- <!-- Response Time Stats Table -->
1580
- <div class="grid-2">
1581
- <div class="card">
1582
- <h3>Response Time Statistics</h3>
1583
- <table>
1584
- <tr><td>Minimum</td><td>\${data.summary.min_response_time.toFixed(0)}ms</td></tr>
1585
- <tr><td>Average</td><td>\${data.summary.avg_response_time.toFixed(0)}ms</td></tr>
1586
- <tr><td>Median (P50)</td><td>\${data.summary.p50_response_time.toFixed(0)}ms</td></tr>
1587
- <tr><td>P75</td><td>\${data.summary.p75_response_time.toFixed(0)}ms</td></tr>
1588
- <tr><td>P90</td><td>\${data.summary.p90_response_time.toFixed(0)}ms</td></tr>
1589
- <tr><td>P95</td><td>\${data.summary.p95_response_time.toFixed(0)}ms</td></tr>
1590
- <tr><td>P99</td><td>\${data.summary.p99_response_time.toFixed(0)}ms</td></tr>
1591
- <tr><td>Maximum</td><td>\${data.summary.max_response_time.toFixed(0)}ms</td></tr>
1592
- </table>
1593
- </div>
1594
- <div class="card">
1595
- <h3>Test Summary</h3>
1596
- <table>
1597
- <tr><td>Duration</td><td>\${formatDuration(data.duration)}</td></tr>
1598
- <tr><td>Total Requests</td><td>\${data.summary.total_requests.toLocaleString()}</td></tr>
1599
- <tr><td>Throughput</td><td>\${data.summary.requests_per_second.toFixed(2)} req/s</td></tr>
1600
- <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>
1601
- <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>
1602
- <tr><td>Timestamp</td><td>\${new Date(data.timestamp).toLocaleString()}</td></tr>
1603
- </table>
1604
- </div>
1605
- </div>
1606
-
1607
- \${data.scenarios && data.scenarios.length ? \`
1608
- <div class="card">
1609
- <h3>Scenarios</h3>
1610
- <table>
1611
- <thead><tr><th>Scenario</th><th>Requests</th><th>Avg Response</th><th>Errors</th></tr></thead>
1612
- <tbody>
1613
- \${data.scenarios.map(s => \`<tr>
1614
- <td>\${s.name}</td>
1615
- <td>\${s.total_requests || s.requests || 0}</td>
1616
- <td>\${(s.avg_response_time || 0).toFixed(0)}ms</td>
1617
- <td>\${s.failed_requests || s.errors || 0}</td>
1618
- </tr>\`).join('')}
1619
- </tbody>
1620
- </table>
1621
- </div>
1622
- \` : ''}
1623
-
1624
- <!-- Top Errors -->
1625
- \${data.error_details && data.error_details.length > 0 ? \`
1626
- <div class="card">
1627
- <h3 style="color: #ef4444;">Top Errors (\${data.error_details.length})</h3>
1628
- <div style="overflow-x: auto; margin-top: 12px;">
1629
- <table class="step-stats-table">
1630
- <thead>
1631
- <tr>
1632
- <th>Count</th>
1633
- <th>Scenario</th>
1634
- <th>Action</th>
1635
- <th>Status</th>
1636
- <th>Error Message</th>
1637
- <th>URL</th>
1638
- </tr>
1639
- </thead>
1640
- <tbody>
1641
- \${data.error_details.slice(0, 20).map(e => \`
1642
- <tr>
1643
- <td style="color: #ef4444; font-weight: bold;">\${e.count || 1}</td>
1644
- <td>\${e.scenario || '-'}</td>
1645
- <td>\${e.action || '-'}</td>
1646
- <td>\${e.status || '-'}</td>
1647
- <td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="\${e.error || ''}">\${e.error || '-'}</td>
1648
- <td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="\${e.request_url || ''}">\${e.request_url || '-'}</td>
1649
- </tr>
1650
- \`).join('')}
1651
- </tbody>
1652
- </table>
1653
- </div>
1654
- </div>
1655
- \` : ''}
1656
- \`;
1657
-
1658
- showPanel('detail');
1659
-
1660
- setTimeout(() => {
1661
- // Response Time Distribution Histogram
1662
- const buckets = generateHistogramBuckets(data.summary);
1663
- new Chart(document.getElementById('detail-distribution'), {
1664
- type: 'bar',
1665
- data: {
1666
- labels: buckets.labels,
1667
- datasets: [{
1668
- label: 'Request Count', data: buckets.values,
1669
- backgroundColor: 'rgba(0, 212, 255, 0.6)', borderColor: 'rgba(0, 212, 255, 1)', borderWidth: 1, borderRadius: 2
1670
- }, {
1671
- label: 'Percentage', data: buckets.percentages, type: 'line',
1672
- borderColor: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.1)', yAxisID: 'y1', tension: 0.4
1673
- }]
1674
- },
1675
- options: {
1676
- responsive: true, maintainAspectRatio: false,
1677
- plugins: { legend: { position: 'top', labels: { color: '#9ca3af' } } },
1678
- scales: {
1679
- y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' }, title: { display: true, text: 'Count', color: '#9ca3af' } },
1680
- y1: { type: 'linear', display: true, position: 'right', min: 0, max: 100, grid: { drawOnChartArea: false }, ticks: { color: '#9ca3af' }, title: { display: true, text: '%', color: '#9ca3af' } },
1681
- x: { grid: { display: false }, ticks: { color: '#9ca3af', maxRotation: 45 } }
1682
- }
1683
- }
1684
- });
1685
-
1686
- // Percentiles Bar Chart
1687
- new Chart(document.getElementById('detail-percentiles'), {
1688
- type: 'bar',
1689
- data: {
1690
- labels: ['Min', 'P50', 'P75', 'P90', 'P95', 'P99', 'Max'],
1691
- datasets: [{
1692
- data: [data.summary.min_response_time, data.summary.p50_response_time, data.summary.p75_response_time,
1693
- data.summary.p90_response_time, data.summary.p95_response_time, data.summary.p99_response_time,
1694
- data.summary.max_response_time],
1695
- backgroundColor: ['#22c55e', '#00d4ff', '#00d4ff', '#00d4ff', '#eab308', '#ef4444', '#ef4444'],
1696
- borderRadius: 4
1697
- }]
1698
- },
1699
- options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' } }, x: { grid: { display: false }, ticks: { color: '#9ca3af' } } } }
1700
- });
1701
-
1702
- // Success/Failure Donut
1703
- new Chart(document.getElementById('detail-success'), {
1704
- type: 'doughnut',
1705
- data: {
1706
- labels: ['Successful', 'Failed'],
1707
- datasets: [{ data: [data.summary.successful_requests, data.summary.failed_requests], backgroundColor: ['#22c55e', '#ef4444'], borderWidth: 0 }]
1708
- },
1709
- options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: '#9ca3af' } } } }
1710
- });
1711
-
1712
- // Step Percentiles Chart (if step data exists)
1713
- if (stepStats.length) {
1714
- const sortedSteps = [...stepStats].sort((a, b) => (b.percentiles?.['95'] || 0) - (a.percentiles?.['95'] || 0)).slice(0, 10);
1715
- new Chart(document.getElementById('detail-step-percentiles'), {
1716
- type: 'bar',
1717
- data: {
1718
- labels: sortedSteps.map(s => s.step_name.substring(0, 20)),
1719
- datasets: [
1720
- { label: 'P50', data: sortedSteps.map(s => s.percentiles?.['50'] || 0), backgroundColor: 'rgba(0, 212, 255, 0.7)' },
1721
- { label: 'P95', data: sortedSteps.map(s => s.percentiles?.['95'] || 0), backgroundColor: 'rgba(234, 179, 8, 0.7)' },
1722
- { label: 'P99', data: sortedSteps.map(s => s.percentiles?.['99'] || 0), backgroundColor: 'rgba(239, 68, 68, 0.7)' }
1723
- ]
1724
- },
1725
- options: {
1726
- indexAxis: 'y', responsive: true, maintainAspectRatio: false,
1727
- plugins: { legend: { position: 'top', labels: { color: '#9ca3af' } }, title: { display: true, text: 'Response Time Percentiles (Slowest Steps)', color: '#9ca3af' } },
1728
- 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' } } }
1729
- }
1730
- });
1731
-
1732
- // Step Distribution Doughnut
1733
- new Chart(document.getElementById('detail-step-distribution'), {
1734
- type: 'doughnut',
1735
- data: {
1736
- labels: stepStats.map(s => s.step_name.substring(0, 15)),
1737
- datasets: [{ data: stepStats.map(s => s.total_requests || 0), backgroundColor: stepStats.map((_, i) => \`hsl(\${i * 137.5 % 360}, 70%, 50%)\`) }]
1738
- },
1739
- options: {
1740
- responsive: true, maintainAspectRatio: false,
1741
- plugins: { legend: { position: 'right', labels: { color: '#9ca3af', boxWidth: 12 } }, title: { display: true, text: 'Request Distribution by Step', color: '#9ca3af' } }
1742
- }
1743
- });
1744
-
1745
- // Individual Response Times Scatter Chart (colored by step)
1746
- // Use raw results with actual timestamps if available
1747
- const rawResults = data.raw?.results || [];
1748
-
1749
- if (rawResults.length > 0) {
1750
- const stepColors = [
1751
- { bg: 'rgba(34, 197, 94, 0.6)', border: '#22c55e' }, // green
1752
- { bg: 'rgba(59, 130, 246, 0.6)', border: '#3b82f6' }, // blue
1753
- { bg: 'rgba(168, 85, 247, 0.6)', border: '#a855f7' }, // purple
1754
- { bg: 'rgba(245, 158, 11, 0.6)', border: '#f59e0b' }, // amber
1755
- { bg: 'rgba(236, 72, 153, 0.6)', border: '#ec4899' }, // pink
1756
- { bg: 'rgba(20, 184, 166, 0.6)', border: '#14b8a6' }, // teal
1757
- { bg: 'rgba(99, 102, 241, 0.6)', border: '#6366f1' }, // indigo
1758
- { bg: 'rgba(249, 115, 22, 0.6)', border: '#f97316' }, // orange
1759
- ];
1760
-
1761
- // Sample if too many results (limit to 2000 total)
1762
- let results = rawResults;
1763
- if (results.length > 2000) {
1764
- const sampleStep = Math.ceil(results.length / 2000);
1765
- results = results.filter((_, i) => i % sampleStep === 0);
1766
- }
1767
-
1768
- // Find test start time
1769
- const startTime = Math.min(...results.map(r => r.timestamp || 0));
1770
-
1771
- // Group results by step name
1772
- const stepGroups = {};
1773
- const failedData = [];
1774
-
1775
- results.forEach(r => {
1776
- const rt = r.duration || r.response_time || 0;
1777
- const ts = r.timestamp || 0;
1778
- const point = { x: (ts - startTime) / 1000, y: rt };
1779
-
1780
- if (r.success === false) {
1781
- failedData.push(point);
1782
- } else {
1783
- const stepName = r.step_name || r.action || 'unknown';
1784
- if (!stepGroups[stepName]) stepGroups[stepName] = [];
1785
- stepGroups[stepName].push(point);
1786
- }
1787
- });
1788
-
1789
- // Create datasets for each step
1790
- const stepNames = Object.keys(stepGroups);
1791
- const scatterDatasets = stepNames.map((name, i) => {
1792
- const colors = stepColors[i % stepColors.length];
1793
- return {
1794
- label: name.substring(0, 20),
1795
- data: stepGroups[name],
1796
- backgroundColor: colors.bg,
1797
- borderColor: colors.border,
1798
- pointRadius: 2
1799
- };
1800
- });
1801
-
1802
- // Add failed requests as separate dataset (always red)
1803
- if (failedData.length > 0) {
1804
- scatterDatasets.push({
1805
- label: 'Failed',
1806
- data: failedData,
1807
- backgroundColor: 'rgba(239, 68, 68, 0.8)',
1808
- borderColor: '#ef4444',
1809
- pointRadius: 3
1810
- });
1811
- }
1812
-
1813
- if (scatterDatasets.length > 0) {
1814
- createScatterChart('detail-rt-scatter', scatterDatasets);
245
+ else {
246
+ await this.staticRoutes.serve(req, res, url.pathname);
1815
247
  }
1816
- }
1817
248
  }
1818
- }, 100);
1819
- }
1820
-
1821
- function generateHistogramBuckets(summary) {
1822
- const max = summary.max_response_time || 1000;
1823
- const bucketCount = 15;
1824
- const bucketSize = Math.ceil(max / bucketCount);
1825
- const labels = [], values = [], percentages = [];
1826
- const total = summary.total_requests || 1;
1827
-
1828
- for (let i = 0; i < bucketCount; i++) {
1829
- const start = i * bucketSize;
1830
- const end = (i + 1) * bucketSize;
1831
- labels.push(start + '-' + end + 'ms');
1832
-
1833
- // Estimate distribution based on percentiles
1834
- const mid = (start + end) / 2;
1835
- let count = 0;
1836
- if (mid <= summary.p50_response_time) count = Math.floor(total * 0.5 / (bucketCount / 2));
1837
- else if (mid <= summary.p75_response_time) count = Math.floor(total * 0.25 / (bucketCount / 4));
1838
- else if (mid <= summary.p90_response_time) count = Math.floor(total * 0.15 / (bucketCount / 6));
1839
- else if (mid <= summary.p95_response_time) count = Math.floor(total * 0.05 / (bucketCount / 10));
1840
- else if (mid <= summary.p99_response_time) count = Math.floor(total * 0.04 / (bucketCount / 10));
1841
- else count = Math.floor(total * 0.01 / (bucketCount / 15));
1842
-
1843
- values.push(Math.max(0, count));
1844
- percentages.push((count / total * 100).toFixed(1));
1845
- }
1846
- return { labels, values, percentages };
1847
- }
1848
-
1849
- // Compare
1850
- function renderCompareSelect() {
1851
- const container = document.getElementById('compareSelectContainer');
1852
- if (!results.length) {
1853
- container.innerHTML = '<p style="color: var(--text-secondary);">No results available</p>';
1854
- return;
1855
- }
1856
- container.innerHTML = \`
1857
- <table>
1858
- <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>
1859
- <tbody>
1860
- \${results.map(r => \`<tr>
1861
- <td><input type="checkbox" \${selectedForCompare.has(r.id) ? 'checked' : ''} onchange="toggleCompare('\${r.id}')"></td>
1862
- <td>\${r.name}</td>
1863
- <td>\${new Date(r.timestamp).toLocaleString()}</td>
1864
- <td>\${r.summary.avg_response_time.toFixed(0)}ms</td>
1865
- <td>\${r.summary.p95_response_time.toFixed(0)}ms</td>
1866
- <td>\${r.summary.requests_per_second.toFixed(1)}</td>
1867
- </tr>\`).join('')}
1868
- </tbody>
1869
- </table>
1870
- \`;
1871
- }
1872
-
1873
- function toggleCompare(id) {
1874
- selectedForCompare.has(id) ? selectedForCompare.delete(id) : selectedForCompare.add(id);
1875
- document.getElementById('compareBtn').disabled = selectedForCompare.size < 2;
1876
- renderCompareSelect();
1877
- }
1878
-
1879
- async function runComparison() {
1880
- const ids = Array.from(selectedForCompare);
1881
- const res = await fetch('/api/compare?ids=' + ids.join(','));
1882
- const data = await res.json();
1883
- renderComparison(data);
1884
- }
1885
-
1886
- function renderComparison(data) {
1887
- const container = document.getElementById('comparisonResults');
1888
- if (!data.comparison) { container.innerHTML = '<div class="empty-state"><h3>Cannot compare</h3></div>'; return; }
1889
-
1890
- const { baseline, comparisons, stepComparisons, timelineComparisons } = data.comparison;
1891
- const allResults = data.results;
1892
- const colors = ['#00d4ff', '#9c40ff', '#22c55e', '#eab308', '#ef4444', '#f97316', '#8b5cf6', '#06b6d4'];
1893
-
1894
- // Build step comparison HTML
1895
- let stepCompareHtml = '';
1896
- if (stepComparisons && stepComparisons.length > 0) {
1897
- stepCompareHtml = \`
1898
- <div class="card">
1899
- <h3>Per-Request Comparison</h3>
1900
- <div style="overflow-x: auto;">
1901
- <table class="step-stats-table">
1902
- <thead>
1903
- <tr>
1904
- <th>Request/Step</th>
1905
- \${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('')}
1906
- </tr>
1907
- <tr>
1908
- <th></th>
1909
- \${allResults.map(() => '<th>Avg RT</th><th>P95</th><th>Success</th>').join('')}
1910
- </tr>
1911
- </thead>
1912
- <tbody>
1913
- \${stepComparisons.map((step, stepIdx) => \`
1914
- <tr>
1915
- <td><strong>\${step.step_name}</strong></td>
1916
- \${step.results.map((r, i) => {
1917
- if (!r) return '<td colspan="3" style="color:#6b7280;">N/A</td>';
1918
- const diff = i > 0 && step.diffs ? step.diffs[i-1] : null;
1919
- return \`
1920
- <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>
1921
- <td>\${r.p95?.toFixed(0) || 0}ms \${diff ? diffBadge(diff.p95) : ''}</td>
1922
- <td><span class="status-badge \${r.success_rate < 95 ? 'bad' : r.success_rate < 99 ? 'warn' : 'good'}">\${(r.success_rate || 0).toFixed(1)}%</span></td>
1923
- \`;
1924
- }).join('')}
1925
- </tr>
1926
- \`).join('')}
1927
- </tbody>
1928
- </table>
1929
- </div>
1930
- </div>
1931
- \`;
1932
- }
1933
-
1934
- // Build timeline chart section
1935
- const hasTimeline = timelineComparisons && timelineComparisons.some(t => t.timeline && t.timeline.length > 0);
1936
-
1937
- container.innerHTML = \`
1938
- <div class="card">
1939
- <h3>Comparison: \${allResults.length} Test Runs</h3>
1940
- <p style="color: var(--text-secondary); margin-bottom: 20px;">Baseline: \${baseline.name} (\${new Date(baseline.timestamp).toLocaleString()})</p>
1941
-
1942
- <div class="grid-2" style="margin-bottom: 20px;">
1943
- <div class="card" style="margin-bottom: 0;"><h3>Average Response Times</h3><div class="chart-container tall"><canvas id="compare-rt"></canvas></div></div>
1944
- <div class="card" style="margin-bottom: 0;"><h3>Percentiles Comparison</h3><div class="chart-container tall"><canvas id="compare-percentiles"></canvas></div></div>
1945
- </div>
1946
- <div class="grid-2" style="margin-bottom: 20px;">
1947
- <div class="card" style="margin-bottom: 0;"><h3>Throughput</h3><div class="chart-container"><canvas id="compare-rps"></canvas></div></div>
1948
- <div class="card" style="margin-bottom: 0;"><h3>Error Rates</h3><div class="chart-container"><canvas id="compare-errors"></canvas></div></div>
1949
- </div>
1950
-
1951
- \${hasTimeline ? \`
1952
- <div class="card" style="margin-bottom: 20px;">
1953
- <h3>Response Time Over Time</h3>
1954
- <p style="color: var(--text-secondary); font-size: 12px; margin-bottom: 12px;">Line graph comparing response times throughout each test run</p>
1955
- <div class="chart-container" style="height: 350px;"><canvas id="compare-timeline"></canvas></div>
1956
- </div>
1957
- \` : ''}
1958
- </div>
1959
-
1960
- <div class="card">
1961
- <h3>Overall Metrics Comparison</h3>
1962
- <div style="overflow-x: auto;">
1963
- <table>
1964
- <thead><tr><th>Metric</th>\${allResults.map(r => '<th>' + r.name.substring(0, 25) + '</th>').join('')}</tr></thead>
1965
- <tbody>
1966
- <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>
1967
- <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>
1968
- <tr><td>P90</td>\${allResults.map(r => '<td>' + r.summary.p90_response_time.toFixed(0) + 'ms</td>').join('')}</tr>
1969
- <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>
1970
- <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>
1971
- <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>
1972
- <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>
1973
- <tr><td>Total Requests</td>\${allResults.map(r => '<td>' + (r.summary.total_requests || 0).toLocaleString() + '</td>').join('')}</tr>
1974
- <tr><td>Duration</td>\${allResults.map(r => '<td>' + (r.summary.total_duration || 0).toFixed(1) + 's</td>').join('')}</tr>
1975
- </tbody>
1976
- </table>
1977
- </div>
1978
- </div>
1979
-
1980
- \${stepCompareHtml}
1981
- \`;
1982
-
1983
- setTimeout(() => {
1984
- const labels = allResults.map(r => r.name.substring(0, 15));
1985
-
1986
- new Chart(document.getElementById('compare-rt'), {
1987
- type: 'bar',
1988
- data: { labels, datasets: [{ label: 'Avg Response (ms)', data: allResults.map(r => r.summary.avg_response_time), backgroundColor: colors.slice(0, allResults.length), borderRadius: 4 }] },
1989
- options: chartOptions('ms')
1990
- });
1991
-
1992
- new Chart(document.getElementById('compare-percentiles'), {
1993
- type: 'bar',
1994
- data: {
1995
- labels,
1996
- datasets: [
1997
- { label: 'P50', data: allResults.map(r => r.summary.p50_response_time), backgroundColor: '#22c55e' },
1998
- { label: 'P90', data: allResults.map(r => r.summary.p90_response_time), backgroundColor: '#00d4ff' },
1999
- { label: 'P95', data: allResults.map(r => r.summary.p95_response_time), backgroundColor: '#eab308' },
2000
- { label: 'P99', data: allResults.map(r => r.summary.p99_response_time), backgroundColor: '#ef4444' }
2001
- ]
2002
- },
2003
- options: chartOptions('ms')
2004
- });
2005
-
2006
- new Chart(document.getElementById('compare-rps'), {
2007
- type: 'bar',
2008
- data: { labels, datasets: [{ label: 'Requests/sec', data: allResults.map(r => r.summary.requests_per_second), backgroundColor: colors.slice(0, allResults.length), borderRadius: 4 }] },
2009
- options: chartOptions('req/s')
2010
- });
2011
-
2012
- new Chart(document.getElementById('compare-errors'), {
2013
- type: 'bar',
2014
- 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 }] },
2015
- options: chartOptions('%')
2016
- });
2017
-
2018
- // Timeline line chart
2019
- if (hasTimeline) {
2020
- const timelineDatasets = timelineComparisons.map((tc, idx) => {
2021
- const timeline = tc.timeline || [];
2022
- // Normalize to elapsed seconds from start
2023
- const startTime = timeline.length > 0 ? timeline[0].timestamp : 0;
2024
- return {
2025
- label: tc.name.substring(0, 20),
2026
- data: timeline.map(t => ({ x: (t.timestamp - startTime) / 1000, y: t.avg_response_time || t.p95 || 0 })),
2027
- borderColor: colors[idx % colors.length],
2028
- backgroundColor: colors[idx % colors.length] + '33',
2029
- fill: false,
2030
- tension: 0.3,
2031
- pointRadius: 2,
2032
- borderWidth: 2
2033
- };
2034
- });
2035
-
2036
- new Chart(document.getElementById('compare-timeline'), {
2037
- type: 'line',
2038
- data: { datasets: timelineDatasets },
2039
- options: {
2040
- responsive: true,
2041
- maintainAspectRatio: false,
2042
- interaction: { intersect: false, mode: 'index' },
2043
- plugins: {
2044
- legend: { position: 'bottom', labels: { color: '#9ca3af', usePointStyle: true } },
2045
- tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(0) + 'ms' } }
2046
- },
2047
- scales: {
2048
- x: {
2049
- type: 'linear',
2050
- title: { display: true, text: 'Elapsed Time (seconds)', color: '#9ca3af' },
2051
- grid: { color: 'rgba(255,255,255,0.1)' },
2052
- ticks: { color: '#9ca3af' }
2053
- },
2054
- y: {
2055
- title: { display: true, text: 'Response Time (ms)', color: '#9ca3af' },
2056
- beginAtZero: true,
2057
- grid: { color: 'rgba(255,255,255,0.1)' },
2058
- ticks: { color: '#9ca3af', callback: v => v + ' ms' }
2059
- }
2060
- }
2061
- }
2062
- });
249
+ catch (error) {
250
+ res.writeHead(500, { 'Content-Type': 'application/json' });
251
+ res.end(JSON.stringify({ error: error.message }));
2063
252
  }
2064
- }, 100);
2065
- }
2066
-
2067
- function diffBadge(diff, higherIsBetter = false) {
2068
- if (!diff) return '';
2069
- const improved = higherIsBetter ? parseFloat(diff.change) > 0 : parseFloat(diff.change) < 0;
2070
- return '<span style="font-size:11px;color:' + (improved ? '#22c55e' : '#ef4444') + ';">' + (parseFloat(diff.change) > 0 ? '+' : '') + diff.change + '</span>';
2071
- }
2072
-
2073
- function chartOptions(unit) {
2074
- return {
2075
- responsive: true, maintainAspectRatio: false,
2076
- plugins: { legend: { position: 'bottom', labels: { color: '#9ca3af' } } },
2077
- 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' } } }
2078
- };
2079
- }
2080
-
2081
- // Scatter chart helper for response times
2082
- function createScatterChart(id, datasets) {
2083
- const canvas = document.getElementById(id);
2084
- if (!canvas) return;
2085
-
2086
- // If chart exists but canvas was recreated (DOM rebuild), destroy old chart
2087
- if (charts[id] && charts[id].canvas !== canvas) {
2088
- charts[id].destroy();
2089
- delete charts[id];
2090
- }
2091
-
2092
- if (charts[id]) {
2093
- charts[id].data.datasets = datasets;
2094
- charts[id].update('none');
2095
- } else {
2096
- charts[id] = new Chart(canvas, {
2097
- type: 'scatter',
2098
- data: { datasets },
2099
- options: {
2100
- responsive: true, maintainAspectRatio: false, animation: false,
2101
- plugins: {
2102
- legend: { display: false }
2103
- },
2104
- scales: {
2105
- y: {
2106
- beginAtZero: true,
2107
- grid: { color: 'rgba(255,255,255,0.1)' },
2108
- ticks: { color: '#9ca3af' },
2109
- title: { display: true, text: 'ms', color: '#9ca3af' }
2110
- },
2111
- x: {
2112
- grid: { color: 'rgba(255,255,255,0.05)' },
2113
- ticks: { color: '#9ca3af' },
2114
- title: { display: true, text: 'Time (s)', color: '#9ca3af' }
2115
- }
2116
- }
2117
- }
2118
- });
2119
- }
2120
- }
2121
-
2122
- // Chart helper
2123
- function createOrUpdateChart(id, type, labels, datasets) {
2124
- const canvas = document.getElementById(id);
2125
- if (!canvas) return;
2126
-
2127
- // If chart exists but canvas was recreated (DOM rebuild), destroy old chart
2128
- if (charts[id] && charts[id].canvas !== canvas) {
2129
- charts[id].destroy();
2130
- delete charts[id];
2131
- }
2132
-
2133
- // Check if this is a multi-line chart (response time with percentiles)
2134
- const showLegend = datasets.length > 1;
2135
-
2136
- if (charts[id]) {
2137
- charts[id].data.labels = labels;
2138
- charts[id].data.datasets = datasets;
2139
- charts[id].update('none');
2140
- } else {
2141
- charts[id] = new Chart(canvas, {
2142
- type,
2143
- data: { labels, datasets },
2144
- options: {
2145
- responsive: true, maintainAspectRatio: false, animation: false,
2146
- plugins: {
2147
- legend: {
2148
- display: showLegend,
2149
- position: 'top',
2150
- labels: { color: '#9ca3af', boxWidth: 12, padding: 8, font: { size: 11 } }
2151
- }
2152
- },
2153
- scales: {
2154
- y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#9ca3af' } },
2155
- x: { display: true, grid: { display: false }, ticks: { color: '#9ca3af', maxRotation: 0, autoSkip: true, maxTicksLimit: 8 } }
2156
- }
2157
- }
2158
- });
2159
- }
2160
- }
2161
-
2162
- // Tabs
2163
- function setupTabs() {
2164
- document.querySelectorAll('.tab').forEach(tab => {
2165
- tab.addEventListener('click', () => showPanel(tab.dataset.tab));
2166
- });
2167
- }
2168
-
2169
- function showPanel(name) {
2170
- document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
2171
- document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === name));
2172
- }
2173
-
2174
- // Helpers
2175
- function formatDuration(ms) {
2176
- if (!ms) return '-';
2177
- if (ms < 1000) return ms + 'ms';
2178
- if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
2179
- return (ms / 60000).toFixed(1) + 'm';
2180
- }
2181
- </script>
2182
- </body>
2183
- </html>`;
2184
253
  }
2185
254
  }
2186
255
  exports.DashboardServer = DashboardServer;