diff --git a/app/admin/page.tsx b/app/admin/page.tsx index dd3534b..93ca8f6 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -4,6 +4,7 @@ import { auth } from '@/lib/auth'; import { redirect } from 'next/navigation'; import { getCachedBunnyStorageStats, getCachedTotalStorage } from '@/lib/admin-stats'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { RefreshR2StatsButton } from '@/components/admin/refresh-r2-stats-button'; import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon, Film, MessageSquareQuote, Star } from 'lucide-react'; export const metadata: Metadata = { @@ -79,6 +80,7 @@ export default async function AdminDashboardPage() {

Dashboard Overview

+
diff --git a/app/api/admin/stats/refresh-r2/route.ts b/app/api/admin/stats/refresh-r2/route.ts new file mode 100644 index 0000000..0374557 --- /dev/null +++ b/app/api/admin/stats/refresh-r2/route.ts @@ -0,0 +1,22 @@ +import { auth } from '@/lib/auth'; +import { apiErrors, successResponse } from '@/lib/api-response'; +import { refreshR2StorageSnapshot } from '@/lib/admin-stats'; + +export async function POST() { + try { + const session = await auth(); + if (!session?.user?.isAdmin) { + return apiErrors.forbidden('Admin access required'); + } + + const refreshedAt = await refreshR2StorageSnapshot(); + + return successResponse({ + ok: true, + refreshedAt, + }); + } catch (error) { + console.error('Error refreshing R2 admin stats cache:', error); + return apiErrors.internalError('Failed to refresh R2 stats'); + } +} diff --git a/components/admin/refresh-r2-stats-button.tsx b/components/admin/refresh-r2-stats-button.tsx new file mode 100644 index 0000000..18295c1 --- /dev/null +++ b/components/admin/refresh-r2-stats-button.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Loader2, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export function RefreshR2StatsButton() { + const router = useRouter(); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + + const handleRefresh = async () => { + setIsRefreshing(true); + setError(null); + + try { + const response = await fetch('/api/admin/stats/refresh-r2', { + method: 'POST', + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const message = (payload as { error?: string }).error || 'Failed to refresh R2 stats'; + setError(message); + return; + } + + router.refresh(); + } catch { + setError('Failed to refresh R2 stats'); + } finally { + setIsRefreshing(false); + } + }; + + return ( +
+ + {error ?

{error}

: null} +
+ ); +} diff --git a/lib/admin-stats.ts b/lib/admin-stats.ts index d8bf9e3..acb4e00 100644 --- a/lib/admin-stats.ts +++ b/lib/admin-stats.ts @@ -6,6 +6,17 @@ import { ListObjectsV2Command, type ListObjectsV2CommandInput } from '@aws-sdk/c const BUNNY_API_BASE = 'https://video.bunnycdn.com'; const STORAGE_CACHE_SECONDS = 600; +interface R2StorageSnapshot { + fileSizes: Map; + totalBytes: number; + refreshedAt: string; +} + +const globalForAdminStats = globalThis as unknown as { + adminR2StorageSnapshot?: R2StorageSnapshot; + adminR2StorageSnapshotPromise?: Promise; +}; + interface BunnyStorageStats { totalBytes: number; byVideoId: Record; @@ -75,6 +86,46 @@ async function listAllR2FileSizes(): Promise> { return fileSizes; } +async function buildR2StorageSnapshot(): Promise { + const fileSizes = await listAllR2FileSizes(); + let totalBytes = 0; + for (const size of fileSizes.values()) { + totalBytes += size; + } + + return { + fileSizes, + totalBytes, + refreshedAt: new Date().toISOString(), + }; +} + +async function getR2StorageSnapshot(): Promise { + if (globalForAdminStats.adminR2StorageSnapshot) { + return globalForAdminStats.adminR2StorageSnapshot; + } + + if (!globalForAdminStats.adminR2StorageSnapshotPromise) { + globalForAdminStats.adminR2StorageSnapshotPromise = buildR2StorageSnapshot() + .then((snapshot) => { + globalForAdminStats.adminR2StorageSnapshot = snapshot; + return snapshot; + }) + .finally(() => { + globalForAdminStats.adminR2StorageSnapshotPromise = undefined; + }); + } + + return globalForAdminStats.adminR2StorageSnapshotPromise; +} + +export async function refreshR2StorageSnapshot(): Promise { + const snapshot = await buildR2StorageSnapshot(); + globalForAdminStats.adminR2StorageSnapshot = snapshot; + globalForAdminStats.adminR2StorageSnapshotPromise = undefined; + return snapshot.refreshedAt; +} + async function fetchBunnyStorageStats(): Promise { const { apiKey, libraryId } = getBunnyConfig(); const byVideoId: Record = {}; @@ -124,24 +175,15 @@ async function fetchBunnyStorageStats(): Promise { return { totalBytes, byVideoId }; } -// Cache for 10 minutes (600 seconds) -export const getCachedTotalStorage = unstable_cache( - async () => { - let totalStorageBytes = 0; - try { - const fileSizes = await listAllR2FileSizes(); - for (const size of fileSizes.values()) { - totalStorageBytes += size; - } - } catch (err) { - console.error('Failed to fetch total storage stats:', err); - return -1; - } - return totalStorageBytes; - }, - ['admin-total-storage'], - { revalidate: STORAGE_CACHE_SECONDS } -); +export async function getCachedTotalStorage(): Promise { + try { + const snapshot = await getR2StorageSnapshot(); + return snapshot.totalBytes; + } catch (err) { + console.error('Failed to fetch total storage stats:', err); + return -1; + } +} export const getCachedBunnyStorageStats = unstable_cache( async () => { @@ -196,28 +238,26 @@ export const getCachedUserBunnyStorage = unstable_cache( { revalidate: STORAGE_CACHE_SECONDS } ); -export const getCachedUserMediaStorage = unstable_cache( - async () => { - // Return a plain object so it maps cleanly out of unstable_cache across requests - const userStorage: Record = {}; - try { - const fileSizes = await listAllR2FileSizes(); - const seenKeys = new Set(); +export async function getCachedUserMediaStorage(): Promise> { + // Return a plain object so it maps cleanly out of server component boundaries + const userStorage: Record = {}; + try { + const snapshot = await getR2StorageSnapshot(); + const seenKeys = new Set(); - const mediaComments = await db.comment.findMany({ - where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] }, - select: { - voiceUrl: true, - imageUrl: true, - version: { - select: { - video: { - select: { - project: { - select: { - workspace: { - select: { ownerId: true }, - }, + const mediaComments = await db.comment.findMany({ + where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] }, + select: { + voiceUrl: true, + imageUrl: true, + version: { + select: { + video: { + select: { + project: { + select: { + workspace: { + select: { ownerId: true }, }, }, }, @@ -225,50 +265,48 @@ export const getCachedUserMediaStorage = unstable_cache( }, }, }, - }); + }, + }); - for (const comment of mediaComments) { - const billedUserId = comment.version.video.project.workspace.ownerId; - if (!billedUserId) continue; + for (const comment of mediaComments) { + const billedUserId = comment.version.video.project.workspace.ownerId; + if (!billedUserId) continue; - if (!userStorage[billedUserId]) { - userStorage[billedUserId] = { total: 0, voice: 0, image: 0 }; - } + if (!userStorage[billedUserId]) { + userStorage[billedUserId] = { total: 0, voice: 0, image: 0 }; + } - if (comment.voiceUrl) { - const keyParts = comment.voiceUrl.split('/'); - const filename = keyParts[keyParts.length - 1]; - const r2Key = `voice/${filename}`; - const dedupeKey = `${billedUserId}:${r2Key}`; - if (!seenKeys.has(dedupeKey)) { - seenKeys.add(dedupeKey); - const size = fileSizes.get(r2Key) || 0; - userStorage[billedUserId].voice += size; - userStorage[billedUserId].total += size; - } + if (comment.voiceUrl) { + const keyParts = comment.voiceUrl.split('/'); + const filename = keyParts[keyParts.length - 1]; + const r2Key = `voice/${filename}`; + const dedupeKey = `${billedUserId}:${r2Key}`; + if (!seenKeys.has(dedupeKey)) { + seenKeys.add(dedupeKey); + const size = snapshot.fileSizes.get(r2Key) || 0; + userStorage[billedUserId].voice += size; + userStorage[billedUserId].total += size; } + } - if (comment.imageUrl) { - const keyParts = comment.imageUrl.split('/'); - const filename = keyParts[keyParts.length - 1]; - const r2Key = `images/${filename}`; - const dedupeKey = `${billedUserId}:${r2Key}`; - if (!seenKeys.has(dedupeKey)) { - seenKeys.add(dedupeKey); - const size = fileSizes.get(r2Key) || 0; - userStorage[billedUserId].image += size; - userStorage[billedUserId].total += size; - } + if (comment.imageUrl) { + const keyParts = comment.imageUrl.split('/'); + const filename = keyParts[keyParts.length - 1]; + const r2Key = `images/${filename}`; + const dedupeKey = `${billedUserId}:${r2Key}`; + if (!seenKeys.has(dedupeKey)) { + seenKeys.add(dedupeKey); + const size = snapshot.fileSizes.get(r2Key) || 0; + userStorage[billedUserId].image += size; + userStorage[billedUserId].total += size; } } - } catch (err) { - console.error('Failed to parse user storage:', err); } - return userStorage; - }, - ['admin-user-media-storage'], - { revalidate: STORAGE_CACHE_SECONDS } -); + } catch (err) { + console.error('Failed to parse user storage:', err); + } + return userStorage; +} export const getCachedUserDownloadEgress = unstable_cache( async () => {