fix(system): disable Update button while a service update is in flight (#931)

A multi-GB service update (e.g. nomad_ollama pulling ~6.5 GB) left the
Update button clickable with no feedback, so users clicked again thinking
it was stuck. The second click raced a concurrent updateContainer run into
Docker 304/400 errors (stop/rename on a container the first run had already
moved). The backend lock was in-memory only and never written to the DB, so
nothing durable signaled "update in progress" to the UI, and a page reload
mid-pull re-enabled the button.

Backend (docker_service.updateContainer):
- Set installation_status='installing' when the update starts and reset it
  to 'idle' in a finally on every exit path. This mirrors the install path,
  survives a page reload, and is visible to other tabs/clients.
- Reject a second update with a clear message when installation_status is
  already 'installing', instead of letting it race into Docker errors.

Frontend (settings/apps.tsx):
- Track in-flight updates per service. Seed optimistically on click and
  reconcile with the durable installation_status from the server.
- Disable the per-service Update button and show "Updating..." while in
  flight. Drop the fullscreen spinner for updates so the table and the
  activity feed (live pull/stop/start progress) stay visible.

Closes #931
This commit is contained in:
Chris Sherwood 2026-06-08 09:18:09 -07:00 committed by jakeaturner
parent 1b9f4f30f2
commit 663c1593df
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
2 changed files with 56 additions and 12 deletions

View File

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

View File

@ -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<Set<string>>(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
</StyledButton>
{record.available_update_version && (
<StyledButton
icon="IconArrowUp"
variant="primary"
onClick={() => handleUpdateService(record)}
disabled={isInstalling || !isOnline}
>
Update
</StyledButton>
)}
{record.available_update_version && (() => {
const isUpdating =
updatingServices.has(record.service_name) || record.installation_status === 'installing'
return (
<StyledButton
icon="IconArrowUp"
variant="primary"
onClick={() => handleUpdateService(record)}
disabled={isInstalling || !isOnline || isUpdating}
loading={isUpdating}
>
{isUpdating ? 'Updating...' : 'Update'}
</StyledButton>
)
})()}
{record.status && record.status !== 'unknown' && (
<>
<StyledButton