a cache for slack profile pictures and emojis
1<!doctype html> 2<html lang="en"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <title>Cachet Analytics Dashboard</title> 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> 9 <style> 10 * { 11 margin: 0; 12 padding: 0; 13 box-sizing: border-box; 14 } 15 16 body { 17 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 18 sans-serif; 19 background: #f9fafb; 20 color: #111827; 21 line-height: 1.6; 22 } 23 24 .header { 25 background: #fff; 26 padding: 1.5rem 2rem; 27 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 28 margin-bottom: 2rem; 29 border-bottom: 1px solid #e5e7eb; 30 } 31 32 .header h1 { 33 color: #111827; 34 font-size: 1.875rem; 35 font-weight: 700; 36 margin-bottom: 0.5rem; 37 } 38 39 .header-links { 40 display: flex; 41 gap: 1.5rem; 42 } 43 44 .header-links a { 45 color: #6366f1; 46 text-decoration: none; 47 font-weight: 500; 48 } 49 50 .header-links a:hover { 51 color: #4f46e5; 52 text-decoration: underline; 53 } 54 55 .controls { 56 margin-bottom: 2rem; 57 display: flex; 58 justify-content: center; 59 align-items: center; 60 gap: 1rem; 61 flex-wrap: wrap; 62 } 63 64 .controls select, 65 .controls button { 66 padding: 0.75rem 1.25rem; 67 border: 1px solid #d1d5db; 68 border-radius: 8px; 69 background: white; 70 cursor: pointer; 71 font-size: 0.875rem; 72 font-weight: 500; 73 transition: all 0.2s ease; 74 } 75 76 .controls select:hover, 77 .controls select:focus { 78 border-color: #6366f1; 79 outline: none; 80 box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); 81 } 82 83 .controls button { 84 background: #6366f1; 85 color: white; 86 border: none; 87 } 88 89 .controls button:hover { 90 background: #4f46e5; 91 } 92 93 .controls button:disabled { 94 background: #9ca3af; 95 cursor: not-allowed; 96 } 97 98 .dashboard { 99 max-width: 1200px; 100 margin: 0 auto; 101 padding: 0 2rem; 102 } 103 104 .stats-grid { 105 display: grid; 106 grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 107 gap: 1.5rem; 108 margin-bottom: 3rem; 109 } 110 111 .stat-card { 112 background: white; 113 padding: 2rem; 114 border-radius: 12px; 115 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 116 text-align: center; 117 border: 1px solid #e5e7eb; 118 transition: all 0.2s ease; 119 min-height: 140px; 120 display: flex; 121 flex-direction: column; 122 justify-content: center; 123 } 124 125 .stat-card:hover { 126 transform: translateY(-2px); 127 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 128 } 129 130 .stat-number { 131 font-weight: 800; 132 color: #111827; 133 margin-bottom: 0.5rem; 134 font-size: 2.5rem; 135 line-height: 1; 136 } 137 138 .stat-label { 139 color: #6b7280; 140 font-weight: 600; 141 font-size: 0.875rem; 142 text-transform: uppercase; 143 letter-spacing: 0.05em; 144 } 145 146 .charts-grid { 147 display: grid; 148 grid-template-columns: 1fr; 149 gap: 2rem; 150 margin-bottom: 3rem; 151 } 152 153 .charts-row { 154 display: grid; 155 grid-template-columns: 1fr 1fr; 156 gap: 2rem; 157 } 158 159 @media (max-width: 768px) { 160 .charts-row { 161 grid-template-columns: 1fr; 162 } 163 164 .stats-grid { 165 grid-template-columns: 1fr; 166 } 167 168 .dashboard { 169 padding: 0 1rem; 170 } 171 172 .stat-number { 173 font-size: 2rem; 174 } 175 } 176 177 .chart-container { 178 background: white; 179 padding: 1.5rem; 180 border-radius: 12px; 181 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 182 border: 1px solid #e5e7eb; 183 height: 25rem; 184 padding-bottom: 5rem; 185 } 186 187 .chart-title { 188 font-size: 1.25rem; 189 margin-bottom: 1.5rem; 190 color: #111827; 191 font-weight: 700; 192 } 193 194 .user-agents-table { 195 background: white; 196 padding: 2rem; 197 border-radius: 12px; 198 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 199 border: 1px solid #e5e7eb; 200 } 201 202 .search-container { 203 margin-bottom: 1.5rem; 204 position: relative; 205 } 206 207 .search-input { 208 width: 100%; 209 padding: 0.75rem 1rem; 210 border: 1px solid #d1d5db; 211 border-radius: 8px; 212 font-size: 0.875rem; 213 background: #f9fafb; 214 transition: border-color 0.2s ease; 215 } 216 217 .search-input:focus { 218 outline: none; 219 border-color: #6366f1; 220 background: white; 221 box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); 222 } 223 224 .ua-table { 225 width: 100%; 226 border-collapse: collapse; 227 font-size: 0.875rem; 228 } 229 230 .ua-table th { 231 text-align: left; 232 padding: 0.75rem 1rem; 233 background: #f9fafb; 234 border-bottom: 2px solid #e5e7eb; 235 font-weight: 600; 236 color: #374151; 237 position: sticky; 238 top: 0; 239 } 240 241 .ua-table td { 242 padding: 0.75rem 1rem; 243 border-bottom: 1px solid #f3f4f6; 244 vertical-align: top; 245 } 246 247 .ua-table tbody tr:hover { 248 background: #f9fafb; 249 } 250 251 .ua-name { 252 font-weight: 500; 253 color: #111827; 254 line-height: 1.4; 255 max-width: 400px; 256 word-break: break-word; 257 } 258 259 .ua-raw { 260 font-family: monospace; 261 font-size: 0.75rem; 262 color: #6b7280; 263 margin-top: 0.25rem; 264 max-width: 400px; 265 word-break: break-all; 266 line-height: 1.3; 267 } 268 269 .ua-count { 270 font-weight: 600; 271 color: #111827; 272 text-align: right; 273 white-space: nowrap; 274 } 275 276 .ua-percentage { 277 color: #6b7280; 278 text-align: right; 279 font-size: 0.75rem; 280 } 281 282 .no-results { 283 text-align: center; 284 padding: 2rem; 285 color: #6b7280; 286 font-style: italic; 287 } 288 289 .loading { 290 text-align: center; 291 padding: 3rem; 292 color: #6b7280; 293 } 294 295 .loading-spinner { 296 display: inline-block; 297 width: 2rem; 298 height: 2rem; 299 border: 3px solid #e5e7eb; 300 border-radius: 50%; 301 border-top-color: #6366f1; 302 animation: spin 1s ease-in-out infinite; 303 margin-bottom: 1rem; 304 } 305 306 @keyframes spin { 307 to { transform: rotate(360deg); } 308 } 309 310 .error { 311 background: #fef2f2; 312 color: #dc2626; 313 padding: 1rem; 314 border-radius: 8px; 315 margin: 1rem 0; 316 border: 1px solid #fecaca; 317 } 318 319 .auto-refresh { 320 display: flex; 321 align-items: center; 322 gap: 0.5rem; 323 font-size: 0.875rem; 324 color: #6b7280; 325 } 326 327 .auto-refresh input[type="checkbox"] { 328 transform: scale(1.1); 329 accent-color: #6366f1; 330 } 331 </style> 332 </head> 333 <body> 334 <div class="header"> 335 <h1>📊 Cachet Analytics Dashboard</h1> 336 <div class="header-links"> 337 <a href="https://github.com/taciturnaxolotl/cachet">Github</a> 338 <a href="/swagger">API Docs</a> 339 <a href="/stats">Raw Stats</a> 340 </div> 341 </div> 342 343 <div class="dashboard"> 344 <div class="controls"> 345 <select id="daysSelect"> 346 <option value="1">Last 24 hours</option> 347 <option value="7" selected>Last 7 days</option> 348 <option value="30">Last 30 days</option> 349 </select> 350 <button id="refreshBtn" onclick="loadData()">Refresh</button> 351 <div class="auto-refresh"> 352 <input type="checkbox" id="autoRefresh" /> 353 <label for="autoRefresh">Auto-refresh (30s)</label> 354 </div> 355 </div> 356 357 <div id="loading" class="loading"> 358 <div class="loading-spinner"></div> 359 Loading analytics data... 360 </div> 361 <div id="error" class="error" style="display: none"></div> 362 363 <div id="content" style="display: none"> 364 <!-- Key Metrics --> 365 <div class="stats-grid"> 366 <div class="stat-card"> 367 <div class="stat-number" id="totalRequests">-</div> 368 <div class="stat-label">Total Requests</div> 369 </div> 370 <div class="stat-card"> 371 <div class="stat-number" id="uptime">-</div> 372 <div class="stat-label">Uptime</div> 373 </div> 374 <div class="stat-card"> 375 <div class="stat-number" id="avgResponseTime">-</div> 376 <div class="stat-label">Avg Response Time</div> 377 </div> 378 </div> 379 380 <!-- Main Charts --> 381 <div class="charts-grid"> 382 <div class="charts-row"> 383 <div class="chart-container"> 384 <div class="chart-title">Requests Over Time</div> 385 <canvas id="requestsChart"></canvas> 386 </div> 387 <div class="chart-container"> 388 <div class="chart-title">Latency Over Time</div> 389 <canvas id="latencyChart"></canvas> 390 </div> 391 </div> 392 </div> 393 394 <!-- User Agents Table --> 395 <div class="user-agents-table"> 396 <div class="chart-title">User Agents</div> 397 <div class="search-container"> 398 <input type="text" id="userAgentSearch" class="search-input" placeholder="Search user agents..."> 399 </div> 400 <div id="userAgentsTable"> 401 <div class="loading">Loading user agents...</div> 402 </div> 403 </div> 404 </div> 405 </div> 406 407 <script> 408 let charts = {}; 409 let autoRefreshInterval; 410 let currentData = null; 411 let isLoading = false; 412 let currentRequestId = 0; 413 let abortController = null; 414 415 // Debounced resize handler for charts 416 let resizeTimeout; 417 function handleResize() { 418 clearTimeout(resizeTimeout); 419 resizeTimeout = setTimeout(() => { 420 Object.values(charts).forEach(chart => { 421 if (chart && typeof chart.resize === 'function') { 422 chart.resize(); 423 } 424 }); 425 }, 250); 426 } 427 428 window.addEventListener('resize', handleResize); 429 430 async function loadData() { 431 // Cancel any existing requests 432 if (abortController) { 433 abortController.abort(); 434 } 435 436 // Create new abort controller for this request 437 abortController = new AbortController(); 438 const requestId = ++currentRequestId; 439 const signal = abortController.signal; 440 441 isLoading = true; 442 const startTime = Date.now(); 443 444 // Capture the days value at the start to ensure consistency 445 const days = document.getElementById("daysSelect").value; 446 const loading = document.getElementById("loading"); 447 const error = document.getElementById("error"); 448 const content = document.getElementById("content"); 449 const refreshBtn = document.getElementById("refreshBtn"); 450 451 console.log(`Starting request ${requestId} for ${days} days`); 452 453 // Update UI state 454 loading.style.display = "block"; 455 error.style.display = "none"; 456 content.style.display = "none"; 457 refreshBtn.disabled = true; 458 refreshBtn.textContent = "Loading..."; 459 460 try { 461 // Step 1: Load essential stats first (fastest) 462 console.log(`[${requestId}] Loading essential stats...`); 463 const essentialResponse = await fetch(`/api/stats/essential?days=${days}`, { signal }); 464 465 // Check if this request is still current 466 if (requestId !== currentRequestId) { 467 console.log(`[${requestId}] Request cancelled (essential stats)`); 468 return; 469 } 470 471 if (!essentialResponse.ok) throw new Error(`HTTP ${essentialResponse.status}`); 472 473 const essentialData = await essentialResponse.json(); 474 475 // Double-check we're still the current request 476 if (requestId !== currentRequestId) { 477 console.log(`[${requestId}] Request cancelled (essential stats after response)`); 478 return; 479 } 480 481 updateEssentialStats(essentialData); 482 483 // Show content immediately with essential stats 484 loading.style.display = "none"; 485 content.style.display = "block"; 486 refreshBtn.textContent = "Loading Charts..."; 487 488 console.log(`[${requestId}] Essential stats loaded in ${Date.now() - startTime}ms`); 489 490 // Step 2: Load chart data (medium speed) 491 console.log(`[${requestId}] Loading chart data...`); 492 const chartResponse = await fetch(`/api/stats/charts?days=${days}`, { signal }); 493 494 if (requestId !== currentRequestId) { 495 console.log(`[${requestId}] Request cancelled (chart data)`); 496 return; 497 } 498 499 if (!chartResponse.ok) throw new Error(`HTTP ${chartResponse.status}`); 500 501 const chartData = await chartResponse.json(); 502 503 if (requestId !== currentRequestId) { 504 console.log(`[${requestId}] Request cancelled (chart data after response)`); 505 return; 506 } 507 508 updateCharts(chartData, parseInt(days)); 509 refreshBtn.textContent = "Loading User Agents..."; 510 511 console.log(`[${requestId}] Charts loaded in ${Date.now() - startTime}ms`); 512 513 // Step 3: Load user agents last (slowest) 514 console.log(`[${requestId}] Loading user agents...`); 515 const userAgentsResponse = await fetch(`/api/stats/useragents?days=${days}`, { signal }); 516 517 if (requestId !== currentRequestId) { 518 console.log(`[${requestId}] Request cancelled (user agents)`); 519 return; 520 } 521 522 if (!userAgentsResponse.ok) throw new Error(`HTTP ${userAgentsResponse.status}`); 523 524 const userAgentsData = await userAgentsResponse.json(); 525 526 if (requestId !== currentRequestId) { 527 console.log(`[${requestId}] Request cancelled (user agents after response)`); 528 return; 529 } 530 531 updateUserAgentsTable(userAgentsData); 532 533 const totalTime = Date.now() - startTime; 534 console.log(`[${requestId}] All data loaded in ${totalTime}ms`); 535 } catch (err) { 536 // Only show error if this is still the current request 537 if (requestId === currentRequestId) { 538 if (err.name === 'AbortError') { 539 console.log(`[${requestId}] Request aborted`); 540 } else { 541 loading.style.display = "none"; 542 error.style.display = "block"; 543 error.textContent = `Failed to load data: ${err.message}`; 544 console.error(`[${requestId}] Error: ${err.message}`); 545 } 546 } 547 } finally { 548 // Only update UI if this is still the current request 549 if (requestId === currentRequestId) { 550 isLoading = false; 551 refreshBtn.disabled = false; 552 refreshBtn.textContent = "Refresh"; 553 abortController = null; 554 } 555 } 556 } 557 558 // Update just the essential stats (fast) 559 function updateEssentialStats(data) { 560 document.getElementById("totalRequests").textContent = data.totalRequests.toLocaleString(); 561 document.getElementById("uptime").textContent = data.uptime.toFixed(1) + "%"; 562 document.getElementById("avgResponseTime").textContent = 563 data.averageResponseTime ? Math.round(data.averageResponseTime) + "ms" : "N/A"; 564 } 565 566 // Update charts (medium speed) 567 function updateCharts(data, days) { 568 updateRequestsChart(data.requestsByDay, days === 1); 569 updateLatencyChart(data.latencyOverTime, days === 1); 570 } 571 572 573 // Requests Over Time Chart 574 function updateRequestsChart(data, isHourly) { 575 const ctx = document.getElementById("requestsChart").getContext("2d"); 576 const days = parseInt(document.getElementById("daysSelect").value); 577 578 if (charts.requests) charts.requests.destroy(); 579 580 // Format labels based on granularity 581 const labels = data.map((d) => { 582 if (days === 1) { 583 // 15-minute intervals: show just time 584 return d.date.split(" ")[1] || d.date; 585 } else if (days <= 7) { 586 // Hourly: show date + hour 587 const parts = d.date.split(" "); 588 const date = parts[0].split("-")[2]; // Get day 589 const hour = parts[1] || "00:00"; 590 return `${date} ${hour}`; 591 } else { 592 // 4-hour intervals: show abbreviated 593 return d.date.split(" ")[0]; 594 } 595 }); 596 597 charts.requests = new Chart(ctx, { 598 type: "line", 599 data: { 600 labels: labels, 601 datasets: [{ 602 label: "Requests", 603 data: data.map((d) => d.count), 604 borderColor: "#6366f1", 605 backgroundColor: "rgba(99, 102, 241, 0.1)", 606 tension: 0.4, 607 fill: true, 608 borderWidth: 1.5, 609 pointRadius: 1, 610 pointBackgroundColor: "#6366f1", 611 }], 612 }, 613 options: { 614 responsive: true, 615 maintainAspectRatio: false, 616 plugins: { 617 legend: { display: false }, 618 tooltip: { 619 callbacks: { 620 title: function(context) { 621 const original = data[context[0].dataIndex]; 622 if (days === 1) return `Time: ${original.date}`; 623 if (days <= 7) return `DateTime: ${original.date}`; 624 return `Interval: ${original.date}`; 625 }, 626 label: function(context) { 627 return `Requests: ${context.parsed.y.toLocaleString()}`; 628 } 629 } 630 } 631 }, 632 scales: { 633 x: { 634 title: { 635 display: true, 636 text: days === 1 ? 'Time (15min intervals)' : days <= 7 ? 'Time (hourly)' : 'Time (4hr intervals)' 637 }, 638 grid: { color: 'rgba(0, 0, 0, 0.05)' }, 639 ticks: { 640 maxTicksLimit: days === 1 ? 12 : 20, 641 maxRotation: 0, 642 minRotation: 0 643 } 644 }, 645 y: { 646 title: { display: true, text: 'Requests' }, 647 beginAtZero: true, 648 grid: { color: 'rgba(0, 0, 0, 0.05)' } 649 } 650 } 651 } 652 }); 653 } 654 655 // Latency Over Time Chart 656 function updateLatencyChart(data, isHourly) { 657 const ctx = document.getElementById("latencyChart").getContext("2d"); 658 const days = parseInt(document.getElementById("daysSelect").value); 659 660 if (charts.latency) charts.latency.destroy(); 661 662 // Format labels based on granularity 663 const labels = data.map((d) => { 664 if (days === 1) { 665 // 15-minute intervals: show just time 666 return d.time.split(" ")[1] || d.time; 667 } else if (days <= 7) { 668 // Hourly: show date + hour 669 const parts = d.time.split(" "); 670 const date = parts[0].split("-")[2]; // Get day 671 const hour = parts[1] || "00:00"; 672 return `${date} ${hour}`; 673 } else { 674 // 4-hour intervals: show abbreviated 675 return d.time.split(" ")[0]; 676 } 677 }); 678 679 charts.latency = new Chart(ctx, { 680 type: "line", 681 data: { 682 labels: labels, 683 datasets: [{ 684 label: "Average Response Time", 685 data: data.map((d) => d.averageResponseTime), 686 borderColor: "#10b981", 687 backgroundColor: "rgba(16, 185, 129, 0.1)", 688 tension: 0.4, 689 fill: true, 690 borderWidth: 1.5, 691 pointRadius: 1, 692 pointBackgroundColor: "#10b981", 693 }], 694 }, 695 options: { 696 responsive: true, 697 maintainAspectRatio: false, 698 plugins: { 699 legend: { display: false }, 700 tooltip: { 701 callbacks: { 702 title: function(context) { 703 const original = data[context[0].dataIndex]; 704 if (days === 1) return `Time: ${original.time}`; 705 if (days <= 7) return `DateTime: ${original.time}`; 706 return `Interval: ${original.time}`; 707 }, 708 label: function(context) { 709 const point = data[context.dataIndex]; 710 return [ 711 `Response Time: ${Math.round(context.parsed.y)}ms`, 712 `Request Count: ${point.count.toLocaleString()}` 713 ]; 714 } 715 } 716 } 717 }, 718 scales: { 719 x: { 720 title: { 721 display: true, 722 text: days === 1 ? 'Time (15min intervals)' : days <= 7 ? 'Time (hourly)' : 'Time (4hr intervals)' 723 }, 724 grid: { color: 'rgba(0, 0, 0, 0.05)' }, 725 ticks: { 726 maxTicksLimit: days === 1 ? 12 : 20, 727 maxRotation: 0, 728 minRotation: 0 729 } 730 }, 731 y: { 732 type: 'logarithmic', 733 title: { display: true, text: 'Response Time (ms, log scale)' }, 734 min: 1, 735 grid: { color: 'rgba(0, 0, 0, 0.05)' }, 736 ticks: { 737 callback: function(value) { 738 // Show clean numbers: 1, 10, 100, 1000, etc. 739 if (value === 1 || value === 10 || value === 100 || value === 1000 || value === 10000) { 740 return value + 'ms'; 741 } 742 return ''; 743 } 744 } 745 } 746 } 747 } 748 }); 749 } 750 751 // User Agents Table 752 let allUserAgents = []; 753 754 function updateUserAgentsTable(userAgents) { 755 allUserAgents = userAgents; 756 renderUserAgentsTable(userAgents); 757 setupUserAgentSearch(); 758 } 759 760 function parseUserAgent(ua) { 761 // Keep strange/unique ones as-is 762 if (ua.length < 50 || 763 !ua.includes('Mozilla/') || 764 ua.includes('bot') || 765 ua.includes('crawler') || 766 ua.includes('spider') || 767 !ua.includes('AppleWebKit') || 768 ua.includes('Shiba-Arcade') || 769 ua === 'node' || 770 ua.includes('curl') || 771 ua.includes('python') || 772 ua.includes('PostmanRuntime')) { 773 return ua; 774 } 775 776 // Parse common browsers 777 const os = ua.includes('Macintosh') ? 'macOS' : 778 ua.includes('Windows NT 10.0') ? 'Windows 10' : 779 ua.includes('Windows NT') ? 'Windows' : 780 ua.includes('X11; Linux') ? 'Linux' : 781 ua.includes('iPhone') ? 'iOS' : 782 ua.includes('Android') ? 'Android' : 'Unknown OS'; 783 784 // Detect browser and version 785 let browser = 'Unknown Browser'; 786 787 if (ua.includes('Edg/')) { 788 const match = ua.match(/Edg\/(\d+\.\d+)/); 789 const version = match ? match[1] : ''; 790 browser = `Edge ${version}`; 791 } else if (ua.includes('Chrome/')) { 792 const match = ua.match(/Chrome\/(\d+\.\d+)/); 793 const version = match ? match[1] : ''; 794 browser = `Chrome ${version}`; 795 } else if (ua.includes('Firefox/')) { 796 const match = ua.match(/Firefox\/(\d+\.\d+)/); 797 const version = match ? match[1] : ''; 798 browser = `Firefox ${version}`; 799 } else if (ua.includes('Safari/') && !ua.includes('Chrome')) { 800 browser = 'Safari'; 801 } 802 803 return `${browser} (${os})`; 804 } 805 806 function renderUserAgentsTable(userAgents) { 807 const container = document.getElementById("userAgentsTable"); 808 809 if (userAgents.length === 0) { 810 container.innerHTML = '<div class="no-results">No user agents found</div>'; 811 return; 812 } 813 814 const totalRequests = userAgents.reduce((sum, ua) => sum + ua.count, 0); 815 816 const tableHTML = ` 817 <table class="ua-table"> 818 <thead> 819 <tr> 820 <th style="width: 50%">User Agent</th> 821 <th style="width: 20%">Requests</th> 822 <th style="width: 15%">Percentage</th> 823 </tr> 824 </thead> 825 <tbody> 826 ${userAgents.map(ua => { 827 const displayName = parseUserAgent(ua.userAgent); 828 const percentage = ((ua.count / totalRequests) * 100).toFixed(1); 829 830 return ` 831 <tr> 832 <td> 833 <div class="ua-name">${displayName}</div> 834 <div class="ua-raw">${ua.userAgent}</div> 835 </td> 836 <td class="ua-count">${ua.count.toLocaleString()}</td> 837 <td class="ua-percentage">${percentage}%</td> 838 </tr> 839 `; 840 }).join('')} 841 </tbody> 842 </table> 843 `; 844 845 container.innerHTML = tableHTML; 846 } 847 848 function setupUserAgentSearch() { 849 const searchInput = document.getElementById('userAgentSearch'); 850 851 searchInput.addEventListener('input', function() { 852 const searchTerm = this.value.toLowerCase().trim(); 853 854 if (searchTerm === '') { 855 renderUserAgentsTable(allUserAgents); 856 return; 857 } 858 859 const filtered = allUserAgents.filter(ua => { 860 const displayName = parseUserAgent(ua.userAgent).toLowerCase(); 861 const rawUA = ua.userAgent.toLowerCase(); 862 return displayName.includes(searchTerm) || rawUA.includes(searchTerm); 863 }); 864 865 renderUserAgentsTable(filtered); 866 }); 867 } 868 869 // Event Handlers 870 document.getElementById("autoRefresh").addEventListener("change", function () { 871 if (this.checked) { 872 autoRefreshInterval = setInterval(loadData, 30000); 873 } else { 874 clearInterval(autoRefreshInterval); 875 } 876 }); 877 878 document.getElementById("daysSelect").addEventListener("change", loadData); 879 880 // Initialize dashboard 881 document.addEventListener('DOMContentLoaded', loadData); 882 883 // Cleanup on page unload 884 window.addEventListener('beforeunload', function() { 885 clearInterval(autoRefreshInterval); 886 Object.values(charts).forEach(chart => { 887 if (chart && typeof chart.destroy === 'function') { 888 chart.destroy(); 889 } 890 }); 891 }); 892 </script> 893 </body> 894</html>