feat: add storage cleanup mechanism with soft delete and auto scheduler
- 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>
This commit is contained in:
@@ -7,7 +7,7 @@ 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 } from '@/types/apiV2'
|
||||
import type { SystemStats, UserWithStats, TopUser, TranslationStats, StorageStats } from '@/types/apiV2'
|
||||
import {
|
||||
Users,
|
||||
ClipboardList,
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
Loader2,
|
||||
Languages,
|
||||
Coins,
|
||||
HardDrive,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -41,6 +43,8 @@ export default function AdminDashboardPage() {
|
||||
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('')
|
||||
|
||||
@@ -50,17 +54,19 @@ export default function AdminDashboardPage() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const [statsData, usersData, topUsersData, translationStatsData] = await Promise.all([
|
||||
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'))
|
||||
@@ -80,6 +86,27 @@ export default function AdminDashboardPage() {
|
||||
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">
|
||||
@@ -329,6 +356,104 @@ export default function AdminDashboardPage() {
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user