a cache for slack profile pictures and emojis

feat: add stats link

dunkirk.sh c28c12c0 1c7a4df0

verified
Changed files
+747 -740
src
+747 -740
src/dashboard.html
··· 1 1 <!doctype html> 2 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 3 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 - } 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 + } 23 17 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 - } 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 + } 31 25 32 - .header h1 { 33 - color: #111827; 34 - font-size: 1.875rem; 35 - font-weight: 700; 36 - margin-bottom: 0.5rem; 37 - } 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 + } 38 33 39 - .header-links { 40 - display: flex; 41 - gap: 1.5rem; 42 - } 34 + .header h1 { 35 + color: #111827; 36 + font-size: 1.875rem; 37 + font-weight: 700; 38 + margin-bottom: 0.5rem; 39 + } 43 40 44 - .header-links a { 45 - color: #6366f1; 46 - text-decoration: none; 47 - font-weight: 500; 48 - } 41 + .header-links { 42 + display: flex; 43 + gap: 1.5rem; 44 + } 49 45 50 - .header-links a:hover { 51 - color: #4f46e5; 52 - text-decoration: underline; 53 - } 46 + .header-links a { 47 + color: #6366f1; 48 + text-decoration: none; 49 + font-weight: 500; 50 + } 54 51 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 - } 52 + .header-links a:hover { 53 + color: #4f46e5; 54 + text-decoration: underline; 55 + } 63 56 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 - } 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 + } 75 65 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 - } 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 + } 82 77 83 - .controls button { 84 - background: #6366f1; 85 - color: white; 86 - border: none; 87 - } 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 + } 88 84 89 - .controls button:hover { 90 - background: #4f46e5; 91 - } 85 + .controls button { 86 + background: #6366f1; 87 + color: white; 88 + border: none; 89 + } 92 90 93 - .controls button:disabled { 94 - background: #9ca3af; 95 - cursor: not-allowed; 96 - } 91 + .controls button:hover { 92 + background: #4f46e5; 93 + } 97 94 98 - .dashboard { 99 - max-width: 1200px; 100 - margin: 0 auto; 101 - padding: 0 2rem; 102 - } 95 + .controls button:disabled { 96 + background: #9ca3af; 97 + cursor: not-allowed; 98 + } 103 99 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 - } 100 + .dashboard { 101 + max-width: 1200px; 102 + margin: 0 auto; 103 + padding: 0 2rem; 104 + } 110 105 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 - } 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 + } 124 139 125 - .stat-card:hover { 126 - transform: translateY(-2px); 127 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 128 - } 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 + } 129 147 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 - } 148 + .charts-grid { 149 + display: grid; 150 + grid-template-columns: 1fr; 151 + gap: 2rem; 152 + margin-bottom: 3rem; 153 + } 137 154 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 - } 155 + .charts-row { 156 + display: grid; 157 + grid-template-columns: 1fr 1fr; 158 + gap: 2rem; 159 + } 145 160 146 - .charts-grid { 147 - display: grid; 161 + @media (max-width: 768px) { 162 + .charts-row { 148 163 grid-template-columns: 1fr; 149 - gap: 2rem; 150 - margin-bottom: 3rem; 151 164 } 152 165 153 - .charts-row { 154 - display: grid; 155 - grid-template-columns: 1fr 1fr; 156 - gap: 2rem; 166 + .stats-grid { 167 + grid-template-columns: 1fr; 157 168 } 158 169 159 - @media (max-width: 768px) { 160 - .charts-row { 161 - grid-template-columns: 1fr; 162 - } 170 + .dashboard { 171 + padding: 0 1rem; 172 + } 163 173 164 - .stats-grid { 165 - grid-template-columns: 1fr; 166 - } 174 + .stat-number { 175 + font-size: 2rem; 176 + } 177 + } 167 178 168 - .dashboard { 169 - padding: 0 1rem; 170 - } 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 + } 171 188 172 - .stat-number { 173 - font-size: 2rem; 174 - } 175 - } 189 + .chart-title { 190 + font-size: 1.25rem; 191 + margin-bottom: 1.5rem; 192 + color: #111827; 193 + font-weight: 700; 194 + } 176 195 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 - } 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 + } 186 203 187 - .chart-title { 188 - font-size: 1.25rem; 189 - margin-bottom: 1.5rem; 190 - color: #111827; 191 - font-weight: 700; 192 - } 204 + .search-container { 205 + margin-bottom: 1.5rem; 206 + position: relative; 207 + } 193 208 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 - } 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 + } 201 218 202 - .search-container { 203 - margin-bottom: 1.5rem; 204 - position: relative; 205 - } 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 + } 206 225 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 - } 226 + .ua-table { 227 + width: 100%; 228 + border-collapse: collapse; 229 + font-size: 0.875rem; 230 + } 216 231 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 - } 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 + } 223 242 224 - .ua-table { 225 - width: 100%; 226 - border-collapse: collapse; 227 - font-size: 0.875rem; 228 - } 243 + .ua-table td { 244 + padding: 0.75rem 1rem; 245 + border-bottom: 1px solid #f3f4f6; 246 + vertical-align: top; 247 + } 229 248 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 - } 249 + .ua-table tbody tr:hover { 250 + background: #f9fafb; 251 + } 240 252 241 - .ua-table td { 242 - padding: 0.75rem 1rem; 243 - border-bottom: 1px solid #f3f4f6; 244 - vertical-align: top; 245 - } 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 + } 246 260 247 - .ua-table tbody tr:hover { 248 - background: #f9fafb; 249 - } 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 + } 250 270 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 - } 271 + .ua-count { 272 + font-weight: 600; 273 + color: #111827; 274 + text-align: right; 275 + white-space: nowrap; 276 + } 258 277 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 - } 278 + .ua-percentage { 279 + color: #6b7280; 280 + text-align: right; 281 + font-size: 0.75rem; 282 + } 268 283 269 - .ua-count { 270 - font-weight: 600; 271 - color: #111827; 272 - text-align: right; 273 - white-space: nowrap; 274 - } 284 + .no-results { 285 + text-align: center; 286 + padding: 2rem; 287 + color: #6b7280; 288 + font-style: italic; 289 + } 275 290 276 - .ua-percentage { 277 - color: #6b7280; 278 - text-align: right; 279 - font-size: 0.75rem; 280 - } 291 + .loading { 292 + text-align: center; 293 + padding: 3rem; 294 + color: #6b7280; 295 + } 281 296 282 - .no-results { 283 - text-align: center; 284 - padding: 2rem; 285 - color: #6b7280; 286 - font-style: italic; 287 - } 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 + } 288 307 289 - .loading { 290 - text-align: center; 291 - padding: 3rem; 292 - color: #6b7280; 308 + @keyframes spin { 309 + to { 310 + transform: rotate(360deg); 293 311 } 312 + } 294 313 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 - } 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 + } 305 322 306 - @keyframes spin { 307 - to { transform: rotate(360deg); } 308 - } 323 + .auto-refresh { 324 + display: flex; 325 + align-items: center; 326 + gap: 0.5rem; 327 + font-size: 0.875rem; 328 + color: #6b7280; 329 + } 309 330 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 - } 331 + .auto-refresh input[type="checkbox"] { 332 + transform: scale(1.1); 333 + accent-color: #6366f1; 334 + } 335 + </style> 336 + </head> 318 337 319 - .auto-refresh { 320 - display: flex; 321 - align-items: center; 322 - gap: 0.5rem; 323 - font-size: 0.875rem; 324 - color: #6b7280; 325 - } 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> 326 348 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> 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> 340 360 </div> 341 361 </div> 342 362 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> 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> 354 379 </div> 355 - </div> 356 - 357 - <div id="loading" class="loading"> 358 - <div class="loading-spinner"></div> 359 - Loading analytics data... 380 + <div class="stat-card"> 381 + <div class="stat-number" id="avgResponseTime">-</div> 382 + <div class="stat-label">Avg Response Time</div> 383 + </div> 360 384 </div> 361 - <div id="error" class="error" style="display: none"></div> 362 385 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> 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> 369 392 </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> 393 + <div class="chart-container"> 394 + <div class="chart-title">Latency Over Time</div> 395 + <canvas id="latencyChart"></canvas> 377 396 </div> 378 397 </div> 398 + </div> 379 399 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> 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..."> 392 405 </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> 406 + <div id="userAgentsTable"> 407 + <div class="loading">Loading user agents...</div> 403 408 </div> 404 409 </div> 405 410 </div> 411 + </div> 406 412 407 - <script> 408 - const charts = {}; 409 - let autoRefreshInterval; 410 - const _currentData = null; 411 - let _isLoading = false; 412 - let currentRequestId = 0; 413 - let abortController = null; 413 + <script> 414 + const charts = {}; 415 + let autoRefreshInterval; 416 + const _currentData = null; 417 + let _isLoading = false; 418 + let currentRequestId = 0; 419 + let abortController = null; 414 420 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 - } 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 + } 427 433 428 - window.addEventListener('resize', handleResize); 434 + window.addEventListener('resize', handleResize); 429 435 430 - async function loadData() { 431 - // Cancel any existing requests 432 - if (abortController) { 433 - abortController.abort(); 434 - } 436 + async function loadData() { 437 + // Cancel any existing requests 438 + if (abortController) { 439 + abortController.abort(); 440 + } 435 441 436 - // Create new abort controller for this request 437 - abortController = new AbortController(); 438 - const requestId = ++currentRequestId; 439 - const signal = abortController.signal; 442 + // Create new abort controller for this request 443 + abortController = new AbortController(); 444 + const requestId = ++currentRequestId; 445 + const signal = abortController.signal; 440 446 441 - _isLoading = true; 442 - const startTime = Date.now(); 447 + _isLoading = true; 448 + const startTime = Date.now(); 443 449 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 + // 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"); 450 456 451 - console.log(`Starting request ${requestId} for ${days} days`); 457 + console.log(`Starting request ${requestId} for ${days} days`); 452 458 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 + // 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..."; 459 465 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 }); 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}); 464 470 465 - // Check if this request is still current 466 - if (requestId !== currentRequestId) { 467 - console.log(`[${requestId}] Request cancelled (essential stats)`); 468 - return; 469 - } 471 + // Check if this request is still current 472 + if (requestId !== currentRequestId) { 473 + console.log(`[${requestId}] Request cancelled (essential stats)`); 474 + return; 475 + } 470 476 471 - if (!essentialResponse.ok) throw new Error(`HTTP ${essentialResponse.status}`); 477 + if (!essentialResponse.ok) throw new Error(`HTTP ${essentialResponse.status}`); 472 478 473 - const essentialData = await essentialResponse.json(); 479 + const essentialData = await essentialResponse.json(); 474 480 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 - } 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 + } 480 486 481 - updateEssentialStats(essentialData); 487 + updateEssentialStats(essentialData); 482 488 483 - // Show content immediately with essential stats 484 - loading.style.display = "none"; 485 - content.style.display = "block"; 486 - refreshBtn.textContent = "Loading Charts..."; 489 + // Show content immediately with essential stats 490 + loading.style.display = "none"; 491 + content.style.display = "block"; 492 + refreshBtn.textContent = "Loading Charts..."; 487 493 488 - console.log(`[${requestId}] Essential stats loaded in ${Date.now() - startTime}ms`); 494 + console.log(`[${requestId}] Essential stats loaded in ${Date.now() - startTime}ms`); 489 495 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 }); 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}); 493 499 494 - if (requestId !== currentRequestId) { 495 - console.log(`[${requestId}] Request cancelled (chart data)`); 496 - return; 497 - } 500 + if (requestId !== currentRequestId) { 501 + console.log(`[${requestId}] Request cancelled (chart data)`); 502 + return; 503 + } 498 504 499 - if (!chartResponse.ok) throw new Error(`HTTP ${chartResponse.status}`); 505 + if (!chartResponse.ok) throw new Error(`HTTP ${chartResponse.status}`); 500 506 501 - const chartData = await chartResponse.json(); 507 + const chartData = await chartResponse.json(); 502 508 503 - if (requestId !== currentRequestId) { 504 - console.log(`[${requestId}] Request cancelled (chart data after response)`); 505 - return; 506 - } 509 + if (requestId !== currentRequestId) { 510 + console.log(`[${requestId}] Request cancelled (chart data after response)`); 511 + return; 512 + } 507 513 508 - updateCharts(chartData, parseInt(days, 10)); 509 - refreshBtn.textContent = "Loading User Agents..."; 514 + updateCharts(chartData, parseInt(days, 10)); 515 + refreshBtn.textContent = "Loading User Agents..."; 510 516 511 - console.log(`[${requestId}] Charts loaded in ${Date.now() - startTime}ms`); 517 + console.log(`[${requestId}] Charts loaded in ${Date.now() - startTime}ms`); 512 518 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 }); 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}); 516 522 517 - if (requestId !== currentRequestId) { 518 - console.log(`[${requestId}] Request cancelled (user agents)`); 519 - return; 520 - } 523 + if (requestId !== currentRequestId) { 524 + console.log(`[${requestId}] Request cancelled (user agents)`); 525 + return; 526 + } 521 527 522 - if (!userAgentsResponse.ok) throw new Error(`HTTP ${userAgentsResponse.status}`); 528 + if (!userAgentsResponse.ok) throw new Error(`HTTP ${userAgentsResponse.status}`); 523 529 524 - const userAgentsData = await userAgentsResponse.json(); 530 + const userAgentsData = await userAgentsResponse.json(); 525 531 526 - if (requestId !== currentRequestId) { 527 - console.log(`[${requestId}] Request cancelled (user agents after response)`); 528 - return; 529 - } 532 + if (requestId !== currentRequestId) { 533 + console.log(`[${requestId}] Request cancelled (user agents after response)`); 534 + return; 535 + } 530 536 531 - updateUserAgentsTable(userAgentsData); 537 + updateUserAgentsTable(userAgentsData); 532 538 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; 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}`); 554 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; 555 560 } 556 561 } 562 + } 557 563 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 - } 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 + } 565 571 566 - // Update charts (medium speed) 567 - function updateCharts(data, days) { 568 - updateRequestsChart(data.requestsByDay, days === 1); 569 - updateLatencyChart(data.latencyOverTime, days === 1); 570 - } 572 + // Update charts (medium speed) 573 + function updateCharts(data, days) { 574 + updateRequestsChart(data.requestsByDay, days === 1); 575 + updateLatencyChart(data.latencyOverTime, days === 1); 576 + } 571 577 572 578 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); 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); 577 583 578 - if (charts.requests) charts.requests.destroy(); 584 + if (charts.requests) charts.requests.destroy(); 579 585 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 - }); 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 + }); 596 602 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)' 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}`; 635 631 }, 636 - grid: { color: 'rgba(0, 0, 0, 0.05)' }, 637 - ticks: { 638 - maxTicksLimit: days === 1 ? 12 : 20, 639 - maxRotation: 0, 640 - minRotation: 0 641 - } 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)' 642 641 }, 643 - y: { 644 - title: { display: true, text: 'Requests' }, 645 - beginAtZero: true, 646 - grid: { color: 'rgba(0, 0, 0, 0.05)' } 642 + grid: {color: 'rgba(0, 0, 0, 0.05)'}, 643 + ticks: { 644 + maxTicksLimit: days === 1 ? 12 : 20, 645 + maxRotation: 0, 646 + minRotation: 0 647 647 } 648 + }, 649 + y: { 650 + title: {display: true, text: 'Requests'}, 651 + beginAtZero: true, 652 + grid: {color: 'rgba(0, 0, 0, 0.05)'} 648 653 } 649 654 } 650 - }); 651 - } 655 + } 656 + }); 657 + } 652 658 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); 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); 657 663 658 - if (charts.latency) charts.latency.destroy(); 664 + if (charts.latency) charts.latency.destroy(); 659 665 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 - }); 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 + }); 676 682 677 - // Calculate dynamic max for logarithmic scale 678 - const responseTimes = data.map((d) => d.averageResponseTime); 679 - const maxResponseTime = Math.max(...responseTimes); 683 + // Calculate dynamic max for logarithmic scale 684 + const responseTimes = data.map((d) => d.averageResponseTime); 685 + const maxResponseTime = Math.max(...responseTimes); 680 686 681 - // Calculate appropriate max for log scale (next power of 10) 682 - const logMax = 10 ** Math.ceil(Math.log10(maxResponseTime)); 687 + // Calculate appropriate max for log scale (next power of 10) 688 + const logMax = 10 ** Math.ceil(Math.log10(maxResponseTime)); 683 689 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 - }; 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 + }; 694 700 695 - const dynamicTicks = generateLogTicks(1, logMax); 701 + const dynamicTicks = generateLogTicks(1, logMax); 696 702 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)' 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}`; 741 731 }, 742 - grid: { color: 'rgba(0, 0, 0, 0.05)' }, 743 - ticks: { 744 - maxTicksLimit: days === 1 ? 12 : 20, 745 - maxRotation: 0, 746 - minRotation: 0 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 + ]; 747 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)' 748 747 }, 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 ''; 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`; 762 766 } 767 + return ''; 763 768 } 764 769 } 765 770 } 766 771 } 767 - }); 768 - } 772 + } 773 + }); 774 + } 775 + 776 + // User Agents Table 777 + let allUserAgents = []; 769 778 770 - // User Agents Table 771 - let allUserAgents = []; 779 + function updateUserAgentsTable(userAgents) { 780 + allUserAgents = userAgents; 781 + renderUserAgentsTable(userAgents); 782 + setupUserAgentSearch(); 783 + } 772 784 773 - function updateUserAgentsTable(userAgents) { 774 - allUserAgents = userAgents; 775 - renderUserAgentsTable(userAgents); 776 - setupUserAgentSearch(); 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; 777 799 } 778 800 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 - } 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'; 794 811 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'; 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 + } 802 827 803 - // Detect browser and version 804 - let browser = 'Unknown Browser'; 828 + return `${browser} (${os})`; 829 + } 805 830 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 - } 831 + function renderUserAgentsTable(userAgents) { 832 + const container = document.getElementById("userAgentsTable"); 821 833 822 - return `${browser} (${os})`; 834 + if (userAgents.length === 0) { 835 + container.innerHTML = '<div class="no-results">No user agents found</div>'; 836 + return; 823 837 } 824 838 825 - function renderUserAgentsTable(userAgents) { 826 - const container = document.getElementById("userAgentsTable"); 839 + const totalRequests = userAgents.reduce((sum, ua) => sum + ua.count, 0); 827 840 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 = ` 841 + const tableHTML = ` 836 842 <table class="ua-table"> 837 843 <thead> 838 844 <tr> ··· 843 849 </thead> 844 850 <tbody> 845 851 ${userAgents.map(ua => { 846 - const displayName = parseUserAgent(ua.userAgent); 847 - const percentage = ((ua.count / totalRequests) * 100).toFixed(1); 852 + const displayName = parseUserAgent(ua.userAgent); 853 + const percentage = ((ua.count / totalRequests) * 100).toFixed(1); 848 854 849 - return ` 855 + return ` 850 856 <tr> 851 857 <td> 852 858 <div class="ua-name">${displayName}</div> ··· 856 862 <td class="ua-percentage">${percentage}%</td> 857 863 </tr> 858 864 `; 859 - }).join('')} 865 + }).join('')} 860 866 </tbody> 861 867 </table> 862 868 `; 863 869 864 - container.innerHTML = tableHTML; 865 - } 870 + container.innerHTML = tableHTML; 871 + } 866 872 867 - function setupUserAgentSearch() { 868 - const searchInput = document.getElementById('userAgentSearch'); 869 - 870 - searchInput.addEventListener('input', function() { 871 - const searchTerm = this.value.toLowerCase().trim(); 873 + function setupUserAgentSearch() { 874 + const searchInput = document.getElementById('userAgentSearch'); 872 875 873 - if (searchTerm === '') { 874 - renderUserAgentsTable(allUserAgents); 875 - return; 876 - } 876 + searchInput.addEventListener('input', function () { 877 + const searchTerm = this.value.toLowerCase().trim(); 877 878 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 - }); 879 + if (searchTerm === '') { 880 + renderUserAgentsTable(allUserAgents); 881 + return; 882 + } 883 883 884 - renderUserAgentsTable(filtered); 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); 885 888 }); 886 - } 887 889 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 - } 890 + renderUserAgentsTable(filtered); 895 891 }); 892 + } 896 893 897 - document.getElementById("daysSelect").addEventListener("change", loadData); 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 + }); 898 902 899 - // Initialize dashboard 900 - document.addEventListener('DOMContentLoaded', loadData); 903 + document.getElementById("daysSelect").addEventListener("change", loadData); 901 904 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 - }); 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 + } 910 915 }); 911 - </script> 912 - </body> 913 - </html> 916 + }); 917 + </script> 918 + </body> 919 + 920 + </html>