a cache for slack profile pictures and emojis

feat: implement cleaner and cache charts

dunkirk.sh 9e2761e3 1d4774ed

verified
Changed files
+1238 -201
src
+47 -2
src/cache.ts
··· 46 46 private db: Database; 47 47 private defaultExpiration: number; // in hours 48 48 private onEmojiExpired?: () => void; 49 + private analyticsCache: Map<string, { data: any; timestamp: number }> = new Map(); 50 + private analyticsCacheTTL = 60000; // 1 minute cache for analytics 49 51 50 52 /** 51 53 * Creates a new Cache instance ··· 121 123 CREATE INDEX IF NOT EXISTS idx_request_analytics_endpoint 122 124 ON request_analytics(endpoint) 123 125 `); 126 + 127 + this.db.run(` 128 + CREATE INDEX IF NOT EXISTS idx_request_analytics_status_timestamp 129 + ON request_analytics(status_code, timestamp) 130 + `); 131 + 132 + this.db.run(` 133 + CREATE INDEX IF NOT EXISTS idx_request_analytics_response_time 134 + ON request_analytics(response_time) WHERE response_time IS NOT NULL 135 + `); 136 + 137 + this.db.run(` 138 + CREATE INDEX IF NOT EXISTS idx_request_analytics_composite 139 + ON request_analytics(timestamp, endpoint, status_code) 140 + `); 141 + 142 + // Enable WAL mode for better concurrent performance 143 + this.db.run('PRAGMA journal_mode = WAL'); 144 + this.db.run('PRAGMA synchronous = NORMAL'); 145 + this.db.run('PRAGMA cache_size = 10000'); 146 + this.db.run('PRAGMA temp_store = memory'); 124 147 125 148 // check if there are any emojis in the db 126 149 if (this.onEmojiExpired) { ··· 488 511 } 489 512 490 513 /** 491 - * Gets request analytics statistics 514 + * Gets request analytics statistics with performance optimizations 492 515 * @param days Number of days to look back (default: 7) 493 516 * @returns Analytics data 494 517 */ ··· 559 582 total: number; 560 583 }>; 561 584 }> { 585 + // Check cache first 586 + const cacheKey = `analytics_${days}`; 587 + const cached = this.analyticsCache.get(cacheKey); 588 + const now = Date.now(); 589 + 590 + if (cached && (now - cached.timestamp) < this.analyticsCacheTTL) { 591 + return cached.data; 592 + } 562 593 const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000; 563 594 564 595 // Total requests (excluding stats endpoint) ··· 1329 1360 .sort((a, b) => a.time.localeCompare(b.time)); 1330 1361 } 1331 1362 1332 - return { 1363 + const result = { 1333 1364 totalRequests: totalResult.count, 1334 1365 requestsByEndpoint: requestsByEndpoint, 1335 1366 requestsByStatus: statusResults, ··· 1361 1392 }, 1362 1393 trafficOverview, 1363 1394 }; 1395 + 1396 + // Cache the result 1397 + this.analyticsCache.set(cacheKey, { 1398 + data: result, 1399 + timestamp: now 1400 + }); 1401 + 1402 + // Clean up old cache entries (keep only last 5) 1403 + if (this.analyticsCache.size > 5) { 1404 + const oldestKey = Array.from(this.analyticsCache.keys())[0]; 1405 + this.analyticsCache.delete(oldestKey); 1406 + } 1407 + 1408 + return result; 1364 1409 } 1365 1410 } 1366 1411
+1191 -199
src/dashboard.html
··· 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 6 <title>Cachet Analytics Dashboard</title> 7 - <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> 7 + <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script> 8 + <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script> 8 9 <style> 9 10 * { 10 11 margin: 0; ··· 15 16 body { 16 17 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 17 18 sans-serif; 18 - background: #f5f5f5; 19 - color: #333; 19 + background: #f8fafc; 20 + color: #1e293b; 21 + line-height: 1.6; 20 22 } 21 23 22 24 .header { 23 25 background: #fff; 24 26 padding: 1rem 2rem; 25 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 27 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 26 28 margin-bottom: 2rem; 27 29 display: flex; 28 30 justify-content: space-between; 29 31 align-items: center; 32 + border-bottom: 1px solid #e2e8f0; 30 33 } 31 34 32 35 .header h1 { ··· 45 48 46 49 .controls { 47 50 margin-bottom: 2rem; 48 - text-align: center; 51 + display: flex; 52 + justify-content: center; 53 + align-items: center; 54 + gap: 1rem; 55 + flex-wrap: wrap; 49 56 } 50 57 51 58 .controls select, 52 59 .controls button { 53 - padding: 0.5rem 1rem; 54 - margin: 0 0.5rem; 55 - border: 1px solid #ddd; 56 - border-radius: 4px; 60 + padding: 0.75rem 1.25rem; 61 + border: 1px solid #d1d5db; 62 + border-radius: 8px; 57 63 background: white; 58 64 cursor: pointer; 65 + font-size: 0.875rem; 66 + font-weight: 500; 67 + transition: all 0.2s ease; 68 + } 69 + 70 + .controls select:hover, 71 + .controls select:focus { 72 + border-color: #3b82f6; 73 + outline: none; 74 + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); 59 75 } 60 76 61 77 .controls button { 62 - background: #3498db; 78 + background: #3b82f6; 63 79 color: white; 64 80 border: none; 65 81 } 66 82 67 83 .controls button:hover { 68 - background: #2980b9; 84 + background: #2563eb; 85 + transform: translateY(-1px); 86 + } 87 + 88 + .controls button:disabled { 89 + background: #9ca3af; 90 + cursor: not-allowed; 91 + transform: none; 69 92 } 70 93 71 94 .dashboard { ··· 84 107 .stat-card { 85 108 background: white; 86 109 padding: 1.5rem; 87 - border-radius: 8px; 88 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 110 + border-radius: 12px; 111 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 89 112 text-align: center; 113 + border: 1px solid #e2e8f0; 114 + transition: all 0.2s ease; 115 + position: relative; 116 + overflow: hidden; 117 + display: flex; 118 + flex-direction: column; 119 + justify-content: center; 120 + min-height: 120px; 121 + } 122 + 123 + .stat-card:hover { 124 + transform: translateY(-2px); 125 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 126 + } 127 + 128 + .stat-card.loading { 129 + opacity: 0.6; 130 + } 131 + 132 + .stat-card.loading::after { 133 + content: ''; 134 + position: absolute; 135 + top: 0; 136 + left: -100%; 137 + width: 100%; 138 + height: 100%; 139 + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); 140 + animation: shimmer 1.5s infinite; 141 + } 142 + 143 + @keyframes shimmer { 144 + 0% { left: -100%; } 145 + 100% { left: 100%; } 90 146 } 91 147 92 148 .stat-number { 93 - font-size: 2rem; 94 - font-weight: bold; 95 - color: #3498db; 149 + font-weight: 700; 150 + color: #1f2937; 151 + margin-bottom: 0.25rem; 152 + word-break: break-word; 153 + overflow-wrap: break-word; 154 + line-height: 1.1; 155 + 156 + /* Responsive font sizing using clamp() */ 157 + font-size: clamp(1.25rem, 4vw, 2.5rem); 96 158 } 97 159 98 160 .stat-label { 99 - color: #666; 161 + color: #4b5563; 100 162 margin-top: 0.5rem; 163 + font-weight: 500; 164 + line-height: 1.3; 165 + 166 + /* Responsive font sizing for labels */ 167 + font-size: clamp(0.75rem, 2vw, 0.875rem); 168 + } 169 + 170 + /* Container query support for modern browsers */ 171 + @supports (container-type: inline-size) { 172 + .stats-grid { 173 + container-type: inline-size; 174 + } 175 + 176 + @container (max-width: 250px) { 177 + .stat-number { 178 + font-size: 1.25rem; 179 + } 180 + .stat-label { 181 + font-size: 0.75rem; 182 + } 183 + } 184 + 185 + @container (min-width: 300px) { 186 + .stat-number { 187 + font-size: 2rem; 188 + } 189 + .stat-label { 190 + font-size: 0.875rem; 191 + } 192 + } 193 + 194 + @container (min-width: 400px) { 195 + .stat-number { 196 + font-size: 2.5rem; 197 + } 198 + .stat-label { 199 + font-size: 1rem; 200 + } 201 + } 101 202 } 102 203 103 204 .charts-grid { ··· 107 208 margin-bottom: 2rem; 108 209 } 109 210 211 + @media (max-width: 480px) { 212 + .charts-grid { 213 + grid-template-columns: 1fr; 214 + } 215 + 216 + .chart-container { 217 + min-height: 250px; 218 + } 219 + 220 + .stats-grid { 221 + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); 222 + } 223 + 224 + .stat-card { 225 + padding: 1rem; 226 + min-height: 100px; 227 + } 228 + 229 + .stat-number { 230 + font-size: clamp(0.9rem, 4vw, 1.5rem) !important; 231 + } 232 + 233 + .stat-label { 234 + font-size: clamp(0.65rem, 2.5vw, 0.75rem) !important; 235 + } 236 + } 237 + 110 238 .chart-container { 111 239 background: white; 112 240 padding: 1.5rem; 113 - border-radius: 8px; 114 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 241 + border-radius: 12px; 242 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 243 + border: 1px solid #e2e8f0; 244 + position: relative; 245 + min-height: 350px; 246 + max-height: 500px; 247 + overflow: hidden; 248 + display: flex; 249 + flex-direction: column; 250 + } 251 + 252 + .chart-container.loading { 253 + display: flex; 254 + align-items: center; 255 + justify-content: center; 256 + } 257 + 258 + .chart-container.loading::before { 259 + content: 'Loading chart...'; 260 + color: #64748b; 261 + font-size: 0.875rem; 115 262 } 116 263 117 264 .chart-title { 118 - font-size: 1.2rem; 265 + font-size: 1.125rem; 119 266 margin-bottom: 1rem; 120 - color: #2c3e50; 267 + padding-top: 1rem; 268 + color: #1e293b; 269 + font-weight: 600; 270 + display: block; 271 + width: 100%; 272 + text-align: left; 273 + word-break: break-word; 274 + overflow-wrap: break-word; 275 + } 276 + 277 + .chart-title-with-indicator { 278 + display: flex; 279 + align-items: center; 280 + justify-content: space-between; 281 + flex-wrap: wrap; 282 + gap: 0.5rem; 283 + margin-bottom: 1rem; 284 + } 285 + 286 + .chart-title-with-indicator .chart-title { 287 + margin-bottom: 0; 288 + flex: 1; 289 + min-width: 0; 290 + } 291 + 292 + .chart-error { 293 + color: #dc2626; 294 + text-align: center; 295 + padding: 2rem; 296 + font-size: 0.875rem; 121 297 } 122 298 123 299 .loading { 124 300 text-align: center; 125 - padding: 2rem; 126 - color: #666; 301 + padding: 3rem; 302 + color: #64748b; 303 + } 304 + 305 + .loading-spinner { 306 + display: inline-block; 307 + width: 2rem; 308 + height: 2rem; 309 + border: 3px solid #e2e8f0; 310 + border-radius: 50%; 311 + border-top-color: #3b82f6; 312 + animation: spin 1s ease-in-out infinite; 313 + margin-bottom: 1rem; 314 + } 315 + 316 + @keyframes spin { 317 + to { transform: rotate(360deg); } 127 318 } 128 319 129 320 .error { 130 - background: #e74c3c; 131 - color: white; 321 + background: #fef2f2; 322 + color: #dc2626; 132 323 padding: 1rem; 133 - border-radius: 4px; 324 + border-radius: 8px; 134 325 margin: 1rem 0; 326 + border: 1px solid #fecaca; 135 327 } 136 328 137 329 .auto-refresh { 138 330 display: flex; 139 331 align-items: center; 140 332 gap: 0.5rem; 141 - justify-content: center; 142 - margin-top: 1rem; 333 + font-size: 0.875rem; 334 + color: #64748b; 143 335 } 144 336 145 337 .auto-refresh input[type="checkbox"] { 146 - transform: scale(1.2); 338 + transform: scale(1.1); 339 + accent-color: #3b82f6; 340 + } 341 + 342 + .performance-indicator { 343 + display: inline-flex; 344 + align-items: center; 345 + gap: 0.25rem; 346 + font-size: 0.75rem; 347 + color: #64748b; 348 + } 349 + 350 + .performance-indicator.good { color: #059669; } 351 + .performance-indicator.warning { color: #d97706; } 352 + .performance-indicator.error { color: #dc2626; } 353 + 354 + .lazy-chart { 355 + min-height: 300px; 356 + display: flex; 357 + align-items: center; 358 + justify-content: center; 359 + background: #f8fafc; 360 + border-radius: 8px; 361 + margin: 1rem 0; 362 + } 363 + 364 + .lazy-chart.visible { 365 + background: transparent; 147 366 } 148 367 149 368 @media (max-width: 768px) { ··· 159 378 flex-direction: column; 160 379 gap: 1rem; 161 380 text-align: center; 381 + padding: 1rem; 382 + } 383 + 384 + .controls { 385 + flex-direction: column; 386 + align-items: stretch; 162 387 } 388 + 389 + .controls select, 390 + .controls button { 391 + margin: 0.25rem 0; 392 + } 393 + 394 + .stat-number { 395 + font-size: clamp(1rem, 5vw, 1.75rem) !important; 396 + } 397 + 398 + .stat-label { 399 + font-size: clamp(0.7rem, 3vw, 0.8rem) !important; 400 + } 401 + 402 + .chart-container { 403 + padding: 1rem; 404 + min-height: 250px; 405 + } 406 + 407 + .chart-title { 408 + font-size: 1rem; 409 + flex-direction: column; 410 + align-items: flex-start; 411 + } 412 + } 413 + 414 + .toast { 415 + position: fixed; 416 + top: 1rem; 417 + right: 1rem; 418 + background: #1f2937; 419 + color: white; 420 + padding: 0.75rem 1rem; 421 + border-radius: 8px; 422 + font-size: 0.875rem; 423 + z-index: 1000; 424 + transform: translateX(100%); 425 + transition: transform 0.3s ease; 426 + } 427 + 428 + .toast.show { 429 + transform: translateX(0); 430 + } 431 + 432 + .toast.success { 433 + background: #059669; 434 + } 435 + 436 + .toast.error { 437 + background: #dc2626; 163 438 } 164 439 </style> 165 440 </head> ··· 180 455 <option value="7" selected>Last 7 days</option> 181 456 <option value="30">Last 30 days</option> 182 457 </select> 183 - <button onclick="loadData()">Refresh</button> 458 + <button id="refreshBtn" onclick="loadData()">Refresh</button> 184 459 <div class="auto-refresh"> 185 460 <input type="checkbox" id="autoRefresh" /> 186 461 <label for="autoRefresh">Auto-refresh (30s)</label> 187 462 </div> 188 463 </div> 189 464 190 - <div id="loading" class="loading">Loading analytics data...</div> 465 + <div id="loading" class="loading"> 466 + <div class="loading-spinner"></div> 467 + Loading analytics data... 468 + </div> 191 469 <div id="error" class="error" style="display: none"></div> 192 470 193 471 <div id="content" style="display: none"> 472 + <!-- Key Metrics Overview --> 194 473 <div class="chart-container" style="margin-bottom: 2rem; height: 450px"> 195 - <div class="chart-title">Traffic Overview - All Routes Over Time</div> 474 + <div class="chart-title-with-indicator"> 475 + <div class="chart-title">Traffic Overview - All Routes Over Time</div> 476 + <span class="performance-indicator" id="trafficPerformance"></span> 477 + </div> 196 478 <canvas 197 479 id="trafficOverviewChart" 198 480 style="padding-bottom: 2rem" 199 481 ></canvas> 200 482 </div> 201 483 484 + <!-- Stats Grid --> 202 485 <div class="stats-grid"> 203 - <div class="stat-card"> 486 + <div class="stat-card" id="totalRequestsCard"> 204 487 <div class="stat-number" id="totalRequests">-</div> 205 488 <div class="stat-label">Total Requests</div> 206 489 </div> 207 - <div class="stat-card"> 490 + <div class="stat-card" id="avgResponseTimeCard"> 208 491 <div class="stat-number" id="avgResponseTime">-</div> 209 492 <div class="stat-label">Avg Response Time (ms)</div> 210 493 </div> 211 - <div class="stat-card"> 494 + <div class="stat-card" id="p95ResponseTimeCard"> 212 495 <div class="stat-number" id="p95ResponseTime">-</div> 213 496 <div class="stat-label">P95 Response Time (ms)</div> 214 497 </div> 215 - <div class="stat-card"> 498 + <div class="stat-card" id="uniqueEndpointsCard"> 216 499 <div class="stat-number" id="uniqueEndpoints">-</div> 217 500 <div class="stat-label">Unique Endpoints</div> 218 501 </div> 219 - <div class="stat-card"> 502 + <div class="stat-card" id="errorRateCard"> 220 503 <div class="stat-number" id="errorRate">-</div> 221 504 <div class="stat-label">Error Rate (%)</div> 222 505 </div> 223 - <div class="stat-card"> 506 + <div class="stat-card" id="fastRequestsCard"> 224 507 <div class="stat-number" id="fastRequests">-</div> 225 508 <div class="stat-label">Fast Requests (&lt;100ms)</div> 226 509 </div> 227 - <div class="stat-card"> 510 + <div class="stat-card" id="uptimeCard"> 228 511 <div class="stat-number" id="uptime">-</div> 229 512 <div class="stat-label">Uptime (%)</div> 230 513 </div> 231 - <div class="stat-card"> 514 + <div class="stat-card" id="throughputCard"> 232 515 <div class="stat-number" id="throughput">-</div> 233 516 <div class="stat-label">Throughput (req/hr)</div> 234 517 </div> 235 - <div class="stat-card"> 518 + <div class="stat-card" id="apdexCard"> 236 519 <div class="stat-number" id="apdex">-</div> 237 520 <div class="stat-label">APDEX Score</div> 238 521 </div> 239 - <div class="stat-card"> 522 + <div class="stat-card" id="cacheHitRateCard"> 240 523 <div class="stat-number" id="cacheHitRate">-</div> 241 524 <div class="stat-label">Cache Hit Rate (%)</div> 242 525 </div> 243 526 </div> 244 527 528 + <!-- Peak Traffic Stats --> 245 529 <div class="stats-grid"> 246 - <div class="stat-card"> 530 + <div class="stat-card" id="peakHourCard"> 247 531 <div class="stat-number" id="peakHour">-</div> 248 532 <div class="stat-label">Peak Hour</div> 249 533 </div> 250 - <div class="stat-card"> 534 + <div class="stat-card" id="peakHourRequestsCard"> 251 535 <div class="stat-number" id="peakHourRequests">-</div> 252 536 <div class="stat-label">Peak Hour Requests</div> 253 537 </div> 254 - <div class="stat-card"> 538 + <div class="stat-card" id="peakDayCard"> 255 539 <div class="stat-number" id="peakDay">-</div> 256 540 <div class="stat-label">Peak Day</div> 257 541 </div> 258 - <div class="stat-card"> 542 + <div class="stat-card" id="peakDayRequestsCard"> 259 543 <div class="stat-number" id="peakDayRequests">-</div> 260 544 <div class="stat-label">Peak Day Requests</div> 261 545 </div> 262 - <div class="stat-card"> 546 + <div class="stat-card" id="dashboardRequestsCard"> 263 547 <div class="stat-number" id="dashboardRequests">-</div> 264 548 <div class="stat-label">Dashboard Requests</div> 265 549 </div> 266 550 </div> 267 551 552 + <!-- Charts Grid with Lazy Loading --> 268 553 <div class="charts-grid"> 269 - <div class="chart-container"> 554 + <div class="chart-container lazy-chart" data-chart="timeChart"> 270 555 <div class="chart-title">Requests Over Time</div> 271 556 <canvas id="timeChart"></canvas> 272 557 </div> 273 558 274 - <div class="chart-container"> 559 + <div class="chart-container lazy-chart" data-chart="latencyTimeChart"> 275 560 <div class="chart-title">Latency Over Time (Hourly)</div> 276 561 <canvas id="latencyTimeChart"></canvas> 277 562 </div> 278 563 279 - <div class="chart-container"> 564 + <div class="chart-container lazy-chart" data-chart="latencyDistributionChart"> 280 565 <div class="chart-title">Response Time Distribution</div> 281 566 <canvas id="latencyDistributionChart"></canvas> 282 567 </div> 283 568 284 - <div class="chart-container"> 569 + <div class="chart-container lazy-chart" data-chart="percentilesChart"> 285 570 <div class="chart-title">Latency Percentiles</div> 286 571 <canvas id="percentilesChart"></canvas> 287 572 </div> 288 573 289 - <div class="chart-container"> 574 + <div class="chart-container lazy-chart" data-chart="endpointChart"> 290 575 <div class="chart-title">Top Endpoints</div> 291 576 <canvas id="endpointChart"></canvas> 292 577 </div> 293 578 294 - <div class="chart-container"> 579 + <div class="chart-container lazy-chart" data-chart="slowestEndpointsChart"> 295 580 <div class="chart-title">Slowest Endpoints</div> 296 581 <canvas id="slowestEndpointsChart"></canvas> 297 582 </div> 298 583 299 - <div class="chart-container"> 584 + <div class="chart-container lazy-chart" data-chart="statusChart"> 300 585 <div class="chart-title">Status Codes</div> 301 586 <canvas id="statusChart"></canvas> 302 587 </div> 303 588 304 - <div class="chart-container"> 589 + <div class="chart-container lazy-chart" data-chart="userAgentChart"> 305 590 <div class="chart-title">Top User Agents</div> 306 591 <canvas id="userAgentChart"></canvas> 307 592 </div> ··· 312 597 <script> 313 598 let charts = {}; 314 599 let autoRefreshInterval; 600 + let currentData = null; 601 + let isLoading = false; 602 + let visibleCharts = new Set(); 603 + let intersectionObserver; 604 + 605 + // Performance monitoring 606 + const performance = { 607 + startTime: 0, 608 + endTime: 0, 609 + loadTime: 0 610 + }; 611 + 612 + // Initialize intersection observer for lazy loading 613 + function initLazyLoading() { 614 + intersectionObserver = new IntersectionObserver((entries) => { 615 + entries.forEach(entry => { 616 + if (entry.isIntersecting) { 617 + const chartContainer = entry.target; 618 + const chartType = chartContainer.dataset.chart; 619 + 620 + if (!visibleCharts.has(chartType) && currentData) { 621 + visibleCharts.add(chartType); 622 + chartContainer.classList.add('visible'); 623 + loadChart(chartType, currentData); 624 + } 625 + } 626 + }); 627 + }, { 628 + rootMargin: '50px', 629 + threshold: 0.1 630 + }); 631 + 632 + // Observe all lazy chart containers 633 + document.querySelectorAll('.lazy-chart').forEach(container => { 634 + intersectionObserver.observe(container); 635 + }); 636 + } 637 + 638 + // Show toast notification 639 + function showToast(message, type = 'info') { 640 + const toast = document.createElement('div'); 641 + toast.className = `toast ${type}`; 642 + toast.textContent = message; 643 + document.body.appendChild(toast); 644 + 645 + setTimeout(() => toast.classList.add('show'), 100); 646 + setTimeout(() => { 647 + toast.classList.remove('show'); 648 + setTimeout(() => document.body.removeChild(toast), 300); 649 + }, 3000); 650 + } 651 + 652 + // Update loading states for stat cards 653 + function setStatCardLoading(cardId, loading) { 654 + const card = document.getElementById(cardId); 655 + if (card) { 656 + if (loading) { 657 + card.classList.add('loading'); 658 + } else { 659 + card.classList.remove('loading'); 660 + } 661 + } 662 + } 663 + 664 + // Debounced resize handler for charts 665 + let resizeTimeout; 666 + function handleResize() { 667 + clearTimeout(resizeTimeout); 668 + resizeTimeout = setTimeout(() => { 669 + Object.values(charts).forEach(chart => { 670 + if (chart && typeof chart.resize === 'function') { 671 + chart.resize(); 672 + } 673 + }); 674 + }, 250); 675 + } 676 + 677 + window.addEventListener('resize', handleResize); 315 678 316 679 async function loadData() { 680 + if (isLoading) return; 681 + 682 + isLoading = true; 683 + performance.startTime = Date.now(); 684 + 317 685 const days = document.getElementById("daysSelect").value; 318 686 const loading = document.getElementById("loading"); 319 687 const error = document.getElementById("error"); 320 688 const content = document.getElementById("content"); 689 + const refreshBtn = document.getElementById("refreshBtn"); 321 690 691 + // Update UI state 322 692 loading.style.display = "block"; 323 693 error.style.display = "none"; 324 694 content.style.display = "none"; 695 + refreshBtn.disabled = true; 696 + refreshBtn.textContent = "Loading..."; 697 + 698 + // Set all stat cards to loading state 699 + const statCards = [ 700 + 'totalRequestsCard', 'avgResponseTimeCard', 'p95ResponseTimeCard', 701 + 'uniqueEndpointsCard', 'errorRateCard', 'fastRequestsCard', 702 + 'uptimeCard', 'throughputCard', 'apdexCard', 'cacheHitRateCard', 703 + 'peakHourCard', 'peakHourRequestsCard', 'peakDayCard', 704 + 'peakDayRequestsCard', 'dashboardRequestsCard' 705 + ]; 706 + statCards.forEach(cardId => setStatCardLoading(cardId, true)); 325 707 326 708 try { 327 709 const response = await fetch(`/stats?days=${days}`); 328 710 if (!response.ok) throw new Error(`HTTP ${response.status}`); 329 711 330 712 const data = await response.json(); 713 + currentData = data; 714 + 715 + performance.endTime = Date.now(); 716 + performance.loadTime = performance.endTime - performance.startTime; 717 + 331 718 updateDashboard(data); 332 - 719 + 333 720 loading.style.display = "none"; 334 721 content.style.display = "block"; 722 + 723 + showToast(`Dashboard updated in ${performance.loadTime}ms`, 'success'); 335 724 } catch (err) { 336 725 loading.style.display = "none"; 337 726 error.style.display = "block"; 338 727 error.textContent = `Failed to load data: ${err.message}`; 728 + showToast(`Error: ${err.message}`, 'error'); 729 + } finally { 730 + isLoading = false; 731 + refreshBtn.disabled = false; 732 + refreshBtn.textContent = "Refresh"; 733 + 734 + // Remove loading state from stat cards 735 + statCards.forEach(cardId => setStatCardLoading(cardId, false)); 339 736 } 340 737 } 341 738 342 739 function updateDashboard(data) { 343 - // Main metrics 344 - document.getElementById("totalRequests").textContent = 345 - data.totalRequests.toLocaleString(); 346 - document.getElementById("avgResponseTime").textContent = 347 - data.averageResponseTime 348 - ? Math.round(data.averageResponseTime) 349 - : "N/A"; 350 - document.getElementById("p95ResponseTime").textContent = data 351 - .latencyAnalytics.percentiles.p95 352 - ? Math.round(data.latencyAnalytics.percentiles.p95) 353 - : "N/A"; 354 - document.getElementById("uniqueEndpoints").textContent = 355 - data.requestsByEndpoint.length; 740 + // Update main metrics with animation 741 + updateStatWithAnimation("totalRequests", data.totalRequests.toLocaleString()); 742 + updateStatWithAnimation("avgResponseTime", 743 + data.averageResponseTime ? Math.round(data.averageResponseTime) : "N/A"); 744 + updateStatWithAnimation("p95ResponseTime", 745 + data.latencyAnalytics.percentiles.p95 ? Math.round(data.latencyAnalytics.percentiles.p95) : "N/A"); 746 + updateStatWithAnimation("uniqueEndpoints", data.requestsByEndpoint.length); 356 747 357 748 const errorRequests = data.requestsByStatus 358 749 .filter((s) => s.status >= 400) 359 750 .reduce((sum, s) => sum + s.count, 0); 360 - const errorRate = 361 - data.totalRequests > 0 362 - ? ((errorRequests / data.totalRequests) * 100).toFixed(1) 363 - : "0.0"; 364 - document.getElementById("errorRate").textContent = errorRate; 751 + const errorRate = data.totalRequests > 0 752 + ? ((errorRequests / data.totalRequests) * 100).toFixed(1) 753 + : "0.0"; 754 + updateStatWithAnimation("errorRate", errorRate); 365 755 366 756 // Calculate fast requests percentage 367 757 const fastRequestsData = data.latencyAnalytics.distribution 368 758 .filter((d) => d.range === "0-50ms" || d.range === "50-100ms") 369 759 .reduce((sum, d) => sum + d.percentage, 0); 370 - document.getElementById("fastRequests").textContent = 371 - fastRequestsData.toFixed(1) + "%"; 760 + updateStatWithAnimation("fastRequests", fastRequestsData.toFixed(1) + "%"); 372 761 373 762 // Performance metrics 374 - document.getElementById("uptime").textContent = 375 - data.performanceMetrics.uptime.toFixed(1); 376 - document.getElementById("throughput").textContent = Math.round( 377 - data.performanceMetrics.throughput, 378 - ); 379 - document.getElementById("apdex").textContent = 380 - data.performanceMetrics.apdex.toFixed(2); 381 - document.getElementById("cacheHitRate").textContent = 382 - data.performanceMetrics.cachehitRate.toFixed(1); 763 + updateStatWithAnimation("uptime", data.performanceMetrics.uptime.toFixed(1)); 764 + updateStatWithAnimation("throughput", Math.round(data.performanceMetrics.throughput)); 765 + updateStatWithAnimation("apdex", data.performanceMetrics.apdex.toFixed(2)); 766 + updateStatWithAnimation("cacheHitRate", data.performanceMetrics.cachehitRate.toFixed(1)); 383 767 384 768 // Peak traffic 385 - document.getElementById("peakHour").textContent = 386 - data.peakTraffic.peakHour; 387 - document.getElementById("peakHourRequests").textContent = 388 - data.peakTraffic.peakRequests.toLocaleString(); 389 - document.getElementById("peakDay").textContent = 390 - data.peakTraffic.peakDay; 391 - document.getElementById("peakDayRequests").textContent = 392 - data.peakTraffic.peakDayRequests.toLocaleString(); 769 + updateStatWithAnimation("peakHour", data.peakTraffic.peakHour); 770 + updateStatWithAnimation("peakHourRequests", data.peakTraffic.peakRequests.toLocaleString()); 771 + updateStatWithAnimation("peakDay", data.peakTraffic.peakDay); 772 + updateStatWithAnimation("peakDayRequests", data.peakTraffic.peakDayRequests.toLocaleString()); 773 + updateStatWithAnimation("dashboardRequests", data.dashboardMetrics.statsRequests.toLocaleString()); 774 + 775 + // Update performance indicator 776 + updatePerformanceIndicator(data); 777 + 778 + // Load main traffic overview chart immediately 779 + const days = parseInt(document.getElementById("daysSelect").value); 780 + updateTrafficOverviewChart(data.trafficOverview, days); 781 + 782 + // Other charts will be loaded lazily when they come into view 783 + } 784 + 785 + function updateStatWithAnimation(elementId, value) { 786 + const element = document.getElementById(elementId); 787 + if (element && element.textContent !== value.toString()) { 788 + element.style.transform = 'scale(1.1)'; 789 + element.style.transition = 'transform 0.2s ease'; 790 + 791 + setTimeout(() => { 792 + element.textContent = value; 793 + element.style.transform = 'scale(1)'; 794 + }, 100); 795 + } 796 + } 797 + 798 + function updatePerformanceIndicator(data) { 799 + const indicator = document.getElementById('trafficPerformance'); 800 + const avgResponseTime = data.averageResponseTime || 0; 801 + const errorRate = data.requestsByStatus 802 + .filter((s) => s.status >= 400) 803 + .reduce((sum, s) => sum + s.count, 0) / data.totalRequests * 100; 804 + 805 + let status, text; 806 + if (avgResponseTime < 100 && errorRate < 1) { 807 + status = 'good'; 808 + text = '🟢 Excellent'; 809 + } else if (avgResponseTime < 300 && errorRate < 5) { 810 + status = 'warning'; 811 + text = '🟡 Good'; 812 + } else { 813 + status = 'error'; 814 + text = '🔴 Needs Attention'; 815 + } 393 816 394 - // Dashboard metrics 395 - document.getElementById("dashboardRequests").textContent = 396 - data.dashboardMetrics.statsRequests.toLocaleString(); 817 + indicator.className = `performance-indicator ${status}`; 818 + indicator.textContent = text; 819 + } 397 820 398 - // Determine if we're showing hourly or daily data 821 + // Load individual charts (called by intersection observer) 822 + function loadChart(chartType, data) { 399 823 const days = parseInt(document.getElementById("daysSelect").value); 400 824 const isHourly = days === 1; 401 825 402 - updateTrafficOverviewChart(data.trafficOverview, days); 403 - updateTimeChart(data.requestsByDay, isHourly); 404 - updateLatencyTimeChart(data.latencyAnalytics.latencyOverTime, isHourly); 405 - updateLatencyDistributionChart(data.latencyAnalytics.distribution); 406 - updatePercentilesChart(data.latencyAnalytics.percentiles); 407 - updateEndpointChart(data.requestsByEndpoint.slice(0, 10)); 408 - updateSlowestEndpointsChart(data.latencyAnalytics.slowestEndpoints); 409 - updateStatusChart(data.requestsByStatus); 410 - updateUserAgentChart(data.topUserAgents.slice(0, 5)); 826 + try { 827 + switch(chartType) { 828 + case 'timeChart': 829 + updateTimeChart(data.requestsByDay, isHourly); 830 + break; 831 + case 'latencyTimeChart': 832 + updateLatencyTimeChart(data.latencyAnalytics.latencyOverTime, isHourly); 833 + break; 834 + case 'latencyDistributionChart': 835 + updateLatencyDistributionChart(data.latencyAnalytics.distribution); 836 + break; 837 + case 'percentilesChart': 838 + updatePercentilesChart(data.latencyAnalytics.percentiles); 839 + break; 840 + case 'endpointChart': 841 + updateEndpointChart(data.requestsByEndpoint.slice(0, 10)); 842 + break; 843 + case 'slowestEndpointsChart': 844 + updateSlowestEndpointsChart(data.latencyAnalytics.slowestEndpoints); 845 + break; 846 + case 'statusChart': 847 + updateStatusChart(data.requestsByStatus); 848 + break; 849 + case 'userAgentChart': 850 + updateUserAgentChart(data.topUserAgents.slice(0, 5)); 851 + break; 852 + } 853 + } catch (error) { 854 + console.error(`Error loading chart ${chartType}:`, error); 855 + const container = document.querySelector(`[data-chart="${chartType}"]`); 856 + if (container) { 857 + container.innerHTML = `<div class="chart-error">Error loading chart: ${error.message}</div>`; 858 + } 859 + } 411 860 } 412 861 413 862 function updateTrafficOverviewChart(data, days) { 414 - const ctx = document 415 - .getElementById("trafficOverviewChart") 416 - .getContext("2d"); 863 + const canvas = document.getElementById("trafficOverviewChart"); 864 + if (!canvas) { 865 + console.warn('trafficOverviewChart canvas not found'); 866 + return; 867 + } 868 + 869 + const ctx = canvas.getContext("2d"); 870 + if (!ctx) { 871 + console.warn('Could not get 2d context for trafficOverviewChart'); 872 + return; 873 + } 417 874 418 - if (charts.trafficOverview) charts.trafficOverview.destroy(); 875 + if (charts.trafficOverview) { 876 + charts.trafficOverview.destroy(); 877 + } 419 878 420 879 // Update chart title based on granularity 421 - const chartTitle = document 422 - .querySelector("#trafficOverviewChart") 423 - .parentElement.querySelector(".chart-title"); 880 + const chartTitleElement = document.querySelector(".chart-title-with-indicator .chart-title"); 424 881 let titleText = "Traffic Overview - All Routes Over Time"; 425 882 if (days === 1) { 426 883 titleText += " (Hourly)"; ··· 429 886 } else { 430 887 titleText += " (Daily)"; 431 888 } 432 - chartTitle.textContent = titleText; 889 + if (chartTitleElement) { 890 + chartTitleElement.textContent = titleText; 891 + } 433 892 434 893 // Get all unique routes across all time periods 435 894 const allRoutes = new Set(); ··· 441 900 442 901 // Define colors for different route types 443 902 const routeColors = { 444 - Dashboard: "#3498db", 445 - "User Data": "#2ecc71", 446 - "User Redirects": "#27ae60", 447 - "Emoji Data": "#e74c3c", 448 - "Emoji Redirects": "#c0392b", 449 - "Emoji List": "#e67e22", 450 - "Health Check": "#f39c12", 451 - "API Documentation": "#9b59b6", 452 - "Cache Management": "#34495e", 903 + Dashboard: "#3b82f6", 904 + "User Data": "#10b981", 905 + "User Redirects": "#059669", 906 + "Emoji Data": "#ef4444", 907 + "Emoji Redirects": "#dc2626", 908 + "Emoji List": "#f97316", 909 + "Health Check": "#f59e0b", 910 + "API Documentation": "#8b5cf6", 911 + "Cache Management": "#6b7280", 453 912 }; 454 913 455 914 // Create datasets for each route 456 915 const datasets = Array.from(allRoutes).map((route) => { 457 - const color = routeColors[route] || "#95a5a6"; 916 + const color = routeColors[route] || "#9ca3af"; 458 917 return { 459 918 label: route, 460 919 data: data.map((timePoint) => timePoint.routes[route] || 0), 461 920 borderColor: color, 462 - backgroundColor: color + "20", // Add transparency 921 + backgroundColor: color + "20", 463 922 tension: 0.4, 464 923 fill: false, 465 924 pointRadius: 2, ··· 470 929 // Format labels based on time granularity 471 930 const labels = data.map((timePoint) => { 472 931 if (days === 1) { 473 - // Show just hour for 24h view 474 932 return timePoint.time.split(" ")[1] || timePoint.time; 475 933 } else if (days <= 7) { 476 - // Show day and hour for 7-day view 477 934 const parts = timePoint.time.split(" "); 478 - const date = parts[0].split("-")[2]; // Get day 935 + const date = parts[0].split("-")[2]; 479 936 const hour = parts[1] || "00:00"; 480 937 return `${date} ${hour}`; 481 938 } else { 482 - // Show full date for longer periods 483 939 return timePoint.time; 484 940 } 485 941 }); ··· 493 949 options: { 494 950 responsive: true, 495 951 maintainAspectRatio: false, 952 + layout: { 953 + padding: { 954 + left: 10, 955 + right: 10, 956 + top: 10, 957 + bottom: 50 958 + } 959 + }, 960 + animation: { 961 + duration: 750, 962 + easing: 'easeInOutQuart' 963 + }, 496 964 interaction: { 497 965 mode: "index", 498 966 intersect: false, ··· 506 974 font: { 507 975 size: 11, 508 976 }, 977 + boxWidth: 12, 978 + boxHeight: 12, 979 + generateLabels: function(chart) { 980 + const original = Chart.defaults.plugins.legend.labels.generateLabels; 981 + const labels = original.call(this, chart); 982 + 983 + // Add total request count to legend labels 984 + labels.forEach((label, index) => { 985 + const dataset = chart.data.datasets[index]; 986 + const total = dataset.data.reduce((sum, val) => sum + val, 0); 987 + label.text += ` (${total.toLocaleString()})`; 988 + }); 989 + 990 + return labels; 991 + } 509 992 }, 510 993 }, 511 994 tooltip: { 512 995 mode: "index", 513 996 intersect: false, 997 + backgroundColor: 'rgba(0, 0, 0, 0.8)', 998 + titleColor: 'white', 999 + bodyColor: 'white', 1000 + borderColor: 'rgba(255, 255, 255, 0.1)', 1001 + borderWidth: 1, 514 1002 callbacks: { 515 - afterLabel: function (context) { 516 - const timePoint = data[context.dataIndex]; 517 - return `Total: ${timePoint.total} requests`; 1003 + title: function(context) { 1004 + return `Time: ${context[0].label}`; 1005 + }, 1006 + afterBody: function(context) { 1007 + const timePoint = data[context[0].dataIndex]; 1008 + return [ 1009 + `Total Requests: ${timePoint.total.toLocaleString()}`, 1010 + `Peak Route: ${Object.entries(timePoint.routes).sort((a, b) => b[1] - a[1])[0]?.[0] || 'N/A'}` 1011 + ]; 518 1012 }, 519 1013 }, 520 1014 }, ··· 525 1019 title: { 526 1020 display: true, 527 1021 text: days === 1 ? "Hour" : days <= 7 ? "Day & Hour" : "Date", 1022 + font: { 1023 + weight: 'bold' 1024 + } 528 1025 }, 529 1026 ticks: { 530 - maxTicksLimit: 20, 1027 + maxTicksLimit: window.innerWidth < 768 ? 8 : 20, 1028 + maxRotation: 0, // Don't rotate labels 1029 + minRotation: 0, 1030 + callback: function(value, index, values) { 1031 + const label = this.getLabelForValue(value); 1032 + // Truncate long labels 1033 + if (label.length > 8) { 1034 + if (days === 1) { 1035 + return label; // Hours are usually short 1036 + } else { 1037 + // For longer periods, abbreviate 1038 + return label.substring(0, 6) + '...'; 1039 + } 1040 + } 1041 + return label; 1042 + } 1043 + }, 1044 + grid: { 1045 + color: 'rgba(0, 0, 0, 0.05)', 531 1046 }, 532 1047 }, 533 1048 y: { ··· 535 1050 title: { 536 1051 display: true, 537 1052 text: "Requests", 1053 + font: { 1054 + weight: 'bold' 1055 + } 538 1056 }, 539 1057 beginAtZero: true, 1058 + grid: { 1059 + color: 'rgba(0, 0, 0, 0.05)', 1060 + }, 1061 + ticks: { 1062 + callback: function(value) { 1063 + return value.toLocaleString(); 1064 + } 1065 + } 540 1066 }, 541 1067 }, 542 1068 elements: { 543 1069 line: { 544 1070 tension: 0.4, 545 1071 }, 1072 + point: { 1073 + radius: 3, 1074 + hoverRadius: 6, 1075 + hitRadius: 10, 1076 + }, 546 1077 }, 547 1078 }, 548 1079 }); 549 1080 } 550 1081 551 1082 function updateTimeChart(data, isHourly) { 552 - const ctx = document.getElementById("timeChart").getContext("2d"); 1083 + const canvas = document.getElementById("timeChart"); 1084 + if (!canvas) { 1085 + console.warn('timeChart canvas not found'); 1086 + return; 1087 + } 1088 + 1089 + const ctx = canvas.getContext("2d"); 1090 + if (!ctx) { 1091 + console.warn('Could not get 2d context for timeChart'); 1092 + return; 1093 + } 553 1094 554 1095 if (charts.time) charts.time.destroy(); 555 1096 556 - // Update chart title 557 1097 const chartTitle = document 558 1098 .querySelector("#timeChart") 559 1099 .parentElement.querySelector(".chart-title"); 560 - chartTitle.textContent = isHourly 561 - ? "Requests Over Time (Hourly)" 562 - : "Requests Over Time (Daily)"; 1100 + if (chartTitle) { 1101 + chartTitle.textContent = isHourly 1102 + ? "Requests Over Time (Hourly)" 1103 + : "Requests Over Time (Daily)"; 1104 + } 563 1105 564 1106 charts.time = new Chart(ctx, { 565 1107 type: "line", ··· 569 1111 { 570 1112 label: "Requests", 571 1113 data: data.map((d) => d.count), 572 - borderColor: "#3498db", 573 - backgroundColor: "rgba(52, 152, 219, 0.1)", 1114 + borderColor: "#3b82f6", 1115 + backgroundColor: "rgba(59, 130, 246, 0.1)", 574 1116 tension: 0.4, 575 1117 fill: true, 1118 + pointRadius: 4, 1119 + pointHoverRadius: 6, 1120 + pointBackgroundColor: "#3b82f6", 1121 + pointBorderColor: "#ffffff", 1122 + pointBorderWidth: 2, 576 1123 }, 577 1124 ], 578 1125 }, 579 1126 options: { 580 1127 responsive: true, 1128 + maintainAspectRatio: false, 1129 + layout: { 1130 + padding: { 1131 + left: 10, 1132 + right: 10, 1133 + top: 10, 1134 + bottom: 50 // Extra space for rotated labels 1135 + } 1136 + }, 1137 + animation: { 1138 + duration: 500, 1139 + easing: 'easeInOutQuart' 1140 + }, 1141 + plugins: { 1142 + tooltip: { 1143 + backgroundColor: 'rgba(0, 0, 0, 0.8)', 1144 + titleColor: 'white', 1145 + bodyColor: 'white', 1146 + borderColor: 'rgba(255, 255, 255, 0.1)', 1147 + borderWidth: 1, 1148 + callbacks: { 1149 + title: function(context) { 1150 + const point = data[context[0].dataIndex]; 1151 + return isHourly ? `Hour: ${context[0].label}` : `Date: ${context[0].label}`; 1152 + }, 1153 + label: function(context) { 1154 + const point = data[context.dataIndex]; 1155 + return [ 1156 + `Requests: ${context.parsed.y.toLocaleString()}`, 1157 + `Avg Response Time: ${Math.round(point.averageResponseTime || 0)}ms` 1158 + ]; 1159 + } 1160 + } 1161 + }, 1162 + legend: { 1163 + labels: { 1164 + generateLabels: function(chart) { 1165 + const total = data.reduce((sum, d) => sum + d.count, 0); 1166 + const avg = Math.round(data.reduce((sum, d) => sum + (d.averageResponseTime || 0), 0) / data.length); 1167 + return [{ 1168 + text: `Requests (Total: ${total.toLocaleString()}, Avg RT: ${avg}ms)`, 1169 + fillStyle: '#3b82f6', 1170 + strokeStyle: '#3b82f6', 1171 + lineWidth: 2, 1172 + pointStyle: 'circle' 1173 + }]; 1174 + } 1175 + } 1176 + } 1177 + }, 581 1178 scales: { 1179 + x: { 1180 + title: { 1181 + display: true, 1182 + text: isHourly ? 'Hour of Day' : 'Date', 1183 + font: { weight: 'bold' } 1184 + }, 1185 + ticks: { 1186 + maxTicksLimit: window.innerWidth < 768 ? 6 : 12, 1187 + maxRotation: 0, // Don't rotate labels 1188 + minRotation: 0, 1189 + callback: function(value, index, values) { 1190 + const label = this.getLabelForValue(value); 1191 + // Truncate long labels for better fit 1192 + if (isHourly) { 1193 + return label; // Hours are short 1194 + } else { 1195 + // For dates, show abbreviated format 1196 + const date = new Date(label); 1197 + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); 1198 + } 1199 + } 1200 + }, 1201 + grid: { 1202 + color: 'rgba(0, 0, 0, 0.05)', 1203 + }, 1204 + }, 582 1205 y: { 1206 + title: { 1207 + display: true, 1208 + text: 'Number of Requests', 1209 + font: { weight: 'bold' } 1210 + }, 583 1211 beginAtZero: true, 1212 + grid: { 1213 + color: 'rgba(0, 0, 0, 0.05)', 1214 + }, 1215 + ticks: { 1216 + callback: function(value) { 1217 + return value.toLocaleString(); 1218 + } 1219 + } 584 1220 }, 585 1221 }, 586 1222 }, ··· 588 1224 } 589 1225 590 1226 function updateEndpointChart(data) { 591 - const ctx = document.getElementById("endpointChart").getContext("2d"); 1227 + const canvas = document.getElementById("endpointChart"); 1228 + if (!canvas) { 1229 + console.warn('endpointChart canvas not found'); 1230 + return; 1231 + } 1232 + 1233 + const ctx = canvas.getContext("2d"); 1234 + if (!ctx) { 1235 + console.warn('Could not get 2d context for endpointChart'); 1236 + return; 1237 + } 592 1238 593 1239 if (charts.endpoint) charts.endpoint.destroy(); 594 1240 ··· 600 1246 { 601 1247 label: "Requests", 602 1248 data: data.map((d) => d.count), 603 - backgroundColor: "#2ecc71", 1249 + backgroundColor: "#10b981", 1250 + borderRadius: 4, 604 1251 }, 605 1252 ], 606 1253 }, 607 1254 options: { 608 1255 responsive: true, 609 - indexAxis: "y", 1256 + maintainAspectRatio: false, 1257 + layout: { 1258 + padding: { 1259 + left: 10, 1260 + right: 10, 1261 + top: 10, 1262 + bottom: 60 // Extra space for labels 1263 + } 1264 + }, 1265 + animation: { 1266 + duration: 500, 1267 + easing: 'easeInOutQuart' 1268 + }, 1269 + indexAxis: "x", 610 1270 scales: { 611 1271 x: { 1272 + title: { 1273 + display: true, 1274 + text: 'Endpoints', 1275 + font: { weight: 'bold' } 1276 + }, 1277 + ticks: { 1278 + maxRotation: 0, // Don't rotate 1279 + minRotation: 0, 1280 + callback: function(value, index, values) { 1281 + const label = this.getLabelForValue(value); 1282 + // Truncate long labels but show full in tooltip 1283 + return label.length > 12 ? label.substring(0, 9) + '...' : label; 1284 + } 1285 + }, 1286 + grid: { 1287 + color: 'rgba(0, 0, 0, 0.05)', 1288 + }, 1289 + }, 1290 + y: { 1291 + title: { 1292 + display: true, 1293 + text: 'Number of Requests', 1294 + font: { weight: 'bold' } 1295 + }, 612 1296 beginAtZero: true, 1297 + grid: { 1298 + color: 'rgba(0, 0, 0, 0.05)', 1299 + }, 1300 + ticks: { 1301 + callback: function(value) { 1302 + return value.toLocaleString(); 1303 + } 1304 + } 613 1305 }, 614 1306 }, 1307 + plugins: { 1308 + tooltip: { 1309 + backgroundColor: 'rgba(0, 0, 0, 0.8)', 1310 + titleColor: 'white', 1311 + bodyColor: 'white', 1312 + borderColor: 'rgba(255, 255, 255, 0.1)', 1313 + borderWidth: 1, 1314 + callbacks: { 1315 + title: function(context) { 1316 + return context[0].label; // Show full label in tooltip 1317 + }, 1318 + label: function(context) { 1319 + return `Requests: ${context.parsed.y.toLocaleString()}`; 1320 + } 1321 + } 1322 + }, 1323 + legend: { 1324 + labels: { 1325 + generateLabels: function(chart) { 1326 + const total = data.reduce((sum, d) => sum + d.count, 0); 1327 + return [{ 1328 + text: `Total Requests: ${total.toLocaleString()}`, 1329 + fillStyle: '#10b981', 1330 + strokeStyle: '#10b981', 1331 + lineWidth: 2, 1332 + pointStyle: 'rect' 1333 + }]; 1334 + } 1335 + } 1336 + } 1337 + }, 615 1338 }, 616 1339 }); 617 1340 } ··· 622 1345 if (charts.status) charts.status.destroy(); 623 1346 624 1347 const colors = data.map((d) => { 625 - if (d.status >= 200 && d.status < 300) return "#2ecc71"; 626 - if (d.status >= 300 && d.status < 400) return "#f39c12"; 627 - if (d.status >= 400 && d.status < 500) return "#e74c3c"; 628 - return "#9b59b6"; 1348 + if (d.status >= 200 && d.status < 300) return "#10b981"; 1349 + if (d.status >= 300 && d.status < 400) return "#f59e0b"; 1350 + if (d.status >= 400 && d.status < 500) return "#ef4444"; 1351 + return "#8b5cf6"; 629 1352 }); 630 1353 631 1354 charts.status = new Chart(ctx, { ··· 636 1359 { 637 1360 data: data.map((d) => d.count), 638 1361 backgroundColor: colors, 1362 + borderWidth: 2, 1363 + borderColor: '#fff' 639 1364 }, 640 1365 ], 641 1366 }, 642 1367 options: { 643 1368 responsive: true, 1369 + animation: { 1370 + duration: 500, 1371 + easing: 'easeInOutQuart' 1372 + }, 644 1373 }, 645 1374 }); 646 1375 } ··· 658 1387 { 659 1388 data: data.map((d) => d.count), 660 1389 backgroundColor: [ 661 - "#3498db", 662 - "#e74c3c", 663 - "#2ecc71", 664 - "#f39c12", 665 - "#9b59b6", 666 - "#34495e", 667 - "#16a085", 668 - "#8e44ad", 669 - "#d35400", 670 - "#7f8c8d", 1390 + "#3b82f6", 1391 + "#ef4444", 1392 + "#10b981", 1393 + "#f59e0b", 1394 + "#8b5cf6", 1395 + "#6b7280", 1396 + "#06b6d4", 1397 + "#84cc16", 1398 + "#f97316", 1399 + "#64748b", 671 1400 ], 1401 + borderWidth: 2, 1402 + borderColor: '#fff' 672 1403 }, 673 1404 ], 674 1405 }, 675 1406 options: { 676 1407 responsive: true, 1408 + animation: { 1409 + duration: 500, 1410 + easing: 'easeInOutQuart' 1411 + }, 677 1412 }, 678 1413 }); 679 1414 } 680 1415 681 1416 function updateLatencyTimeChart(data, isHourly) { 682 - const ctx = document 683 - .getElementById("latencyTimeChart") 684 - .getContext("2d"); 1417 + const ctx = document.getElementById("latencyTimeChart").getContext("2d"); 685 1418 686 1419 if (charts.latencyTime) charts.latencyTime.destroy(); 687 1420 688 - // Update chart title 689 1421 const chartTitle = document 690 1422 .querySelector("#latencyTimeChart") 691 1423 .parentElement.querySelector(".chart-title"); 692 - chartTitle.textContent = isHourly 693 - ? "Latency Over Time (Hourly)" 694 - : "Latency Over Time (Daily)"; 1424 + if (chartTitle) { 1425 + chartTitle.textContent = isHourly 1426 + ? "Latency Over Time (Hourly)" 1427 + : "Latency Over Time (Daily)"; 1428 + } 695 1429 696 1430 charts.latencyTime = new Chart(ctx, { 697 1431 type: "line", ··· 701 1435 { 702 1436 label: "Average Response Time", 703 1437 data: data.map((d) => d.averageResponseTime), 704 - borderColor: "#3498db", 705 - backgroundColor: "rgba(52, 152, 219, 0.1)", 1438 + borderColor: "#3b82f6", 1439 + backgroundColor: "rgba(59, 130, 246, 0.1)", 706 1440 tension: 0.4, 707 1441 yAxisID: "y", 1442 + pointRadius: 4, 1443 + pointHoverRadius: 6, 1444 + pointBackgroundColor: "#3b82f6", 1445 + pointBorderColor: "#ffffff", 1446 + pointBorderWidth: 2, 708 1447 }, 709 1448 { 710 1449 label: "P95 Response Time", 711 1450 data: data.map((d) => d.p95), 712 - borderColor: "#e74c3c", 713 - backgroundColor: "rgba(231, 76, 60, 0.1)", 1451 + borderColor: "#ef4444", 1452 + backgroundColor: "rgba(239, 68, 68, 0.1)", 714 1453 tension: 0.4, 715 1454 yAxisID: "y", 1455 + pointRadius: 4, 1456 + pointHoverRadius: 6, 1457 + pointBackgroundColor: "#ef4444", 1458 + pointBorderColor: "#ffffff", 1459 + pointBorderWidth: 2, 716 1460 }, 717 1461 ], 718 1462 }, 719 1463 options: { 720 1464 responsive: true, 1465 + maintainAspectRatio: false, 1466 + layout: { 1467 + padding: { 1468 + left: 10, 1469 + right: 10, 1470 + top: 10, 1471 + bottom: 50 1472 + } 1473 + }, 1474 + animation: { 1475 + duration: 500, 1476 + easing: 'easeInOutQuart' 1477 + }, 1478 + plugins: { 1479 + tooltip: { 1480 + backgroundColor: 'rgba(0, 0, 0, 0.8)', 1481 + titleColor: 'white', 1482 + bodyColor: 'white', 1483 + borderColor: 'rgba(255, 255, 255, 0.1)', 1484 + borderWidth: 1, 1485 + callbacks: { 1486 + title: function(context) { 1487 + return isHourly ? `Hour: ${context[0].label}` : `Date: ${context[0].label}`; 1488 + }, 1489 + afterBody: function(context) { 1490 + const point = data[context[0].dataIndex]; 1491 + return [ 1492 + `Request Count: ${point.count.toLocaleString()}`, 1493 + `Performance: ${point.averageResponseTime < 100 ? 'Excellent' : point.averageResponseTime < 300 ? 'Good' : 'Needs Attention'}` 1494 + ]; 1495 + } 1496 + } 1497 + }, 1498 + legend: { 1499 + labels: { 1500 + generateLabels: function(chart) { 1501 + const avgAvg = Math.round(data.reduce((sum, d) => sum + d.averageResponseTime, 0) / data.length); 1502 + const avgP95 = Math.round(data.reduce((sum, d) => sum + (d.p95 || 0), 0) / data.length); 1503 + return [ 1504 + { 1505 + text: `Average Response Time (Overall: ${avgAvg}ms)`, 1506 + fillStyle: '#3b82f6', 1507 + strokeStyle: '#3b82f6', 1508 + lineWidth: 2, 1509 + pointStyle: 'circle' 1510 + }, 1511 + { 1512 + text: `P95 Response Time (Overall: ${avgP95}ms)`, 1513 + fillStyle: '#ef4444', 1514 + strokeStyle: '#ef4444', 1515 + lineWidth: 2, 1516 + pointStyle: 'circle' 1517 + } 1518 + ]; 1519 + } 1520 + } 1521 + } 1522 + }, 721 1523 scales: { 1524 + x: { 1525 + title: { 1526 + display: true, 1527 + text: isHourly ? 'Hour of Day' : 'Date', 1528 + font: { weight: 'bold' } 1529 + }, 1530 + ticks: { 1531 + maxTicksLimit: window.innerWidth < 768 ? 6 : 12, 1532 + maxRotation: 0, 1533 + minRotation: 0, 1534 + callback: function(value, index, values) { 1535 + const label = this.getLabelForValue(value); 1536 + if (isHourly) { 1537 + return label; 1538 + } else { 1539 + const date = new Date(label); 1540 + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); 1541 + } 1542 + } 1543 + }, 1544 + grid: { 1545 + color: 'rgba(0, 0, 0, 0.05)', 1546 + }, 1547 + }, 722 1548 y: { 723 - beginAtZero: true, 724 1549 title: { 725 1550 display: true, 726 1551 text: "Response Time (ms)", 1552 + font: { weight: 'bold' } 727 1553 }, 1554 + beginAtZero: true, 1555 + grid: { 1556 + color: 'rgba(0, 0, 0, 0.05)', 1557 + }, 1558 + ticks: { 1559 + callback: function(value) { 1560 + return Math.round(value) + 'ms'; 1561 + } 1562 + } 728 1563 }, 729 1564 }, 730 1565 }, ··· 732 1567 } 733 1568 734 1569 function updateLatencyDistributionChart(data) { 735 - const ctx = document 736 - .getElementById("latencyDistributionChart") 737 - .getContext("2d"); 1570 + const ctx = document.getElementById("latencyDistributionChart").getContext("2d"); 738 1571 739 1572 if (charts.latencyDistribution) charts.latencyDistribution.destroy(); 740 1573 ··· 746 1579 { 747 1580 label: "Requests", 748 1581 data: data.map((d) => d.count), 749 - backgroundColor: "#2ecc71", 1582 + backgroundColor: "#10b981", 1583 + borderRadius: 4, 750 1584 }, 751 1585 ], 752 1586 }, 753 1587 options: { 754 1588 responsive: true, 1589 + animation: { 1590 + duration: 500, 1591 + easing: 'easeInOutQuart' 1592 + }, 755 1593 scales: { 756 1594 y: { 757 1595 beginAtZero: true, ··· 762 1600 } 763 1601 764 1602 function updatePercentilesChart(percentiles) { 765 - const ctx = document 766 - .getElementById("percentilesChart") 767 - .getContext("2d"); 1603 + const ctx = document.getElementById("percentilesChart").getContext("2d"); 768 1604 769 1605 if (charts.percentiles) charts.percentiles.destroy(); 770 1606 771 1607 const data = [ 772 - { label: "P50", value: percentiles.p50 }, 1608 + { label: "P50 (Median)", value: percentiles.p50 }, 773 1609 { label: "P75", value: percentiles.p75 }, 774 1610 { label: "P90", value: percentiles.p90 }, 775 1611 { label: "P95", value: percentiles.p95 }, ··· 785 1621 label: "Response Time (ms)", 786 1622 data: data.map((d) => d.value), 787 1623 backgroundColor: [ 788 - "#3498db", 789 - "#2ecc71", 790 - "#f39c12", 791 - "#e74c3c", 792 - "#9b59b6", 1624 + "#10b981", // P50 - Green (good) 1625 + "#3b82f6", // P75 - Blue 1626 + "#f59e0b", // P90 - Yellow (warning) 1627 + "#ef4444", // P95 - Red (concerning) 1628 + "#8b5cf6", // P99 - Purple (critical) 793 1629 ], 1630 + borderRadius: 4, 1631 + borderWidth: 2, 1632 + borderColor: '#ffffff', 794 1633 }, 795 1634 ], 796 1635 }, 797 1636 options: { 798 1637 responsive: true, 1638 + maintainAspectRatio: false, 1639 + animation: { 1640 + duration: 500, 1641 + easing: 'easeInOutQuart' 1642 + }, 1643 + plugins: { 1644 + tooltip: { 1645 + backgroundColor: 'rgba(0, 0, 0, 0.8)', 1646 + titleColor: 'white', 1647 + bodyColor: 'white', 1648 + borderColor: 'rgba(255, 255, 255, 0.1)', 1649 + borderWidth: 1, 1650 + callbacks: { 1651 + label: function(context) { 1652 + const percentile = context.label; 1653 + const value = Math.round(context.parsed.y); 1654 + let interpretation = ''; 1655 + 1656 + if (percentile.includes('P50')) { 1657 + interpretation = '50% of requests are faster than this'; 1658 + } else if (percentile.includes('P95')) { 1659 + interpretation = '95% of requests are faster than this'; 1660 + } else if (percentile.includes('P99')) { 1661 + interpretation = '99% of requests are faster than this'; 1662 + } 1663 + 1664 + return [ 1665 + `${percentile}: ${value}ms`, 1666 + interpretation 1667 + ]; 1668 + } 1669 + } 1670 + }, 1671 + legend: { 1672 + display: false // Hide legend since colors are self-explanatory 1673 + } 1674 + }, 799 1675 scales: { 1676 + x: { 1677 + title: { 1678 + display: true, 1679 + text: 'Response Time Percentiles', 1680 + font: { weight: 'bold' } 1681 + }, 1682 + grid: { 1683 + color: 'rgba(0, 0, 0, 0.05)', 1684 + }, 1685 + }, 800 1686 y: { 1687 + title: { 1688 + display: true, 1689 + text: 'Response Time (ms)', 1690 + font: { weight: 'bold' } 1691 + }, 801 1692 beginAtZero: true, 1693 + grid: { 1694 + color: 'rgba(0, 0, 0, 0.05)', 1695 + }, 1696 + ticks: { 1697 + callback: function(value) { 1698 + return Math.round(value) + 'ms'; 1699 + } 1700 + } 802 1701 }, 803 1702 }, 804 1703 }, ··· 806 1705 } 807 1706 808 1707 function updateSlowestEndpointsChart(data) { 809 - const ctx = document 810 - .getElementById("slowestEndpointsChart") 811 - .getContext("2d"); 1708 + const ctx = document.getElementById("slowestEndpointsChart").getContext("2d"); 812 1709 813 1710 if (charts.slowestEndpoints) charts.slowestEndpoints.destroy(); 814 1711 ··· 820 1717 { 821 1718 label: "Avg Response Time (ms)", 822 1719 data: data.map((d) => d.averageResponseTime), 823 - backgroundColor: "#e74c3c", 1720 + backgroundColor: "#ef4444", 1721 + borderRadius: 4, 824 1722 }, 825 1723 ], 826 1724 }, 827 1725 options: { 828 1726 responsive: true, 829 - indexAxis: "y", 1727 + maintainAspectRatio: false, 1728 + animation: { 1729 + duration: 500, 1730 + easing: 'easeInOutQuart' 1731 + }, 1732 + indexAxis: "x", // Changed from "y" to "x" to put labels on top 830 1733 scales: { 831 1734 x: { 1735 + title: { 1736 + display: true, 1737 + text: 'Endpoints', 1738 + font: { weight: 'bold' } 1739 + }, 1740 + ticks: { 1741 + maxRotation: 45, 1742 + minRotation: 45, 1743 + callback: function(value, index, values) { 1744 + const label = this.getLabelForValue(value); 1745 + return label.length > 15 ? label.substring(0, 12) + '...' : label; 1746 + } 1747 + }, 1748 + grid: { 1749 + color: 'rgba(0, 0, 0, 0.05)', 1750 + }, 1751 + }, 1752 + y: { 1753 + title: { 1754 + display: true, 1755 + text: 'Response Time (ms)', 1756 + font: { weight: 'bold' } 1757 + }, 832 1758 beginAtZero: true, 1759 + grid: { 1760 + color: 'rgba(0, 0, 0, 0.05)', 1761 + }, 1762 + ticks: { 1763 + callback: function(value) { 1764 + return Math.round(value) + 'ms'; 1765 + } 1766 + } 833 1767 }, 834 1768 }, 1769 + plugins: { 1770 + tooltip: { 1771 + backgroundColor: 'rgba(0, 0, 0, 0.8)', 1772 + titleColor: 'white', 1773 + bodyColor: 'white', 1774 + borderColor: 'rgba(255, 255, 255, 0.1)', 1775 + borderWidth: 1, 1776 + callbacks: { 1777 + title: function(context) { 1778 + return context[0].label; // Show full label in tooltip 1779 + }, 1780 + label: function(context) { 1781 + const point = data[context.dataIndex]; 1782 + return [ 1783 + `Avg Response Time: ${Math.round(context.parsed.y)}ms`, 1784 + `Request Count: ${point.count.toLocaleString()}` 1785 + ]; 1786 + } 1787 + } 1788 + }, 1789 + legend: { 1790 + labels: { 1791 + generateLabels: function(chart) { 1792 + const avgTime = Math.round(data.reduce((sum, d) => sum + d.averageResponseTime, 0) / data.length); 1793 + return [{ 1794 + text: `Average Response Time: ${avgTime}ms`, 1795 + fillStyle: '#ef4444', 1796 + strokeStyle: '#ef4444', 1797 + lineWidth: 2, 1798 + pointStyle: 'rect' 1799 + }]; 1800 + } 1801 + } 1802 + } 1803 + }, 835 1804 }, 836 1805 }); 837 1806 } 838 1807 839 - document 840 - .getElementById("autoRefresh") 841 - .addEventListener("change", function () { 842 - if (this.checked) { 843 - autoRefreshInterval = setInterval(loadData, 30000); 844 - } else { 845 - clearInterval(autoRefreshInterval); 1808 + document.getElementById("autoRefresh").addEventListener("change", function () { 1809 + if (this.checked) { 1810 + autoRefreshInterval = setInterval(loadData, 30000); 1811 + showToast('Auto-refresh enabled', 'success'); 1812 + } else { 1813 + clearInterval(autoRefreshInterval); 1814 + showToast('Auto-refresh disabled', 'info'); 1815 + } 1816 + }); 1817 + 1818 + // Days selector change handler 1819 + document.getElementById("daysSelect").addEventListener("change", function() { 1820 + // Reset visible charts when changing time period 1821 + visibleCharts.clear(); 1822 + loadData(); 1823 + }); 1824 + 1825 + // Initialize dashboard 1826 + document.addEventListener('DOMContentLoaded', function() { 1827 + initLazyLoading(); 1828 + loadData(); 1829 + }); 1830 + 1831 + // Cleanup on page unload 1832 + window.addEventListener('beforeunload', function() { 1833 + if (intersectionObserver) { 1834 + intersectionObserver.disconnect(); 1835 + } 1836 + clearInterval(autoRefreshInterval); 1837 + 1838 + // Destroy all charts to prevent memory leaks 1839 + Object.values(charts).forEach(chart => { 1840 + if (chart && typeof chart.destroy === 'function') { 1841 + chart.destroy(); 846 1842 } 847 1843 }); 848 - 849 - loadData(); 850 - document 851 - .getElementById("daysSelect") 852 - .addEventListener("change", loadData); 1844 + }); 853 1845 </script> 854 1846 </body> 855 1847 </html>