a cache for slack profile pictures and emojis
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>