From 2a05dfa853abbf32afb334847bed80c4a135ef3b Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 20 Jan 2026 20:18:27 +0100 Subject: [PATCH] feat: implement exoscale maintenance features including folder structure checks, loose file management, and ghost directory listing --- .../hooks/exoscaleMaintenance.ts | 210 +++++++ src/app/admin/dev-management/page.tsx | 575 +++++++++++++++--- 2 files changed, 714 insertions(+), 71 deletions(-) create mode 100644 src/app/admin/dev-management/hooks/exoscaleMaintenance.ts diff --git a/src/app/admin/dev-management/hooks/exoscaleMaintenance.ts b/src/app/admin/dev-management/hooks/exoscaleMaintenance.ts new file mode 100644 index 0000000..2e739c4 --- /dev/null +++ b/src/app/admin/dev-management/hooks/exoscaleMaintenance.ts @@ -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.' }; + } +} diff --git a/src/app/admin/dev-management/page.tsx b/src/app/admin/dev-management/page.tsx index be78e7a..7457a35 100644 --- a/src/app/admin/dev-management/page.tsx +++ b/src/app/admin/dev-management/page.tsx @@ -4,10 +4,11 @@ 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 } 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 { useRouter } from 'next/navigation' import { importSqlDump, SqlExecutionData, SqlExecutionMeta } from './hooks/executeSql' +import { createFolderStructure, listFolderStructureIssues, listLooseFiles, listGhostDirectories, moveLooseFilesToContract, FixResult, FolderStructureIssueUser, GhostDirectory, LooseFileUser } from './hooks/exoscaleMaintenance' export default function DevManagementPage() { const router = useRouter() @@ -46,6 +47,23 @@ export default function DevManagementPage() { const [meta, setMeta] = React.useState(null) const fileInputRef = React.useRef(null) + const [activeTab, setActiveTab] = React.useState<'sql' | 'structure' | 'loose' | 'ghost'>('sql') + const [structureUsers, setStructureUsers] = React.useState([]) + const [structureMeta, setStructureMeta] = React.useState<{ scannedUsers?: number; invalidCount?: number } | null>(null) + const [looseUsers, setLooseUsers] = React.useState([]) + const [looseMeta, setLooseMeta] = React.useState<{ scannedUsers?: number; looseCount?: number } | null>(null) + const [ghostDirs, setGhostDirs] = React.useState([]) + 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(null) + const [fixingAll, setFixingAll] = React.useState(false) + const [fixResults, setFixResults] = React.useState>({}) + const [structureActionMeta, setStructureActionMeta] = React.useState<{ processedUsers?: number; createdTotal?: number; errorCount?: number } | null>(null) + const [structureActionResults, setStructureActionResults] = React.useState([]) + const [looseActionMeta, setLooseActionMeta] = React.useState<{ processedUsers?: number; movedTotal?: number; errorCount?: number } | null>(null) + const [looseActionResults, setLooseActionResults] = React.useState([]) + const runImport = async () => { setError('') if (!selectedFile) { @@ -72,6 +90,108 @@ export default function DevManagementPage() { 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) => { const file = e.target.files?.[0] if (!file) return @@ -111,99 +231,412 @@ export default function DevManagementPage() { -
-
-
-

SQL Dump Import

+
+
+ + + + +
+
- {/* actions: stack on mobile, full width */} -
+ {activeTab === 'sql' && ( + <> +
+
+
+

SQL Dump Import

+ + {/* actions: stack on mobile, full width */} +
+ + + +
+
+ +
+
Select a .sql dump file using Import SQL.
+
Only SQL dump files are supported.
+ {selectedFile && ( +
+ Selected: {selectedFile.name} +
+ )} +
+ + + + {error && ( +
+ {error} +
+ )} +
+ +
+

Result Summary

+
+
+ Result Sets + + {Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0} + +
+
+ Multi-statement + {result?.isMulti ? 'Yes' : 'No'} +
+
+ Duration + {meta?.durationMs ? `${meta.durationMs} ms` : '-'} +
+
+
+ Multi-statement SQL and dump files are supported. Use with caution. +
+
+
+ +
+
+

Import Results

+
+ + {!result && ( +
No results yet. Import a SQL dump to see output.
+ )} + + {result?.result && ( +
+                      {JSON.stringify(result.result, null, 2)}
+                    
+ )} +
+ + )} + + {activeTab === 'structure' && ( +
+
+
+

Exoscale Folder Structure

+

+ Ensures both contract and gdpr folders exist for each user. +

+
+
-
-
-
Select a .sql dump file using Import SQL.
-
Only SQL dump files are supported.
- {selectedFile && ( -
- Selected: {selectedFile.name} -
- )} +
+ + + Scanned: {structureMeta?.scannedUsers ?? '-'} + + + Missing: {structureMeta?.invalidCount ?? structureUsers.length} +
- - - {error && ( -
- {error} + {exoscaleError && ( +
+ {exoscaleError}
)} -
-
-

Result Summary

-
-
- Result Sets - - {Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0} - + {exoscaleLoading ? ( +
Loading folder issues...
+ ) : structureUsers.length === 0 ? ( +
No missing folders found. Run Refresh to scan again.
+ ) : ( +
+ {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 ( +
+
+
{displayName}
+
+ #{user.userId} • {user.email} • {user.userType} • {user.contractCategory} +
+
Missing: {missing || 'none'}
+ {fix && ( +
+ Created {fix.created || 0}{fix.errors && fix.errors.length > 0 ? `, errors ${fix.errors.length}` : ''}. +
+ )} +
+
+ +
+
+ ) + })}
-
- Multi-statement - {result?.isMulti ? 'Yes' : 'No'} + )} + + {(structureActionMeta || structureActionResults.length > 0) && ( +
+
Last Folder Structure Action
+
+ Processed: {structureActionMeta?.processedUsers ?? '-'} + Created: {structureActionMeta?.createdTotal ?? '-'} + Errors: {structureActionMeta?.errorCount ?? '-'} +
+ {structureActionResults.length > 0 && ( +
+ {structureActionResults.map(item => ( +
+ #{item.userId} + Created: {item.created ?? 0} + {item.errors && item.errors.length > 0 && ( + Errors: {item.errors.length} + )} +
+ ))} +
+ )}
-
- Duration - {meta?.durationMs ? `${meta.durationMs} ms` : '-'} + )} +
+ )} + + {activeTab === 'loose' && ( +
+
+
+

Loose Files

+

+ Shows files directly under the user folder that are not in contract or gdpr. +

+
+
+ +
-
- Multi-statement SQL and dump files are supported. Use with caution. + +
+ + + Scanned: {looseMeta?.scannedUsers ?? '-'} + + + Loose: {looseMeta?.looseCount ?? looseUsers.length} +
-
-
-
-
-

Import Results

-
+ {exoscaleError && ( +
+ {exoscaleError} +
+ )} - {!result && ( -
No results yet. Import a SQL dump to see output.
- )} + {exoscaleLoading ? ( +
Loading loose files...
+ ) : looseUsers.length === 0 ? ( +
No loose files found. Run Refresh to scan again.
+ ) : ( +
+ {looseUsers.map(user => { + const fix = fixResults[user.userId] + const displayName = user.name || user.email + return ( +
+
+
{displayName}
+
+ #{user.userId} • {user.email} • {user.userType} • {user.contractCategory} +
+
+ Loose files: {user.looseObjects} +
+ {user.sampleKeys && user.sampleKeys.length > 0 && ( +
+ Sample: {user.sampleKeys.join(', ')} +
+ )} + {fix && ( +
+ Moved {fix.moved}, skipped {fix.skipped}{fix.errors && fix.errors.length > 0 ? `, errors ${fix.errors.length}` : ''}. +
+ )} +
+
+ +
+
+ ) + })} +
+ )} - {result?.result && ( -
-                  {JSON.stringify(result.result, null, 2)}
-                
- )} -
+ {(looseActionMeta || looseActionResults.length > 0) && ( +
+
Last Loose Files Action
+
+ Processed: {looseActionMeta?.processedUsers ?? '-'} + Moved: {looseActionMeta?.movedTotal ?? '-'} + Errors: {looseActionMeta?.errorCount ?? '-'} +
+ {looseActionResults.length > 0 && ( +
+ {looseActionResults.map(item => ( +
+ #{item.userId} + Moved: {item.moved} + Skipped: {item.skipped} + {item.errors && item.errors.length > 0 && ( + Errors: {item.errors.length} + )} +
+ ))} +
+ )} +
+ )} +
+ )} + + {activeTab === 'ghost' && ( +
+
+
+

Ghost Directories

+

+ Exoscale directories that do not have a matching user in the database. +

+
+ +
+ +
+ Ghost directories: {ghostMeta?.ghostCount ?? ghostDirs.length} +
+ + {exoscaleError && ( +
+ {exoscaleError} +
+ )} + + {exoscaleLoading ? ( +
Loading ghost directories...
+ ) : ghostDirs.length === 0 ? ( +
No ghost directories found. Run Refresh to scan again.
+ ) : ( +
+ {ghostDirs.map(dir => ( +
+
+
#{dir.userId}
+
+ {dir.contractCategory} • {dir.basePrefix} +
+
+
+ ))} +
+ )} +
+ )}