feat: implement API usage tracking and admin dashboard
- Added database schema for API tracking system: * api_usage table - tracks all Google Search and Gemini AI calls * user_credits table - prepared for future credits system * admin_users table - controls admin dashboard access - Created comprehensive tracking service (track-api-usage.ts): * trackApiUsage() - records API calls with success/failure * checkDailyLimit() - enforces 80 Google Search calls/day limit * getUserApiStats() - per-user statistics * getGlobalApiStats() - app-wide statistics (admin only) * checkIsAdmin() - server-side authorization - Integrated tracking into discover-whiskybase.ts: * Pre-call limit checking with friendly error messages * Post-call usage tracking for success and failures * User authentication verification - Built admin dashboard at /admin: * Global statistics cards (total, today, by API type) * Top 10 users by API usage * Recent activity log with 50 latest calls * Color-coded status indicators * Secure access with RLS policies - Features: * Daily limit resets at midnight Europe/Berlin timezone * Graceful error handling (allows on tracking failure) * Comprehensive indexes for fast queries * Ready for future credits/monetization system
This commit is contained in:
214
src/app/admin/page.tsx
Normal file
214
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage';
|
||||
import { BarChart3, TrendingUp, Users, Calendar, AlertCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default async function AdminPage() {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
// Fetch global API stats
|
||||
const stats = await getGlobalApiStats();
|
||||
|
||||
// Fetch recent API usage
|
||||
const { data: recentUsage } = await supabase
|
||||
.from('api_usage')
|
||||
.select(`
|
||||
*,
|
||||
profiles:user_id (
|
||||
username
|
||||
)
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
// Fetch per-user statistics
|
||||
const { data: userStats } = await supabase
|
||||
.from('api_usage')
|
||||
.select('user_id, api_type')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
// Group by user
|
||||
const userStatsMap = new Map<string, { googleSearch: number; geminiAi: number; total: number }>();
|
||||
userStats?.forEach(item => {
|
||||
const current = userStatsMap.get(item.user_id) || { googleSearch: 0, geminiAi: 0, total: 0 };
|
||||
if (item.api_type === 'google_search') current.googleSearch++;
|
||||
if (item.api_type === 'gemini_ai') current.geminiAi++;
|
||||
current.total++;
|
||||
userStatsMap.set(item.user_id, current);
|
||||
});
|
||||
|
||||
// Get user details for top users
|
||||
const topUserIds = Array.from(userStatsMap.entries())
|
||||
.sort((a, b) => b[1].total - a[1].total)
|
||||
.slice(0, 10)
|
||||
.map(([userId]) => userId);
|
||||
|
||||
const { data: topUsers } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, username')
|
||||
.in('id', topUserIds);
|
||||
|
||||
const topUsersWithStats = topUsers?.map(user => ({
|
||||
...user,
|
||||
stats: userStatsMap.get(user.id)!
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">Admin Dashboard</h1>
|
||||
<p className="text-zinc-500 mt-1">API Usage Monitoring & Statistics</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Back to App
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Global Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<BarChart3 size={20} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase text-zinc-400">Total Calls</span>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats?.totalCalls || 0}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">All time</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||
<Calendar size={20} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase text-zinc-400">Today</span>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats?.todayCalls || 0}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">
|
||||
{stats?.todayCalls && stats.todayCalls >= 80 ? (
|
||||
<span className="text-red-500 font-bold flex items-center gap-1">
|
||||
<AlertCircle size={12} /> Limit reached
|
||||
</span>
|
||||
) : (
|
||||
`${80 - (stats?.todayCalls || 0)} remaining`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
||||
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase text-zinc-400">Google Search</span>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats?.googleSearchCalls || 0}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">Whiskybase searches</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<Users size={20} className="text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase text-zinc-400">Gemini AI</span>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats?.geminiAiCalls || 0}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">Bottle analyses</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Users */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Top Users by API Usage</h2>
|
||||
<div className="space-y-3">
|
||||
{topUsersWithStats.map((user, index) => (
|
||||
<div key={user.id} className="flex items-center justify-between p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-amber-700 dark:text-amber-500 font-black text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-zinc-900 dark:text-white">{user.username}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{user.stats.googleSearch} searches · {user.stats.geminiAi} analyses
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-black text-zinc-900 dark:text-white">{user.stats.total}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent API Calls</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-200 dark:border-zinc-800">
|
||||
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Time</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">User</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">API Type</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Status</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentUsage?.map((item) => (
|
||||
<tr key={item.id} className="border-b border-zinc-100 dark:border-zinc-800/50">
|
||||
<td className="py-3 px-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{new Date(item.created_at).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
{(item.profiles as any)?.username || 'Unknown'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`text-xs font-black uppercase px-2 py-1 rounded ${item.api_type === 'google_search'
|
||||
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-500'
|
||||
: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-500'
|
||||
}`}>
|
||||
{item.api_type === 'google_search' ? 'Search' : 'AI'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`text-xs font-black uppercase px-2 py-1 rounded ${item.success
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-500'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-500'
|
||||
}`}>
|
||||
{item.success ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs text-zinc-500 max-w-xs truncate">
|
||||
{item.error_message || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user