Compare commits
5 Commits
develop
...
feat/4-sta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bf4e71b79 | ||
|
|
e9fae32b43 | ||
|
|
a1851417d1 | ||
|
|
b4d10a67b2 | ||
|
|
cb11b0fca4 |
@@ -418,7 +418,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
|
||||
var stack = {
|
||||
name: 'docker.stacks.stack',
|
||||
url: '/:name?id&type®ular&external&orphaned&orphanedRunning&tab',
|
||||
url: '/:name?stackId&type®ular&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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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®ular&external&orphaned&orphanedRunning&tab',
|
||||
},
|
||||
{ name: 'docker.containers', url: '/containers' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
return render(<Wrapped />);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
146
app/react/docker/containers/ItemView/containerBreadcrumbs.ts
Normal file
146
app/react/docker/containers/ItemView/containerBreadcrumbs.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user