Compare commits

...

4 Commits

Author SHA1 Message Date
claude code agent
e9fae32b43 test(stacks): cover orphaned branch; name the stack-container state; type link params (F1,F2,F4)
Maintainer pre-merge review follow-up:
F1: test the orphaned-stack breadcrumb branch (orphaned=true, no regular) —
    href carries stackId/orphaned, not external.
F2: extract STACK_CONTAINER_STATE_NAME so code + test share one literal.
F4: type buildStackLinkParams' return as StackLinkParams (documents the real
    shape; external stays boolean, serialized by ui-router — no runtime change).
F3 (legacy ?id= deep links) answered wontfix in the PR thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:55:41 +03:00
claude code agent
a1851417d1 test(stacks): cover external-stack breadcrumb branch in buildStackLinkParams (F1)
Add a test case driving the external-stack branch (external='true', no DB
stackId) and assert the back-link carries external=true/type and omits
stackId/regular. stackId/regular are set in the route params so the negative
assertions actually catch a fall-through-to-regular regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:06:25 +03:00
claude code agent
b4d10a67b2 fix(stacks): preserve stack tab on breadcrumb back-link + assert default crumb (F1,F2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:55:47 +03:00
claude code agent
cb11b0fca4 fix(stacks): keep stack breadcrumb trail when opening a container from a stack (#4)
Opening a container from a stack's Containers table showed
"Home > Containers > <container>" instead of keeping the stack trail,
so the user could not navigate back to the stack.

Two root causes are addressed:

1. Route param collision: docker.stacks.stack used the query param `id`
   for the numeric stack DB id, while its child docker.stacks.stack.container
   uses the path param `id` for the container id. Navigating into a container
   overwrote the stack id. The stack id param is renamed `id` -> `stackId`
   everywhere it is read or written (route url, stacks datatable link,
   create-stack redirect, gitops workflow card link, stack ItemView reader).

2. Hardcoded breadcrumbs: the container details ItemView always rendered the
   global "Containers" crumb. Breadcrumbs are now state-aware: when reached
   via docker.stacks.stack.container the stack trail
   (Stacks > <stack> > <container>) is rebuilt from the inherited stack params,
   honoring external/orphaned stacks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:44:18 +03:00
7 changed files with 219 additions and 17 deletions

View File

@@ -418,7 +418,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
var stack = {
name: 'docker.stacks.stack',
url: '/:name?id&type&regular&external&orphaned&orphanedRunning&tab',
url: '/:name?stackId&type&regular&external&orphaned&orphanedRunning&tab',
views: {
'content@': {
component: 'stackItemView',

View File

@@ -9,18 +9,25 @@ 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(),
}));
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { id: 'container-id-123', endpointId: '1', nodeName: undefined },
})),
useCurrentStateAndParams: useCurrentStateAndParamsMock,
}));
describe('ItemView', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: container opened from the global containers list.
useCurrentStateAndParamsMock.mockReturnValue({
state: { name: 'docker.containers.container' },
params: { id: 'container-id-123', endpointId: '1', nodeName: undefined },
});
});
it('renders page header with container details title', async () => {
@@ -39,6 +46,118 @@ describe('ItemView', () => {
expect(await screen.findByText('test-container')).toBeVisible();
expect(screen.queryByText('/test-container')).not.toBeInTheDocument();
// The global Containers crumb is shown in the default (non-stack) context.
expect(screen.getByRole('link', { name: 'Containers' })).toBeVisible();
});
it('keeps the stack trail when the container is opened from a stack', async () => {
useCurrentStateAndParamsMock.mockReturnValue({
state: { name: STACK_CONTAINER_STATE_NAME },
params: {
id: 'container-id-123',
endpointId: '1',
nodeName: undefined,
name: 'my-stack',
stackId: '7',
type: '2',
regular: 'true',
},
});
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: 'my-stack' });
expect(stackCrumb).toBeVisible();
// Back-link carries the numeric stack id (stackId) so the stack can reload.
expect(stackCrumb.getAttribute('href')).toContain('stackId=7');
expect(
screen.queryByRole('link', { name: 'Containers' })
).not.toBeInTheDocument();
});
it('keeps the stack trail without a stack id for an external stack', async () => {
useCurrentStateAndParamsMock.mockReturnValue({
state: { name: STACK_CONTAINER_STATE_NAME },
params: {
id: 'container-id-123',
endpointId: '1',
nodeName: undefined,
name: 'ext-stack',
type: '2',
external: 'true',
// stackId/regular are present in the inherited route params but the
// external branch must NOT propagate them. They are set here to
// non-empty values so the `not.toContain` assertions below actually
// catch a fall-through-to-regular regression instead of passing vacuously.
stackId: '7',
regular: '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: 'ext-stack' });
expect(stackCrumb).toBeVisible();
// External stacks have no DB id, so the back-link is built from
// name/type/external only and must omit stackId and regular.
const href = stackCrumb.getAttribute('href');
expect(href).toContain('external=true');
expect(href).toContain('type=2');
expect(href).not.toContain('stackId=');
expect(href).not.toContain('regular=');
expect(
screen.queryByRole('link', { name: 'Containers' })
).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 () => {
@@ -79,7 +198,17 @@ function renderComponent({
);
const Wrapped = withTestQueryProvider(
withTestRouter(withUserProvider(ItemView, user))
withTestRouter(withUserProvider(ItemView, user), {
stateConfig: [
{ name: 'docker', url: '/docker' },
{ name: 'docker.stacks', url: '/stacks' },
{
name: 'docker.stacks.stack',
url: '/:name?stackId&type&regular&external&orphaned&orphanedRunning&tab',
},
{ name: 'docker.containers', url: '/containers' },
],
})
);
return render(<Wrapped />);

View File

@@ -9,6 +9,7 @@ import { useEnvironmentRegistries } from '@/react/portainer/environments/queries
import { Registry } from '@/react/portainer/registries/types/registry';
import { PageHeader } from '@@/PageHeader';
import { Crumb } from '@@/PageHeader/Breadcrumbs/Breadcrumbs';
import { findBestMatchRegistry } from '@@/ImageConfigFieldset/findRegistryMatch';
import { useContainer } from '../queries/useContainer';
@@ -21,11 +22,31 @@ 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 {
params: { id: containerId, nodeName },
} = useCurrentStateAndParams();
const { state, params } = useCurrentStateAndParams();
const { id: containerId, nodeName } = params;
const containerQuery = useContainer(
{ environmentId, containerId, nodeName },
@@ -56,10 +77,11 @@ export function ItemView() {
<>
<PageHeader
title="Container details"
breadcrumbs={[
{ label: 'Containers', link: 'docker.containers' },
containerName,
]}
breadcrumbs={getContainerBreadcrumbs(
state?.name,
params,
containerName
)}
/>
<div className="mx-4 mb-4 space-y-4 [&>*]:block">
@@ -115,6 +137,55 @@ export function ItemView() {
);
}
// 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(
stateName: string | undefined,
params: Record<string, string | undefined>,
containerName: string
): Array<Crumb | string> {
if (stateName === STACK_CONTAINER_STATE_NAME) {
return [
{ label: 'Stacks', link: 'docker.stacks' },
{
label: params.name || '',
link: 'docker.stacks.stack',
linkParams: buildStackLinkParams(params),
},
containerName,
];
}
return [{ label: 'Containers', link: 'docker.containers' }, containerName];
}
// Rebuild the params expected by the docker.stacks.stack route from the current
// (container) route params, which are inherited from the parent stack state.
function buildStackLinkParams(
params: Record<string, string | undefined>
): StackLinkParams {
// External stacks have no DB id; they are identified by name/type only.
if (params.external === 'true') {
return {
name: params.name,
type: params.type,
external: true,
tab: params.tab,
};
}
return {
name: params.name,
stackId: params.stackId,
type: params.type,
regular: params.regular,
orphaned: params.orphaned,
orphanedRunning: params.orphanedRunning,
tab: params.tab,
};
}
function getRegistryId(
container: ContainerDetailsViewModel,
registries?: Array<Registry>

View File

@@ -91,7 +91,7 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) {
notifySuccess('Success', 'Stack successfully created');
router.stateService.go('docker.stacks.stack', {
name: stack.Name,
id: stack.Id,
stackId: stack.Id,
type: stack.Type,
regular: 'true',
});

View File

@@ -127,7 +127,9 @@ why stack.EndpointID and not params.envId?
const isOrphaned = params.orphaned === 'true';
const isOrphanedRunning = params.orphanedRunning === 'true';
const stackName = params.name || ('' as string);
const id = params.id ? (parseInt(params.id, 10) as Stack['Id']) : undefined;
const id = params.stackId
? (parseInt(params.stackId, 10) as Stack['Id'])
: undefined;
const type = ['1', '2', '3'].includes(params.type)
? (parseInt(params.type, 10) as StackType)
: undefined;

View File

@@ -116,7 +116,7 @@ function NameLink({ item }: { item: DecoratedStack }) {
to="docker.stacks.stack"
params={{
name: item.Name,
id: item.Id,
stackId: item.Id,
type: item.Type,
regular: item.Regular,
orphaned: item.Orphaned,

View File

@@ -91,7 +91,7 @@ function getStackLink(item: Workflow): { to: string; params: object } {
params: {
endpointId: item.target.endpointId,
name: item.name,
id: item.id,
stackId: item.id,
type,
regular: true,
},