Features: - Image generation (OpenAI, Gemini, Leonardo, Bria, Stability, Flux) - Nano Banana iterative editing - Video generation and upscaling - Audio TTS, STT, sound effects (ElevenLabs) - Text prompt studio and alt text - User authentication with JWT/cookies - Admin panel with voice management - Job queue with Celery - PostgreSQL + Redis backend - Next.js 15 + FastAPI architecture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { toast } from 'react-hot-toast';
|
|
import {
|
|
TrendingUp,
|
|
Download,
|
|
Calendar,
|
|
BarChart3,
|
|
PieChart,
|
|
Activity,
|
|
} from 'lucide-react';
|
|
import AdminGuard from '@/components/AdminGuard';
|
|
import api from '@/lib/api';
|
|
|
|
interface UsageData {
|
|
date: string;
|
|
jobs: number;
|
|
cost: number;
|
|
}
|
|
|
|
interface ModuleUsage {
|
|
module: string;
|
|
count: number;
|
|
percentage: number;
|
|
}
|
|
|
|
interface UserUsage {
|
|
user_id: string;
|
|
user_email: string;
|
|
job_count: number;
|
|
total_cost: number;
|
|
}
|
|
|
|
export default function ReportsPage() {
|
|
const [dateRange, setDateRange] = useState('7d');
|
|
const [loading, setLoading] = useState(true);
|
|
const [usageOverTime, setUsageOverTime] = useState<UsageData[]>([]);
|
|
const [moduleBreakdown, setModuleBreakdown] = useState<ModuleUsage[]>([]);
|
|
const [topUsers, setTopUsers] = useState<UserUsage[]>([]);
|
|
const [totals, setTotals] = useState({
|
|
totalJobs: 0,
|
|
totalCost: 0,
|
|
avgJobsPerDay: 0,
|
|
});
|
|
|
|
useEffect(() => {
|
|
fetchReportData();
|
|
}, [dateRange]);
|
|
|
|
const fetchReportData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await api.get('/admin/reports', {
|
|
params: { range: dateRange },
|
|
});
|
|
// Set real data from API
|
|
setUsageOverTime(response.data.usage_over_time || []);
|
|
setModuleBreakdown(response.data.module_breakdown || []);
|
|
setTopUsers(response.data.top_users || []);
|
|
setTotals(response.data.totals || {});
|
|
} catch (err) {
|
|
// Use mock data for demo
|
|
setUsageOverTime([
|
|
{ date: '2024-12-03', jobs: 45, cost: 12.50 },
|
|
{ date: '2024-12-04', jobs: 62, cost: 18.30 },
|
|
{ date: '2024-12-05', jobs: 38, cost: 9.80 },
|
|
{ date: '2024-12-06', jobs: 71, cost: 22.40 },
|
|
{ date: '2024-12-07', jobs: 55, cost: 15.60 },
|
|
{ date: '2024-12-08', jobs: 48, cost: 13.20 },
|
|
{ date: '2024-12-09', jobs: 47, cost: 14.70 },
|
|
]);
|
|
setModuleBreakdown([
|
|
{ module: 'Image Generation', count: 156, percentage: 35 },
|
|
{ module: 'Video Generation', count: 89, percentage: 20 },
|
|
{ module: 'Text to Speech', count: 78, percentage: 18 },
|
|
{ module: 'Voice to Text', count: 67, percentage: 15 },
|
|
{ module: 'Image Upscaling', count: 45, percentage: 10 },
|
|
{ module: 'Other', count: 11, percentage: 2 },
|
|
]);
|
|
setTopUsers([
|
|
{ user_id: '1', user_email: 'john@example.com', job_count: 89, total_cost: 28.50 },
|
|
{ user_id: '2', user_email: 'jane@example.com', job_count: 67, total_cost: 21.30 },
|
|
{ user_id: '3', user_email: 'bob@example.com', job_count: 45, total_cost: 15.80 },
|
|
{ user_id: '4', user_email: 'alice@example.com', job_count: 34, total_cost: 12.40 },
|
|
{ user_id: '5', user_email: 'admin@forgeai.dev', job_count: 28, total_cost: 9.20 },
|
|
]);
|
|
setTotals({
|
|
totalJobs: 366,
|
|
totalCost: 106.50,
|
|
avgJobsPerDay: 52.3,
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleExport = async (format: 'csv' | 'json') => {
|
|
try {
|
|
const response = await api.get('/admin/reports/export', {
|
|
params: { range: dateRange, format },
|
|
responseType: 'blob',
|
|
});
|
|
const url = window.URL.createObjectURL(response.data);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `forge-ai-report-${dateRange}.${format}`;
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
toast.success('Report exported!');
|
|
} catch (err) {
|
|
toast.error('Failed to export report');
|
|
}
|
|
};
|
|
|
|
const maxJobs = Math.max(...usageOverTime.map((d) => d.jobs), 1);
|
|
|
|
return (
|
|
<AdminGuard>
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 bg-forge-yellow/10 rounded-lg flex items-center justify-center">
|
|
<TrendingUp className="w-6 h-6 text-forge-yellow" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Usage Reports</h1>
|
|
<p className="text-gray-500">Analytics and usage statistics</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<select
|
|
value={dateRange}
|
|
onChange={(e) => setDateRange(e.target.value)}
|
|
className="select-field"
|
|
>
|
|
<option value="7d">Last 7 days</option>
|
|
<option value="30d">Last 30 days</option>
|
|
<option value="90d">Last 90 days</option>
|
|
<option value="365d">Last year</option>
|
|
</select>
|
|
<button
|
|
onClick={() => handleExport('csv')}
|
|
className="btn-secondary flex items-center gap-2"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
Export CSV
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="bg-forge-dark rounded-xl p-6 border border-gray-800">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<Activity className="w-5 h-5 text-forge-yellow" />
|
|
<span className="text-gray-500">Total Jobs</span>
|
|
</div>
|
|
<p className="text-3xl font-bold text-white">{totals.totalJobs}</p>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Avg {totals.avgJobsPerDay.toFixed(1)}/day
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-forge-dark rounded-xl p-6 border border-gray-800">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<BarChart3 className="w-5 h-5 text-green-400" />
|
|
<span className="text-gray-500">Estimated Cost</span>
|
|
</div>
|
|
<p className="text-3xl font-bold text-white">
|
|
${totals.totalCost.toFixed(2)}
|
|
</p>
|
|
<p className="text-sm text-gray-500 mt-1">API usage costs</p>
|
|
</div>
|
|
|
|
<div className="bg-forge-dark rounded-xl p-6 border border-gray-800">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<Calendar className="w-5 h-5 text-blue-400" />
|
|
<span className="text-gray-500">Period</span>
|
|
</div>
|
|
<p className="text-3xl font-bold text-white">
|
|
{dateRange === '7d'
|
|
? '7 Days'
|
|
: dateRange === '30d'
|
|
? '30 Days'
|
|
: dateRange === '90d'
|
|
? '90 Days'
|
|
: '1 Year'}
|
|
</p>
|
|
<p className="text-sm text-gray-500 mt-1">Date range</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Charts Row */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Usage Over Time */}
|
|
<div className="bg-forge-dark rounded-xl border border-gray-800 p-6">
|
|
<h3 className="text-lg font-semibold text-white mb-4">
|
|
Jobs Over Time
|
|
</h3>
|
|
{loading ? (
|
|
<div className="h-64 flex items-center justify-center text-gray-500">
|
|
Loading...
|
|
</div>
|
|
) : (
|
|
<div className="h-64 flex items-end gap-2">
|
|
{usageOverTime.map((data, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex-1 flex flex-col items-center gap-2"
|
|
>
|
|
<div
|
|
className="w-full bg-forge-yellow rounded-t transition-all"
|
|
style={{
|
|
height: `${(data.jobs / maxJobs) * 200}px`,
|
|
minHeight: '4px',
|
|
}}
|
|
/>
|
|
<span className="text-xs text-gray-500">
|
|
{new Date(data.date).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Module Breakdown */}
|
|
<div className="bg-forge-dark rounded-xl border border-gray-800 p-6">
|
|
<h3 className="text-lg font-semibold text-white mb-4">
|
|
Usage by Module
|
|
</h3>
|
|
{loading ? (
|
|
<div className="h-64 flex items-center justify-center text-gray-500">
|
|
Loading...
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{moduleBreakdown.map((module) => (
|
|
<div key={module.module}>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-gray-300">{module.module}</span>
|
|
<span className="text-gray-500 text-sm">
|
|
{module.count} ({module.percentage}%)
|
|
</span>
|
|
</div>
|
|
<div className="progress-bar">
|
|
<div
|
|
className="progress-bar-fill"
|
|
style={{ width: `${module.percentage}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top Users */}
|
|
<div className="bg-forge-dark rounded-xl border border-gray-800">
|
|
<div className="p-6 border-b border-gray-800">
|
|
<h3 className="text-lg font-semibold text-white">Top Users</h3>
|
|
</div>
|
|
{loading ? (
|
|
<div className="p-8 text-center text-gray-500">Loading...</div>
|
|
) : (
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-gray-800">
|
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500">
|
|
Rank
|
|
</th>
|
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500">
|
|
User
|
|
</th>
|
|
<th className="text-right px-6 py-4 text-sm font-medium text-gray-500">
|
|
Jobs
|
|
</th>
|
|
<th className="text-right px-6 py-4 text-sm font-medium text-gray-500">
|
|
Est. Cost
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{topUsers.map((user, index) => (
|
|
<tr
|
|
key={user.user_id}
|
|
className="border-b border-gray-800 last:border-0"
|
|
>
|
|
<td className="px-6 py-4">
|
|
<span
|
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-sm font-medium ${
|
|
index === 0
|
|
? 'bg-forge-yellow text-black'
|
|
: index === 1
|
|
? 'bg-gray-400 text-black'
|
|
: index === 2
|
|
? 'bg-orange-600 text-white'
|
|
: 'bg-forge-gray text-gray-400'
|
|
}`}
|
|
>
|
|
{index + 1}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-white">{user.user_email}</td>
|
|
<td className="px-6 py-4 text-right text-gray-300">
|
|
{user.job_count}
|
|
</td>
|
|
<td className="px-6 py-4 text-right text-gray-300">
|
|
${user.total_cost.toFixed(2)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</AdminGuard>
|
|
);
|
|
}
|