@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.
- package/dist/cli/commands/distributed.js +2 -2
- package/dist/cli/commands/report.js +2 -2
- package/dist/cli/commands/run.js +2 -0
- package/dist/config/parser.js +2 -2
- package/dist/config/types/global-config.d.ts +82 -2
- package/dist/config/types/scenario-config.d.ts +2 -2
- package/dist/core/data/data-manager.d.ts +70 -0
- package/dist/core/data/data-manager.js +186 -0
- package/dist/core/data/data-provider.d.ts +85 -0
- package/dist/core/data/data-provider.js +468 -0
- package/dist/core/data/index.d.ts +8 -0
- package/dist/core/data/index.js +13 -0
- package/dist/core/execution/check-evaluator.d.ts +10 -0
- package/dist/core/execution/check-evaluator.js +79 -0
- package/dist/core/execution/data-extractor.d.ts +6 -0
- package/dist/core/execution/data-extractor.js +70 -0
- package/dist/core/execution/index.d.ts +3 -0
- package/dist/core/execution/index.js +9 -0
- package/dist/core/execution/json-payload-processor.d.ts +7 -0
- package/dist/core/execution/json-payload-processor.js +140 -0
- package/dist/core/factories/index.d.ts +2 -0
- package/dist/core/factories/index.js +7 -0
- package/dist/core/factories/output-handler-factory.d.ts +10 -0
- package/dist/core/factories/output-handler-factory.js +91 -0
- package/dist/core/factories/protocol-handler-factory.d.ts +12 -0
- package/dist/core/factories/protocol-handler-factory.js +96 -0
- package/dist/core/index.d.ts +3 -2
- package/dist/core/index.js +8 -3
- package/dist/core/reporting/dashboard-reporter.d.ts +17 -0
- package/dist/core/reporting/dashboard-reporter.js +127 -0
- package/dist/core/reporting/index.d.ts +1 -0
- package/dist/core/reporting/index.js +5 -0
- package/dist/core/step-executor.d.ts +6 -20
- package/dist/core/step-executor.js +72 -366
- package/dist/core/strategies/index.d.ts +2 -0
- package/dist/core/strategies/index.js +7 -0
- package/dist/core/strategies/scenario-selector.d.ts +13 -0
- package/dist/core/strategies/scenario-selector.js +37 -0
- package/dist/core/strategies/think-time-strategy.d.ts +15 -0
- package/dist/core/strategies/think-time-strategy.js +71 -0
- package/dist/core/test-runner.d.ts +4 -11
- package/dist/core/test-runner.js +105 -312
- package/dist/core/virtual-user.d.ts +7 -37
- package/dist/core/virtual-user.js +29 -269
- package/dist/dashboard/routes/api.d.ts +64 -0
- package/dist/dashboard/routes/api.js +569 -0
- package/dist/dashboard/routes/index.d.ts +2 -0
- package/dist/dashboard/routes/index.js +7 -0
- package/dist/dashboard/routes/static.d.ts +6 -0
- package/dist/dashboard/routes/static.js +76 -0
- package/dist/dashboard/server.d.ts +8 -84
- package/dist/dashboard/server.js +76 -2007
- package/dist/dashboard/services/file-scanner.d.ts +7 -0
- package/dist/dashboard/services/file-scanner.js +114 -0
- package/dist/dashboard/services/index.d.ts +5 -0
- package/dist/dashboard/services/index.js +13 -0
- package/dist/dashboard/services/influxdb-service.d.ts +41 -0
- package/dist/dashboard/services/influxdb-service.js +329 -0
- package/dist/dashboard/services/metrics-parser.d.ts +12 -0
- package/dist/dashboard/services/metrics-parser.js +209 -0
- package/dist/dashboard/services/results-manager.d.ts +17 -0
- package/dist/dashboard/services/results-manager.js +311 -0
- package/dist/dashboard/services/test-executor.d.ts +41 -0
- package/dist/dashboard/services/test-executor.js +250 -0
- package/dist/dashboard/services/workers-manager.d.ts +13 -0
- package/dist/dashboard/services/workers-manager.js +81 -0
- package/dist/dashboard/templates/index.html +122 -0
- package/dist/dashboard/templates/scripts/main.js +3280 -0
- package/dist/dashboard/templates/styles.css +402 -0
- package/dist/dashboard/types.d.ts +168 -0
- package/dist/dashboard/types.js +2 -0
- package/dist/distributed/result-aggregator.js +1 -3
- package/dist/metrics/batch/batch-processor.d.ts +27 -0
- package/dist/metrics/batch/batch-processor.js +85 -0
- package/dist/metrics/batch/index.d.ts +1 -0
- package/dist/metrics/batch/index.js +5 -0
- package/dist/metrics/collector.d.ts +46 -45
- package/dist/metrics/collector.js +179 -640
- package/dist/metrics/core/error-tracker.d.ts +9 -0
- package/dist/metrics/core/error-tracker.js +52 -0
- package/dist/metrics/core/index.d.ts +3 -0
- package/dist/metrics/core/index.js +9 -0
- package/dist/metrics/core/result-storage.d.ts +19 -0
- package/dist/metrics/core/result-storage.js +56 -0
- package/dist/metrics/core/statistics-engine.d.ts +27 -0
- package/dist/metrics/core/statistics-engine.js +91 -0
- package/dist/metrics/output/file-writer.d.ts +19 -0
- package/dist/metrics/output/file-writer.js +129 -0
- package/dist/metrics/output/index.d.ts +2 -0
- package/dist/metrics/output/index.js +10 -0
- package/dist/metrics/output/influxdb-writer.d.ts +89 -0
- package/dist/metrics/output/influxdb-writer.js +404 -0
- package/dist/metrics/realtime/dispatcher.d.ts +18 -0
- package/dist/metrics/realtime/dispatcher.js +45 -0
- package/dist/metrics/realtime/endpoints/graphite.d.ts +3 -0
- package/dist/metrics/realtime/endpoints/graphite.js +61 -0
- package/dist/metrics/realtime/endpoints/influxdb.d.ts +3 -0
- package/dist/metrics/realtime/endpoints/influxdb.js +35 -0
- package/dist/metrics/realtime/endpoints/webhook.d.ts +3 -0
- package/dist/metrics/realtime/endpoints/webhook.js +22 -0
- package/dist/metrics/realtime/endpoints/websocket.d.ts +3 -0
- package/dist/metrics/realtime/endpoints/websocket.js +25 -0
- package/dist/metrics/realtime/index.d.ts +5 -0
- package/dist/metrics/realtime/index.js +13 -0
- package/dist/metrics/reporting/index.d.ts +3 -0
- package/dist/metrics/reporting/index.js +9 -0
- package/dist/metrics/reporting/step-statistics.d.ts +6 -0
- package/dist/metrics/reporting/step-statistics.js +59 -0
- package/dist/metrics/reporting/summary-generator.d.ts +16 -0
- package/dist/metrics/reporting/summary-generator.js +46 -0
- package/dist/metrics/reporting/timeline-calculator.d.ts +7 -0
- package/dist/metrics/reporting/timeline-calculator.js +86 -0
- package/dist/metrics/types.d.ts +58 -0
- package/dist/outputs/csv.d.ts +2 -0
- package/dist/outputs/csv.js +21 -2
- package/dist/outputs/json.js +6 -2
- package/dist/protocols/rest/handler.d.ts +4 -53
- package/dist/protocols/rest/handler.js +73 -454
- package/dist/protocols/rest/request/auth-handler.d.ts +4 -0
- package/dist/protocols/rest/request/auth-handler.js +30 -0
- package/dist/protocols/rest/request/body-processor.d.ts +11 -0
- package/dist/protocols/rest/request/body-processor.js +62 -0
- package/dist/protocols/rest/request/index.d.ts +2 -0
- package/dist/protocols/rest/request/index.js +7 -0
- package/dist/protocols/rest/response/checks.d.ts +6 -0
- package/dist/protocols/rest/response/checks.js +71 -0
- package/dist/protocols/rest/response/index.d.ts +2 -0
- package/dist/protocols/rest/response/index.js +7 -0
- package/dist/protocols/rest/response/size-calculator.d.ts +12 -0
- package/dist/protocols/rest/response/size-calculator.js +64 -0
- package/dist/protocols/web/browser/highlight.d.ts +7 -0
- package/dist/protocols/web/browser/highlight.js +47 -0
- package/dist/protocols/web/browser/index.d.ts +4 -0
- package/dist/protocols/web/browser/index.js +11 -0
- package/dist/protocols/web/browser/manager.d.ts +20 -0
- package/dist/protocols/web/browser/manager.js +189 -0
- package/dist/protocols/web/browser/screenshot.d.ts +8 -0
- package/dist/protocols/web/browser/screenshot.js +69 -0
- package/dist/protocols/web/browser/storage.d.ts +5 -0
- package/dist/protocols/web/browser/storage.js +45 -0
- package/dist/protocols/web/commands/index.d.ts +5 -0
- package/dist/protocols/web/commands/index.js +11 -0
- package/dist/protocols/web/commands/interaction.d.ts +13 -0
- package/dist/protocols/web/commands/interaction.js +68 -0
- package/dist/protocols/web/commands/measurement.d.ts +16 -0
- package/dist/protocols/web/commands/measurement.js +33 -0
- package/dist/protocols/web/commands/navigation.d.ts +11 -0
- package/dist/protocols/web/commands/navigation.js +43 -0
- package/dist/protocols/web/commands/types.d.ts +12 -0
- package/dist/protocols/web/commands/types.js +2 -0
- package/dist/protocols/web/commands/verification.d.ts +11 -0
- package/dist/protocols/web/commands/verification.js +98 -0
- package/dist/protocols/web/handler.d.ts +19 -30
- package/dist/protocols/web/handler.js +160 -650
- package/dist/protocols/web/network/capture.d.ts +19 -0
- package/dist/protocols/web/network/capture.js +225 -0
- package/dist/protocols/web/network/filters.d.ts +5 -0
- package/dist/protocols/web/network/filters.js +49 -0
- package/dist/protocols/web/network/index.d.ts +4 -0
- package/dist/protocols/web/network/index.js +9 -0
- package/dist/protocols/web/network/types.d.ts +13 -0
- package/dist/protocols/web/network/types.js +2 -0
- package/dist/protocols/web/network/utils.d.ts +8 -0
- package/dist/protocols/web/network/utils.js +29 -0
- package/dist/recorder/native-recorder.js +2 -1
- package/dist/reporting/chart-data/index.d.ts +5 -0
- package/dist/reporting/chart-data/index.js +13 -0
- package/dist/reporting/chart-data/network.d.ts +25 -0
- package/dist/reporting/chart-data/network.js +78 -0
- package/dist/reporting/chart-data/scenario.d.ts +37 -0
- package/dist/reporting/chart-data/scenario.js +76 -0
- package/dist/reporting/chart-data/step-statistics.d.ts +24 -0
- package/dist/reporting/chart-data/step-statistics.js +94 -0
- package/dist/reporting/chart-data/throughput.d.ts +16 -0
- package/dist/reporting/chart-data/throughput.js +24 -0
- package/dist/reporting/chart-data/timeline.d.ts +17 -0
- package/dist/reporting/chart-data/timeline.js +46 -0
- package/dist/reporting/handlebars-helpers.d.ts +1 -0
- package/dist/reporting/handlebars-helpers.js +63 -0
- package/dist/reporting/{enhanced-html-generator.d.ts → html-generator.d.ts} +1 -1
- package/dist/reporting/{enhanced-html-generator.js → html-generator.js} +10 -7
- package/dist/reporting/templates/{enhanced-report.hbs → report.hbs} +9 -9
- package/dist/utils/data-utils.d.ts +17 -0
- package/dist/utils/data-utils.js +129 -0
- package/dist/utils/template.js +2 -2
- package/package.json +5 -2
- package/dist/core/csv-data-provider.d.ts +0 -47
- package/dist/core/csv-data-provider.js +0 -265
- package/dist/reporting/generator.d.ts +0 -42
- package/dist/reporting/generator.js +0 -1217
- package/dist/reporting/templates/html.hbs +0 -2453
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
215
|
+
else if (url.pathname === '/api/metrics/export') {
|
|
216
|
+
await this.apiRoutes.handleExportTestData(res, url);
|
|
380
217
|
}
|
|
381
|
-
|
|
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
|
-
|
|
402
|
-
|
|
221
|
+
else if (url.pathname === '/api/infra/export') {
|
|
222
|
+
await this.apiRoutes.handleExportInfra(req, res, url);
|
|
403
223
|
}
|
|
404
|
-
|
|
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
|
-
|
|
422
|
-
|
|
227
|
+
else if (url.pathname === '/api/infra/status') {
|
|
228
|
+
await this.apiRoutes.handleGetInfraStatus(res);
|
|
423
229
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
550
|
-
|
|
235
|
+
else if (url.pathname === '/api/infra' && req.method === 'POST') {
|
|
236
|
+
await this.apiRoutes.handleInfraMetrics(req, res);
|
|
551
237
|
}
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|