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: () => }));
import { ContainersHealth, type ContainersHealthProps } from '../editor-view-blocks';
import { copyToClipboard } from '@/lib/clipboard';
@@ -302,12 +302,12 @@ describe('declared-service headers (multi-service only)', () => {
expect(screen.getByRole('button', { name: 'View logs' })).toBeInTheDocument();
});
- it('renders one header per declared service and groups containers under it', () => {
+ it('flattens single-container multi-service rows without a declared-service header', () => {
render(
{
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db', declaredImage: 'postgres:16' })]}
/>,
);
+ expect(screen.queryByText(/running$/)).toBeNull();
expect(screen.getByText('web')).toBeInTheDocument();
expect(screen.getByText('db')).toBeInTheDocument();
+ // Flattened rows restore the per-container Service actions kebab.
expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
- // Per-container service menu is hidden inside a multi-service header group.
expect(screen.getAllByLabelText('Open bash shell')).toHaveLength(2);
+ // Registry services without a confirmed update hide the Update button.
+ expect(screen.queryByRole('button', { name: /^Update$/ })).toBeNull();
});
- it('shows the Update badge only for the service with a confirmed update', () => {
+ it('hides Update for registry services without a confirmed pending update', () => {
render(
,
+ );
+ expect(screen.queryByRole('button', { name: /^Update$/ })).toBeNull();
+ expect(screen.queryByText('Update', { selector: 'span' })).toBeNull();
+ });
+
+ it('shows the Update badge and button on the flattened container card', () => {
+ render(
+ {
/>,
);
expect(screen.getByText('Update', { selector: 'span' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /^Update$/ })).toBeInTheDocument();
+ const updateBtn = screen.getByRole('button', { name: /^Update$/ });
+ expect(updateBtn).toBeInTheDocument();
+ const imageSource = screen.getAllByLabelText('Image source links')[0];
+ // Update sits left of ImageSourceMenu in the action row.
+ expect(
+ updateBtn.compareDocumentPosition(imageSource) & globalThis.Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
});
it('uses Rebuild wording with no badge for a build-backed service without a detected update', () => {
@@ -355,8 +389,8 @@ describe('declared-service headers (multi-service only)', () => {
render(
{
expect(onRequestServiceUpdate).toHaveBeenCalledWith('web', 'rebuild');
});
- it('moves Start/Stop/Restart to the declared-service header menu', async () => {
+ it('keeps Start/Stop/Restart on the multi-replica service header menu', async () => {
const user = userEvent.setup();
const serviceAction = vi.fn();
render(
{
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={serviceAction}
- effectiveServices={[spec({ name: 'web' }), spec({ name: 'db' })]}
+ effectiveServices={[
+ spec({ name: 'web', expectedReplicas: 2 }),
+ spec({ name: 'db' }),
+ ]}
/>,
);
+ // web keeps a header (2 containers); db is flattened (1 container).
+ expect(screen.getByText(/2\/2 running/i)).toBeInTheDocument();
+ // Only the web header kebab + the flattened db card kebab.
+ expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
await user.click(screen.getAllByLabelText('Service actions')[0]);
await user.click(await screen.findByRole('menuitem', { name: 'Restart service' }));
expect(serviceAction).toHaveBeenCalledWith('restart', 'web');
});
+ it('retains multi-replica header Update without leaking onto nested children', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/2\/2 running/i)).toBeInTheDocument();
+ // One Update badge + one Update button on the web header only (db ineligible).
+ expect(screen.getAllByText('Update', { selector: 'span' })).toHaveLength(1);
+ expect(screen.getAllByRole('button', { name: /^Update$/ })).toHaveLength(1);
+ // Nested web children hide the Service actions kebab (header owns it).
+ // web header kebab + db flattened kebab = 2.
+ expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
+ expect(screen.getAllByLabelText('Open bash shell')).toHaveLength(3);
+ });
+
+ it('renders a compact Update row for a declared service with zero containers', () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText(/No containers running for this service/i)).toBeNull();
+ expect(screen.queryByText(/running$/)).toBeNull();
+ expect(screen.getByText('web')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /^Update$/ })).toBeInTheDocument();
+ // Compact empty row + flattened db card each have a Service actions kebab.
+ expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
+ });
+
it('still surfaces summary strip and density toggle on multi-service stacks', () => {
render(
void;
+}
+
export interface ContainersHealthProps {
safeContainers: ContainerInfo[];
containerStats: Record;
@@ -464,10 +476,73 @@ export function ContainersHealth({
) : null;
// One container card. `hideServiceMenu` drops the per-container
- // Start/Stop/Restart kebab on multi-service stacks; the declared-service
- // header above owns lifecycle actions. Child cards keep logs, shell, ports,
- // and metrics only.
- const renderContainerCard = (container: ContainerInfo, hideServiceMenu: boolean) => {
+ // Start/Stop/Restart kebab on multi-replica nested children; the
+ // declared-service header above owns lifecycle actions there. Flattened
+ // single-container multi-service rows pass updateAffordance and keep the
+ // kebab (`hideServiceMenu=false`). Single-service flat rows leave
+ // updateAffordance undefined.
+ const renderServiceUpdateButton = (affordance: ServiceUpdateAffordance) => {
+ if (!affordance.showUpdateAction) return null;
+ return (
+
+
+
+
+
+ {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}