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 const charts = {}; 409 let autoRefreshInterval; 410 const _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, 10)); 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, 10); 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: (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: (context) => `Requests: ${context.parsed.y.toLocaleString()}` 627 } 628 } 629 }, 630 scales: { 631 x: { 632 title: { 633 display: true, 634 text: days === 1 ? 'Time (15min intervals)' : days <= 7 ? 'Time (hourly)' : 'Time (4hr intervals)' 635 }, 636 grid: { color: 'rgba(0, 0, 0, 0.05)' }, 637 ticks: { 638 maxTicksLimit: days === 1 ? 12 : 20, 639 maxRotation: 0, 640 minRotation: 0 641 } 642 }, 643 y: { 644 title: { display: true, text: 'Requests' }, 645 beginAtZero: true, 646 grid: { color: 'rgba(0, 0, 0, 0.05)' } 647 } 648 } 649 } 650 }); 651 } 652 653 // Latency Over Time Chart 654 function updateLatencyChart(data, _isHourly) { 655 const ctx = document.getElementById("latencyChart").getContext("2d"); 656 const days = parseInt(document.getElementById("daysSelect").value, 10); 657 658 if (charts.latency) charts.latency.destroy(); 659 660 // Format labels based on granularity 661 const labels = data.map((d) => { 662 if (days === 1) { 663 // 15-minute intervals: show just time 664 return d.time.split(" ")[1] || d.time; 665 } else if (days <= 7) { 666 // Hourly: show date + hour 667 const parts = d.time.split(" "); 668 const date = parts[0].split("-")[2]; // Get day 669 const hour = parts[1] || "00:00"; 670 return `${date} ${hour}`; 671 } else { 672 // 4-hour intervals: show abbreviated 673 return d.time.split(" ")[0]; 674 } 675 }); 676 677 // Calculate dynamic max for logarithmic scale 678 const responseTimes = data.map((d) => d.averageResponseTime); 679 const maxResponseTime = Math.max(...responseTimes); 680 681 // Calculate appropriate max for log scale (next power of 10) 682 const logMax = 10 ** Math.ceil(Math.log10(maxResponseTime)); 683 684 // Generate dynamic tick values based on the data range 685 const generateLogTicks = (_min, max) => { 686 const ticks = []; 687 let current = 1; 688 while (current <= max) { 689 ticks.push(current); 690 current *= 10; 691 } 692 return ticks; 693 }; 694 695 const dynamicTicks = generateLogTicks(1, logMax); 696 697 charts.latency = new Chart(ctx, { 698 type: "line", 699 data: { 700 labels: labels, 701 datasets: [{ 702 label: "Average Response Time", 703 data: responseTimes, 704 borderColor: "#10b981", 705 backgroundColor: "rgba(16, 185, 129, 0.1)", 706 tension: 0.4, 707 fill: true, 708 borderWidth: 1.5, 709 pointRadius: 1, 710 pointBackgroundColor: "#10b981", 711 }], 712 }, 713 options: { 714 responsive: true, 715 maintainAspectRatio: false, 716 plugins: { 717 legend: { display: false }, 718 tooltip: { 719 callbacks: { 720 title: (context) => { 721 const original = data[context[0].dataIndex]; 722 if (days === 1) return `Time: ${original.time}`; 723 if (days <= 7) return `DateTime: ${original.time}`; 724 return `Interval: ${original.time}`; 725 }, 726 label: (context) => { 727 const point = data[context.dataIndex]; 728 return [ 729 `Response Time: ${Math.round(context.parsed.y)}ms`, 730 `Request Count: ${point.count.toLocaleString()}` 731 ]; 732 } 733 } 734 } 735 }, 736 scales: { 737 x: { 738 title: { 739 display: true, 740 text: days === 1 ? 'Time (15min intervals)' : days <= 7 ? 'Time (hourly)' : 'Time (4hr intervals)' 741 }, 742 grid: { color: 'rgba(0, 0, 0, 0.05)' }, 743 ticks: { 744 maxTicksLimit: days === 1 ? 12 : 20, 745 maxRotation: 0, 746 minRotation: 0 747 } 748 }, 749 y: { 750 type: 'logarithmic', 751 title: { display: true, text: 'Response Time (ms, log scale)' }, 752 min: 1, 753 max: logMax, 754 grid: { color: 'rgba(0, 0, 0, 0.05)' }, 755 ticks: { 756 callback: (value) => { 757 // Show clean numbers based on dynamic range 758 if (dynamicTicks.includes(value)) { 759 return `${value}ms`; 760 } 761 return ''; 762 } 763 } 764 } 765 } 766 } 767 }); 768 } 769 770 // User Agents Table 771 let allUserAgents = []; 772 773 function updateUserAgentsTable(userAgents) { 774 allUserAgents = userAgents; 775 renderUserAgentsTable(userAgents); 776 setupUserAgentSearch(); 777 } 778 779 function parseUserAgent(ua) { 780 // Keep strange/unique ones as-is 781 if (ua.length < 50 || 782 !ua.includes('Mozilla/') || 783 ua.includes('bot') || 784 ua.includes('crawler') || 785 ua.includes('spider') || 786 !ua.includes('AppleWebKit') || 787 ua.includes('Shiba-Arcade') || 788 ua === 'node' || 789 ua.includes('curl') || 790 ua.includes('python') || 791 ua.includes('PostmanRuntime')) { 792 return ua; 793 } 794 795 // Parse common browsers 796 const os = ua.includes('Macintosh') ? 'macOS' : 797 ua.includes('Windows NT 10.0') ? 'Windows 10' : 798 ua.includes('Windows NT') ? 'Windows' : 799 ua.includes('X11; Linux') ? 'Linux' : 800 ua.includes('iPhone') ? 'iOS' : 801 ua.includes('Android') ? 'Android' : 'Unknown OS'; 802 803 // Detect browser and version 804 let browser = 'Unknown Browser'; 805 806 if (ua.includes('Edg/')) { 807 const match = ua.match(/Edg\/(\d+\.\d+)/); 808 const version = match ? match[1] : ''; 809 browser = `Edge ${version}`; 810 } else if (ua.includes('Chrome/')) { 811 const match = ua.match(/Chrome\/(\d+\.\d+)/); 812 const version = match ? match[1] : ''; 813 browser = `Chrome ${version}`; 814 } else if (ua.includes('Firefox/')) { 815 const match = ua.match(/Firefox\/(\d+\.\d+)/); 816 const version = match ? match[1] : ''; 817 browser = `Firefox ${version}`; 818 } else if (ua.includes('Safari/') && !ua.includes('Chrome')) { 819 browser = 'Safari'; 820 } 821 822 return `${browser} (${os})`; 823 } 824 825 function renderUserAgentsTable(userAgents) { 826 const container = document.getElementById("userAgentsTable"); 827 828 if (userAgents.length === 0) { 829 container.innerHTML = '<div class="no-results">No user agents found</div>'; 830 return; 831 } 832 833 const totalRequests = userAgents.reduce((sum, ua) => sum + ua.count, 0); 834 835 const tableHTML = ` 836 <table class="ua-table"> 837 <thead> 838 <tr> 839 <th style="width: 50%">User Agent</th> 840 <th style="width: 20%">Requests</th> 841 <th style="width: 15%">Percentage</th> 842 </tr> 843 </thead> 844 <tbody> 845 ${userAgents.map(ua => { 846 const displayName = parseUserAgent(ua.userAgent); 847 const percentage = ((ua.count / totalRequests) * 100).toFixed(1); 848 849 return ` 850 <tr> 851 <td> 852 <div class="ua-name">${displayName}</div> 853 <div class="ua-raw">${ua.userAgent}</div> 854 </td> 855 <td class="ua-count">${ua.count.toLocaleString()}</td> 856 <td class="ua-percentage">${percentage}%</td> 857 </tr> 858 `; 859 }).join('')} 860 </tbody> 861 </table> 862 `; 863 864 container.innerHTML = tableHTML; 865 } 866 867 function setupUserAgentSearch() { 868 const searchInput = document.getElementById('userAgentSearch'); 869 870 searchInput.addEventListener('input', function() { 871 const searchTerm = this.value.toLowerCase().trim(); 872 873 if (searchTerm === '') { 874 renderUserAgentsTable(allUserAgents); 875 return; 876 } 877 878 const filtered = allUserAgents.filter(ua => { 879 const displayName = parseUserAgent(ua.userAgent).toLowerCase(); 880 const rawUA = ua.userAgent.toLowerCase(); 881 return displayName.includes(searchTerm) || rawUA.includes(searchTerm); 882 }); 883 884 renderUserAgentsTable(filtered); 885 }); 886 } 887 888 // Event Handlers 889 document.getElementById("autoRefresh").addEventListener("change", function () { 890 if (this.checked) { 891 autoRefreshInterval = setInterval(loadData, 30000); 892 } else { 893 clearInterval(autoRefreshInterval); 894 } 895 }); 896 897 document.getElementById("daysSelect").addEventListener("change", loadData); 898 899 // Initialize dashboard 900 document.addEventListener('DOMContentLoaded', loadData); 901 902 // Cleanup on page unload 903 window.addEventListener('beforeunload', () => { 904 clearInterval(autoRefreshInterval); 905 Object.values(charts).forEach(chart => { 906 if (chart && typeof chart.destroy === 'function') { 907 chart.destroy(); 908 } 909 }); 910 }); 911 </script> 912 </body> 913</html>