Compare commits

...

1 Commits

Author SHA1 Message Date
agent_coder
f379e8057e fix(stacks): keep stack breadcrumb on container Quick Actions
The stack container list reuses the shared containers datatable, whose
Quick Actions column linked to the global docker.containers.container.*
states with only {id,nodeName}. Clicking Logs/Stats/Console/Inspect/Attach
from within a stack therefore jumped to the global route and collapsed the
breadcrumb to "Containers > <name> > Logs", losing the stack trail that
PR #7 added.

Thread the current stack route params (via RowContext) down to
ContainerQuickActions so, when rendered inside a stack, its links target the
stack-scoped docker.stacks.stack.container.* sub-tab states (reusing #7's
buildStackContainerLinkParams / STACK_CONTAINER_STATE_NAME helpers). The
global containers list and service tasks pass no stack params and keep the
global links unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:52:44 +03:00
5 changed files with 184 additions and 11 deletions

View File

@@ -4,6 +4,13 @@ import { createRowContext } from '@@/datatables/RowContext';
interface RowContextState {
environment: Environment;
// When the containers datatable is rendered inside a stack, these are the
// current stack route params (name/stackId/type/regular/external/orphaned/
// orphanedRunning/tab). They let the Quick Actions column build links to the
// stack-scoped container sub-tab states so the stack breadcrumb trail is
// preserved. Undefined for the global containers list (keeps global links).
// Raw route params (strings), consumed by buildStackLinkParams downstream.
stackRouteParams?: Record<string, string | undefined>;
}
const { RowProvider, useRowContext } = createRowContext<RowContextState>();

View File

@@ -3,10 +3,12 @@ import { CellContext } from '@tanstack/react-table';
import { useAuthorizations } from '@/react/hooks/useUser';
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
import { ContainerListViewModel } from '@/react/docker/containers/types';
import { buildStackContainerLinkParams } from '@/react/docker/containers/ItemView/containerBreadcrumbs';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { TableSettings } from '../types';
import { useRowContext } from '../RowContext';
import { columnHelper } from './helper';
@@ -20,9 +22,20 @@ function QuickActionsCell({
row: { original: container },
}: CellContext<ContainerListViewModel, unknown>) {
const settings = useTableSettings<TableSettings>();
const { stackRouteParams } = useRowContext();
const { hiddenQuickActions = [] } = settings;
// When rendered inside a stack, build the params for the stack-scoped
// container sub-tab states so the stack breadcrumb trail is preserved.
// Undefined in the global containers list, which keeps the global links.
const containerStackLinkParams = stackRouteParams
? buildStackContainerLinkParams(
{ ...stackRouteParams, nodeName: container.NodeName },
container.Id
)
: undefined;
const wrapperState = {
showQuickActionAttach: !hiddenQuickActions.includes('attach'),
showQuickActionExec: !hiddenQuickActions.includes('exec'),
@@ -57,6 +70,7 @@ function QuickActionsCell({
nodeName={container.NodeName}
status={container.Status}
state={wrapperState}
stackLinkParams={containerStackLinkParams}
/>
);
}

View File

@@ -0,0 +1,129 @@
import { render } from '@testing-library/react';
import { ReactNode } from 'react';
import { vi } from 'vitest';
import { ContainerStatus } from '@/react/docker/containers/types';
import { buildStackContainerLinkParams } from '@/react/docker/containers/ItemView/containerBreadcrumbs';
import {
ContainerQuickActions,
QuickActionsState,
} from './ContainerQuickActions';
// Render the Link's target state and params as data attributes so the tests can
// assert on them without a full ui-router state tree.
vi.mock('@@/Link', () => ({
Link: ({
children,
to,
params,
'data-cy': dataCy,
}: {
children: ReactNode;
to: string;
params?: Record<string, unknown>;
'data-cy'?: string;
}) => (
<a
href="/"
data-cy={dataCy}
data-to={to}
data-params={JSON.stringify(params)}
>
{children}
</a>
),
}));
// Authorizations are covered elsewhere; render the children unconditionally.
vi.mock('@/react/hooks/useUser', () => ({
Authorized: ({ children }: { children: ReactNode }) => children,
}));
const allOn: QuickActionsState = {
showQuickActionAttach: true,
showQuickActionExec: true,
showQuickActionInspect: true,
showQuickActionLogs: true,
showQuickActionStats: true,
};
const containerId = 'abc123';
const nodeName = 'node-1';
const tabs = ['logs', 'inspect', 'stats', 'exec', 'attach'] as const;
// Look the rendered Link up by the data-cy our Link mock sets.
function byDataCy(tab: (typeof tabs)[number]) {
return document.querySelector<HTMLElement>(
`[data-cy="container-${tab}-${containerId}"]`
);
}
test('without stack context, links target the global container states', () => {
render(
<ContainerQuickActions
containerId={containerId}
nodeName={nodeName}
status={ContainerStatus.Running}
state={allOn}
/>
);
tabs.forEach((tab) => {
const link = byDataCy(tab);
expect(link).not.toBeNull();
expect(link?.getAttribute('data-to')).toBe(
`docker.containers.container.${tab}`
);
expect(JSON.parse(link?.getAttribute('data-params') || '{}')).toEqual({
id: containerId,
nodeName,
});
});
});
test('with stack context, links target the stack-scoped container states with stack params', () => {
const stackRouteParams = {
name: 'my-stack',
stackId: '7',
type: '2',
regular: 'true',
tab: 'containers',
};
const stackLinkParams = buildStackContainerLinkParams(
{ ...stackRouteParams, nodeName },
containerId
);
render(
<ContainerQuickActions
containerId={containerId}
nodeName={nodeName}
status={ContainerStatus.Running}
state={allOn}
stackLinkParams={stackLinkParams}
/>
);
tabs.forEach((tab) => {
const link = byDataCy(tab);
expect(link).not.toBeNull();
expect(link?.getAttribute('data-to')).toBe(
`docker.stacks.stack.container.${tab}`
);
// Every sub-tab shares the same stack params + container id/nodeName so the
// stack breadcrumb trail is preserved on navigation.
expect(JSON.parse(link?.getAttribute('data-params') || '{}')).toEqual({
name: 'my-stack',
stackId: '7',
type: '2',
regular: 'true',
orphaned: undefined,
orphanedRunning: undefined,
tab: 'containers',
id: containerId,
nodeName,
});
});
});

View File

@@ -2,6 +2,10 @@ import clsx from 'clsx';
import { BarChart, FileText, Info, Paperclip, Terminal } from 'lucide-react';
import { ContainerStatus } from '@/react/docker/containers/types';
import {
STACK_CONTAINER_STATE_NAME,
StackContainerLinkParams,
} from '@/react/docker/containers/ItemView/containerBreadcrumbs';
import { Authorized } from '@/react/hooks/useUser';
import { Icon } from '@@/Icon';
@@ -22,11 +26,16 @@ export function ContainerQuickActions({
containerId,
nodeName,
state,
stackLinkParams,
}: {
containerId: string;
nodeName: string;
status: ContainerStatus;
state: QuickActionsState;
// When provided (container opened from a stack), the quick actions link to
// the stack-scoped container sub-tab states with these params so the stack
// breadcrumb trail is preserved. Otherwise they link to the global states.
stackLinkParams?: StackContainerLinkParams;
}) {
const isActive =
!!status &&
@@ -37,13 +46,23 @@ export function ContainerQuickActions({
ContainerStatus.Unhealthy,
].includes(status);
// Build the target ui-router state for a given sub-tab: the stack-scoped
// state when inside a stack, the global container state otherwise. Both share
// the same tab suffix (logs/inspect/stats/exec/attach).
function linkTo(tab: string) {
return stackLinkParams
? `${STACK_CONTAINER_STATE_NAME}.${tab}`
: `docker.containers.container.${tab}`;
}
const linkParams = stackLinkParams ?? { id: containerId, nodeName };
return (
<div className={clsx('space-x-1', styles.root)}>
{state.showQuickActionLogs && (
<Authorized authorizations="DockerContainerLogs">
<Link
to="docker.containers.container.logs"
params={{ id: containerId, nodeName }}
to={linkTo('logs')}
params={linkParams}
title="Logs"
data-cy={`container-logs-${containerId}`}
>
@@ -55,8 +74,8 @@ export function ContainerQuickActions({
{state.showQuickActionInspect && (
<Authorized authorizations="DockerContainerInspect">
<Link
to="docker.containers.container.inspect"
params={{ id: containerId, nodeName }}
to={linkTo('inspect')}
params={linkParams}
title="Inspect"
data-cy={`container-inspect-${containerId}`}
>
@@ -68,8 +87,8 @@ export function ContainerQuickActions({
{state.showQuickActionStats && isActive && (
<Authorized authorizations="DockerContainerStats">
<Link
to="docker.containers.container.stats"
params={{ id: containerId, nodeName }}
to={linkTo('stats')}
params={linkParams}
title="Stats"
data-cy={`container-stats-${containerId}`}
>
@@ -81,8 +100,8 @@ export function ContainerQuickActions({
{state.showQuickActionExec && isActive && (
<Authorized authorizations="DockerExecStart">
<Link
to="docker.containers.container.exec"
params={{ id: containerId, nodeName }}
to={linkTo('exec')}
params={linkParams}
title="Exec Console"
data-cy={`container-exec-${containerId}`}
>
@@ -94,8 +113,8 @@ export function ContainerQuickActions({
{state.showQuickActionAttach && isActive && (
<Authorized authorizations="DockerContainerAttach">
<Link
to="docker.containers.container.attach"
params={{ id: containerId, nodeName }}
to={linkTo('attach')}
params={linkParams}
title="Attach Console"
data-cy={`container-attach-${containerId}`}
>

View File

@@ -1,4 +1,5 @@
import { Box } from 'lucide-react';
import { useCurrentStateAndParams } from '@uirouter/react';
import { ContainerListViewModel } from '@/react/docker/containers/types';
import { createStore } from '@/react/docker/containers/ListView/ContainersDatatable/datatable-store';
@@ -42,6 +43,9 @@ export interface Props {
export function StackContainersDatatable({ stackName }: Props) {
const environmentQuery = useCurrentEnvironment();
const tableState = useTableState(settingsStore, storageKey);
// Current stack route params, forwarded to the Quick Actions column so it can
// link to the stack-scoped container sub-tab states and keep the stack trail.
const { params: stackRouteParams } = useCurrentStateAndParams();
const isGPUsColumnVisible = useShowGPUsColumn(environmentQuery.data);
const columns = useColumns(false, isGPUsColumnVisible);
@@ -60,7 +64,7 @@ export function StackContainersDatatable({ stackName }: Props) {
const environment = environmentQuery.data;
return (
<RowProvider context={{ environment }}>
<RowProvider context={{ environment, stackRouteParams }}>
<TableSettingsProvider settings={settingsStore}>
<Datatable
title="Containers"