@testsmith/perfornium 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +360 -0
  2. package/dist/cli/cli.d.ts +2 -0
  3. package/dist/cli/cli.js +192 -0
  4. package/dist/cli/commands/distributed.d.ts +11 -0
  5. package/dist/cli/commands/distributed.js +179 -0
  6. package/dist/cli/commands/import.d.ts +23 -0
  7. package/dist/cli/commands/import.js +461 -0
  8. package/dist/cli/commands/init.d.ts +7 -0
  9. package/dist/cli/commands/init.js +923 -0
  10. package/dist/cli/commands/mock.d.ts +7 -0
  11. package/dist/cli/commands/mock.js +281 -0
  12. package/dist/cli/commands/report.d.ts +5 -0
  13. package/dist/cli/commands/report.js +70 -0
  14. package/dist/cli/commands/run.d.ts +12 -0
  15. package/dist/cli/commands/run.js +260 -0
  16. package/dist/cli/commands/validate.d.ts +3 -0
  17. package/dist/cli/commands/validate.js +35 -0
  18. package/dist/cli/commands/worker.d.ts +27 -0
  19. package/dist/cli/commands/worker.js +320 -0
  20. package/dist/config/index.d.ts +2 -0
  21. package/dist/config/index.js +20 -0
  22. package/dist/config/parser.d.ts +19 -0
  23. package/dist/config/parser.js +330 -0
  24. package/dist/config/types/global-config.d.ts +74 -0
  25. package/dist/config/types/global-config.js +2 -0
  26. package/dist/config/types/hooks.d.ts +58 -0
  27. package/dist/config/types/hooks.js +3 -0
  28. package/dist/config/types/import-types.d.ts +33 -0
  29. package/dist/config/types/import-types.js +2 -0
  30. package/dist/config/types/index.d.ts +11 -0
  31. package/dist/config/types/index.js +27 -0
  32. package/dist/config/types/load-config.d.ts +32 -0
  33. package/dist/config/types/load-config.js +9 -0
  34. package/dist/config/types/output-config.d.ts +10 -0
  35. package/dist/config/types/output-config.js +2 -0
  36. package/dist/config/types/report-config.d.ts +10 -0
  37. package/dist/config/types/report-config.js +2 -0
  38. package/dist/config/types/runtime-types.d.ts +6 -0
  39. package/dist/config/types/runtime-types.js +2 -0
  40. package/dist/config/types/scenario-config.d.ts +30 -0
  41. package/dist/config/types/scenario-config.js +2 -0
  42. package/dist/config/types/step-types.d.ts +139 -0
  43. package/dist/config/types/step-types.js +2 -0
  44. package/dist/config/types/test-configuration.d.ts +18 -0
  45. package/dist/config/types/test-configuration.js +2 -0
  46. package/dist/config/types/worker-config.d.ts +12 -0
  47. package/dist/config/types/worker-config.js +2 -0
  48. package/dist/config/validator.d.ts +19 -0
  49. package/dist/config/validator.js +198 -0
  50. package/dist/core/csv-data-provider.d.ts +47 -0
  51. package/dist/core/csv-data-provider.js +265 -0
  52. package/dist/core/hooks-manager.d.ts +33 -0
  53. package/dist/core/hooks-manager.js +129 -0
  54. package/dist/core/index.d.ts +5 -0
  55. package/dist/core/index.js +11 -0
  56. package/dist/core/script-executor.d.ts +14 -0
  57. package/dist/core/script-executor.js +290 -0
  58. package/dist/core/step-executor.d.ts +41 -0
  59. package/dist/core/step-executor.js +680 -0
  60. package/dist/core/test-runner.d.ts +34 -0
  61. package/dist/core/test-runner.js +465 -0
  62. package/dist/core/threshold-evaluator.d.ts +43 -0
  63. package/dist/core/threshold-evaluator.js +170 -0
  64. package/dist/core/virtual-user-pool.d.ts +42 -0
  65. package/dist/core/virtual-user-pool.js +136 -0
  66. package/dist/core/virtual-user.d.ts +51 -0
  67. package/dist/core/virtual-user.js +488 -0
  68. package/dist/distributed/coordinator.d.ts +34 -0
  69. package/dist/distributed/coordinator.js +158 -0
  70. package/dist/distributed/health-monitor.d.ts +18 -0
  71. package/dist/distributed/health-monitor.js +72 -0
  72. package/dist/distributed/load-distributor.d.ts +17 -0
  73. package/dist/distributed/load-distributor.js +106 -0
  74. package/dist/distributed/remote-worker.d.ts +37 -0
  75. package/dist/distributed/remote-worker.js +241 -0
  76. package/dist/distributed/result-aggregator.d.ts +43 -0
  77. package/dist/distributed/result-aggregator.js +146 -0
  78. package/dist/dsl/index.d.ts +3 -0
  79. package/dist/dsl/index.js +11 -0
  80. package/dist/dsl/test-builder.d.ts +111 -0
  81. package/dist/dsl/test-builder.js +514 -0
  82. package/dist/importers/har-importer.d.ts +17 -0
  83. package/dist/importers/har-importer.js +172 -0
  84. package/dist/importers/open-api-importer.d.ts +23 -0
  85. package/dist/importers/open-api-importer.js +181 -0
  86. package/dist/importers/wsdl-importer.d.ts +42 -0
  87. package/dist/importers/wsdl-importer.js +440 -0
  88. package/dist/index.d.ts +5 -0
  89. package/dist/index.js +17 -0
  90. package/dist/load-patterns/arrivals.d.ts +7 -0
  91. package/dist/load-patterns/arrivals.js +118 -0
  92. package/dist/load-patterns/base.d.ts +9 -0
  93. package/dist/load-patterns/base.js +2 -0
  94. package/dist/load-patterns/basic.d.ts +7 -0
  95. package/dist/load-patterns/basic.js +117 -0
  96. package/dist/load-patterns/stepping.d.ts +6 -0
  97. package/dist/load-patterns/stepping.js +122 -0
  98. package/dist/metrics/collector.d.ts +72 -0
  99. package/dist/metrics/collector.js +662 -0
  100. package/dist/metrics/types.d.ts +135 -0
  101. package/dist/metrics/types.js +2 -0
  102. package/dist/outputs/base.d.ts +7 -0
  103. package/dist/outputs/base.js +2 -0
  104. package/dist/outputs/csv.d.ts +13 -0
  105. package/dist/outputs/csv.js +163 -0
  106. package/dist/outputs/graphite.d.ts +13 -0
  107. package/dist/outputs/graphite.js +126 -0
  108. package/dist/outputs/influxdb.d.ts +12 -0
  109. package/dist/outputs/influxdb.js +82 -0
  110. package/dist/outputs/json.d.ts +14 -0
  111. package/dist/outputs/json.js +107 -0
  112. package/dist/outputs/streaming-csv.d.ts +37 -0
  113. package/dist/outputs/streaming-csv.js +254 -0
  114. package/dist/outputs/streaming-json.d.ts +43 -0
  115. package/dist/outputs/streaming-json.js +353 -0
  116. package/dist/outputs/webhook.d.ts +16 -0
  117. package/dist/outputs/webhook.js +96 -0
  118. package/dist/protocols/base.d.ts +33 -0
  119. package/dist/protocols/base.js +2 -0
  120. package/dist/protocols/rest/handler.d.ts +67 -0
  121. package/dist/protocols/rest/handler.js +776 -0
  122. package/dist/protocols/soap/handler.d.ts +12 -0
  123. package/dist/protocols/soap/handler.js +165 -0
  124. package/dist/protocols/web/core-web-vitals.d.ts +121 -0
  125. package/dist/protocols/web/core-web-vitals.js +373 -0
  126. package/dist/protocols/web/handler.d.ts +50 -0
  127. package/dist/protocols/web/handler.js +706 -0
  128. package/dist/recorder/native-recorder.d.ts +14 -0
  129. package/dist/recorder/native-recorder.js +533 -0
  130. package/dist/recorder/scenario-recorder.d.ts +55 -0
  131. package/dist/recorder/scenario-recorder.js +296 -0
  132. package/dist/reporting/constants.d.ts +94 -0
  133. package/dist/reporting/constants.js +82 -0
  134. package/dist/reporting/enhanced-html-generator.d.ts +55 -0
  135. package/dist/reporting/enhanced-html-generator.js +965 -0
  136. package/dist/reporting/generator.d.ts +42 -0
  137. package/dist/reporting/generator.js +1217 -0
  138. package/dist/reporting/statistics.d.ts +144 -0
  139. package/dist/reporting/statistics.js +742 -0
  140. package/dist/reporting/templates/enhanced-report.hbs +2812 -0
  141. package/dist/reporting/templates/html.hbs +2453 -0
  142. package/dist/utils/faker-manager.d.ts +55 -0
  143. package/dist/utils/faker-manager.js +166 -0
  144. package/dist/utils/file-manager.d.ts +33 -0
  145. package/dist/utils/file-manager.js +154 -0
  146. package/dist/utils/handlebars-manager.d.ts +42 -0
  147. package/dist/utils/handlebars-manager.js +172 -0
  148. package/dist/utils/logger.d.ts +16 -0
  149. package/dist/utils/logger.js +46 -0
  150. package/dist/utils/template.d.ts +80 -0
  151. package/dist/utils/template.js +513 -0
  152. package/dist/utils/test-output-writer.d.ts +56 -0
  153. package/dist/utils/test-output-writer.js +643 -0
  154. package/dist/utils/time.d.ts +3 -0
  155. package/dist/utils/time.js +23 -0
  156. package/dist/utils/timestamp-helper.d.ts +17 -0
  157. package/dist/utils/timestamp-helper.js +53 -0
  158. package/dist/workers/manager.d.ts +18 -0
  159. package/dist/workers/manager.js +95 -0
  160. package/dist/workers/server.d.ts +21 -0
  161. package/dist/workers/server.js +205 -0
  162. package/dist/workers/worker.d.ts +19 -0
  163. package/dist/workers/worker.js +147 -0
  164. package/package.json +102 -0
@@ -0,0 +1,2812 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>{{testName}} - Enhanced Performance Report</title>
8
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
9
+ <script
10
+ src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
11
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
12
+
13
+ <style>
14
+ :root {
15
+ --primary-color: #2563eb;
16
+ --success-color: #10b981;
17
+ --warning-color: #f59e0b;
18
+ --error-color: #ef4444;
19
+ --background-color: #f8fafc;
20
+ --card-background: #ffffff;
21
+ --text-primary: #1f2937;
22
+ --text-secondary: #6b7280;
23
+ --border-color: #e5e7eb;
24
+ }
25
+
26
+ [data-theme="dark"] {
27
+ --primary-color: #3b82f6;
28
+ --success-color: #10b981;
29
+ --warning-color: #fbbf24;
30
+ --error-color: #f87171;
31
+ --background-color: #111827;
32
+ --card-background: #1f2937;
33
+ --text-primary: #f9fafb;
34
+ --text-secondary: #9ca3af;
35
+ --border-color: #374151;
36
+ }
37
+
38
+ * {
39
+ margin: 0;
40
+ padding: 0;
41
+ box-sizing: border-box;
42
+ }
43
+
44
+ body {
45
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
46
+ background-color: var(--background-color);
47
+ color: var(--text-primary);
48
+ line-height: 1.6;
49
+ }
50
+
51
+ .container {
52
+ max-width: 1400px;
53
+ margin: 0 auto;
54
+ padding: 20px;
55
+ }
56
+
57
+ .header {
58
+ background: linear-gradient(135deg, var(--primary-color) 0%, #1d4ed8 100%);
59
+ color: white;
60
+ padding: 40px 20px;
61
+ border-radius: 12px;
62
+ margin-bottom: 30px;
63
+ text-align: center;
64
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
65
+ position: relative;
66
+ }
67
+
68
+ [data-theme="dark"] .header {
69
+ background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 100%);
70
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
71
+ }
72
+
73
+ .header h1 {
74
+ font-size: 2.5rem;
75
+ font-weight: 700;
76
+ margin-bottom: 10px;
77
+ }
78
+
79
+ .header p {
80
+ font-size: 1.1rem;
81
+ opacity: 0.9;
82
+ }
83
+
84
+ .theme-toggle {
85
+ position: absolute;
86
+ top: 20px;
87
+ right: 20px;
88
+ background: rgba(255, 255, 255, 0.2);
89
+ border: 2px solid rgba(255, 255, 255, 0.3);
90
+ border-radius: 50px;
91
+ padding: 8px 16px;
92
+ cursor: pointer;
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 8px;
96
+ color: white;
97
+ font-size: 0.9rem;
98
+ font-weight: 500;
99
+ transition: all 0.3s ease;
100
+ }
101
+
102
+ .theme-toggle:hover {
103
+ background: rgba(255, 255, 255, 0.3);
104
+ border-color: rgba(255, 255, 255, 0.5);
105
+ }
106
+
107
+ .theme-icon {
108
+ font-size: 1.2rem;
109
+ }
110
+
111
+ .summary-grid {
112
+ display: grid;
113
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
114
+ gap: 20px;
115
+ margin-bottom: 40px;
116
+ }
117
+
118
+ .metric-card {
119
+ background: var(--card-background);
120
+ padding: 24px;
121
+ border-radius: 12px;
122
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
123
+ border: 1px solid var(--border-color);
124
+ text-align: center;
125
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
126
+ }
127
+
128
+ .metric-card:hover {
129
+ transform: translateY(-2px);
130
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
131
+ }
132
+
133
+ .metric-value {
134
+ font-size: 2.5rem;
135
+ font-weight: 700;
136
+ margin-bottom: 8px;
137
+ }
138
+
139
+ .metric-label {
140
+ color: var(--text-secondary);
141
+ font-size: 0.9rem;
142
+ text-transform: uppercase;
143
+ letter-spacing: 1px;
144
+ font-weight: 500;
145
+ }
146
+
147
+ .metric-success {
148
+ color: var(--success-color);
149
+ }
150
+
151
+ .metric-warning {
152
+ color: var(--warning-color);
153
+ }
154
+
155
+ .metric-error {
156
+ color: var(--error-color);
157
+ }
158
+
159
+ .section {
160
+ background: var(--card-background);
161
+ margin-bottom: 30px;
162
+ border-radius: 12px;
163
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
164
+ border: 1px solid var(--border-color);
165
+ overflow: hidden;
166
+ }
167
+
168
+ .section-header {
169
+ padding: 20px 24px;
170
+ border-bottom: 1px solid var(--border-color);
171
+ background: #f9fafb;
172
+ }
173
+
174
+ [data-theme="dark"] .section-header {
175
+ background: #1f2937;
176
+ }
177
+
178
+ .section-title {
179
+ font-size: 1.5rem;
180
+ font-weight: 600;
181
+ color: var(--text-primary);
182
+ }
183
+
184
+ .section-content {
185
+ padding: 24px;
186
+ }
187
+
188
+ .chart-container {
189
+ margin-bottom: 30px;
190
+ height: 400px;
191
+ position: relative;
192
+ }
193
+
194
+ .chart-container.large {
195
+ height: 500px;
196
+ }
197
+
198
+ .chart-container.small {
199
+ height: 300px;
200
+ }
201
+
202
+ .chart-container canvas {
203
+ max-height: 100%;
204
+ }
205
+
206
+ .step-stats-table {
207
+ width: 100%;
208
+ border-collapse: collapse;
209
+ margin-top: 20px;
210
+ font-size: 0.85rem;
211
+ }
212
+
213
+ .step-stats-table th,
214
+ .step-stats-table td {
215
+ padding: 8px 6px;
216
+ text-align: left;
217
+ border-bottom: 1px solid var(--border-color);
218
+ }
219
+
220
+ .step-stats-table th {
221
+ background: #f9fafb;
222
+ font-weight: 600;
223
+ color: var(--text-primary);
224
+ position: sticky;
225
+ top: 0;
226
+ font-size: 0.75rem;
227
+ }
228
+
229
+ [data-theme="dark"] .step-stats-table th {
230
+ background: #1f2937;
231
+ }
232
+
233
+ .step-stats-table tr:hover {
234
+ background: #f9fafb;
235
+ }
236
+
237
+ [data-theme="dark"] .step-stats-table tr:hover {
238
+ background: #374151;
239
+ }
240
+
241
+ /* Error Details Table Styles */
242
+ .data-table {
243
+ width: 100%;
244
+ border-collapse: collapse;
245
+ margin-top: 20px;
246
+ font-size: 0.85rem;
247
+ }
248
+
249
+ .data-table th,
250
+ .data-table td {
251
+ padding: 10px 8px;
252
+ text-align: left;
253
+ border-bottom: 1px solid var(--border-color);
254
+ }
255
+
256
+ .data-table th {
257
+ background: #f9fafb;
258
+ font-weight: 600;
259
+ color: var(--text-primary);
260
+ position: sticky;
261
+ top: 0;
262
+ font-size: 0.8rem;
263
+ white-space: nowrap;
264
+ }
265
+
266
+ [data-theme="dark"] .data-table th {
267
+ background: #1f2937;
268
+ }
269
+
270
+ .data-table tr:hover {
271
+ background: #fef9f9;
272
+ }
273
+
274
+ [data-theme="dark"] .data-table tr:hover {
275
+ background: #374151;
276
+ }
277
+
278
+ .data-table .url-cell,
279
+ .data-table .error-cell,
280
+ .data-table .response-body-cell {
281
+ max-width: 250px;
282
+ overflow: hidden;
283
+ text-overflow: ellipsis;
284
+ white-space: nowrap;
285
+ cursor: help;
286
+ }
287
+
288
+ .data-table .error-cell {
289
+ color: var(--error-color);
290
+ font-weight: 500;
291
+ }
292
+
293
+ .data-table .response-body-cell {
294
+ font-family: monospace;
295
+ font-size: 0.75rem;
296
+ color: var(--text-secondary);
297
+ }
298
+
299
+ .table-container {
300
+ overflow-x: auto;
301
+ margin-top: 15px;
302
+ }
303
+
304
+ .status-badge {
305
+ padding: 4px 8px;
306
+ border-radius: 4px;
307
+ font-size: 0.7rem;
308
+ font-weight: 500;
309
+ text-transform: uppercase;
310
+ }
311
+
312
+ .status-success {
313
+ background: #dcfce7;
314
+ color: #166534;
315
+ }
316
+
317
+ .status-warning {
318
+ background: #fef3c7;
319
+ color: #92400e;
320
+ }
321
+
322
+ .status-error {
323
+ background: #fee2e2;
324
+ color: #991b1b;
325
+ }
326
+
327
+ .tabs {
328
+ display: flex;
329
+ border-bottom: 1px solid var(--border-color);
330
+ margin-bottom: 20px;
331
+ }
332
+
333
+ .tab {
334
+ padding: 12px 24px;
335
+ background: none;
336
+ border: none;
337
+ cursor: pointer;
338
+ font-size: 1rem;
339
+ font-weight: 500;
340
+ color: var(--text-secondary);
341
+ border-bottom: 2px solid transparent;
342
+ transition: all 0.2s ease;
343
+ }
344
+
345
+ .tab.active {
346
+ color: var(--primary-color);
347
+ border-bottom-color: var(--primary-color);
348
+ }
349
+
350
+ .tab:hover {
351
+ color: var(--primary-color);
352
+ }
353
+
354
+ .tab-content {
355
+ display: none;
356
+ }
357
+
358
+ .tab-content.active {
359
+ display: block;
360
+ }
361
+
362
+ .grid-2 {
363
+ display: grid;
364
+ grid-template-columns: 1fr 1fr;
365
+ gap: 30px;
366
+ }
367
+
368
+ .grid-3 {
369
+ display: grid;
370
+ grid-template-columns: 1fr 1fr 1fr;
371
+ gap: 20px;
372
+ }
373
+
374
+ @media (max-width: 768px) {
375
+ .grid-2, .grid-3 {
376
+ grid-template-columns: 1fr;
377
+ }
378
+
379
+ .container {
380
+ padding: 10px;
381
+ }
382
+
383
+ .header h1 {
384
+ font-size: 2rem;
385
+ }
386
+
387
+ .chart-container {
388
+ height: 300px;
389
+ }
390
+
391
+ .step-stats-table {
392
+ font-size: 0.7rem;
393
+ }
394
+
395
+ .step-stats-table th,
396
+ .step-stats-table td {
397
+ padding: 6px 4px;
398
+ }
399
+ }
400
+
401
+ .footer {
402
+ text-align: center;
403
+ padding: 20px;
404
+ color: var(--text-secondary);
405
+ font-size: 0.9rem;
406
+ }
407
+
408
+ .chart-controls {
409
+ margin-bottom: 15px;
410
+ }
411
+
412
+ .chart-controls button {
413
+ padding: 8px 16px;
414
+ background: var(--primary-color);
415
+ color: white;
416
+ border: none;
417
+ border-radius: 6px;
418
+ cursor: pointer;
419
+ margin-right: 10px;
420
+ }
421
+
422
+ .chart-controls button:hover {
423
+ background: #1d4ed8;
424
+ }
425
+
426
+ /* Enhanced table styles for better readability */
427
+ .step-stats-table td:nth-child(n+5):nth-child(-n+13) {
428
+ text-align: right;
429
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
430
+ }
431
+
432
+ .step-stats-table th:nth-child(n+5):nth-child(-n+13) {
433
+ text-align: right;
434
+ }
435
+
436
+ /* Core Web Vitals Styles */
437
+ .vitals-section {
438
+ margin-bottom: 30px;
439
+ }
440
+
441
+ .vitals-cards {
442
+ display: grid;
443
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
444
+ gap: 20px;
445
+ margin-bottom: 20px;
446
+ }
447
+
448
+ .vitals-card {
449
+ background: var(--card-background);
450
+ border-radius: 12px;
451
+ padding: 20px;
452
+ text-align: center;
453
+ border: 2px solid var(--border-color);
454
+ transition: transform 0.2s ease;
455
+ }
456
+
457
+ .vitals-card:hover {
458
+ transform: translateY(-2px);
459
+ }
460
+
461
+ .vitals-card h3 {
462
+ margin-bottom: 10px;
463
+ font-size: 0.9rem;
464
+ color: var(--text-secondary);
465
+ text-transform: uppercase;
466
+ letter-spacing: 0.5px;
467
+ }
468
+
469
+ .vitals-value {
470
+ font-size: 2rem;
471
+ font-weight: bold;
472
+ margin-bottom: 5px;
473
+ }
474
+
475
+ .vitals-good {
476
+ border-color: var(--success-color);
477
+ }
478
+
479
+ .vitals-good .vitals-value {
480
+ color: var(--success-color);
481
+ }
482
+
483
+ .vitals-warning {
484
+ border-color: var(--warning-color);
485
+ }
486
+
487
+ .vitals-warning .vitals-value {
488
+ color: var(--warning-color);
489
+ }
490
+
491
+ .vitals-poor {
492
+ border-color: var(--error-color);
493
+ }
494
+
495
+ .vitals-poor .vitals-value {
496
+ color: var(--error-color);
497
+ }
498
+
499
+ .vitals-unknown {
500
+ border-color: var(--text-secondary);
501
+ }
502
+
503
+ .vitals-unknown .vitals-value {
504
+ color: var(--text-secondary);
505
+ }
506
+
507
+ .vitals-score {
508
+ display: inline-block;
509
+ padding: 4px 12px;
510
+ border-radius: 20px;
511
+ font-size: 0.8rem;
512
+ font-weight: 600;
513
+ text-transform: uppercase;
514
+ }
515
+
516
+ .vitals-score.vitals-good {
517
+ background: #d1fae5;
518
+ color: #065f46;
519
+ }
520
+
521
+ .vitals-score.vitals-warning {
522
+ background: #fef3c7;
523
+ color: #92400e;
524
+ }
525
+
526
+ .vitals-score.vitals-poor {
527
+ background: #fee2e2;
528
+ color: #991b1b;
529
+ }
530
+
531
+ .verification-metrics {
532
+ background: var(--card-background);
533
+ border-radius: 12px;
534
+ padding: 20px;
535
+ margin-top: 20px;
536
+ }
537
+
538
+ .verification-stats {
539
+ display: grid;
540
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
541
+ gap: 15px;
542
+ margin-top: 15px;
543
+ }
544
+
545
+ .verification-stat {
546
+ text-align: center;
547
+ }
548
+
549
+ .verification-stat-value {
550
+ font-size: 1.5rem;
551
+ font-weight: bold;
552
+ color: var(--primary-color);
553
+ }
554
+
555
+ .verification-stat-label {
556
+ font-size: 0.9rem;
557
+ color: var(--text-secondary);
558
+ margin-top: 5px;
559
+ }
560
+
561
+ @media (max-width: 768px) {
562
+ .vitals-cards {
563
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
564
+ gap: 15px;
565
+ }
566
+
567
+ .vitals-value {
568
+ font-size: 1.5rem;
569
+ }
570
+
571
+ .verification-stats {
572
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
573
+ gap: 10px;
574
+ }
575
+ }
576
+
577
+ /* Apdex Score Styles */
578
+ .quality-card {
579
+ background: var(--card-background);
580
+ border: 1px solid var(--border-color);
581
+ border-radius: 12px;
582
+ padding: 24px;
583
+ text-align: center;
584
+ }
585
+
586
+ .quality-card h3 {
587
+ font-size: 1.1rem;
588
+ color: var(--text-secondary);
589
+ margin-bottom: 15px;
590
+ text-transform: uppercase;
591
+ letter-spacing: 0.5px;
592
+ }
593
+
594
+ .apdex-score {
595
+ font-size: 3rem;
596
+ font-weight: 700;
597
+ margin-bottom: 8px;
598
+ }
599
+
600
+ .apdex-excellent { color: #10b981; }
601
+ .apdex-good { color: #22c55e; }
602
+ .apdex-fair { color: #f59e0b; }
603
+ .apdex-poor { color: #f97316; }
604
+ .apdex-unacceptable { color: #ef4444; }
605
+
606
+ .apdex-rating {
607
+ font-size: 1.2rem;
608
+ font-weight: 600;
609
+ margin-bottom: 20px;
610
+ color: var(--text-primary);
611
+ }
612
+
613
+ .apdex-breakdown {
614
+ display: flex;
615
+ justify-content: center;
616
+ gap: 20px;
617
+ flex-wrap: wrap;
618
+ }
619
+
620
+ .apdex-item {
621
+ display: flex;
622
+ flex-direction: column;
623
+ gap: 4px;
624
+ }
625
+
626
+ .apdex-label {
627
+ font-size: 0.85rem;
628
+ color: var(--text-secondary);
629
+ }
630
+
631
+ .apdex-value {
632
+ font-size: 1.2rem;
633
+ font-weight: 600;
634
+ }
635
+
636
+ .apdex-value.satisfied { color: #10b981; }
637
+ .apdex-value.tolerating { color: #f59e0b; }
638
+ .apdex-value.frustrated { color: #ef4444; }
639
+
640
+ /* SLA Compliance Styles */
641
+ .sla-status {
642
+ font-size: 2rem;
643
+ font-weight: 700;
644
+ padding: 12px 24px;
645
+ border-radius: 8px;
646
+ margin-bottom: 12px;
647
+ }
648
+
649
+ .sla-passed {
650
+ background: #dcfce7;
651
+ color: #166534;
652
+ }
653
+
654
+ .sla-failed {
655
+ background: #fee2e2;
656
+ color: #991b1b;
657
+ }
658
+
659
+ .sla-summary {
660
+ font-size: 0.95rem;
661
+ color: var(--text-secondary);
662
+ margin-bottom: 20px;
663
+ }
664
+
665
+ .sla-checks {
666
+ text-align: left;
667
+ }
668
+
669
+ .sla-check {
670
+ display: flex;
671
+ align-items: center;
672
+ gap: 10px;
673
+ padding: 8px 12px;
674
+ border-radius: 6px;
675
+ margin-bottom: 6px;
676
+ font-size: 0.9rem;
677
+ }
678
+
679
+ .check-passed {
680
+ background: #f0fdf4;
681
+ }
682
+
683
+ .check-failed {
684
+ background: #fef2f2;
685
+ }
686
+
687
+ .check-icon {
688
+ font-weight: bold;
689
+ font-size: 1.1rem;
690
+ }
691
+
692
+ .check-passed .check-icon { color: #10b981; }
693
+ .check-failed .check-icon { color: #ef4444; }
694
+
695
+ .check-name {
696
+ flex: 1;
697
+ font-weight: 500;
698
+ }
699
+
700
+ .check-values {
701
+ color: var(--text-secondary);
702
+ font-family: monospace;
703
+ }
704
+
705
+ /* Confidence Interval Styles */
706
+ .confidence-section {
707
+ margin-top: 24px;
708
+ padding: 16px;
709
+ background: #f8fafc;
710
+ border-radius: 8px;
711
+ }
712
+
713
+ .confidence-section h4 {
714
+ margin-bottom: 12px;
715
+ color: var(--text-primary);
716
+ }
717
+
718
+ .confidence-bar {
719
+ display: flex;
720
+ justify-content: space-between;
721
+ align-items: center;
722
+ padding: 12px;
723
+ background: linear-gradient(to right, #e0f2fe, #bae6fd, #e0f2fe);
724
+ border-radius: 6px;
725
+ font-family: monospace;
726
+ }
727
+
728
+ .ci-lower, .ci-upper {
729
+ color: var(--text-secondary);
730
+ font-size: 0.9rem;
731
+ }
732
+
733
+ .ci-mean {
734
+ font-weight: 600;
735
+ color: var(--primary-color);
736
+ font-size: 1.1rem;
737
+ }
738
+
739
+ /* Outlier Analysis Styles */
740
+ .outlier-section {
741
+ margin-top: 16px;
742
+ padding: 16px;
743
+ background: #fffbeb;
744
+ border-radius: 8px;
745
+ border-left: 4px solid #f59e0b;
746
+ }
747
+
748
+ .outlier-section h4 {
749
+ margin-bottom: 8px;
750
+ color: #92400e;
751
+ }
752
+
753
+ .outlier-summary {
754
+ display: flex;
755
+ flex-wrap: wrap;
756
+ gap: 12px;
757
+ font-size: 0.9rem;
758
+ }
759
+
760
+ .outlier-count {
761
+ font-weight: 600;
762
+ color: #f59e0b;
763
+ }
764
+
765
+ .outlier-percentage {
766
+ color: var(--text-secondary);
767
+ }
768
+
769
+ .outlier-bounds {
770
+ color: var(--text-secondary);
771
+ font-family: monospace;
772
+ }
773
+
774
+ /* Performance Trend Styles */
775
+ .trend-section {
776
+ margin-top: 16px;
777
+ padding: 16px;
778
+ background: #f8fafc;
779
+ border-radius: 8px;
780
+ }
781
+
782
+ .trend-section h4 {
783
+ margin-bottom: 8px;
784
+ color: var(--text-primary);
785
+ }
786
+
787
+ .trend-indicator {
788
+ font-size: 1.1rem;
789
+ font-weight: 600;
790
+ padding: 8px 16px;
791
+ border-radius: 6px;
792
+ display: inline-block;
793
+ }
794
+
795
+ .trend-indicator.improving {
796
+ background: #dcfce7;
797
+ color: #166534;
798
+ }
799
+
800
+ .trend-indicator.degrading {
801
+ background: #fee2e2;
802
+ color: #991b1b;
803
+ }
804
+
805
+ .trend-indicator.stable {
806
+ background: #f0f9ff;
807
+ color: #0369a1;
808
+ }
809
+
810
+ .trend-indicator.insufficient_data {
811
+ background: #f3f4f6;
812
+ color: #6b7280;
813
+ }
814
+ </style>
815
+ </head>
816
+
817
+ <body>
818
+ <div class="container">
819
+ <!-- Header -->
820
+ <div class="header">
821
+ <button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
822
+ <span class="theme-icon" id="themeIcon">🌙</span>
823
+ <span id="themeText">Dark Mode</span>
824
+ </button>
825
+ <h1>{{testName}}</h1>
826
+ <p>Enhanced Performance Test Report • Generated on {{generatedAt}}</p>
827
+ </div>
828
+
829
+ <!-- Summary Metrics -->
830
+ <div class="summary-grid">
831
+ <div class="metric-card">
832
+ <div class="metric-value">{{summary.total_requests}}</div>
833
+ <div class="metric-label">Total Requests</div>
834
+ </div>
835
+ <div class="metric-card">
836
+ <div
837
+ class="metric-value {{#if (gt summary.success_rate 95)}}metric-success{{else}}{{#if (gt summary.success_rate 90)}}metric-warning{{else}}metric-error{{/if}}{{/if}}">
838
+ {{toFixed summary.success_rate 2}}%
839
+ </div>
840
+ <div class="metric-label">Success Rate</div>
841
+ </div>
842
+ <div class="metric-card">
843
+ <div class="metric-value">{{toFixed summary.avg_response_time 2}}ms</div>
844
+ <div class="metric-label">Avg Response Time</div>
845
+ </div>
846
+ <div class="metric-card">
847
+ <div class="metric-value">{{toFixed summary.requests_per_second 2}}</div>
848
+ <div class="metric-label">Requests/sec</div>
849
+ </div>
850
+ <div class="metric-card">
851
+ <div class="metric-value">{{#if summary.peak_virtual_users}}{{summary.peak_virtual_users}}{{else}}{{#if summary.total_virtual_users}}{{summary.total_virtual_users}}{{else}}{{summary.vu_ramp_up.length}}{{/if}}{{/if}}</div>
852
+ <div class="metric-label">Virtual Users</div>
853
+ </div>
854
+ <div class="metric-card">
855
+ <div class="metric-value">{{toFixed summary.total_duration 0}}s</div>
856
+ <div class="metric-label">Total Duration</div>
857
+ </div>
858
+ </div>
859
+
860
+ <!-- Apdex Score & SLA Compliance -->
861
+ {{#if apdexScore}}
862
+ <div class="section">
863
+ <div class="section-header">
864
+ <h2 class="section-title">Performance Quality</h2>
865
+ </div>
866
+ <div class="section-content">
867
+ <div class="grid-2">
868
+ <!-- Apdex Score Card -->
869
+ <div class="quality-card">
870
+ <h3>Apdex Score</h3>
871
+ <div class="apdex-score {{#if (gte apdexScore.score 0.94)}}apdex-excellent{{else}}{{#if (gte apdexScore.score 0.85)}}apdex-good{{else}}{{#if (gte apdexScore.score 0.70)}}apdex-fair{{else}}{{#if (gte apdexScore.score 0.50)}}apdex-poor{{else}}apdex-unacceptable{{/if}}{{/if}}{{/if}}{{/if}}">
872
+ {{toFixed apdexScore.score 3}}
873
+ </div>
874
+ <div class="apdex-rating">{{apdexScore.rating}}</div>
875
+ <div class="apdex-breakdown">
876
+ <div class="apdex-item">
877
+ <span class="apdex-label">Satisfied:</span>
878
+ <span class="apdex-value satisfied">{{apdexScore.satisfied}}</span>
879
+ </div>
880
+ <div class="apdex-item">
881
+ <span class="apdex-label">Tolerating:</span>
882
+ <span class="apdex-value tolerating">{{apdexScore.tolerating}}</span>
883
+ </div>
884
+ <div class="apdex-item">
885
+ <span class="apdex-label">Frustrated:</span>
886
+ <span class="apdex-value frustrated">{{apdexScore.frustrated}}</span>
887
+ </div>
888
+ </div>
889
+ </div>
890
+
891
+ <!-- SLA Compliance Card -->
892
+ {{#if slaCompliance}}
893
+ <div class="quality-card">
894
+ <h3>SLA Compliance</h3>
895
+ <div class="sla-status {{#if slaCompliance.passed}}sla-passed{{else}}sla-failed{{/if}}">
896
+ {{#if slaCompliance.passed}}PASSED{{else}}FAILED{{/if}}
897
+ </div>
898
+ <div class="sla-summary">{{slaCompliance.summary}}</div>
899
+ <div class="sla-checks">
900
+ {{#each slaCompliance.checks}}
901
+ <div class="sla-check {{#if passed}}check-passed{{else}}check-failed{{/if}}">
902
+ <span class="check-icon">{{#if passed}}✓{{else}}✗{{/if}}</span>
903
+ <span class="check-name">{{name}}</span>
904
+ <span class="check-values">{{actual}}{{unit}} / {{target}}{{unit}}</span>
905
+ </div>
906
+ {{/each}}
907
+ </div>
908
+ </div>
909
+ {{/if}}
910
+ </div>
911
+
912
+ <!-- Confidence Interval -->
913
+ {{#if confidenceInterval}}
914
+ <div class="confidence-section">
915
+ <h4>Response Time Confidence Interval (95%)</h4>
916
+ <div class="confidence-bar">
917
+ <span class="ci-lower">{{confidenceInterval.lower}}ms</span>
918
+ <span class="ci-mean">{{confidenceInterval.mean}}ms ± {{confidenceInterval.marginOfError}}ms</span>
919
+ <span class="ci-upper">{{confidenceInterval.upper}}ms</span>
920
+ </div>
921
+ </div>
922
+ {{/if}}
923
+
924
+ <!-- Outlier Analysis -->
925
+ {{#if outlierAnalysis}}
926
+ {{#if outlierAnalysis.outlierCount}}
927
+ <div class="outlier-section">
928
+ <h4>Outlier Analysis</h4>
929
+ <div class="outlier-summary">
930
+ <span class="outlier-count">{{outlierAnalysis.outlierCount}} outliers detected</span>
931
+ <span class="outlier-percentage">({{outlierAnalysis.outlierPercentage}}%)</span>
932
+ <span class="outlier-bounds">Normal range: {{outlierAnalysis.lowerBound}}ms - {{outlierAnalysis.upperBound}}ms</span>
933
+ </div>
934
+ </div>
935
+ {{/if}}
936
+ {{/if}}
937
+
938
+ <!-- Performance Trend -->
939
+ {{#if performanceTrends}}
940
+ <div class="trend-section">
941
+ <h4>Performance Trend</h4>
942
+ <div class="trend-indicator {{performanceTrends.trend}}">
943
+ {{#ifEquals performanceTrends.trend "improving"}}↓ Improving{{/ifEquals}}
944
+ {{#ifEquals performanceTrends.trend "degrading"}}↑ Degrading{{/ifEquals}}
945
+ {{#ifEquals performanceTrends.trend "stable"}}→ Stable{{/ifEquals}}
946
+ {{#ifEquals performanceTrends.trend "insufficient_data"}}— Insufficient Data{{/ifEquals}}
947
+ </div>
948
+ </div>
949
+ {{/if}}
950
+ </div>
951
+ </div>
952
+ {{/if}}
953
+
954
+ <!-- NEW: Response Time Distribution -->
955
+ <div class="section">
956
+ <div class="section-header">
957
+ <h2 class="section-title">Response Time Distribution</h2>
958
+ </div>
959
+ <div class="section-content">
960
+ <div class="chart-container">
961
+ <canvas id="responseTimeDistributionChart"></canvas>
962
+ </div>
963
+ </div>
964
+ </div>
965
+
966
+ <!-- NEW: Throughput Charts -->
967
+ <div class="section">
968
+ <div class="section-header">
969
+ <h2 class="section-title">Throughput Analysis</h2>
970
+ </div>
971
+ <div class="section-content">
972
+ <div class="grid-2">
973
+ <div class="chart-container">
974
+ <canvas id="requestsPerSecondChart"></canvas>
975
+ </div>
976
+ <div class="chart-container">
977
+ <canvas id="responsesPerSecondChart"></canvas>
978
+ </div>
979
+ </div>
980
+ </div>
981
+ </div>
982
+
983
+ <!-- VU Ramp-up Chart -->
984
+ <div class="section">
985
+ <div class="section-header">
986
+ <h2 class="section-title">Virtual User Ramp-up</h2>
987
+ </div>
988
+ <div class="section-content">
989
+ <div class="chart-container">
990
+ <canvas id="vuRampupChart"></canvas>
991
+ </div>
992
+ </div>
993
+ </div>
994
+
995
+ <!-- Network Timing Analysis -->
996
+ <div class="section">
997
+ <div class="section-header">
998
+ <h2 class="section-title">Network Timing Analysis</h2>
999
+ </div>
1000
+ <div class="section-content">
1001
+ <div class="grid-2">
1002
+ <div class="chart-container">
1003
+ <canvas id="connectTimeChart"></canvas>
1004
+ </div>
1005
+ <div class="chart-container">
1006
+ <canvas id="latencyChart"></canvas>
1007
+ </div>
1008
+ </div>
1009
+ </div>
1010
+ </div>
1011
+
1012
+ {{#if errorAnalysis.errorDetails}}
1013
+ {{#if errorAnalysis.errorDetails.length}}
1014
+ <!-- Error Details Table -->
1015
+ <div class="section">
1016
+ <div class="section-header">
1017
+ <h2 class="section-title">Errors by Sample/Request</h2>
1018
+ <div class="section-subtitle">
1019
+ Total Errors: <span class="metric-error">{{errorAnalysis.totalErrors}}</span>
1020
+ ({{errorAnalysis.errorRate}}% of all requests)
1021
+ </div>
1022
+ </div>
1023
+ <div class="section-content">
1024
+ <div class="table-container">
1025
+ <table class="data-table">
1026
+ <thead>
1027
+ <tr>
1028
+ <th>Sample/Request</th>
1029
+ <th>Method</th>
1030
+ <th>URL</th>
1031
+ <th>Status</th>
1032
+ <th>Error Type</th>
1033
+ <th>Error Count</th>
1034
+ <th>Total Requests</th>
1035
+ <th>Error %</th>
1036
+ </tr>
1037
+ </thead>
1038
+ <tbody>
1039
+ {{#each errorAnalysis.errorDetails}}
1040
+ <tr>
1041
+ <td><strong>{{sample_name}}</strong></td>
1042
+ <td>{{request_method}}</td>
1043
+ <td class="url-cell" title="{{request_url}}">{{request_url}}</td>
1044
+ <td><span class="status-badge status-error">{{status}}</span></td>
1045
+ <td class="error-cell" title="{{error_type}}">{{error_type}}</td>
1046
+ <td>{{error_count}}</td>
1047
+ <td>{{total_sample_requests}}</td>
1048
+ <td><strong>{{percentage}}%</strong></td>
1049
+ </tr>
1050
+ {{/each}}
1051
+ </tbody>
1052
+ </table>
1053
+ </div>
1054
+ </div>
1055
+ </div>
1056
+ {{/if}}
1057
+ {{/if}}
1058
+
1059
+ {{#if summary.web_vitals_data}}
1060
+ <!-- Core Web Vitals Section -->
1061
+ <div class="section vitals-section">
1062
+ <div class="section-header">
1063
+ <h2 class="section-title">Core Web Vitals Performance</h2>
1064
+ <span class="vitals-score {{vitalsScoreClass summary.vitals_score}}">{{summary.vitals_score}}</span>
1065
+ </div>
1066
+ <div class="section-content">
1067
+ <div class="vitals-cards">
1068
+ {{#if summary.web_vitals_data.lcp}}
1069
+ <div class="vitals-card {{vitalsScoreClass summary.vitals_details.lcp.score}}">
1070
+ <h3>LCP</h3>
1071
+ <div class="vitals-value">{{formatVitalsMetric summary.web_vitals_data.lcp 'lcp'}}</div>
1072
+ <div class="vitals-description">Largest Contentful Paint</div>
1073
+ </div>
1074
+ {{/if}}
1075
+
1076
+ {{#if summary.web_vitals_data.cls}}
1077
+ <div class="vitals-card {{vitalsScoreClass summary.vitals_details.cls.score}}">
1078
+ <h3>CLS</h3>
1079
+ <div class="vitals-value">{{formatVitalsMetric summary.web_vitals_data.cls 'cls'}}</div>
1080
+ <div class="vitals-description">Cumulative Layout Shift</div>
1081
+ </div>
1082
+ {{/if}}
1083
+
1084
+ {{#if summary.web_vitals_data.inp}}
1085
+ <div class="vitals-card {{vitalsScoreClass summary.vitals_details.inp.score}}">
1086
+ <h3>INP</h3>
1087
+ <div class="vitals-value">{{formatVitalsMetric summary.web_vitals_data.inp 'inp'}}</div>
1088
+ <div class="vitals-description">Interaction to Next Paint</div>
1089
+ </div>
1090
+ {{/if}}
1091
+
1092
+ {{#if summary.web_vitals_data.ttfb}}
1093
+ <div class="vitals-card {{vitalsScoreClass summary.vitals_details.ttfb.score}}">
1094
+ <h3>TTFB</h3>
1095
+ <div class="vitals-value">{{formatVitalsMetric summary.web_vitals_data.ttfb 'ttfb'}}</div>
1096
+ <div class="vitals-description">Time to First Byte</div>
1097
+ </div>
1098
+ {{/if}}
1099
+
1100
+ {{#if summary.web_vitals_data.fcp}}
1101
+ <div class="vitals-card {{vitalsScoreClass summary.vitals_details.fcp.score}}">
1102
+ <h3>FCP</h3>
1103
+ <div class="vitals-value">{{formatVitalsMetric summary.web_vitals_data.fcp 'fcp'}}</div>
1104
+ <div class="vitals-description">First Contentful Paint</div>
1105
+ </div>
1106
+ {{/if}}
1107
+ </div>
1108
+
1109
+ <!-- Web Vitals Chart -->
1110
+ <div class="chart-container">
1111
+ <canvas id="webVitalsChart"></canvas>
1112
+ </div>
1113
+ </div>
1114
+ </div>
1115
+ {{/if}}
1116
+
1117
+ {{#if webVitalsCharts}}
1118
+ <!-- Enhanced Web Vitals Analysis (Playwright Tests) -->
1119
+ <div class="section">
1120
+ <div class="section-header">
1121
+ <h2 class="section-title">Web Vitals Detailed Analysis</h2>
1122
+ </div>
1123
+ <div class="section-content">
1124
+ <!-- Score Distribution -->
1125
+ <div class="metrics-grid">
1126
+ <div class="metric-card">
1127
+ <h3>Performance Score Distribution</h3>
1128
+ <div class="chart-container small">
1129
+ <canvas id="webVitalsScoreChart"></canvas>
1130
+ </div>
1131
+ </div>
1132
+ </div>
1133
+
1134
+ <!-- Combined Web Vitals Timeline -->
1135
+ <div class="section-header" style="margin-top: 30px;">
1136
+ <h3>Web Vitals Timeline (All Metrics)</h3>
1137
+ <p style="color: #666; font-size: 14px;">Interactive timeline showing LCP, CLS, INP, TTFB, FCP, and FID values across all URLs</p>
1138
+ </div>
1139
+ <div class="chart-container">
1140
+ <canvas id="webVitalsTimelineChart"></canvas>
1141
+ </div>
1142
+
1143
+ <!-- Individual Metric Charts -->
1144
+ <div class="metrics-grid" style="margin-top: 30px;">
1145
+ <!-- Time Series for Each Metric -->
1146
+ {{#each webVitalsCharts.metrics}}
1147
+ <div class="metric-card">
1148
+ <h3>{{this}} Over Time</h3>
1149
+ <div class="chart-container small">
1150
+ <canvas id="webVitals{{this}}Chart"></canvas>
1151
+ </div>
1152
+ </div>
1153
+ {{/each}}
1154
+ </div>
1155
+
1156
+ <!-- Percentile Distribution -->
1157
+ <div class="section-header" style="margin-top: 30px;">
1158
+ <h3>Web Vitals Percentile Distribution</h3>
1159
+ </div>
1160
+ <div class="chart-container">
1161
+ <canvas id="webVitalsPercentilesChart"></canvas>
1162
+ </div>
1163
+
1164
+ <!-- Page-by-Page Analysis -->
1165
+ {{#if webVitalsCharts.pageAnalysis}}
1166
+ <div class="section-header" style="margin-top: 30px;">
1167
+ <h3>Performance by Page</h3>
1168
+ </div>
1169
+ <div class="table-container">
1170
+ <table>
1171
+ <thead>
1172
+ <tr>
1173
+ <th>Page URL</th>
1174
+ <th>Measurements</th>
1175
+ <th>Avg Score</th>
1176
+ <th>LCP (ms)</th>
1177
+ <th>CLS</th>
1178
+ <th>INP (ms)</th>
1179
+ <th>TTFB (ms)</th>
1180
+ <th>FCP (ms)</th>
1181
+ </tr>
1182
+ </thead>
1183
+ <tbody>
1184
+ {{#each webVitalsCharts.pageAnalysis}}
1185
+ <tr>
1186
+ <td>{{this.url}}</td>
1187
+ <td>{{this.measurements}}</td>
1188
+ <td><span class="{{vitalsScoreClass this.avgScore}}">{{this.avgScore}}</span></td>
1189
+ <td>{{#if this.metrics.lcp}}{{formatVitalsMetric this.metrics.lcp.avg 'lcp'}}{{else}}-{{/if}}</td>
1190
+ <td>{{#if this.metrics.cls}}{{formatVitalsMetric this.metrics.cls.avg 'cls'}}{{else}}-{{/if}}</td>
1191
+ <td>{{#if this.metrics.inp}}{{formatVitalsMetric this.metrics.inp.avg 'inp'}}{{else}}-{{/if}}</td>
1192
+ <td>{{#if this.metrics.ttfb}}{{formatVitalsMetric this.metrics.ttfb.avg 'ttfb'}}{{else}}-{{/if}}</td>
1193
+ <td>{{#if this.metrics.fcp}}{{formatVitalsMetric this.metrics.fcp.avg 'fcp'}}{{else}}-{{/if}}</td>
1194
+ </tr>
1195
+ {{/each}}
1196
+ </tbody>
1197
+ </table>
1198
+ </div>
1199
+ {{/if}}
1200
+ </div>
1201
+ </div>
1202
+ {{/if}}
1203
+
1204
+ {{#if summary.verification_metrics}}
1205
+ <!-- Verification Metrics Section -->
1206
+ <div class="section">
1207
+ <div class="section-header">
1208
+ <h2 class="section-title">Verification Performance</h2>
1209
+ </div>
1210
+ <div class="section-content">
1211
+ <div class="verification-metrics">
1212
+ <div class="verification-stats">
1213
+ <div class="verification-stat">
1214
+ <div class="verification-stat-value">{{summary.verification_metrics.total_verifications}}</div>
1215
+ <div class="verification-stat-label">Total Verifications</div>
1216
+ </div>
1217
+ <div class="verification-stat">
1218
+ <div class="verification-stat-value">{{percent summary.verification_metrics.success_rate 1}}</div>
1219
+ <div class="verification-stat-label">Success Rate</div>
1220
+ </div>
1221
+ <div class="verification-stat">
1222
+ <div class="verification-stat-value">{{formatVerificationDuration summary.verification_metrics.average_duration}}</div>
1223
+ <div class="verification-stat-label">Avg Duration</div>
1224
+ </div>
1225
+ <div class="verification-stat">
1226
+ <div class="verification-stat-value">{{formatVerificationDuration summary.verification_metrics.p95_duration}}</div>
1227
+ <div class="verification-stat-label">95th Percentile</div>
1228
+ </div>
1229
+ </div>
1230
+
1231
+ <!-- Verification Performance Chart -->
1232
+ <div class="chart-container" style="margin-top: 20px;">
1233
+ <canvas id="verificationChart"></canvas>
1234
+ </div>
1235
+ </div>
1236
+ </div>
1237
+ </div>
1238
+ {{/if}}
1239
+
1240
+ <!-- Response Time Analysis -->
1241
+ <div class="section">
1242
+ <div class="section-header">
1243
+ <h2 class="section-title">Response Time Analysis</h2>
1244
+ </div>
1245
+ <div class="section-content">
1246
+ <div class="tabs">
1247
+ <button class="tab active" onclick="showTab('timeline')">Timeline</button>
1248
+ <button class="tab" onclick="showTab('by-step')">By Step</button>
1249
+ <button class="tab" onclick="showTab('distribution')">Distribution</button>
1250
+ </div>
1251
+
1252
+ <div id="timeline" class="tab-content active">
1253
+ <div class="chart-container large">
1254
+ <canvas id="timelineChart"></canvas>
1255
+ </div>
1256
+ </div>
1257
+
1258
+ <div id="by-step" class="tab-content">
1259
+ <div class="chart-container large">
1260
+ <canvas id="stepResponseTimeChart"></canvas>
1261
+ </div>
1262
+ </div>
1263
+
1264
+ <div id="distribution" class="tab-content">
1265
+ <div class="chart-container">
1266
+ <canvas id="responseDistributionChart"></canvas>
1267
+ </div>
1268
+ </div>
1269
+ </div>
1270
+ </div>
1271
+
1272
+ <!-- Enhanced Step Statistics -->
1273
+ <div class="section">
1274
+ <div class="chart-controls" style="margin-bottom: 15px;">
1275
+ <button onclick="resetZoom()" style="padding: 8px 16px; background: #2563eb; color: white; border: none; border-radius: 6px; cursor: pointer;">
1276
+ Reset Zoom
1277
+ </button>
1278
+ </div>
1279
+ <div class="chart-container">
1280
+ <canvas id="stepLinesChart"></canvas>
1281
+ </div>
1282
+ <div class="section-header">
1283
+ <h2 class="section-title">Step Performance Statistics</h2>
1284
+ </div>
1285
+ <div class="section-content">
1286
+ <table class="step-stats-table">
1287
+ <thead>
1288
+ <tr>
1289
+ <th>Step Name</th>
1290
+ <th>Scenario</th>
1291
+ <th>Requests</th>
1292
+ <th>Success Rate</th>
1293
+ <th>Min (ms)</th>
1294
+ <th>Avg (ms)</th>
1295
+ <th>Median (ms)</th>
1296
+ <th>Max (ms)</th>
1297
+ <th>P90</th>
1298
+ <th>P95</th>
1299
+ <th>P99</th>
1300
+ <th>P99.9</th>
1301
+ <th>P99.99</th>
1302
+ <th>Status</th>
1303
+ </tr>
1304
+ </thead>
1305
+ <tbody>
1306
+ {{#each stepStatistics}}
1307
+ <tr>
1308
+ <td><strong>{{step_name}}</strong></td>
1309
+ <td>{{scenario}}</td>
1310
+ <td>{{total_requests}}</td>
1311
+ <td>{{toFixed success_rate 1}}%</td>
1312
+ <td>{{toFixed min_response_time 1}}</td>
1313
+ <td>{{toFixed avg_response_time 1}}</td>
1314
+ <td>{{lookup percentiles 50}}</td>
1315
+ <td>{{toFixed max_response_time 1}}</td>
1316
+ <td>{{lookup percentiles 90}}</td>
1317
+ <td>{{lookup percentiles 95}}</td>
1318
+ <td>{{lookup percentiles 99}}</td>
1319
+ <td>{{lookup percentiles 99.9}}</td>
1320
+ <td>{{lookup percentiles 99.99}}</td>
1321
+ <td>
1322
+ <span
1323
+ class="status-badge {{#if (gt success_rate 95)}}status-success{{else}}{{#if (gt success_rate 90)}}status-warning{{else}}status-error{{/if}}{{/if}}">
1324
+ {{#if (gt success_rate 95)}}Good{{else}}{{#if (gt success_rate
1325
+ 90)}}Warning{{else}}Error{{/if}}{{/if}}
1326
+ </span>
1327
+ </td>
1328
+ </tr>
1329
+ {{/each}}
1330
+ </tbody>
1331
+ </table>
1332
+ </div>
1333
+ </div>
1334
+
1335
+ <!-- Step Percentiles Comparison -->
1336
+ <div class="section">
1337
+ <div class="section-header">
1338
+ <h2 class="section-title">Step Percentile Comparison</h2>
1339
+ </div>
1340
+ <div class="section-content">
1341
+ <div class="grid-2">
1342
+ <div class="chart-container">
1343
+ <canvas id="stepPercentilesChart"></canvas>
1344
+ </div>
1345
+ <div class="chart-container">
1346
+ <canvas id="stepThroughputChart"></canvas>
1347
+ </div>
1348
+ </div>
1349
+ </div>
1350
+ </div>
1351
+
1352
+ <!-- Performance Timeline -->
1353
+ <div class="section">
1354
+ <div class="section-header">
1355
+ <h2 class="section-title">Performance Timeline</h2>
1356
+ </div>
1357
+ <div class="section-content">
1358
+ <div class="chart-container large">
1359
+ <canvas id="performanceTimelineChart"></canvas>
1360
+ </div>
1361
+ </div>
1362
+ </div>
1363
+
1364
+ <!-- Footer -->
1365
+ <div class="footer">
1366
+ <p>Generated by Perfornium Performance Testing Framework</p>
1367
+ </div>
1368
+ </div>
1369
+
1370
+ <script>
1371
+ // Theme toggle functionality
1372
+ function toggleTheme() {
1373
+ const html = document.documentElement;
1374
+ const currentTheme = html.getAttribute('data-theme');
1375
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
1376
+
1377
+ html.setAttribute('data-theme', newTheme);
1378
+ localStorage.setItem('theme', newTheme);
1379
+
1380
+ // Update button text and icon
1381
+ const themeIcon = document.getElementById('themeIcon');
1382
+ const themeText = document.getElementById('themeText');
1383
+
1384
+ if (newTheme === 'dark') {
1385
+ themeIcon.textContent = '☀️';
1386
+ themeText.textContent = 'Light Mode';
1387
+ } else {
1388
+ themeIcon.textContent = '🌙';
1389
+ themeText.textContent = 'Dark Mode';
1390
+ }
1391
+ }
1392
+
1393
+ // Load theme from localStorage on page load
1394
+ (function() {
1395
+ const savedTheme = localStorage.getItem('theme') || 'light';
1396
+ const html = document.documentElement;
1397
+ html.setAttribute('data-theme', savedTheme);
1398
+
1399
+ // Update button on load
1400
+ const themeIcon = document.getElementById('themeIcon');
1401
+ const themeText = document.getElementById('themeText');
1402
+
1403
+ if (savedTheme === 'dark') {
1404
+ themeIcon.textContent = '☀️';
1405
+ themeText.textContent = 'Light Mode';
1406
+ }
1407
+ })();
1408
+
1409
+ // Chart data from server
1410
+ const summaryData = {{{ summaryData }}};
1411
+ const stepStatistics = {{{ stepStatisticsData }}};
1412
+ const vuRampupData = {{{ vuRampupData }}};
1413
+ const timelineData = {{{ timelineData }}};
1414
+ const responseTimeDistributionData = {{{ responseTimeDistributionData }}};
1415
+ const requestsPerSecondData = {{{ requestsPerSecondData }}};
1416
+ const responsesPerSecondData = {{{ responsesPerSecondData }}};
1417
+ const connectTimeData = {{{ connectTimeData }}};
1418
+ const latencyData = {{{ latencyData }}};
1419
+
1420
+ // Tab functionality
1421
+ function showTab(tabName) {
1422
+ // Hide all tab contents
1423
+ document.querySelectorAll('.tab-content').forEach(content => {
1424
+ content.classList.remove('active');
1425
+ });
1426
+
1427
+ // Remove active from all tabs
1428
+ document.querySelectorAll('.tab').forEach(tab => {
1429
+ tab.classList.remove('active');
1430
+ });
1431
+
1432
+ // Show selected tab content
1433
+ document.getElementById(tabName).classList.add('active');
1434
+
1435
+ // Add active to clicked tab
1436
+ event.target.classList.add('active');
1437
+ }
1438
+
1439
+ // NEW: Response Time Distribution Chart
1440
+ const responseDistCtx = document.getElementById('responseTimeDistributionChart').getContext('2d');
1441
+ new Chart(responseDistCtx, {
1442
+ type: 'bar',
1443
+ data: {
1444
+ labels: responseTimeDistributionData.map(d => d.bucket),
1445
+ datasets: [{
1446
+ label: 'Request Count',
1447
+ data: responseTimeDistributionData.map(d => d.count),
1448
+ backgroundColor: 'rgba(37, 99, 235, 0.6)',
1449
+ borderColor: 'rgba(37, 99, 235, 1)',
1450
+ borderWidth: 1
1451
+ }, {
1452
+ label: 'Percentage',
1453
+ data: responseTimeDistributionData.map(d => d.percentage),
1454
+ type: 'line',
1455
+ borderColor: 'rgba(239, 68, 68, 1)',
1456
+ backgroundColor: 'rgba(239, 68, 68, 0.1)',
1457
+ yAxisID: 'y1',
1458
+ tension: 0.4
1459
+ }]
1460
+ },
1461
+ options: {
1462
+ responsive: true,
1463
+ maintainAspectRatio: false,
1464
+ plugins: {
1465
+ title: {
1466
+ display: true,
1467
+ text: 'Overall Response Time Distribution'
1468
+ }
1469
+ },
1470
+ scales: {
1471
+ y: {
1472
+ beginAtZero: true,
1473
+ title: {
1474
+ display: true,
1475
+ text: 'Number of Requests'
1476
+ }
1477
+ },
1478
+ y1: {
1479
+ type: 'linear',
1480
+ display: true,
1481
+ position: 'right',
1482
+ title: {
1483
+ display: true,
1484
+ text: 'Percentage (%)'
1485
+ },
1486
+ grid: {
1487
+ drawOnChartArea: false,
1488
+ },
1489
+ min: 0,
1490
+ max: 100
1491
+ },
1492
+ x: {
1493
+ title: {
1494
+ display: true,
1495
+ text: 'Response Time Range'
1496
+ }
1497
+ }
1498
+ }
1499
+ }
1500
+ });
1501
+
1502
+ // NEW: Requests Per Second Chart
1503
+ const requestsPerSecCtx = document.getElementById('requestsPerSecondChart').getContext('2d');
1504
+ new Chart(requestsPerSecCtx, {
1505
+ type: 'line',
1506
+ data: {
1507
+ datasets: [{
1508
+ label: 'Total Requests/sec',
1509
+ data: requestsPerSecondData.map(d => ({
1510
+ x: new Date(d.timestamp),
1511
+ y: d.requests_per_second
1512
+ })),
1513
+ borderColor: '#2563eb',
1514
+ backgroundColor: 'rgba(37, 99, 235, 0.1)',
1515
+ fill: true,
1516
+ tension: 0.4
1517
+ }, {
1518
+ label: 'Successful Requests/sec',
1519
+ data: requestsPerSecondData.map(d => ({
1520
+ x: new Date(d.timestamp),
1521
+ y: d.successful_requests_per_second
1522
+ })),
1523
+ borderColor: '#10b981',
1524
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
1525
+ fill: true,
1526
+ tension: 0.4
1527
+ }]
1528
+ },
1529
+ options: {
1530
+ responsive: true,
1531
+ maintainAspectRatio: false,
1532
+ plugins: {
1533
+ title: {
1534
+ display: true,
1535
+ text: 'Requests Per Second Over Time'
1536
+ }
1537
+ },
1538
+ scales: {
1539
+ x: {
1540
+ type: 'time',
1541
+ time: {
1542
+ displayFormats: {
1543
+ second: 'HH:mm:ss'
1544
+ }
1545
+ },
1546
+ title: {
1547
+ display: true,
1548
+ text: 'Time'
1549
+ }
1550
+ },
1551
+ y: {
1552
+ beginAtZero: true,
1553
+ title: {
1554
+ display: true,
1555
+ text: 'Requests/Second'
1556
+ }
1557
+ }
1558
+ }
1559
+ }
1560
+ });
1561
+
1562
+ // NEW: Responses Per Second Chart
1563
+ const responsesPerSecCtx = document.getElementById('responsesPerSecondChart').getContext('2d');
1564
+ new Chart(responsesPerSecCtx, {
1565
+ type: 'line',
1566
+ data: {
1567
+ datasets: [{
1568
+ label: 'Successful Responses/sec',
1569
+ data: responsesPerSecondData.map(d => ({
1570
+ x: new Date(d.timestamp),
1571
+ y: d.responses_per_second
1572
+ })),
1573
+ borderColor: '#10b981',
1574
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
1575
+ fill: true,
1576
+ tension: 0.4
1577
+ }, {
1578
+ label: 'Error Responses/sec',
1579
+ data: responsesPerSecondData.map(d => ({
1580
+ x: new Date(d.timestamp),
1581
+ y: d.error_responses_per_second
1582
+ })),
1583
+ borderColor: '#ef4444',
1584
+ backgroundColor: 'rgba(239, 68, 68, 0.1)',
1585
+ fill: true,
1586
+ tension: 0.4
1587
+ }]
1588
+ },
1589
+ options: {
1590
+ responsive: true,
1591
+ maintainAspectRatio: false,
1592
+ plugins: {
1593
+ title: {
1594
+ display: true,
1595
+ text: 'Responses Per Second Over Time'
1596
+ }
1597
+ },
1598
+ scales: {
1599
+ x: {
1600
+ type: 'time',
1601
+ time: {
1602
+ displayFormats: {
1603
+ second: 'HH:mm:ss'
1604
+ }
1605
+ },
1606
+ title: {
1607
+ display: true,
1608
+ text: 'Time'
1609
+ }
1610
+ },
1611
+ y: {
1612
+ beginAtZero: true,
1613
+ title: {
1614
+ display: true,
1615
+ text: 'Responses/Second'
1616
+ }
1617
+ }
1618
+ }
1619
+ }
1620
+ });
1621
+
1622
+ // VU Ramp-up Chart - uses vuRampupData for accurate ramp-up visualization
1623
+ const vuRampupCtx = document.getElementById('vuRampupChart').getContext('2d');
1624
+ new Chart(vuRampupCtx, {
1625
+ type: 'line',
1626
+ data: {
1627
+ datasets: [{
1628
+ label: 'Active Virtual Users',
1629
+ data: vuRampupData.map(d => ({
1630
+ x: new Date(d.timestamp),
1631
+ y: d.count
1632
+ })),
1633
+ borderColor: '#2563eb',
1634
+ backgroundColor: 'rgba(37, 99, 235, 0.1)',
1635
+ fill: true,
1636
+ tension: 0.1,
1637
+ stepped: true
1638
+ }]
1639
+ },
1640
+ options: {
1641
+ responsive: true,
1642
+ maintainAspectRatio: false,
1643
+ plugins: {
1644
+ title: {
1645
+ display: true,
1646
+ text: 'Virtual User Ramp-up Pattern'
1647
+ }
1648
+ },
1649
+ scales: {
1650
+ x: {
1651
+ type: 'time',
1652
+ time: {
1653
+ displayFormats: {
1654
+ second: 'HH:mm:ss'
1655
+ }
1656
+ },
1657
+ title: {
1658
+ display: true,
1659
+ text: 'Time'
1660
+ }
1661
+ },
1662
+ y: {
1663
+ beginAtZero: true,
1664
+ title: {
1665
+ display: true,
1666
+ text: 'Active Virtual Users'
1667
+ }
1668
+ }
1669
+ }
1670
+ }
1671
+ });
1672
+
1673
+ // Timeline Chart
1674
+ const timelineCtx = document.getElementById('timelineChart').getContext('2d');
1675
+ new Chart(timelineCtx, {
1676
+ type: 'line',
1677
+ data: {
1678
+ datasets: [
1679
+ {
1680
+ label: 'Avg Response Time (ms)',
1681
+ data: timelineData.map(d => ({
1682
+ x: new Date(d.timestamp),
1683
+ y: d.avg_response_time
1684
+ })),
1685
+ borderColor: '#10b981',
1686
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
1687
+ yAxisID: 'y',
1688
+ tension: 0.4
1689
+ },
1690
+ {
1691
+ label: 'Success Rate (%)',
1692
+ data: timelineData.map(d => ({
1693
+ x: new Date(d.timestamp),
1694
+ y: d.success_rate
1695
+ })),
1696
+ borderColor: '#f59e0b',
1697
+ backgroundColor: 'rgba(245, 158, 11, 0.1)',
1698
+ yAxisID: 'y1',
1699
+ tension: 0.4
1700
+ }
1701
+ ]
1702
+ },
1703
+ options: {
1704
+ responsive: true,
1705
+ maintainAspectRatio: false,
1706
+ plugins: {
1707
+ title: {
1708
+ display: true,
1709
+ text: 'Performance Timeline'
1710
+ }
1711
+ },
1712
+ scales: {
1713
+ x: {
1714
+ type: 'time',
1715
+ time: {
1716
+ displayFormats: {
1717
+ second: 'HH:mm:ss'
1718
+ }
1719
+ }
1720
+ },
1721
+ y: {
1722
+ type: 'linear',
1723
+ display: true,
1724
+ position: 'left',
1725
+ title: {
1726
+ display: true,
1727
+ text: 'Response Time (ms)'
1728
+ }
1729
+ },
1730
+ y1: {
1731
+ type: 'linear',
1732
+ display: true,
1733
+ position: 'right',
1734
+ title: {
1735
+ display: true,
1736
+ text: 'Success Rate (%)'
1737
+ },
1738
+ grid: {
1739
+ drawOnChartArea: false,
1740
+ },
1741
+ min: 0,
1742
+ max: 100
1743
+ }
1744
+ }
1745
+ }
1746
+ });
1747
+
1748
+ // Step Response Time Chart
1749
+ const stepResponseCtx = document.getElementById('stepResponseTimeChart').getContext('2d');
1750
+ new Chart(stepResponseCtx, {
1751
+ type: 'bar',
1752
+ data: {
1753
+ labels: stepStatistics.map(s => s.step_name),
1754
+ datasets: [
1755
+ {
1756
+ label: 'P50',
1757
+ data: stepStatistics.map(s => s.percentiles[50] || 0),
1758
+ backgroundColor: 'rgba(37, 99, 235, 0.6)'
1759
+ },
1760
+ {
1761
+ label: 'P90',
1762
+ data: stepStatistics.map(s => s.percentiles[90] || 0),
1763
+ backgroundColor: 'rgba(16, 185, 129, 0.6)'
1764
+ },
1765
+ {
1766
+ label: 'P95',
1767
+ data: stepStatistics.map(s => s.percentiles[95] || 0),
1768
+ backgroundColor: 'rgba(245, 158, 11, 0.6)'
1769
+ },
1770
+ {
1771
+ label: 'P99',
1772
+ data: stepStatistics.map(s => s.percentiles[99] || 0),
1773
+ backgroundColor: 'rgba(239, 68, 68, 0.6)'
1774
+ }
1775
+ ]
1776
+ },
1777
+ options: {
1778
+ responsive: true,
1779
+ maintainAspectRatio: false,
1780
+ plugins: {
1781
+ title: {
1782
+ display: true,
1783
+ text: 'Response Time Percentiles by Step'
1784
+ }
1785
+ },
1786
+ scales: {
1787
+ y: {
1788
+ beginAtZero: true,
1789
+ title: {
1790
+ display: true,
1791
+ text: 'Response Time (ms)'
1792
+ }
1793
+ }
1794
+ }
1795
+ }
1796
+ });
1797
+
1798
+ // Step Percentiles Chart - horizontal bar for better readability
1799
+ const stepPercentilesCtx = document.getElementById('stepPercentilesChart').getContext('2d');
1800
+ if (stepStatistics && stepStatistics.length > 0) {
1801
+ // Sort by P95 to show slowest steps first
1802
+ const sortedSteps = [...stepStatistics].sort((a, b) =>
1803
+ (b.percentiles[95] || 0) - (a.percentiles[95] || 0)
1804
+ ).slice(0, 8);
1805
+
1806
+ new Chart(stepPercentilesCtx, {
1807
+ type: 'bar',
1808
+ data: {
1809
+ labels: sortedSteps.map(s => s.step_name),
1810
+ datasets: [
1811
+ {
1812
+ label: 'P50',
1813
+ data: sortedSteps.map(s => s.percentiles[50] || 0),
1814
+ backgroundColor: 'rgba(37, 99, 235, 0.7)'
1815
+ },
1816
+ {
1817
+ label: 'P95',
1818
+ data: sortedSteps.map(s => s.percentiles[95] || 0),
1819
+ backgroundColor: 'rgba(245, 158, 11, 0.7)'
1820
+ },
1821
+ {
1822
+ label: 'P99',
1823
+ data: sortedSteps.map(s => s.percentiles[99] || 0),
1824
+ backgroundColor: 'rgba(239, 68, 68, 0.7)'
1825
+ }
1826
+ ]
1827
+ },
1828
+ options: {
1829
+ indexAxis: 'y',
1830
+ responsive: true,
1831
+ maintainAspectRatio: false,
1832
+ plugins: {
1833
+ title: {
1834
+ display: true,
1835
+ text: 'Response Time Percentiles (Slowest Steps)'
1836
+ }
1837
+ },
1838
+ scales: {
1839
+ x: {
1840
+ beginAtZero: true,
1841
+ title: {
1842
+ display: true,
1843
+ text: 'Response Time (ms)'
1844
+ }
1845
+ }
1846
+ }
1847
+ }
1848
+ });
1849
+ }
1850
+
1851
+ // Step Throughput Chart
1852
+ const stepThroughputCtx = document.getElementById('stepThroughputChart').getContext('2d');
1853
+ if (stepStatistics && stepStatistics.length > 0) {
1854
+ new Chart(stepThroughputCtx, {
1855
+ type: 'doughnut',
1856
+ data: {
1857
+ labels: stepStatistics.map(s => s.step_name),
1858
+ datasets: [{
1859
+ data: stepStatistics.map(s => s.total_requests),
1860
+ backgroundColor: stepStatistics.map((_, index) =>
1861
+ `hsl(${index * 137.5 % 360}, 70%, 50%)`
1862
+ )
1863
+ }]
1864
+ },
1865
+ options: {
1866
+ responsive: true,
1867
+ maintainAspectRatio: false,
1868
+ plugins: {
1869
+ title: {
1870
+ display: true,
1871
+ text: 'Request Distribution by Step'
1872
+ }
1873
+ }
1874
+ }
1875
+ });
1876
+ }
1877
+
1878
+ let stepLinesChart;
1879
+ const stepResponseTimes = {{{stepResponseTimesData}}};
1880
+ const stepLinesCtx = document.getElementById('stepLinesChart').getContext('2d');
1881
+
1882
+ function resetZoom() {
1883
+ if (stepLinesChart) {
1884
+ stepLinesChart.resetZoom();
1885
+ }
1886
+ }
1887
+
1888
+ // Create datasets - one line per step with ALL individual response times
1889
+ // Use test start time from timeline data for fallback timestamps
1890
+ const testStartTime = timelineData.length > 0 ? new Date(timelineData[0].timestamp).getTime() : Date.now();
1891
+
1892
+ const datasets = (stepResponseTimes || []).filter(step => step.response_times && step.response_times.length > 0).map((step, index) => {
1893
+ // Use timeline_data if available, otherwise create timestamps relative to test start
1894
+ const timelineDataPoints = step.timeline_data || step.response_times.map((responseTime, idx) => ({
1895
+ duration: responseTime,
1896
+ timestamp: testStartTime + (idx * 100), // 100ms intervals from test start
1897
+ vu_id: Math.floor(idx / 10) + 1,
1898
+ iteration: (idx % 10) + 1
1899
+ }));
1900
+
1901
+ return {
1902
+ label: step.step_name,
1903
+ data: timelineDataPoints.map(point => ({
1904
+ x: new Date(point.timestamp),
1905
+ y: point.duration
1906
+ })),
1907
+ borderColor: `hsl(${index * 360 / Math.max(stepResponseTimes.length, 1)}, 70%, 50%)`,
1908
+ backgroundColor: `hsla(${index * 360 / Math.max(stepResponseTimes.length, 1)}, 70%, 50%, 0.1)`,
1909
+ fill: false,
1910
+ tension: 0,
1911
+ pointRadius: 2,
1912
+ pointHoverRadius: 4,
1913
+ borderWidth: 2,
1914
+ pointBackgroundColor: `hsl(${index * 360 / Math.max(stepResponseTimes.length, 1)}, 70%, 50%)`,
1915
+ pointBorderColor: `hsl(${index * 360 / Math.max(stepResponseTimes.length, 1)}, 70%, 40%)`
1916
+ };
1917
+ });
1918
+
1919
+ if (datasets.length > 0) {
1920
+ stepLinesChart = new Chart(stepLinesCtx, {
1921
+ type: 'line',
1922
+ data: {
1923
+ datasets: datasets
1924
+ },
1925
+ options: {
1926
+ responsive: true,
1927
+ maintainAspectRatio: false,
1928
+ plugins: {
1929
+ title: {
1930
+ display: true,
1931
+ text: 'Response Times by Step - All Individual Results'
1932
+ },
1933
+ legend: {
1934
+ position: 'top',
1935
+ labels: {
1936
+ usePointStyle: true,
1937
+ boxWidth: 6
1938
+ }
1939
+ },
1940
+ tooltip: {
1941
+ mode: 'point',
1942
+ intersect: false,
1943
+ callbacks: {
1944
+ label: function(context) {
1945
+ const step = stepResponseTimes[context.datasetIndex];
1946
+ const timelineData = step.timeline_data || [];
1947
+ const dataPoint = timelineData[context.dataIndex];
1948
+
1949
+ if (dataPoint) {
1950
+ return [
1951
+ `${step.step_name}: ${context.parsed.y}ms`,
1952
+ `VU: ${dataPoint.vu_id}`,
1953
+ `Iteration: ${dataPoint.iteration}`
1954
+ ];
1955
+ } else {
1956
+ return `${step.step_name}: ${context.parsed.y}ms`;
1957
+ }
1958
+ },
1959
+ title: function(context) {
1960
+ return new Date(context[0].parsed.x).toLocaleTimeString();
1961
+ }
1962
+ }
1963
+ },
1964
+ zoom: {
1965
+ limits: {
1966
+ x: {min: 'original', max: 'original'},
1967
+ y: {min: 0, max: 'original'}
1968
+ },
1969
+ pan: {
1970
+ enabled: true,
1971
+ mode: 'xy',
1972
+ modifierKey: null,
1973
+ threshold: 10
1974
+ },
1975
+ zoom: {
1976
+ wheel: {
1977
+ enabled: true,
1978
+ speed: 0.1,
1979
+ modifierKey: null
1980
+ },
1981
+ pinch: {
1982
+ enabled: true
1983
+ },
1984
+ drag: {
1985
+ enabled: true,
1986
+ backgroundColor: 'rgba(37, 99, 235, 0.2)',
1987
+ borderColor: 'rgba(37, 99, 235, 0.8)',
1988
+ borderWidth: 2,
1989
+ threshold: 10,
1990
+ modifierKey: null
1991
+ },
1992
+ mode: 'xy'
1993
+ }
1994
+ }
1995
+ },
1996
+ scales: {
1997
+ x: {
1998
+ type: 'time',
1999
+ time: {
2000
+ displayFormats: {
2001
+ second: 'HH:mm:ss',
2002
+ minute: 'HH:mm'
2003
+ }
2004
+ },
2005
+ title: {
2006
+ display: true,
2007
+ text: 'Test Timeline'
2008
+ }
2009
+ },
2010
+ y: {
2011
+ beginAtZero: true,
2012
+ title: {
2013
+ display: true,
2014
+ text: 'Response Time (ms)'
2015
+ }
2016
+ }
2017
+ },
2018
+ interaction: {
2019
+ mode: 'point',
2020
+ intersect: false,
2021
+ },
2022
+ elements: {
2023
+ point: {
2024
+ hoverRadius: 6
2025
+ }
2026
+ },
2027
+ animation: {
2028
+ duration: 0 // Disable animation for better performance with many points
2029
+ }
2030
+ }
2031
+ });
2032
+ }
2033
+
2034
+ // Performance Timeline Chart
2035
+ const performanceTimelineCtx = document.getElementById('performanceTimelineChart').getContext('2d');
2036
+ if (timelineData && timelineData.length > 0) {
2037
+ new Chart(performanceTimelineCtx, {
2038
+ type: 'line',
2039
+ data: {
2040
+ datasets: [
2041
+ {
2042
+ label: 'Active VUs',
2043
+ data: timelineData.map(d => ({
2044
+ x: new Date(d.timestamp),
2045
+ y: d.active_vus || 0
2046
+ })),
2047
+ borderColor: '#8b5cf6',
2048
+ backgroundColor: 'rgba(139, 92, 246, 0.1)',
2049
+ yAxisID: 'y2',
2050
+ tension: 0.1
2051
+ },
2052
+ {
2053
+ label: 'Throughput (req/s)',
2054
+ data: timelineData.map(d => ({
2055
+ x: new Date(d.timestamp),
2056
+ y: d.throughput
2057
+ })),
2058
+ borderColor: '#06b6d4',
2059
+ backgroundColor: 'rgba(6, 182, 212, 0.1)',
2060
+ yAxisID: 'y',
2061
+ tension: 0.4
2062
+ },
2063
+ {
2064
+ label: 'Avg Response Time',
2065
+ data: timelineData.map(d => ({
2066
+ x: new Date(d.timestamp),
2067
+ y: d.avg_response_time
2068
+ })),
2069
+ borderColor: '#ef4444',
2070
+ backgroundColor: 'rgba(239, 68, 68, 0.1)',
2071
+ yAxisID: 'y1',
2072
+ tension: 0.4
2073
+ }
2074
+ ]
2075
+ },
2076
+ options: {
2077
+ responsive: true,
2078
+ maintainAspectRatio: false,
2079
+ plugins: {
2080
+ title: {
2081
+ display: true,
2082
+ text: 'Complete Performance Timeline'
2083
+ }
2084
+ },
2085
+ scales: {
2086
+ x: {
2087
+ type: 'time',
2088
+ time: {
2089
+ displayFormats: {
2090
+ second: 'HH:mm:ss'
2091
+ }
2092
+ }
2093
+ },
2094
+ y: {
2095
+ type: 'linear',
2096
+ display: true,
2097
+ position: 'left',
2098
+ title: {
2099
+ display: true,
2100
+ text: 'Throughput (req/s)'
2101
+ }
2102
+ },
2103
+ y1: {
2104
+ type: 'linear',
2105
+ display: true,
2106
+ position: 'right',
2107
+ title: {
2108
+ display: true,
2109
+ text: 'Response Time (ms)'
2110
+ },
2111
+ grid: {
2112
+ drawOnChartArea: false,
2113
+ }
2114
+ },
2115
+ y2: {
2116
+ type: 'linear',
2117
+ display: true,
2118
+ position: 'right',
2119
+ title: {
2120
+ display: true,
2121
+ text: 'Active VUs'
2122
+ },
2123
+ grid: {
2124
+ drawOnChartArea: false,
2125
+ },
2126
+ beginAtZero: true
2127
+ }
2128
+ }
2129
+ }
2130
+ });
2131
+ }
2132
+
2133
+ // Response Distribution Chart (in tab)
2134
+ const responseDistributionCtx = document.getElementById('responseDistributionChart').getContext('2d');
2135
+
2136
+ // Calculate response time distribution
2137
+ const responseTimes = [];
2138
+ stepStatistics.forEach(step => {
2139
+ step.response_times.forEach(time => {
2140
+ responseTimes.push(time);
2141
+ });
2142
+ });
2143
+
2144
+ // Create buckets for histogram with sensible sizing
2145
+ const distribution = [];
2146
+ if (responseTimes.length > 0) {
2147
+ const min = Math.min(...responseTimes);
2148
+ const max = Math.max(...responseTimes);
2149
+ const range = max - min;
2150
+
2151
+ if (range < 1) {
2152
+ distribution.push({ bucket: `${Math.round(min)}ms`, count: responseTimes.length });
2153
+ } else {
2154
+ // Calculate ideal bucket size based on range
2155
+ const targetBuckets = 10;
2156
+ let bucketSize = range / targetBuckets;
2157
+
2158
+ // Round to nice numbers
2159
+ if (bucketSize < 1) bucketSize = 1;
2160
+ else if (bucketSize < 2) bucketSize = 2;
2161
+ else if (bucketSize < 5) bucketSize = 5;
2162
+ else if (bucketSize < 10) bucketSize = 10;
2163
+ else if (bucketSize < 25) bucketSize = 25;
2164
+ else if (bucketSize < 50) bucketSize = 50;
2165
+ else if (bucketSize < 100) bucketSize = 100;
2166
+ else if (bucketSize < 250) bucketSize = 250;
2167
+ else if (bucketSize < 500) bucketSize = 500;
2168
+ else if (bucketSize < 1000) bucketSize = 1000;
2169
+ else bucketSize = Math.ceil(bucketSize / 1000) * 1000;
2170
+
2171
+ const bucketStart = Math.floor(min / bucketSize) * bucketSize;
2172
+ const bucketEnd = Math.ceil(max / bucketSize) * bucketSize;
2173
+ const numBuckets = Math.max(1, Math.round((bucketEnd - bucketStart) / bucketSize));
2174
+
2175
+ for (let i = 0; i < numBuckets; i++) {
2176
+ const start = bucketStart + (i * bucketSize);
2177
+ const end = start + bucketSize;
2178
+ const count = responseTimes.filter(time =>
2179
+ time >= start && (i === numBuckets - 1 ? time <= end : time < end)
2180
+ ).length;
2181
+
2182
+ distribution.push({ bucket: `${Math.round(start)}-${Math.round(end)}ms`, count });
2183
+ }
2184
+ }
2185
+ }
2186
+
2187
+ new Chart(responseDistributionCtx, {
2188
+ type: 'bar',
2189
+ data: {
2190
+ labels: distribution.map(d => d.bucket),
2191
+ datasets: [{
2192
+ label: 'Request Count',
2193
+ data: distribution.map(d => d.count),
2194
+ backgroundColor: 'rgba(37, 99, 235, 0.6)',
2195
+ borderColor: 'rgba(37, 99, 235, 1)',
2196
+ borderWidth: 1
2197
+ }]
2198
+ },
2199
+ options: {
2200
+ responsive: true,
2201
+ maintainAspectRatio: false,
2202
+ plugins: {
2203
+ title: {
2204
+ display: true,
2205
+ text: 'Response Time Distribution'
2206
+ }
2207
+ },
2208
+ scales: {
2209
+ y: {
2210
+ beginAtZero: true,
2211
+ title: {
2212
+ display: true,
2213
+ text: 'Number of Requests'
2214
+ }
2215
+ },
2216
+ x: {
2217
+ title: {
2218
+ display: true,
2219
+ text: 'Response Time Range'
2220
+ }
2221
+ }
2222
+ }
2223
+ }
2224
+ });
2225
+
2226
+ // Core Web Vitals Chart
2227
+ {{#if summary.web_vitals_data}}
2228
+ const webVitalsCtx = document.getElementById('webVitalsChart')?.getContext('2d');
2229
+ if (webVitalsCtx) {
2230
+ const vitalsData = {
2231
+ labels: [],
2232
+ values: [],
2233
+ thresholds: [],
2234
+ colors: []
2235
+ };
2236
+
2237
+ {{#if summary.web_vitals_data.lcp}}
2238
+ vitalsData.labels.push('LCP');
2239
+ vitalsData.values.push({{summary.web_vitals_data.lcp}});
2240
+ vitalsData.thresholds.push([2500, 4000]);
2241
+ vitalsData.colors.push('{{#ifEquals summary.vitals_details.lcp.score "good"}}#10b981{{else}}{{#ifEquals summary.vitals_details.lcp.score "needs-improvement"}}#f59e0b{{else}}#ef4444{{/ifEquals}}{{/ifEquals}}');
2242
+ {{/if}}
2243
+
2244
+ {{#if summary.web_vitals_data.cls}}
2245
+ vitalsData.labels.push('CLS');
2246
+ vitalsData.values.push({{summary.web_vitals_data.cls}});
2247
+ vitalsData.thresholds.push([0.1, 0.25]);
2248
+ vitalsData.colors.push('{{#ifEquals summary.vitals_details.cls.score "good"}}#10b981{{else}}{{#ifEquals summary.vitals_details.cls.score "needs-improvement"}}#f59e0b{{else}}#ef4444{{/ifEquals}}{{/ifEquals}}');
2249
+ {{/if}}
2250
+
2251
+ {{#if summary.web_vitals_data.inp}}
2252
+ vitalsData.labels.push('INP');
2253
+ vitalsData.values.push({{summary.web_vitals_data.inp}});
2254
+ vitalsData.thresholds.push([200, 500]);
2255
+ vitalsData.colors.push('{{#ifEquals summary.vitals_details.inp.score "good"}}#10b981{{else}}{{#ifEquals summary.vitals_details.inp.score "needs-improvement"}}#f59e0b{{else}}#ef4444{{/ifEquals}}{{/ifEquals}}');
2256
+ {{/if}}
2257
+
2258
+ {{#if summary.web_vitals_data.fid}}
2259
+ vitalsData.labels.push('FID (deprecated)');
2260
+ vitalsData.values.push({{summary.web_vitals_data.fid}});
2261
+ vitalsData.thresholds.push([100, 300]);
2262
+ vitalsData.colors.push('{{#ifEquals summary.vitals_details.fid.score "good"}}#10b981{{else}}{{#ifEquals summary.vitals_details.fid.score "needs-improvement"}}#f59e0b{{else}}#ef4444{{/ifEquals}}{{/ifEquals}}');
2263
+ {{/if}}
2264
+
2265
+ {{#if summary.web_vitals_data.fcp}}
2266
+ vitalsData.labels.push('FCP');
2267
+ vitalsData.values.push({{summary.web_vitals_data.fcp}});
2268
+ vitalsData.thresholds.push([1800, 3000]);
2269
+ vitalsData.colors.push('{{#ifEquals summary.vitals_details.fcp.score "good"}}#10b981{{else}}{{#ifEquals summary.vitals_details.fcp.score "needs-improvement"}}#f59e0b{{else}}#ef4444{{/ifEquals}}{{/ifEquals}}');
2270
+ {{/if}}
2271
+
2272
+ {{#if summary.web_vitals_data.ttfb}}
2273
+ vitalsData.labels.push('TTFB');
2274
+ vitalsData.values.push({{summary.web_vitals_data.ttfb}});
2275
+ vitalsData.thresholds.push([800, 1800]);
2276
+ vitalsData.colors.push('{{#ifEquals summary.vitals_details.ttfb.score "good"}}#10b981{{else}}{{#ifEquals summary.vitals_details.ttfb.score "needs-improvement"}}#f59e0b{{else}}#ef4444{{/ifEquals}}{{/ifEquals}}');
2277
+ {{/if}}
2278
+
2279
+ new Chart(webVitalsCtx, {
2280
+ type: 'bar',
2281
+ data: {
2282
+ labels: vitalsData.labels,
2283
+ datasets: [{
2284
+ label: 'Measured Value',
2285
+ data: vitalsData.values,
2286
+ backgroundColor: vitalsData.colors,
2287
+ borderColor: vitalsData.colors,
2288
+ borderWidth: 2
2289
+ }]
2290
+ },
2291
+ options: {
2292
+ responsive: true,
2293
+ maintainAspectRatio: false,
2294
+ plugins: {
2295
+ title: {
2296
+ display: true,
2297
+ text: 'Core Web Vitals Performance',
2298
+ font: { size: 16 }
2299
+ },
2300
+ tooltip: {
2301
+ callbacks: {
2302
+ afterLabel: function(context) {
2303
+ const thresholds = vitalsData.thresholds[context.dataIndex];
2304
+ if (thresholds) {
2305
+ return [
2306
+ `Good: ≤ ${thresholds[0]}${context.label === 'CLS' ? '' : 'ms'}`,
2307
+ `Poor: > ${thresholds[1]}${context.label === 'CLS' ? '' : 'ms'}`
2308
+ ];
2309
+ }
2310
+ return '';
2311
+ }
2312
+ }
2313
+ }
2314
+ },
2315
+ scales: {
2316
+ y: {
2317
+ beginAtZero: true,
2318
+ title: {
2319
+ display: true,
2320
+ text: 'Value (ms / ratio)'
2321
+ }
2322
+ },
2323
+ x: {
2324
+ title: {
2325
+ display: true,
2326
+ text: 'Core Web Vitals Metrics'
2327
+ }
2328
+ }
2329
+ }
2330
+ }
2331
+ });
2332
+ }
2333
+ {{/if}}
2334
+
2335
+ // Enhanced Web Vitals Charts for Playwright Tests
2336
+ {{#if webVitalsCharts}}
2337
+ // Score Distribution Pie Chart
2338
+ const scoreCtx = document.getElementById('webVitalsScoreChart')?.getContext('2d');
2339
+ if (scoreCtx) {
2340
+ new Chart(scoreCtx, {
2341
+ type: 'doughnut',
2342
+ data: {
2343
+ labels: ['Good', 'Needs Improvement', 'Poor'],
2344
+ datasets: [{
2345
+ data: [
2346
+ {{webVitalsCharts.scoreDistribution.good}},
2347
+ {{webVitalsCharts.scoreDistribution.needsImprovement}},
2348
+ {{webVitalsCharts.scoreDistribution.poor}}
2349
+ ],
2350
+ backgroundColor: ['#10b981', '#f59e0b', '#ef4444']
2351
+ }]
2352
+ },
2353
+ options: {
2354
+ responsive: true,
2355
+ maintainAspectRatio: false,
2356
+ plugins: {
2357
+ legend: { position: 'bottom' }
2358
+ }
2359
+ }
2360
+ });
2361
+ }
2362
+
2363
+ // Combined Web Vitals Timeline Chart
2364
+ const webVitalsTimelineCtx = document.getElementById('webVitalsTimelineChart')?.getContext('2d');
2365
+ const webVitalsTimeSeries = {{{json webVitalsCharts.timeSeries}}};
2366
+ if (webVitalsTimelineCtx && webVitalsTimeSeries && webVitalsTimeSeries.unified) {
2367
+ const timeSeries = webVitalsTimeSeries;
2368
+ const unifiedData = timeSeries.unified;
2369
+ const metrics = ['lcp', 'cls', 'inp', 'ttfb', 'fcp', 'fid'];
2370
+ const metricsConfig = {
2371
+ lcp: { label: 'LCP', color: '#FF6B6B', yAxisID: 'y' },
2372
+ cls: { label: 'CLS', color: '#45B7D1', yAxisID: 'y1' },
2373
+ inp: { label: 'INP', color: '#4ECDC4', yAxisID: 'y' },
2374
+ ttfb: { label: 'TTFB', color: '#FECA57', yAxisID: 'y' },
2375
+ fcp: { label: 'FCP', color: '#96CEB4', yAxisID: 'y' },
2376
+ fid: { label: 'FID', color: '#9370DB', yAxisID: 'y' }
2377
+ };
2378
+
2379
+ // Create datasets for available metrics using unified data
2380
+ const datasets = [];
2381
+ metrics.forEach(metric => {
2382
+ if (unifiedData.data[metric] && unifiedData.data[metric].some(v => v !== null)) {
2383
+ const config = metricsConfig[metric];
2384
+ datasets.push({
2385
+ label: config.label,
2386
+ data: unifiedData.data[metric],
2387
+ borderColor: config.color,
2388
+ backgroundColor: config.color + '20',
2389
+ tension: 0.1,
2390
+ yAxisID: config.yAxisID,
2391
+ pointRadius: 3,
2392
+ pointHoverRadius: 5,
2393
+ spanGaps: true // Connect lines across null values
2394
+ });
2395
+ }
2396
+ });
2397
+
2398
+ const labels = unifiedData.labels;
2399
+
2400
+ new Chart(webVitalsTimelineCtx, {
2401
+ type: 'line',
2402
+ data: {
2403
+ labels: labels,
2404
+ datasets: datasets
2405
+ },
2406
+ options: {
2407
+ responsive: true,
2408
+ maintainAspectRatio: false,
2409
+ interaction: {
2410
+ mode: 'index',
2411
+ intersect: false
2412
+ },
2413
+ plugins: {
2414
+ legend: {
2415
+ position: 'top',
2416
+ labels: {
2417
+ usePointStyle: true,
2418
+ boxWidth: 6
2419
+ }
2420
+ },
2421
+ tooltip: {
2422
+ callbacks: {
2423
+ label: function(context) {
2424
+ const metric = context.dataset.label.toLowerCase();
2425
+ const value = context.parsed.y;
2426
+ if (metric === 'cls') {
2427
+ return `${context.dataset.label}: ${value.toFixed(3)}`;
2428
+ } else if (value < 1000) {
2429
+ return `${context.dataset.label}: ${Math.round(value)}ms`;
2430
+ } else {
2431
+ return `${context.dataset.label}: ${(value / 1000).toFixed(2)}s`;
2432
+ }
2433
+ }
2434
+ }
2435
+ }
2436
+ },
2437
+ scales: {
2438
+ x: {
2439
+ display: true,
2440
+ title: {
2441
+ display: true,
2442
+ text: 'Time'
2443
+ }
2444
+ },
2445
+ y: {
2446
+ type: 'linear',
2447
+ display: true,
2448
+ position: 'left',
2449
+ title: {
2450
+ display: true,
2451
+ text: 'Time (ms)'
2452
+ },
2453
+ beginAtZero: true
2454
+ },
2455
+ y1: {
2456
+ type: 'linear',
2457
+ display: true,
2458
+ position: 'right',
2459
+ title: {
2460
+ display: true,
2461
+ text: 'CLS Score'
2462
+ },
2463
+ max: 1,
2464
+ beginAtZero: true,
2465
+ grid: {
2466
+ drawOnChartArea: false,
2467
+ },
2468
+ }
2469
+ }
2470
+ }
2471
+ });
2472
+ }
2473
+
2474
+ // Time Series Charts for Each Metric
2475
+ {{#each webVitalsCharts.metrics}}
2476
+ const {{this}}Ctx = document.getElementById('webVitals{{this}}Chart')?.getContext('2d');
2477
+ if ({{this}}Ctx && webVitalsTimeSeries.{{this}}) {
2478
+ const timeSeriesData = webVitalsTimeSeries.{{this}};
2479
+ new Chart({{this}}Ctx, {
2480
+ type: 'line',
2481
+ data: {
2482
+ labels: timeSeriesData.labels,
2483
+ datasets: [{
2484
+ label: '{{this}}',
2485
+ data: timeSeriesData.data,
2486
+ borderColor: timeSeriesData.borderColor,
2487
+ backgroundColor: timeSeriesData.backgroundColor + '20',
2488
+ tension: 0.1
2489
+ }]
2490
+ },
2491
+ options: {
2492
+ responsive: true,
2493
+ maintainAspectRatio: false,
2494
+ plugins: {
2495
+ legend: { display: false }
2496
+ },
2497
+ scales: {
2498
+ y: {
2499
+ beginAtZero: true,
2500
+ title: {
2501
+ display: true,
2502
+ text: '{{#ifEquals this "cls"}}Score{{else}}ms{{/ifEquals}}'
2503
+ }
2504
+ }
2505
+ }
2506
+ }
2507
+ });
2508
+ }
2509
+ {{/each}}
2510
+
2511
+ // Percentiles Chart
2512
+ const percentilesCtx = document.getElementById('webVitalsPercentilesChart')?.getContext('2d');
2513
+ const webVitalsDistributions = {{{json webVitalsCharts.distributions}}};
2514
+ if (percentilesCtx && webVitalsDistributions) {
2515
+ const distributions = webVitalsDistributions;
2516
+ const metrics = Object.keys(distributions);
2517
+ const datasets = [
2518
+ {
2519
+ label: 'P50 (Median)',
2520
+ data: metrics.map(m => distributions[m]?.median || 0),
2521
+ backgroundColor: '#4ECDC4'
2522
+ },
2523
+ {
2524
+ label: 'P75',
2525
+ data: metrics.map(m => distributions[m]?.p75 || 0),
2526
+ backgroundColor: '#45B7D1'
2527
+ },
2528
+ {
2529
+ label: 'P90',
2530
+ data: metrics.map(m => distributions[m]?.p90 || 0),
2531
+ backgroundColor: '#FECA57'
2532
+ },
2533
+ {
2534
+ label: 'P95',
2535
+ data: metrics.map(m => distributions[m]?.p95 || 0),
2536
+ backgroundColor: '#FF6B6B'
2537
+ },
2538
+ {
2539
+ label: 'P99',
2540
+ data: metrics.map(m => distributions[m]?.p99 || 0),
2541
+ backgroundColor: '#C44569'
2542
+ }
2543
+ ];
2544
+
2545
+ new Chart(percentilesCtx, {
2546
+ type: 'bar',
2547
+ data: {
2548
+ labels: metrics.map(m => m.toUpperCase()),
2549
+ datasets: datasets
2550
+ },
2551
+ options: {
2552
+ responsive: true,
2553
+ maintainAspectRatio: false,
2554
+ plugins: {
2555
+ legend: { position: 'top' },
2556
+ title: {
2557
+ display: true,
2558
+ text: 'Web Vitals Percentile Distribution'
2559
+ }
2560
+ },
2561
+ scales: {
2562
+ y: {
2563
+ beginAtZero: true,
2564
+ type: 'logarithmic',
2565
+ title: {
2566
+ display: true,
2567
+ text: 'Value (log scale)'
2568
+ }
2569
+ }
2570
+ }
2571
+ }
2572
+ });
2573
+ }
2574
+ {{/if}}
2575
+
2576
+ // Verification Performance Chart
2577
+ {{#if summary.verification_metrics}}
2578
+ const verificationCtx = document.getElementById('verificationChart')?.getContext('2d');
2579
+ if (verificationCtx && {{{json results}}}) {
2580
+ const verificationData = {{{json results}}}.filter(r => r.custom_metrics?.verification_metrics)
2581
+ .map(r => ({
2582
+ timestamp: r.timestamp,
2583
+ duration: r.custom_metrics.verification_metrics.duration,
2584
+ success: r.custom_metrics.verification_metrics.success,
2585
+ stepName: r.custom_metrics.verification_metrics.step_name || 'Unknown'
2586
+ }));
2587
+
2588
+ new Chart(verificationCtx, {
2589
+ type: 'scatter',
2590
+ data: {
2591
+ datasets: [{
2592
+ label: 'Successful Verifications',
2593
+ data: verificationData.filter(d => d.success).map(d => ({
2594
+ x: new Date(d.timestamp),
2595
+ y: d.duration
2596
+ })),
2597
+ backgroundColor: '#10b981',
2598
+ borderColor: '#10b981',
2599
+ pointRadius: 6
2600
+ }, {
2601
+ label: 'Failed Verifications',
2602
+ data: verificationData.filter(d => !d.success).map(d => ({
2603
+ x: new Date(d.timestamp),
2604
+ y: d.duration
2605
+ })),
2606
+ backgroundColor: '#ef4444',
2607
+ borderColor: '#ef4444',
2608
+ pointRadius: 6
2609
+ }]
2610
+ },
2611
+ options: {
2612
+ responsive: true,
2613
+ maintainAspectRatio: false,
2614
+ plugins: {
2615
+ title: {
2616
+ display: true,
2617
+ text: 'Verification Step Performance Over Time',
2618
+ font: { size: 16 }
2619
+ },
2620
+ tooltip: {
2621
+ callbacks: {
2622
+ title: function(context) {
2623
+ return new Date(context[0].parsed.x).toLocaleTimeString();
2624
+ },
2625
+ label: function(context) {
2626
+ const datasetIndex = context.datasetIndex;
2627
+ const dataIndex = context.dataIndex;
2628
+ const filtered = datasetIndex === 0
2629
+ ? verificationData.filter(d => d.success)
2630
+ : verificationData.filter(d => !d.success);
2631
+ const point = filtered[dataIndex];
2632
+ return [
2633
+ `Duration: ${Math.round(context.parsed.y)}ms`,
2634
+ `Step: ${point?.stepName || 'Unknown'}`,
2635
+ `Status: ${point?.success ? 'Success' : 'Failed'}`
2636
+ ];
2637
+ }
2638
+ }
2639
+ }
2640
+ },
2641
+ scales: {
2642
+ x: {
2643
+ type: 'time',
2644
+ position: 'bottom',
2645
+ time: {
2646
+ displayFormats: {
2647
+ second: 'HH:mm:ss',
2648
+ minute: 'HH:mm',
2649
+ hour: 'HH:mm'
2650
+ }
2651
+ },
2652
+ title: {
2653
+ display: true,
2654
+ text: 'Time'
2655
+ }
2656
+ },
2657
+ y: {
2658
+ beginAtZero: true,
2659
+ title: {
2660
+ display: true,
2661
+ text: 'Duration (ms)'
2662
+ }
2663
+ }
2664
+ }
2665
+ }
2666
+ });
2667
+ }
2668
+ {{/if}}
2669
+
2670
+ // Connect Time Over Time Chart - only show if there's meaningful data
2671
+ const hasConnectTimeData = connectTimeData && connectTimeData.length > 0 &&
2672
+ connectTimeData.some(d => d.avg_connect_time > 0);
2673
+ if (hasConnectTimeData) {
2674
+ const connectTimeCtx = document.getElementById('connectTimeChart').getContext('2d');
2675
+ new Chart(connectTimeCtx, {
2676
+ type: 'line',
2677
+ data: {
2678
+ datasets: [{
2679
+ label: 'Avg Connect Time (ms)',
2680
+ data: connectTimeData.map(d => ({
2681
+ x: d.timestamp,
2682
+ y: d.avg_connect_time
2683
+ })),
2684
+ borderColor: '#10b981',
2685
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
2686
+ tension: 0.4,
2687
+ fill: true,
2688
+ pointRadius: 3
2689
+ }]
2690
+ },
2691
+ options: {
2692
+ responsive: true,
2693
+ maintainAspectRatio: false,
2694
+ plugins: {
2695
+ title: {
2696
+ display: true,
2697
+ text: 'TCP Connection Time Over Time',
2698
+ font: { size: 16 }
2699
+ },
2700
+ tooltip: {
2701
+ callbacks: {
2702
+ title: function(context) {
2703
+ return new Date(context[0].parsed.x).toLocaleTimeString();
2704
+ },
2705
+ label: function(context) {
2706
+ return `Connect Time: ${Math.round(context.parsed.y)}ms`;
2707
+ }
2708
+ }
2709
+ }
2710
+ },
2711
+ scales: {
2712
+ x: {
2713
+ type: 'time',
2714
+ time: {
2715
+ displayFormats: {
2716
+ second: 'HH:mm:ss'
2717
+ }
2718
+ },
2719
+ title: {
2720
+ display: true,
2721
+ text: 'Time'
2722
+ }
2723
+ },
2724
+ y: {
2725
+ beginAtZero: true,
2726
+ title: {
2727
+ display: true,
2728
+ text: 'Connect Time (ms)'
2729
+ }
2730
+ }
2731
+ }
2732
+ }
2733
+ });
2734
+ } else {
2735
+ // Hide connect time chart container if no data
2736
+ const connectTimeContainer = document.getElementById('connectTimeChart')?.closest('.chart-container, .bg-white, [class*="rounded"]');
2737
+ if (connectTimeContainer) connectTimeContainer.style.display = 'none';
2738
+ }
2739
+
2740
+ // Latency Over Time Chart - only show if there's meaningful data
2741
+ const hasLatencyData = latencyData && latencyData.length > 0 &&
2742
+ latencyData.some(d => d.avg_latency > 0);
2743
+ if (hasLatencyData) {
2744
+ const latencyCtx = document.getElementById('latencyChart').getContext('2d');
2745
+ new Chart(latencyCtx, {
2746
+ type: 'line',
2747
+ data: {
2748
+ datasets: [{
2749
+ label: 'Avg Latency / TTFB (ms)',
2750
+ data: latencyData.map(d => ({
2751
+ x: d.timestamp,
2752
+ y: d.avg_latency
2753
+ })),
2754
+ borderColor: '#f59e0b',
2755
+ backgroundColor: 'rgba(245, 158, 11, 0.1)',
2756
+ tension: 0.4,
2757
+ fill: true,
2758
+ pointRadius: 3
2759
+ }]
2760
+ },
2761
+ options: {
2762
+ responsive: true,
2763
+ maintainAspectRatio: false,
2764
+ plugins: {
2765
+ title: {
2766
+ display: true,
2767
+ text: 'Latency (Time to First Byte) Over Time',
2768
+ font: { size: 16 }
2769
+ },
2770
+ tooltip: {
2771
+ callbacks: {
2772
+ title: function(context) {
2773
+ return new Date(context[0].parsed.x).toLocaleTimeString();
2774
+ },
2775
+ label: function(context) {
2776
+ return `Latency: ${Math.round(context.parsed.y)}ms`;
2777
+ }
2778
+ }
2779
+ }
2780
+ },
2781
+ scales: {
2782
+ x: {
2783
+ type: 'time',
2784
+ time: {
2785
+ displayFormats: {
2786
+ second: 'HH:mm:ss'
2787
+ }
2788
+ },
2789
+ title: {
2790
+ display: true,
2791
+ text: 'Time'
2792
+ }
2793
+ },
2794
+ y: {
2795
+ beginAtZero: true,
2796
+ title: {
2797
+ display: true,
2798
+ text: 'Latency (ms)'
2799
+ }
2800
+ }
2801
+ }
2802
+ }
2803
+ });
2804
+ } else {
2805
+ // Hide latency chart container if no data
2806
+ const latencyContainer = document.getElementById('latencyChart')?.closest('.chart-container, .bg-white, [class*="rounded"]');
2807
+ if (latencyContainer) latencyContainer.style.display = 'none';
2808
+ }
2809
+ </script>
2810
+ </body>
2811
+
2812
+ </html>