k6-modern-reporter 1.0.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.
@@ -0,0 +1,1291 @@
1
+ /**
2
+ * Modern K6 HTML Reporter with Modern UI
3
+ *
4
+ * This module generates beautiful, modern HTML reports for k6 performance tests.
5
+ * Features include:
6
+ * - Responsive design with gradient UI
7
+ * - Interactive tabs for different sections
8
+ * - Visual charts and progress bars
9
+ * - Detailed metrics, checks, and threshold reporting
10
+ * - Support for custom test information
11
+ */
12
+
13
+ /**
14
+ * Main entry point for generating HTML reports
15
+ *
16
+ * @param {Object} data - The k6 test results data object containing metrics, checks, thresholds, etc.
17
+ * @param {Object} options - Configuration options for the report
18
+ * @param {string} options.title - Main title for the report (defaults to current timestamp)
19
+ * @param {string} options.subtitle - Subtitle or endpoint description
20
+ * @param {string} options.httpMethod - HTTP method (GET, POST, PUT, DELETE, etc.)
21
+ * @param {Object} options.additionalInfo - Key-value pairs of additional test information to display
22
+ * @param {boolean} options.debug - If true, logs the raw k6 data to console
23
+ * @returns {string} Complete HTML document as a string
24
+ */
25
+ export function htmlReport(data, options = {}) {
26
+ // Extract options with default values
27
+ const title = options.title || new Date().toISOString().slice(0, 16).replace("T", " ");
28
+ const subtitle = options.subtitle || '';
29
+ const httpMethod = options.httpMethod || '';
30
+ const additionalInfo = options.additionalInfo || {};
31
+ const debug = options.debug || false;
32
+
33
+ console.log("[k6-reporter-modern] Generating modern HTML summary report");
34
+
35
+ // Debug mode: print raw k6 data to console for troubleshooting
36
+ if (debug) {
37
+ console.log(JSON.stringify(data, null, 2));
38
+ }
39
+
40
+ // Calculate summary statistics from k6 metrics data
41
+ const stats = calculateStats(data);
42
+
43
+ // Generate and return the complete HTML report
44
+ return generateModernHTML(data, title, subtitle, httpMethod, additionalInfo, stats);
45
+ }
46
+
47
+ /**
48
+ * Generates the complete HTML document structure
49
+ *
50
+ * @param {Object} data - The k6 test results data
51
+ * @param {string} title - Report title
52
+ * @param {string} subtitle - Report subtitle/endpoint
53
+ * @param {string} httpMethod - HTTP method for the API call
54
+ * @param {Object} additionalInfo - Additional test configuration info
55
+ * @param {Object} stats - Pre-calculated statistics
56
+ * @returns {string} Complete HTML document
57
+ */
58
+ function generateModernHTML(data, title, subtitle, httpMethod, additionalInfo, stats) {
59
+ // Determine overall test status (pass/fail) based on errors, check failures, and threshold failures
60
+ const testStatus = stats.failedRequests === 0 && stats.checkFailures === 0 && stats.thresholdFailures === 0;
61
+
62
+ return `
63
+ <!DOCTYPE html>
64
+ <html lang="en">
65
+ <head>
66
+ <meta charset="UTF-8">
67
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
68
+ <title>K6 Performance Test Report - ${escapeHtml(title)}</title>
69
+ <!-- Font Awesome for icons -->
70
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
71
+ <style>
72
+ /* ========================================
73
+ GLOBAL STYLES
74
+ ======================================== */
75
+
76
+ /* Reset default browser styles */
77
+ * {
78
+ margin: 0;
79
+ padding: 0;
80
+ box-sizing: border-box;
81
+ }
82
+
83
+ /* Main body styling with gradient background */
84
+ body {
85
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
86
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
87
+ color: #333;
88
+ line-height: 1.6;
89
+ padding: 20px;
90
+ min-height: 100vh;
91
+ }
92
+
93
+ /* Main container with white background and shadow */
94
+ .container {
95
+ max-width: 1400px;
96
+ margin: 0 auto;
97
+ background: white;
98
+ border-radius: 20px;
99
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
100
+ overflow: hidden;
101
+ }
102
+
103
+ /* ========================================
104
+ HEADER SECTION
105
+ ======================================== */
106
+
107
+ /* Header with dynamic gradient based on test pass/fail status */
108
+ .header {
109
+ background: ${testStatus ? 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)' : 'linear-gradient(135deg, #eb3349 0%, #f45c43 100%)'};
110
+ color: white;
111
+ padding: 40px;
112
+ position: relative;
113
+ overflow: hidden;
114
+ }
115
+
116
+ /* Animated background effect in header */
117
+ .header::before {
118
+ content: '';
119
+ position: absolute;
120
+ top: -50%;
121
+ right: -50%;
122
+ width: 200%;
123
+ height: 200%;
124
+ background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
125
+ animation: pulse 15s ease-in-out infinite;
126
+ }
127
+
128
+ /* Pulse animation for header background */
129
+ @keyframes pulse {
130
+ 0%, 100% { transform: scale(1); opacity: 0.5; }
131
+ 50% { transform: scale(1.1); opacity: 0.8; }
132
+ }
133
+
134
+ /* Header content positioned above the animated background */
135
+ .header-content {
136
+ position: relative;
137
+ z-index: 10;
138
+ }
139
+
140
+ /* Main heading with flexbox for logo alignment */
141
+ .header h1 {
142
+ font-size: 2.5em;
143
+ margin-bottom: 10px;
144
+ font-weight: 300;
145
+ display: flex;
146
+ align-items: center;
147
+ gap: 15px;
148
+ }
149
+
150
+ /* K6 logo container */
151
+ .k6-logo {
152
+ width: 60px;
153
+ height: 60px;
154
+ background: rgba(255, 255, 255, 0.2);
155
+ border-radius: 12px;
156
+ display: flex;
157
+ align-items: center;
158
+ justify-content: center;
159
+ font-size: 2em;
160
+ }
161
+
162
+ /* Test status badge (PASSED/FAILED) */
163
+ .test-status {
164
+ display: inline-block;
165
+ padding: 10px 25px;
166
+ border-radius: 50px;
167
+ font-size: 0.9em;
168
+ font-weight: 600;
169
+ margin-top: 15px;
170
+ background: rgba(255, 255, 255, 0.3);
171
+ backdrop-filter: blur(10px);
172
+ }
173
+
174
+ /* ========================================
175
+ STATS GRID SECTION
176
+ ======================================== */
177
+
178
+ /* Responsive grid for statistic cards */
179
+ .stats-grid {
180
+ display: grid;
181
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
182
+ gap: 25px;
183
+ padding: 40px;
184
+ background: #f8f9fa;
185
+ }
186
+
187
+ /* Individual stat card with shadow and hover effects */
188
+ .stat-card {
189
+ background: white;
190
+ border-radius: 15px;
191
+ padding: 30px;
192
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
193
+ transition: all 0.3s ease;
194
+ position: relative;
195
+ overflow: hidden;
196
+ }
197
+
198
+ /* Colored top border for each stat card */
199
+ .stat-card::before {
200
+ content: '';
201
+ position: absolute;
202
+ top: 0;
203
+ left: 0;
204
+ width: 100%;
205
+ height: 4px;
206
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
207
+ }
208
+
209
+ /* Hover effect: lift the card */
210
+ .stat-card:hover {
211
+ transform: translateY(-5px);
212
+ box-shadow: 0 15px 40px rgba(0, 0, 0, 0.15);
213
+ }
214
+
215
+ /* Success state: green gradient */
216
+ .stat-card.success::before {
217
+ background: linear-gradient(90deg, #11998e 0%, #38ef7d 100%);
218
+ }
219
+
220
+ /* Error state: red gradient */
221
+ .stat-card.error::before {
222
+ background: linear-gradient(90deg, #eb3349 0%, #f45c43 100%);
223
+ }
224
+
225
+ /* Warning state: yellow/orange gradient */
226
+ .stat-card.warning::before {
227
+ background: linear-gradient(90deg, #f2994a 0%, #f2c94c 100%);
228
+ }
229
+
230
+ /* Background icon for stat cards (low opacity) */
231
+ .stat-icon {
232
+ font-size: 3em;
233
+ opacity: 0.1;
234
+ position: absolute;
235
+ right: 20px;
236
+ top: 50%;
237
+ transform: translateY(-50%);
238
+ }
239
+
240
+ /* Label text for each statistic */
241
+ .stat-label {
242
+ font-size: 0.85em;
243
+ color: #6c757d;
244
+ text-transform: uppercase;
245
+ letter-spacing: 1px;
246
+ margin-bottom: 10px;
247
+ font-weight: 600;
248
+ }
249
+
250
+ /* Main value display for each statistic */
251
+ .stat-value {
252
+ font-size: 2.5em;
253
+ font-weight: 700;
254
+ color: #2c3e50;
255
+ position: relative;
256
+ z-index: 10;
257
+ }
258
+
259
+ /* Subtext below the main stat value */
260
+ .stat-subtext {
261
+ font-size: 0.9em;
262
+ color: #95a5a6;
263
+ margin-top: 8px;
264
+ }
265
+
266
+ /* ========================================
267
+ TABS SECTION
268
+ ======================================== */
269
+
270
+ /* Container for all tab content */
271
+ .tabs-container {
272
+ padding: 40px;
273
+ }
274
+
275
+ /* Tab button container with bottom border */
276
+ .tabs {
277
+ display: flex;
278
+ gap: 10px;
279
+ border-bottom: 2px solid #e9ecef;
280
+ margin-bottom: 30px;
281
+ flex-wrap: wrap;
282
+ }
283
+
284
+ /* Individual tab button styling */
285
+ .tab-button {
286
+ padding: 15px 30px;
287
+ background: none;
288
+ border: none;
289
+ cursor: pointer;
290
+ font-size: 1em;
291
+ font-weight: 600;
292
+ color: #6c757d;
293
+ border-bottom: 3px solid transparent;
294
+ transition: all 0.3s ease;
295
+ display: flex;
296
+ align-items: center;
297
+ gap: 10px;
298
+ }
299
+
300
+ /* Tab button hover state */
301
+ .tab-button:hover {
302
+ color: #667eea;
303
+ background: rgba(102, 126, 234, 0.1);
304
+ }
305
+
306
+ /* Active tab styling */
307
+ .tab-button.active {
308
+ color: #667eea;
309
+ border-bottom-color: #667eea;
310
+ }
311
+
312
+ /* Tab content panel (hidden by default) */
313
+ .tab-content {
314
+ display: none;
315
+ animation: fadeIn 0.5s ease;
316
+ }
317
+
318
+ /* Show active tab content */
319
+ .tab-content.active {
320
+ display: block;
321
+ }
322
+
323
+ /* Fade-in animation for tab content */
324
+ @keyframes fadeIn {
325
+ from { opacity: 0; transform: translateY(10px); }
326
+ to { opacity: 1; transform: translateY(0); }
327
+ }
328
+
329
+ /* ========================================
330
+ METRICS TABLE
331
+ ======================================== */
332
+
333
+ /* Main metrics table with rounded corners */
334
+ .metrics-table {
335
+ width: 100%;
336
+ border-collapse: separate;
337
+ border-spacing: 0;
338
+ border-radius: 10px;
339
+ overflow: hidden;
340
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
341
+ }
342
+
343
+ /* Table header with gradient background */
344
+ .metrics-table thead {
345
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
346
+ color: white;
347
+ }
348
+
349
+ /* Table header cells */
350
+ .metrics-table th {
351
+ padding: 15px;
352
+ text-align: left;
353
+ font-weight: 600;
354
+ font-size: 0.9em;
355
+ text-transform: uppercase;
356
+ letter-spacing: 0.5px;
357
+ }
358
+
359
+ /* Table data cells */
360
+ .metrics-table td {
361
+ padding: 15px;
362
+ border-bottom: 1px solid #e9ecef;
363
+ }
364
+
365
+ /* Table row hover effect */
366
+ .metrics-table tbody tr {
367
+ transition: background 0.2s ease;
368
+ }
369
+
370
+ .metrics-table tbody tr:hover {
371
+ background: #f8f9fa;
372
+ }
373
+
374
+ /* Remove border from last row */
375
+ .metrics-table tbody tr:last-child td {
376
+ border-bottom: none;
377
+ }
378
+
379
+ /* ========================================
380
+ BADGES AND INDICATORS
381
+ ======================================== */
382
+
383
+ /* Generic badge styling */
384
+ .badge {
385
+ display: inline-block;
386
+ padding: 5px 12px;
387
+ border-radius: 20px;
388
+ font-size: 0.85em;
389
+ font-weight: 600;
390
+ }
391
+
392
+ /* Success badge (green) */
393
+ .badge-success {
394
+ background: #d4edda;
395
+ color: #155724;
396
+ }
397
+
398
+ /* Error badge (red) */
399
+ .badge-error {
400
+ background: #f8d7da;
401
+ color: #721c24;
402
+ }
403
+
404
+ /* Warning badge (yellow) */
405
+ .badge-warning {
406
+ background: #fff3cd;
407
+ color: #856404;
408
+ }
409
+
410
+ /* Good metric value (green text) */
411
+ .metric-value-good {
412
+ color: #28a745;
413
+ font-weight: 600;
414
+ }
415
+
416
+ /* Bad metric value (red text) */
417
+ .metric-value-bad {
418
+ color: #dc3545;
419
+ font-weight: 600;
420
+ }
421
+
422
+ /* ========================================
423
+ CHART AND CONTAINER STYLES
424
+ ======================================== */
425
+
426
+ /* Container for charts and content sections */
427
+ .chart-container {
428
+ background: white;
429
+ border-radius: 15px;
430
+ padding: 30px;
431
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
432
+ margin-bottom: 30px;
433
+ }
434
+
435
+ /* Chart section title */
436
+ .chart-title {
437
+ font-size: 1.3em;
438
+ font-weight: 600;
439
+ margin-bottom: 20px;
440
+ color: #2c3e50;
441
+ }
442
+
443
+ /* Progress bar container */
444
+ .progress-bar {
445
+ height: 30px;
446
+ background: #e9ecef;
447
+ border-radius: 15px;
448
+ overflow: hidden;
449
+ margin: 10px 0;
450
+ position: relative;
451
+ }
452
+
453
+ /* Progress bar fill with animation */
454
+ .progress-fill {
455
+ height: 100%;
456
+ background: linear-gradient(90deg, #11998e 0%, #38ef7d 100%);
457
+ border-radius: 15px;
458
+ display: flex;
459
+ align-items: center;
460
+ justify-content: flex-end;
461
+ padding-right: 15px;
462
+ color: white;
463
+ font-weight: 600;
464
+ transition: width 1s ease;
465
+ }
466
+
467
+ /* Error state for progress bar (red gradient) */
468
+ .progress-fill.error {
469
+ background: linear-gradient(90deg, #eb3349 0%, #f45c43 100%);
470
+ }
471
+
472
+ /* ========================================
473
+ FOOTER
474
+ ======================================== */
475
+
476
+ /* Footer section */
477
+ .footer {
478
+ text-align: center;
479
+ padding: 30px;
480
+ background: #f8f9fa;
481
+ color: #6c757d;
482
+ font-size: 0.9em;
483
+ }
484
+
485
+ /* Footer links */
486
+ .footer a {
487
+ color: #667eea;
488
+ text-decoration: none;
489
+ font-weight: 600;
490
+ }
491
+
492
+ .footer a:hover {
493
+ text-decoration: underline;
494
+ }
495
+
496
+ /* ========================================
497
+ RESPONSIVE DESIGN
498
+ ======================================== */
499
+
500
+ /* Mobile/tablet optimizations */
501
+ @media (max-width: 768px) {
502
+ .header h1 {
503
+ font-size: 1.8em;
504
+ }
505
+
506
+ .stats-grid {
507
+ grid-template-columns: 1fr;
508
+ padding: 20px;
509
+ }
510
+
511
+ .tabs-container {
512
+ padding: 20px;
513
+ }
514
+
515
+ .tab-button {
516
+ font-size: 0.9em;
517
+ padding: 12px 20px;
518
+ }
519
+ }
520
+
521
+ /* ========================================
522
+ CHECK ITEMS
523
+ ======================================== */
524
+
525
+ /* Individual check result item */
526
+ .check-item {
527
+ display: flex;
528
+ justify-content: space-between;
529
+ align-items: center;
530
+ padding: 15px;
531
+ background: white;
532
+ border-radius: 10px;
533
+ margin-bottom: 10px;
534
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
535
+ }
536
+
537
+ .check-item:hover {
538
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
539
+ }
540
+
541
+ /* Check name/description */
542
+ .check-name {
543
+ flex: 1;
544
+ font-weight: 500;
545
+ }
546
+
547
+ /* Container for check statistics */
548
+ .check-stats {
549
+ display: flex;
550
+ gap: 20px;
551
+ }
552
+
553
+ /* ========================================
554
+ HEADER SUBTITLE AND METHOD BADGES
555
+ ======================================== */
556
+
557
+ /* Subtitle display in header */
558
+ .header-subtitle {
559
+ font-size: 1.1em;
560
+ opacity: 0.85;
561
+ margin: 15px 0 10px 0;
562
+ font-family: 'Courier New', monospace;
563
+ background: rgba(255, 255, 255, 0.2);
564
+ padding: 10px 20px;
565
+ border-radius: 8px;
566
+ display: inline-block;
567
+ }
568
+
569
+ /* HTTP method badge (GET, POST, etc.) */
570
+ .http-method-badge {
571
+ display: inline-block;
572
+ padding: 5px 15px;
573
+ border-radius: 6px;
574
+ font-weight: 700;
575
+ font-size: 0.9em;
576
+ margin-right: 10px;
577
+ font-family: 'Courier New', monospace;
578
+ letter-spacing: 1px;
579
+ }
580
+
581
+ /* Color coding for different HTTP methods */
582
+ .method-GET {
583
+ background: #28a745;
584
+ color: white;
585
+ }
586
+
587
+ .method-POST {
588
+ background: #007bff;
589
+ color: white;
590
+ }
591
+
592
+ .method-PUT {
593
+ background: #ffc107;
594
+ color: #000;
595
+ }
596
+
597
+ .method-DELETE {
598
+ background: #dc3545;
599
+ color: white;
600
+ }
601
+
602
+ .method-PATCH {
603
+ background: #17a2b8;
604
+ color: white;
605
+ }
606
+
607
+ /* ========================================
608
+ INFO TABLE
609
+ ======================================== */
610
+
611
+ /* Table for displaying test information */
612
+ .info-table {
613
+ width: 100%;
614
+ border-collapse: collapse;
615
+ margin-top: 15px;
616
+ }
617
+
618
+ /* Info table cells */
619
+ .info-table td {
620
+ padding: 10px 15px;
621
+ border-bottom: 1px solid #e9ecef;
622
+ }
623
+
624
+ /* Info table labels (left column) */
625
+ .info-table td:first-child {
626
+ font-weight: 600;
627
+ color: #667eea;
628
+ width: 40%;
629
+ }
630
+
631
+ /* Info table values (right column) */
632
+ .info-table td:last-child {
633
+ color: #2c3e50;
634
+ font-family: 'Courier New', monospace;
635
+ }
636
+
637
+ /* Remove border from last row */
638
+ .info-table tr:last-child td {
639
+ border-bottom: none;
640
+ }
641
+
642
+ /* Row hover effect */
643
+ .info-table tr:hover {
644
+ background: #f8f9fa;
645
+ }
646
+ </style>
647
+ </head>
648
+ <body>
649
+ <div class="container">
650
+ <!-- ========================================
651
+ HEADER SECTION
652
+ ======================================== -->
653
+ <div class="header">
654
+ <div class="header-content">
655
+ <h1>
656
+ <!-- K6 Logo SVG -->
657
+ <div class="k6-logo">
658
+ <svg width="40" height="36" viewBox="0 0 50 45" fill="white">
659
+ <path d="M31.968 34.681a2.007 2.007 0 002.011-2.003c0-1.106-.9-2.003-2.011-2.003a2.007 2.007 0 00-2.012 2.003c0 1.106.9 2.003 2.012 2.003z"/>
660
+ <path d="M39.575 0L27.154 16.883 16.729 9.31 0 45h50L39.575 0zM23.663 37.17l-2.97-4.072v4.072h-2.751V22.038l2.75 1.989v7.66l3.659-5.014 2.086 1.51-3.071 4.21 3.486 4.776h-3.189v.001zm8.305.17c-2.586 0-4.681-2.088-4.681-4.662 0-1.025.332-1.972.896-2.743l4.695-6.435 2.086 1.51-2.239 3.07a4.667 4.667 0 013.924 4.6c0 2.572-2.095 4.66-4.681 4.66z"/>
661
+ </svg>
662
+ </div>
663
+ K6 Performance Test Report
664
+ </h1>
665
+ <!-- Report title -->
666
+ <p style="font-size: 1.2em; opacity: 0.9; margin: 10px 0;">${escapeHtml(title)}</p>
667
+ <!-- Subtitle with HTTP method badge if provided -->
668
+ ${subtitle ? `
669
+ <div class="header-subtitle">
670
+ ${httpMethod ? `<span class="http-method-badge method-${httpMethod}">${httpMethod}</span>` : '<i class="fas fa-link"></i>'}
671
+ ${escapeHtml(subtitle)}
672
+ </div>
673
+ <span class="test-status" style="margin-top: 15px;">
674
+ ${testStatus ? '✅ ALL TESTS PASSED' : '❌ TESTS FAILED'}
675
+ </span>
676
+ ` : `
677
+ <span class="test-status">
678
+ ${testStatus ? '✅ ALL TESTS PASSED' : '❌ TESTS FAILED'}
679
+ </span>
680
+ `}
681
+ <!-- Timestamp when report was generated -->
682
+ <p style="margin-top: 15px; opacity: 0.9;">
683
+ <i class="far fa-clock"></i> Generated: ${new Date().toLocaleString()}
684
+ </p>
685
+ </div>
686
+ </div>
687
+
688
+ <!-- ========================================
689
+ STATS GRID - Overview Cards
690
+ ======================================== -->
691
+ <div class="stats-grid">
692
+ <!-- Total Requests Card -->
693
+ <div class="stat-card ${stats.failedRequests === 0 ? 'success' : 'error'}">
694
+ <i class="fas fa-globe stat-icon"></i>
695
+ <div class="stat-label">Total Requests</div>
696
+ <div class="stat-value">${stats.totalRequests.toLocaleString()}</div>
697
+ <div class="stat-subtext">${stats.successfulRequests.toLocaleString()} successful</div>
698
+ </div>
699
+
700
+ <!-- Success Rate Card -->
701
+ <div class="stat-card ${stats.errorRate > 0 ? 'error' : 'success'}">
702
+ <i class="fas fa-chart-line stat-icon"></i>
703
+ <div class="stat-label">Success Rate</div>
704
+ <div class="stat-value">${stats.successRate}%</div>
705
+ <div class="stat-subtext">${stats.failedRequests} failed requests</div>
706
+ </div>
707
+
708
+ <!-- Average Response Time Card -->
709
+ <div class="stat-card ${parseFloat(stats.avgResponseTime) > 1000 ? 'warning' : 'success'}">
710
+ <i class="fas fa-tachometer-alt stat-icon"></i>
711
+ <div class="stat-label">Avg Response Time</div>
712
+ <div class="stat-value">${stats.avgResponseTime}<span style="font-size: 0.5em;">ms</span></div>
713
+ <div class="stat-subtext">P95: ${stats.p95ResponseTime}ms</div>
714
+ </div>
715
+
716
+ <!-- Virtual Users Card -->
717
+ <div class="stat-card">
718
+ <i class="fas fa-users stat-icon"></i>
719
+ <div class="stat-label">Virtual Users</div>
720
+ <div class="stat-value">${stats.maxVUs}</div>
721
+ <div class="stat-subtext">Average: ${stats.avgVUs} VUs</div>
722
+ </div>
723
+
724
+ <!-- Checks Card -->
725
+ <div class="stat-card ${stats.checkFailures > 0 ? 'error' : 'success'}">
726
+ <i class="fas fa-check-circle stat-icon"></i>
727
+ <div class="stat-label">Checks</div>
728
+ <div class="stat-value">${stats.totalChecks}</div>
729
+ <div class="stat-subtext">${stats.checkPasses} passed / ${stats.checkFailures} failed</div>
730
+ </div>
731
+
732
+ <!-- Thresholds Card -->
733
+ <div class="stat-card ${stats.thresholdFailures > 0 ? 'error' : 'success'}">
734
+ <i class="fas fa-exclamation-triangle stat-icon"></i>
735
+ <div class="stat-label">Thresholds</div>
736
+ <div class="stat-value">${stats.thresholdFailures}</div>
737
+ <div class="stat-subtext">of ${stats.thresholdCount} breached</div>
738
+ </div>
739
+ </div>
740
+
741
+ <!-- ========================================
742
+ TABS SECTION
743
+ ======================================== -->
744
+ <div class="tabs-container">
745
+ <!-- Tab Navigation Buttons -->
746
+ <div class="tabs">
747
+ <button class="tab-button active" onclick="switchTab(event, 'overview')">
748
+ <i class="fas fa-chart-pie"></i> Overview
749
+ </button>
750
+ <button class="tab-button" onclick="switchTab(event, 'metrics')">
751
+ <i class="fas fa-table"></i> Detailed Metrics
752
+ </button>
753
+ <button class="tab-button" onclick="switchTab(event, 'checks')">
754
+ <i class="fas fa-tasks"></i> Checks & Groups
755
+ </button>
756
+ <button class="tab-button" onclick="switchTab(event, 'thresholds')">
757
+ <i class="fas fa-gauge-high"></i> Thresholds
758
+ </button>
759
+ <!-- Conditional Test Info tab (only if additional info provided) -->
760
+ ${Object.keys(additionalInfo).length > 0 ? `
761
+ <button class="tab-button" onclick="switchTab(event, 'testinfo')">
762
+ <i class="fas fa-info-circle"></i> Test Info
763
+ </button>
764
+ ` : ''}
765
+ </div>
766
+
767
+ <!-- Tab Content Panels -->
768
+
769
+ <!-- Overview Tab - Charts and graphs -->
770
+ <div id="overview" class="tab-content active">
771
+ ${generateOverviewSection(stats)}
772
+ </div>
773
+
774
+ <!-- Metrics Tab - Detailed metrics table -->
775
+ <div id="metrics" class="tab-content">
776
+ ${generateMetricsTable(data)}
777
+ </div>
778
+
779
+ <!-- Checks Tab - Test checks and validations -->
780
+ <div id="checks" class="tab-content">
781
+ ${generateChecksSection(data)}
782
+ </div>
783
+
784
+ <!-- Thresholds Tab - Threshold pass/fail status -->
785
+ <div id="thresholds" class="tab-content">
786
+ ${generateThresholdsSection(data)}
787
+ </div>
788
+
789
+ <!-- Test Info Tab - Additional configuration details -->
790
+ ${Object.keys(additionalInfo).length > 0 ? `
791
+ <div id="testinfo" class="tab-content">
792
+ ${generateTestInfoSection(additionalInfo)}
793
+ </div>
794
+ ` : ''}
795
+ </div>
796
+
797
+ <!-- ========================================
798
+ FOOTER
799
+ ======================================== -->
800
+ <div class="footer">
801
+ <p><strong>K6 Modern Reporter by Samin Azhan</strong></p>
802
+ <p>Generated with ❤️ by K6 Performance Testing Suite</p>
803
+ <p style="margin-top: 10px;">
804
+ <a href="https://github.com/Samin005/k6-modern-reporter" target="_blank">Documentation</a> |
805
+ <a href="https://github.com/Samin005" target="_blank">GitHub</a>
806
+ </p>
807
+ </div>
808
+ </div>
809
+
810
+ <!-- ========================================
811
+ JAVASCRIPT - Tab Switching & Animations
812
+ ======================================== -->
813
+ <script>
814
+ /**
815
+ * Switch between tabs when clicking tab buttons
816
+ * @param {Event} event - The click event
817
+ * @param {string} tabId - The ID of the tab to show
818
+ */
819
+ function switchTab(event, tabId) {
820
+ // Remove active class from all tab buttons
821
+ document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
822
+ // Hide all tab content panels
823
+ document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
824
+
825
+ // Add active class to clicked button
826
+ event.currentTarget.classList.add('active');
827
+ // Show selected tab content
828
+ document.getElementById(tabId).classList.add('active');
829
+ }
830
+
831
+ /**
832
+ * Animate progress bars on page load
833
+ * Progress bars start at 0 width and animate to their final width
834
+ */
835
+ window.addEventListener('load', () => {
836
+ document.querySelectorAll('.progress-fill').forEach(bar => {
837
+ const width = bar.style.width; // Store final width
838
+ bar.style.width = '0'; // Start at 0
839
+ setTimeout(() => { bar.style.width = width; }, 100); // Animate to final width
840
+ });
841
+ });
842
+ </script>
843
+ </body>
844
+ </html>`;
845
+ }
846
+
847
+ /**
848
+ * Calculate summary statistics from k6 test data
849
+ * Processes metrics, checks, and thresholds to generate summary numbers
850
+ *
851
+ * @param {Object} data - The k6 test results data object
852
+ * @returns {Object} Object containing calculated statistics
853
+ */
854
+ function calculateStats(data) {
855
+ // Initialize stats object with default values
856
+ const stats = {
857
+ totalRequests: 0,
858
+ successfulRequests: 0,
859
+ failedRequests: 0,
860
+ successRate: 0,
861
+ errorRate: 0,
862
+ avgResponseTime: 0,
863
+ p95ResponseTime: 0,
864
+ maxResponseTime: 0,
865
+ minResponseTime: 0,
866
+ thresholdFailures: 0,
867
+ thresholdCount: 0,
868
+ checkFailures: 0,
869
+ checkPasses: 0,
870
+ totalChecks: 0,
871
+ maxVUs: 0,
872
+ avgVUs: 0,
873
+ iterations: 0,
874
+ dataReceived: 0,
875
+ dataSent: 0,
876
+ testDuration: 0
877
+ };
878
+
879
+ // Extract HTTP request count
880
+ if (data.metrics.http_reqs) {
881
+ stats.totalRequests = data.metrics.http_reqs.values.count || 0;
882
+ }
883
+
884
+ // Calculate success/failure rates
885
+ if (data.metrics.http_req_failed) {
886
+ stats.failedRequests = Math.round((data.metrics.http_req_failed.values.rate || 0) * stats.totalRequests);
887
+ stats.successfulRequests = stats.totalRequests - stats.failedRequests;
888
+ stats.errorRate = ((data.metrics.http_req_failed.values.rate || 0) * 100).toFixed(2);
889
+ stats.successRate = (100 - stats.errorRate).toFixed(2);
890
+ }
891
+
892
+ // Extract response time metrics (avg, p95, min, max)
893
+ if (data.metrics.http_req_duration) {
894
+ const duration = data.metrics.http_req_duration.values;
895
+ stats.avgResponseTime = (duration.avg || 0).toFixed(2);
896
+ stats.p95ResponseTime = (duration['p(95)'] || 0).toFixed(2);
897
+ stats.maxResponseTime = (duration.max || 0).toFixed(2);
898
+ stats.minResponseTime = (duration.min || 0).toFixed(2);
899
+ }
900
+
901
+ // Extract Virtual User (VU) metrics
902
+ if (data.metrics.vus) {
903
+ stats.maxVUs = data.metrics.vus.values.max || 0;
904
+ stats.avgVUs = (data.metrics.vus.values.avg || 0).toFixed(0);
905
+ }
906
+
907
+ // Extract iteration count
908
+ if (data.metrics.iterations) {
909
+ stats.iterations = data.metrics.iterations.values.count || 0;
910
+ }
911
+
912
+ // Extract data transfer metrics (convert bytes to megabytes)
913
+ if (data.metrics.data_received) {
914
+ stats.dataReceived = (data.metrics.data_received.values.count / 1000000).toFixed(2);
915
+ }
916
+ if (data.metrics.data_sent) {
917
+ stats.dataSent = (data.metrics.data_sent.values.count / 1000000).toFixed(2);
918
+ }
919
+
920
+ // Count threshold failures across all metrics
921
+ for (let metricName in data.metrics) {
922
+ if (data.metrics[metricName].thresholds) {
923
+ stats.thresholdCount++;
924
+ const thresholds = data.metrics[metricName].thresholds;
925
+ for (let thres in thresholds) {
926
+ if (!thresholds[thres].ok) {
927
+ stats.thresholdFailures++;
928
+ }
929
+ }
930
+ }
931
+ }
932
+
933
+ // Count check passes and failures from root group
934
+ if (data.root_group.checks) {
935
+ const { passes, fails } = countChecks(data.root_group.checks);
936
+ stats.checkPasses += passes;
937
+ stats.checkFailures += fails;
938
+ }
939
+
940
+ // Count check passes and failures from all sub-groups
941
+ for (let group of data.root_group.groups || []) {
942
+ if (group.checks) {
943
+ const { passes, fails } = countChecks(group.checks);
944
+ stats.checkPasses += passes;
945
+ stats.checkFailures += fails;
946
+ }
947
+ }
948
+
949
+ stats.totalChecks = stats.checkPasses + stats.checkFailures;
950
+
951
+ return stats;
952
+ }
953
+
954
+ /**
955
+ * Count total passes and fails from an array of checks
956
+ *
957
+ * @param {Array} checks - Array of check objects
958
+ * @returns {Object} Object with passes and fails counts
959
+ */
960
+ function countChecks(checks) {
961
+ let passes = 0;
962
+ let fails = 0;
963
+ for (let check of checks) {
964
+ passes += parseInt(check.passes || 0);
965
+ fails += parseInt(check.fails || 0);
966
+ }
967
+ return { passes, fails };
968
+ }
969
+
970
+ /**
971
+ * Generate the metrics table HTML for detailed HTTP metrics
972
+ * Shows timing breakdowns like duration, waiting, connecting, etc.
973
+ *
974
+ * @param {Object} data - The k6 test results data
975
+ * @returns {string} HTML string for the metrics table
976
+ */
977
+ function generateMetricsTable(data) {
978
+ // List of standard k6 HTTP timing metrics to display
979
+ const standardMetrics = [
980
+ 'http_req_duration',
981
+ 'http_req_waiting',
982
+ 'http_req_connecting',
983
+ 'http_req_tls_handshaking',
984
+ 'http_req_sending',
985
+ 'http_req_receiving',
986
+ 'http_req_blocked',
987
+ 'iteration_duration',
988
+ ];
989
+
990
+ let html = '<div class="chart-container"><h3 class="chart-title">HTTP Request Metrics</h3>';
991
+ html += '<table class="metrics-table"><thead><tr>';
992
+ html += '<th>Metric</th><th>Avg</th><th>Min</th><th>Med</th><th>Max</th><th>P90</th><th>P95</th>';
993
+ html += '</tr></thead><tbody>';
994
+
995
+ // Generate table row for each metric
996
+ for (let metricName of standardMetrics) {
997
+ if (data.metrics[metricName]) {
998
+ const metric = data.metrics[metricName];
999
+ const values = metric.values;
1000
+
1001
+ html += '<tr>';
1002
+ html += `<td><strong>${metricName}</strong></td>`;
1003
+ html += `<td>${(values.avg || 0).toFixed(2)}</td>`;
1004
+ html += `<td class="metric-value-good">${(values.min || 0).toFixed(2)}</td>`;
1005
+ html += `<td>${(values.med || 0).toFixed(2)}</td>`;
1006
+ html += `<td class="metric-value-bad">${(values.max || 0).toFixed(2)}</td>`;
1007
+ html += `<td>${(values['p(90)'] || 0).toFixed(2)}</td>`;
1008
+ html += `<td>${(values['p(95)'] || 0).toFixed(2)}</td>`;
1009
+ html += '</tr>';
1010
+ }
1011
+ }
1012
+
1013
+ html += '</tbody></table></div>';
1014
+ html += '<p style="margin-top: 10px; color: #6c757d; font-size: 0.9em;"><i class="fas fa-info-circle"></i> All times are in milliseconds</p>';
1015
+
1016
+ return html;
1017
+ }
1018
+
1019
+ /**
1020
+ * Generate the checks section HTML
1021
+ * Displays all test checks organized by groups
1022
+ *
1023
+ * @param {Object} data - The k6 test results data
1024
+ * @returns {string} HTML string for the checks section
1025
+ */
1026
+ function generateChecksSection(data) {
1027
+ let html = '';
1028
+
1029
+ // Process checks from all groups
1030
+ if (data.root_group.groups && data.root_group.groups.length > 0) {
1031
+ for (let group of data.root_group.groups) {
1032
+ html += `<div class="chart-container">`;
1033
+ html += `<h3 class="chart-title"><i class="fas fa-layer-group"></i> Group: ${escapeHtml(group.name)}</h3>`;
1034
+
1035
+ // Display each check in the group
1036
+ if (group.checks && group.checks.length > 0) {
1037
+ for (let check of group.checks) {
1038
+ const isPassed = check.fails === 0;
1039
+ html += `<div class="check-item">`;
1040
+ html += `<div class="check-name">${escapeHtml(check.name)}</div>`;
1041
+ html += `<div class="check-stats">`;
1042
+ html += `<span class="badge badge-success"><i class="fas fa-check"></i> ${check.passes} passed</span>`;
1043
+ if (check.fails > 0) {
1044
+ html += `<span class="badge badge-error"><i class="fas fa-times"></i> ${check.fails} failed</span>`;
1045
+ }
1046
+ html += `</div></div>`;
1047
+ }
1048
+ } else {
1049
+ html += `<p style="color: #6c757d;">No checks in this group</p>`;
1050
+ }
1051
+
1052
+ html += `</div>`;
1053
+ }
1054
+ }
1055
+
1056
+ // Process checks from root group (ungrouped checks)
1057
+ if (data.root_group.checks && data.root_group.checks.length > 0) {
1058
+ html += `<div class="chart-container">`;
1059
+ html += `<h3 class="chart-title"><i class="fas fa-list-check"></i> Other Checks</h3>`;
1060
+
1061
+ for (let check of data.root_group.checks) {
1062
+ html += `<div class="check-item">`;
1063
+ html += `<div class="check-name">${escapeHtml(check.name)}</div>`;
1064
+ html += `<div class="check-stats">`;
1065
+ html += `<span class="badge badge-success"><i class="fas fa-check"></i> ${check.passes} passed</span>`;
1066
+ if (check.fails > 0) {
1067
+ html += `<span class="badge badge-error"><i class="fas fa-times"></i> ${check.fails} failed</span>`;
1068
+ }
1069
+ html += `</div></div>`;
1070
+ }
1071
+
1072
+ html += `</div>`;
1073
+ }
1074
+
1075
+ // Show message if no checks were configured
1076
+ if (!html) {
1077
+ html = '<div class="chart-container"><p style="color: #6c757d;">No checks configured for this test</p></div>';
1078
+ }
1079
+
1080
+ return html;
1081
+ }
1082
+
1083
+ /**
1084
+ * Generate the thresholds section HTML
1085
+ * Displays pass/fail status for all configured thresholds
1086
+ *
1087
+ * @param {Object} data - The k6 test results data
1088
+ * @returns {string} HTML string for the thresholds section
1089
+ */
1090
+ function generateThresholdsSection(data) {
1091
+ let html = '<div class="chart-container">';
1092
+ html += '<h3 class="chart-title"><i class="fas fa-gauge-high"></i> Threshold Results</h3>';
1093
+
1094
+ let hasThresholds = false;
1095
+ let passedCount = 0;
1096
+ let failedCount = 0;
1097
+
1098
+ // Iterate through all metrics to find thresholds
1099
+ for (let metricName in data.metrics) {
1100
+ const metric = data.metrics[metricName];
1101
+
1102
+ if (metric.thresholds) {
1103
+ hasThresholds = true;
1104
+
1105
+ // Check each threshold for this metric
1106
+ for (let thresholdName in metric.thresholds) {
1107
+ const threshold = metric.thresholds[thresholdName];
1108
+ const isPassed = threshold.ok;
1109
+
1110
+ if (isPassed) {
1111
+ passedCount++;
1112
+ } else {
1113
+ failedCount++;
1114
+ }
1115
+
1116
+ // Create threshold result card
1117
+ html += `<div class="check-item" style="border-left: 4px solid ${isPassed ? '#28a745' : '#dc3545'};">`;
1118
+ html += `<div style="flex: 1;">`;
1119
+ html += `<div style="font-weight: 600; margin-bottom: 5px;">`;
1120
+ html += `<i class="fas fa-${isPassed ? 'check-circle' : 'times-circle'}" style="color: ${isPassed ? '#28a745' : '#dc3545'};"></i> `;
1121
+ html += `${escapeHtml(metricName)}`;
1122
+ html += `</div>`;
1123
+ html += `<div style="color: #6c757d; font-size: 0.9em; font-family: monospace;">`;
1124
+ html += `${escapeHtml(thresholdName)}`;
1125
+ html += `</div>`;
1126
+ html += `</div>`;
1127
+ html += `<div class="check-stats">`;
1128
+
1129
+ if (isPassed) {
1130
+ html += `<span class="badge badge-success">`;
1131
+ html += `<i class="fas fa-check"></i> PASSED`;
1132
+ html += `</span>`;
1133
+ } else {
1134
+ html += `<span class="badge badge-error">`;
1135
+ html += `<i class="fas fa-times"></i> FAILED`;
1136
+ html += `</span>`;
1137
+ }
1138
+
1139
+ html += `</div>`;
1140
+ html += `</div>`;
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ // Show message if no thresholds configured
1146
+ if (!hasThresholds) {
1147
+ html += '<p style="color: #6c757d; text-align: center; padding: 40px;">No thresholds configured for this test</p>';
1148
+ } else {
1149
+ // Add summary cards at the top showing passed/failed counts
1150
+ html = `<div class="chart-container">
1151
+ <h3 class="chart-title"><i class="fas fa-gauge-high"></i> Threshold Results</h3>
1152
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 30px;">
1153
+ <div style="background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%); padding: 20px; border-radius: 10px; text-align: center;">
1154
+ <div style="font-size: 2.5em; font-weight: 700; color: #155724;">${passedCount}</div>
1155
+ <div style="color: #155724; font-weight: 600;">Passed</div>
1156
+ </div>
1157
+ <div style="background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%); padding: 20px; border-radius: 10px; text-align: center;">
1158
+ <div style="font-size: 2.5em; font-weight: 700; color: #721c24;">${failedCount}</div>
1159
+ <div style="color: #721c24; font-weight: 600;">Failed</div>
1160
+ </div>
1161
+ </div>` + html.substring(html.indexOf('</h3>') + 5);
1162
+ }
1163
+
1164
+ html += '</div>';
1165
+ return html;
1166
+ }
1167
+
1168
+ /**
1169
+ * Generate the test info section HTML
1170
+ * Displays additional test configuration and metadata
1171
+ *
1172
+ * @param {Object} additionalInfo - Key-value pairs of test information
1173
+ * @returns {string} HTML string for the test info section
1174
+ */
1175
+ function generateTestInfoSection(additionalInfo) {
1176
+ let html = '<div class="chart-container">';
1177
+ html += '<h3 class="chart-title"><i class="fas fa-info-circle"></i> Test Configuration & Additional Information</h3>';
1178
+
1179
+ html += '<table class="info-table">';
1180
+
1181
+ // Generate table row for each info item
1182
+ for (let key in additionalInfo) {
1183
+ html += '<tr>';
1184
+ html += `<td><i class="fas fa-caret-right" style="color: #667eea; margin-right: 8px;"></i>${escapeHtml(key)}</td>`;
1185
+ html += `<td>${escapeHtml(String(additionalInfo[key]))}</td>`;
1186
+ html += '</tr>';
1187
+ }
1188
+
1189
+ html += '</table>';
1190
+ html += '</div>';
1191
+
1192
+ return html;
1193
+ }
1194
+
1195
+ /**
1196
+ * Generate the overview section HTML
1197
+ * Contains performance charts and data transfer statistics
1198
+ *
1199
+ * @param {Object} stats - Calculated statistics object
1200
+ * @returns {string} HTML string for the overview section
1201
+ */
1202
+ function generateOverviewSection(stats) {
1203
+ return `
1204
+ <div class="chart-container">
1205
+ <h3 class="chart-title">Performance Overview</h3>
1206
+
1207
+ <!-- Success Rate Progress Bar -->
1208
+ <div style="margin: 30px 0;">
1209
+ <div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
1210
+ <span>Success Rate</span>
1211
+ <span class="metric-value-good">${stats.successRate}%</span>
1212
+ </div>
1213
+ <div class="progress-bar">
1214
+ <div class="progress-fill" style="width: ${stats.successRate}%">${stats.successRate}%</div>
1215
+ </div>
1216
+ </div>
1217
+
1218
+ <!-- Error Rate Progress Bar -->
1219
+ <div style="margin: 30px 0;">
1220
+ <div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
1221
+ <span>Error Rate</span>
1222
+ <span class="metric-value-bad">${stats.errorRate}%</span>
1223
+ </div>
1224
+ <div class="progress-bar">
1225
+ <div class="progress-fill error" style="width: ${stats.errorRate}%">${stats.errorRate}%</div>
1226
+ </div>
1227
+ </div>
1228
+
1229
+ <!-- Response Time Statistics Grid -->
1230
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-top: 30px;">
1231
+ <!-- Minimum Response Time -->
1232
+ <div style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 10px;">
1233
+ <div style="font-size: 0.9em; color: #6c757d; margin-bottom: 5px;">MIN</div>
1234
+ <div style="font-size: 1.8em; font-weight: 700; color: #28a745;">${stats.minResponseTime}ms</div>
1235
+ </div>
1236
+ <!-- Average Response Time -->
1237
+ <div style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 10px;">
1238
+ <div style="font-size: 0.9em; color: #6c757d; margin-bottom: 5px;">AVG</div>
1239
+ <div style="font-size: 1.8em; font-weight: 700; color: #667eea;">${stats.avgResponseTime}ms</div>
1240
+ </div>
1241
+ <!-- 95th Percentile Response Time -->
1242
+ <div style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 10px;">
1243
+ <div style="font-size: 0.9em; color: #6c757d; margin-bottom: 5px;">P95</div>
1244
+ <div style="font-size: 1.8em; font-weight: 700; color: #f2994a;">${stats.p95ResponseTime}ms</div>
1245
+ </div>
1246
+ <!-- Maximum Response Time -->
1247
+ <div style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 10px;">
1248
+ <div style="font-size: 0.9em; color: #6c757d; margin-bottom: 5px;">MAX</div>
1249
+ <div style="font-size: 1.8em; font-weight: 700; color: #dc3545;">${stats.maxResponseTime}ms</div>
1250
+ </div>
1251
+ </div>
1252
+ </div>
1253
+
1254
+ <!-- Data Transfer Statistics -->
1255
+ <div class="chart-container">
1256
+ <h3 class="chart-title">Data Transfer</h3>
1257
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 30px; margin-top: 20px;">
1258
+ <!-- Data Received -->
1259
+ <div style="text-align: center;">
1260
+ <i class="fas fa-download" style="font-size: 3em; color: #667eea; opacity: 0.3;"></i>
1261
+ <div style="font-size: 2em; font-weight: 700; margin: 10px 0;">${stats.dataReceived} MB</div>
1262
+ <div style="color: #6c757d;">Data Received</div>
1263
+ </div>
1264
+ <!-- Data Sent -->
1265
+ <div style="text-align: center;">
1266
+ <i class="fas fa-upload" style="font-size: 3em; color: #764ba2; opacity: 0.3;"></i>
1267
+ <div style="font-size: 2em; font-weight: 700; margin: 10px 0;">${stats.dataSent} MB</div>
1268
+ <div style="color: #6c757d;">Data Sent</div>
1269
+ </div>
1270
+ </div>
1271
+ </div>
1272
+ `;
1273
+ }
1274
+
1275
+ /**
1276
+ * Escape HTML special characters to prevent XSS attacks
1277
+ * Converts characters like <, >, &, etc. to HTML entities
1278
+ *
1279
+ * @param {string} text - Text to escape
1280
+ * @returns {string} Escaped HTML-safe text
1281
+ */
1282
+ function escapeHtml(text) {
1283
+ const map = {
1284
+ '&': '&amp;',
1285
+ '<': '&lt;',
1286
+ '>': '&gt;',
1287
+ '"': '&quot;',
1288
+ "'": '&#039;'
1289
+ };
1290
+ return String(text).replace(/[&<>"']/g, m => map[m]);
1291
+ }