image cache on cloudflare r2
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 492 lines 12 kB view raw
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();