mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(admin): add manual R2 stats refresh endpoint/button and share in-memory storage snapshot cache
This commit is contained in:
@@ -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() {
|
||||
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard Overview</h2>
|
||||
<RefreshR2StatsButton />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
{isRefreshing ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||
)}
|
||||
{isRefreshing ? 'Refreshing...' : 'Refresh R2 Stats'}
|
||||
</Button>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+113
-75
@@ -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<string, number>;
|
||||
totalBytes: number;
|
||||
refreshedAt: string;
|
||||
}
|
||||
|
||||
const globalForAdminStats = globalThis as unknown as {
|
||||
adminR2StorageSnapshot?: R2StorageSnapshot;
|
||||
adminR2StorageSnapshotPromise?: Promise<R2StorageSnapshot>;
|
||||
};
|
||||
|
||||
interface BunnyStorageStats {
|
||||
totalBytes: number;
|
||||
byVideoId: Record<string, number>;
|
||||
@@ -75,6 +86,46 @@ async function listAllR2FileSizes(): Promise<Map<string, number>> {
|
||||
return fileSizes;
|
||||
}
|
||||
|
||||
async function buildR2StorageSnapshot(): Promise<R2StorageSnapshot> {
|
||||
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<R2StorageSnapshot> {
|
||||
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<string> {
|
||||
const snapshot = await buildR2StorageSnapshot();
|
||||
globalForAdminStats.adminR2StorageSnapshot = snapshot;
|
||||
globalForAdminStats.adminR2StorageSnapshotPromise = undefined;
|
||||
return snapshot.refreshedAt;
|
||||
}
|
||||
|
||||
async function fetchBunnyStorageStats(): Promise<BunnyStorageStats> {
|
||||
const { apiKey, libraryId } = getBunnyConfig();
|
||||
const byVideoId: Record<string, number> = {};
|
||||
@@ -124,24 +175,15 @@ async function fetchBunnyStorageStats(): Promise<BunnyStorageStats> {
|
||||
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<number> {
|
||||
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<string, { total: number, voice: number, image: number }> = {};
|
||||
try {
|
||||
const fileSizes = await listAllR2FileSizes();
|
||||
const seenKeys = new Set<string>();
|
||||
export async function getCachedUserMediaStorage(): Promise<Record<string, { total: number, voice: number, image: number }>> {
|
||||
// Return a plain object so it maps cleanly out of server component boundaries
|
||||
const userStorage: Record<string, { total: number, voice: number, image: number }> = {};
|
||||
try {
|
||||
const snapshot = await getR2StorageSnapshot();
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user