+45
-3
src/cache.ts
+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
+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
+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
+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
]),