From 1c9060a855a8673915e638906f5e8f190f097378 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 3 Aug 2026 10:10:09 -0400 Subject: [PATCH] fix(ui): flatten single-container Update onto the service row (#1759)q * fix(ui): flatten single-container Update onto the service row On multi-service stacks, put Update/Rebuild on the container card (left of image source) when a service has one matching container, and keep the shared header only for multi-replica services. * fix(ui): show per-service Update only when an image update is confirmed Registry services were always showing Update because eligibility checked declaredImage/hasBuild only. Gate Update on a confirmed pending check so the button clears after a successful recheck; keep Rebuild for build-backed services. --- docs/features/stack-management.mdx | 12 +- .../__tests__/ContainersHealth.test.tsx | 131 ++++++++-- .../EditorLayout/editor-view-blocks.tsx | 242 +++++++++++------- 3 files changed, 273 insertions(+), 112 deletions(-) diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 31ed3e2c..2615ec4f 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -223,13 +223,17 @@ Each row includes: - **Meta line.** Uptime (`up 12 hours`), the Docker health status when defined (`healthy`, `unhealthy`, or `starting`), and the primary port mapping (`8989 โ†’ 8989/tcp`). - **Open link.** When the container publishes a port, the mapping itself is a link (`8989 โ†’ 8989/tcp โ†—`) that opens the service in a new tab, with a **Copy URL** button beside it. The address uses the active node's host and switches to `https` for port 443. Recognised multi-port apps open their web path automatically (for example, Plex opens `/web`). - **Live stat tiles.** Three tiles show CPU, memory, and network I/O with a rolling sparkline. The sparkline uses the cyan data color and refreshes roughly every 1.5 seconds. -- **Action icons.** The image source links button (see above), plus shortcuts to **View logs**, open a bash shell, and (on single-service stacks) the per-container Start / Stop / Restart kebab. +- **Action icons.** The image source links button (see above), plus shortcuts to **View logs**, open a bash shell, and the **Service actions** kebab on single-service stacks and single-container multi-service rows (multi-replica services keep the kebab on the shared header; see below). ### Multi-service stacks -When a stack declares more than one Compose service, each service gets its own header above its container cards. That header owns **Update service** or **Rebuild service** (when eligible), the update badge when a registry update is confirmed for that service, and **Start / Stop / Restart** for every replica of the service. Child container cards keep logs, shell, ports, and metrics only. +When a stack declares more than one Compose service, each service with a single matching container renders as one flat container card. When a registry image update is confirmed for that service, the card shows **Update** immediately left of the image source links control (with an update badge). Build-backed services without a confirmed registry update show **Rebuild** in the same place. The **Service actions** kebab for Start / Stop / Restart stays on the card. -Build-backed services without a registry update show **Rebuild** wording and no update badge. The editor toolbar **Update** action still updates the entire stack. +Services with multiple replicas keep a shared header above their nested container cards. That header owns the same **Update** / **Rebuild** rules, the update badge, and **Start / Stop / Restart** for every replica. Nested cards keep logs, shell, ports, and metrics only. + +A declared service with no running containers shows a compact row with the service name, Update/Rebuild under the same rules, and the Service actions kebab (so you can still Start the service). + +After a service update succeeds and the image check clears, the **Update** button disappears. The editor toolbar **Update** action still updates the entire stack at any time. Single-service stacks keep the existing flat container layout; service headers do not appear. @@ -425,7 +429,7 @@ Press `B` again or toggle the bulk mode button to leave bulk mode. ## Controlling a single service -Inside the stack detail view, each container card has quick actions (View logs, Monitor, Open bash when available) and a **Service actions** kebab on the right side. Use them to act on that service without touching the rest of the stack. +Inside the stack detail view, container cards expose quick actions (View logs, Monitor, Open bash when available). On single-service stacks, single-container multi-service rows, and empty declared-service rows, a **Service actions** kebab sits on that row. On multi-replica services the kebab lives on the shared service header only, not on each replica card. Use these actions to act on that service without touching the rest of the stack. - **Monitor**: opens the stack **Monitor** sheet with that Compose service preferred in the add forms. Shown only when the container reports a Compose service name. - **Restart service**: stops and starts all containers for that service. diff --git a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx index 06db972c..bbe38632 100644 --- a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event'; vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) })); vi.mock('../../Terminal', () => ({ default: () => null })); vi.mock('../../StructuredLogViewer', () => ({ default: () => null })); -vi.mock('../../ImageSourceMenu', () => ({ ImageSourceMenu: () => null })); +vi.mock('../../ImageSourceMenu', () => ({ ImageSourceMenu: () => + + {affordance.replicaCopy} + + + ); + }; + + const renderServiceLifecycleMenu = (serviceName: string, isServiceActive: boolean) => ( + + + + + + {isServiceActive ? ( + <> + serviceAction('restart', serviceName)}> + Restart service + + serviceAction('stop', serviceName)}> + Stop service + + + ) : ( + serviceAction('start', serviceName)}> + Start service + + )} + + + ); + + const renderContainerCard = ( + container: ContainerInfo, + hideServiceMenu: boolean, + updateAffordance?: ServiceUpdateAffordance, + ) => { let mainPort: number | undefined; let mainPortPrivate: number | undefined; let mainPortProto: string | undefined; @@ -517,7 +592,14 @@ export function ContainersHealth({ {badgeGlyph}
-
{containerName}
+
+
{containerName}
+ {updateAffordance?.hasUpdate && ( + + Update + + )} +
{uptime ? {uptime} : {(container.State || 'unknown').toLowerCase()}} {hcLabel ? <>ยท{hcLabel} : null} @@ -563,6 +645,7 @@ export function ContainersHealth({
+ {updateAffordance ? renderServiceUpdateButton(updateAffordance) : null} )} {!hideServiceMenu && container.Service && ( - - - - - - {isActive ? ( - <> - serviceAction('restart', container.Service!)}> - Restart service - - serviceAction('stop', container.Service!)}> - Stop service - - - ) : ( - serviceAction('start', container.Service!)}> - Start service - - )} - - + renderServiceLifecycleMenu( + container.Service, + isActive, + ) )}
@@ -750,13 +809,63 @@ export function ContainersHealth({ const busy = serviceUpdateInProgress?.service === spec.name; const hasUpdate = status ? isConfirmedServiceUpdate(status) : false; const mode: 'update' | 'rebuild' = !hasUpdate && spec.hasBuild ? 'rebuild' : 'update'; - const showUpdateAction = spec.declaredImage !== null || spec.hasBuild; + // Registry Update only when a check confirmed a pending + // image update (clears after a successful recheck). Rebuild + // stays available for build-backed services without one. + // Stack-level Update in the identity header remains the + // always-on full-stack pull path. + const showUpdateAction = hasUpdate || spec.hasBuild; const isServiceActive = group.some(c => c.State === 'running' || c.State === 'paused'); const runningCount = group.filter(c => c.State === 'running').length; const replicaWord = spec.expectedReplicas === 1 ? 'replica' : 'replicas'; const replicaCopy = mode === 'rebuild' ? `Rebuilds all ${spec.expectedReplicas} ${replicaWord}` : `Updates all ${spec.expectedReplicas} ${replicaWord}`; + const updateAffordance: ServiceUpdateAffordance = { + hasUpdate, + mode, + showUpdateAction, + busy, + replicaCopy, + onRequest: () => onRequestServiceUpdate?.(spec.name, mode), + }; + + // Single-container declared service: one flat card with + // Update left of ImageSourceMenu and the lifecycle kebab. + if (group.length === 1) { + return ( +
+ {renderContainerCard(group[0], false, updateAffordance)} +
+ ); + } + + // Zero containers: compact row (name + Update + kebab), + // not renderContainerCard (no ContainerInfo). + if (group.length === 0) { + return ( +
+
+ {spec.name} + {hasUpdate && ( + + Update + + )} +
+
+ {renderServiceUpdateButton(updateAffordance)} + {renderServiceLifecycleMenu(spec.name, false)} +
+
+ ); + } + + // Multi-replica: keep header + nested children (no + // updateAffordance on child cards). return (
@@ -772,67 +881,13 @@ export function ContainersHealth({ )}
- {showUpdateAction && ( - - - - - - {replicaCopy} - - - )} - - - - - - {isServiceActive ? ( - <> - serviceAction('restart', spec.name)}> - Restart service - - serviceAction('stop', spec.name)}> - Stop service - - - ) : ( - serviceAction('start', spec.name)}> - Start service - - )} - - + {renderServiceUpdateButton(updateAffordance)} + {renderServiceLifecycleMenu(spec.name, isServiceActive)}
- {group.length > 0 ? ( -
- {group.map(container => renderContainerCard(container, true))} -
- ) : ( -
- No containers running for this service. -
- )} +
+ {group.map(container => renderContainerCard(container, true))} +
); })} @@ -844,6 +899,7 @@ export function ContainersHealth({ {safeContainers.map(container => renderContainerCard(container, false))} )} + ); }