fix(admin): improve duplicate install request handling

This commit is contained in:
Jake Turner 2026-01-11 14:41:52 -08:00 committed by Jake Turner
parent 3e4985c3c7
commit c46b75e63d
5 changed files with 81 additions and 1 deletions

View File

@ -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

View File

@ -11,6 +11,7 @@ import { ZIM_STORAGE_PATH } from '../utils/fs.js'
@inject()
export class DockerService {
private docker: Docker
private activeInstallations: Set<string> = 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<void> {
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,

View File

@ -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')
})
}
}

View File

@ -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,
},

View File

@ -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)
}