fix(web): wait for async plugin install to finish before auto-enabling it

Confirmed live: installing hockey-scoreboard logged "installation queued"
(success) immediately followed by "enabling it failed" with a 404 "Plugin
not found" from /api/v3/plugins/toggle.

/api/v3/plugins/install runs the actual clone + plugin-manager discovery
asynchronously via an operation queue when one is configured - the response
installPlugin() was checking only means the operation was queued, not that
the plugin is installed yet. Calling togglePlugin() right after that
response 404s because plugin_manager hasn't discovered the new plugin.

Fix: reuse the same operation-polling mechanism uninstallPlugin() already
has (generalized pollOperationStatus() to take onComplete/onFailed/onTimeout
callbacks instead of hardcoding uninstall behavior) so installPlugin() waits
for the operation to actually complete before enabling it. Falls back to
enabling immediately when no operation_id is returned (direct/synchronous
install path, no queue configured).
This commit is contained in:
ChuckBuilds
2026-07-16 20:20:10 -04:00
parent 99ea157fb2
commit 14df879e31
+51 -36
View File
@@ -3334,16 +3334,28 @@ window.uninstallPlugin = function(pluginId) {
}); });
} }
function pollOperationStatus(operationId, pluginId, pluginName, maxAttempts = 60, attempt = 0) { function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
const maxAttempts = options.maxAttempts || 60;
const attempt = options.attempt || 0;
const onComplete = options.onComplete || (() => handleUninstallSuccess(pluginId));
const onFailed = options.onFailed || ((errorMsg) => {
showNotification(errorMsg || `Operation failed for ${pluginName}`, 'error');
setTimeout(() => loadInstalledPlugins(), 1000);
});
const onTimeout = options.onTimeout || (() => {
showNotification(`Operation timed out for ${pluginName}`, 'error');
setTimeout(() => loadInstalledPlugins(), 1000);
});
if (attempt >= maxAttempts) { if (attempt >= maxAttempts) {
showNotification(`Uninstall operation timed out for ${pluginName}`, 'error'); onTimeout();
// Refresh plugin list to see actual state
setTimeout(() => {
loadInstalledPlugins();
}, 1000);
return; return;
} }
const pollAgain = () => setTimeout(() => {
pollOperationStatus(operationId, pluginId, pluginName, { ...options, attempt: attempt + 1 });
}, 1000);
fetch(`/api/v3/plugins/operation/${operationId}`) fetch(`/api/v3/plugins/operation/${operationId}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
@@ -3352,32 +3364,16 @@ function pollOperationStatus(operationId, pluginId, pluginName, maxAttempts = 60
const status = operation.status; const status = operation.status;
if (status === 'completed') { if (status === 'completed') {
// Operation completed successfully onComplete();
handleUninstallSuccess(pluginId);
} else if (status === 'failed') { } else if (status === 'failed') {
// Operation failed onFailed(operation.error || operation.message);
const errorMsg = operation.error || operation.message || `Failed to uninstall ${pluginName}`;
showNotification(errorMsg, 'error');
// Refresh plugin list to see actual state
setTimeout(() => {
loadInstalledPlugins();
}, 1000);
} else if (status === 'pending' || status === 'in_progress') {
// Still in progress, poll again
setTimeout(() => {
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
}, 1000); // Poll every second
} else { } else {
// Unknown status, poll again // 'pending', 'in_progress', or unknown - poll again
setTimeout(() => { pollAgain();
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
}, 1000);
} }
} else { } else {
// Error getting operation status, try again // Error getting operation status, try again
setTimeout(() => { pollAgain();
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
}, 1000);
} }
}) })
.catch(error => { .catch(error => {
@@ -3883,15 +3879,7 @@ window.installPlugin = function(pluginId, branch = null) {
requestBody.branch = branch; requestBody.branch = branch;
} }
fetch('/api/v3/plugins/install', { function enableAfterInstall() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
})
.then(response => response.json())
.then(data => {
showNotification(data.message, data.status);
if (data.status === 'success') {
// Enable immediately so install -> enable is one step; only nudge // Enable immediately so install -> enable is one step; only nudge
// for a restart once enablement actually succeeded (persistent // for a restart once enablement actually succeeded (persistent
// toast; duration 0 = stays until dismissed). // toast; duration 0 = stays until dismissed).
@@ -3917,6 +3905,33 @@ window.installPlugin = function(pluginId, branch = null) {
loadInstalledPlugins(); loadInstalledPlugins();
setTimeout(() => applyStoreFiltersAndSort(true), 500); setTimeout(() => applyStoreFiltersAndSort(true), 500);
} }
fetch('/api/v3/plugins/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
})
.then(response => response.json())
.then(data => {
showNotification(data.message, data.status);
if (data.status !== 'success') return;
if (data.data && data.data.operation_id) {
// Install runs async via the operation queue - this response only
// means "queued", not "installed". Enabling immediately here would
// 404 with "Plugin not found" against the toggle endpoint, since
// the plugin manager hasn't discovered the new plugin yet (seen
// live: "installation queued" followed immediately by a failed
// enable). Wait for the operation to actually finish first.
pollOperationStatus(data.data.operation_id, pluginId, pluginId, {
onComplete: enableAfterInstall,
onFailed: (errorMsg) => showNotification(errorMsg || `Failed to install ${pluginId}`, 'error'),
onTimeout: () => showNotification(`Install operation timed out for ${pluginId}`, 'error')
});
} else {
// No operation queue configured - install already completed synchronously.
enableAfterInstall();
}
}) })
.catch(error => { .catch(error => {
showNotification('Error installing plugin: ' + error.message, 'error'); showNotification('Error installing plugin: ' + error.message, 'error');