From c322eb1e5abfc57b5012bd993d5d180c94bc91e4 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 7 Jan 2026 17:09:54 +0100 Subject: [PATCH] Add NPC creation status tracking and progress reporting in Admin module - Implemented getNPCsCreationStatus method in AdminController to retrieve the status of NPC creation jobs. - Enhanced AdminService to manage NPC creation jobs, including job ID generation, progress updates, and error handling. - Updated frontend CreateNPCView to display progress of NPC creation, including estimated time remaining and job status. - Added localization strings for progress reporting in both German and English. - Improved overall user experience by providing real-time feedback during NPC creation processes. --- backend/controllers/adminController.js | 22 ++- backend/routers/adminRouter.js | 1 + backend/services/adminService.js | 96 ++++++++++-- backend/services/npcCreationJobService.js | 86 +++++++++++ frontend/src/components/AppContent.vue | 12 +- frontend/src/i18n/locales/de/admin.json | 8 +- frontend/src/i18n/locales/en/admin.json | 8 +- .../views/admin/falukant/CreateNPCView.vue | 139 +++++++++++++++++- 8 files changed, 347 insertions(+), 25 deletions(-) create mode 100644 backend/services/npcCreationJobService.js diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 2b722d9..9329e7a 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -45,6 +45,7 @@ class AdminController { this.deleteRegionDistance = this.deleteRegionDistance.bind(this); this.createNPCs = this.createNPCs.bind(this); this.getTitlesOfNobility = this.getTitlesOfNobility.bind(this); + this.getNPCsCreationStatus = this.getNPCsCreationStatus.bind(this); } async getOpenInterests(req, res) { @@ -393,6 +394,7 @@ class AdminController { if (countValue < 1 || countValue > 500) { return res.status(400).json({ error: 'Count must be between 1 and 500' }); } + console.log('[createNPCs] Request received:', { userId, regionIds, minAge, maxAge, minTitleId, maxTitleId, count: countValue }); const result = await AdminService.createNPCs(userId, { regionIds: regionIds && regionIds.length > 0 ? regionIds : null, minAge: parseInt(minAge) || 0, @@ -401,11 +403,13 @@ class AdminController { maxTitleId: parseInt(maxTitleId) || 19, count: countValue }); + console.log('[createNPCs] Job created:', result); res.status(200).json(result); } catch (error) { - console.log(error); + console.error('[createNPCs] Error:', error); + console.error('[createNPCs] Error stack:', error.stack); const status = error.message === 'noaccess' ? 403 : 500; - res.status(status).json({ error: error.message }); + res.status(status).json({ error: error.message || 'Internal server error' }); } } @@ -421,6 +425,20 @@ class AdminController { } } + async getNPCsCreationStatus(req, res) { + try { + const { userid: userId } = req.headers; + const { jobId } = req.params; + const status = await AdminService.getNPCsCreationStatus(userId, jobId); + res.status(200).json(status); + } catch (error) { + console.log(error); + const status = error.message === 'noaccess' || error.message === 'Access denied' ? 403 : + error.message === 'Job not found' ? 404 : 500; + res.status(status).json({ error: error.message }); + } + } + async getRoomTypes(req, res) { try { const userId = req.headers.userid; diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index af99583..6964f8f 100644 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -47,6 +47,7 @@ router.get('/falukant/region-distances', authenticate, adminController.getRegion router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance); router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance); router.post('/falukant/npcs/create', authenticate, adminController.createNPCs); +router.get('/falukant/npcs/status/:jobId', authenticate, adminController.getNPCsCreationStatus); router.get('/falukant/titles', authenticate, adminController.getTitlesOfNobility); // --- Minigames Admin --- diff --git a/backend/services/adminService.js b/backend/services/adminService.js index 44e067d..e1f190e 100644 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -26,6 +26,8 @@ import Room from '../models/chat/room.js'; import UserParam from '../models/community/user_param.js'; import TitleOfNobility from "../models/falukant/type/title_of_nobility.js"; import { sequelize } from '../utils/sequelize.js'; +import npcCreationJobService from './npcCreationJobService.js'; +import { v4 as uuidv4 } from 'uuid'; class AdminService { async hasUserAccess(userId, section) { @@ -1113,7 +1115,7 @@ class AdminService { count // Anzahl der zu erstellenden NPCs } = options; - // Hole alle Städte, wenn keine spezifischen Regionen angegeben + // Berechne zuerst die Gesamtanzahl, um den Job richtig zu initialisieren let targetRegions = []; if (regionIds && regionIds.length > 0) { targetRegions = await RegionData.findAll({ @@ -1140,7 +1142,6 @@ class AdminService { throw new Error('No cities found'); } - // Hole alle Titles im Bereich const titles = await TitleOfNobility.findAll({ where: { id: { @@ -1154,13 +1155,56 @@ class AdminService { throw new Error('No titles found in specified range'); } - const genders = ['male', 'female']; - const createdNPCs = []; const totalNPCs = targetRegions.length * titles.length * count; - // Erstelle NPCs in einer Transaktion - // Für jede Stadt-Titel-Kombination wird die angegebene Anzahl erstellt - await sequelize.transaction(async (t) => { + // Erstelle Job-ID + const jobId = uuidv4(); + npcCreationJobService.createJob(userId, jobId); + npcCreationJobService.updateProgress(jobId, 0, totalNPCs); + npcCreationJobService.setStatus(jobId, 'running'); + + // Starte asynchronen Prozess + this._createNPCsAsync(jobId, userId, { + regionIds, + minAge, + maxAge, + minTitleId, + maxTitleId, + count, + targetRegions, + titles + }).catch(error => { + console.error('Error in _createNPCsAsync:', error); + const errorMessage = error?.message || error?.toString() || 'Unknown error occurred'; + npcCreationJobService.setError(jobId, errorMessage); + }); + + return { jobId }; + } + + async _createNPCsAsync(jobId, userId, options) { + try { + const { + regionIds, + minAge, + maxAge, + minTitleId, + maxTitleId, + count, + targetRegions, + titles + } = options; + + const genders = ['male', 'female']; + const createdNPCs = []; + const totalNPCs = targetRegions.length * titles.length * count; + let currentNPC = 0; + + console.log(`[NPC Creation Job ${jobId}] Starting creation of ${totalNPCs} NPCs`); + + // Erstelle NPCs in einer Transaktion + // Für jede Stadt-Titel-Kombination wird die angegebene Anzahl erstellt + await sequelize.transaction(async (t) => { for (const region of targetRegions) { for (const title of titles) { // Erstelle 'count' NPCs für diese Stadt-Titel-Kombination @@ -1218,18 +1262,40 @@ class AdminService { region: region.name, title: title.labelTr }); + + // Update Progress + currentNPC++; + npcCreationJobService.updateProgress(jobId, currentNPC, totalNPCs); } } } - }); + }); - return { - success: true, - count: createdNPCs.length, - countPerCombination: count, - totalCombinations: targetRegions.length * titles.length, - npcs: createdNPCs - }; + console.log(`[NPC Creation Job ${jobId}] Completed: ${createdNPCs.length} NPCs created`); + + // Job abschließen + npcCreationJobService.setResult(jobId, { + success: true, + count: createdNPCs.length, + countPerCombination: count, + totalCombinations: targetRegions.length * titles.length, + npcs: createdNPCs + }); + } catch (error) { + console.error(`[NPC Creation Job ${jobId}] Error:`, error); + throw error; // Re-throw für den catch-Block in createNPCs + } + } + + getNPCsCreationStatus(userId, jobId) { + const job = npcCreationJobService.getJob(jobId); + if (!job) { + throw new Error('Job not found'); + } + if (job.userId !== userId) { + throw new Error('Access denied'); + } + return job; } } diff --git a/backend/services/npcCreationJobService.js b/backend/services/npcCreationJobService.js new file mode 100644 index 0000000..d09fb80 --- /dev/null +++ b/backend/services/npcCreationJobService.js @@ -0,0 +1,86 @@ +// In-Memory Job-Status-Service für NPC-Erstellung +// Für Produktion sollte man Redis oder eine Datenbank verwenden + +const jobs = new Map(); + +class NPCCreationJobService { + createJob(userId, jobId) { + jobs.set(jobId, { + userId, + status: 'pending', + progress: 0, + total: 0, + current: 0, + startTime: Date.now(), + estimatedTimeRemaining: null, + error: null, + result: null + }); + return jobId; + } + + getJob(jobId) { + return jobs.get(jobId); + } + + updateProgress(jobId, current, total) { + const job = jobs.get(jobId); + if (!job) return; + + job.current = current; + job.total = total; + job.progress = total > 0 ? Math.round((current / total) * 100) : 0; + + // Berechne verbleibende Zeit basierend auf bisheriger Geschwindigkeit + if (current > 0 && job.progress < 100) { + const elapsed = Date.now() - job.startTime; + const avgTimePerItem = elapsed / current; + const remaining = total - current; + job.estimatedTimeRemaining = Math.round(remaining * avgTimePerItem); + } + } + + setStatus(jobId, status) { + const job = jobs.get(jobId); + if (!job) return; + job.status = status; + } + + setError(jobId, error) { + const job = jobs.get(jobId); + if (!job) return; + job.status = 'error'; + job.error = error; + } + + setResult(jobId, result) { + const job = jobs.get(jobId); + if (!job) return; + job.status = 'completed'; + job.result = result; + job.progress = 100; + job.estimatedTimeRemaining = 0; + } + + deleteJob(jobId) { + jobs.delete(jobId); + } + + // Cleanup alte Jobs (älter als 1 Stunde) + cleanupOldJobs() { + const oneHourAgo = Date.now() - (60 * 60 * 1000); + for (const [jobId, job] of jobs.entries()) { + if (job.startTime < oneHourAgo) { + jobs.delete(jobId); + } + } + } +} + +// Cleanup alle 10 Minuten +setInterval(() => { + const service = new NPCCreationJobService(); + service.cleanupOldJobs(); +}, 10 * 60 * 1000); + +export default new NPCCreationJobService(); diff --git a/frontend/src/components/AppContent.vue b/frontend/src/components/AppContent.vue index 0981712..901a284 100644 --- a/frontend/src/components/AppContent.vue +++ b/frontend/src/components/AppContent.vue @@ -1,6 +1,8 @@ @@ -12,9 +14,13 @@ \ No newline at end of file diff --git a/frontend/src/i18n/locales/de/admin.json b/frontend/src/i18n/locales/de/admin.json index 4c94ad0..24d0834 100644 --- a/frontend/src/i18n/locales/de/admin.json +++ b/frontend/src/i18n/locales/de/admin.json @@ -135,7 +135,13 @@ "errorCreating": "Fehler beim Erstellen der NPCs.", "invalidAgeRange": "Ungültiger Altersbereich.", "invalidTitleRange": "Ungültiger Titel-Bereich.", - "invalidCount": "Ungültige Anzahl (1-500)." + "invalidCount": "Ungültige Anzahl (1-500).", + "progress": "Fortschritt", + "progressDetails": "{current} von {total} NPCs erstellt", + "timeRemainingSeconds": "Verbleibende Zeit: {seconds} Sekunden", + "timeRemainingMinutes": "Verbleibende Zeit: {minutes} Minuten {seconds} Sekunden", + "almostDone": "Fast fertig...", + "jobNotFound": "Job nicht gefunden oder abgelaufen." } }, "chatrooms": { diff --git a/frontend/src/i18n/locales/en/admin.json b/frontend/src/i18n/locales/en/admin.json index e1a4ff8..8bee04e 100644 --- a/frontend/src/i18n/locales/en/admin.json +++ b/frontend/src/i18n/locales/en/admin.json @@ -162,7 +162,13 @@ "errorCreating": "Error creating NPCs.", "invalidAgeRange": "Invalid age range.", "invalidTitleRange": "Invalid title range.", - "invalidCount": "Invalid count (1-500)." + "invalidCount": "Invalid count (1-500).", + "progress": "Progress", + "progressDetails": "{current} of {total} NPCs created", + "timeRemainingSeconds": "Time remaining: {seconds} seconds", + "timeRemainingMinutes": "Time remaining: {minutes} minutes {seconds} seconds", + "almostDone": "Almost done...", + "jobNotFound": "Job not found or expired." } }, "chatrooms": { diff --git a/frontend/src/views/admin/falukant/CreateNPCView.vue b/frontend/src/views/admin/falukant/CreateNPCView.vue index ea60dbe..d55489c 100644 --- a/frontend/src/views/admin/falukant/CreateNPCView.vue +++ b/frontend/src/views/admin/falukant/CreateNPCView.vue @@ -58,6 +58,26 @@ {{ creating ? $t('admin.falukant.createNPC.creating') : $t('admin.falukant.createNPC.create') }} + + +
+
+

{{ $t('admin.falukant.createNPC.progress') }}

+ {{ jobStatus.progress }}% +
+
+
+
+
+
{{ $t('admin.falukant.createNPC.progressDetails', { + current: jobStatus.current || 0, + total: jobStatus.total || 0 + }) }}
+
+ {{ formatTimeRemaining(jobStatus.estimatedTimeRemaining) }} +
+
+
@@ -105,9 +125,17 @@ export default { count: 1, creating: false, result: null, - error: null + error: null, + jobId: null, + jobStatus: null, + statusPollInterval: null }; }, + beforeUnmount() { + if (this.statusPollInterval) { + clearInterval(this.statusPollInterval); + } + }, async mounted() { await this.loadRegions(); await this.loadTitles(); @@ -162,6 +190,8 @@ export default { this.creating = true; this.error = null; this.result = null; + this.jobStatus = null; + this.jobId = null; try { const response = await apiClient.post('/api/admin/falukant/npcs/create', { @@ -173,13 +203,62 @@ export default { count: this.count }); - this.result = response.data; + this.jobId = response.data.jobId; + this.startStatusPolling(); } catch (error) { console.error('Error creating NPCs:', error); this.error = error.response?.data?.error || this.$t('admin.falukant.createNPC.errorCreating'); - } finally { this.creating = false; } + }, + startStatusPolling() { + if (this.statusPollInterval) { + clearInterval(this.statusPollInterval); + } + + this.statusPollInterval = setInterval(async () => { + if (!this.jobId) return; + + try { + const response = await apiClient.get(`/api/admin/falukant/npcs/status/${this.jobId}`); + this.jobStatus = response.data; + + if (this.jobStatus.status === 'completed') { + this.result = this.jobStatus.result; + this.creating = false; + clearInterval(this.statusPollInterval); + this.statusPollInterval = null; + } else if (this.jobStatus.status === 'error') { + this.error = this.jobStatus.error || this.$t('admin.falukant.createNPC.errorCreating'); + this.creating = false; + clearInterval(this.statusPollInterval); + this.statusPollInterval = null; + } + } catch (error) { + console.error('Error polling status:', error); + if (error.response?.status === 404) { + // Job nicht gefunden - möglicherweise abgelaufen + this.error = this.$t('admin.falukant.createNPC.jobNotFound'); + this.creating = false; + clearInterval(this.statusPollInterval); + this.statusPollInterval = null; + } + } + }, 1000); // Poll alle Sekunde + }, + formatTimeRemaining(ms) { + if (!ms || ms <= 0) return this.$t('admin.falukant.createNPC.almostDone'); + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + + if (minutes > 0) { + return this.$t('admin.falukant.createNPC.timeRemainingMinutes', { + minutes, + seconds: remainingSeconds + }); + } + return this.$t('admin.falukant.createNPC.timeRemainingSeconds', { seconds }); } } }; @@ -311,4 +390,58 @@ export default { color: #155724; margin-top: 5px; } + +.progress-section { + background: #e7f3ff; + padding: 20px; + border-radius: 8px; + margin-top: 20px; + border: 1px solid #b3d9ff; +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.progress-header h3 { + margin: 0; + font-size: 1.2em; +} + +.progress-percentage { + font-size: 1.5em; + font-weight: bold; + color: #0066cc; +} + +.progress-bar-container { + width: 100%; + height: 30px; + background-color: #e0e0e0; + border-radius: 15px; + overflow: hidden; + margin-bottom: 10px; +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, #28a745, #20c997); + transition: width 0.3s ease; + border-radius: 15px; +} + +.progress-details { + display: flex; + justify-content: space-between; + font-size: 0.9em; + color: #666; +} + +.time-remaining { + font-weight: bold; + color: #0066cc; +}