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