From 9c087f4fe3456a5ff753b7a02ba4accc5a48cc9f Mon Sep 17 00:00:00 2001 From: Nico Date: Thu, 26 Mar 2026 11:38:08 -0300 Subject: [PATCH 1/2] kraken: support should_enable parameter for extension install/update --- core/services/kraken/api/v2/routers/extension.py | 4 ++-- core/services/kraken/extension/extension.py | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/core/services/kraken/api/v2/routers/extension.py b/core/services/kraken/api/v2/routers/extension.py index e05952b297..ef2451c91a 100644 --- a/core/services/kraken/api/v2/routers/extension.py +++ b/core/services/kraken/api/v2/routers/extension.py @@ -149,13 +149,13 @@ async def update_to_latest(identifier: str, purge: bool = True, stable: bool = T @extension_router_v2.put("/{identifier}/{tag}", status_code=status.HTTP_200_OK) @extension_to_http_exception -async def update_to_tag(identifier: str, tag: str, purge: bool = True) -> Response: +async def update_to_tag(identifier: str, tag: str, purge: bool = True, should_enable: bool = True) -> Response: """ Update a given extension by its identifier and tag to latest version on the higher priority manifest and by default purge all other tags, if purge is set to false it will keep all other versions disabled only. """ extension = cast(Extension, await Extension.from_manifest(identifier, tag)) - return StreamingResponse(streamer(extension.update(purge))) + return StreamingResponse(streamer(extension.update(purge, should_enable))) @extension_router_v2.delete("/{identifier}", status_code=status.HTTP_202_ACCEPTED) diff --git a/core/services/kraken/extension/extension.py b/core/services/kraken/extension/extension.py index 53257a2649..f04034bcc7 100644 --- a/core/services/kraken/extension/extension.py +++ b/core/services/kraken/extension/extension.py @@ -171,7 +171,7 @@ async def _disable_running_extension(self) -> Optional["Extension"]: except ExtensionNotRunning: return None - def _create_extension_settings(self) -> ExtensionSettings: + def _create_extension_settings(self, should_enable: bool = True) -> ExtensionSettings: """Create and save extension settings.""" new_extension = ExtensionSettings( identifier=self.identifier, @@ -179,7 +179,7 @@ def _create_extension_settings(self) -> ExtensionSettings: docker=self.source.docker, tag=self.tag, permissions=self.source.permissions, - enabled=True, + enabled=should_enable, user_permissions=self.source.user_permissions, ) # Save in settings first, if the image fails to install it will try to fetch after in main kraken check loop @@ -223,13 +223,15 @@ async def _clear_remaining_tags(self) -> None: to_clear = [version for version in to_clear if version.source.tag != self.tag] await asyncio.gather(*(version.uninstall() for version in to_clear)) - async def install(self, clear_remaining_tags: bool = True, atomic: bool = False) -> AsyncGenerator[bytes, None]: + async def install( + self, clear_remaining_tags: bool = True, atomic: bool = False, should_enable: bool = True + ) -> AsyncGenerator[bytes, None]: logger.info(f"Installing extension {self.identifier}:{self.tag}") # First we should make sure no other tag is running running_ext = await self._disable_running_extension() - self._create_extension_settings() + self._create_extension_settings(should_enable) try: self.lock(self.unique_entry) @@ -262,8 +264,8 @@ async def install(self, clear_remaining_tags: bool = True, atomic: bool = False) if clear_remaining_tags: await self._clear_remaining_tags() - async def update(self, clear_remaining_tags: bool) -> AsyncGenerator[bytes, None]: - async for data in self.install(clear_remaining_tags): + async def update(self, clear_remaining_tags: bool, should_enable: bool = True) -> AsyncGenerator[bytes, None]: + async for data in self.install(clear_remaining_tags, should_enable=should_enable): yield data async def uninstall(self) -> None: From 453b3c16c5846b937da58cf41922d8db6ac4f583 Mon Sep 17 00:00:00 2001 From: Nico Date: Thu, 26 Mar 2026 11:38:48 -0300 Subject: [PATCH 2/2] frontend: kraken: add cancellation support for extension install and update --- .../src/components/kraken/KrakenManager.ts | 23 ++++ .../src/components/utils/PullProgress.vue | 9 ++ .../src/views/ExtensionManagerView.vue | 111 +++++++++++++----- 3 files changed, 116 insertions(+), 27 deletions(-) diff --git a/core/frontend/src/components/kraken/KrakenManager.ts b/core/frontend/src/components/kraken/KrakenManager.ts index efbcbee1a7..065f2f4e79 100644 --- a/core/frontend/src/components/kraken/KrakenManager.ts +++ b/core/frontend/src/components/kraken/KrakenManager.ts @@ -194,6 +194,7 @@ export async function setManifestSourceOrder(identifier: string, order: number): export async function installExtension( extension: InstalledExtensionData, progressHandler: (event: any) => void, + signal?: AbortSignal, ): Promise { await back_axios({ url: `${KRAKEN_API_V2_URL}/extension/install`, @@ -209,6 +210,7 @@ export async function installExtension( }, timeout: 600000, onDownloadProgress: progressHandler, + signal, }) } @@ -248,6 +250,18 @@ export async function uninstallExtension(identifier: string): Promise { }) } +/** + * Uninstall a specific version of an extension by its identifier and tag, uses API v2 + * @param {string} identifier The identifier of the extension + * @param {string} tag The tag of the version to uninstall + */ +export async function uninstallExtensionVersion(identifier: string, tag: string): Promise { + await back_axios({ + method: 'DELETE', + url: `${KRAKEN_API_V2_URL}/extension/${identifier}/${tag}`, + }) +} + /** * Restart an extension by its identifier, uses API v2 * @param {string} identifier The identifier of the extension @@ -270,12 +284,18 @@ export async function updateExtensionToVersion( identifier: string, version: string, progressHandler: (event: any) => void, + signal?: AbortSignal, ): Promise { await back_axios({ url: `${KRAKEN_API_V2_URL}/extension/${identifier}/${version}`, + params: { + purge: false, + should_enable: false, + }, method: 'PUT', timeout: 120000, onDownloadProgress: progressHandler, + signal, }) } @@ -387,6 +407,7 @@ export async function finalizeExtension( extension: InstalledExtensionData, tempTag: string, progressHandler: (event: any) => void, + signal?: AbortSignal, ): Promise { await back_axios({ method: 'POST', @@ -402,6 +423,7 @@ export async function finalizeExtension( }, timeout: 120000, onDownloadProgress: progressHandler, + signal, }) } @@ -423,6 +445,7 @@ export default { enableExtension, disableExtension, uninstallExtension, + uninstallExtensionVersion, restartExtension, listContainers, getContainersStats, diff --git a/core/frontend/src/components/utils/PullProgress.vue b/core/frontend/src/components/utils/PullProgress.vue index 001a8d4df7..b0293d6050 100755 --- a/core/frontend/src/components/utils/PullProgress.vue +++ b/core/frontend/src/components/utils/PullProgress.vue @@ -50,6 +50,11 @@ + + + Cancel + + @@ -80,6 +85,10 @@ export default Vue.extend({ type: String, required: true, }, + cancelable: { + type: Boolean, + default: false, + }, }, data() { return { diff --git a/core/frontend/src/views/ExtensionManagerView.vue b/core/frontend/src/views/ExtensionManagerView.vue index 7da963b87b..75468d7319 100644 --- a/core/frontend/src/views/ExtensionManagerView.vue +++ b/core/frontend/src/views/ExtensionManagerView.vue @@ -7,6 +7,8 @@ :download="download_percentage" :extraction="extraction_percentage" :statustext="status_text" + :cancelable="!!active_abort_controller" + @cancel="cancelInstallOperation" /> { + return kraken.enableExtension(identifier, enableTag) + .then(() => kraken.uninstallExtensionVersion(identifier, removeTag)) + .catch((error) => notifier.pushBackError('EXTENSION_VERSION_SWAP_FAIL', error)) + }, async update(extension: InstalledExtensionData, version: string) { + const installedExt = this.installed_extensions[extension.identifier] + const previousTag = installedExt?.tag ?? extension.tag this.setInstallingState(extension.identifier, 'update') this.show_pull_output = true - const tracker = this.getTracker() + const controller = this.beginInstallOperation() + const tracker = this.getTracker(controller.signal) kraken.updateExtensionToVersion( extension.identifier, version, (progressEvent) => this.handleDownloadProgress(progressEvent.event, tracker), + controller.signal, ) - .then(() => { - this.fetchInstalledExtensions() + .then(async () => { notifier.pushSuccess('EXTENSION_UPDATE_SUCCESS', `${extension.name} updated successfully.`, true) + if (previousTag !== version) { + await this.swapExtensionVersion(extension.identifier, version, previousTag) + } }) - .catch((error) => { - this.alerter = true - this.alerter_error = String(error) + .catch(async (error) => { + if (axios.isCancel(error)) { + if (controller !== this.active_abort_controller) return + if (previousTag !== version) { + await this.swapExtensionVersion(extension.identifier, previousTag, version) + } + notifier.pushInfo('EXTENSION_UPDATE_CANCELLED', 'Extension update was cancelled.', true) + return + } + this.showAlertError(error) notifier.pushBackError('EXTENSION_UPDATE_FAIL', error) }) .finally(() => { - this.clearInstallingState() - this.resetPullOutput() + if (controller === this.active_abort_controller) this.finishInstallOperation() }) }, metricsFor(extension: InstalledExtensionData): { cpu: number, memory: number} | Record { @@ -993,24 +1034,30 @@ export default Vue.extend({ this.setInstallingState(extension.identifier, 'install') this.show_dialog = false this.show_pull_output = true - const tracker = this.getTracker() + const controller = this.beginInstallOperation() + const tracker = this.getTracker(controller.signal) kraken.installExtension( extension, (progressEvent) => this.handleDownloadProgress(progressEvent.event, tracker), + controller.signal, ) .then(() => { - this.fetchInstalledExtensions() notifier.pushSuccess('EXTENSION_INSTALL_SUCCESS', `${extension.name} installed successfully.`, true) }) - .catch((error) => { - this.alerter = true - this.alerter_error = String(error) - notifier.pushBackError('EXTENSIONS_INSTALL_FAIL', error) + .catch(async (error) => { + if (axios.isCancel(error)) { + if (controller !== this.active_abort_controller) return + await kraken.uninstallExtension(extension.identifier) + .catch((err) => notifier.pushBackError('EXTENSION_UNINSTALL_FAIL', err)) + notifier.pushInfo('EXTENSION_INSTALL_CANCELLED', 'Extension install was cancelled.', true) + return + } + this.showAlertError(error) + notifier.pushBackError('EXTENSION_INSTALL_FAIL', error) }) .finally(() => { - this.clearInstallingState() - this.resetPullOutput() + if (controller === this.active_abort_controller) this.finishInstallOperation() }) }, async performActionFromModal( @@ -1119,7 +1166,7 @@ export default Vue.extend({ temp[extension.identifier].loading = loading this.installed_extensions = temp }, - getTracker(): PullTracker { + getTracker(signal: AbortSignal): PullTracker { return new PullTracker( () => { setTimeout(() => { @@ -1127,9 +1174,9 @@ export default Vue.extend({ }, 1000) }, (error) => { - this.alerter = true - this.alerter_error = String(error) - notifier.pushBackError('EXTENSIONS_INSTALL_FAIL', error) + if (signal.aborted) return + this.showAlertError(error) + notifier.pushBackError('EXTENSION_INSTALL_FAIL', error) this.show_pull_output = false }, ) @@ -1206,7 +1253,8 @@ export default Vue.extend({ } this.show_pull_output = true - const tracker = this.getTracker() + const controller = this.beginInstallOperation() + const tracker = this.getTracker(controller.signal) this.setInstallFromFilePhase('installing') this.install_from_file_install_progress = 0 this.install_from_file_status_text = 'Starting installation...' @@ -1216,20 +1264,29 @@ export default Vue.extend({ extension, this.upload_temp_tag, (progressEvent) => this.handleDownloadProgress(progressEvent.event, tracker), + controller.signal, ) this.setInstallFromFilePhase('success') this.install_from_file_status_text = 'Extension installed successfully' this.stopUploadKeepAlive() this.upload_temp_tag = null this.upload_metadata = null - this.fetchInstalledExtensions() } catch (error) { - this.applyInstallFromFileError(String(error)) - this.alerter = true - this.alerter_error = String(error) - notifier.pushBackError('EXTENSION_FINALIZE_FAIL', error) + if (axios.isCancel(error)) { + if (controller === this.active_abort_controller) { + this.setInstallFromFilePhase('ready') + this.install_from_file_status_text = '' + await kraken.uninstallExtension(extension.identifier) + .catch((err) => notifier.pushBackError('EXTENSION_UNINSTALL_FAIL', err)) + notifier.pushInfo('EXTENSION_INSTALL_CANCELLED', 'Installation from file was cancelled.', true) + } + } else { + this.applyInstallFromFileError(String(error)) + this.showAlertError(error) + notifier.pushBackError('EXTENSION_FINALIZE_FAIL', error) + } } finally { - this.resetPullOutput() + if (controller === this.active_abort_controller) this.finishInstallOperation() } }, setInstallingState(identifier: string, action: 'install' | 'update'): void {