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,6 +231,45 @@ export default function DevManagementPage() {
</div> </div>
</header> </header>
<div className="mt-6">
<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" /> 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>
{activeTab === 'sql' && (
<>
<section className="mt-6 md:mt-8 grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6"> <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="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"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
@ -204,6 +363,280 @@ export default function DevManagementPage() {
</pre> </pre>
)} )}
</section> </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
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...' : 'Create All'}
</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">Loading folder issues...</div>
) : structureUsers.length === 0 ? (
<div className="text-sm text-gray-500">No missing folders found. Run Refresh to scan again.</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">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>
)}
{(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>
)}
</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">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 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">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>
)}
{(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">Last Loose Files Action</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>
<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 />