feat: add per-source search cancellation support
Implement AbortController-based cancellation for individual search sources during background resolution. Users can now cancel a single pending/running source search via a new cancel button in the progress UI, while other sources continue uninterrupted. Add cancelled status tracking, source-level AbortController management, CSS styling for the cancelled state, and cleanup on job teardown. Also refactor getBackgroundSearchJob for consistent job lookup across callers.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -2187,6 +2187,9 @@
|
||||
.mr-search-progress-list-compact .mr-search-progress-error::before {
|
||||
background: #ff8a80;
|
||||
}
|
||||
.mr-search-progress-list-compact .mr-search-progress-cancelled::before {
|
||||
background: rgba(255,255,255,0.38);
|
||||
}
|
||||
.mr-search-progress-list-compact .mr-search-progress-source,
|
||||
.mr-search-progress-list-compact .mr-search-progress-status {
|
||||
min-width: 0;
|
||||
@@ -2227,11 +2230,28 @@
|
||||
color: var(--mr-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mr-search-progress-head span:last-child {
|
||||
.mr-search-progress-head .mr-search-progress-status {
|
||||
flex: 0 1 auto;
|
||||
max-width: 55%;
|
||||
text-align: right;
|
||||
}
|
||||
#model-resolver-modal .mr-search-result-action-btn.mr-search-progress-cancel,
|
||||
#model-resolver-modal .mr-missing-detail-pane .mr-search-result-action-btn.mr-search-progress-cancel {
|
||||
border-color: rgba(255, 120, 112, 0.28);
|
||||
background: rgba(255, 120, 112, 0.055);
|
||||
color: #ff8a80;
|
||||
}
|
||||
#model-resolver-modal .mr-search-result-action-btn.mr-search-progress-cancel:hover:not(:disabled),
|
||||
#model-resolver-modal .mr-missing-detail-pane .mr-search-result-action-btn.mr-search-progress-cancel:hover:not(:disabled) {
|
||||
background: rgba(255, 120, 112, 0.12);
|
||||
border-color: rgba(255, 120, 112, 0.48);
|
||||
color: #ffd2ce;
|
||||
}
|
||||
#model-resolver-modal .mr-search-result-action-btn.mr-search-progress-cancel svg,
|
||||
#model-resolver-modal .mr-missing-detail-pane .mr-search-result-action-btn.mr-search-progress-cancel svg {
|
||||
color: currentColor;
|
||||
stroke: currentColor;
|
||||
}
|
||||
.mr-search-progress-bar {
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
@@ -2256,6 +2276,9 @@
|
||||
.mr-search-progress-error .mr-search-progress-fill {
|
||||
background: #ff8a80;
|
||||
}
|
||||
.mr-search-progress-cancelled .mr-search-progress-fill {
|
||||
background: rgba(255,255,255,0.28);
|
||||
}
|
||||
.mr-combo-section {
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -1486,6 +1486,8 @@ export const resolveDownloadMethods = {
|
||||
runId: searchRunId,
|
||||
missing,
|
||||
startedAt: Date.now(),
|
||||
sourceControllers: new Map(),
|
||||
cancelledSources: new Set(),
|
||||
promise: null
|
||||
};
|
||||
this.backgroundSearchJobs.set(backgroundJobKey, backgroundJob);
|
||||
@@ -1559,15 +1561,31 @@ export const resolveDownloadMethods = {
|
||||
is_urn: sourceIsUrn,
|
||||
sources: [source]
|
||||
};
|
||||
const sourceController = typeof AbortController !== 'undefined'
|
||||
? new AbortController()
|
||||
: null;
|
||||
const currentJob = this.backgroundSearchJobs.get(backgroundJobKey);
|
||||
if (sourceController && currentJob?.runId === searchRunId) {
|
||||
currentJob.sourceControllers?.set(source, sourceController);
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.isSearchSourceCancelled?.(workflowKey, missingSearchKey, searchRunId, source)) {
|
||||
return { source, cancelled: true };
|
||||
}
|
||||
|
||||
log.debug('Model Resolver: Search request:', JSON.stringify(searchData));
|
||||
|
||||
const response = await api.fetchApi('/model_resolver/search', {
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(searchData)
|
||||
});
|
||||
};
|
||||
if (sourceController) {
|
||||
requestOptions.signal = sourceController.signal;
|
||||
}
|
||||
|
||||
const response = await api.fetchApi('/model_resolver/search', requestOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed: ${response.status}`);
|
||||
@@ -1579,6 +1597,9 @@ export const resolveDownloadMethods = {
|
||||
if (!this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, searchRunId)) {
|
||||
return { source, stale: true };
|
||||
}
|
||||
if (this.isSearchSourceCancelled?.(workflowKey, missingSearchKey, searchRunId, source)) {
|
||||
return { source, cancelled: true };
|
||||
}
|
||||
if (baseModelContext !== this.getSearchBaseModelContext(missing)) {
|
||||
return { source, stale: true };
|
||||
}
|
||||
@@ -1637,6 +1658,13 @@ export const resolveDownloadMethods = {
|
||||
this.applySearchResultSuggestion(missing);
|
||||
return { source, data };
|
||||
} catch (error) {
|
||||
const wasCancelled = error?.name === 'AbortError'
|
||||
|| this.isSearchSourceCancelled?.(workflowKey, missingSearchKey, searchRunId, source);
|
||||
if (wasCancelled) {
|
||||
this.clearSearchProgressTimer(searchRunId, source);
|
||||
return { source, cancelled: true };
|
||||
}
|
||||
|
||||
console.error(`Model Resolver: Search error for ${source}:`, error);
|
||||
if (!this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, searchRunId)) {
|
||||
return { source, stale: true, error };
|
||||
@@ -1655,6 +1683,11 @@ export const resolveDownloadMethods = {
|
||||
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
||||
this.refreshSearchUiForMissing(missing, state, { workflowKey });
|
||||
return { source, error };
|
||||
} finally {
|
||||
const cleanupJob = this.backgroundSearchJobs.get(backgroundJobKey);
|
||||
if (cleanupJob?.runId === searchRunId) {
|
||||
cleanupJob.sourceControllers?.delete(source);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1992,11 +2025,13 @@ export const resolveDownloadMethods = {
|
||||
if (!hasResults) {
|
||||
if (hasActiveProgress) {
|
||||
container.innerHTML = progressHtml;
|
||||
this.wireSearchProgressCancelButtons?.(container, missing, state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state?.lastAttemptError) {
|
||||
container.innerHTML = `${progressHtml}${this.renderStatusMessage(state.lastAttemptError, 'error')}`;
|
||||
this.wireSearchProgressCancelButtons?.(container, missing, state);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2004,6 +2039,7 @@ export const resolveDownloadMethods = {
|
||||
'No matches found online for this model.',
|
||||
'warning'
|
||||
);
|
||||
this.wireSearchProgressCancelButtons?.(container, missing, state);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2192,6 +2228,7 @@ export const resolveDownloadMethods = {
|
||||
const html = `${progressHtml}${statusHtml}${this.renderSearchResultsTable(rows)}`;
|
||||
container.innerHTML = html;
|
||||
|
||||
this.wireSearchProgressCancelButtons?.(container, missing, state);
|
||||
this.wireSearchDownloadButtons(container, missing);
|
||||
},
|
||||
|
||||
|
||||
@@ -1531,6 +1531,13 @@ export const downloadTargetMethods = {
|
||||
state.activeSearchRunId = null;
|
||||
}
|
||||
this.clearAllSearchProgressTimers();
|
||||
for (const job of this.backgroundSearchJobs?.values?.() || []) {
|
||||
for (const controller of job.sourceControllers?.values?.() || []) {
|
||||
try {
|
||||
controller.abort();
|
||||
} catch (error) { /* ignore abort cleanup failures */ }
|
||||
}
|
||||
}
|
||||
this.backgroundSearchJobs?.clear();
|
||||
this.searchResultCache.clear();
|
||||
this.workflowSearchResultCaches.clear();
|
||||
|
||||
@@ -268,10 +268,14 @@ export const searchPanelMethods = {
|
||||
return `${workflowKey || 'workflow'}\n${missingSearchKey || 'missing'}`;
|
||||
},
|
||||
|
||||
hasBackgroundSearchJob(workflowKey, missingSearchKey, runId = null) {
|
||||
const job = this.backgroundSearchJobs?.get(
|
||||
getBackgroundSearchJob(workflowKey, missingSearchKey) {
|
||||
return this.backgroundSearchJobs?.get(
|
||||
this.getBackgroundSearchJobKey(workflowKey, missingSearchKey)
|
||||
);
|
||||
) || null;
|
||||
},
|
||||
|
||||
hasBackgroundSearchJob(workflowKey, missingSearchKey, runId = null) {
|
||||
const job = this.getBackgroundSearchJob(workflowKey, missingSearchKey);
|
||||
if (!job) return false;
|
||||
return !runId || job.runId === runId;
|
||||
},
|
||||
@@ -280,6 +284,11 @@ export const searchPanelMethods = {
|
||||
return this.hasBackgroundSearchJob(workflowKey, missingSearchKey, runId);
|
||||
},
|
||||
|
||||
isSearchSourceCancelled(workflowKey, missingSearchKey, runId, source) {
|
||||
const job = this.getBackgroundSearchJob(workflowKey, missingSearchKey);
|
||||
return Boolean(job?.runId === runId && job.cancelledSources?.has(source));
|
||||
},
|
||||
|
||||
getWorkflowSearchCache(workflowKey, { create = false } = {}) {
|
||||
if (!workflowKey) return null;
|
||||
let cache = this.workflowSearchResultCaches.get(workflowKey);
|
||||
@@ -789,7 +798,8 @@ export const searchPanelMethods = {
|
||||
running: 'Searching...',
|
||||
found: 'Found',
|
||||
none: 'No match',
|
||||
error: 'Error'
|
||||
error: 'Error',
|
||||
cancelled: 'Cancelled'
|
||||
};
|
||||
|
||||
if (isCompact) {
|
||||
@@ -816,6 +826,7 @@ export const searchPanelMethods = {
|
||||
const status = progress?.status || 'pending';
|
||||
const statusClass = String(status).replace(/[^a-z0-9_-]/gi, '');
|
||||
const label = this.getSearchSourceLabel(source);
|
||||
const canCancel = state.activeSearchRunId && (status === 'pending' || status === 'running');
|
||||
const percent = status === 'pending'
|
||||
? 0
|
||||
: (status === 'running' ? progress?.percent : 100);
|
||||
@@ -824,11 +835,15 @@ export const searchPanelMethods = {
|
||||
? `Searching... ${Math.round(safePercent)}%`
|
||||
: (progress?.message || statusLabels[status] || status);
|
||||
const title = progress?.error ? ` data-tooltip="${this.escapeHtml(`${label}: ${progress.error}`)}"` : '';
|
||||
const cancelButton = canCancel
|
||||
? `<button type="button" class="mr-search-result-action-btn mr-search-progress-cancel" data-source="${this.escapeHtml(source)}" data-run-id="${this.escapeHtml(state.activeSearchRunId)}" data-tooltip="Cancel ${this.escapeHtml(label)} search" aria-label="Cancel ${this.escapeHtml(label)} search">${getSvgIcon('x')}</button>`
|
||||
: '';
|
||||
html += `
|
||||
<div class="mr-search-progress-item mr-search-progress-${statusClass}"${title}>
|
||||
<div class="mr-search-progress-head">
|
||||
<span>${this.escapeHtml(label)}</span>
|
||||
<span>${this.escapeHtml(statusLabel)}</span>
|
||||
<span class="mr-search-progress-source">${this.escapeHtml(label)}</span>
|
||||
<span class="mr-search-progress-status">${this.escapeHtml(statusLabel)}</span>
|
||||
${cancelButton}
|
||||
</div>
|
||||
<div class="mr-search-progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${safePercent}">
|
||||
<div class="mr-search-progress-fill" style="width: ${safePercent}%;"></div>
|
||||
@@ -840,6 +855,61 @@ export const searchPanelMethods = {
|
||||
return html;
|
||||
},
|
||||
|
||||
wireSearchProgressCancelButtons(container, missing, state = null, { workflowKey = this.getWorkflowScopedQueueKey() } = {}) {
|
||||
if (!container || !missing) return;
|
||||
container.querySelectorAll?.('.mr-search-progress-cancel').forEach(button => {
|
||||
if (button.dataset.mlCancelSearchBound === '1') return;
|
||||
button.dataset.mlCancelSearchBound = '1';
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const source = button.dataset.source || '';
|
||||
const runId = button.dataset.runId || state?.activeSearchRunId || '';
|
||||
this.cancelSearchSource(missing, source, runId, { workflowKey });
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
cancelSearchSource(missing, source, runId = '', { workflowKey = this.getWorkflowScopedQueueKey() } = {}) {
|
||||
if (!missing || !source || !runId) return false;
|
||||
|
||||
const missingSearchKey = this.getMissingSearchKey(missing);
|
||||
const state = this.getSearchStateForWorkflow(workflowKey, missing);
|
||||
const job = this.getBackgroundSearchJob(workflowKey, missingSearchKey);
|
||||
if (!state || state.activeSearchRunId !== runId || !job || job.runId !== runId) return false;
|
||||
|
||||
job.cancelledSources = job.cancelledSources || new Set();
|
||||
job.cancelledSources.add(source);
|
||||
const controller = job.sourceControllers?.get(source);
|
||||
if (controller && !controller.signal?.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
this.clearSearchProgressTimer(runId, source);
|
||||
this.setSourceProgress(state, source, {
|
||||
status: 'cancelled',
|
||||
percent: 100,
|
||||
message: 'Cancelled',
|
||||
error: null
|
||||
}, missing, { workflowKey });
|
||||
|
||||
const hasRunningSources = Object.entries(state.sourceProgress || {}).some(([entrySource, progress]) => (
|
||||
entrySource !== source
|
||||
&& (progress?.status === 'pending' || progress?.status === 'running')
|
||||
&& !job.cancelledSources?.has(entrySource)
|
||||
));
|
||||
|
||||
if (!hasRunningSources) {
|
||||
state.activeSearchRunId = null;
|
||||
this.clearSearchProgressTimers(runId);
|
||||
this.backgroundSearchJobs?.delete(this.getBackgroundSearchJobKey(workflowKey, missingSearchKey));
|
||||
}
|
||||
|
||||
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
||||
this.refreshSearchUiForMissing(missing, state, { workflowKey });
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update source selector buttons and helper text for one card
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user