diff --git a/py/services/batch_import_service.py b/py/services/batch_import_service.py index e1d80bd7..7f558acc 100644 --- a/py/services/batch_import_service.py +++ b/py/services/batch_import_service.py @@ -184,6 +184,7 @@ class BatchImportService: def cancel_import(self, operation_id: str) -> bool: if operation_id in self._active_operations: self._cancellation_flags[operation_id] = True + self._logger.info("Cancel requested for batch import operation %s", operation_id) return True return False @@ -273,6 +274,14 @@ class BatchImportService: self._active_operations[operation_id] = progress self._cancellation_flags[operation_id] = False + self._logger.info( + "Starting batch import operation %s: %d item(s) (%d URL(s), %d local path(s))", + operation_id, + len(import_items), + sum(1 for it in import_items if it.item_type == ImportItemType.URL), + sum(1 for it in import_items if it.item_type == ImportItemType.LOCAL_PATH), + ) + asyncio.create_task( self._run_batch_import( operation_id=operation_id, @@ -295,6 +304,12 @@ class BatchImportService: skip_duplicates: bool = False, ) -> str: image_paths = await self._discover_images(directory, recursive) + self._logger.info( + "Batch import directory scan: %d image(s) discovered in %s (recursive=%s)", + len(image_paths), + directory, + recursive, + ) items = [{"source": path, "type": "local_path"} for path in image_paths] @@ -403,6 +418,19 @@ class BatchImportService: self._concurrency_controller.record_result(item.duration, False) progress.completed += 1 + self._logger.info( + "Batch import %s: item %d/%d status=%s source=%s%s", + operation_id, + progress.completed, + progress.total, + item.status.value, + ( + os.path.basename(item.source) + if item.item_type == ImportItemType.LOCAL_PATH + else item.source[:50] + ), + (f" error={item.error_message}" if item.error_message else ""), + ) await self._broadcast_progress(progress) tasks = [process_item(item) for item in progress.items] @@ -415,6 +443,15 @@ class BatchImportService: progress.finished_at = time.time() progress.current_item = "" + self._logger.info( + "Batch import %s finished: status=%s total=%d success=%d failed=%d skipped=%d", + operation_id, + progress.status, + progress.total, + progress.success, + progress.failed, + progress.skipped, + ) await self._broadcast_progress(progress) await asyncio.sleep(5) @@ -595,3 +632,6 @@ class BatchImportService: def _cleanup_operation(self, operation_id: str) -> None: if operation_id in self._cancellation_flags: del self._cancellation_flags[operation_id] + if operation_id in self._active_operations: + del self._active_operations[operation_id] + self._logger.info("Batch import operation %s cleaned up", operation_id) diff --git a/static/js/managers/BatchImportManager.js b/static/js/managers/BatchImportManager.js index 866897f2..08461d44 100644 --- a/static/js/managers/BatchImportManager.js +++ b/static/js/managers/BatchImportManager.js @@ -17,17 +17,77 @@ export class BatchImportManager { this.progress = null; this.results = null; this.isCancelled = false; + this.isImporting = false; } /** - * Show the batch import modal + * Show the batch import modal. + * + * If an import is still running in the background (e.g. the modal was + * closed mid-run with the X button or a backdrop click), reopen it in the + * progress/results view instead of resetting to a fresh form, so the modal + * never becomes unusable while an operation is in flight. */ showModal() { if (!this.initialized) { this.initialize(); } - this.resetState(); - modalManager.showModal('batchImportModal'); + + if (this.isImporting && this.operationId) { + console.log( + `[BatchImport] Reopening modal while operation ${this.operationId} is still active; restoring its view.` + ); + this.resumeRunningImportView(); + } else if (this.results && this.operationId) { + // A previous operation finished while the modal was closed — + // restore its results view instead of discarding them. + console.log( + `[BatchImport] Reopening modal after operation ${this.operationId} finished; showing results.` + ); + this.showStep('batchResultsStep'); + this.updateResultsUI(this.results); + } else { + this.resetState(); + console.log('[BatchImport] Opening batch import modal.'); + } + + modalManager.showModal('batchImportModal', null, () => this.handleModalClosed()); + } + + /** + * Restore the progress (or results) view for an operation that is still + * running in the background after the modal was closed. + */ + resumeRunningImportView() { + // Operation completed while the modal was closed — show results + if (this.results) { + this.showStep('batchResultsStep'); + this.updateResultsUI(this.results); + return; + } + + // Still running — restore the progress step and re-attach live updates + this.showStep('batchProgressStep'); + this.updateProgressUI(this.progress || {}); + if (!this.wsConnection && !this.pollingInterval) { + this.connectWebSocket(); + this.startPolling(); + } + } + + /** + * Called whenever the modal is closed (X button, backdrop click, cancel, + * closeAndReset). Logs whether an operation is still running so users can + * tell from the console that work continues in the background. + */ + handleModalClosed() { + if (this.isImporting && this.operationId) { + console.log( + `[BatchImport] Modal closed while import ${this.operationId} is still running; it keeps running in the background. Reopen the modal to watch its progress.` + ); + } else { + console.log('[BatchImport] Modal closed (no active import).'); + } } /** @@ -57,6 +117,7 @@ export class BatchImportManager { this.progress = null; this.results = null; this.isCancelled = false; + this.isImporting = false; // Reset UI this.showStep('batchInputStep'); @@ -172,6 +233,10 @@ export class BatchImportManager { return; } + console.log( + `[BatchImport] Starting import: mode=${data.mode}, items=${data.items ? data.items.length : 'directory'}, tags=${data.tags.length}` + ); + try { // Show progress step this.showStep('batchProgressStep'); @@ -182,6 +247,8 @@ export class BatchImportManager { if (response.success) { this.operationId = response.operation_id; this.isCancelled = false; + this.isImporting = true; + console.log(`[BatchImport] Import started, operation_id=${this.operationId}`); // Connect to WebSocket for real-time updates this.connectWebSocket(); @@ -189,6 +256,7 @@ export class BatchImportManager { // Start polling as fallback this.startPolling(); } else { + console.warn(`[BatchImport] Failed to start import: ${response.error}`); showToast('toast.recipes.batchImportFailed', { message: response.error }, 'error'); this.showStep('batchInputStep'); } @@ -357,6 +425,16 @@ export class BatchImportManager { handleProgressUpdate(progress) { this.progress = progress; this.updateProgressUI(progress); + + // Console visibility for background progress: while the modal is + // closed (or open), the console shows what the import is doing. + console.log( + `[BatchImport] Progress ${Math.round(progress.progress_percent || 0)}% ` + + `(${progress.completed}/${progress.total}) ` + + `status=${progress.status} ` + + `success=${progress.success} failed=${progress.failed} skipped=${progress.skipped} ` + + `item=${progress.current_item || '-'}` + ); // Check if import is complete if (progress.status === 'completed' || progress.status === 'cancelled' || @@ -431,7 +509,12 @@ export class BatchImportManager { */ importComplete(progress) { this.cleanupConnections(); + this.isImporting = false; this.results = progress; + console.log( + `[BatchImport] Import finished: status=${progress.status} ` + + `total=${progress.total} success=${progress.success} failed=${progress.failed} skipped=${progress.skipped}` + ); // Refresh recipes list to show newly imported recipes if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') { @@ -559,6 +642,7 @@ export class BatchImportManager { if (!this.operationId) return; this.isCancelled = true; + console.log(`[BatchImport] Cancelling import ${this.operationId}...`); try { const response = await fetch('/api/lm/recipes/batch-import/cancel', { @@ -572,8 +656,10 @@ export class BatchImportManager { const data = await response.json(); if (data.success) { + console.log(`[BatchImport] Cancel request accepted for ${this.operationId}`); showToast('toast.recipes.batchImportCancelling', {}, 'info'); } else { + console.warn(`[BatchImport] Cancel request failed: ${data.error}`); showToast('toast.recipes.batchImportCancelFailed', { message: data.error }, 'error'); } } catch (error) { @@ -586,6 +672,7 @@ export class BatchImportManager { * Close modal and reset state */ closeAndReset() { + console.log('[BatchImport] Closing modal and resetting state.'); this.cleanupConnections(); this.resetState(); modalManager.closeModal('batchImportModal'); @@ -595,6 +682,7 @@ export class BatchImportManager { * Start a new import (from results step) */ startNewImport() { + console.log('[BatchImport] Starting a new import from the results view.'); this.resetState(); this.showStep('batchInputStep'); } @@ -789,6 +877,14 @@ export class BatchImportManager { * Clean up WebSocket and polling connections */ cleanupConnections() { + const hasWs = this.wsConnection && ( + this.wsConnection.readyState === WebSocket.OPEN || + this.wsConnection.readyState === WebSocket.CONNECTING + ); + if (hasWs || this.pollingInterval) { + console.log('[BatchImport] Cleaning up live connections (WebSocket/polling).'); + } + if (this.wsConnection) { if (this.wsConnection.readyState === WebSocket.OPEN || this.wsConnection.readyState === WebSocket.CONNECTING) { diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js index 7bdce97c..dfd4aa16 100644 --- a/static/js/managers/ImportManager.js +++ b/static/js/managers/ImportManager.js @@ -66,10 +66,15 @@ export class ImportManager { // Show modal modalManager.showModal('importModal', null, () => { + console.log('[RecipeImport] Import modal closed.'); this.cleanupFolderBrowser(); this.stepManager.removeInjectedStyles(); }); + console.log( + `[RecipeImport] Import modal opened (${recipeData ? 'download-missing-loras mode' : 'new import'}).` + ); + // Verify visibility and focus on the URL input (primary mode) setTimeout(() => { const urlInput = document.getElementById('imageUrlInput'); diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index f3b2203c..06eb45b7 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -146,7 +146,13 @@ export class ModalManager { }); } - // Add batchImportModal registration + // Add batchImportModal registration. + // Deliberately no closeOnOutsideClick: batch import is a stateful, + // multi-step workflow (input -> progress -> results) that runs a + // long-lived background operation. A stray backdrop click would + // dismiss the modal while the import keeps running, leaving users + // unable to tell what is still happening (issue #1084). Close is + // available via the explicit X button / Cancel instead. const batchImportModal = document.getElementById('batchImportModal'); if (batchImportModal) { this.registerModal('batchImportModal', { @@ -154,8 +160,7 @@ export class ModalManager { onClose: () => { this.getModal('batchImportModal').element.style.display = 'none'; document.body.classList.remove('modal-open'); - }, - closeOnOutsideClick: true + } }); } diff --git a/static/js/managers/import/DownloadManager.js b/static/js/managers/import/DownloadManager.js index 26dc1d79..7fea2b0b 100644 --- a/static/js/managers/import/DownloadManager.js +++ b/static/js/managers/import/DownloadManager.js @@ -19,6 +19,10 @@ export class DownloadManager { return; } + console.log( + `[RecipeImport] Saving recipe "${this.importManager.recipeName}" (download-only=${isDownloadOnly}, skipDownload=${skipDownload})` + ); + try { // Show progress indicator const loadingMessage = skipDownload @@ -102,6 +106,7 @@ export class DownloadManager { if (!result.success) { // Handle save error console.error("Failed to save recipe:", result.error); + console.log('[RecipeImport] Save failed; closing import modal.'); showToast('toast.recipes.recipeSaveFailed', { error: result.error }, 'error'); // Close modal modalManager.closeModal('importModal'); @@ -112,6 +117,7 @@ export class DownloadManager { // Check if we need to download LoRAs (skip if skipDownload is true) let failedDownloads = 0; if (!skipDownload && this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) { + console.log(`[RecipeImport] Downloading ${this.importManager.downloadableLoRAs.length} missing LoRA(s)...`); await this.downloadMissingLoras(); } @@ -127,6 +133,7 @@ export class DownloadManager { } modalManager.closeModal('importModal'); + console.log(`[RecipeImport] Recipe "${this.importManager.recipeName}" saved successfully.`); if (isDownloadOnly && state.virtualScroller) { const recipeId = this.importManager.recipeId; diff --git a/static/js/managers/import/ImageProcessor.js b/static/js/managers/import/ImageProcessor.js index 541f7d79..6cef30a2 100644 --- a/static/js/managers/import/ImageProcessor.js +++ b/static/js/managers/import/ImageProcessor.js @@ -30,6 +30,7 @@ export class ImageProcessor { errorElement.textContent = ''; this.importManager.recipeImage = file; this.importManager.importMode = 'upload'; + console.log(`[RecipeImport] Recipe image selected: ${file.name}`); // Show the selected file name in the drop zone this.importManager.updateSelectedFileName(file.name); @@ -66,6 +67,10 @@ export class ImageProcessor { errorElement.textContent = ''; this.importManager.importMode = 'url'; + console.log( + `[RecipeImport] Analyzing recipe input (${input.startsWith('http://') || input.startsWith('https://') ? 'remote URL' : 'local path'}): ${input.slice(0, 80)}` + ); + // Put the fetch button into a loading state to prevent duplicate submits const fetchBtn = document.getElementById('fetchImageBtn'); this._setFetchButtonLoading(fetchBtn, true); @@ -144,6 +149,9 @@ export class ImageProcessor { this.importManager.importAsNew = false; // Proceed to recipe details step + console.log( + `[RecipeImport] Analysis complete: ${this.importManager.recipeData.loras.length} LoRA(s) found, ${this.importManager.missingLoras.length} missing locally.` + ); this.importManager.showRecipeDetailsStep(); } catch (error) { @@ -196,6 +204,9 @@ export class ImageProcessor { this.importManager.importAsNew = false; // Proceed to recipe details step + console.log( + `[RecipeImport] Analysis complete: ${this.importManager.recipeData.loras.length} LoRA(s) found, ${this.importManager.missingLoras.length} missing locally.` + ); this.importManager.showRecipeDetailsStep(); } catch (error) { @@ -251,6 +262,9 @@ export class ImageProcessor { this.importManager.importAsNew = false; // Proceed to recipe details step + console.log( + `[RecipeImport] Analysis complete: ${this.importManager.recipeData.loras.length} LoRA(s) found, ${this.importManager.missingLoras.length} missing locally.` + ); this.importManager.showRecipeDetailsStep(); } catch (error) { diff --git a/tests/frontend/managers/BatchImportManager.reopen.test.js b/tests/frontend/managers/BatchImportManager.reopen.test.js new file mode 100644 index 00000000..367720fb --- /dev/null +++ b/tests/frontend/managers/BatchImportManager.reopen.test.js @@ -0,0 +1,206 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderTemplate } from '../utils/domFixtures.js'; + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: vi.fn(), + setupAutoNewlineOnPaste: vi.fn(), +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: (key, params = {}, fallback = null) => fallback ?? key, +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + WS_ENDPOINTS: {}, +})); + +vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ + getStorageItem: vi.fn(() => true), + setStorageItem: vi.fn(), +})); + +// jsdom has no WebSocket; the manager only needs open/connecting/close states. +class FakeWebSocket { + constructor(url) { + this.url = url; + this.readyState = 0; + } + close() { + this.readyState = 3; + } +} +FakeWebSocket.OPEN = 1; +FakeWebSocket.CONNECTING = 0; + +const RUNNING_PROGRESS = { + status: 'running', + total: 2, + completed: 1, + success: 1, + failed: 0, + skipped: 0, + progress_percent: 50, + current_item: 'image-1.png', +}; + +const COMPLETED_PROGRESS = { + status: 'completed', + total: 2, + completed: 2, + success: 2, + failed: 0, + skipped: 0, + progress_percent: 100, + current_item: '', +}; + +describe('BatchImportManager reopen behavior (#1084)', () => { + let modalManager; + let batchImportManager; + let fetchMock; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + document.body.innerHTML = ''; + renderTemplate('components/batch_import_modal.html'); + + // jsdom does not implement window.scrollTo; ModalManager calls it on close. + window.scrollTo = vi.fn(); + + vi.stubGlobal('WebSocket', FakeWebSocket); + fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({}), + })); + vi.stubGlobal('fetch', fetchMock); + + const modalModule = await import('../../../static/js/managers/ModalManager.js'); + modalManager = modalModule.modalManager; + modalManager.initialize(); + + const batchModule = await import('../../../static/js/managers/BatchImportManager.js'); + batchImportManager = new batchModule.BatchImportManager(); + }); + + afterEach(() => { + if (batchImportManager) { + batchImportManager.cleanupConnections(); + } + vi.unstubAllGlobals(); + }); + + async function startImportViaUrls(urls) { + batchImportManager.showModal(); + document.getElementById('batchUrlInput').value = urls.join('\n'); + fetchMock.mockImplementation(async (url) => ({ + ok: true, + status: 200, + json: async () => + url.includes('/batch-import/start') + ? { success: true, operation_id: 'op-123' } + : { success: true, progress: RUNNING_PROGRESS }, + })); + await batchImportManager.startImport(); + } + + it('opens a fresh input form when no operation exists', () => { + batchImportManager.showModal(); + expect(document.getElementById('batchImportModal').style.display).toBe('block'); + expect(document.getElementById('batchInputStep').style.display).toBe('block'); + expect(document.getElementById('batchProgressStep').style.display).toBe('none'); + }); + + it('reopens into the progress view while an import keeps running in the background', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await startImportViaUrls([ + 'https://civitai.com/images/1', + 'https://civitai.com/images/2', + ]); + + expect(batchImportManager.isImporting).toBe(true); + expect(batchImportManager.operationId).toBe('op-123'); + expect(document.getElementById('batchProgressStep').style.display).toBe('block'); + + // Close the modal the same way the X button does. + modalManager.closeModal('batchImportModal'); + expect(document.getElementById('batchImportModal').style.display).toBe('none'); + + // Closing while running must be visible in the console (#1084). + const closedWhileRunning = logSpy.mock.calls.some((call) => + String(call[0]).includes('Modal closed while import op-123 is still running') + ); + expect(closedWhileRunning).toBe(true); + + // Reopen: the in-flight operation must be restored, not discarded. + batchImportManager.showModal(); + expect(document.getElementById('batchImportModal').style.display).toBe('block'); + expect(batchImportManager.operationId).toBe('op-123'); + expect(batchImportManager.isImporting).toBe(true); + expect(document.getElementById('batchProgressStep').style.display).toBe('block'); + expect(document.getElementById('batchInputStep').style.display).toBe('none'); + + logSpy.mockRestore(); + }); + + it('reopens into the results view after a background import completes', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await startImportViaUrls([ + 'https://civitai.com/images/1', + 'https://civitai.com/images/2', + ]); + + // Operation finishes while the modal stays closed. + modalManager.closeModal('batchImportModal'); + batchImportManager.handleProgressUpdate(RUNNING_PROGRESS); + batchImportManager.handleProgressUpdate(COMPLETED_PROGRESS); + + expect(batchImportManager.isImporting).toBe(false); + expect(batchImportManager.results.status).toBe('completed'); + + // Reopening shows the finished results instead of a blank form. + batchImportManager.showModal(); + expect(document.getElementById('batchImportModal').style.display).toBe('block'); + expect(document.getElementById('batchResultsStep').style.display).toBe('block'); + expect(document.getElementById('batchInputStep').style.display).toBe('none'); + + logSpy.mockRestore(); + }); + + it('does not close when clicking the backdrop (stateful workflow, #1084)', async () => { + await startImportViaUrls(['https://civitai.com/images/1']); + + const modalEl = document.getElementById('batchImportModal'); + expect(modalEl.style.display).toBe('block'); + + // Simulate a backdrop click: mousedown + mouseup on the modal shell. + // Because batch import is a stateful, multi-step workflow, the modal must + // not dismiss on stray outside clicks. + modalEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + modalEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + + expect(modalEl.style.display).toBe('block'); + expect(modalManager.isAnyModalOpen()).toBe('batchImportModal'); + }); + + it('logs start, progress and completion to the console', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await startImportViaUrls(['https://civitai.com/images/1']); + + // force a polled progress update + batchImportManager.handleProgressUpdate(RUNNING_PROGRESS); + batchImportManager.handleProgressUpdate(COMPLETED_PROGRESS); + + const messages = logSpy.mock.calls.map((call) => String(call[0])); + expect(messages.some((m) => m.includes('[BatchImport] Import started, operation_id=op-123'))).toBe(true); + expect(messages.some((m) => m.includes('[BatchImport] Progress 50%'))).toBe(true); + expect(messages.some((m) => m.includes('[BatchImport] Import finished: status=completed'))).toBe(true); + + logSpy.mockRestore(); + }); +}); \ No newline at end of file