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 { redirect } from 'next/navigation';
|
||||||
import { getCachedBunnyStorageStats, getCachedTotalStorage } from '@/lib/admin-stats';
|
import { getCachedBunnyStorageStats, getCachedTotalStorage } from '@/lib/admin-stats';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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';
|
import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon, Film, MessageSquareQuote, Star } from 'lucide-react';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
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-1 space-y-4 px-4 md:px-8">
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between space-y-2">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard Overview</h2>
|
<h2 className="text-3xl font-bold tracking-tight">Dashboard Overview</h2>
|
||||||
|
<RefreshR2StatsButton />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card>
|
<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 BUNNY_API_BASE = 'https://video.bunnycdn.com';
|
||||||
const STORAGE_CACHE_SECONDS = 600;
|
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 {
|
interface BunnyStorageStats {
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
byVideoId: Record<string, number>;
|
byVideoId: Record<string, number>;
|
||||||
@@ -75,6 +86,46 @@ async function listAllR2FileSizes(): Promise<Map<string, number>> {
|
|||||||
return fileSizes;
|
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> {
|
async function fetchBunnyStorageStats(): Promise<BunnyStorageStats> {
|
||||||
const { apiKey, libraryId } = getBunnyConfig();
|
const { apiKey, libraryId } = getBunnyConfig();
|
||||||
const byVideoId: Record<string, number> = {};
|
const byVideoId: Record<string, number> = {};
|
||||||
@@ -124,24 +175,15 @@ async function fetchBunnyStorageStats(): Promise<BunnyStorageStats> {
|
|||||||
return { totalBytes, byVideoId };
|
return { totalBytes, byVideoId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for 10 minutes (600 seconds)
|
export async function getCachedTotalStorage(): Promise<number> {
|
||||||
export const getCachedTotalStorage = unstable_cache(
|
try {
|
||||||
async () => {
|
const snapshot = await getR2StorageSnapshot();
|
||||||
let totalStorageBytes = 0;
|
return snapshot.totalBytes;
|
||||||
try {
|
} catch (err) {
|
||||||
const fileSizes = await listAllR2FileSizes();
|
console.error('Failed to fetch total storage stats:', err);
|
||||||
for (const size of fileSizes.values()) {
|
return -1;
|
||||||
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 const getCachedBunnyStorageStats = unstable_cache(
|
export const getCachedBunnyStorageStats = unstable_cache(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -196,28 +238,26 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
|||||||
{ revalidate: STORAGE_CACHE_SECONDS }
|
{ revalidate: STORAGE_CACHE_SECONDS }
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getCachedUserMediaStorage = unstable_cache(
|
export async function getCachedUserMediaStorage(): Promise<Record<string, { total: number, voice: number, image: number }>> {
|
||||||
async () => {
|
// Return a plain object so it maps cleanly out of server component boundaries
|
||||||
// Return a plain object so it maps cleanly out of unstable_cache across requests
|
const userStorage: Record<string, { total: number, voice: number, image: number }> = {};
|
||||||
const userStorage: Record<string, { total: number, voice: number, image: number }> = {};
|
try {
|
||||||
try {
|
const snapshot = await getR2StorageSnapshot();
|
||||||
const fileSizes = await listAllR2FileSizes();
|
const seenKeys = new Set<string>();
|
||||||
const seenKeys = new Set<string>();
|
|
||||||
|
|
||||||
const mediaComments = await db.comment.findMany({
|
const mediaComments = await db.comment.findMany({
|
||||||
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
|
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
|
||||||
select: {
|
select: {
|
||||||
voiceUrl: true,
|
voiceUrl: true,
|
||||||
imageUrl: true,
|
imageUrl: true,
|
||||||
version: {
|
version: {
|
||||||
select: {
|
select: {
|
||||||
video: {
|
video: {
|
||||||
select: {
|
select: {
|
||||||
project: {
|
project: {
|
||||||
select: {
|
select: {
|
||||||
workspace: {
|
workspace: {
|
||||||
select: { ownerId: true },
|
select: { ownerId: true },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -225,50 +265,48 @@ export const getCachedUserMediaStorage = unstable_cache(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
for (const comment of mediaComments) {
|
for (const comment of mediaComments) {
|
||||||
const billedUserId = comment.version.video.project.workspace.ownerId;
|
const billedUserId = comment.version.video.project.workspace.ownerId;
|
||||||
if (!billedUserId) continue;
|
if (!billedUserId) continue;
|
||||||
|
|
||||||
if (!userStorage[billedUserId]) {
|
if (!userStorage[billedUserId]) {
|
||||||
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
|
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (comment.voiceUrl) {
|
if (comment.voiceUrl) {
|
||||||
const keyParts = comment.voiceUrl.split('/');
|
const keyParts = comment.voiceUrl.split('/');
|
||||||
const filename = keyParts[keyParts.length - 1];
|
const filename = keyParts[keyParts.length - 1];
|
||||||
const r2Key = `voice/${filename}`;
|
const r2Key = `voice/${filename}`;
|
||||||
const dedupeKey = `${billedUserId}:${r2Key}`;
|
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||||
if (!seenKeys.has(dedupeKey)) {
|
if (!seenKeys.has(dedupeKey)) {
|
||||||
seenKeys.add(dedupeKey);
|
seenKeys.add(dedupeKey);
|
||||||
const size = fileSizes.get(r2Key) || 0;
|
const size = snapshot.fileSizes.get(r2Key) || 0;
|
||||||
userStorage[billedUserId].voice += size;
|
userStorage[billedUserId].voice += size;
|
||||||
userStorage[billedUserId].total += size;
|
userStorage[billedUserId].total += size;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (comment.imageUrl) {
|
if (comment.imageUrl) {
|
||||||
const keyParts = comment.imageUrl.split('/');
|
const keyParts = comment.imageUrl.split('/');
|
||||||
const filename = keyParts[keyParts.length - 1];
|
const filename = keyParts[keyParts.length - 1];
|
||||||
const r2Key = `images/${filename}`;
|
const r2Key = `images/${filename}`;
|
||||||
const dedupeKey = `${billedUserId}:${r2Key}`;
|
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||||
if (!seenKeys.has(dedupeKey)) {
|
if (!seenKeys.has(dedupeKey)) {
|
||||||
seenKeys.add(dedupeKey);
|
seenKeys.add(dedupeKey);
|
||||||
const size = fileSizes.get(r2Key) || 0;
|
const size = snapshot.fileSizes.get(r2Key) || 0;
|
||||||
userStorage[billedUserId].image += size;
|
userStorage[billedUserId].image += size;
|
||||||
userStorage[billedUserId].total += size;
|
userStorage[billedUserId].total += size;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to parse user storage:', err);
|
|
||||||
}
|
}
|
||||||
return userStorage;
|
} catch (err) {
|
||||||
},
|
console.error('Failed to parse user storage:', err);
|
||||||
['admin-user-media-storage'],
|
}
|
||||||
{ revalidate: STORAGE_CACHE_SECONDS }
|
return userStorage;
|
||||||
);
|
}
|
||||||
|
|
||||||
export const getCachedUserDownloadEgress = unstable_cache(
|
export const getCachedUserDownloadEgress = unstable_cache(
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user