mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat(admin): track and display per-user download egress for Bunny version downloads
This commit is contained in:
@@ -18,7 +18,7 @@ export default async function AdminLayout({
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col">
|
<div className="relative flex min-h-screen flex-col">
|
||||||
<Header user={session.user} />
|
<Header user={session.user} />
|
||||||
<div className="container mx-auto px-4 md:px-8 flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
|
<div className="w-full px-4 md:px-8 flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
|
||||||
{/* Mobile Nav */}
|
{/* Mobile Nav */}
|
||||||
<div className="md:hidden py-4 border-b mb-4">
|
<div className="md:hidden py-4 border-b mb-4">
|
||||||
<nav className="flex items-center gap-4 overflow-x-auto">
|
<nav className="flex items-center gap-4 overflow-x-auto">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
|
|||||||
import {
|
import {
|
||||||
getCachedBunnyStorageStats,
|
getCachedBunnyStorageStats,
|
||||||
getCachedUserBunnyStorage,
|
getCachedUserBunnyStorage,
|
||||||
|
getCachedUserDownloadEgress,
|
||||||
getCachedUserMediaStorage
|
getCachedUserMediaStorage
|
||||||
} from '@/lib/admin-stats';
|
} from '@/lib/admin-stats';
|
||||||
import { Film, HardDrive } from 'lucide-react';
|
import { Film, HardDrive } from 'lucide-react';
|
||||||
@@ -33,6 +34,7 @@ type SortBy =
|
|||||||
| 'projectsOwned'
|
| 'projectsOwned'
|
||||||
| 'totalComments'
|
| 'totalComments'
|
||||||
| 'bunnyUpload'
|
| 'bunnyUpload'
|
||||||
|
| 'downloadEgress'
|
||||||
| 'mediaStorage';
|
| 'mediaStorage';
|
||||||
|
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
@@ -45,6 +47,7 @@ const SORTABLE_COLUMNS: SortBy[] = [
|
|||||||
'projectsOwned',
|
'projectsOwned',
|
||||||
'totalComments',
|
'totalComments',
|
||||||
'bunnyUpload',
|
'bunnyUpload',
|
||||||
|
'downloadEgress',
|
||||||
'mediaStorage',
|
'mediaStorage',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -90,7 +93,7 @@ export default async function AdminUsersPage({
|
|||||||
? resolvedSearchParams.sortDirection
|
? resolvedSearchParams.sortDirection
|
||||||
: getDefaultSortDirection(sortBy);
|
: getDefaultSortDirection(sortBy);
|
||||||
|
|
||||||
const [users, userStorage, userBunnyStorage, bunnyStorageStats] = await Promise.all([
|
const [users, userStorage, userBunnyStorage, userDownloadEgress, bunnyStorageStats] = await Promise.all([
|
||||||
db.user.findMany({
|
db.user.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
@@ -118,6 +121,7 @@ export default async function AdminUsersPage({
|
|||||||
}),
|
}),
|
||||||
getCachedUserMediaStorage(),
|
getCachedUserMediaStorage(),
|
||||||
getCachedUserBunnyStorage(),
|
getCachedUserBunnyStorage(),
|
||||||
|
getCachedUserDownloadEgress(),
|
||||||
getCachedBunnyStorageStats(),
|
getCachedBunnyStorageStats(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -128,6 +132,7 @@ export default async function AdminUsersPage({
|
|||||||
0
|
0
|
||||||
),
|
),
|
||||||
bunnyUploadBytes: userBunnyStorage[user.id] || 0,
|
bunnyUploadBytes: userBunnyStorage[user.id] || 0,
|
||||||
|
downloadEgressBytes: userDownloadEgress[user.id] || 0,
|
||||||
mediaStorageBytes: userStorage[user.id]?.total || 0,
|
mediaStorageBytes: userStorage[user.id]?.total || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -150,6 +155,8 @@ export default async function AdminUsersPage({
|
|||||||
comparison = a._count.comments - b._count.comments;
|
comparison = a._count.comments - b._count.comments;
|
||||||
} else if (sortBy === 'bunnyUpload') {
|
} else if (sortBy === 'bunnyUpload') {
|
||||||
comparison = a.bunnyUploadBytes - b.bunnyUploadBytes;
|
comparison = a.bunnyUploadBytes - b.bunnyUploadBytes;
|
||||||
|
} else if (sortBy === 'downloadEgress') {
|
||||||
|
comparison = a.downloadEgressBytes - b.downloadEgressBytes;
|
||||||
} else if (sortBy === 'mediaStorage') {
|
} else if (sortBy === 'mediaStorage') {
|
||||||
comparison = a.mediaStorageBytes - b.mediaStorageBytes;
|
comparison = a.mediaStorageBytes - b.mediaStorageBytes;
|
||||||
}
|
}
|
||||||
@@ -193,7 +200,7 @@ export default async function AdminUsersPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4 px-4 md:px-8">
|
<div className="flex-1 space-y-4">
|
||||||
<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">Users</h2>
|
<h2 className="text-3xl font-bold tracking-tight">Users</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,6 +282,12 @@ export default async function AdminUsersPage({
|
|||||||
<span className="text-xs">{getSortIndicator('bunnyUpload', sortBy, sortDirection)}</span>
|
<span className="text-xs">{getSortIndicator('bunnyUpload', sortBy, sortDirection)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<Link href={buildSortHref('downloadEgress')} className="inline-flex items-center justify-end gap-1 hover:underline">
|
||||||
|
Download Egress (Est.)
|
||||||
|
<span className="text-xs">{getSortIndicator('downloadEgress', sortBy, sortDirection)}</span>
|
||||||
|
</Link>
|
||||||
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
<Link href={buildSortHref('mediaStorage')} className="inline-flex items-center justify-end gap-1 hover:underline">
|
<Link href={buildSortHref('mediaStorage')} className="inline-flex items-center justify-end gap-1 hover:underline">
|
||||||
Media Storage
|
Media Storage
|
||||||
@@ -286,7 +299,7 @@ export default async function AdminUsersPage({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{paginatedUsers.length === 0 ? (
|
{paginatedUsers.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="h-24 text-center">
|
<TableCell colSpan={9} className="h-24 text-center">
|
||||||
No users found.
|
No users found.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -309,6 +322,9 @@ export default async function AdminUsersPage({
|
|||||||
<TableCell className="text-right text-sm font-medium">
|
<TableCell className="text-right text-sm font-medium">
|
||||||
{formatBytes(user.bunnyUploadBytes)}
|
{formatBytes(user.bunnyUploadBytes)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm font-medium">
|
||||||
|
{formatBytes(user.downloadEgressBytes)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right text-sm">
|
<TableCell className="text-right text-sm">
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<span className="font-medium text-foreground">{formatBytes(user.mediaStorageBytes)}</span>
|
<span className="font-medium text-foreground">{formatBytes(user.mediaStorageBytes)}</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { db } from '@/lib/db';
|
|||||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
|
import { DownloadEgressSource } from '@prisma/client';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ versionId: string }> };
|
type RouteParams = { params: Promise<{ versionId: string }> };
|
||||||
type BunnyDownloadSourcePreference = 'auto' | 'original' | 'compressed';
|
type BunnyDownloadSourcePreference = 'auto' | 'original' | 'compressed';
|
||||||
@@ -304,6 +305,19 @@ function resolveSafeDownloadMetadata(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseEstimatedBytes(contentLengthHeader: string | null): bigint {
|
||||||
|
if (!contentLengthHeader) return BigInt(0);
|
||||||
|
const normalized = contentLengthHeader.trim();
|
||||||
|
if (!/^\d+$/.test(normalized)) return BigInt(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = BigInt(normalized);
|
||||||
|
return parsed > BigInt(0) ? parsed : BigInt(0);
|
||||||
|
} catch {
|
||||||
|
return BigInt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/versions/[versionId]/download
|
// GET /api/versions/[versionId]/download
|
||||||
export async function GET(request: Request, { params }: RouteParams) {
|
export async function GET(request: Request, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
@@ -328,7 +342,16 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
include: {
|
include: {
|
||||||
video: {
|
video: {
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: {
|
||||||
|
include: {
|
||||||
|
workspace: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
ownerId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -415,6 +438,24 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
const contentLength = upstream.headers.get('content-length');
|
const contentLength = upstream.headers.get('content-length');
|
||||||
if (contentLength) response.headers.set('Content-Length', contentLength);
|
if (contentLength) response.headers.set('Content-Length', contentLength);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.downloadEgressEvent.create({
|
||||||
|
data: {
|
||||||
|
versionId: version.id,
|
||||||
|
videoId: version.video.id,
|
||||||
|
projectId: version.video.project.id,
|
||||||
|
workspaceId: version.video.project.workspace.id,
|
||||||
|
billedUserId: version.video.project.workspace.ownerId,
|
||||||
|
downloaderUserId: session?.user?.id ?? null,
|
||||||
|
source: source.sourceType === 'original' ? DownloadEgressSource.ORIGINAL : DownloadEgressSource.COMPRESSED,
|
||||||
|
quality: source.quality,
|
||||||
|
estimatedBytes: parseEstimatedBytes(contentLength),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (egressError) {
|
||||||
|
console.error('Failed to record download egress event:', egressError);
|
||||||
|
}
|
||||||
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
return withCacheControl(response, 'private, no-store');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading version:', error);
|
console.error('Error downloading version:', error);
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ interface BunnyStorageStats {
|
|||||||
byVideoId: Record<string, number>;
|
byVideoId: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bigintToNumber(value: bigint): number {
|
||||||
|
return value > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
function getBunnyConfig(): { apiKey: string; libraryId: string } {
|
function getBunnyConfig(): { apiKey: string; libraryId: string } {
|
||||||
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
||||||
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
||||||
@@ -237,3 +241,29 @@ export const getCachedUserMediaStorage = unstable_cache(
|
|||||||
['admin-user-media-storage'],
|
['admin-user-media-storage'],
|
||||||
{ revalidate: STORAGE_CACHE_SECONDS }
|
{ revalidate: STORAGE_CACHE_SECONDS }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getCachedUserDownloadEgress = unstable_cache(
|
||||||
|
async () => {
|
||||||
|
const perUserDownloadEgress: Record<string, number> = {};
|
||||||
|
try {
|
||||||
|
const grouped = await db.downloadEgressEvent.groupBy({
|
||||||
|
by: ['billedUserId'],
|
||||||
|
_sum: {
|
||||||
|
estimatedBytes: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of grouped) {
|
||||||
|
perUserDownloadEgress[row.billedUserId] = row._sum.estimatedBytes
|
||||||
|
? bigintToNumber(row._sum.estimatedBytes)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to calculate per-user download egress:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return perUserDownloadEgress;
|
||||||
|
},
|
||||||
|
['admin-user-download-egress'],
|
||||||
|
{ revalidate: STORAGE_CACHE_SECONDS }
|
||||||
|
);
|
||||||
|
|||||||
@@ -36,6 +36,30 @@ model User {
|
|||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DownloadEgressSource {
|
||||||
|
ORIGINAL
|
||||||
|
COMPRESSED
|
||||||
|
}
|
||||||
|
|
||||||
|
model DownloadEgressEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
versionId String
|
||||||
|
videoId String
|
||||||
|
projectId String
|
||||||
|
workspaceId String
|
||||||
|
billedUserId String
|
||||||
|
downloaderUserId String?
|
||||||
|
source DownloadEgressSource
|
||||||
|
quality Int?
|
||||||
|
estimatedBytes BigInt @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([billedUserId, createdAt])
|
||||||
|
@@index([workspaceId, createdAt])
|
||||||
|
@@index([versionId, createdAt])
|
||||||
|
@@map("download_egress_events")
|
||||||
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
Reference in New Issue
Block a user