mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-07 06:20:15 -03:00
feat(downloads): show batch download summary with failure details and retry
This commit is contained in:
67
static/css/components/download-batch-summary.css
Normal file
67
static/css/components/download-batch-summary.css
Normal file
@@ -0,0 +1,67 @@
|
||||
/* Batch Download Summary Modal — component styles only.
|
||||
Stat cards and failure table styles are shared with the metadata refresh
|
||||
result modal (metadata-refresh-result.css) and are not redefined here. */
|
||||
|
||||
.download-batch-summary-modal {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.summary-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.summary-header i {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.summary-header.success i {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.summary-header.warning i {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.summary-header.error i {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--lora-text);
|
||||
}
|
||||
|
||||
.summary-hint {
|
||||
margin-left: auto;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
background: var(--lora-accent, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.btn-retry:hover {
|
||||
background: var(--lora-accent-hover, #4338ca);
|
||||
}
|
||||
|
||||
.failure-link {
|
||||
color: var(--lora-accent, #4f46e5);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.failure-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
@import 'components/sidebar.css'; /* Add sidebar component */
|
||||
@import 'components/media-viewer.css';
|
||||
@import 'components/metadata-refresh-result.css';
|
||||
@import 'components/download-batch-summary.css';
|
||||
|
||||
.initialization-notice {
|
||||
display: flex;
|
||||
|
||||
340
static/js/components/DownloadBatchSummaryModal.js
Normal file
340
static/js/components/DownloadBatchSummaryModal.js
Normal file
@@ -0,0 +1,340 @@
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { showToast, openHuggingFace } from '../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* Escape HTML entities in a string to prevent injection when interpolating into innerHTML.
|
||||
* Safe for both text content and attribute values (quotes are escaped too).
|
||||
* @param {string} str - The string to escape
|
||||
* @returns {string} - The escaped string
|
||||
*/
|
||||
function _escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the display name of a failed download entry.
|
||||
* Prefers the resolved name carried on the entry, then known item fields,
|
||||
* then derives a name from the item URL as a last resort.
|
||||
* @param {Object} entry - The failed entry ({ item, error, name? })
|
||||
* @returns {string} - The best available display name
|
||||
*/
|
||||
function _resolveItemName(entry) {
|
||||
if (entry?.name) {
|
||||
return entry.name;
|
||||
}
|
||||
const item = entry?.item ?? entry;
|
||||
const direct = item?.displayName || item?.name || item?.file_name || item?.filename || item?.selectedVersion?.name;
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
if (item?.url) {
|
||||
try {
|
||||
const segments = new URL(item.url).pathname.split('/').filter(Boolean);
|
||||
if (segments.length > 0) {
|
||||
return decodeURIComponent(segments[segments.length - 1]);
|
||||
}
|
||||
} catch (e) {
|
||||
// Unparseable URL — fall through to 'Unknown'
|
||||
}
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the URL to open for a failed item — always the original item URL.
|
||||
* @param {Object} item - The failed item payload
|
||||
* @returns {string|null} - A URL string, or null when nothing is available
|
||||
*/
|
||||
function _resolveItemUrl(item) {
|
||||
return item?.url || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a raw failure error into a concise human-readable message.
|
||||
* Unwraps JSON envelopes and extracts HTTP status/body details when present.
|
||||
* @param {*} error - The raw error (usually a string)
|
||||
* @returns {string} - The formatted error message
|
||||
*/
|
||||
function _formatError(error) {
|
||||
if (!error) {
|
||||
return 'Unknown error';
|
||||
}
|
||||
let base = typeof error === 'string' ? error : String(error);
|
||||
|
||||
// Unwrap JSON envelope: { "success": false, "error": "...", ... }
|
||||
try {
|
||||
const parsed = JSON.parse(base);
|
||||
if (parsed && typeof parsed.error === 'string' && parsed.error) {
|
||||
base = parsed.error;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a JSON envelope — keep the raw string
|
||||
}
|
||||
|
||||
// Extract HTTP status and JSON body details, e.g. "status=403 body={...}"
|
||||
let result = base;
|
||||
const statusMatch = base.match(/status=(\d{3})/);
|
||||
const bodyMatch = base.match(/body=(\{.*\})/s);
|
||||
if (bodyMatch) {
|
||||
try {
|
||||
const body = JSON.parse(bodyMatch[1]);
|
||||
const detail = (typeof body?.message === 'string' && body.message)
|
||||
|| (typeof body?.error === 'string' && body.error)
|
||||
|| null;
|
||||
if (detail) {
|
||||
const status = statusMatch ? statusMatch[1] : null;
|
||||
result = `${status ? `HTTP ${status} — ` : ''}${detail}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// Body is not valid JSON — keep the base string
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate overly long messages
|
||||
if (result.length > 220) {
|
||||
result = result.slice(0, 220) + '…';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a plain-text report of the batch download results.
|
||||
* @param {number} total - Total number of models attempted
|
||||
* @param {number} completed - Number of models successfully downloaded
|
||||
* @param {Array} failedItems - Array of failed items ({ item, error })
|
||||
* @returns {string} - The report text
|
||||
*/
|
||||
function _buildReportText(total, completed, failedItems) {
|
||||
const lines = [
|
||||
'=== Batch Download Report ===',
|
||||
`Date: ${new Date().toLocaleString()}`,
|
||||
`Total: ${total}`,
|
||||
`Successfully downloaded: ${completed}`,
|
||||
`Failed: ${failedItems.length}`,
|
||||
'',
|
||||
];
|
||||
if (failedItems.length > 0) {
|
||||
lines.push('--- Failed Items ---');
|
||||
failedItems.forEach((entry, i) => {
|
||||
const name = _resolveItemName(entry);
|
||||
const error = _formatError(entry?.error);
|
||||
lines.push(`${i + 1}. ${name} — ${error}`);
|
||||
const itemUrl = _resolveItemUrl(entry?.item ?? entry);
|
||||
if (itemUrl) {
|
||||
lines.push(` URL: ${itemUrl}`);
|
||||
}
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('====================');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a successful clipboard write: confirm via toast and briefly swap the
|
||||
* trigger button to a "Copied!" state.
|
||||
* @param {HTMLElement|null} btn - The button that triggered the copy action
|
||||
*/
|
||||
function _onCopyReportSuccess(btn) {
|
||||
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||
if (btn) {
|
||||
const origHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for environments without the async Clipboard API (e.g. insecure
|
||||
* contexts over LAN http where `navigator.clipboard` is undefined): copy via a
|
||||
* hidden textarea and `document.execCommand('copy')`.
|
||||
* @param {string} text - The report text to copy
|
||||
*/
|
||||
function _copyReportWithExecCommand(text) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the batch download report to the clipboard.
|
||||
* Uses the async Clipboard API when available, otherwise falls back to a hidden
|
||||
* textarea + execCommand so the action still works in insecure contexts.
|
||||
* @param {HTMLElement} btn - The button that triggered the copy action
|
||||
* @param {number} total - Total number of models attempted
|
||||
* @param {number} completed - Number of models successfully downloaded
|
||||
* @param {Array} failedItems - Array of failed items
|
||||
*/
|
||||
function _copyReport(btn, total, completed, failedItems) {
|
||||
const text = _buildReportText(total, completed, failedItems);
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => _onCopyReportSuccess(btn))
|
||||
.catch(() => _copyReportWithExecCommand(text));
|
||||
} else {
|
||||
_copyReportWithExecCommand(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the batch download summary modal after a batch download completes.
|
||||
* Mirrors the Metadata Fetch Summary modal 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 {number} options.total - Total number of models attempted
|
||||
* @param {number} options.completed - Number of models successfully downloaded
|
||||
* @param {Array} options.failedItems - Array of failed items ({ item, error })
|
||||
* @param {Function} options.onRetry - Callback invoked with failedItems to retry the failed subset
|
||||
*/
|
||||
export function showDownloadBatchSummary({ total, completed, failedItems, onRetry }) {
|
||||
const failures = failedItems || [];
|
||||
const failedCount = failures.length;
|
||||
|
||||
// 3-state summary header semantics (mirrors BatchImportManager results header)
|
||||
let headerState;
|
||||
let headerIcon;
|
||||
let headerText;
|
||||
if (completed === 0) {
|
||||
headerState = 'error';
|
||||
headerIcon = 'fa-times-circle';
|
||||
headerText = translate('modals.downloadBatchSummary.failed', {}, 'Download failed');
|
||||
} else if (failedCount > 0) {
|
||||
headerState = 'warning';
|
||||
headerIcon = 'fa-exclamation-circle';
|
||||
headerText = translate('modals.downloadBatchSummary.completedWithErrors', {}, 'Completed with errors');
|
||||
} else {
|
||||
headerState = 'success';
|
||||
headerIcon = 'fa-check-circle';
|
||||
headerText = translate('modals.downloadBatchSummary.successMessage', { count: completed }, 'All ' + completed + ' models downloaded successfully');
|
||||
}
|
||||
|
||||
// Build failure table rows
|
||||
const failureRows = failures.map((entry, i) => {
|
||||
const item = entry?.item ?? entry;
|
||||
const name = _resolveItemName(entry);
|
||||
const itemUrl = _resolveItemUrl(item);
|
||||
const rawError = entry?.error ? String(entry.error) : '';
|
||||
const error = _formatError(entry?.error);
|
||||
const nameCell = itemUrl
|
||||
? `<td class="failure-name"><a href="#" class="failure-link" data-action="open-model" data-index="${i}" title="${_escapeHtml(itemUrl)}">${_escapeHtml(name)}</a></td>`
|
||||
: `<td class="failure-name" title="${_escapeHtml(name)}">${_escapeHtml(name)}</td>`;
|
||||
return `<tr>
|
||||
<td class="failure-index">${i + 1}</td>
|
||||
${nameCell}
|
||||
<td class="failure-error" title="${_escapeHtml(rawError)}">${_escapeHtml(error)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const modalHtml = `
|
||||
<div id="downloadBatchSummaryModal" class="modal" style="display: block;">
|
||||
<div class="modal-content download-batch-summary-modal">
|
||||
<button class="close" data-action="close-modal">×</button>
|
||||
|
||||
<h2>${translate('modals.downloadBatchSummary.title', {}, 'Batch Download Summary')}</h2>
|
||||
|
||||
<div class="summary-header ${headerState}">
|
||||
<i class="fas ${headerIcon}"></i>
|
||||
<span class="summary-title">${headerText}</span>
|
||||
<span class="summary-hint">${completed}/${total}</span>
|
||||
</div>
|
||||
|
||||
<div class="refresh-summary-stats">
|
||||
<div class="stat-card stat-card-success">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.downloadBatchSummary.statSuccess', {}, 'Success')}</span>
|
||||
<span class="stat-card-value">${completed}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-failure">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.downloadBatchSummary.statFailed', {}, 'Failed')}</span>
|
||||
<span class="stat-card-value">${failedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-total">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.downloadBatchSummary.statTotal', {}, 'Total')}</span>
|
||||
<span class="stat-card-value">${total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${failedCount > 0 ? `
|
||||
<div class="refresh-failures-section">
|
||||
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.downloadBatchSummary.failedItems', { count: failedCount }, 'Failed Items (' + failedCount + ')')}</h4>
|
||||
<div class="failure-table-wrapper">
|
||||
<table class="failure-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>${translate('modals.downloadBatchSummary.columnName', {}, 'Model Name')}</th>
|
||||
<th>${translate('modals.downloadBatchSummary.columnError', {}, 'Error')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${failureRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="refresh-success-message">
|
||||
<i class="fas fa-check-circle"></i> ${translate('modals.downloadBatchSummary.successMessage', { count: completed }, 'All ' + completed + ' models downloaded successfully')}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="modal-actions">
|
||||
${failedCount > 0 ? `
|
||||
<button class="btn-retry" data-action="retry-failed"><i class="fas fa-redo"></i> ${translate('modals.downloadBatchSummary.retryFailed', { count: failedCount }, 'Retry Failed (' + failedCount + ')')}</button>
|
||||
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.downloadBatchSummary.copyReport', {}, 'Copy Report')}</button>
|
||||
` : ''}
|
||||
<button class="cancel-btn" data-action="close-modal">${translate('modals.downloadBatchSummary.close', {}, 'Close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const existing = document.getElementById('downloadBatchSummaryModal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = modalHtml;
|
||||
const modal = container.firstElementChild;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
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':
|
||||
modal.remove();
|
||||
break;
|
||||
case 'retry-failed':
|
||||
modal.remove();
|
||||
if (typeof onRetry === 'function') {
|
||||
onRetry(failures);
|
||||
}
|
||||
break;
|
||||
case 'copy-report':
|
||||
_copyReport(actionEl, total, completed, failures);
|
||||
break;
|
||||
case 'open-model': {
|
||||
// Keep the modal open; just open the item's original URL in a new tab
|
||||
const entry = failures[Number(actionEl.dataset.index)];
|
||||
const item = entry?.item;
|
||||
if (!item?.url) break;
|
||||
openHuggingFace(item.url);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
|
||||
import { formatFileSize } from '../utils/formatters.js';
|
||||
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
|
||||
|
||||
export class DownloadManager {
|
||||
constructor() {
|
||||
@@ -1548,6 +1549,10 @@ export class DownloadManager {
|
||||
|
||||
modalManager.closeModal('downloadModal');
|
||||
|
||||
return this.executeBatchDownload(downloadItems, { modelRoot, targetFolder, useDefaultPaths });
|
||||
}
|
||||
|
||||
async executeBatchDownload(downloadItems, { modelRoot, targetFolder, useDefaultPaths }) {
|
||||
const batchDownloadId = Date.now().toString();
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
|
||||
@@ -1558,6 +1563,7 @@ export class DownloadManager {
|
||||
let completedDownloads = 0;
|
||||
let failedDownloads = 0;
|
||||
let cancelled = false;
|
||||
const failedItems = [];
|
||||
|
||||
loadingManager.showCancelButton(async () => {
|
||||
if (cancelled) return;
|
||||
@@ -1658,6 +1664,7 @@ export class DownloadManager {
|
||||
|
||||
if (!response.success) {
|
||||
failedDownloads++;
|
||||
failedItems.push({ item, error: response.error || 'Unknown error', name });
|
||||
} else {
|
||||
completedDownloads++;
|
||||
updateProgress(100, completedDownloads, '');
|
||||
@@ -1666,6 +1673,7 @@ export class DownloadManager {
|
||||
if (!cancelled) {
|
||||
console.error(`Failed to download ${name}:`, err);
|
||||
failedDownloads++;
|
||||
failedItems.push({ item, error: err?.message || 'Unknown error', name });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1679,10 +1687,15 @@ export class DownloadManager {
|
||||
} else if (failedDownloads === 0) {
|
||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||
} else {
|
||||
showToast('toast.loras.downloadPartialSuccess', {
|
||||
completed: completedDownloads,
|
||||
showDownloadBatchSummary({
|
||||
total: downloadItems.length,
|
||||
}, 'warning');
|
||||
completed: completedDownloads,
|
||||
failedItems,
|
||||
onRetry: (failed) => this.executeBatchDownload(
|
||||
failed.map((f) => f.item),
|
||||
{ modelRoot, targetFolder, useDefaultPaths }
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
await resetAndReload(true);
|
||||
|
||||
Reference in New Issue
Block a user