From c46b75e63dfc2ae9e5db6c1a0551e678581e65d6 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Sun, 11 Jan 2026 14:41:52 -0800 Subject: [PATCH] fix(admin): improve duplicate install request handling --- admin/app/models/service.ts | 3 ++ admin/app/services/docker_service.ts | 50 ++++++++++++++++++- ...ervices_add_installation_statuses_table.ts | 17 +++++++ admin/database/seeders/service_seeder.ts | 6 +++ admin/inertia/pages/settings/apps.tsx | 6 +++ 5 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 admin/database/migrations/1768170944482_update_services_add_installation_statuses_table.ts diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts index 8d9a619..e76a28e 100644 --- a/admin/app/models/service.ts +++ b/admin/app/models/service.ts @@ -33,6 +33,9 @@ export default class Service extends BaseModel { }) declare installed: boolean + @column() + declare installation_status: 'idle' | 'installing' | 'error' + @column() declare depends_on: string | null diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index 8054f5b..7e8f581 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -11,6 +11,7 @@ import { ZIM_STORAGE_PATH } from '../utils/fs.js' @inject() export class DockerService { private docker: Docker + private activeInstallations: Set = new Set() public static KIWIX_SERVICE_NAME = 'nomad_kiwix_serve' public static OLLAMA_SERVICE_NAME = 'nomad_ollama' public static OPEN_WEBUI_SERVICE_NAME = 'nomad_open_webui' @@ -153,6 +154,27 @@ export class DockerService { } } + // Check if installation is already in progress (database-level) + if (service.installation_status === 'installing') { + return { + success: false, + message: `Service ${serviceName} installation is already in progress`, + } + } + + // Double-check with in-memory tracking (race condition protection) + if (this.activeInstallations.has(serviceName)) { + return { + success: false, + message: `Service ${serviceName} installation is already in progress`, + } + } + + // Mark installation as in progress + this.activeInstallations.add(serviceName) + service.installation_status = 'installing' + await service.save() + // Check if a service wasn't marked as installed but has an existing container // This can happen if the service was created but not properly installed // or if the container was removed manually without updating the service status. @@ -167,7 +189,13 @@ export class DockerService { // } const containerConfig = this._parseContainerConfig(service.container_config) - await this._createContainer(service, containerConfig) + + // Execute installation asynchronously and handle cleanup + this._createContainer(service, containerConfig) + .catch(async (error) => { + logger.error(`Installation failed for ${serviceName}: ${error.message}`) + await this._cleanupFailedInstallation(serviceName) + }) return { success: true, @@ -272,8 +300,12 @@ export class DockerService { `Finalizing installation of service ${service.service_name}...` ) service.installed = true + service.installation_status = 'idle' await service.save() + // Remove from active installs tracking + this.activeInstallations.delete(service.service_name) + this._broadcast( service.service_name, 'completed', @@ -285,6 +317,8 @@ export class DockerService { 'error', `Error installing service ${service.service_name}: ${error.message}` ) + // Mark install as failed and cleanup + await this._cleanupFailedInstallation(service.service_name) throw new Error(`Failed to install service ${service.service_name}: ${error.message}`) } } @@ -372,6 +406,20 @@ export class DockerService { } } + private async _cleanupFailedInstallation(serviceName: string): Promise { + try { + const service = await Service.query().where('service_name', serviceName).first() + if (service) { + service.installation_status = 'error' + await service.save() + } + this.activeInstallations.delete(serviceName) + logger.info(`[DockerService] Cleaned up failed installation for ${serviceName}`) + } catch (error) { + logger.error(`[DockerService] Failed to cleanup installation for ${serviceName}: ${error.message}`) + } + } + private _broadcast(service: string, status: string, message: string) { transmit.broadcast('service-installation', { service_name: service, diff --git a/admin/database/migrations/1768170944482_update_services_add_installation_statuses_table.ts b/admin/database/migrations/1768170944482_update_services_add_installation_statuses_table.ts new file mode 100644 index 0000000..f06a990 --- /dev/null +++ b/admin/database/migrations/1768170944482_update_services_add_installation_statuses_table.ts @@ -0,0 +1,17 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.string('installation_status').defaultTo('idle').notNullable() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('installation_status') + }) + } +} \ No newline at end of file diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index 3c930be..ee2564e 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -22,6 +22,7 @@ export default class ServiceSeeder extends BaseSeeder { }), ui_location: '8090', installed: false, + installation_status: 'idle', is_dependency_service: false, depends_on: null, }, @@ -41,6 +42,7 @@ export default class ServiceSeeder extends BaseSeeder { }), ui_location: null, installed: false, + installation_status: 'idle', is_dependency_service: true, depends_on: null, }, @@ -61,6 +63,7 @@ export default class ServiceSeeder extends BaseSeeder { }), ui_location: '3000', installed: false, + installation_status: 'idle', is_dependency_service: false, depends_on: DockerService.OLLAMA_SERVICE_NAME, }, @@ -79,6 +82,7 @@ export default class ServiceSeeder extends BaseSeeder { }), ui_location: '8100', installed: false, + installation_status: 'idle', is_dependency_service: false, depends_on: null, }, @@ -99,6 +103,7 @@ export default class ServiceSeeder extends BaseSeeder { }), ui_location: '8200', installed: false, + installation_status: 'idle', is_dependency_service: false, depends_on: null, }, @@ -118,6 +123,7 @@ export default class ServiceSeeder extends BaseSeeder { }), ui_location: '8300', installed: false, + installation_status: 'idle', is_dependency_service: false, depends_on: null, }, diff --git a/admin/inertia/pages/settings/apps.tsx b/admin/inertia/pages/settings/apps.tsx index f1388f0..a79230d 100644 --- a/admin/inertia/pages/settings/apps.tsx +++ b/admin/inertia/pages/settings/apps.tsx @@ -87,6 +87,9 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] setIsInstalling(true) const response = await api.installService(serviceName) + if (!response) { + throw new Error('An internal error occurred while trying to install the service.') + } if (!response.success) { throw new Error(response.message) } @@ -102,6 +105,9 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] try { setLoading(true) const response = await api.affectService(record.service_name, action) + if (!response) { + throw new Error('An internal error occurred while trying to affect the service.') + } if (!response.success) { throw new Error(response.message) }