feat: implement exoscale maintenance features including folder structure checks, loose file management, and ghost directory listing
This commit is contained in:
parent
d279ae6f84
commit
2a05dfa853
210
src/app/admin/dev-management/hooks/exoscaleMaintenance.ts
Normal file
210
src/app/admin/dev-management/hooks/exoscaleMaintenance.ts
Normal 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.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user