feat: implement exoscale maintenance features including folder structure checks, loose file management, and ghost directory listing

This commit is contained in:
seaznCode 2026-01-20 20:18:27 +01:00
parent d279ae6f84
commit 2a05dfa853
2 changed files with 714 additions and 71 deletions

View File

@ -0,0 +1,210 @@
import { authFetch } from '../../../utils/authFetch';
import { log } from '../../../utils/logger';
export type FolderStructureIssueUser = {
userId: number;
email: string;
userType: string;
name?: string | null;
contractCategory: string;
basePrefix: string;
totalObjects: number;
hasContractFolder: boolean;
hasGdprFolder: boolean;
};
export type LooseFileUser = {
userId: number;
email: string;
userType: string;
name?: string | null;
contractCategory: string;
basePrefix: string;
looseObjects: number;
sampleKeys?: string[];
};
export type GhostDirectory = {
userId: number;
contractCategory: string;
basePrefix: string;
};
export type FixResult = {
userId: number;
created?: number;
moved: number;
skipped: number;
errors?: { key: string; destKey?: string; message?: string }[];
};
export type FixMeta = {
processedUsers?: number;
movedTotal?: number;
errorCount?: number;
};
export async function listFolderStructureIssues(): Promise<{ ok: boolean; data?: FolderStructureIssueUser[]; meta?: any; message?: string }> {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/dev/exoscale/folder-structure-issues`;
log('🧪 Dev Exoscale: GET', url);
try {
const res = await authFetch(url, { method: 'GET', headers: { Accept: 'application/json' } });
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = null;
}
if (res.status === 401) {
return { ok: false, message: 'Unauthorized. Please log in.' };
}
if (res.status === 403) {
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
}
if (!res.ok) {
return { ok: false, message: body?.error || 'Failed to load folder structure issues.' };
}
return { ok: true, data: body?.data || [], meta: body?.meta };
} catch (e: any) {
log('❌ Dev Exoscale: network error', e?.message || e);
return { ok: false, message: 'Network error while loading invalid folders.' };
}
}
export async function createFolderStructure(userId?: number): Promise<{ ok: boolean; data?: FixResult[]; meta?: FixMeta; message?: string }> {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/dev/exoscale/create-folder-structure`;
log('🧪 Dev Exoscale: POST', url);
try {
const res = await authFetch(url, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(userId ? { userId } : {})
});
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = null;
}
if (res.status === 401) {
return { ok: false, message: 'Unauthorized. Please log in.' };
}
if (res.status === 403) {
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
}
if (!res.ok) {
return { ok: false, message: body?.error || 'Failed to create folder structure.' };
}
return { ok: true, data: body?.data || [], meta: body?.meta };
} catch (e: any) {
log('❌ Dev Exoscale: network error', e?.message || e);
return { ok: false, message: 'Network error while creating folder structure.' };
}
}
export async function listLooseFiles(): Promise<{ ok: boolean; data?: LooseFileUser[]; meta?: any; message?: string }> {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/dev/exoscale/loose-files`;
log('🧪 Dev Exoscale: GET', url);
try {
const res = await authFetch(url, { method: 'GET', headers: { Accept: 'application/json' } });
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = null;
}
if (res.status === 401) {
return { ok: false, message: 'Unauthorized. Please log in.' };
}
if (res.status === 403) {
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
}
if (!res.ok) {
return { ok: false, message: body?.error || 'Failed to load loose files.' };
}
return { ok: true, data: body?.data || [], meta: body?.meta };
} catch (e: any) {
log('❌ Dev Exoscale: network error', e?.message || e);
return { ok: false, message: 'Network error while loading loose files.' };
}
}
export async function moveLooseFilesToContract(userId?: number): Promise<{ ok: boolean; data?: FixResult[]; meta?: FixMeta; message?: string }> {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/dev/exoscale/move-loose-files`;
log('🧪 Dev Exoscale: POST', url);
try {
const res = await authFetch(url, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(userId ? { userId } : {})
});
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = null;
}
if (res.status === 401) {
return { ok: false, message: 'Unauthorized. Please log in.' };
}
if (res.status === 403) {
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
}
if (!res.ok) {
return { ok: false, message: body?.error || 'Failed to move loose files.' };
}
return { ok: true, data: body?.data || [], meta: body?.meta };
} catch (e: any) {
log('❌ Dev Exoscale: network error', e?.message || e);
return { ok: false, message: 'Network error while moving loose files.' };
}
}
export async function listGhostDirectories(): Promise<{ ok: boolean; data?: GhostDirectory[]; meta?: any; message?: string }> {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/dev/exoscale/ghost-directories`;
log('🧪 Dev Exoscale: GET', url);
try {
const res = await authFetch(url, { method: 'GET', headers: { Accept: 'application/json' } });
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = null;
}
if (res.status === 401) {
return { ok: false, message: 'Unauthorized. Please log in.' };
}
if (res.status === 403) {
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
}
if (!res.ok) {
return { ok: false, message: body?.error || 'Failed to load ghost directories.' };
}
return { ok: true, data: body?.data || [], meta: body?.meta };
} catch (e: any) {
log('❌ Dev Exoscale: network error', e?.message || e);
return { ok: false, message: 'Network error while loading ghost directories.' };
}
}

View File

@ -4,10 +4,11 @@ import React from 'react'
import Header from '../../components/nav/Header' import Header from '../../components/nav/Header'
import Footer from '../../components/Footer' import Footer from '../../components/Footer'
import PageTransitionEffect from '../../components/animation/pageTransitionEffect' import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
import { CommandLineIcon, PlayIcon, TrashIcon, ExclamationTriangleIcon, ArrowUpTrayIcon } from '@heroicons/react/24/outline' import { CommandLineIcon, PlayIcon, TrashIcon, ExclamationTriangleIcon, ArrowUpTrayIcon, WrenchScrewdriverIcon, FolderOpenIcon, ArrowPathIcon } from '@heroicons/react/24/outline'
import useAuthStore from '../../store/authStore' import useAuthStore from '../../store/authStore'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { importSqlDump, SqlExecutionData, SqlExecutionMeta } from './hooks/executeSql' import { importSqlDump, SqlExecutionData, SqlExecutionMeta } from './hooks/executeSql'
import { createFolderStructure, listFolderStructureIssues, listLooseFiles, listGhostDirectories, moveLooseFilesToContract, FixResult, FolderStructureIssueUser, GhostDirectory, LooseFileUser } from './hooks/exoscaleMaintenance'
export default function DevManagementPage() { export default function DevManagementPage() {
const router = useRouter() const router = useRouter()
@ -46,6 +47,23 @@ export default function DevManagementPage() {
const [meta, setMeta] = React.useState<SqlExecutionMeta | null>(null) const [meta, setMeta] = React.useState<SqlExecutionMeta | null>(null)
const fileInputRef = React.useRef<HTMLInputElement | 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 runImport = async () => { const runImport = async () => {
setError('') setError('')
if (!selectedFile) { if (!selectedFile) {
@ -72,6 +90,108 @@ export default function DevManagementPage() {
setError('') setError('')
} }
const loadStructureIssues = async () => {
setExoscaleError('')
setExoscaleLoading(true)
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)
} finally {
setExoscaleLoading(false)
}
}
const loadLooseFiles = async () => {
setExoscaleError('')
setExoscaleLoading(true)
try {
const res = await listLooseFiles()
if (!res.ok) {
setExoscaleError(res.message || 'Failed to load loose files.')
return
}
setLooseUsers(res.data || [])
setLooseMeta(res.meta || null)
} finally {
setExoscaleLoading(false)
}
}
const loadGhostDirectories = async () => {
setExoscaleError('')
setExoscaleLoading(true)
try {
const res = await listGhostDirectories()
if (!res.ok) {
setExoscaleError(res.message || 'Failed to load ghost directories.')
return
}
setGhostDirs(res.data || [])
setGhostMeta(res.meta || null)
} finally {
setExoscaleLoading(false)
}
}
const runCreateStructure = async (userId?: number) => {
setExoscaleError('')
if (userId) setFixingUserId(userId)
else setFixingAll(true)
try {
const res = await createFolderStructure(userId)
if (!res.ok) {
setExoscaleError(res.message || 'Failed to create folder structure.')
return
}
setStructureActionMeta(res.meta || null)
setStructureActionResults(res.data || [])
setFixResults(prev => {
const next = { ...prev }
for (const item of res.data || []) {
next[item.userId] = item
}
return next
})
await loadStructureIssues()
} finally {
setFixingUserId(null)
setFixingAll(false)
}
}
const runMoveLooseFiles = async (userId?: number) => {
setExoscaleError('')
if (userId) setFixingUserId(userId)
else setFixingAll(true)
try {
const res = await moveLooseFilesToContract(userId)
if (!res.ok) {
setExoscaleError(res.message || 'Failed to move loose files.')
return
}
setLooseActionMeta(res.meta || null)
setLooseActionResults(res.data || [])
setFixResults(prev => {
const next = { ...prev }
for (const item of res.data || []) {
next[item.userId] = item
}
return next
})
await loadLooseFiles()
} finally {
setFixingUserId(null)
setFixingAll(false)
}
}
const onImportFile = (e: React.ChangeEvent<HTMLInputElement>) => { const onImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
@ -111,99 +231,412 @@ export default function DevManagementPage() {
</div> </div>
</header> </header>
<section className="mt-6 md:mt-8 grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6"> <div className="mt-6">
<div className="lg:col-span-2 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6"> <div className="inline-flex w-full sm:w-auto rounded-xl border border-blue-100 bg-white/90 p-1 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4"> <button
<h2 className="text-lg font-semibold text-blue-900">SQL Dump Import</h2> 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" /> SQL Import
</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" /> Folder Structure
</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" /> Loose Files
</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" /> Ghost Directories
</button>
</div>
</div>
{/* actions: stack on mobile, full width */} {activeTab === 'sql' && (
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-2 sm:gap-3"> <>
<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">SQL Dump Import</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" /> Import SQL
</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">Select a .sql dump file using Import SQL.</div>
<div className="mt-2 text-xs text-gray-500">Only SQL dump files are supported.</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">Result Summary</h3>
<div className="space-y-2 text-sm text-gray-700">
<div className="flex justify-between">
<span>Result Sets</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">
Multi-statement SQL and dump files are supported. Use with caution.
</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">Import Results</h2>
</div>
{!result && (
<div className="text-sm text-gray-500">No results yet. Import a SQL dump to see output.</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">Exoscale Folder Structure</h2>
<p className="text-sm text-gray-600">
Ensures both contract and gdpr folders exist for each user.
</p>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<button <button
onClick={() => fileInputRef.current?.click()} onClick={loadStructureIssues}
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" 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"
> >
<ArrowUpTrayIcon className="h-4 w-4" /> Import SQL <ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
</button> </button>
<button <button
onClick={clearResults} onClick={() => runCreateStructure()}
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" 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"
> >
<TrashIcon className="h-4 w-4" /> Clear <WrenchScrewdriverIcon className="h-4 w-4" /> {fixingAll ? 'Creating...' : 'Create All'}
</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> </button>
</div> </div>
</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="mb-4 flex flex-wrap gap-3 text-xs text-gray-600">
<div className="text-sm text-gray-600">Select a .sql dump file using Import SQL.</div> <span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
<div className="mt-2 text-xs text-gray-500">Only SQL dump files are supported.</div> <FolderOpenIcon className="h-4 w-4 text-slate-500" />
{selectedFile && ( Scanned: {structureMeta?.scannedUsers ?? '-'}
<div className="mt-4 text-sm text-blue-900 font-semibold break-words"> </span>
Selected: {selectedFile.name} <span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
</div> Missing: {structureMeta?.invalidCount ?? structureUsers.length}
)} </span>
</div> </div>
<input {exoscaleError && (
ref={fileInputRef} <div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
type="file" {exoscaleError}
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>
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6"> {exoscaleLoading ? (
<h3 className="text-lg font-semibold text-blue-900 mb-3">Result Summary</h3> <div className="text-sm text-gray-500">Loading folder issues...</div>
<div className="space-y-2 text-sm text-gray-700"> ) : structureUsers.length === 0 ? (
<div className="flex justify-between"> <div className="text-sm text-gray-500">No missing folders found. Run Refresh to scan again.</div>
<span>Result Sets</span> ) : (
<span className="font-semibold text-blue-900"> <div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
{Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0} {structureUsers.map(user => {
</span> 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">Missing: <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> </div>
<div className="flex justify-between"> )}
<span>Multi-statement</span>
<span className="font-semibold text-blue-900">{result?.isMulti ? 'Yes' : 'No'}</span> {(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">Last Folder Structure Action</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>
<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> </div>
<div className="flex justify-between"> )}
<span>Duration</span> </section>
<span className="font-semibold text-blue-900">{meta?.durationMs ? `${meta.durationMs} ms` : '-'}</span> )}
{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">Loose Files</h2>
<p className="text-sm text-gray-600">
Shows files directly under the user folder that are not in contract or gdpr.
</p>
</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...' : 'Move All to Contract'}
</button>
</div> </div>
</div> </div>
<div className="mt-6 text-xs text-gray-500">
Multi-statement SQL and dump files are supported. Use with caution. <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> </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"> {exoscaleError && (
<div className="flex items-center justify-between mb-4"> <div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<h2 className="text-lg font-semibold text-blue-900">Import Results</h2> {exoscaleError}
</div> </div>
)}
{!result && ( {exoscaleLoading ? (
<div className="text-sm text-gray-500">No results yet. Import a SQL dump to see output.</div> <div className="text-sm text-gray-500">Loading loose files...</div>
)} ) : looseUsers.length === 0 ? (
<div className="text-sm text-gray-500">No loose files found. Run Refresh to scan again.</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">
Loose files: <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>
)}
{result?.result && ( {(looseActionMeta || looseActionResults.length > 0) && (
<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"> <div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
{JSON.stringify(result.result, null, 2)} <div className="text-sm font-semibold text-blue-900 mb-2">Last Loose Files Action</div>
</pre> <div className="text-xs text-gray-600 flex flex-wrap gap-3">
)} <span>Processed: {looseActionMeta?.processedUsers ?? '-'}</span>
</section> <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>
<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">Ghost Directories</h2>
<p className="text-sm text-gray-600">
Exoscale directories that do not have a matching user in the database.
</p>
</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">Loading ghost directories...</div>
) : ghostDirs.length === 0 ? (
<div className="text-sm text-gray-500">No ghost directories found. Run Refresh to scan again.</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> </div>
</main> </main>
<Footer /> <Footer />