- Add soft delete (deleted_at column) to preserve task records for statistics - Implement cleanup service to delete old files while keeping DB records - Add automatic cleanup scheduler (configurable interval, default 24h) - Add admin endpoints: storage stats, cleanup trigger, scheduler status - Update task service with admin views (include deleted/files_deleted) - Add frontend storage management UI in admin dashboard - Add i18n translations for storage management 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
563 lines
22 KiB
TypeScript
563 lines
22 KiB
TypeScript
/**
|
|
* Admin Dashboard Page
|
|
* System statistics and user management for administrators
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { apiClientV2 } from '@/services/apiV2'
|
|
import type { SystemStats, UserWithStats, TopUser, TranslationStats, StorageStats } from '@/types/apiV2'
|
|
import {
|
|
Users,
|
|
ClipboardList,
|
|
Activity,
|
|
TrendingUp,
|
|
RefreshCw,
|
|
Shield,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Clock,
|
|
Loader2,
|
|
Languages,
|
|
Coins,
|
|
HardDrive,
|
|
Trash2,
|
|
} from 'lucide-react'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { Badge } from '@/components/ui/badge'
|
|
|
|
export default function AdminDashboardPage() {
|
|
const { t, i18n } = useTranslation()
|
|
const navigate = useNavigate()
|
|
const [stats, setStats] = useState<SystemStats | null>(null)
|
|
const [users, setUsers] = useState<UserWithStats[]>([])
|
|
const [topUsers, setTopUsers] = useState<TopUser[]>([])
|
|
const [translationStats, setTranslationStats] = useState<TranslationStats | null>(null)
|
|
const [storageStats, setStorageStats] = useState<StorageStats | null>(null)
|
|
const [cleanupLoading, setCleanupLoading] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
|
|
// Fetch all data
|
|
const fetchData = async () => {
|
|
try {
|
|
setLoading(true)
|
|
setError('')
|
|
|
|
const [statsData, usersData, topUsersData, translationStatsData, storageStatsData] = await Promise.all([
|
|
apiClientV2.getSystemStats(),
|
|
apiClientV2.listUsers({ page: 1, page_size: 10 }),
|
|
apiClientV2.getTopUsers({ metric: 'tasks', limit: 5 }),
|
|
apiClientV2.getTranslationStats(),
|
|
apiClientV2.getStorageStats(),
|
|
])
|
|
|
|
setStats(statsData)
|
|
setUsers(usersData.users)
|
|
setTopUsers(topUsersData)
|
|
setTranslationStats(translationStatsData)
|
|
setStorageStats(storageStatsData)
|
|
} catch (err: any) {
|
|
console.error('Failed to fetch admin data:', err)
|
|
setError(err.response?.data?.detail || t('admin.loadFailed'))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchData()
|
|
}, [])
|
|
|
|
// Format date based on current locale
|
|
const formatDate = (dateStr: string | null) => {
|
|
if (!dateStr) return '-'
|
|
const date = new Date(dateStr)
|
|
return date.toLocaleString(i18n.language === 'zh-TW' ? 'zh-TW' : 'en-US')
|
|
}
|
|
|
|
// Handle cleanup trigger
|
|
const handleCleanup = async () => {
|
|
try {
|
|
setCleanupLoading(true)
|
|
const result = await apiClientV2.triggerCleanup()
|
|
alert(t('admin.storage.cleanupResult', {
|
|
users: result.users_processed,
|
|
files: result.total_files_deleted,
|
|
mb: (result.total_bytes_freed / 1024 / 1024).toFixed(2)
|
|
}))
|
|
// Refresh storage stats
|
|
const newStorageStats = await apiClientV2.getStorageStats()
|
|
setStorageStats(newStorageStats)
|
|
} catch (err: any) {
|
|
console.error('Cleanup failed:', err)
|
|
alert(t('admin.storage.cleanupFailed'))
|
|
} finally {
|
|
setCleanupLoading(false)
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<div className="text-center">
|
|
<Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto mb-4" />
|
|
<p className="text-gray-600">{t('admin.loadingDashboard')}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="container mx-auto p-6">
|
|
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
<XCircle className="w-5 h-5 text-red-600" />
|
|
<div>
|
|
<p className="text-red-600 font-semibold">{t('errors.loadFailed')}</p>
|
|
<p className="text-red-500 text-sm">{error}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto p-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="w-8 h-8 text-blue-600" />
|
|
<h1 className="text-3xl font-bold text-gray-900">{t('admin.title')}</h1>
|
|
</div>
|
|
<p className="text-gray-600 mt-1">{t('admin.subtitle')}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button onClick={() => navigate('/admin/audit-logs')} variant="outline">
|
|
<Activity className="w-4 h-4 mr-2" />
|
|
{t('admin.auditLogs')}
|
|
</Button>
|
|
<Button onClick={fetchData} variant="outline">
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
{t('common.refresh')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* System Statistics */}
|
|
{stats && (
|
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
|
<Users className="w-4 h-4" />
|
|
{t('admin.totalUsers')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{stats.total_users}</div>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
{t('admin.activeUsers')}: {stats.active_users}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
|
<ClipboardList className="w-4 h-4" />
|
|
{t('admin.totalTasks')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{stats.total_tasks}</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
|
<Clock className="w-4 h-4" />
|
|
{t('admin.pendingTasks')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-gray-600">
|
|
{stats.task_stats.pending}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
|
<Loader2 className="w-4 h-4" />
|
|
{t('admin.processingTasks')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-blue-600">
|
|
{stats.task_stats.processing}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
|
<CheckCircle2 className="w-4 h-4" />
|
|
{t('admin.completedTasks')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-green-600">
|
|
{stats.task_stats.completed}
|
|
</div>
|
|
<p className="text-xs text-red-600 mt-1">
|
|
{t('admin.failedTasks')}: {stats.task_stats.failed}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Translation Statistics */}
|
|
{translationStats && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Languages className="w-5 h-5" />
|
|
{t('admin.translationStats.title')}
|
|
</CardTitle>
|
|
<CardDescription>{t('admin.translationStats.description')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
|
<div className="p-4 bg-blue-50 rounded-lg">
|
|
<div className="flex items-center gap-2 text-blue-600 mb-1">
|
|
<Languages className="w-4 h-4" />
|
|
<span className="text-sm font-medium">{t('admin.translationStats.totalTranslations')}</span>
|
|
</div>
|
|
<div className="text-2xl font-bold text-blue-700">
|
|
{translationStats.total_translations.toLocaleString()}
|
|
</div>
|
|
<p className="text-xs text-blue-500 mt-1">
|
|
{t('admin.translationStats.last30Days')}: {translationStats.last_30_days.count}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-4 bg-purple-50 rounded-lg">
|
|
<div className="flex items-center gap-2 text-purple-600 mb-1">
|
|
<Activity className="w-4 h-4" />
|
|
<span className="text-sm font-medium">{t('admin.translationStats.totalTokens')}</span>
|
|
</div>
|
|
<div className="text-2xl font-bold text-purple-700">
|
|
{translationStats.total_tokens.toLocaleString()}
|
|
</div>
|
|
<p className="text-xs text-purple-500 mt-1">
|
|
{t('admin.translationStats.last30Days')}: {translationStats.last_30_days.tokens.toLocaleString()}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-4 bg-green-50 rounded-lg">
|
|
<div className="flex items-center gap-2 text-green-600 mb-1">
|
|
<ClipboardList className="w-4 h-4" />
|
|
<span className="text-sm font-medium">{t('admin.translationStats.totalCharacters')}</span>
|
|
</div>
|
|
<div className="text-2xl font-bold text-green-700">
|
|
{translationStats.total_characters.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 bg-amber-50 rounded-lg">
|
|
<div className="flex items-center gap-2 text-amber-600 mb-1">
|
|
<Coins className="w-4 h-4" />
|
|
<span className="text-sm font-medium">{t('admin.translationStats.estimatedCost')}</span>
|
|
</div>
|
|
<div className="text-2xl font-bold text-amber-700">
|
|
${translationStats.estimated_cost.toFixed(2)}
|
|
</div>
|
|
<p className="text-xs text-amber-500 mt-1">USD</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Language Breakdown */}
|
|
{translationStats.by_language.length > 0 && (
|
|
<div className="mt-4">
|
|
<h4 className="text-sm font-medium text-gray-700 mb-2">{t('admin.translationStats.languageBreakdown')}</h4>
|
|
<div className="flex flex-wrap gap-2">
|
|
{translationStats.by_language.map((lang) => (
|
|
<Badge key={lang.language} variant="outline" className="px-3 py-1">
|
|
{lang.language}: {lang.count} {t('admin.translationStats.count')} ({lang.tokens.toLocaleString()} {t('admin.translationStats.tokens')})
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent Translations */}
|
|
{translationStats.recent_translations.length > 0 && (
|
|
<div className="mt-6">
|
|
<h4 className="text-sm font-medium text-gray-700 mb-2">{t('admin.translationStats.recentTranslations')}</h4>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t('admin.table.taskId')}</TableHead>
|
|
<TableHead>{t('admin.table.targetLang')}</TableHead>
|
|
<TableHead className="text-right">{t('admin.table.tokenCount')}</TableHead>
|
|
<TableHead className="text-right">{t('admin.table.charCount')}</TableHead>
|
|
<TableHead className="text-right">{t('admin.table.cost')}</TableHead>
|
|
<TableHead className="text-right">{t('admin.table.processingTime')}</TableHead>
|
|
<TableHead>{t('admin.table.time')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{translationStats.recent_translations.slice(0, 10).map((tr) => (
|
|
<TableRow key={tr.id}>
|
|
<TableCell className="font-mono text-xs">
|
|
{tr.task_id.substring(0, 8)}...
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="secondary">{tr.target_lang}</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{tr.total_tokens.toLocaleString()}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{tr.total_characters.toLocaleString()}
|
|
</TableCell>
|
|
<TableCell className="text-right font-medium text-amber-600">
|
|
${tr.estimated_cost.toFixed(4)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{tr.processing_time_seconds.toFixed(1)}s
|
|
</TableCell>
|
|
<TableCell className="text-sm text-gray-600">
|
|
{formatDate(tr.created_at)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Storage Management */}
|
|
{storageStats && (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<HardDrive className="w-5 h-5" />
|
|
{t('admin.storage.title')}
|
|
</CardTitle>
|
|
<CardDescription>{t('admin.storage.description')}</CardDescription>
|
|
</div>
|
|
<Button
|
|
onClick={handleCleanup}
|
|
disabled={cleanupLoading}
|
|
variant="outline"
|
|
className="gap-2"
|
|
>
|
|
{cleanupLoading ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-4 h-4" />
|
|
)}
|
|
{t('admin.storage.triggerCleanup')}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
|
<div className="p-4 bg-blue-50 rounded-lg">
|
|
<div className="flex items-center gap-2 text-blue-600 mb-1">
|
|
<ClipboardList className="w-4 h-4" />
|
|
<span className="text-sm font-medium">{t('admin.storage.totalTasks')}</span>
|
|
</div>
|
|
<div className="text-2xl font-bold text-blue-700">
|
|
{storageStats.total_tasks.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 bg-green-50 rounded-lg">
|
|
<div className="flex items-center gap-2 text-green-600 mb-1">
|
|
<CheckCircle2 className="w-4 h-4" />
|
|
<span className="text-sm font-medium">{t('admin.storage.tasksWithFiles')}</span>
|
|
</div>
|
|
<div className="text-2xl font-bold text-green-700">
|
|
{storageStats.tasks_with_files.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 bg-amber-50 rounded-lg">
|
|
<div className="flex items-center gap-2 text-amber-600 mb-1">
|
|
<Trash2 className="w-4 h-4" />
|
|
<span className="text-sm font-medium">{t('admin.storage.filesDeleted')}</span>
|
|
</div>
|
|
<div className="text-2xl font-bold text-amber-700">
|
|
{storageStats.tasks_files_deleted.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 bg-gray-50 rounded-lg">
|
|
<div className="flex items-center gap-2 text-gray-600 mb-1">
|
|
<XCircle className="w-4 h-4" />
|
|
<span className="text-sm font-medium">{t('admin.storage.softDeleted')}</span>
|
|
</div>
|
|
<div className="text-2xl font-bold text-gray-700">
|
|
{storageStats.soft_deleted_tasks.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Disk Usage */}
|
|
<div className="border rounded-lg p-4">
|
|
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('admin.storage.diskUsage')}</h4>
|
|
<div className="grid grid-cols-3 gap-4 text-center">
|
|
<div>
|
|
<div className="text-lg font-semibold text-blue-600">
|
|
{storageStats.disk_usage.uploads_mb} MB
|
|
</div>
|
|
<div className="text-xs text-gray-500">{t('admin.storage.uploadsSize')}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-lg font-semibold text-green-600">
|
|
{storageStats.disk_usage.results_mb} MB
|
|
</div>
|
|
<div className="text-xs text-gray-500">{t('admin.storage.resultsSize')}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-lg font-semibold text-purple-600">
|
|
{storageStats.disk_usage.total_mb} MB
|
|
</div>
|
|
<div className="text-xs text-gray-500">{t('admin.storage.totalSize')}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Top Users */}
|
|
{topUsers.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<TrendingUp className="w-5 h-5" />
|
|
{t('admin.topUsers.title')}
|
|
</CardTitle>
|
|
<CardDescription>{t('admin.topUsers.description')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-12">#</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>{t('admin.topUsers.displayName')}</TableHead>
|
|
<TableHead className="text-right">{t('admin.topUsers.totalTasks')}</TableHead>
|
|
<TableHead className="text-right">{t('admin.topUsers.completedTasks')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{topUsers.map((user, index) => (
|
|
<TableRow key={user.user_id}>
|
|
<TableCell className="font-medium">
|
|
<Badge variant={index === 0 ? 'default' : 'secondary'}>
|
|
{index + 1}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>{user.email}</TableCell>
|
|
<TableCell>{user.display_name || '-'}</TableCell>
|
|
<TableCell className="text-right font-semibold">
|
|
{user.task_count}
|
|
</TableCell>
|
|
<TableCell className="text-right text-green-600">
|
|
{user.completed_tasks}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Recent Users */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Users className="w-5 h-5" />
|
|
{t('admin.recentUsers.title')}
|
|
</CardTitle>
|
|
<CardDescription>{t('admin.recentUsers.description')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{users.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-500">
|
|
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
<p>{t('admin.recentUsers.noUsers')}</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>{t('admin.recentUsers.displayName')}</TableHead>
|
|
<TableHead>{t('admin.recentUsers.registeredAt')}</TableHead>
|
|
<TableHead>{t('admin.recentUsers.lastLogin')}</TableHead>
|
|
<TableHead>{t('admin.recentUsers.status')}</TableHead>
|
|
<TableHead className="text-right">{t('admin.recentUsers.taskCount')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{users.map((user) => (
|
|
<TableRow key={user.id}>
|
|
<TableCell className="font-medium">{user.email}</TableCell>
|
|
<TableCell>{user.display_name || '-'}</TableCell>
|
|
<TableCell className="text-sm text-gray-600">
|
|
{formatDate(user.created_at)}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-gray-600">
|
|
{formatDate(user.last_login)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={user.is_active ? 'default' : 'secondary'}>
|
|
{user.is_active ? t('common.active') : t('common.inactive')}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div>
|
|
<div className="font-semibold">{user.task_count}</div>
|
|
<div className="text-xs text-gray-500">
|
|
{t('admin.recentUsers.completedCount')}: {user.completed_tasks} | {t('admin.recentUsers.failedCount')}: {user.failed_tasks}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|