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:
parent
1b9f4f30f2
commit
663c1593df
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue