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>