a cache for slack profile pictures and emojis

feat: add fancy dashboard

dunkirk.sh 6b4adf72 9911dbc1

verified
Changed files
+591 -739
src
+45 -3
src/cache.ts
··· 1710 1710 } 1711 1711 1712 1712 /** 1713 + * Gets traffic data for charts with adaptive granularity 1714 + * @param options - Either days for relative range, or start/end for absolute range 1715 + * @returns Array of bucket data points 1716 + */ 1717 + getTraffic(options: { 1718 + days?: number; 1719 + startTime?: number; 1720 + endTime?: number; 1721 + } = {}): Array<{ bucket: number; hits: number }> { 1722 + const now = Math.floor(Date.now() / 1000); 1723 + let start: number; 1724 + let end: number; 1725 + 1726 + if (options.startTime && options.endTime) { 1727 + start = options.startTime; 1728 + end = options.endTime; 1729 + } else { 1730 + const days = options.days || 7; 1731 + start = now - days * 24 * 60 * 60; 1732 + end = now; 1733 + } 1734 + 1735 + const spanDays = (end - start) / 86400; 1736 + const { table, bucketSize } = this.selectBucketTable(spanDays); 1737 + const alignedStart = start - (start % bucketSize); 1738 + 1739 + const results = this.db 1740 + .query( 1741 + ` 1742 + SELECT bucket, SUM(hits) as hits 1743 + FROM ${table} 1744 + WHERE bucket >= ? AND bucket <= ? AND endpoint != '/stats' 1745 + GROUP BY bucket 1746 + ORDER BY bucket ASC 1747 + `, 1748 + ) 1749 + .all(alignedStart, end) as Array<{ bucket: number; hits: number }>; 1750 + 1751 + return results; 1752 + } 1753 + 1754 + /** 1713 1755 * Gets user agents data from cumulative stats table 1714 1756 * @param _days Unused - user_agent_stats is cumulative 1715 1757 * @returns User agents data 1716 1758 */ 1717 1759 async getUserAgents( 1718 1760 _days: number = 7, 1719 - ): Promise<Array<{ userAgent: string; count: number }>> { 1761 + ): Promise<Array<{ userAgent: string; hits: number }>> { 1720 1762 const cacheKey = "useragents_all"; 1721 1763 const cached = this.typedAnalyticsCache.getUserAgentData(cacheKey); 1722 1764 ··· 1728 1770 const topUserAgents = this.db 1729 1771 .query( 1730 1772 ` 1731 - SELECT user_agent as userAgent, hits as count 1773 + SELECT user_agent as userAgent, hits 1732 1774 FROM user_agent_stats 1733 1775 WHERE user_agent IS NOT NULL 1734 1776 ORDER BY hits DESC 1735 1777 LIMIT 50 1736 1778 `, 1737 1779 ) 1738 - .all() as Array<{ userAgent: string; count: number }>; 1780 + .all() as Array<{ userAgent: string; hits: number }>; 1739 1781 1740 1782 this.typedAnalyticsCache.setUserAgentData(cacheKey, topUserAgents); 1741 1783
+429 -728
src/dashboard.html
··· 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 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> 8 + <script src="https://cdn.jsdelivr.net/npm/uplot@1.6.30/dist/uPlot.iife.min.js"></script> 9 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.30/dist/uPlot.min.css"> 11 10 <style> 12 11 * { 13 12 margin: 0; ··· 16 15 } 17 16 18 17 body { 19 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 20 - sans-serif; 21 - background: #f9fafb; 22 - color: #111827; 18 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 19 + background: #0d1117; 20 + color: #c9d1d9; 23 21 line-height: 1.6; 24 22 } 25 23 26 24 .header { 27 - background: #fff; 28 - padding: 1.5rem 2rem; 29 - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 25 + background: #161b22; 26 + padding: 1rem 2rem; 27 + border-bottom: 1px solid #30363d; 30 28 margin-bottom: 2rem; 31 - border-bottom: 1px solid #e5e7eb; 29 + display: flex; 30 + justify-content: space-between; 31 + align-items: center; 32 + flex-wrap: wrap; 33 + gap: 1rem; 32 34 } 33 35 34 - .header h1 { 35 - color: #111827; 36 - font-size: 1.875rem; 37 - font-weight: 700; 38 - margin-bottom: 0.5rem; 36 + .header-left h1 { 37 + color: #f0f6fc; 38 + font-size: 1.5rem; 39 + font-weight: 600; 40 + margin-bottom: 0.25rem; 39 41 } 40 42 41 43 .header-links { 42 44 display: flex; 43 - gap: 1.5rem; 45 + gap: 1rem; 44 46 } 45 47 46 48 .header-links a { 47 - color: #6366f1; 49 + color: #58a6ff; 48 50 text-decoration: none; 49 - font-weight: 500; 51 + font-size: 0.875rem; 50 52 } 51 53 52 54 .header-links a:hover { 53 - color: #4f46e5; 54 55 text-decoration: underline; 55 56 } 56 57 57 - .controls { 58 - margin-bottom: 2rem; 58 + .header-right { 59 59 display: flex; 60 - justify-content: center; 61 - align-items: center; 62 - gap: 1rem; 63 - flex-wrap: wrap; 60 + gap: 0.5rem; 64 61 } 65 62 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; 63 + .dashboard { 64 + max-width: 1200px; 65 + margin: 0 auto; 66 + padding: 0 2rem 2rem; 76 67 } 77 68 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); 69 + .time-btn { 70 + padding: 0.375rem 0.75rem; 71 + border: 1px solid #30363d; 72 + border-radius: 6px; 73 + background: #21262d; 74 + color: #c9d1d9; 75 + cursor: pointer; 76 + font-size: 0.8125rem; 77 + transition: all 0.15s ease; 83 78 } 84 79 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; 80 + .time-btn:hover { 81 + background: #30363d; 82 + border-color: #8b949e; 98 83 } 99 84 100 - .dashboard { 101 - max-width: 1200px; 102 - margin: 0 auto; 103 - padding: 0 2rem; 85 + .time-btn.active { 86 + background: #238636; 87 + border-color: #238636; 88 + color: #fff; 104 89 } 105 90 106 91 .stats-grid { 107 92 display: grid; 108 - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 109 - gap: 1.5rem; 110 - margin-bottom: 3rem; 93 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 94 + gap: 1rem; 95 + margin-bottom: 2rem; 111 96 } 112 97 113 98 .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); 99 + background: #161b22; 100 + padding: 1.5rem; 101 + border-radius: 8px; 102 + border: 1px solid #30363d; 130 103 } 131 104 132 105 .stat-number { 133 - font-weight: 800; 134 - color: #111827; 135 - margin-bottom: 0.5rem; 136 - font-size: 2.5rem; 106 + font-size: 2rem; 107 + font-weight: 700; 108 + color: #f0f6fc; 137 109 line-height: 1; 110 + margin-bottom: 0.25rem; 138 111 } 139 112 140 113 .stat-label { 141 - color: #6b7280; 142 - font-weight: 600; 143 - font-size: 0.875rem; 114 + color: #8b949e; 115 + font-size: 0.75rem; 144 116 text-transform: uppercase; 145 117 letter-spacing: 0.05em; 146 118 } 147 119 148 - .charts-grid { 149 - display: grid; 150 - grid-template-columns: 1fr; 151 - gap: 2rem; 152 - margin-bottom: 3rem; 120 + .chart-container { 121 + background: #161b22; 122 + border-radius: 8px; 123 + border: 1px solid #30363d; 124 + padding: 1.5rem; 125 + margin-bottom: 2rem; 126 + position: relative; 153 127 } 154 128 155 - .charts-row { 156 - display: grid; 157 - grid-template-columns: 1fr 1fr; 158 - gap: 2rem; 129 + .chart-title { 130 + color: #f0f6fc; 131 + font-size: 1rem; 132 + font-weight: 600; 133 + margin-bottom: 1rem; 159 134 } 160 135 161 - @media (max-width: 768px) { 162 - .charts-row { 163 - grid-template-columns: 1fr; 164 - } 136 + .chart-hint { 137 + color: #8b949e; 138 + font-size: 0.75rem; 139 + margin-top: 0.5rem; 140 + } 165 141 166 - .stats-grid { 167 - grid-template-columns: 1fr; 168 - } 142 + #traffic-chart { 143 + width: 100%; 144 + } 169 145 170 - .dashboard { 171 - padding: 0 1rem; 172 - } 173 - 174 - .stat-number { 175 - font-size: 2rem; 176 - } 146 + .loading-overlay { 147 + position: absolute; 148 + top: 0; 149 + left: 0; 150 + right: 0; 151 + bottom: 0; 152 + background: rgba(13, 17, 23, 0.8); 153 + display: flex; 154 + align-items: center; 155 + justify-content: center; 156 + border-radius: 8px; 157 + z-index: 10; 177 158 } 178 159 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; 160 + .loading-overlay.hidden { 161 + display: none; 187 162 } 188 163 189 - .chart-title { 190 - font-size: 1.25rem; 191 - margin-bottom: 1.5rem; 192 - color: #111827; 193 - font-weight: 700; 164 + .loading-text { 165 + color: #8b949e; 166 + font-size: 0.875rem; 194 167 } 195 168 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; 169 + .user-agents-section { 170 + background: #161b22; 171 + border-radius: 8px; 172 + border: 1px solid #30363d; 173 + padding: 1.5rem; 202 174 } 203 175 204 - .search-container { 205 - margin-bottom: 1.5rem; 206 - position: relative; 176 + .section-title { 177 + color: #f0f6fc; 178 + font-size: 1rem; 179 + font-weight: 600; 180 + margin-bottom: 1rem; 207 181 } 208 182 209 183 .search-input { 210 184 width: 100%; 211 - padding: 0.75rem 1rem; 212 - border: 1px solid #d1d5db; 213 - border-radius: 8px; 185 + padding: 0.625rem 0.875rem; 186 + border: 1px solid #30363d; 187 + border-radius: 6px; 188 + background: #0d1117; 189 + color: #c9d1d9; 214 190 font-size: 0.875rem; 215 - background: #f9fafb; 216 - transition: border-color 0.2s ease; 191 + margin-bottom: 1rem; 217 192 } 218 193 219 194 .search-input:focus { 220 195 outline: none; 221 - border-color: #6366f1; 222 - background: white; 223 - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); 196 + border-color: #58a6ff; 224 197 } 225 198 226 - .ua-table { 227 - width: 100%; 228 - border-collapse: collapse; 229 - font-size: 0.875rem; 199 + .search-input::placeholder { 200 + color: #6e7681; 230 201 } 231 202 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; 203 + .ua-list { 204 + max-height: 400px; 205 + overflow-y: auto; 241 206 } 242 207 243 - .ua-table td { 244 - padding: 0.75rem 1rem; 245 - border-bottom: 1px solid #f3f4f6; 246 - vertical-align: top; 208 + .ua-item { 209 + display: flex; 210 + justify-content: space-between; 211 + align-items: center; 212 + padding: 0.75rem 0; 213 + border-bottom: 1px solid #21262d; 247 214 } 248 215 249 - .ua-table tbody tr:hover { 250 - background: #f9fafb; 216 + .ua-item:last-child { 217 + border-bottom: none; 251 218 } 252 219 253 - .ua-name { 254 - font-weight: 500; 255 - color: #111827; 256 - line-height: 1.4; 257 - max-width: 400px; 258 - word-break: break-word; 220 + .ua-rank { 221 + width: 2rem; 222 + font-weight: 600; 223 + color: #8b949e; 259 224 } 260 225 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; 226 + .ua-rank.top-1 { color: #ffd700; } 227 + .ua-rank.top-2 { color: #c0c0c0; } 228 + .ua-rank.top-3 { color: #cd7f32; } 229 + 230 + .ua-name { 231 + flex: 1; 232 + font-size: 0.875rem; 233 + color: #c9d1d9; 234 + overflow: hidden; 235 + text-overflow: ellipsis; 236 + white-space: nowrap; 237 + margin-right: 1rem; 269 238 } 270 239 271 240 .ua-count { 272 241 font-weight: 600; 273 - color: #111827; 274 - text-align: right; 275 - white-space: nowrap; 242 + color: #f0f6fc; 243 + font-size: 0.875rem; 276 244 } 277 245 278 - .ua-percentage { 279 - color: #6b7280; 280 - text-align: right; 281 - font-size: 0.75rem; 246 + .uplot { 247 + margin: 0 auto; 282 248 } 283 249 284 - .no-results { 285 - text-align: center; 286 - padding: 2rem; 287 - color: #6b7280; 288 - font-style: italic; 250 + .u-legend { 251 + display: none !important; 289 252 } 290 253 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 - } 254 + @media (max-width: 768px) { 255 + .dashboard { 256 + padding: 0 1rem 1rem; 257 + } 307 258 308 - @keyframes spin { 309 - to { 310 - transform: rotate(360deg); 259 + .stats-grid { 260 + grid-template-columns: repeat(2, 1fr); 311 261 } 312 - } 313 262 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; 263 + .stat-number { 264 + font-size: 1.5rem; 265 + } 334 266 } 335 267 </style> 336 268 </head> 337 269 338 270 <body> 339 271 <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> 272 + <div class="header-left"> 273 + <h1>📊 Cachet Analytics</h1> 274 + <div class="header-links"> 275 + <a href="/swagger">API Docs</a> 276 + <a href="/health">Health</a> 277 + <a href="https://tangled.sh/dunkirk.sh/cachet" target="_blank">Source</a> 278 + </div> 279 + </div> 280 + <div class="header-right" role="group" aria-label="Time range selector"> 281 + <button class="time-btn" data-days="1">24h</button> 282 + <button class="time-btn active" data-days="7">7d</button> 283 + <button class="time-btn" data-days="30">30d</button> 284 + <button class="time-btn" data-days="90">90d</button> 285 + <button class="time-btn" data-days="365">1y</button> 346 286 </div> 347 287 </div> 348 288 349 289 <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> 290 + 291 + <div class="stats-grid"> 292 + <div class="stat-card"> 293 + <div class="stat-number" id="totalRequests">-</div> 294 + <div class="stat-label">Total Requests</div> 295 + </div> 296 + <div class="stat-card"> 297 + <div class="stat-number" id="avgResponseTime">-</div> 298 + <div class="stat-label">Avg Response</div> 299 + </div> 300 + <div class="stat-card"> 301 + <div class="stat-number" id="uptime">-</div> 302 + <div class="stat-label">Uptime</div> 303 + </div> 304 + <div class="stat-card"> 305 + <div class="stat-number" id="uniqueAgents">-</div> 306 + <div class="stat-label">User Agents</div> 360 307 </div> 361 308 </div> 362 309 363 - <div id="loading" class="loading"> 364 - <div class="loading-spinner"></div> 365 - Loading analytics data... 310 + <div class="chart-container"> 311 + <div class="chart-title">Requests Over Time</div> 312 + <div id="traffic-chart" role="img" aria-label="Traffic chart"></div> 313 + <div class="chart-hint">Drag to zoom • Double-click to reset</div> 314 + <div class="loading-overlay hidden" id="chartLoading"> 315 + <span class="loading-text">Loading...</span> 316 + </div> 366 317 </div> 367 - <div id="error" class="error" style="display: none"></div> 368 318 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> 319 + <div class="user-agents-section"> 320 + <div class="section-title">Top User Agents</div> 321 + <input type="text" class="search-input" id="uaSearch" placeholder="Search user agents..."> 322 + <div class="ua-list" id="uaList"></div> 410 323 </div> 411 324 </div> 412 325 413 326 <script> 414 - const charts = {}; 415 - let autoRefreshInterval; 416 - const _currentData = null; 417 - let _isLoading = false; 418 - let currentRequestId = 0; 327 + // State 328 + let currentDays = 7; 329 + let chart = null; 330 + let allUserAgents = []; 419 331 let abortController = null; 420 332 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); 333 + // Utilities 334 + function formatNumber(n) { 335 + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; 336 + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; 337 + return n.toLocaleString(); 432 338 } 433 339 434 - window.addEventListener('resize', handleResize); 340 + function formatMs(ms) { 341 + if (ms === null || ms === undefined) return '-'; 342 + if (ms < 1) return '<1ms'; 343 + if (ms < 1000) return `${Math.round(ms)}ms`; 344 + return `${(ms / 1000).toFixed(2)}s`; 345 + } 435 346 436 - async function loadData() { 437 - // Cancel any existing requests 438 - if (abortController) { 439 - abortController.abort(); 347 + function parseUserAgent(ua) { 348 + if (!ua) return 'Unknown'; 349 + if (ua.length < 50 || !ua.includes('Mozilla/') || ua.includes('bot') || ua.includes('curl')) { 350 + return ua.length > 60 ? ua.substring(0, 57) + '...' : ua; 440 351 } 441 352 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..."; 353 + const os = ua.includes('Macintosh') ? 'macOS' : 354 + ua.includes('Windows') ? 'Windows' : 355 + ua.includes('Linux') ? 'Linux' : 356 + ua.includes('iPhone') ? 'iOS' : 357 + ua.includes('Android') ? 'Android' : ''; 465 358 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}); 359 + let browser = ''; 360 + if (ua.includes('Edg/')) browser = 'Edge'; 361 + else if (ua.includes('Chrome/')) browser = 'Chrome'; 362 + else if (ua.includes('Firefox/')) browser = 'Firefox'; 363 + else if (ua.includes('Safari/') && !ua.includes('Chrome')) browser = 'Safari'; 470 364 471 - // Check if this request is still current 472 - if (requestId !== currentRequestId) { 473 - console.log(`[${requestId}] Request cancelled (essential stats)`); 474 - return; 475 - } 365 + if (browser && os) return `${browser} (${os})`; 366 + if (browser) return browser; 367 + return ua.length > 60 ? ua.substring(0, 57) + '...' : ua; 368 + } 476 369 477 - if (!essentialResponse.ok) throw new Error(`HTTP ${essentialResponse.status}`); 370 + // Chart 371 + function initChart(data) { 372 + const container = document.getElementById('traffic-chart'); 373 + const width = container.clientWidth; 478 374 479 - const essentialData = await essentialResponse.json(); 375 + if (chart) { 376 + chart.destroy(); 377 + } 480 378 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 - } 379 + if (!data || data.length === 0) { 380 + container.innerHTML = '<div style="color: #8b949e; text-align: center; padding: 2rem;">No data available</div>'; 381 + return; 561 382 } 562 - } 563 383 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(); 384 + // Transform data for uPlot 385 + const timestamps = data.map(d => d.bucket); 386 + const hits = data.map(d => d.hits); 585 387 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 - }); 388 + const minSpan = 1.5 * 86400; // 1.5 days minimum zoom 602 389 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 - }], 390 + const opts = { 391 + width: width, 392 + height: 280, 393 + cursor: { 394 + drag: { x: true, y: false } 618 395 }, 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()}` 396 + select: { 397 + show: true, 398 + over: true, 399 + }, 400 + scales: { 401 + x: { 402 + time: true, 403 + range: (u, dataMin, dataMax) => { 404 + let min = dataMin; 405 + let max = dataMax; 406 + const span = max - min; 407 + if (span < minSpan) { 408 + const center = (min + max) / 2; 409 + min = center - minSpan / 2; 410 + max = center + minSpan / 2; 633 411 } 412 + return [min, max]; 634 413 } 635 414 }, 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 415 + y: { auto: true, range: (u, min, max) => [0, max * 1.1] } 416 + }, 417 + axes: [ 418 + { 419 + stroke: '#8b949e', 420 + grid: { stroke: '#21262d', width: 1 }, 421 + ticks: { stroke: '#30363d', width: 1 }, 422 + font: '11px system-ui', 423 + }, 424 + { 425 + stroke: '#8b949e', 426 + grid: { stroke: '#21262d', width: 1 }, 427 + ticks: { stroke: '#30363d', width: 1 }, 428 + font: '11px system-ui', 429 + values: (u, vals) => vals.map(v => formatNumber(v)), 430 + } 431 + ], 432 + series: [ 433 + {}, 434 + { 435 + label: 'Requests', 436 + stroke: '#238636', 437 + width: 2, 438 + fill: 'rgba(35, 134, 54, 0.1)', 439 + } 440 + ], 441 + hooks: { 442 + setSelect: [ 443 + (u) => { 444 + if (u.select.width > 10) { 445 + const min = u.posToVal(u.select.left, 'x'); 446 + const max = u.posToVal(u.select.left + u.select.width, 'x'); 447 + handleZoom(min, max); 647 448 } 648 - }, 649 - y: { 650 - title: {display: true, text: 'Requests'}, 651 - beginAtZero: true, 652 - grid: {color: 'rgba(0, 0, 0, 0.05)'} 449 + u.setSelect({ left: 0, width: 0, top: 0, height: 0 }, false); 653 450 } 654 - } 451 + ] 655 452 } 453 + }; 454 + 455 + chart = new uPlot(opts, [timestamps, hits], container); 456 + 457 + // Double-click to reset zoom 458 + container.addEventListener('dblclick', () => { 459 + loadData(); 656 460 }); 657 461 } 658 462 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(); 463 + function handleZoom(minTime, maxTime) { 464 + const minSpan = 1.5 * 86400; // 1.5 days minimum 465 + let span = maxTime - minTime; 466 + 467 + // Enforce minimum zoom 468 + if (span < minSpan) { 469 + const center = (minTime + maxTime) / 2; 470 + minTime = Math.floor(center - minSpan / 2); 471 + maxTime = Math.floor(center + minSpan / 2); 472 + } 665 473 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]; 474 + showLoading(true); 475 + fetchTrafficData(minTime, maxTime).then(data => { 476 + if (data !== null) { 477 + initChart(data); 680 478 } 479 + showLoading(false); 681 480 }); 481 + } 682 482 683 - // Calculate dynamic max for logarithmic scale 684 - const responseTimes = data.map((d) => d.averageResponseTime); 685 - const maxResponseTime = Math.max(...responseTimes); 483 + function showLoading(show) { 484 + document.getElementById('chartLoading').classList.toggle('hidden', !show); 485 + } 686 486 687 - // Calculate appropriate max for log scale (next power of 10) 688 - const logMax = 10 ** Math.ceil(Math.log10(maxResponseTime)); 487 + // Data fetching 488 + let fetchId = 0; 489 + async function fetchTrafficData(startTime, endTime) { 490 + const thisFetchId = ++fetchId; 491 + 492 + if (abortController) { 493 + abortController.abort(); 494 + } 495 + abortController = new AbortController(); 689 496 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; 497 + try { 498 + let url = '/stats/traffic'; 499 + if (startTime && endTime) { 500 + url += `?start=${Math.floor(startTime)}&end=${Math.floor(endTime)}`; 501 + } else { 502 + url += `?days=${currentDays}`; 697 503 } 698 - return ticks; 699 - }; 700 504 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 - } 505 + const res = await fetch(url, { signal: abortController.signal }); 506 + if (!res.ok) throw new Error('Failed to fetch traffic data'); 507 + const data = await res.json(); 508 + 509 + // Only return data if this is still the latest fetch 510 + if (thisFetchId !== fetchId) return null; 511 + return data; 512 + } catch (e) { 513 + if (e.name !== 'AbortError') { 514 + console.error('Error fetching traffic:', e); 772 515 } 773 - }); 516 + return null; 517 + } 774 518 } 775 519 776 - // User Agents Table 777 - let allUserAgents = []; 520 + async function loadData() { 521 + showLoading(true); 778 522 779 - function updateUserAgentsTable(userAgents) { 780 - allUserAgents = userAgents; 781 - renderUserAgentsTable(userAgents); 782 - setupUserAgentSearch(); 783 - } 523 + try { 524 + // Fetch stats and traffic in parallel 525 + const [statsRes, trafficData, uaRes] = await Promise.all([ 526 + fetch(`/stats/essential?days=${currentDays}`), 527 + fetchTrafficData(), 528 + fetch('/stats/useragents') 529 + ]); 784 530 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 - } 531 + // If traffic fetch was superseded, don't update 532 + if (trafficData === null) { 533 + showLoading(false); 534 + return; 535 + } 800 536 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'; 537 + const stats = await statsRes.json(); 538 + const userAgents = await uaRes.json(); 539 + 540 + // Update stats cards 541 + document.getElementById('totalRequests').textContent = formatNumber(stats.totalRequests || 0); 542 + document.getElementById('avgResponseTime').textContent = formatMs(stats.averageResponseTime); 543 + document.getElementById('uptime').textContent = stats.uptime ? `${stats.uptime.toFixed(1)}%` : '-'; 544 + document.getElementById('uniqueAgents').textContent = formatNumber(userAgents.length || 0); 545 + 546 + // Update chart 547 + initChart(trafficData); 808 548 809 - // Detect browser and version 810 - let browser = 'Unknown Browser'; 549 + // Update user agents 550 + allUserAgents = userAgents; 551 + renderUserAgents(userAgents); 811 552 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'; 553 + } catch (e) { 554 + console.error('Error loading data:', e); 555 + } finally { 556 + showLoading(false); 826 557 } 827 - 828 - return `${browser} (${os})`; 829 558 } 830 559 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>'; 560 + // User agents 561 + function renderUserAgents(agents) { 562 + const list = document.getElementById('uaList'); 563 + 564 + if (!agents || agents.length === 0) { 565 + list.innerHTML = '<div style="color: #8b949e; padding: 1rem 0;">No user agents found</div>'; 836 566 return; 837 567 } 838 568 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 - 569 + list.innerHTML = agents.slice(0, 50).map((ua, i) => { 570 + const rankClass = i < 3 ? `top-${i + 1}` : ''; 855 571 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> 572 + <div class="ua-item"> 573 + <span class="ua-rank ${rankClass}">${i + 1}</span> 574 + <span class="ua-name" title="${ua.userAgent}">${parseUserAgent(ua.userAgent)}</span> 575 + <span class="ua-count">${formatNumber(ua.hits || ua.count)}</span> 576 + </div> 868 577 `; 869 - 870 - container.innerHTML = tableHTML; 578 + }).join(''); 871 579 } 872 580 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); 581 + // Event listeners 582 + document.querySelectorAll('.time-btn').forEach(btn => { 583 + btn.addEventListener('click', () => { 584 + document.querySelectorAll('.time-btn').forEach(b => b.classList.remove('active')); 585 + btn.classList.add('active'); 586 + currentDays = parseInt(btn.dataset.days); 587 + loadData(); 891 588 }); 892 - } 589 + }); 893 590 894 - // Event Handlers 895 - document.getElementById("autoRefresh").addEventListener("change", function () { 896 - if (this.checked) { 897 - autoRefreshInterval = setInterval(loadData, 30000); 898 - } else { 899 - clearInterval(autoRefreshInterval); 591 + document.getElementById('uaSearch').addEventListener('input', (e) => { 592 + const term = e.target.value.toLowerCase(); 593 + if (!term) { 594 + renderUserAgents(allUserAgents); 595 + return; 900 596 } 597 + const filtered = allUserAgents.filter(ua => 598 + ua.userAgent.toLowerCase().includes(term) || 599 + parseUserAgent(ua.userAgent).toLowerCase().includes(term) 600 + ); 601 + renderUserAgents(filtered); 901 602 }); 902 603 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(); 604 + // Handle resize 605 + let resizeTimeout; 606 + window.addEventListener('resize', () => { 607 + clearTimeout(resizeTimeout); 608 + resizeTimeout = setTimeout(() => { 609 + if (chart) { 610 + const container = document.getElementById('traffic-chart'); 611 + chart.setSize({ width: container.clientWidth, height: 280 }); 914 612 } 915 - }); 613 + }, 100); 916 614 }); 615 + 616 + // Initialize 617 + document.addEventListener('DOMContentLoaded', loadData); 917 618 </script> 918 619 </body> 919 620 920 - </html> 621 + </html>
+23 -3
src/handlers/index.ts
··· 285 285 }; 286 286 287 287 export const handleGetUserAgents: RouteHandlerWithAnalytics = async ( 288 + _request, 289 + recordAnalytics, 290 + ) => { 291 + const userAgents = await cache.getUserAgents(); 292 + await recordAnalytics(200); 293 + return Response.json(userAgents); 294 + }; 295 + 296 + export const handleGetTraffic: RouteHandlerWithAnalytics = async ( 288 297 request, 289 298 recordAnalytics, 290 299 ) => { 291 300 const url = new URL(request.url); 292 301 const params = new URLSearchParams(url.search); 302 + 303 + const startParam = params.get("start"); 304 + const endParam = params.get("end"); 293 305 const daysParam = params.get("days"); 294 - const days = daysParam ? parseInt(daysParam, 10) : 7; 295 306 296 - const userAgents = await cache.getUserAgents(days); 307 + let options: { days?: number; startTime?: number; endTime?: number } = {}; 308 + 309 + if (startParam && endParam) { 310 + options.startTime = parseInt(startParam, 10); 311 + options.endTime = parseInt(endParam, 10); 312 + } else { 313 + options.days = daysParam ? parseInt(daysParam, 10) : 7; 314 + } 315 + 316 + const traffic = cache.getTraffic(options); 297 317 await recordAnalytics(200); 298 - return Response.json(userAgents); 318 + return Response.json(traffic); 299 319 }; 300 320 301 321 export const handleGetStats: RouteHandlerWithAnalytics = async (
+94 -5
src/routes/api-routes.ts
··· 396 396 { 397 397 summary: "Get user agents statistics", 398 398 description: 399 - "List of user agents accessing the service with request counts", 399 + "Cumulative list of user agents accessing the service with hit counts", 400 + tags: ["Analytics"], 401 + responses: Object.fromEntries([ 402 + apiResponse(200, "User agents data retrieved successfully", { 403 + type: "array", 404 + items: { 405 + type: "object", 406 + properties: { 407 + userAgent: { type: "string", example: "Mozilla/5.0..." }, 408 + hits: { type: "number", example: 123 }, 409 + }, 410 + }, 411 + }), 412 + ]), 413 + }, 414 + ), 415 + }, 416 + 417 + "/stats/traffic": { 418 + GET: createRoute( 419 + withAnalytics("/stats/traffic", "GET", handlers.handleGetTraffic), 420 + { 421 + summary: "Get traffic time-series data", 422 + description: 423 + "Returns bucketed traffic data with adaptive granularity based on time range", 400 424 tags: ["Analytics"], 401 425 parameters: { 402 426 query: [ 403 427 queryParam( 404 428 "days", 405 429 "number", 406 - "Number of days to analyze", 430 + "Number of days to look back (default: 7)", 407 431 false, 408 432 7, 409 433 ), 434 + queryParam( 435 + "start", 436 + "number", 437 + "Start timestamp in seconds (use with end)", 438 + false, 439 + ), 440 + queryParam( 441 + "end", 442 + "number", 443 + "End timestamp in seconds (use with start)", 444 + false, 445 + ), 410 446 ], 411 447 }, 412 448 responses: Object.fromEntries([ 413 - apiResponse(200, "User agents data retrieved successfully", { 449 + apiResponse(200, "Traffic data retrieved successfully", { 414 450 type: "array", 415 451 items: { 416 452 type: "object", 417 453 properties: { 418 - userAgent: { type: "string", example: "Mozilla/5.0..." }, 419 - count: { type: "number", example: 123 }, 454 + bucket: { 455 + type: "number", 456 + example: 1704067200, 457 + description: "Unix timestamp of bucket start", 458 + }, 459 + hits: { type: "number", example: 42 }, 420 460 }, 421 461 }, 422 462 }), ··· 452 492 averageResponseTime: { type: "number" }, 453 493 chartData: { type: "array" }, 454 494 userAgents: { type: "array" }, 495 + }, 496 + }), 497 + ]), 498 + }, 499 + ), 500 + }, 501 + 502 + "/stats/essential": { 503 + GET: createRoute( 504 + withAnalytics("/stats/essential", "GET", handlers.handleGetEssentialStats), 505 + { 506 + summary: "Get essential stats", 507 + description: "Fast-loading essential statistics for the dashboard", 508 + tags: ["Analytics"], 509 + parameters: { 510 + query: [ 511 + queryParam("days", "number", "Number of days to analyze", false, 7), 512 + ], 513 + }, 514 + responses: Object.fromEntries([ 515 + apiResponse(200, "Essential stats retrieved", { 516 + type: "object", 517 + properties: { 518 + totalRequests: { type: "number" }, 519 + averageResponseTime: { type: "number" }, 520 + uptime: { type: "number" }, 521 + }, 522 + }), 523 + ]), 524 + }, 525 + ), 526 + }, 527 + 528 + "/stats/useragents": { 529 + GET: createRoute( 530 + withAnalytics("/stats/useragents", "GET", handlers.handleGetUserAgents), 531 + { 532 + summary: "Get user agents", 533 + description: "Cumulative user agent statistics", 534 + tags: ["Analytics"], 535 + responses: Object.fromEntries([ 536 + apiResponse(200, "User agents retrieved", { 537 + type: "array", 538 + items: { 539 + type: "object", 540 + properties: { 541 + userAgent: { type: "string" }, 542 + hits: { type: "number" }, 543 + }, 455 544 }, 456 545 }), 457 546 ]),