feat(admin): track and display per-user download egress for Bunny version downloads

This commit is contained in:
Yusuf İpek
2026-02-22 20:19:55 +03:00
parent bbae612a70
commit 609b33bbcb
5 changed files with 116 additions and 5 deletions
+1 -1
View File
@@ -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">
+19 -3
View File
@@ -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>
+42 -1
View File
@@ -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);
+30
View File
@@ -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 }
);
+24
View File
@@ -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