Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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',
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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