Compare commits

..

5 Commits

Author SHA1 Message Date
claude code agent
0bf4e71b79 fix(stacks): keep the stack breadcrumb trail on container attribute sub-tabs
When a container is opened from a stack, the detail tab kept the stack
trail (PR #7) but the attribute sub-tabs (Logs, Stats, Inspect, Console,
Attach) dropped it: those tabs were registered only under the global
docker.containers.container.* tree, so navigating to one left the stack
state (and its inherited params) behind, and each sub-view set a hardcoded
"Containers > ..." breadcrumb.

- Register stack-scoped child states docker.stacks.stack.container.{attach,
  exec,inspect,logs,stats} mirroring the global ones, so the inherited stack
  params survive and the trail can be kept.
- Centralize the breadcrumb logic in containerBreadcrumbs.ts (moved out of
  ItemView, which re-exports it) and add isStackContainerState +
  getContainerSubTabBreadcrumbs + buildStackContainerLinkParams.
- ActionLinksRow links sub-tabs into the stack tree (with stack+container
  params) when opened from a stack, else the global states unchanged.
- InspectView + the logs/stats/console controllers render the stack-aware
  trail; set up-front (no name) so it survives the load window and errors.

Covers regular/external/orphaned stacks and the non-stack fallback,
matching the existing ItemView breadcrumb behavior. New unit tests in
containerBreadcrumbs.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 02:25:04 +03:00
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
18 changed files with 622 additions and 93 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',
@@ -436,6 +436,65 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
},
};
// Stack-scoped container attribute sub-tabs. These mirror the global
// docker.containers.container.* states but live under the stack tree so the
// inherited stack params (name/stackId/type/...) are preserved and the
// breadcrumb keeps the stack trail (Stacks > stack > container > tab) when a
// container is opened from a stack.
var stackContainerAttach = {
name: 'docker.stacks.stack.container.attach',
url: '/attach',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/console/attach.html',
controller: 'ContainerConsoleController',
},
},
};
var stackContainerExec = {
name: 'docker.stacks.stack.container.exec',
url: '/exec',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/console/exec.html',
controller: 'ContainerConsoleController',
},
},
};
var stackContainerInspect = {
name: 'docker.stacks.stack.container.inspect',
url: '/inspect',
views: {
'content@': {
component: 'dockerContainerInspectView',
},
},
};
var stackContainerLogs = {
name: 'docker.stacks.stack.container.logs',
url: '/logs',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/logs/containerlogs.html',
controller: 'ContainerLogsController',
},
},
};
var stackContainerStats = {
name: 'docker.stacks.stack.container.stats',
url: '/stats',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/stats/containerstats.html',
controller: 'ContainerStatsController',
},
},
};
var stackCreation = {
name: 'docker.stacks.newstack',
url: '/newstack',
@@ -669,6 +728,11 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
$stateRegistryProvider.register(stacks);
$stateRegistryProvider.register(stack);
$stateRegistryProvider.register(stackContainer);
$stateRegistryProvider.register(stackContainerAttach);
$stateRegistryProvider.register(stackContainerExec);
$stateRegistryProvider.register(stackContainerInspect);
$stateRegistryProvider.register(stackContainerLogs);
$stateRegistryProvider.register(stackContainerStats);
$stateRegistryProvider.register(stackCreation);
$stateRegistryProvider.register(swarm);
$stateRegistryProvider.register(swarmVisualizer);

View File

@@ -1,14 +1,4 @@
<page-header
title="'Container console'"
breadcrumbs="[
{ label:'Containers', link:'docker.containers' },
{
label:(container.Name | trimcontainername),
link: 'docker.containers.container',
linkParams: { id: container.Id },
}, 'Console']"
>
</page-header>
<page-header title="'Container console'" breadcrumbs="breadcrumbs"> </page-header>
<div class="row" ng-init="autoconnectAttachView()" ng-show="loaded">
<div class="col-lg-12 col-md-12 col-xs-12">

View File

@@ -1,5 +1,7 @@
import { baseHref } from '@/portainer/helpers/pathHelper';
import { commandStringToArray } from '@/docker/helpers/containers';
import { trimContainerName } from '@/docker/filters/utils';
import { getContainerSubTabBreadcrumbs } from '@/react/docker/containers/ItemView/containerBreadcrumbs';
import { isLinuxTerminalCommand, LINUX_SHELL_INIT_COMMANDS } from '@@/Terminal/Terminal';
angular.module('portainer.docker').controller('ContainerConsoleController', [
@@ -126,9 +128,15 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
$scope.initView = function () {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
// Set the trail up-front (without the container name) so it survives the
// load window and a load error; the success path fills in the name.
$scope.breadcrumbs = getContainerSubTabBreadcrumbs($transition$.to().name, $transition$.params(), '', 'Console');
return ContainerService.container(endpoint.Id, $transition$.params().id)
.then(function (data) {
$scope.container = data;
// Stack-aware breadcrumb: keeps the stack trail when the container was
// opened from a stack, falls back to the global Containers trail otherwise.
$scope.breadcrumbs = getContainerSubTabBreadcrumbs($transition$.to().name, $transition$.params(), trimContainerName(data.Name), 'Console');
return ImageService.image(data.Image);
})
.then(function (data) {

View File

@@ -1,14 +1,4 @@
<page-header
title="'Container console'"
breadcrumbs="[
{ label:'Containers', link:'docker.containers' },
{
label:(container.Name | trimcontainername),
link: 'docker.containers.container',
linkParams: { id: container.Id },
}, 'Console']"
>
</page-header>
<page-header title="'Container console'" breadcrumbs="breadcrumbs"> </page-header>
<div class="row" ng-init="initView()" ng-show="loaded">
<div class="col-lg-12 col-md-12 col-xs-12">

View File

@@ -1,5 +1,8 @@
import moment from 'moment';
import { trimContainerName } from '@/docker/filters/utils';
import { getContainerSubTabBreadcrumbs } from '@/react/docker/containers/ItemView/containerBreadcrumbs';
angular.module('portainer.docker').controller('ContainerLogsController', [
'$scope',
'$transition$',
@@ -81,10 +84,16 @@ angular.module('portainer.docker').controller('ContainerLogsController', [
function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
// Set the trail up-front (without the container name) so it survives the
// load window and a load error; the success path fills in the name.
$scope.breadcrumbs = getContainerSubTabBreadcrumbs($transition$.to().name, $transition$.params(), '', 'Logs');
ContainerService.container(endpoint.Id, $transition$.params().id)
.then(function success(data) {
var container = data;
$scope.container = container;
// Stack-aware breadcrumb: keeps the stack trail when the container was
// opened from a stack, falls back to the global Containers trail otherwise.
$scope.breadcrumbs = getContainerSubTabBreadcrumbs($transition$.to().name, $transition$.params(), trimContainerName(container.Name), 'Logs');
const logsEnabled = container.HostConfig && container.HostConfig.LogConfig && container.HostConfig.LogConfig.Type && container.HostConfig.LogConfig.Type !== 'none';
$scope.logsEnabled = logsEnabled;

View File

@@ -1,14 +1,4 @@
<page-header
title="'Container logs'"
breadcrumbs="[
{ label:'Containers', link:'docker.containers' },
{
label:(container.Name | trimcontainername),
link: 'docker.containers.container',
linkParams: { id: container.Id }
}, 'Logs']"
>
</page-header>
<page-header title="'Container logs'" breadcrumbs="breadcrumbs"> </page-header>
<container-log-view ng-if="!logsEnabled"></container-log-view>

View File

@@ -1,5 +1,8 @@
import moment from 'moment';
import { trimContainerName } from '@/docker/filters/utils';
import { getContainerSubTabBreadcrumbs } from '@/react/docker/containers/ItemView/containerBreadcrumbs';
angular.module('portainer.docker').controller('ContainerStatsController', [
'$q',
'$scope',
@@ -159,9 +162,15 @@ angular.module('portainer.docker').controller('ContainerStatsController', [
function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
// Set the trail up-front (without the container name) so it survives the
// load window and a load error; the success path fills in the name.
$scope.breadcrumbs = getContainerSubTabBreadcrumbs($transition$.to().name, $transition$.params(), '', 'Stats');
ContainerService.container(endpoint.Id, $transition$.params().id)
.then(function success(data) {
$scope.container = data;
// Stack-aware breadcrumb: keeps the stack trail when the container was
// opened from a stack, falls back to the global Containers trail otherwise.
$scope.breadcrumbs = getContainerSubTabBreadcrumbs($transition$.to().name, $transition$.params(), trimContainerName(data.Name), 'Stats');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve container information');

View File

@@ -1,14 +1,4 @@
<page-header
title="'Container statistics'"
breadcrumbs="[
{ label:'Containers', link:'docker.containers' },
{
label:(container.Name | trimcontainername),
link: 'docker.containers.container',
linkParams: { id: container.Id },
}, 'Stats']"
>
</page-header>
<page-header title="'Container statistics'" breadcrumbs="breadcrumbs"> </page-header>
<div class="row">
<div class="col-md-12">

View File

@@ -12,12 +12,12 @@ import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector
import { Code } from '@@/Code';
import { useContainerInspect } from '../queries/useContainerInspect';
import { getContainerSubTabBreadcrumbs } from '../ItemView/containerBreadcrumbs';
export function InspectView() {
const environmentId = useEnvironmentId();
const {
params: { id, nodeName },
} = useCurrentStateAndParams();
const { state, params } = useCurrentStateAndParams();
const { id, nodeName } = params;
const inspectQuery = useContainerInspect(environmentId, id, { nodeName });
const [viewType, setViewType] = useState<'tree' | 'text'>('tree');
@@ -31,15 +31,12 @@ export function InspectView() {
<>
<PageHeader
title="Container inspect"
breadcrumbs={[
{ label: 'Containers', link: 'docker.containers' },
{
label: trimContainerName(containerInfo.Name),
link: '^',
// linkParams: { id: containerInfo.Id },
},
'Inspect',
]}
breadcrumbs={getContainerSubTabBreadcrumbs(
state?.name,
params,
trimContainerName(containerInfo.Name),
'Inspect'
)}
/>
<div className="row">

View File

@@ -1,4 +1,5 @@
import { FileText, Info, BarChart2, Terminal, Paperclip } from 'lucide-react';
import { useCurrentStateAndParams } from '@uirouter/react';
import { ContainerId } from '@/react/docker/containers/types';
import { useAuthorizations } from '@/react/hooks/useUser';
@@ -7,11 +8,31 @@ import { Icon } from '@@/Icon';
import { Button, ButtonGroup } from '@@/buttons';
import { Link } from '@@/Link';
import {
STACK_CONTAINER_STATE_NAME,
buildStackContainerLinkParams,
isStackContainerState,
} from '../containerBreadcrumbs';
interface Props {
containerId: ContainerId;
}
export function ActionLinksRow({ containerId }: Props) {
const { state, params } = useCurrentStateAndParams();
// When the container was opened from a stack, keep the sub-tab links inside
// the stack tree (docker.stacks.stack.container.*) so the stack params are
// preserved and the breadcrumb keeps the stack trail. Otherwise use the
// global container states.
const fromStack = isStackContainerState(state?.name);
const baseState = fromStack
? STACK_CONTAINER_STATE_NAME
: 'docker.containers.container';
const linkParams = fromStack
? buildStackContainerLinkParams(params, containerId)
: { id: containerId };
const { authorized: canLogs } = useAuthorizations(['DockerContainerLogs']);
const { authorized: canInspect } = useAuthorizations([
'DockerContainerInspect',
@@ -37,10 +58,8 @@ export function ActionLinksRow({ containerId }: Props) {
<Button
as={Link}
props={{
to: 'docker.containers.container.logs',
params: {
id: containerId,
},
to: `${baseState}.logs`,
params: linkParams,
}}
data-cy="container-logs-link"
color="link"
@@ -53,10 +72,8 @@ export function ActionLinksRow({ containerId }: Props) {
<Button
as={Link}
props={{
to: 'docker.containers.container.inspect',
params: {
id: containerId,
},
to: `${baseState}.inspect`,
params: linkParams,
}}
data-cy="container-inspect-link"
color="link"
@@ -69,10 +86,8 @@ export function ActionLinksRow({ containerId }: Props) {
<Button
as={Link}
props={{
to: 'docker.containers.container.stats',
params: {
id: containerId,
},
to: `${baseState}.stats`,
params: linkParams,
}}
data-cy="container-stats-link"
color="link"
@@ -85,10 +100,8 @@ export function ActionLinksRow({ containerId }: Props) {
<Button
as={Link}
props={{
to: 'docker.containers.container.exec',
params: {
id: containerId,
},
to: `${baseState}.exec`,
params: linkParams,
}}
data-cy="container-console-link"
color="link"
@@ -101,10 +114,8 @@ export function ActionLinksRow({ containerId }: Props) {
<Button
as={Link}
props={{
to: 'docker.containers.container.attach',
params: {
id: containerId,
},
to: `${baseState}.attach`,
params: linkParams,
}}
data-cy="container-attach-link"
color="link"

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

@@ -20,12 +20,16 @@ import { ContainerDetailsSection } from './ContainerDetailsSection/ContainerDeta
import { VolumesSection } from './VolumesSection/VolumesSection';
import { ContainerNetworksDatatable } from './ContainerNetworksDatatable';
import { HealthStatus } from './HealthStatus';
import { getContainerBreadcrumbs } from './containerBreadcrumbs';
// Re-exported for backwards compatibility: the breadcrumb source of truth now
// lives in ./containerBreadcrumbs (shared with the container sub-tab views).
export { STACK_CONTAINER_STATE_NAME } from './containerBreadcrumbs';
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 +60,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">

View File

@@ -0,0 +1,189 @@
import { Crumb } from '@@/PageHeader/Breadcrumbs/Breadcrumbs';
import {
STACK_CONTAINER_STATE_NAME,
getContainerBreadcrumbs,
getContainerSubTabBreadcrumbs,
isStackContainerState,
} from './containerBreadcrumbs';
// Narrow a crumb to the object form so the tests can assert on its link/params.
function asCrumb(value: Crumb | string): Crumb {
if (typeof value === 'string') {
throw new Error(`expected an object crumb but got the string "${value}"`);
}
return value;
}
// Find a crumb by its visible label.
function findCrumb(
crumbs: Array<Crumb | string>,
label: string
): Crumb | undefined {
return crumbs
.filter((c): c is Crumb => typeof c !== 'string')
.find((c) => c.label === label);
}
describe('isStackContainerState', () => {
it('matches the stack container detail state and its sub-tab children', () => {
expect(isStackContainerState(STACK_CONTAINER_STATE_NAME)).toBe(true);
expect(isStackContainerState(`${STACK_CONTAINER_STATE_NAME}.logs`)).toBe(
true
);
expect(isStackContainerState(`${STACK_CONTAINER_STATE_NAME}.exec`)).toBe(
true
);
});
it('does not match the global container states or undefined', () => {
expect(isStackContainerState('docker.containers.container')).toBe(false);
expect(isStackContainerState('docker.containers.container.logs')).toBe(
false
);
expect(isStackContainerState(undefined)).toBe(false);
});
});
describe('getContainerBreadcrumbs', () => {
it('returns the global Containers trail when not opened from a stack', () => {
const crumbs = getContainerBreadcrumbs(
'docker.containers.container',
{ id: 'c1' },
'web'
);
expect(asCrumb(crumbs[0]).link).toBe('docker.containers');
expect(crumbs[crumbs.length - 1]).toBe('web');
expect(findCrumb(crumbs, 'Stacks')).toBeUndefined();
});
it('returns the stack trail when opened from a stack', () => {
const crumbs = getContainerBreadcrumbs(
STACK_CONTAINER_STATE_NAME,
{ id: 'c1', name: 'my-stack', stackId: '7', type: '2', regular: 'true' },
'web'
);
expect(asCrumb(crumbs[0]).link).toBe('docker.stacks');
const stackCrumb = asCrumb(crumbs[1]);
expect(stackCrumb.label).toBe('my-stack');
expect(stackCrumb.link).toBe('docker.stacks.stack');
expect(stackCrumb.linkParams).toMatchObject({ stackId: '7' });
expect(findCrumb(crumbs, 'Containers')).toBeUndefined();
});
});
describe('getContainerSubTabBreadcrumbs', () => {
it('falls back to the global Containers trail for a non-stack sub-tab', () => {
const crumbs = getContainerSubTabBreadcrumbs(
'docker.containers.container.logs',
{ id: 'c1' },
'web',
'Logs'
);
expect(crumbs).toHaveLength(3);
expect(asCrumb(crumbs[0]).link).toBe('docker.containers');
const containerCrumb = asCrumb(crumbs[1]);
expect(containerCrumb.label).toBe('web');
expect(containerCrumb.link).toBe('docker.containers.container');
expect(containerCrumb.linkParams).toEqual({ id: 'c1' });
expect(crumbs[2]).toBe('Logs');
expect(findCrumb(crumbs, 'Stacks')).toBeUndefined();
});
it('keeps the stack trail for a regular stack sub-tab', () => {
const crumbs = getContainerSubTabBreadcrumbs(
`${STACK_CONTAINER_STATE_NAME}.logs`,
{ id: 'c1', name: 'my-stack', stackId: '7', type: '2', regular: 'true' },
'web',
'Logs'
);
// Stacks > my-stack > web > Logs, and never the global Containers crumb.
expect(asCrumb(crumbs[0]).link).toBe('docker.stacks');
const stackCrumb = asCrumb(crumbs[1]);
expect(stackCrumb.label).toBe('my-stack');
expect(stackCrumb.link).toBe('docker.stacks.stack');
expect(stackCrumb.linkParams).toMatchObject({
stackId: '7',
regular: 'true',
});
const containerCrumb = asCrumb(crumbs[2]);
expect(containerCrumb.label).toBe('web');
// The container crumb links back into the stack tree, carrying both the
// container id and the stack params so the trail survives a reload.
expect(containerCrumb.link).toBe(STACK_CONTAINER_STATE_NAME);
expect(containerCrumb.linkParams).toMatchObject({
id: 'c1',
stackId: '7',
});
expect(crumbs[crumbs.length - 1]).toBe('Logs');
expect(findCrumb(crumbs, 'Containers')).toBeUndefined();
});
it('omits the stack id and regular flag for an external stack sub-tab', () => {
const crumbs = getContainerSubTabBreadcrumbs(
`${STACK_CONTAINER_STATE_NAME}.stats`,
{
id: 'c1',
name: 'ext-stack',
type: '2',
external: 'true',
// Present in the inherited params but must NOT propagate via the
// external branch.
stackId: '7',
regular: 'true',
},
'web',
'Stats'
);
const stackCrumb = asCrumb(crumbs[1]);
expect(stackCrumb.linkParams).toMatchObject({ external: true, type: '2' });
expect(stackCrumb.linkParams).not.toHaveProperty('stackId');
expect(stackCrumb.linkParams).not.toHaveProperty('regular');
const containerCrumb = asCrumb(crumbs[2]);
expect(containerCrumb.linkParams).toMatchObject({
id: 'c1',
external: true,
});
expect(containerCrumb.linkParams).not.toHaveProperty('stackId');
expect(findCrumb(crumbs, 'Containers')).toBeUndefined();
});
it('keeps the stack id and flags an orphaned stack sub-tab', () => {
const crumbs = getContainerSubTabBreadcrumbs(
`${STACK_CONTAINER_STATE_NAME}.exec`,
{
id: 'c1',
name: 'orphan-stack',
stackId: '7',
type: '2',
orphaned: 'true',
},
'web',
'Console'
);
const stackCrumb = asCrumb(crumbs[1]);
expect(stackCrumb.linkParams).toMatchObject({
stackId: '7',
orphaned: 'true',
});
expect(stackCrumb.linkParams).not.toHaveProperty('external');
const containerCrumb = asCrumb(crumbs[2]);
expect(containerCrumb.linkParams).toMatchObject({
id: 'c1',
stackId: '7',
orphaned: 'true',
});
expect(crumbs[crumbs.length - 1]).toBe('Console');
expect(findCrumb(crumbs, 'Containers')).toBeUndefined();
});
});

View File

@@ -0,0 +1,146 @@
import { Crumb } from '@@/PageHeader/Breadcrumbs/Breadcrumbs';
// 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. The container attribute sub-tabs (logs/stats/inspect/exec/
// attach) are registered as child states of this one so the stack params are
// inherited and the stack trail can be preserved on every sub-tab.
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.
export type StackLinkParams = {
name?: string;
stackId?: string;
type?: string;
regular?: string;
orphaned?: string;
orphanedRunning?: string;
external?: boolean;
tab?: string;
};
// Params for the back-link to the stack-scoped container detail view: the stack
// params (so the stack tree stays active) plus the container id/nodeName.
export type StackContainerLinkParams = StackLinkParams & {
id?: string;
nodeName?: string;
};
// True when the current route is the stack-scoped container detail view or any
// of its attribute sub-tabs (which live under STACK_CONTAINER_STATE_NAME). Used
// so the breadcrumb logic recognises the sub-tabs as "opened from a stack"
// instead of falling back to the global Containers trail.
export function isStackContainerState(stateName: string | undefined): boolean {
return (
stateName === STACK_CONTAINER_STATE_NAME ||
!!stateName?.startsWith(`${STACK_CONTAINER_STATE_NAME}.`)
);
}
// 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.
export function getContainerBreadcrumbs(
stateName: string | undefined,
params: Record<string, string | undefined>,
containerName: string
): Array<Crumb | string> {
if (isStackContainerState(stateName)) {
return [
{ label: 'Stacks', link: 'docker.stacks' },
{
label: params.name || '',
link: 'docker.stacks.stack',
linkParams: buildStackLinkParams(params),
},
containerName,
];
}
return [{ label: 'Containers', link: 'docker.containers' }, containerName];
}
// Breadcrumbs for a container attribute sub-tab (Logs/Stats/Inspect/Console/
// Attach). Mirrors getContainerBreadcrumbs but adds a clickable container crumb
// (back to the container detail view) plus the tab label, so the stack trail is
// preserved on every sub-tab when (and only when) the container was opened from
// a stack.
export function getContainerSubTabBreadcrumbs(
stateName: string | undefined,
params: Record<string, string | undefined>,
containerName: string,
tabLabel: string
): Array<Crumb | string> {
const containerId = params.id;
if (isStackContainerState(stateName)) {
return [
{ label: 'Stacks', link: 'docker.stacks' },
{
label: params.name || '',
link: 'docker.stacks.stack',
linkParams: buildStackLinkParams(params),
},
{
label: containerName,
link: STACK_CONTAINER_STATE_NAME,
linkParams: buildStackContainerLinkParams(params, containerId),
},
tabLabel,
];
}
return [
{ label: 'Containers', link: 'docker.containers' },
{
label: containerName,
link: 'docker.containers.container',
linkParams: { id: containerId },
},
tabLabel,
];
}
// Rebuild the params expected by the docker.stacks.stack route from the current
// (container) route params, which are inherited from the parent stack state.
export 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,
};
}
// Params for navigating to / linking back to the stack-scoped container detail
// view: the stack params (so the stack tree stays active and the trail can be
// rebuilt) plus the container id/nodeName.
export function buildStackContainerLinkParams(
params: Record<string, string | undefined>,
containerId: string | undefined
): StackContainerLinkParams {
return {
...buildStackLinkParams(params),
id: containerId,
nodeName: params.nodeName,
};
}

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,
},