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:
Dariusz L
2026-06-19 21:36:01 +02:00
parent c3caeb0a24
commit 2e9ffb05a8
5 changed files with 147 additions and 10 deletions
File diff suppressed because one or more lines are too long
+24 -1
View File
@@ -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();
+76 -6
View File
@@ -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
*/