From d9286b53d5f342c4f16edc7acd9108e56dd2142c Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Mon, 26 Oct 2026 16:42:56 +0100 Subject: [PATCH] Retain interrupted download progress cards and add in-place retry action - Keep failed or interrupted downloads in active downloads queue instead of dismissing immediately - Present interrupted state with error tooltip, distinct status chip, and progress percentage - Add in-place Retry download play action that resumes downloading from existing partial files - Support dismissing interrupted downloads via cancel button - Update CSS styling and unit tests for download action handlers and progress states --- tests/test_download_action_handlers.mjs | 10 +- tests/test_download_progress_methods.mjs | 6 + web/css/resolver-shell.css | 23 ++++ .../actions/download_progress_methods.js | 3 +- web/resolver/actions/queue_methods.js | 40 ++++-- .../actions/resolve_download_methods.js | 125 +++++++++++++++++- .../utils/download_action_handlers.js | 2 + 7 files changed, 186 insertions(+), 23 deletions(-) diff --git a/tests/test_download_action_handlers.mjs b/tests/test_download_action_handlers.mjs index 6b0ad48..e7e156e 100644 --- a/tests/test_download_action_handlers.mjs +++ b/tests/test_download_action_handlers.mjs @@ -62,6 +62,7 @@ function createDownloadDialog(queueList, calls) { cancelDownload: id => calls.push(['cancel', id]), pauseDownload: id => calls.push(['pause', id]), resumeDownload: id => calls.push(['resume', id]), + retryDownload: id => calls.push(['retry', id]), openContainingFolder: context => calls.push(['folder', context.widget_name]), switchToDownloadWorkflow: context => calls.push(['workflow', context.widget_name]), setQueueDownloadsTab: () => {}, @@ -83,6 +84,7 @@ test('queue download controls trigger each action once and keep more as click-on 'mr-download-queue-cancel', 'mr-download-queue-pause', 'mr-download-queue-resume', + 'mr-download-queue-retry', 'mr-download-queue-open-folder', 'mr-download-queue-switch-workflow', 'mr-download-queue-more', @@ -103,12 +105,14 @@ test('queue download controls trigger each action once and keep more as click-on dispatchPointerAndClick(window, buttons[2]); dispatchPointerAndClick(window, buttons[3]); dispatchPointerAndClick(window, buttons[4]); - buttons[5].dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); + dispatchPointerAndClick(window, buttons[5]); + buttons[6].dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); assert.deepEqual(calls, [ ['cancel', 'download-1'], ['pause', 'download-1'], ['resume', 'download-1'], + ['retry', 'download-1'], ['folder', 'test-model'], ['workflow', 'test-model'], ]); @@ -124,6 +128,7 @@ test('progress download controls support pending buttons and fallback download i 'cancel-download-btn-pending', 'pause-download-btn', 'resume-download-btn', + 'retry-download-btn', 'mr-download-queue-open-folder', 'mr-download-queue-switch-workflow', 'mr-download-queue-more', @@ -145,16 +150,19 @@ test('progress download controls support pending buttons and fallback download i buttons[3].dataset.downloadId = ''; buttons[4].dataset.downloadId = ''; buttons[5].dataset.downloadId = ''; + buttons[6].dataset.downloadId = ''; dispatchPointerAndClick(window, buttons[0]); dispatchPointerAndClick(window, buttons[1]); dispatchPointerAndClick(window, buttons[2]); dispatchPointerAndClick(window, buttons[3]); dispatchPointerAndClick(window, buttons[4]); + dispatchPointerAndClick(window, buttons[5]); assert.deepEqual(calls, [ ['cancel', 'fallback-download'], ['pause', 'fallback-download'], ['resume', 'fallback-download'], + ['retry', 'fallback-download'], ['folder', 'test-model'], ['workflow', 'test-model'], ]); diff --git a/tests/test_download_progress_methods.mjs b/tests/test_download_progress_methods.mjs index a5ee28b..38bff54 100644 --- a/tests/test_download_progress_methods.mjs +++ b/tests/test_download_progress_methods.mjs @@ -13,6 +13,8 @@ test('download progress labels cover active, paused, checking, and terminal stat assert.equal(context.getDownloadProgressStatusLabel('downloading', 42.5, true), 'Finalizing'); assert.equal(context.getDownloadProgressStatusLabel('starting'), 'Starting'); assert.equal(context.getDownloadProgressStatusLabel('paused'), 'Paused'); + assert.equal(context.getDownloadProgressStatusLabel('error'), 'Interrupted'); + assert.equal(context.getDownloadProgressStatusLabel('refresh_error'), 'Interrupted'); assert.equal(context.getDownloadProgressStatusLabel('completed_checking'), 'Checking'); assert.equal(context.getDownloadProgressStatusLabel('cancelled'), 'cancelled'); assert.equal(context.getDownloadProgressStatusLabel(''), 'Download'); @@ -26,6 +28,10 @@ test('download progress status distinguishes active and terminal states', () => assert.equal(isActive('paused'), true); assert.equal(isActive('cancelling'), false); assert.equal(isActive('completed'), false); + assert.equal(isActive('error', true), true); + assert.equal(isActive('error', false), false); + assert.equal(isActive('refresh_error', true), true); + assert.equal(isActive('refresh_error', false), false); assert.equal(isActive('custom', true), true); assert.equal(isActive('custom', false), false); }); diff --git a/web/css/resolver-shell.css b/web/css/resolver-shell.css index f56b669..08a47ad 100644 --- a/web/css/resolver-shell.css +++ b/web/css/resolver-shell.css @@ -769,6 +769,29 @@ border-radius: var(--mr-missing-chip-radius); } +.mr-download-queue-item.mr-download-queue-item-error { + border-color: rgba(239, 68, 68, 0.35); +} + +.mr-download-queue-status.mr-download-queue-status-error { + color: var(--mr-color-danger-bright, #ff4d4f); + background: var(--mr-badge-error-bg, rgba(239, 68, 68, 0.16)); + border-color: var(--mr-badge-error-border, rgba(239, 68, 68, 0.34)); +} + +.mr-download-queue-progress .mr-progress-fill.mr-progress-fill-error { + background: var(--mr-color-danger-bright, #ff4d4f); + box-shadow: 0 0 10px rgba(239, 68, 68, 0.3); +} + +.mr-download-queue-stat.mr-download-queue-stat-error { + max-width: 220px; + overflow: hidden; + color: var(--mr-color-danger-bright, #ff4d4f); + text-overflow: ellipsis; + white-space: nowrap; +} + .mr-download-history-toolbar { display: flex; justify-content: flex-end; diff --git a/web/resolver/actions/download_progress_methods.js b/web/resolver/actions/download_progress_methods.js index 7811b8d..3ce38de 100644 --- a/web/resolver/actions/download_progress_methods.js +++ b/web/resolver/actions/download_progress_methods.js @@ -18,12 +18,13 @@ export const downloadProgressMethods = { } if (status === 'starting') return 'Starting'; if (status === 'paused') return 'Paused'; + if (status === 'error' || status === 'refresh_error') return 'Interrupted'; if (status === 'completed_checking') return 'Checking'; return String(status || '').replace(/_/g, ' ') || 'Download'; }, isDownloadProgressStatus(status = '', isActive = false) { - const terminalStatuses = new Set(['cancelling', 'cancelled', 'error', 'refresh_error', 'completed_checking', 'completed']); + const terminalStatuses = new Set(['cancelling', 'cancelled', 'completed_checking', 'completed']); return status === 'starting' || status === 'downloading' || status === 'paused' diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js index b6e86f6..b129972 100644 --- a/web/resolver/actions/queue_methods.js +++ b/web/resolver/actions/queue_methods.js @@ -1160,6 +1160,7 @@ export const queueMethods = { cancel: '.mr-download-queue-cancel', pause: '.mr-download-queue-pause', resume: '.mr-download-queue-resume', + retry: '.mr-download-queue-retry', openFolder: '.mr-download-queue-open-folder', switchWorkflow: '.mr-download-queue-switch-workflow', more: '.mr-download-queue-more', @@ -1169,6 +1170,7 @@ export const queueMethods = { onCancel: downloadId => this.cancelDownload(downloadId), onPause: downloadId => this.pauseDownload(downloadId), onResume: downloadId => this.resumeDownload(downloadId), + onRetry: downloadId => this.retryDownload?.(downloadId), onOpenFolder: context => this.openContainingFolder?.(context), onSwitchWorkflow: context => this.switchToDownloadWorkflow?.(context), moreBinding: 'click', @@ -1229,9 +1231,10 @@ export const queueMethods = { const backend = String(progress.download_backend || info.downloadBackend || '').toLowerCase(); const isAria2 = backend === 'aria2'; const isPaused = status === 'paused'; + const isError = status === 'error' || status === 'refresh_error'; const statusLabel = status === 'downloading' ? (presentation.isFinalizing ? 'Finalizing' : '') - : (status === 'starting' ? 'Starting' : (isPaused ? 'Paused' : status.replace(/_/g, ' '))); + : (status === 'starting' ? 'Starting' : (isPaused ? 'Paused' : (isError ? 'Interrupted' : status.replace(/_/g, ' ')))); const sizeTextBase = presentation.isFinalizing && logicalTotal ? `Finalizing file: ${logicalDownloaded} / ${logicalTotal}` : (presentation.totalSize ? `${downloaded} / ${total}` : downloaded); @@ -1260,21 +1263,27 @@ export const queueMethods = { `` ); - html += `
`; + html += `
`; html += `
`; html += `
`; html += `${this.escapeHtml(filename)}`; if (statusLabel) { - html += `${this.escapeHtml(statusLabel)}`; + const errorMessage = progress.error || info.statusSnapshot?.message || ''; + const errorTooltip = isError && errorMessage ? ` data-tooltip="${this.escapeHtml(errorMessage)}"` : ''; + html += `${this.escapeHtml(statusLabel)}`; } html += `
`; html += `
`; - html += actionButton( - isPaused ? 'mr-download-queue-resume' : 'mr-download-queue-pause', - isPaused ? 'Resume download' : 'Pause download', - isPaused ? 'play' : 'pause', - !isAria2 - ); + if (isError) { + html += actionButton('mr-download-queue-retry', 'Retry download', 'play'); + } else { + html += actionButton( + isPaused ? 'mr-download-queue-resume' : 'mr-download-queue-pause', + isPaused ? 'Resume download' : 'Pause download', + isPaused ? 'play' : 'pause', + !isAria2 + ); + } html += actionButton('mr-download-queue-cancel', 'Cancel download', 'x'); html += actionButton('mr-download-queue-open-folder', 'Open download folder', 'folderOpen', !hasFolderAction); if (showWorkflowAction) { @@ -1284,20 +1293,23 @@ export const queueMethods = { html += `
`; html += `
`; html += `
`; - html += `
`; + html += `
`; html += `
`; html += `${this.escapeHtml(sizeText)}`; html += ``; - if (speedText) { + if (speedText && !isError) { html += `${renderIcon('download', 'mr-download-queue-stat-icon')}${this.escapeHtml(speedText)}`; } - if (etaText) { + if (etaText && !isError) { html += `${this.escapeHtml(etaText)}`; } - if (connections > 0) { + if (connections > 0 && !isError) { html += `${renderIcon('network', 'mr-download-queue-stat-icon')}${connections}`; } - if (!speedText && !etaText) { + if (isError) { + const errorMessage = progress.error || info.statusSnapshot?.message || 'Download interrupted'; + html += `${this.escapeHtml(errorMessage)}`; + } else if (!speedText && !etaText) { html += `${this.escapeHtml(statusLabel)}`; } html += ``; diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 44e1e53..1ad1676 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -1132,6 +1132,7 @@ export const resolveDownloadMethods = { cancel: '.cancel-download-btn, .cancel-download-btn-pending, .mr-download-queue-cancel', pause: '.pause-download-btn, .mr-download-queue-pause', resume: '.resume-download-btn, .mr-download-queue-resume', + retry: '.retry-download-btn, .mr-download-queue-retry', openFolder: '.mr-download-queue-open-folder', switchWorkflow: '.mr-download-queue-switch-workflow', more: '.mr-download-queue-more', @@ -1140,6 +1141,7 @@ export const resolveDownloadMethods = { onCancel: targetDownloadId => this.cancelDownload(targetDownloadId), onPause: targetDownloadId => this.pauseDownload(targetDownloadId), onResume: targetDownloadId => this.resumeDownload(targetDownloadId), + onRetry: targetDownloadId => this.retryDownload?.(targetDownloadId), onOpenFolder: context => this.openContainingFolder?.(context), onSwitchWorkflow: context => this.switchToDownloadWorkflow?.(context), onMore: (event, button) => { @@ -1943,6 +1945,8 @@ export const resolveDownloadMethods = { downloadDirectory: '', baseDirectory, sourceUrl: url, + pathMetadata, + downloadMetadata, workflowKey, workflowRouteKey, workflowLabel, @@ -1995,6 +1999,17 @@ export const resolveDownloadMethods = { } const downloadId = data.download_id; + if (missing) { + for (const [existingId, existingInfo] of Object.entries(this.activeDownloads || {})) { + if (existingInfo?.missing === missing) { + const existingStatus = String(existingInfo.lastStatus || existingInfo.lastProgress?.status || '').toLowerCase(); + if (existingStatus === 'error' || existingStatus === 'refresh_error') { + delete this.activeDownloads[existingId]; + this.removeActiveDownloadRecovery?.(existingId); + } + } + } + } this.activeDownloads[downloadId] = { missing, progressDiv, @@ -2006,6 +2021,8 @@ export const resolveDownloadMethods = { downloadDirectory: data.directory || '', baseDirectory, sourceUrl: url, + pathMetadata, + downloadMetadata, workflowKey, workflowRouteKey, workflowLabel, @@ -2049,6 +2066,8 @@ export const resolveDownloadMethods = { downloadDirectory: '', baseDirectory, sourceUrl: url, + pathMetadata, + downloadMetadata, workflowKey, workflowRouteKey, workflowLabel, @@ -2253,11 +2272,13 @@ export const resolveDownloadMethods = { status: 'error', message: progress.error || 'Download failed', type: 'error', - isActive: false + isActive: true }); + info.lastStatus = 'error'; + info.lastProgress = progress; + info.statusSnapshot = errorSnapshot; this.renderDownloadSnapshot(downloadId, errorSnapshot, { progressDiv, downloadBtn }); - delete this.activeDownloads[downloadId]; - this.removeActiveDownloadRecovery?.(downloadId); + this.persistActiveDownloadRecovery?.(downloadId, this.activeDownloads[downloadId]); this.updateDownloadAllButtonState(); this.updateQueuePanel?.(); this.refreshLocalMatchesUiForMissing?.(missing); @@ -2279,18 +2300,20 @@ export const resolveDownloadMethods = { const snapshot = this.rememberDownloadUiState( downloadId, info, - { ...(info.lastProgress || {}), status: 'error' }, + { ...(info.lastProgress || {}), status: 'error', error: error.message }, { status: 'error', message: 'Connection lost - download may have failed', type: 'error', - isActive: false + isActive: true } ); + info.lastStatus = 'error'; + info.lastProgress = { ...(info.lastProgress || {}), status: 'error', error: error.message }; + info.statusSnapshot = snapshot; this.renderDownloadSnapshot(downloadId, snapshot); + this.persistActiveDownloadRecovery?.(downloadId, this.activeDownloads[downloadId]); } - delete this.activeDownloads[downloadId]; - this.removeActiveDownloadRecovery?.(downloadId); this.updateDownloadAllButtonState(); this.updateQueuePanel?.(); } @@ -2314,6 +2337,40 @@ export const resolveDownloadMethods = { */ cancelDownload(downloadId) { const info = this.activeDownloads[downloadId]; + const currentStatus = String( + info?.pendingDownloadStatus + || info?.lastStatus + || info?.lastProgress?.status + || '' + ).toLowerCase(); + + if (currentStatus === 'error' || currentStatus === 'refresh_error') { + delete this.activeDownloads[downloadId]; + this.removeActiveDownloadRecovery?.(downloadId); + if (info) { + const elements = this.resolveDownloadUiElements?.(info) || {}; + const cancelledSnapshot = this.rememberDownloadUiState( + downloadId, + info, + { ...(info.lastProgress || {}), status: 'cancelled' }, + { + status: 'cancelled', + message: 'Download dismissed', + type: 'info', + isActive: false + } + ); + this.renderDownloadSnapshot?.(downloadId, cancelledSnapshot, elements); + this.refreshLocalMatchesUiForMissing?.(info.missing); + } + this.updateDownloadAllButtonState?.(); + this.updateQueuePanel?.(); + this.fetchJson(`/model_resolver/cancel/${downloadId}`, { + method: 'POST' + }, 'Cancel download').catch(() => {}); + return; + } + if (info) { this.setPendingDownloadStatus(info, 'cancelling', 30000); const snapshot = this.rememberDownloadUiState( @@ -2432,8 +2489,62 @@ export const resolveDownloadMethods = { }); }, + async retryDownload(downloadId) { + const info = this.activeDownloads?.[downloadId]; + if (!info) return; + + const missing = info.missing; + const sourceUrl = info.sourceUrl || missing?.download_source?.url || ''; + const filename = info.filename || missing?.download_source?.filename || ''; + const category = info.category || missing?.category || 'checkpoints'; + const subfolder = info.subfolder || ''; + const baseDirectory = info.baseDirectory || ''; + const pathMetadata = info.pathMetadata || (missing && missing.download_source + ? this.getDownloadPathMetadata?.(missing, missing.download_source) + : null); + const downloadMetadata = info.downloadMetadata || (missing && missing.download_source + ? this.getDownloadMetadata?.(missing, missing.download_source, { + filename, + category, + url: sourceUrl, + pathMetadata + }) + : null); + + if (!sourceUrl) { + this.showNotification('Cannot retry download: missing download URL', 'error'); + return; + } + + delete this.activeDownloads[downloadId]; + this.removeActiveDownloadRecovery?.(downloadId); + + const elements = this.resolveDownloadUiElements?.(info) || {}; + const btn = elements.downloadBtn || info.downloadBtn || null; + + await this.executeDownloadRequest(missing, { + url: sourceUrl, + filename, + category, + subfolder, + baseDirectory, + pathMetadata, + downloadMetadata, + btn + }); + }, + resumeDownload(downloadId) { const info = this.activeDownloads[downloadId]; + const currentStatus = String( + info?.pendingDownloadStatus + || info?.lastStatus + || info?.lastProgress?.status + || '' + ).toLowerCase(); + if (currentStatus === 'error' || currentStatus === 'refresh_error') { + return this.retryDownload(downloadId); + } const previousProgress = info?.lastProgress ? { ...info.lastProgress } : null; if (info) { this.setPendingDownloadStatus(info, 'downloading'); diff --git a/web/resolver/utils/download_action_handlers.js b/web/resolver/utils/download_action_handlers.js index de67168..00c32a1 100644 --- a/web/resolver/utils/download_action_handlers.js +++ b/web/resolver/utils/download_action_handlers.js @@ -27,6 +27,7 @@ export function bindDownloadActionHandlers(root, { onCancel = null, onPause = null, onResume = null, + onRetry = null, onOpenFolder = null, onSwitchWorkflow = null, onMore = null, @@ -47,6 +48,7 @@ export function bindDownloadActionHandlers(root, { bindIdAction(selectors.cancel, onCancel); bindIdAction(selectors.pause, onPause); bindIdAction(selectors.resume, onResume); + bindIdAction(selectors.retry, onRetry); bindActionButtons(root, selectors.openFolder, (button) => { const context = resolveContext(button);