fix(docker): ask for the depends_on confirmation from the stacks list #1

Merged
julian merged 5 commits from fix/docker-stacks-update-confirm into main 2026-08-09 00:45:56 +02:00
Owner

The per-container update button on the stacks page posted to
/container/:id/update and reported any failure verbatim. When other
services in the stack depend_on the target, that route answers 409
{needsConfirmation, service, dependents} and expects the client to
re-send with ?confirmed=1. ContainerPage does exactly that; StacksPage
never looked at the body, so the row got a bare "update failed: ... HTTP
409" — no detail, since the body carries no error field for apiFetch to
append — and the update was simply unreachable from that page.

Mirror ContainerPage: keep the ApiError body, raise ConfirmDialog naming
the dependents, retry with ?confirmed=1 on ack. The pending confirmation
is keyed by container and the dialog names the target service, because
this page lists many containers at once.

The per-container update button on the stacks page posted to /container/:id/update and reported any failure verbatim. When other services in the stack depend_on the target, that route answers 409 {needsConfirmation, service, dependents} and expects the client to re-send with ?confirmed=1. ContainerPage does exactly that; StacksPage never looked at the body, so the row got a bare "update failed: ... HTTP 409" — no detail, since the body carries no `error` field for apiFetch to append — and the update was simply unreachable from that page. Mirror ContainerPage: keep the ApiError body, raise ConfirmDialog naming the dependents, retry with ?confirmed=1 on ack. The pending confirmation is keyed by container and the dialog names the target service, because this page lists many containers at once.
The per-container update button on the stacks page posted to
/container/:id/update and reported any failure verbatim. When other
services in the stack depend_on the target, that route answers 409
{needsConfirmation, service, dependents} and expects the client to
re-send with ?confirmed=1. ContainerPage does exactly that; StacksPage
never looked at the body, so the row got a bare "update failed: ... HTTP
409" — no detail, since the body carries no `error` field for apiFetch to
append — and the update was simply unreachable from that page.

Mirror ContainerPage: keep the ApiError body, raise ConfirmDialog naming
the dependents, retry with ?confirmed=1 on ack. The pending confirmation
is keyed by container and the dialog names the target service, because
this page lists many containers at once.
julian requested changes 2026-08-08 22:26:42 +02:00
Dismissed
julian left a comment

The fix itself is correct — I verified it end-to-end against fake data (dialog appears naming the dependents, confirm retries with ?confirmed=1, the row shows the updating state and settles cleanly). Good commit message, too.

Requesting changes on three points before this merges; see the inline comments:

  1. Remove the containerName fallback — the server always sends a non-empty service in the 409 body, so this is dead code.
  2. Deduplicate the confirmation dialog — the message text and wiring are now copied verbatim from ContainerPage; extract a shared component so the wording can't drift.
  3. Guard against a second 409 overwriting a pending confirmation — the first container's dialog is silently dropped.
The fix itself is correct — I verified it end-to-end against fake data (dialog appears naming the dependents, confirm retries with `?confirmed=1`, the row shows the updating state and settles cleanly). Good commit message, too. Requesting changes on three points before this merges; see the inline comments: 1. Remove the `containerName` fallback — the server always sends a non-empty `service` in the 409 body, so this is dead code. 2. Deduplicate the confirmation dialog — the message text and wiring are now copied verbatim from ContainerPage; extract a shared component so the wording can't drift. 3. Guard against a second 409 overwriting a pending confirmation — the first container's dialog is silently dropped.
@ -62,0 +70,4 @@
let confirmUpdate = $state<
{ id: string; service: string; dependents: string[] } | null
>(null);
const containerName = (id: string) =>
Owner

Required: drop this helper. The server builds the 409 body as service: target.service || target.name, so body.service is never empty and containerName() (plus the id.slice(0, 12) fallback behind it) is unreachable. Use body.service directly in the handler below — dead defensive code hides the actual contract.

**Required:** drop this helper. The server builds the 409 body as `service: target.service || target.name`, so `body.service` is never empty and `containerName()` (plus the `id.slice(0, 12)` fallback behind it) is unreachable. Use `body.service` directly in the handler below — dead defensive code hides the actual contract.
thisilike marked this conversation as resolved
@ -69,1 +92,4 @@
}
: null;
updatingC[id] = false;
if (body?.needsConfirmation) {
Owner

Required: guard the overwrite. If two updates are in flight and both answer 409 (slow request, quick clicks on two rows before the first dialog opens), the second assignment replaces the first confirmUpdate — the first container gets no dialog and no error, it just silently does nothing. Cheapest fix: if confirmUpdate is already set, put the confirmation prompt into actionError[id] instead of replacing the pending dialog, so the user sees why nothing happened and can click again.

**Required:** guard the overwrite. If two updates are in flight and both answer 409 (slow request, quick clicks on two rows before the first dialog opens), the second assignment replaces the first `confirmUpdate` — the first container gets no dialog and no error, it just silently does nothing. Cheapest fix: if `confirmUpdate` is already set, put the confirmation prompt into `actionError[id]` instead of replacing the pending dialog, so the user sees why nothing happened and can click again.
thisilike marked this conversation as resolved
@ -365,6 +399,22 @@
</Modal>
{/if}
<ConfirmDialog
Owner

Required: extract this. The title, message template, labels, and confirm/cancel wiring are now duplicated verbatim from ContainerPage.svelte — two copies of user-facing wording will drift on the next edit. Move it into a small shared component in the docker frontend (e.g. DependentsConfirm.svelte taking service, dependents, onconfirm, oncancel) and use it from both pages.

**Required:** extract this. The title, message template, labels, and confirm/cancel wiring are now duplicated verbatim from `ContainerPage.svelte` — two copies of user-facing wording will drift on the next edit. Move it into a small shared component in the docker frontend (e.g. `DependentsConfirm.svelte` taking `service`, `dependents`, `onconfirm`, `oncancel`) and use it from both pages.
thisilike marked this conversation as resolved
Review follow-up on the stacks-list confirmation.

The server builds the 409 body as `service: target.service || target.name`,
so `body.service` is always populated and the local name lookup behind it
was unreachable — drop it and read the field directly.

Two rows can be updating at once, and a second 409 arriving before the
first prompt is answered overwrote the pending confirmation: the first
container lost its dialog and reported nothing at all. Keep the pending
one and report the second through the row's own error slot instead.

The dialog itself now lives in DependentsConfirm.svelte, used by both
StacksPage and ContainerPage — the title, message and button labels were
duplicated verbatim and would have drifted. ContainerPage passes the
service name it already receives in the 409 body, so its message names the
service instead of saying "this service".
The previous commit's one-at-a-time guard was wrong in three ways. It told
the user to answer the open dialog and retry, but for two containers in the
same stack that retry cannot succeed: confirming the first one makes the
server reserve the project (`updating.add`), so the second POST comes back
"update already running". The message it wrote to the row was never cleared
by anything, so it outlived the dialog it referred to. And it hand-typed a
second copy of the dependents wording, hardcoding the plural, one commit
after that wording was centralized.

Queue the pending confirmations instead. The dialog shows the head of the
queue and each answer shifts it, so a second 409 waits its turn rather than
replacing or being turned away — no advice to give, nothing to go stale, and
the wording stays in one place. A container already queued is not enqueued
twice.

A confirmed retry that still comes back needing confirmation now reports
that instead of reopening the same dialog, which otherwise loops forever if
the query flag never reaches the server.

The ApiError body cast also claimed `service` and `dependents` for every
failure on the route, including the `{ error }` bodies and the ones with no
body at all. Narrow it on `status === 409` first, where those fields are
actually guaranteed.
Three ways the confirmation could be answered and then mean nothing.

A stack update reserves the project server-side for the whole pull, but the
Modal showing its progress can be dismissed while it runs, and the per-
container buttons underneath stayed enabled. Clicking one raised the
depends_on prompt, and "Update anyway" then came back "update already
running" — the acknowledgement thrown away, the row left on a raw 409.
Disable those buttons while their own stack is locked and say why in the
title.

Both pages keep refreshing under an open prompt. A queued confirmation names
its container by short id, which a recreate changes, so answering it POSTed
to an id the server no longer knows — a 404 written to an actionError key no
row renders, i.e. nothing on screen at all. Drop queued entries whose
container is gone on refresh, and whose image is no longer reported
updatable. A null updateAvailable means "not checked yet" and keeps the
entry.

Same shape on the container page, where the prompt lives outside the
`{#if update?.updateAvailable}` block that owns the button: if a refresh
retracts the offer while the dialog is up, confirming would pull and recreate
an already-current container, restarting its dependents for nothing.
Author
Owner

All three addressed. Head is now 2039373.

1. containerName dropped (327984d). Confirmed against mod.ts:1083 — the body is built as service: target.service || target.name, so body.service is never empty. The helper and its id.slice(0, 12) fallback are gone; both pages read body.service directly. I also narrowed the ApiError body cast on e.status === 409 first, since that is the only response on the route where those fields are actually guaranteed.

2. Dialog extracted (327984d). New DependentsConfirm.svelte taking service, dependents, onconfirm, oncancel, used by both StacksPage and ContainerPage; neither imports ConfirmDialog any more. One user-visible consequence: ContainerPage's message used to say "depend on this service" and now names it, because the shared component takes the service and ContainerPage was already receiving it in the 409 body without using it.

3. Overwrite guarded — but not the way you suggested, because I don't think that way works. Writing the second confirmation into actionError[id] tells the user to answer the open dialog and then retry. For two containers in the same stack that retry cannot succeed: updating.add(stack.project) (mod.ts:1088) runs as soon as the first confirmation is accepted, so the second POST comes back update already running from mod.ts:1067. The advice would be wrong precisely when the collision is most likely. It also left a message on the row that nothing ever cleared, so it outlived the dialog it pointed at.

Replaced with a queue (89b6bb7): confirmQueue holds the pending confirmations, the dialog shows the head, and each answer shifts it. A second 409 waits its turn instead of being dropped or turned away — no advice to get wrong, no message to go stale, and the wording stays in the one place point 2 put it. A container already queued is not enqueued twice.

All three addressed. Head is now `2039373`. **1. `containerName` dropped** (`327984d`). Confirmed against `mod.ts:1083` — the body is built as `service: target.service || target.name`, so `body.service` is never empty. The helper and its `id.slice(0, 12)` fallback are gone; both pages read `body.service` directly. I also narrowed the `ApiError` body cast on `e.status === 409` first, since that is the only response on the route where those fields are actually guaranteed. **2. Dialog extracted** (`327984d`). New `DependentsConfirm.svelte` taking `service`, `dependents`, `onconfirm`, `oncancel`, used by both StacksPage and ContainerPage; neither imports `ConfirmDialog` any more. One user-visible consequence: ContainerPage's message used to say "depend on **this service**" and now names it, because the shared component takes the service and ContainerPage was already receiving it in the 409 body without using it. **3. Overwrite guarded — but not the way you suggested, because I don't think that way works.** Writing the second confirmation into `actionError[id]` tells the user to answer the open dialog and then retry. For two containers in the *same stack* that retry cannot succeed: `updating.add(stack.project)` (`mod.ts:1088`) runs as soon as the first confirmation is accepted, so the second POST comes back `update already running` from `mod.ts:1067`. The advice would be wrong precisely when the collision is most likely. It also left a message on the row that nothing ever cleared, so it outlived the dialog it pointed at. Replaced with a queue (`89b6bb7`): `confirmQueue` holds the pending confirmations, the dialog shows the head, and each answer shifts it. A second 409 waits its turn instead of being dropped or turned away — no advice to get wrong, no message to go stale, and the wording stays in the one place point 2 put it. A container already queued is not enqueued twice.
thisilike force-pushed fix/docker-stacks-update-confirm from 959ddb2e65 to 20393731ca 2026-08-09 00:26:07 +02:00 Compare
julian requested changes 2026-08-09 00:36:09 +02:00
Dismissed
julian left a comment

All three points from the previous review are resolved, and resolved well — the queue, the shared DependentsConfirm, the narrowed 409 cast, plus the stale-prompt pruning and the stackLocked guard I didn't ask for. Re-verified end-to-end on this head: build clean, cancel path, confirm path, and the container page through the shared component all behave.

One remaining required change before merge; see the inline comment: consecutive queued prompts reuse the still-mounted dialog, so one Enter can acknowledge two containers back to back.

All three points from the previous review are resolved, and resolved well — the queue, the shared `DependentsConfirm`, the narrowed 409 cast, plus the stale-prompt pruning and the `stackLocked` guard I didn't ask for. Re-verified end-to-end on this head: build clean, cancel path, confirm path, and the container page through the shared component all behave. One remaining required change before merge; see the inline comment: consecutive queued prompts reuse the still-mounted dialog, so one Enter can acknowledge two containers back to back.
@ -365,6 +430,19 @@
</Modal>
{/if}
<DependentsConfirm
Owner

Required: with two or more prompts queued, confirming shifts the next one into the dialog that is already mounted — open never goes false, so ConfirmDialog's $effect doesn't re-run: no remount, no focus reset. Its keydown handler confirms on Enter regardless of focus, so a second Enter acks the next container while its message has barely rendered. Wrap the dialog in {#key confirmUpdate?.id} so each pending confirmation gets a fresh mount (restoring the focus-on-Cancel behavior of the danger variant), or blank open for a tick between prompts.

**Required:** with two or more prompts queued, confirming shifts the next one into the dialog that is already mounted — `open` never goes false, so `ConfirmDialog`'s `$effect` doesn't re-run: no remount, no focus reset. Its keydown handler confirms on Enter regardless of focus, so a second Enter acks the next container while its message has barely rendered. Wrap the dialog in `{#key confirmUpdate?.id}` so each pending confirmation gets a fresh mount (restoring the focus-on-Cancel behavior of the danger variant), or blank `open` for a tick between prompts.
Confirming one prompt shifts the next into a dialog that never closed.
ConfirmDialog takes focus in an effect guarded on `open`, which stays
true across the swap, so nothing re-runs: no remount, no focus reset. Its
keydown handler confirms on Enter regardless of what has focus, so a
second Enter acknowledges the next container while its message has barely
rendered — the danger variant's focus-on-Cancel never gets a chance to
apply.

Key the dialog by container so each pending confirmation mounts fresh.

This narrows the window rather than closing it: Enter confirming without
regard to focus is ConfirmDialog's own behaviour, and every caller in the
app inherits it. That belongs in a packages/ui change, not here.
julian approved these changes 2026-08-09 00:45:52 +02:00
julian left a comment

The {#key confirmUpdate?.id} remount is exactly what was asked — focus reset and a visible re-animation per queued prompt. Re-verified on this head: build clean, cancel path, confirm path, and the container page through the shared component all behave.

Approving. One note for a separate issue, not this PR: ConfirmDialog confirms Enter regardless of focus, so a held key (auto-repeat) could still chain-acknowledge queued prompts across remounts — an e.repeat guard in the component would close that.

Thanks for the precise turnaround across all three rounds.

The `{#key confirmUpdate?.id}` remount is exactly what was asked — focus reset and a visible re-animation per queued prompt. Re-verified on this head: build clean, cancel path, confirm path, and the container page through the shared component all behave. Approving. One note for a separate issue, not this PR: `ConfirmDialog` confirms Enter regardless of focus, so a held key (auto-repeat) could still chain-acknowledge queued prompts across remounts — an `e.repeat` guard in the component would close that. Thanks for the precise turnaround across all three rounds.
julian merged commit 1ae484461a into main 2026-08-09 00:45:56 +02:00
julian deleted branch fix/docker-stacks-update-confirm 2026-08-09 00:45:56 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OpsDeck/core!1
No description provided.