735 lines
36 KiB
TypeScript
735 lines
36 KiB
TypeScript
'use client'
|
|
|
|
|
|
|
|
import { useTranslation } from '../../i18n/useTranslation';
|
|
import React from 'react'
|
|
import Header from '../../components/nav/Header'
|
|
import Footer from '../../components/Footer'
|
|
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
|
import { CommandLineIcon, PlayIcon, TrashIcon, ExclamationTriangleIcon, ArrowUpTrayIcon, WrenchScrewdriverIcon, FolderOpenIcon, ArrowPathIcon, ArrowDownTrayIcon } from '@heroicons/react/24/outline'
|
|
import useAuthStore from '../../store/authStore'
|
|
import { useRouter } from 'next/navigation'
|
|
import { importSqlDump, SqlExecutionData, SqlExecutionMeta } from './hooks/executeSql'
|
|
import { createFolderStructure, listFolderStructureIssues, listLooseFiles, listGhostDirectories, moveLooseFilesToContract, downloadExoscaleArchive, FixResult, FolderStructureIssueUser, GhostDirectory, LooseFileUser } from './hooks/exoscaleMaintenance'
|
|
|
|
export default function DevManagementPage() {
|
|
const { t } = useTranslation();
|
|
const router = useRouter()
|
|
const user = useAuthStore(s => s.user)
|
|
|
|
const isAdmin =
|
|
!!user &&
|
|
(
|
|
(user as any)?.role === 'admin' ||
|
|
(user as any)?.userType === 'admin' ||
|
|
(user as any)?.isAdmin === true ||
|
|
((user as any)?.roles?.includes?.('admin')) ||
|
|
(user as any)?.role === 'super_admin' ||
|
|
(user as any)?.userType === 'super_admin' ||
|
|
(user as any)?.isSuperAdmin === true ||
|
|
((user as any)?.roles?.includes?.('super_admin'))
|
|
)
|
|
|
|
const [authChecked, setAuthChecked] = React.useState(false)
|
|
React.useEffect(() => {
|
|
if (user === null) {
|
|
router.replace('/login')
|
|
return
|
|
}
|
|
if (user && !isAdmin) {
|
|
router.replace('/admin')
|
|
return
|
|
}
|
|
setAuthChecked(true)
|
|
}, [user, isAdmin, router])
|
|
|
|
const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
|
|
const [loading, setLoading] = React.useState(false)
|
|
const [error, setError] = React.useState('')
|
|
const [result, setResult] = React.useState<SqlExecutionData | null>(null)
|
|
const [meta, setMeta] = React.useState<SqlExecutionMeta | null>(null)
|
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null)
|
|
|
|
const [activeTab, setActiveTab] = React.useState<'sql' | 'structure' | 'loose' | 'ghost'>('sql')
|
|
const [structureUsers, setStructureUsers] = React.useState<FolderStructureIssueUser[]>([])
|
|
const [structureMeta, setStructureMeta] = React.useState<{ scannedUsers?: number; invalidCount?: number } | null>(null)
|
|
const [looseUsers, setLooseUsers] = React.useState<LooseFileUser[]>([])
|
|
const [looseMeta, setLooseMeta] = React.useState<{ scannedUsers?: number; looseCount?: number } | null>(null)
|
|
const [ghostDirs, setGhostDirs] = React.useState<GhostDirectory[]>([])
|
|
const [ghostMeta, setGhostMeta] = React.useState<{ ghostCount?: number } | null>(null)
|
|
const [exoscaleLoading, setExoscaleLoading] = React.useState(false)
|
|
const [exoscaleError, setExoscaleError] = React.useState('')
|
|
const [fixingUserId, setFixingUserId] = React.useState<number | null>(null)
|
|
const [fixingAll, setFixingAll] = React.useState(false)
|
|
const [fixResults, setFixResults] = React.useState<Record<number, FixResult>>({})
|
|
const [structureActionMeta, setStructureActionMeta] = React.useState<{ processedUsers?: number; createdTotal?: number; errorCount?: number } | null>(null)
|
|
const [structureActionResults, setStructureActionResults] = React.useState<FixResult[]>([])
|
|
const [looseActionMeta, setLooseActionMeta] = React.useState<{ processedUsers?: number; movedTotal?: number; errorCount?: number } | null>(null)
|
|
const [looseActionResults, setLooseActionResults] = React.useState<FixResult[]>([])
|
|
const [structureStatus, setStructureStatus] = React.useState('')
|
|
const [looseStatus, setLooseStatus] = React.useState('')
|
|
const [ghostStatus, setGhostStatus] = React.useState('')
|
|
const [archiveLoading, setArchiveLoading] = React.useState(false)
|
|
const [archiveStatus, setArchiveStatus] = React.useState('')
|
|
|
|
const formatNow = () => new Date().toLocaleString()
|
|
|
|
const runImport = async () => {
|
|
setError('')
|
|
if (!selectedFile) {
|
|
setError('Please select a SQL dump file.')
|
|
return
|
|
}
|
|
setLoading(true)
|
|
try {
|
|
const res = await importSqlDump(selectedFile)
|
|
if (!res.ok) {
|
|
setError(res.message || 'Failed to import SQL dump.')
|
|
return
|
|
}
|
|
setResult(res.data || null)
|
|
setMeta(res.meta || null)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const clearResults = () => {
|
|
setResult(null)
|
|
setMeta(null)
|
|
setError('')
|
|
}
|
|
|
|
const loadStructureIssues = async () => {
|
|
setExoscaleError('')
|
|
setExoscaleLoading(true)
|
|
setStructureStatus('Refreshing list...')
|
|
try {
|
|
const res = await listFolderStructureIssues()
|
|
if (!res.ok) {
|
|
setExoscaleError(res.message || 'Failed to load folder structure issues.')
|
|
return
|
|
}
|
|
setStructureUsers(res.data || [])
|
|
setStructureMeta(res.meta || null)
|
|
setStructureStatus(`Last refresh: ${formatNow()}`)
|
|
} finally {
|
|
setExoscaleLoading(false)
|
|
}
|
|
}
|
|
|
|
const loadLooseFiles = async () => {
|
|
setExoscaleError('')
|
|
setExoscaleLoading(true)
|
|
setLooseStatus('Refreshing list...')
|
|
try {
|
|
const res = await listLooseFiles()
|
|
if (!res.ok) {
|
|
setExoscaleError(res.message || 'Failed to load loose files.')
|
|
return
|
|
}
|
|
setLooseUsers(res.data || [])
|
|
setLooseMeta(res.meta || null)
|
|
setLooseStatus(`Last refresh: ${formatNow()}`)
|
|
} finally {
|
|
setExoscaleLoading(false)
|
|
}
|
|
}
|
|
|
|
const loadGhostDirectories = async () => {
|
|
setExoscaleError('')
|
|
setExoscaleLoading(true)
|
|
setGhostStatus('Refreshing list...')
|
|
try {
|
|
const res = await listGhostDirectories()
|
|
if (!res.ok) {
|
|
setExoscaleError(res.message || 'Failed to load ghost directories.')
|
|
return
|
|
}
|
|
setGhostDirs(res.data || [])
|
|
setGhostMeta(res.meta || null)
|
|
setGhostStatus(`Last refresh: ${formatNow()}`)
|
|
} finally {
|
|
setExoscaleLoading(false)
|
|
}
|
|
}
|
|
|
|
const runArchiveDownload = async () => {
|
|
setExoscaleError('')
|
|
setArchiveLoading(true)
|
|
setArchiveStatus('Preparing ZIP export...')
|
|
try {
|
|
const res = await downloadExoscaleArchive()
|
|
if (!res.ok) {
|
|
setExoscaleError(res.message || 'Failed to download Exoscale archive.')
|
|
setArchiveStatus('')
|
|
return
|
|
}
|
|
setArchiveStatus(`Download started: ${res.fileName || formatNow()}`)
|
|
} finally {
|
|
setArchiveLoading(false)
|
|
}
|
|
}
|
|
|
|
const runCreateStructure = async (userId?: number) => {
|
|
setExoscaleError('')
|
|
if (userId) {
|
|
setFixingUserId(userId)
|
|
setStructureStatus(`Creating folders for #${userId}...`)
|
|
} else {
|
|
setFixingAll(true)
|
|
setStructureStatus('Creating folders for each user...')
|
|
setStructureActionResults([])
|
|
setStructureActionMeta({ processedUsers: 0, createdTotal: 0, errorCount: 0 })
|
|
}
|
|
|
|
try {
|
|
const targets = userId ? [{ userId }] : structureUsers.map(u => ({ userId: u.userId }))
|
|
for (const target of targets) {
|
|
const res = await createFolderStructure(target.userId)
|
|
if (!res.ok) {
|
|
setExoscaleError(res.message || 'Failed to create folder structure.')
|
|
break
|
|
}
|
|
const batch = res.data || []
|
|
setStructureActionResults(prev => [...prev, ...batch])
|
|
setStructureActionMeta(prev => ({
|
|
processedUsers: (prev?.processedUsers || 0) + (res.meta?.processedUsers || 0),
|
|
createdTotal: (prev?.createdTotal || 0) + (res.meta?.createdTotal || 0),
|
|
errorCount: (prev?.errorCount || 0) + (res.meta?.errorCount || 0)
|
|
}))
|
|
setFixResults(prev => {
|
|
const next = { ...prev }
|
|
for (const item of batch) {
|
|
next[item.userId] = item
|
|
}
|
|
return next
|
|
})
|
|
setStructureStatus(`Last create: ${formatNow()} (processed #${target.userId})`)
|
|
}
|
|
await loadStructureIssues()
|
|
} finally {
|
|
setFixingUserId(null)
|
|
setFixingAll(false)
|
|
}
|
|
}
|
|
|
|
const runMoveLooseFiles = async (userId?: number) => {
|
|
setExoscaleError('')
|
|
if (userId) {
|
|
setFixingUserId(userId)
|
|
setLooseStatus(`Moving loose files for #${userId}...`)
|
|
} else {
|
|
setFixingAll(true)
|
|
setLooseStatus('Moving loose files for each user...')
|
|
setLooseActionResults([])
|
|
setLooseActionMeta({ processedUsers: 0, movedTotal: 0, errorCount: 0 })
|
|
}
|
|
|
|
try {
|
|
const targets = userId ? [{ userId }] : looseUsers.map(u => ({ userId: u.userId }))
|
|
for (const target of targets) {
|
|
const res = await moveLooseFilesToContract(target.userId)
|
|
if (!res.ok) {
|
|
setExoscaleError(res.message || 'Failed to move loose files.')
|
|
break
|
|
}
|
|
const batch = res.data || []
|
|
setLooseActionResults(prev => [...prev, ...batch])
|
|
setLooseActionMeta(prev => ({
|
|
processedUsers: (prev?.processedUsers || 0) + (res.meta?.processedUsers || 0),
|
|
movedTotal: (prev?.movedTotal || 0) + (res.meta?.movedTotal || 0),
|
|
errorCount: (prev?.errorCount || 0) + (res.meta?.errorCount || 0)
|
|
}))
|
|
setFixResults(prev => {
|
|
const next = { ...prev }
|
|
for (const item of batch) {
|
|
next[item.userId] = item
|
|
}
|
|
return next
|
|
})
|
|
setLooseStatus(`Last move: ${formatNow()} (processed #${target.userId})`)
|
|
}
|
|
await loadLooseFiles()
|
|
} finally {
|
|
setFixingUserId(null)
|
|
setFixingAll(false)
|
|
}
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
if (activeTab === 'structure') loadStructureIssues()
|
|
if (activeTab === 'loose') loadLooseFiles()
|
|
if (activeTab === 'ghost') loadGhostDirectories()
|
|
}, [activeTab])
|
|
|
|
const onImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
setSelectedFile(file)
|
|
e.target.value = ''
|
|
}
|
|
|
|
if (!authChecked) return null
|
|
|
|
return (
|
|
<PageTransitionEffect>
|
|
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 via-white to-blue-50">
|
|
<Header />
|
|
{/* tighter padding on mobile */}
|
|
<main className="flex-1 py-6 md:py-8 px-4 sm:px-6 lg:px-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
<header className="bg-white/90 backdrop-blur border border-blue-100 py-6 md:py-8 px-4 sm:px-6 rounded-2xl shadow-lg flex flex-col gap-4">
|
|
{/* stack on mobile */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
|
<div className="h-11 w-11 sm:h-12 sm:w-12 rounded-xl bg-blue-100 border border-blue-200 flex items-center justify-center flex-shrink-0">
|
|
<CommandLineIcon className="h-6 w-6 text-blue-700" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-blue-900">{t('autofix.k664072a1')}</h1>
|
|
<p className="text-sm sm:text-base text-blue-700">{t('autofix.k6e4a6069')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 flex items-start gap-2">
|
|
<ExclamationTriangleIcon className="h-5 w-5 mt-0.5 flex-shrink-0" />
|
|
<div>
|
|
<div className="font-semibold">{t('autofix.k6c6e5c0f')}</div>
|
|
<div>{t('autofix.k8a35cc53')}</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="mt-6">
|
|
<section className="mb-6 rounded-2xl border border-blue-100 bg-white/90 p-4 shadow-lg">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-blue-900">Exoscale Backup Export</h2>
|
|
<p className="text-sm text-gray-600">Ladet alle Dateien aus dem Exoscale Storage und liefert sie als ZIP-Download aus.</p>
|
|
{archiveStatus && (
|
|
<div className="mt-1 text-xs text-slate-500">{archiveStatus}</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={runArchiveDownload}
|
|
disabled={archiveLoading}
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
|
|
>
|
|
<ArrowDownTrayIcon className="h-4 w-4" /> {archiveLoading ? 'Creating ZIP...' : 'Download Exoscale ZIP'}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="inline-flex w-full sm:w-auto rounded-xl border border-blue-100 bg-white/90 p-1 shadow-sm">
|
|
<button
|
|
onClick={() => setActiveTab('sql')}
|
|
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
|
activeTab === 'sql' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
|
}`}
|
|
>
|
|
<CommandLineIcon className="h-4 w-4" />{t('autofix.k4db68c96')}</button>
|
|
<button
|
|
onClick={() => setActiveTab('structure')}
|
|
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
|
activeTab === 'structure' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
|
}`}
|
|
>
|
|
<WrenchScrewdriverIcon className="h-4 w-4" />{t('autofix.kcb491706')}</button>
|
|
<button
|
|
onClick={() => setActiveTab('loose')}
|
|
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
|
activeTab === 'loose' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
|
}`}
|
|
>
|
|
<FolderOpenIcon className="h-4 w-4" />{t('autofix.k04b5cbca')}</button>
|
|
<button
|
|
onClick={() => setActiveTab('ghost')}
|
|
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
|
activeTab === 'ghost' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
|
}`}
|
|
>
|
|
<FolderOpenIcon className="h-4 w-4" />{t('autofix.k6838438d')}</button>
|
|
</div>
|
|
</div>
|
|
|
|
{activeTab === 'sql' && (
|
|
<>
|
|
<section className="mt-6 md:mt-8 grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
|
|
<div className="lg:col-span-2 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k981b1f1a')}</h2>
|
|
|
|
{/* actions: stack on mobile, full width */}
|
|
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-2 sm:gap-3">
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
|
>
|
|
<ArrowUpTrayIcon className="h-4 w-4" />{t('autofix.k8a59b156')}</button>
|
|
<button
|
|
onClick={clearResults}
|
|
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
|
>
|
|
<TrashIcon className="h-4 w-4" /> Clear
|
|
</button>
|
|
<button
|
|
onClick={runImport}
|
|
disabled={loading}
|
|
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
|
|
>
|
|
<PlayIcon className="h-4 w-4" /> {loading ? 'Importing...' : 'Import'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-dashed border-gray-200 bg-slate-50 px-4 sm:px-6 py-8 sm:py-10 text-center">
|
|
<div className="text-sm text-gray-600">{t('autofix.kb6eacc9d')}</div>
|
|
<div className="mt-2 text-xs text-gray-500">{t('autofix.k3ac8ca10')}</div>
|
|
{selectedFile && (
|
|
<div className="mt-4 text-sm text-blue-900 font-semibold break-words">
|
|
Selected: {selectedFile.name}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".sql,text/plain"
|
|
className="hidden"
|
|
onChange={onImportFile}
|
|
/>
|
|
|
|
{error && (
|
|
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
|
<h3 className="text-lg font-semibold text-blue-900 mb-3">{t('autofix.kde2b4fa0')}</h3>
|
|
<div className="space-y-2 text-sm text-gray-700">
|
|
<div className="flex justify-between">
|
|
<span>{t('autofix.k7938d4fd')}</span>
|
|
<span className="font-semibold text-blue-900">
|
|
{Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Multi-statement</span>
|
|
<span className="font-semibold text-blue-900">{result?.isMulti ? 'Yes' : 'No'}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Duration</span>
|
|
<span className="font-semibold text-blue-900">{meta?.durationMs ? `${meta.durationMs} ms` : '-'}</span>
|
|
</div>
|
|
</div>
|
|
<div className="mt-6 text-xs text-gray-500">{t('autofix.k0f0395ca')}</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.kb4675362')}</h2>
|
|
</div>
|
|
|
|
{!result && (
|
|
<div className="text-sm text-gray-500">{t('autofix.k23c9f0ff')}</div>
|
|
)}
|
|
|
|
{result?.result && (
|
|
<pre className="mt-2 rounded-lg bg-slate-50 border border-gray-200 p-3 md:p-4 text-xs text-gray-800 overflow-auto">
|
|
{JSON.stringify(result.result, null, 2)}
|
|
</pre>
|
|
)}
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'structure' && (
|
|
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
|
<div className="space-y-1">
|
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.kd51f320c')}</h2>
|
|
<p className="text-sm text-gray-600">{t('autofix.kb1341138')}</p>
|
|
{structureStatus && (
|
|
<div className="text-xs text-slate-500">{structureStatus}</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
|
<button
|
|
onClick={loadStructureIssues}
|
|
disabled={exoscaleLoading}
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
|
>
|
|
<ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
|
|
</button>
|
|
<button
|
|
onClick={() => runCreateStructure()}
|
|
disabled={fixingAll || exoscaleLoading || structureUsers.length === 0}
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
|
|
>
|
|
<WrenchScrewdriverIcon className="h-4 w-4" />{fixingAll ? 'Creating...' : t('autofix.k1db77fc0')}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4 flex flex-wrap gap-3 text-xs text-gray-600">
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
|
|
<FolderOpenIcon className="h-4 w-4 text-slate-500" />
|
|
Scanned: {structureMeta?.scannedUsers ?? '-'}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
|
|
Missing: {structureMeta?.invalidCount ?? structureUsers.length}
|
|
</span>
|
|
</div>
|
|
|
|
{exoscaleError && (
|
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
{exoscaleError}
|
|
</div>
|
|
)}
|
|
|
|
{exoscaleLoading ? (
|
|
<div className="text-sm text-gray-500">{t('autofix.k8358f1d1')}</div>
|
|
) : structureUsers.length === 0 ? (
|
|
<div className="text-sm text-gray-500">{t('autofix.k9e609523')}</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
|
|
{structureUsers.map(user => {
|
|
const fix = fixResults[user.userId]
|
|
const displayName = user.name || user.email
|
|
const missing = [!user.hasContractFolder ? 'contract' : null, !user.hasGdprFolder ? 'gdpr' : null].filter(Boolean).join(', ')
|
|
return (
|
|
<div key={user.userId} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="font-semibold text-blue-900 truncate">{displayName}</div>
|
|
<div className="text-xs text-gray-500 truncate">
|
|
#{user.userId} • {user.email} • {user.userType} • {user.contractCategory}
|
|
</div>
|
|
<div className="mt-1 text-xs text-gray-600">{t('autofix.kd058bb7b')}<span className="font-semibold text-blue-900">{missing || 'none'}</span></div>
|
|
{fix && (
|
|
<div className="mt-2 text-xs text-emerald-700">
|
|
Created {fix.created || 0}{fix.errors && fix.errors.length > 0 ? `, errors ${fix.errors.length}` : ''}.
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => runCreateStructure(user.userId)}
|
|
disabled={fixingUserId === user.userId || fixingAll}
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
|
>
|
|
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingUserId === user.userId ? 'Creating...' : 'Create'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{(structureActionMeta || structureActionResults.length > 0) && (
|
|
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
|
<div className="text-sm font-semibold text-blue-900 mb-2">{t('autofix.k941fd092')}</div>
|
|
<div className="text-xs text-gray-600 flex flex-wrap gap-3">
|
|
<span>Processed: {structureActionMeta?.processedUsers ?? '-'}</span>
|
|
<span>Created: {structureActionMeta?.createdTotal ?? '-'}</span>
|
|
<span>Errors: {structureActionMeta?.errorCount ?? '-'}</span>
|
|
</div>
|
|
{structureActionResults.length > 0 && (
|
|
<div className="mt-3 space-y-2 text-xs text-gray-700">
|
|
{structureActionResults.map(item => (
|
|
<div key={item.userId} className="flex flex-wrap gap-2">
|
|
<span className="font-semibold text-blue-900">#{item.userId}</span>
|
|
{item.contractCategory && (
|
|
<span className="text-gray-500">{item.contractCategory}</span>
|
|
)}
|
|
<span>Created: {item.created ?? 0}</span>
|
|
{item.errors && item.errors.length > 0 && (
|
|
<span className="text-red-700">Errors: {item.errors.length}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{activeTab === 'loose' && (
|
|
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
|
<div className="space-y-1">
|
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k04b5cbca')}</h2>
|
|
<p className="text-sm text-gray-600">{t('autofix.kbff01823')}</p>
|
|
{looseStatus && (
|
|
<div className="text-xs text-slate-500">{looseStatus}</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
|
<button
|
|
onClick={loadLooseFiles}
|
|
disabled={exoscaleLoading}
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
|
>
|
|
<ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
|
|
</button>
|
|
<button
|
|
onClick={() => runMoveLooseFiles()}
|
|
disabled={fixingAll || exoscaleLoading || looseUsers.length === 0}
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
|
|
>
|
|
<WrenchScrewdriverIcon className="h-4 w-4" />{fixingAll ? 'Moving...' : t('autofix.k0188c7bc')}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4 flex flex-wrap gap-3 text-xs text-gray-600">
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
|
|
<FolderOpenIcon className="h-4 w-4 text-slate-500" />
|
|
Scanned: {looseMeta?.scannedUsers ?? '-'}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
|
|
Loose: {looseMeta?.looseCount ?? looseUsers.length}
|
|
</span>
|
|
</div>
|
|
|
|
{exoscaleError && (
|
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
{exoscaleError}
|
|
</div>
|
|
)}
|
|
|
|
{exoscaleLoading ? (
|
|
<div className="text-sm text-gray-500">{t('autofix.k8193b7a2')}</div>
|
|
) : looseUsers.length === 0 ? (
|
|
<div className="text-sm text-gray-500">{t('autofix.k1db0c7cd')}</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
|
|
{looseUsers.map(user => {
|
|
const fix = fixResults[user.userId]
|
|
const displayName = user.name || user.email
|
|
return (
|
|
<div key={user.userId} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="font-semibold text-blue-900 truncate">{displayName}</div>
|
|
<div className="text-xs text-gray-500 truncate">
|
|
#{user.userId} • {user.email} • {user.userType} • {user.contractCategory}
|
|
</div>
|
|
<div className="mt-1 text-xs text-gray-600">{t('autofix.kf340aa10')}<span className="font-semibold text-blue-900">{user.looseObjects}</span>
|
|
</div>
|
|
{user.sampleKeys && user.sampleKeys.length > 0 && (
|
|
<div className="mt-2 text-[11px] text-gray-400 break-all">
|
|
Sample: {user.sampleKeys.join(', ')}
|
|
</div>
|
|
)}
|
|
{fix && (
|
|
<div className="mt-2 text-xs text-emerald-700">
|
|
Moved {fix.moved}, skipped {fix.skipped}{fix.errors && fix.errors.length > 0 ? `, errors ${fix.errors.length}` : ''}.
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => runMoveLooseFiles(user.userId)}
|
|
disabled={fixingUserId === user.userId || fixingAll}
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
|
>
|
|
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingUserId === user.userId ? 'Moving...' : 'Move'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{(looseActionMeta || looseActionResults.length > 0) && (
|
|
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
|
<div className="text-sm font-semibold text-blue-900 mb-2">{t('autofix.kcf61fc9e')}</div>
|
|
<div className="text-xs text-gray-600 flex flex-wrap gap-3">
|
|
<span>Processed: {looseActionMeta?.processedUsers ?? '-'}</span>
|
|
<span>Moved: {looseActionMeta?.movedTotal ?? '-'}</span>
|
|
<span>Errors: {looseActionMeta?.errorCount ?? '-'}</span>
|
|
</div>
|
|
{looseActionResults.length > 0 && (
|
|
<div className="mt-3 space-y-2 text-xs text-gray-700">
|
|
{looseActionResults.map(item => (
|
|
<div key={item.userId} className="flex flex-wrap gap-2">
|
|
<span className="font-semibold text-blue-900">#{item.userId}</span>
|
|
{item.contractCategory && (
|
|
<span className="text-gray-500">{item.contractCategory}</span>
|
|
)}
|
|
<span>Moved: {item.moved}</span>
|
|
<span>Skipped: {item.skipped}</span>
|
|
{item.errors && item.errors.length > 0 && (
|
|
<span className="text-red-700">Errors: {item.errors.length}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{activeTab === 'ghost' && (
|
|
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
|
<div className="space-y-1">
|
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k6838438d')}</h2>
|
|
<p className="text-sm text-gray-600">{t('autofix.k77444d5b')}</p>
|
|
{ghostStatus && (
|
|
<div className="text-xs text-slate-500">{ghostStatus}</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={loadGhostDirectories}
|
|
disabled={exoscaleLoading}
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
|
>
|
|
<ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mb-4 text-xs text-gray-600">
|
|
Ghost directories: {ghostMeta?.ghostCount ?? ghostDirs.length}
|
|
</div>
|
|
|
|
{exoscaleError && (
|
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
{exoscaleError}
|
|
</div>
|
|
)}
|
|
|
|
{exoscaleLoading ? (
|
|
<div className="text-sm text-gray-500">{t('autofix.k883ea8c5')}</div>
|
|
) : ghostDirs.length === 0 ? (
|
|
<div className="text-sm text-gray-500">{t('autofix.k12a7170a')}</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
|
|
{ghostDirs.map(dir => (
|
|
<div key={`${dir.contractCategory}-${dir.userId}`} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="font-semibold text-blue-900 truncate">#{dir.userId}</div>
|
|
<div className="text-xs text-gray-500 truncate">
|
|
{dir.contractCategory} • {dir.basePrefix}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
</div>
|
|
</main>
|
|
<Footer />
|
|
</div>
|
|
</PageTransitionEffect>
|
|
)
|
|
}
|