diff --git a/locales/en.json b/locales/en.json
index 0cf091ef..6b818700 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -805,7 +805,6 @@
"migrateButton": "Migrate Sidecars Now",
"migratingButton": "Migrating...",
"migrating": "Migrating sidecars...",
- "migrateSuccess": "Sidecar migration completed successfully",
"migrateFailed": "Sidecar migration failed: {message}",
"migrationDeferred": "Existing sidecars were not moved. You can migrate them later from Settings → Library → Sidecar Storage.",
"confirmToCentralized": "The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them into the centralized storage directory now? You can also do this later with the \"Migrate Sidecars Now\" button.",
@@ -1680,9 +1679,18 @@
"destination": "Destination: {path}"
},
"sidecarMigrationResult": {
- "title": "Sidecar migration completed",
- "titleWithErrors": "Sidecar migration completed with {count} error(s)",
- "summary": "Moved {moved} files for {models} models. Skipped: {skipped}, conflicts resolved: {conflicts}.",
+ "title": "Sidecar Migration Summary",
+ "completedSuccessfully": "Migration completed successfully",
+ "completedWithErrors": "Completed with {count} error(s)",
+ "statMoved": "Moved Files",
+ "statModels": "Models",
+ "statSkipped": "Skipped",
+ "statConflicts": "Conflicts Resolved",
+ "statErrors": "Errors",
+ "failedItems": "Failed Items ({count})",
+ "columnModel": "Model",
+ "columnError": "Error",
+ "successMessage": "Moved {moved} files for {models} models",
"location": "Storage location: {path}"
},
"bulkAddTags": {
diff --git a/static/css/components/sidecar-migration-summary.css b/static/css/components/sidecar-migration-summary.css
new file mode 100644
index 00000000..b1bd090b
--- /dev/null
+++ b/static/css/components/sidecar-migration-summary.css
@@ -0,0 +1,15 @@
+/* Sidecar Migration Summary Modal — component styles only.
+ Stat cards, summary header and failure table styles are shared with the
+ metadata refresh result modal (metadata-refresh-result.css) and the batch
+ download summary modal (download-batch-summary.css); not redefined here. */
+
+.sidecar-migration-summary-modal {
+ max-width: 700px;
+}
+
+.sidecar-migration-location {
+ margin: 0 0 var(--space-3) 0;
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+ word-break: break-all;
+}
diff --git a/static/css/style.css b/static/css/style.css
index 32d82a0a..a08747a3 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -43,6 +43,7 @@
@import 'components/media-viewer.css';
@import 'components/metadata-refresh-result.css';
@import 'components/download-batch-summary.css';
+@import 'components/sidecar-migration-summary.css';
.initialization-notice {
display: flex;
diff --git a/static/js/components/SidecarMigrationSummaryModal.js b/static/js/components/SidecarMigrationSummaryModal.js
new file mode 100644
index 00000000..49944569
--- /dev/null
+++ b/static/js/components/SidecarMigrationSummaryModal.js
@@ -0,0 +1,193 @@
+import { translate } from '../utils/i18nHelpers.js';
+
+/**
+ * Escape HTML entities in a string to prevent injection when interpolating into innerHTML.
+ * @param {string} str - The string to escape
+ * @returns {string} - The escaped string
+ */
+function _escapeHtml(str) {
+ if (str === null || str === undefined) return '';
+ const div = document.createElement('div');
+ div.textContent = String(str);
+ return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
+}
+
+/**
+ * Show the sidecar migration summary modal after a migration completes.
+ * Mirrors the Metadata Fetch Summary / Batch Download Summary lifecycle: the
+ * modal element is appended directly to document.body and removed on close;
+ * it is not registered with ModalManager.
+ * @param {Object} options - Summary options
+ * @param {Object} options.result - Migration result payload from /api/lm/sidecars/migrate
+ * @param {Function} options.onClose - Callback invoked after the modal is dismissed
+ * @param {Function} [options.onOpenLocation] - Callback for the "Open Folder" action; the modal stays open
+ */
+export function showSidecarMigrationSummary({ result, onClose, onOpenLocation }) {
+ const errorCount = result.error_count || 0;
+ const errors = Array.isArray(result.errors) ? result.errors : [];
+ const moved = result.moved || 0;
+ const modelsMoved = result.models_moved || 0;
+ const modelsTotal = result.models_total || 0;
+ const skipped = result.skipped || 0;
+ const conflicts = result.conflicts || 0;
+
+ const showLocation = result.direction !== 'to_alongside' && !!result.sidecar_root;
+
+ // 3-state summary header semantics (mirrors DownloadBatchSummaryModal)
+ let headerState;
+ let headerIcon;
+ let headerText;
+ if (errorCount > 0) {
+ headerState = 'warning';
+ headerIcon = 'fa-exclamation-circle';
+ headerText = translate('modals.sidecarMigrationResult.completedWithErrors', { count: errorCount }, 'Completed with ' + errorCount + ' error(s)');
+ } else {
+ headerState = 'success';
+ headerIcon = 'fa-check-circle';
+ headerText = translate('modals.sidecarMigrationResult.completedSuccessfully', {}, 'Migration completed successfully');
+ }
+
+ const failureRows = errors.map((entry, i) => {
+ const name = entry?.model || 'Unknown';
+ const error = entry?.error ? String(entry.error) : 'Unknown error';
+ return `
+ | ${i + 1} |
+ ${_escapeHtml(name)} |
+ ${_escapeHtml(error)} |
+
`;
+ }).join('');
+
+ const modalHtml = `
+
+
+
+
+
${translate('modals.sidecarMigrationResult.title', {}, 'Sidecar Migration Summary')}
+
+
+
+
+
+
+ ${translate('modals.sidecarMigrationResult.statMoved', {}, 'Moved Files')}
+ ${moved}
+
+
+
+
+ ${translate('modals.sidecarMigrationResult.statModels', {}, 'Models')}
+ ${modelsMoved}
+
+
+
+
+ ${translate('modals.sidecarMigrationResult.statSkipped', {}, 'Skipped')}
+ ${skipped}
+
+
+
+
+ ${translate('modals.sidecarMigrationResult.statConflicts', {}, 'Conflicts Resolved')}
+ ${conflicts}
+
+
+ ${errorCount > 0 ? `
+
+
+ ${translate('modals.sidecarMigrationResult.statErrors', {}, 'Errors')}
+ ${errorCount}
+
+
+ ` : ''}
+
+
+ ${errorCount > 0 ? `
+
+
${translate('modals.sidecarMigrationResult.failedItems', { count: errorCount }, 'Failed Items (' + errorCount + ')')}
+
+
+
+
+ | # |
+ ${translate('modals.sidecarMigrationResult.columnModel', {}, 'Model')} |
+ ${translate('modals.sidecarMigrationResult.columnError', {}, 'Error')} |
+
+
+ ${failureRows}
+
+
+
+ ` : `
+
+ ${translate('modals.sidecarMigrationResult.successMessage', { moved: moved, models: modelsMoved }, 'Moved ' + moved + ' files for ' + modelsMoved + ' models')}
+
+ `}
+
+ ${showLocation ? `
+
+ ${translate('modals.sidecarMigrationResult.location', { path: result.sidecar_root }, 'Storage location: ' + result.sidecar_root)}
+
+ ` : ''}
+
+
+ ${showLocation ? `
+
+ ` : ''}
+
+
+
+
+ `;
+
+ const existing = document.getElementById('sidecarMigrationSummaryModal');
+ if (existing) existing.remove();
+
+ const container = document.createElement('div');
+ container.innerHTML = modalHtml;
+ const modal = container.firstElementChild;
+ document.body.appendChild(modal);
+
+ const close = () => {
+ document.removeEventListener('keydown', handleEscape, true);
+ modal.remove();
+ if (typeof onClose === 'function') {
+ onClose();
+ }
+ };
+
+ // Capture phase + stopPropagation so ESC never reaches the settings
+ // modal's own ESC handler underneath.
+ const handleEscape = (event) => {
+ if (event.key === 'Escape') {
+ event.stopPropagation();
+ event.preventDefault();
+ close();
+ }
+ };
+ document.addEventListener('keydown', handleEscape, true);
+
+ modal.addEventListener('click', (e) => {
+ const actionEl = e.target.closest('[data-action]');
+ const action = actionEl?.dataset.action;
+ if (!action) return;
+ e.preventDefault();
+
+ switch (action) {
+ case 'close-modal':
+ close();
+ break;
+ case 'open-sidecar-location':
+ // Keep the modal open; only trigger the file-manager action.
+ if (typeof onOpenLocation === 'function') {
+ onOpenLocation();
+ }
+ break;
+ }
+ });
+
+ return modal;
+}
diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js
index b2261f51..808e4692 100644
--- a/static/js/managers/SettingsManager.js
+++ b/static/js/managers/SettingsManager.js
@@ -18,6 +18,7 @@ import { configureModelCardVideo } from '../components/shared/ModelCard.js';
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
import { bannerService } from './BannerService.js';
import { directoryPickerModal } from '../components/DirectoryPickerModal.js';
+import { showSidecarMigrationSummary } from '../components/SidecarMigrationSummaryModal.js';
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
@@ -3682,103 +3683,14 @@ export class SettingsManager {
// Post-migration summary: counters + storage location, with an "open
// folder" shortcut. Closing reloads so cards pick up the new paths.
showSidecarMigrationResult(result) {
- const modalElement = document.getElementById('sidecarMigrationResultModal');
- if (!modalElement) {
- showToast('settings.sidecarStorage.migrateSuccess', {}, 'success');
- resetAndReload(true);
- return;
- }
-
- const errorCount = result.error_count || 0;
-
- const titleElement = modalElement.querySelector('[data-role="title"]');
- if (titleElement) {
- titleElement.textContent = errorCount
- ? translate('modals.sidecarMigrationResult.titleWithErrors', { count: errorCount }, `Sidecar migration completed with ${errorCount} error(s)`)
- : translate('modals.sidecarMigrationResult.title', {}, 'Sidecar migration completed');
- }
-
- const messageElement = modalElement.querySelector('[data-role="message"]');
- if (messageElement) {
- messageElement.textContent = translate(
- 'modals.sidecarMigrationResult.summary',
- {
- moved: result.moved || 0,
- models: result.models_moved || 0,
- skipped: result.skipped || 0,
- conflicts: result.conflicts || 0,
- },
- `Moved ${result.moved || 0} files for ${result.models_moved || 0} models. Skipped: ${result.skipped || 0}, conflicts resolved: ${result.conflicts || 0}.`
- );
- }
-
- const showLocation = result.direction !== 'to_alongside' && !!result.sidecar_root;
-
- const destinationElement = modalElement.querySelector('[data-role="destination"]');
- if (destinationElement) {
- if (showLocation) {
- destinationElement.textContent = translate(
- 'modals.sidecarMigrationResult.location',
- { path: result.sidecar_root },
- `Storage location: ${result.sidecar_root}`
- );
- destinationElement.style.display = 'block';
- } else {
- destinationElement.style.display = 'none';
- }
- }
-
- const openButton = modalElement.querySelector('[data-action="open-sidecar-location"]');
- const closeButton = modalElement.querySelector('[data-action="close-sidecar-result"]');
- if (!closeButton) {
- resetAndReload(true);
- return;
- }
-
- if (openButton) {
- openButton.style.display = showLocation ? '' : 'none';
- }
-
- const cleanup = () => {
- closeButton.removeEventListener('click', handleClose);
- if (openButton) {
- openButton.removeEventListener('click', handleOpen);
- }
- document.removeEventListener('keydown', handleEscape, true);
- };
-
- const handleClose = (event) => {
- event.preventDefault();
- cleanup();
- modalElement.classList.remove('show');
+ showSidecarMigrationSummary({
+ result,
// Reload so cards pick up metadata/preview paths from the new location
- resetAndReload(true);
- };
-
- // Opening the folder keeps the result modal open; the reload happens
- // when the user closes it.
- const handleOpen = (event) => {
- event.preventDefault();
- this.openSidecarStorageLocation();
- };
-
- // Capture phase + stopPropagation so ESC never reaches the settings
- // modal's own ESC handler underneath.
- const handleEscape = (event) => {
- if (event.key === 'Escape') {
- event.stopPropagation();
- handleClose(event);
- }
- };
-
- closeButton.addEventListener('click', handleClose);
- if (openButton) {
- openButton.addEventListener('click', handleOpen);
- }
- document.addEventListener('keydown', handleEscape, true);
-
- modalElement.classList.add('show');
- closeButton.focus();
+ onClose: () => resetAndReload(true),
+ // Opening the folder keeps the result modal open; the reload
+ // happens when the user closes it.
+ onOpenLocation: () => this.openSidecarStorageLocation(),
+ });
}
async loadMetadataArchiveSettings() {
diff --git a/templates/components/modals/confirm_modals.html b/templates/components/modals/confirm_modals.html
index 773e3fb2..cde87e75 100644
--- a/templates/components/modals/confirm_modals.html
+++ b/templates/components/modals/confirm_modals.html
@@ -111,22 +111,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/frontend/managers/settingsManager.sidecarStorage.test.js b/tests/frontend/managers/settingsManager.sidecarStorage.test.js
index ba357751..890269fe 100644
--- a/tests/frontend/managers/settingsManager.sidecarStorage.test.js
+++ b/tests/frontend/managers/settingsManager.sidecarStorage.test.js
@@ -221,9 +221,15 @@ describe('SettingsManager sidecar storage', () => {
method: 'POST',
body: JSON.stringify({ direction: 'to_centralized', force: true }),
}));
- expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.migrateSuccess', {}, 'success');
- expect(resetAndReload).toHaveBeenCalledWith(true);
+ // The summary modal stacks above the settings modal; the reload
+ // only happens once the user dismisses it.
+ const summaryModal = document.getElementById('sidecarMigrationSummaryModal');
+ expect(summaryModal).not.toBeNull();
+ expect(resetAndReload).not.toHaveBeenCalled();
expect(modal.classList.contains('show')).toBe(false);
+
+ summaryModal.querySelector('[data-action="close-modal"]').click();
+ expect(resetAndReload).toHaveBeenCalledWith(true);
});
it('names the resolved destination in the confirm dialog', async () => {
@@ -432,80 +438,90 @@ describe('SettingsManager sidecar storage', () => {
});
describe('showSidecarMigrationResult', () => {
- const appendResultModal = () => {
- const modal = document.createElement('div');
- modal.id = 'sidecarMigrationResultModal';
- modal.innerHTML = `
-
-
-
-
- `;
- document.body.appendChild(modal);
- return modal;
+ const baseResult = {
+ success: true,
+ direction: 'to_centralized',
+ moved: 12,
+ models_moved: 5,
+ models_total: 6,
+ skipped: 1,
+ conflicts: 2,
+ errors: [],
+ error_count: 0,
+ sidecar_root: '/data/sidecars',
};
- it('renders counters and location, reloads only when closed', async () => {
+ it('renders stat cards and location, reloads only when closed', async () => {
const manager = createManager();
- const modal = appendResultModal();
mockFetchOk({ success: true });
- manager.showSidecarMigrationResult({
- success: true,
- direction: 'to_centralized',
- moved: 12,
- models_moved: 5,
- skipped: 1,
- conflicts: 2,
- error_count: 0,
- sidecar_root: '/data/sidecars',
- });
+ manager.showSidecarMigrationResult(baseResult);
- expect(modal.classList.contains('show')).toBe(true);
- expect(modal.querySelector('[data-role="message"]').textContent).toContain('12');
- expect(modal.querySelector('[data-role="destination"]').textContent).toContain('/data/sidecars');
- expect(modal.querySelector('[data-action="open-sidecar-location"]').style.display).not.toBe('none');
+ const modal = document.getElementById('sidecarMigrationSummaryModal');
+ expect(modal).not.toBeNull();
+ const statValues = [...modal.querySelectorAll('.stat-card-value')].map((el) => el.textContent);
+ expect(statValues).toEqual(['12', '5', '1', '2']);
+ expect(modal.querySelector('.sidecar-migration-location').textContent).toContain('/data/sidecars');
+ expect(modal.querySelector('[data-action="open-sidecar-location"]')).not.toBeNull();
+ expect(modal.querySelector('.refresh-success-message')).not.toBeNull();
expect(resetAndReload).not.toHaveBeenCalled();
- // "Open Folder" keeps the result modal open.
+ // "Open Folder" keeps the summary modal open.
modal.querySelector('[data-action="open-sidecar-location"]').click();
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/sidecars/open-location',
{ method: 'POST' }
));
- expect(modal.classList.contains('show')).toBe(true);
+ expect(document.getElementById('sidecarMigrationSummaryModal')).not.toBeNull();
- modal.querySelector('[data-action="close-sidecar-result"]').click();
- expect(modal.classList.contains('show')).toBe(false);
+ modal.querySelector('[data-action="close-modal"]').click();
+ expect(document.getElementById('sidecarMigrationSummaryModal')).toBeNull();
expect(resetAndReload).toHaveBeenCalledWith(true);
});
- it('hides the location row and open button when migrating back alongside', () => {
+ it('hides the location line and open button when migrating back alongside', () => {
+ const manager = createManager();
+
+ manager.showSidecarMigrationResult({ ...baseResult, direction: 'to_alongside' });
+
+ const modal = document.getElementById('sidecarMigrationSummaryModal');
+ expect(modal.querySelector('.sidecar-migration-location')).toBeNull();
+ expect(modal.querySelector('[data-action="open-sidecar-location"]')).toBeNull();
+ });
+
+ it('renders the failure table when errors occurred', () => {
const manager = createManager();
- const modal = appendResultModal();
manager.showSidecarMigrationResult({
- success: true,
- direction: 'to_alongside',
- moved: 3,
- models_moved: 3,
- skipped: 0,
- conflicts: 0,
- error_count: 0,
- sidecar_root: '/data/sidecars',
+ ...baseResult,
+ errors: [{ model: 'broken.safetensors', error: 'permission denied' }],
+ error_count: 1,
});
- expect(modal.querySelector('[data-role="destination"]').style.display).toBe('none');
- expect(modal.querySelector('[data-action="open-sidecar-location"]').style.display).toBe('none');
+ const modal = document.getElementById('sidecarMigrationSummaryModal');
+ expect(modal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
+ expect(modal.querySelector('.refresh-success-message')).toBeNull();
+ const rows = modal.querySelectorAll('.failure-table tbody tr');
+ expect(rows).toHaveLength(1);
+ expect(rows[0].textContent).toContain('broken.safetensors');
+ expect(rows[0].textContent).toContain('permission denied');
+ const statValues = [...modal.querySelectorAll('.stat-card-value')].map((el) => el.textContent);
+ expect(statValues).toContain('1');
});
- it('falls back to toast plus reload when the modal is absent', () => {
+ it('closes on ESC without leaking the keydown to the settings modal', () => {
const manager = createManager();
+ const underlyingHandler = vi.fn();
+ document.addEventListener('keydown', underlyingHandler);
- manager.showSidecarMigrationResult({ success: true, direction: 'to_centralized' });
+ manager.showSidecarMigrationResult(baseResult);
- expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.migrateSuccess', {}, 'success');
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+
+ expect(document.getElementById('sidecarMigrationSummaryModal')).toBeNull();
expect(resetAndReload).toHaveBeenCalledWith(true);
+ expect(underlyingHandler).not.toHaveBeenCalled();
+ document.removeEventListener('keydown', underlyingHandler);
});
});
});