image cache on cloudflare r2
1import uPlot from "uplot";
2import "uplot/dist/uPlot.min.css";
3import "./dashboard.css";
4
5interface TrafficData {
6 granularity: string;
7 data: Array<{
8 bucket?: number;
9 bucket_hour?: number;
10 bucket_day?: number;
11 hits: number;
12 }>;
13}
14
15interface OverviewData {
16 totalHits: number;
17 uniqueImages: number;
18 topImages: Array<{ image_key: string; total: number }>;
19}
20
21type Granularity = "10min" | "hourly" | "daily";
22
23interface LodCacheEntry {
24 granularity: Granularity;
25 range: { start: number; end: number };
26 timestamps: number[];
27 hits: number[];
28}
29
30function formatNumber(n: number): string {
31 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
32 if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
33 return n.toString();
34}
35
36function downsample(
37 timestamps: number[],
38 hits: number[],
39 minX: number,
40 maxX: number,
41 maxPoints: number,
42): { timestamps: number[]; hits: number[] } {
43 const startIdx = timestamps.findIndex((t) => t >= minX);
44 if (startIdx === -1) return { timestamps: [], hits: [] };
45
46 let endIdx = timestamps.length - 1;
47 for (let i = timestamps.length - 1; i >= 0; i--) {
48 if (timestamps[i]! <= maxX) {
49 endIdx = i;
50 break;
51 }
52 }
53
54 const sliceLen = endIdx - startIdx + 1;
55 if (sliceLen <= 0) return { timestamps: [], hits: [] };
56
57 const tsSlice = timestamps.slice(startIdx, endIdx + 1);
58 const hSlice = hits.slice(startIdx, endIdx + 1);
59
60 if (sliceLen <= maxPoints) {
61 return { timestamps: tsSlice, hits: hSlice };
62 }
63
64 const bucketSize = Math.ceil(sliceLen / maxPoints);
65 const dsTs: number[] = [];
66 const dsHits: number[] = [];
67
68 for (let i = 0; i < sliceLen; i += bucketSize) {
69 const jEnd = Math.min(i + bucketSize, sliceLen);
70 let sumHits = 0;
71 for (let j = i; j < jEnd; j++) sumHits += hSlice[j]!;
72 const avgHits = sumHits / (jEnd - i);
73 dsTs.push(tsSlice[i]!);
74 dsHits.push(avgHits);
75 }
76
77 return { timestamps: dsTs, hits: dsHits };
78}
79
80class Dashboard {
81 private days = 7;
82 private chart: uPlot | null = null;
83 private abortController: AbortController | null = null;
84 private originalRange: { start: number; end: number } | null = null;
85 private currentRange: { start: number; end: number } | null = null;
86 private lodCache: Partial<Record<Granularity, LodCacheEntry>> = {};
87 private activeGranularity: Granularity | null = null;
88 private isLoading = false;
89
90 private readonly totalHitsEl = document.getElementById(
91 "total-hits",
92 ) as HTMLElement;
93 private readonly uniqueImagesEl = document.getElementById(
94 "unique-images",
95 ) as HTMLElement;
96 private readonly imageListEl = document.getElementById(
97 "image-list",
98 ) as HTMLElement;
99 private readonly chartEl = document.getElementById("chart") as HTMLElement;
100 private readonly loadingEl = document.getElementById(
101 "chart-loading",
102 ) as HTMLElement | null;
103 private readonly buttons = document.querySelectorAll<HTMLButtonElement>(
104 ".time-selector button",
105 );
106
107 constructor() {
108 this.days = this.getDaysFromUrl();
109 this.updateActiveButton();
110 this.setupEventListeners();
111 this.fetchData();
112 window.addEventListener("resize", this.handleResize);
113 window.addEventListener("popstate", this.handlePopState);
114 }
115
116 private getDaysFromUrl(): number {
117 const params = new URLSearchParams(window.location.search);
118 const days = parseInt(params.get("days") || "7", 10);
119 if ([1, 7, 30, 90, 365].includes(days)) {
120 return days;
121 }
122 return 7;
123 }
124
125 private updateUrl(days: number) {
126 const url = new URL(window.location.href);
127 url.searchParams.set("days", String(days));
128 window.history.pushState({ days }, "", url.toString());
129 }
130
131 private handlePopState = (event: PopStateEvent) => {
132 const days = event.state?.days ?? this.getDaysFromUrl();
133 if (days !== this.days) {
134 this.days = days;
135 this.currentRange = null;
136 this.originalRange = null;
137 this.lodCache = {};
138 this.activeGranularity = null;
139 this.updateActiveButton();
140 this.fetchData();
141 }
142 };
143
144 private setupEventListeners() {
145 this.buttons.forEach((btn) => {
146 btn.addEventListener("click", () => {
147 const newDays = parseInt(btn.dataset.days || "7", 10);
148 if (newDays !== this.days) {
149 this.days = newDays;
150 this.currentRange = null;
151 this.originalRange = null;
152 this.lodCache = {};
153 this.activeGranularity = null;
154 this.updateActiveButton();
155 this.updateUrl(newDays);
156 this.fetchData();
157 }
158 });
159 });
160 }
161
162 private updateActiveButton() {
163 this.buttons.forEach((btn) => {
164 btn.classList.toggle(
165 "active",
166 parseInt(btn.dataset.days || "0", 10) === this.days,
167 );
168 });
169 }
170
171 private setLoading(loading: boolean) {
172 this.isLoading = loading;
173 if (this.loadingEl) {
174 this.loadingEl.classList.toggle("visible", loading);
175 }
176 }
177
178 private getGranularityForRange(start: number, end: number): Granularity {
179 const spanDays = (end - start) / 86400;
180 if (spanDays <= 1) return "10min";
181 if (spanDays <= 30) return "hourly";
182 return "daily";
183 }
184
185 private async fetchData() {
186 this.abortController?.abort();
187 this.abortController = new AbortController();
188 const signal = this.abortController.signal;
189
190 this.setLoading(true);
191
192 try {
193 let trafficUrl = `/api/stats/traffic?days=${this.days}`;
194
195 if (this.currentRange) {
196 trafficUrl = `/api/stats/traffic?start=${this.currentRange.start}&end=${this.currentRange.end}`;
197 }
198
199 const [overview, traffic] = await Promise.all([
200 fetch(`/api/stats/overview?days=${this.days}`, { signal }).then(
201 (r) => r.json() as Promise<OverviewData>,
202 ),
203 fetch(trafficUrl, { signal }).then(
204 (r) => r.json() as Promise<TrafficData>,
205 ),
206 ]);
207
208 if (signal.aborted) return;
209
210 this.renderOverview(overview);
211
212 const { timestamps, hits } = this.transformTraffic(traffic);
213
214 if (timestamps.length === 0) {
215 return;
216 }
217
218 if (!this.chart) {
219 this.initChart(timestamps, hits);
220 }
221
222 this.updateCache(traffic);
223 } catch (e) {
224 if ((e as Error).name !== "AbortError") {
225 console.error("Failed to fetch data:", e);
226 }
227 } finally {
228 if (!signal.aborted) {
229 this.setLoading(false);
230 }
231 }
232 }
233
234 private renderOverview(data: OverviewData) {
235 this.totalHitsEl.textContent = formatNumber(data.totalHits);
236 this.uniqueImagesEl.textContent = String(data.uniqueImages);
237
238 if (data.topImages.length === 0) {
239 this.imageListEl.innerHTML = '<div class="loading">No data yet</div>';
240 return;
241 }
242
243 this.imageListEl.innerHTML = data.topImages
244 .map(
245 (img, i) => `
246 <div class="image-row" data-key="${img.image_key}">
247 <div class="image-rank">${i + 1}</div>
248 <div class="image-key">${img.image_key}</div>
249 <div class="image-hits">${formatNumber(img.total)}</div>
250 </div>
251 `,
252 )
253 .join("");
254
255 this.imageListEl.querySelectorAll(".image-row").forEach((row) => {
256 row.addEventListener("click", () => {
257 const key = (row as HTMLElement).dataset.key;
258 if (key) window.open(`/i/${key}`, "_blank");
259 });
260 });
261 }
262
263 private transformTraffic(data: TrafficData): {
264 timestamps: number[];
265 hits: number[];
266 } {
267 const timestamps: number[] = [];
268 const hits: number[] = [];
269
270 for (const point of data.data) {
271 const ts = point.bucket ?? point.bucket_hour ?? point.bucket_day ?? 0;
272 timestamps.push(ts);
273 hits.push(point.hits);
274 }
275
276 return { timestamps, hits };
277 }
278
279 private updateCache(traffic: TrafficData) {
280 const { timestamps, hits } = this.transformTraffic(traffic);
281 if (timestamps.length === 0) return;
282
283 const first = timestamps[0]!;
284 const last = timestamps[timestamps.length - 1]!;
285
286 const gran = traffic.granularity as Granularity;
287
288 this.lodCache[gran] = {
289 granularity: gran,
290 range: { start: first, end: last },
291 timestamps,
292 hits,
293 };
294
295 this.activeGranularity = gran;
296
297 if (!this.currentRange) {
298 this.originalRange = { start: first, end: last };
299 this.renderCurrentViewport({ min: first, max: last });
300 } else {
301 this.renderCurrentViewport({
302 min: this.currentRange.start,
303 max: this.currentRange.end,
304 });
305 }
306 }
307
308 private getBestCacheForRange(
309 minX: number,
310 maxX: number,
311 ): LodCacheEntry | null {
312 const lodPriority: Granularity[] = ["10min", "hourly", "daily"];
313
314 for (const lod of lodPriority) {
315 const cache = this.lodCache[lod];
316 if (cache && cache.range.start <= minX && cache.range.end >= maxX) {
317 return cache;
318 }
319 }
320
321 for (const lod of lodPriority) {
322 const cache = this.lodCache[lod];
323 if (cache) return cache;
324 }
325
326 return null;
327 }
328
329 private renderCurrentViewport(forceRange?: { min: number; max: number }) {
330 if (!this.chart) return;
331
332 let minX: number | undefined;
333 let maxX: number | undefined;
334
335 if (forceRange) {
336 minX = forceRange.min;
337 maxX = forceRange.max;
338 } else {
339 const xScale = this.chart.scales.x;
340 minX =
341 xScale && xScale.min != null ? xScale.min : this.originalRange?.start;
342 maxX =
343 xScale && xScale.max != null ? xScale.max : this.originalRange?.end;
344 }
345
346 if (minX == null || maxX == null) return;
347
348 const cache = this.getBestCacheForRange(minX, maxX);
349 if (!cache) return;
350
351 this.activeGranularity = cache.granularity;
352
353 const width = this.chartEl.clientWidth || 600;
354 const maxPoints = Math.min(width, 800);
355
356 const { timestamps, hits } = downsample(
357 cache.timestamps,
358 cache.hits,
359 minX,
360 maxX,
361 maxPoints,
362 );
363
364 if (timestamps.length === 0) return;
365
366 this.chart.setData([timestamps, hits]);
367 this.chart.setScale("x", { min: minX, max: maxX });
368 }
369
370 private handleSelect(u: uPlot) {
371 if (u.select.width <= 10) return;
372
373 let min = Math.floor(u.posToVal(u.select.left, "x"));
374 let max = Math.floor(u.posToVal(u.select.left + u.select.width, "x"));
375
376 u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false);
377
378 const minSpan = 1.5 * 86400;
379 const span = max - min;
380 if (span < minSpan) {
381 const center = (min + max) / 2;
382 min = Math.floor(center - minSpan / 2);
383 max = Math.floor(center + minSpan / 2);
384 }
385
386 this.currentRange = { start: min, end: max };
387
388 const bestCache = this.getBestCacheForRange(min, max);
389 const targetGran = this.getGranularityForRange(min, max);
390
391 if (bestCache && bestCache.granularity === targetGran) {
392 this.renderCurrentViewport({ min, max });
393 return;
394 }
395
396 this.renderCurrentViewport({ min, max });
397 this.fetchData();
398 }
399
400 private resetZoom() {
401 this.currentRange = null;
402
403 if (this.originalRange && this.chart) {
404 this.chart.setScale("x", {
405 min: this.originalRange.start,
406 max: this.originalRange.end,
407 });
408 this.renderCurrentViewport();
409 this.fetchData();
410 }
411 }
412
413 private initChart(timestamps: number[], hits: number[]) {
414 const opts: uPlot.Options = {
415 width: this.chartEl.clientWidth,
416 height: 280,
417 cursor: {
418 drag: { x: true, y: false },
419 },
420 select: {
421 show: true,
422 left: 0,
423 top: 0,
424 width: 0,
425 height: 0,
426 },
427 scales: {
428 x: {
429 time: true,
430 range: (u, dataMin, dataMax) => {
431 let min = dataMin;
432 let max = dataMax;
433 const minSpan = 1.5 * 86400;
434 const span = max - min;
435 if (span < minSpan) {
436 const center = (min + max) / 2;
437 min = center - minSpan / 2;
438 max = center + minSpan / 2;
439 }
440 return [min, max];
441 },
442 },
443 y: { auto: true },
444 },
445 axes: [
446 {
447 stroke: "#6b635a",
448 grid: { stroke: "#e8e0d8", width: 1 },
449 },
450 {
451 stroke: "#6b635a",
452 grid: { stroke: "#e8e0d8", width: 1 },
453 size: 60,
454 values: (_, ticks) => ticks.map((v) => formatNumber(v)),
455 },
456 ],
457 series: [
458 {},
459 {
460 label: "Hits",
461 stroke: "#dc602e",
462 fill: "rgba(220, 96, 46, 0.1)",
463 width: 2,
464 points: { show: false },
465 },
466 ],
467 hooks: {
468 setSelect: [(u) => this.handleSelect(u)],
469 ready: [
470 (u) => {
471 u.over.addEventListener("dblclick", () => this.resetZoom());
472 },
473 ],
474 },
475 };
476
477 this.chartEl.innerHTML = "";
478 this.chart = new uPlot(opts, [timestamps, hits], this.chartEl);
479 }
480
481 private handleResize = () => {
482 if (this.chart) {
483 this.chart.setSize({
484 width: this.chartEl.clientWidth,
485 height: 280,
486 });
487 this.renderCurrentViewport();
488 }
489 };
490}
491
492new Dashboard();