diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index b4d40e4..c836705 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -1478,8 +1478,19 @@ export class DockerService { if (this.activeInstallations.has(serviceName)) { return { success: false, message: `Service ${serviceName} already has an operation in progress` } } + // DB-level guard mirrors the install path. Unlike the in-memory Set above, this survives a + // page reload and is visible to other clients, so a second Update click (or one from another + // tab) is rejected cleanly instead of racing two updateContainer runs into Docker 304/400 + // errors (stop/rename on a container the first run already moved). + if (service.installation_status === 'installing') { + return { success: false, message: `Service ${serviceName} already has an update in progress` } + } this.activeInstallations.add(serviceName) + // Persist the in-progress flag so the Apps page can durably disable the Update button while + // the (often multi-GB, multi-minute) pull runs. Cleared in the finally below. + service.installation_status = 'installing' + await service.save() // newImage = the semver tag we record in the DB after the update (e.g. ollama/ollama:0.23.2). // runtimeImage = the tag we actually pull and run. For AMD-on-Ollama these diverge: we run @@ -1741,6 +1752,21 @@ export class DockerService { ) logger.error({ err: error }, `[DockerService] Update failed for ${serviceName}`) return { success: false, message: 'Update failed. Check server logs for details.' } + } finally { + // Always clear the in-progress flag we set above, on every exit path (success, rollback, + // not-found, or thrown error). The success path already persisted the new image/version on + // this same in-memory model, so saving again with idle preserves that — it only flips the + // status. The existing activeInstallations.delete() calls on each branch handle the + // in-memory lock; this just keeps the durable DB flag honest. + const svc = await Service.query().where('service_name', serviceName).first() + if (svc && svc.installation_status === 'installing') { + svc.installation_status = 'idle' + try { + await svc.save() + } catch (saveErr: any) { + logger.error({ err: saveErr }, `[DockerService] Failed to reset installation_status for ${serviceName}`) + } + } } } diff --git a/admin/inertia/pages/settings/apps.tsx b/admin/inertia/pages/settings/apps.tsx index c5c5fbf..2f23387 100644 --- a/admin/inertia/pages/settings/apps.tsx +++ b/admin/inertia/pages/settings/apps.tsx @@ -34,6 +34,10 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] const [isInstalling, setIsInstalling] = useState(false) const [loading, setLoading] = useState(false) const [checkingUpdates, setCheckingUpdates] = useState(false) + // Services with an update in flight. Seeded optimistically on click so the button disables + // instantly, and reconciled with the durable `installation_status` from the server so the + // disabled state survives a page reload or a second open tab while the pull runs. + const [updatingServices, setUpdatingServices] = useState>(new Set()) useEffect(() => { if (installActivity.length === 0) return @@ -181,16 +185,25 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] onCancel={closeAllModals} onUpdate={async (targetVersion: string) => { closeAllModals() + // Mark this service as updating instead of showing the fullscreen spinner, so the table + // and the activity feed stay visible (the feed streams live pull/stop/start progress) + // while the button shows "Updating..." and is disabled. + setUpdatingServices((prev) => new Set(prev).add(record.service_name)) try { - setLoading(true) const response = await api.updateService(record.service_name, targetVersion) if (!response?.success) { throw new Error(response?.message || 'Update failed') } + // On success the backend broadcasts `update-complete`, which triggers the reload effect + // above and refreshes the version + status. Leave the button disabled until then. } catch (error) { console.error(`Error updating service ${record.service_name}:`, error) showError(`Failed to update service: ${error.message || 'Unknown error'}`) - setLoading(false) + setUpdatingServices((prev) => { + const next = new Set(prev) + next.delete(record.service_name) + return next + }) } }} showError={showError} @@ -258,16 +271,21 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] > Open - {record.available_update_version && ( - handleUpdateService(record)} - disabled={isInstalling || !isOnline} - > - Update - - )} + {record.available_update_version && (() => { + const isUpdating = + updatingServices.has(record.service_name) || record.installation_status === 'installing' + return ( + handleUpdateService(record)} + disabled={isInstalling || !isOnline || isUpdating} + loading={isUpdating} + > + {isUpdating ? 'Updating...' : 'Update'} + + ) + })()} {record.status && record.status !== 'unknown' && ( <>