diff --git a/app/react/docker/containers/ItemView/ItemView.test.tsx b/app/react/docker/containers/ItemView/ItemView.test.tsx index 9c1a362e6..7d5a898d1 100644 --- a/app/react/docker/containers/ItemView/ItemView.test.tsx +++ b/app/react/docker/containers/ItemView/ItemView.test.tsx @@ -9,7 +9,7 @@ import { server } from '@/setup-tests/server'; import { User } from '@/portainer/users/types'; import { ContainerDetailsViewModel } from '@/docker/models/containerDetails'; -import { ItemView } from './ItemView'; +import { ItemView, STACK_CONTAINER_STATE_NAME } from './ItemView'; const { useCurrentStateAndParamsMock } = vi.hoisted(() => ({ useCurrentStateAndParamsMock: vi.fn(), @@ -52,7 +52,7 @@ describe('ItemView', () => { it('keeps the stack trail when the container is opened from a stack', async () => { useCurrentStateAndParamsMock.mockReturnValue({ - state: { name: 'docker.stacks.stack.container' }, + state: { name: STACK_CONTAINER_STATE_NAME }, params: { id: 'container-id-123', endpointId: '1', @@ -82,7 +82,7 @@ describe('ItemView', () => { it('keeps the stack trail without a stack id for an external stack', async () => { useCurrentStateAndParamsMock.mockReturnValue({ - state: { name: 'docker.stacks.stack.container' }, + state: { name: STACK_CONTAINER_STATE_NAME }, params: { id: 'container-id-123', endpointId: '1', @@ -121,6 +121,45 @@ describe('ItemView', () => { ).not.toBeInTheDocument(); }); + it('keeps the stack trail with the stack id for an orphaned stack', async () => { + useCurrentStateAndParamsMock.mockReturnValue({ + state: { name: STACK_CONTAINER_STATE_NAME }, + params: { + id: 'container-id-123', + endpointId: '1', + nodeName: undefined, + name: 'orphan-stack', + stackId: '7', + type: '2', + // Orphaned stacks are identified by orphaned=true and, unlike regular + // stacks, carry no `regular` flag. They still have a DB id (stackId), + // so the back-link must keep stackId and must NOT take the external + // (id-less) branch. + orphaned: 'true', + tab: 'logs', + }, + }); + + renderComponent(); + + // Stack trail is shown instead of the global Containers crumb. + const stacksCrumb = await screen.findByRole('link', { name: 'Stacks' }); + expect(stacksCrumb).toBeVisible(); + + const stackCrumb = screen.getByRole('link', { name: 'orphan-stack' }); + expect(stackCrumb).toBeVisible(); + const href = stackCrumb.getAttribute('href'); + // Orphaned stacks keep their numeric stack id so the stack can reload, + // flag the orphaned state, and must not be treated as external. + expect(href).toContain('stackId=7'); + expect(href).toContain('orphaned=true'); + expect(href).not.toContain('external='); + + expect( + screen.queryByRole('link', { name: 'Containers' }) + ).not.toBeInTheDocument(); + }); + it('renders health status section when container has health data', async () => { renderComponent({ container: { diff --git a/app/react/docker/containers/ItemView/ItemView.tsx b/app/react/docker/containers/ItemView/ItemView.tsx index de5fdbfd3..c2d159f34 100644 --- a/app/react/docker/containers/ItemView/ItemView.tsx +++ b/app/react/docker/containers/ItemView/ItemView.tsx @@ -22,6 +22,27 @@ import { VolumesSection } from './VolumesSection/VolumesSection'; import { ContainerNetworksDatatable } from './ContainerNetworksDatatable'; import { HealthStatus } from './HealthStatus'; +// ui-router state name for a container opened from within a stack. Kept as a +// single source of truth so a future state rename stays in sync with the +// breadcrumb logic (and its test) instead of silently falling back to the +// default crumb. +export const STACK_CONTAINER_STATE_NAME = 'docker.stacks.stack.container'; + +// Shape of the params handed to the docker.stacks.stack route when building the +// back-to-stack breadcrumb link. Values are strings (inherited route params) +// except `external`, which is emitted as a boolean and serialized to +// `external=true` by ui-router. +type StackLinkParams = { + name?: string; + stackId?: string; + type?: string; + regular?: string; + orphaned?: string; + orphanedRunning?: string; + external?: boolean; + tab?: string; +}; + export function ItemView() { const environmentId = useEnvironmentId(); const { state, params } = useCurrentStateAndParams(); @@ -116,7 +137,7 @@ export function ItemView() { ); } -// When a container is opened from a stack (state docker.stacks.stack.container), +// When a container is opened from a stack (state STACK_CONTAINER_STATE_NAME), // keep the stack trail in the breadcrumbs so the user can navigate back to the // stack. Otherwise fall back to the global containers list. function getContainerBreadcrumbs( @@ -124,7 +145,7 @@ function getContainerBreadcrumbs( params: Record, containerName: string ): Array { - if (stateName === 'docker.stacks.stack.container') { + if (stateName === STACK_CONTAINER_STATE_NAME) { return [ { label: 'Stacks', link: 'docker.stacks' }, { @@ -143,7 +164,7 @@ function getContainerBreadcrumbs( // (container) route params, which are inherited from the parent stack state. function buildStackLinkParams( params: Record -): Record { +): StackLinkParams { // External stacks have no DB id; they are identified by name/type only. if (params.external === 'true') { return {