image cache on cloudflare r2

feat: use enhanced lod system

dunkirk.sh 463c1fcd 736f1576

verified
+26 -2
src/dashboard.css
··· 115 115 color: var(--text-primary); 116 116 } 117 117 118 + .chart-outer { 119 + position: relative; 120 + height: 300px; 121 + } 122 + 118 123 .chart-wrapper { 119 - height: 300px; 120 - position: relative; 124 + height: 100%; 125 + } 126 + 127 + .chart-loading { 128 + position: absolute; 129 + inset: 0; 130 + display: flex; 131 + align-items: center; 132 + justify-content: center; 133 + background: rgba(250, 246, 243, 0.5); 134 + backdrop-filter: blur(1px); 135 + font-size: 0.75rem; 136 + color: var(--text-secondary); 137 + opacity: 0; 138 + pointer-events: none; 139 + transition: opacity 0.15s ease-out; 140 + z-index: 10; 141 + } 142 + 143 + .chart-loading.visible { 144 + opacity: 1; 121 145 } 122 146 123 147 .chart-hint {
+4 -1
src/dashboard.html
··· 52 52 53 53 <section class="chart-container"> 54 54 <h2>Traffic Overview</h2> 55 - <div class="chart-wrapper" id="chart" role="img" aria-label="Traffic over time chart"></div> 55 + <div class="chart-outer"> 56 + <div class="chart-wrapper" id="chart" role="img" aria-label="Traffic over time chart"></div> 57 + <div id="chart-loading" class="chart-loading">Refining...</div> 58 + </div> 56 59 <p class="chart-hint">Drag to zoom, double-click to reset</p> 57 60 </section> 58 61
+260 -39
src/dashboard.ts
··· 18 18 topImages: Array<{ image_key: string; total: number }>; 19 19 } 20 20 21 + type Granularity = "10min" | "hourly" | "daily"; 22 + 23 + interface LodCacheEntry { 24 + granularity: Granularity; 25 + range: { start: number; end: number }; 26 + timestamps: number[]; 27 + hits: number[]; 28 + } 29 + 21 30 function formatNumber(n: number): string { 22 31 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; 23 32 if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; 24 33 return n.toString(); 25 34 } 26 35 36 + function 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 + 27 80 class Dashboard { 28 81 private days = 7; 29 82 private chart: uPlot | null = null; 30 83 private abortController: AbortController | null = null; 31 84 private originalRange: { start: number; end: number } | null = null; 32 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 + private dblClickHandler: (() => void) | null = null; 33 90 34 91 private readonly totalHitsEl = document.getElementById( 35 92 "total-hits", ··· 41 98 "image-list", 42 99 ) as HTMLElement; 43 100 private readonly chartEl = document.getElementById("chart") as HTMLElement; 101 + private readonly loadingEl = document.getElementById( 102 + "chart-loading", 103 + ) as HTMLElement | null; 44 104 private readonly buttons = document.querySelectorAll<HTMLButtonElement>( 45 105 ".time-selector button", 46 106 ); 47 107 48 108 constructor() { 109 + this.days = this.getDaysFromUrl(); 110 + this.updateActiveButton(); 49 111 this.setupEventListeners(); 50 112 this.fetchData(); 51 113 window.addEventListener("resize", this.handleResize); 114 + window.addEventListener("popstate", this.handlePopState); 52 115 } 53 116 117 + private getDaysFromUrl(): number { 118 + const params = new URLSearchParams(window.location.search); 119 + const days = parseInt(params.get("days") || "7", 10); 120 + if ([1, 7, 30, 90, 365].includes(days)) { 121 + return days; 122 + } 123 + return 7; 124 + } 125 + 126 + private updateUrl(days: number) { 127 + const url = new URL(window.location.href); 128 + url.searchParams.set("days", String(days)); 129 + window.history.pushState({ days }, "", url.toString()); 130 + } 131 + 132 + private handlePopState = (event: PopStateEvent) => { 133 + const days = event.state?.days ?? this.getDaysFromUrl(); 134 + if (days !== this.days) { 135 + this.days = days; 136 + this.currentRange = null; 137 + this.originalRange = null; 138 + this.lodCache = {}; 139 + this.activeGranularity = null; 140 + this.updateActiveButton(); 141 + this.fetchData(); 142 + } 143 + }; 144 + 54 145 private setupEventListeners() { 55 146 this.buttons.forEach((btn) => { 56 147 btn.addEventListener("click", () => { 57 148 const newDays = parseInt(btn.dataset.days || "7", 10); 58 149 if (newDays !== this.days) { 59 150 this.days = newDays; 60 - this.currentRange = null; // Reset zoom 151 + this.currentRange = null; 61 152 this.originalRange = null; 153 + this.lodCache = {}; 154 + this.activeGranularity = null; 62 155 this.updateActiveButton(); 156 + this.updateUrl(newDays); 63 157 this.fetchData(); 64 158 } 65 159 }); ··· 75 169 }); 76 170 } 77 171 172 + private setLoading(loading: boolean) { 173 + this.isLoading = loading; 174 + if (this.loadingEl) { 175 + this.loadingEl.classList.toggle("visible", loading); 176 + } 177 + } 178 + 179 + private getGranularityForRange(start: number, end: number): Granularity { 180 + const spanDays = (end - start) / 86400; 181 + if (spanDays <= 1) return "10min"; 182 + if (spanDays <= 30) return "hourly"; 183 + return "daily"; 184 + } 185 + 78 186 private async fetchData() { 79 187 this.abortController?.abort(); 80 188 this.abortController = new AbortController(); 81 189 const signal = this.abortController.signal; 82 190 191 + this.setLoading(true); 192 + 83 193 try { 84 194 let trafficUrl = `/api/stats/traffic?days=${this.days}`; 85 195 86 - // If we have a current range from zooming, use start/end instead 87 196 if (this.currentRange) { 88 197 trafficUrl = `/api/stats/traffic?start=${this.currentRange.start}&end=${this.currentRange.end}`; 89 198 } ··· 100 209 if (signal.aborted) return; 101 210 102 211 this.renderOverview(overview); 103 - this.renderChart(traffic); 212 + 213 + const { timestamps, hits } = this.transformTraffic(traffic); 214 + 215 + if (timestamps.length === 0) { 216 + return; 217 + } 218 + 219 + if (!this.chart) { 220 + this.initChart(timestamps, hits); 221 + } 222 + 223 + this.updateCache(traffic); 104 224 } catch (e) { 105 225 if ((e as Error).name !== "AbortError") { 106 226 console.error("Failed to fetch data:", e); 227 + } 228 + } finally { 229 + if (!signal.aborted) { 230 + this.setLoading(false); 107 231 } 108 232 } 109 233 } ··· 137 261 }); 138 262 } 139 263 140 - private renderChart(data: TrafficData) { 264 + private transformTraffic(data: TrafficData): { 265 + timestamps: number[]; 266 + hits: number[]; 267 + } { 141 268 const timestamps: number[] = []; 142 269 const hits: number[] = []; 143 270 ··· 147 274 hits.push(point.hits); 148 275 } 149 276 150 - if (timestamps.length === 0) { 277 + return { timestamps, hits }; 278 + } 279 + 280 + private updateCache(traffic: TrafficData) { 281 + const { timestamps, hits } = this.transformTraffic(traffic); 282 + if (timestamps.length === 0) return; 283 + 284 + const first = timestamps[0]!; 285 + const last = timestamps[timestamps.length - 1]!; 286 + 287 + const gran = traffic.granularity as Granularity; 288 + 289 + this.lodCache[gran] = { 290 + granularity: gran, 291 + range: { start: first, end: last }, 292 + timestamps, 293 + hits, 294 + }; 295 + 296 + this.activeGranularity = gran; 297 + 298 + if (!this.currentRange) { 299 + this.originalRange = { start: first, end: last }; 300 + this.renderCurrentViewport({ min: first, max: last }); 301 + } else { 302 + this.renderCurrentViewport(); 303 + } 304 + } 305 + 306 + private getBestCacheForRange( 307 + minX: number, 308 + maxX: number, 309 + ): LodCacheEntry | null { 310 + const lodPriority: Granularity[] = ["10min", "hourly", "daily"]; 311 + 312 + for (const lod of lodPriority) { 313 + const cache = this.lodCache[lod]; 314 + if (cache && cache.range.start <= minX && cache.range.end >= maxX) { 315 + return cache; 316 + } 317 + } 318 + 319 + for (const lod of lodPriority) { 320 + const cache = this.lodCache[lod]; 321 + if (cache) return cache; 322 + } 323 + 324 + return null; 325 + } 326 + 327 + private renderCurrentViewport(forceRange?: { min: number; max: number }) { 328 + if (!this.chart) return; 329 + 330 + let minX: number | undefined; 331 + let maxX: number | undefined; 332 + 333 + if (forceRange) { 334 + minX = forceRange.min; 335 + maxX = forceRange.max; 336 + } else { 337 + const xScale = this.chart.scales.x; 338 + minX = 339 + xScale && xScale.min != null ? xScale.min : this.originalRange?.start; 340 + maxX = 341 + xScale && xScale.max != null ? xScale.max : this.originalRange?.end; 342 + } 343 + 344 + if (minX == null || maxX == null) return; 345 + 346 + const cache = this.getBestCacheForRange(minX, maxX); 347 + if (!cache) return; 348 + 349 + this.activeGranularity = cache.granularity; 350 + 351 + const width = this.chartEl.clientWidth || 600; 352 + const maxPoints = Math.min(width, 800); 353 + 354 + const { timestamps, hits } = downsample( 355 + cache.timestamps, 356 + cache.hits, 357 + minX, 358 + maxX, 359 + maxPoints, 360 + ); 361 + 362 + if (timestamps.length === 0) return; 363 + 364 + this.chart.setData([timestamps, hits]); 365 + this.chart.setScale("x", { min: minX, max: maxX }); 366 + } 367 + 368 + private handleSelect(u: uPlot) { 369 + if (u.select.width <= 10) return; 370 + 371 + const min = Math.floor(u.posToVal(u.select.left, "x")); 372 + const max = Math.floor(u.posToVal(u.select.left + u.select.width, "x")); 373 + 374 + u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false); 375 + 376 + this.currentRange = { start: min, end: max }; 377 + 378 + const bestCache = this.getBestCacheForRange(min, max); 379 + const targetGran = this.getGranularityForRange(min, max); 380 + 381 + if (bestCache && bestCache.granularity === targetGran) { 382 + this.renderCurrentViewport({ min, max }); 151 383 return; 152 384 } 153 385 154 - // Store original range if not set 155 - if (!this.originalRange) { 156 - this.originalRange = { 157 - start: timestamps[0], 158 - end: timestamps[timestamps.length - 1], 159 - }; 160 - } 386 + this.renderCurrentViewport({ min, max }); 387 + this.fetchData(); 388 + } 161 389 162 - const chartData: uPlot.AlignedData = [timestamps, hits]; 390 + private resetZoom() { 391 + this.currentRange = null; 163 392 393 + if (this.originalRange && this.chart) { 394 + this.chart.setScale("x", { 395 + min: this.originalRange.start, 396 + max: this.originalRange.end, 397 + }); 398 + this.renderCurrentViewport(); 399 + } 400 + } 401 + 402 + private initChart(timestamps: number[], hits: number[]) { 164 403 const opts: uPlot.Options = { 165 404 width: this.chartEl.clientWidth, 166 405 height: 280, ··· 201 440 }, 202 441 ], 203 442 hooks: { 204 - setSelect: [ 205 - (u) => { 206 - if (u.select.width > 10) { 207 - const min = Math.floor(u.posToVal(u.select.left, "x")); 208 - const max = Math.floor( 209 - u.posToVal(u.select.left + u.select.width, "x"), 210 - ); 211 - 212 - // Store the zoomed range and fetch new data 213 - this.currentRange = { start: min, end: max }; 214 - this.fetchData(); 215 - 216 - u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false); 217 - } 218 - }, 219 - ], 443 + setSelect: [(u) => this.handleSelect(u)], 220 444 }, 221 445 }; 222 446 223 - if (this.chart) { 224 - this.chart.destroy(); 225 - } 226 - 227 447 this.chartEl.innerHTML = ""; 228 - this.chart = new uPlot(opts, chartData, this.chartEl); 448 + this.chart = new uPlot(opts, [timestamps, hits], this.chartEl); 229 449 230 - // Add double-click to reset zoom 231 - this.chartEl.addEventListener("dblclick", () => { 232 - this.currentRange = null; 233 - this.fetchData(); 234 - }); 450 + if (this.dblClickHandler) { 451 + this.chartEl.removeEventListener("dblclick", this.dblClickHandler); 452 + } 453 + this.dblClickHandler = () => this.resetZoom(); 454 + this.chartEl.addEventListener("dblclick", this.dblClickHandler); 235 455 } 236 456 237 457 private handleResize = () => { ··· 240 460 width: this.chartEl.clientWidth, 241 461 height: 280, 242 462 }); 463 + this.renderCurrentViewport(); 243 464 } 244 465 }; 245 466 }
+3 -2
src/index.ts
··· 159 159 const end = parseInt(endParam, 10); 160 160 const spanDays = (end - start) / 86400; 161 161 162 - // Use span as "days" parameter, pass end time 163 - return Response.json(getTraffic(spanDays, end)); 162 + return Response.json( 163 + getTraffic(spanDays, { startTime: start, endTime: end }), 164 + ); 164 165 } 165 166 166 167 // Normal mode: last N days
+20 -77
src/stats.ts
··· 137 137 .all(since) as { bucket_day: number; hits: number }[]; 138 138 } 139 139 140 - export function getTraffic(sinceDays: number = 7, endTime?: number) { 140 + export function getTraffic( 141 + sinceDays: number = 7, 142 + options?: { startTime?: number; endTime?: number }, 143 + ) { 141 144 const now = Math.floor(Date.now() / 1000); 142 - const since = now - sinceDays * 86400; 143 - const end = endTime || now; 145 + const since = options?.startTime ?? now - sinceDays * 86400; 146 + const end = options?.endTime ?? now; 144 147 145 - // Calculate actual span (in case we're querying a specific range) 146 148 const spanSeconds = end - since; 147 149 const spanDays = spanSeconds / 86400; 148 150 149 - // For <= 1 day, use 10-minute data if available 151 + // <= 1 day: 10min data if available 150 152 if (spanDays <= 1) { 151 153 const data = db 152 154 .prepare( ··· 161 163 } 162 164 } 163 165 164 - // For > 30 days, use daily data for better performance 165 - if (spanDays > 30) { 166 - const rangeResult = db 167 - .prepare( 168 - `SELECT MIN(bucket_day) as min_time, MAX(bucket_day) as max_time 169 - FROM image_stats_daily WHERE bucket_day >= ? AND bucket_day <= ?`, 170 - ) 171 - .get(since, end) as { min_time: number | null; max_time: number | null }; 172 - 173 - if (!rangeResult.min_time || !rangeResult.max_time) { 174 - return { granularity: "daily", data: [] }; 175 - } 176 - 177 - const actualSpanSeconds = rangeResult.max_time - rangeResult.min_time; 178 - const actualSpanDays = actualSpanSeconds / 86400; 179 - 180 - let bucketSize: number; 181 - let bucketLabel: string; 182 - 183 - // For very long ranges, group days into larger buckets 184 - if (actualSpanDays <= 90) { 185 - bucketSize = 86400; // 1 day 186 - bucketLabel = "daily"; 187 - } else { 188 - // For 90+ days, use multi-day buckets to keep point count reasonable 189 - const dayMultiplier = Math.max(1, Math.floor(actualSpanDays / 90)); 190 - bucketSize = 86400 * dayMultiplier; 191 - bucketLabel = dayMultiplier === 1 ? "daily" : `${dayMultiplier}daily`; 192 - } 193 - 166 + // 1-30 days: always hourly 167 + if (spanDays <= 30) { 194 168 const data = db 195 169 .prepare( 196 - `SELECT (bucket_day / ?1) * ?1 as bucket, SUM(hits) as hits 197 - FROM image_stats_daily WHERE bucket_day >= ?2 AND bucket_day <= ?3 198 - GROUP BY bucket ORDER BY bucket`, 170 + `SELECT bucket_hour as bucket, SUM(hits) as hits 171 + FROM image_stats WHERE bucket_hour >= ? AND bucket_hour <= ? 172 + GROUP BY bucket_hour ORDER BY bucket_hour`, 199 173 ) 200 - .all(bucketSize, since, end) as { bucket: number; hits: number }[]; 201 - 202 - return { granularity: bucketLabel, data }; 203 - } 204 - 205 - // For 1-30 days, use hourly data 206 - const rangeResult = db 207 - .prepare( 208 - `SELECT MIN(bucket_hour) as min_time, MAX(bucket_hour) as max_time 209 - FROM image_stats WHERE bucket_hour >= ? AND bucket_hour <= ?`, 210 - ) 211 - .get(since, end) as { min_time: number | null; max_time: number | null }; 212 - 213 - if (!rangeResult.min_time || !rangeResult.max_time) { 214 - return { granularity: "hourly", data: [] }; 215 - } 216 - 217 - // Calculate actual data span in days 218 - const actualSpanSeconds = rangeResult.max_time - rangeResult.min_time; 219 - const actualSpanDays = actualSpanSeconds / 86400; 220 - 221 - // Scale granularity based on actual data span 222 - // <= 7 days: hourly 223 - // > 7 days: bucket size = floor(days / 7) hours 224 - 225 - let bucketSize: number; 226 - let bucketLabel: string; 174 + .all(since, end) as { bucket: number; hits: number }[]; 227 175 228 - if (actualSpanDays <= 7) { 229 - bucketSize = 3600; // 1 hour 230 - bucketLabel = "hourly"; 231 - } else { 232 - const hourMultiplier = Math.floor(actualSpanDays / 7); 233 - bucketSize = 3600 * hourMultiplier; 234 - bucketLabel = `${hourMultiplier}hourly`; 176 + return { granularity: "hourly", data }; 235 177 } 236 178 179 + // > 30 days: daily 237 180 const data = db 238 181 .prepare( 239 - `SELECT (bucket_hour / ?1) * ?1 as bucket, SUM(hits) as hits 240 - FROM image_stats WHERE bucket_hour >= ?2 AND bucket_hour <= ?3 241 - GROUP BY bucket ORDER BY bucket`, 182 + `SELECT bucket_day as bucket, SUM(hits) as hits 183 + FROM image_stats_daily WHERE bucket_day >= ? AND bucket_day <= ? 184 + GROUP BY bucket_day ORDER BY bucket_day`, 242 185 ) 243 - .all(bucketSize, since, end) as { bucket: number; hits: number }[]; 186 + .all(since, end) as { bucket: number; hits: number }[]; 244 187 245 - return { granularity: bucketLabel, data }; 188 + return { granularity: "daily", data }; 246 189 }