Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { LoadingBox } from '@console/internal/components/utils/status-box';
import { ImageStreamTagModel, NamespaceModel, PodModel } from '@console/internal/models';
import type { NodeKind, PodKind } from '@console/internal/module/k8s';
import { k8sCreate, k8sGet, k8sKillByName } from '@console/internal/module/k8s';
import store from '@console/internal/redux';
import PaneBody from '@console/shared/src/components/layout/PaneBody';
import { getDetachedSessions } from '@console/webterminal-plugin/src/redux/reducers/cloud-shell-selectors';

type NodeTerminalErrorProps = {
error: ReactNode;
Expand All @@ -18,6 +20,7 @@ type NodeTerminalInnerProps = {
pod?: PodKind;
loaded: boolean;
loadError?: unknown;
debugNamespace?: string;
};

type NodeTerminalProps = {
Expand Down Expand Up @@ -77,7 +80,7 @@ const getDebugPod = async (
runAsUser: 0,
},
stdin: true,
stdinOnce: true,
stdinOnce: false,
tty: true,
volumeMounts: [
{
Expand Down Expand Up @@ -126,7 +129,12 @@ const NodeTerminalError: FC<NodeTerminalErrorProps> = ({ error }) => {
);
};

const NodeTerminalInner: FC<NodeTerminalInnerProps> = ({ pod, loaded, loadError }) => {
const NodeTerminalInner: FC<NodeTerminalInnerProps> = ({
pod,
loaded,
loadError,
debugNamespace,
}) => {
const { t } = useTranslation();
const message = (
<Trans t={t} ns="console-app">
Expand Down Expand Up @@ -166,7 +174,14 @@ const NodeTerminalInner: FC<NodeTerminalInnerProps> = ({ pod, loaded, loadError
/>
);
case 'Running':
return <PodConnectLoader obj={pod} message={message} attach />;
return (
<PodConnectLoader
obj={pod}
message={message}
attach
cleanupOnDetach={debugNamespace ? { type: 'namespace', name: debugNamespace } : undefined}
/>
);
default:
return <LoadingBox />;
}
Expand Down Expand Up @@ -207,7 +222,10 @@ const NodeTerminal: FC<NodeTerminalProps> = ({ obj: node }) => {
};
const closeTab = (event) => {
event.preventDefault();
deleteNamespace(namespace.metadata.name);
const detached = getDetachedSessions(store.getState());
if (!detached.some((s) => s.podName === name)) {
deleteNamespace(namespace.metadata.name);
}
};
const createDebugPod = async () => {
try {
Expand Down Expand Up @@ -244,15 +262,24 @@ const NodeTerminal: FC<NodeTerminalProps> = ({ obj: node }) => {
createDebugPod();
window.addEventListener('beforeunload', closeTab);
return () => {
deleteNamespace(namespace.metadata.name);
const detached = getDetachedSessions(store.getState());
const isDetached = detached.some((s) => s.podName === name);
if (!isDetached) {
deleteNamespace(namespace.metadata.name);
}
window.removeEventListener('beforeunload', closeTab);
};
}, [nodeName, isWindows]);

return errorMessage ? (
<NodeTerminalError error={errorMessage} />
) : (
<NodeTerminalInner pod={pod} loaded={loaded} loadError={loadError} />
<NodeTerminalInner
pod={pod}
loaded={loaded}
loadError={loadError}
debugNamespace={podNamespace}
/>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
@web-terminal
Feature: Persistent Terminal Sessions (Detach to Cloud Shell)
As a user, I should be able to detach pod terminals to the Cloud Shell drawer
so that they persist across page navigation

Background:
Given user has logged in as basic user
And user has created or selected namespace "aut-terminal-detach"
And user can see terminal icon on masthead

@regression
Scenario: Detach pod terminal to Cloud Shell drawer: WT-02-TC01
Given user is on the pod details terminal tab for a running pod
When user clicks the Detach to Cloud Shell button
Then user will see the Cloud Shell drawer open
And user will see a detached session tab with the pod name

@regression
Scenario: Detached session persists across navigation: WT-02-TC02
Given user has a detached terminal session in the Cloud Shell drawer
When user navigates to a different page
Then user will still see the detached session tab in the Cloud Shell drawer

@regression
Scenario: Close a detached session tab: WT-02-TC03
Given user has a detached terminal session in the Cloud Shell drawer
When user clicks the close button on the detached session tab
Then the detached session tab is removed from the drawer

@regression
Scenario: Session limit prevents more than five detached sessions: WT-02-TC04
Given user has five detached terminal sessions in the Cloud Shell drawer
Then the Detach to Cloud Shell button is disabled on the pod terminal

@regression
Scenario: Close drawer clears all detached sessions: WT-02-TC05
Given user has a detached terminal session in the Cloud Shell drawer
When user closes the Cloud Shell drawer
And user clicks on the Web Terminal icon on the Masthead
Then user will not see any detached session tabs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export const detachTerminalPO = {
detachButton: 'button:contains("Detach to Cloud Shell")',
detachedButton: 'button:contains("Detached")',
detachedTab: '[data-test="detached-terminal-tab"]',
multiTabTerminal: '[data-test="multi-tab-terminal"]',
closeTabButton: '[aria-label="Close terminal tab"]',
cloudShellDrawer: '.co-cloud-shell-drawer',
};

export const detachTerminalPage = {
clickDetachButton: () => {
cy.get(detachTerminalPO.detachButton).should('be.visible').click();
},

verifyDetachedTabs: (count: number) => {
cy.get(detachTerminalPO.detachedTab).should('have.length', count);
},

verifyNoDetachedTabs: () => {
cy.get(detachTerminalPO.detachedTab).should('not.exist');
},

verifyDetachButtonDisabled: () => {
cy.get(detachTerminalPO.detachButton).should('be.disabled');
},

closeDetachedTab: (index = 0) => {
cy.get(detachTerminalPO.detachedTab).eq(index).find(detachTerminalPO.closeTabButton).click();
},

verifyDrawerOpen: () => {
cy.get(detachTerminalPO.cloudShellDrawer).should('be.visible');
},

verifyDetachedTabWithPodName: (podName: string) => {
cy.get(detachTerminalPO.detachedTab).contains(podName).should('be.visible');
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps';
import { detachTerminalPage } from '@console/webterminal-plugin/integration-tests/support/step-definitions/pages/web-terminal/detachTerminal-page';
import { webTerminalPage } from '@console/webterminal-plugin/integration-tests/support/step-definitions/pages/web-terminal/webTerminal-page';

Given('user is on the pod details terminal tab for a running pod', () => {
const ns = Cypress.expose('NAMESPACE') || 'aut-terminal-detach';
cy.exec(`oc get pods -n ${ns} -o jsonpath='{.items[0].metadata.name}'`).then((result) => {
const podName = result.stdout.replace(/'/g, '');
cy.visit(`/k8s/ns/${ns}/pods/${podName}/terminal`);
cy.get('.co-terminal', { timeout: 30000 }).should('be.visible');
});
});

When('user clicks the Detach to Cloud Shell button', () => {
detachTerminalPage.clickDetachButton();
});

Then('user will see the Cloud Shell drawer open', () => {
detachTerminalPage.verifyDrawerOpen();
});

Then('user will see a detached session tab with the pod name', () => {
detachTerminalPage.verifyDetachedTabs(1);
});

Given('user has a detached terminal session in the Cloud Shell drawer', () => {
const ns = Cypress.expose('NAMESPACE') || 'aut-terminal-detach';
cy.exec(`oc get pods -n ${ns} -o jsonpath='{.items[0].metadata.name}'`).then((result) => {
const podName = result.stdout.replace(/'/g, '');
cy.visit(`/k8s/ns/${ns}/pods/${podName}/terminal`);
cy.get('.co-terminal', { timeout: 30000 }).should('be.visible');
detachTerminalPage.clickDetachButton();
detachTerminalPage.verifyDetachedTabs(1);
});
});

When('user navigates to a different page', () => {
cy.visit('/k8s/cluster/projects');
cy.url().should('include', '/projects');
});

Then('user will still see the detached session tab in the Cloud Shell drawer', () => {
detachTerminalPage.verifyDetachedTabs(1);
});

When('user clicks the close button on the detached session tab', () => {
detachTerminalPage.closeDetachedTab(0);
});

Then('the detached session tab is removed from the drawer', () => {
detachTerminalPage.verifyNoDetachedTabs();
});

Given('user has five detached terminal sessions in the Cloud Shell drawer', () => {
const ns = Cypress.expose('NAMESPACE') || 'aut-terminal-detach';
cy.exec(`oc get pods -n ${ns} -o jsonpath='{.items[*].metadata.name}'`).then((result) => {
const pods = result.stdout.replace(/'/g, '').split(' ');
const targetPod = pods[0];
for (let i = 0; i < 5; i++) {
cy.visit(`/k8s/ns/${ns}/pods/${targetPod}/terminal`);
cy.get('.co-terminal', { timeout: 30000 }).should('be.visible');
detachTerminalPage.clickDetachButton();
}
detachTerminalPage.verifyDetachedTabs(5);
});
});

Then('the Detach to Cloud Shell button is disabled on the pod terminal', () => {
detachTerminalPage.verifyDetachButtonDisabled();
});

When('user closes the Cloud Shell drawer', () => {
webTerminalPage.closeCurrentTerminalSession();
});

Then('user will not see any detached session tabs', () => {
detachTerminalPage.verifyNoDetachedTabs();
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import type { FC } from 'react';
import { useFlag } from '@console/shared/src/hooks/useFlag';
import { FLAG_DEVWORKSPACE } from '../../const';
import { useToggleCloudShellExpanded } from '../../redux/actions/cloud-shell-dispatchers';
import { useIsCloudShellExpanded } from '../../redux/reducers/cloud-shell-selectors';
import {
useIsCloudShellExpanded,
useDetachedSessions,
} from '../../redux/reducers/cloud-shell-selectors';
import { CloudShellDrawer } from './CloudShellDrawer';

interface CloudShellProps {
Expand All @@ -13,12 +16,15 @@ const CloudShell: FC<CloudShellProps> = ({ children }) => {
const onClose = useToggleCloudShellExpanded();
const open = useIsCloudShellExpanded();
const devWorkspaceAvailable = useFlag(FLAG_DEVWORKSPACE);
const detachedSessions = useDetachedSessions();

if (!devWorkspaceAvailable) {
const hasDetachedSessions = detachedSessions.length > 0;

if (!devWorkspaceAvailable && !hasDetachedSessions) {
return <>{children}</>;
}
return (
<CloudShellDrawer onClose={onClose} open={open}>
<CloudShellDrawer onClose={onClose} open={open || hasDetachedSessions}>
{children}
</CloudShellDrawer>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ import { css } from '@patternfly/react-styles';
import { c_drawer_m_inline_m_panel_bottom__splitter_Height as pfSplitterHeight } from '@patternfly/react-tokens/dist/esm/c_drawer_m_inline_m_panel_bottom__splitter_Height';
import { useTranslation } from 'react-i18next';
import { ExternalLinkButton } from '@console/shared/src/components/links/ExternalLinkButton';
import { useFlag } from '@console/shared/src/hooks/useFlag';
import { useTelemetry } from '@console/shared/src/hooks/useTelemetry';
import { MinimizeRestoreButton } from '@console/webterminal-plugin/src/components/cloud-shell/MinimizeRestoreButton';
import { MultiTabbedTerminal } from '@console/webterminal-plugin/src/components/cloud-shell/MultiTabbedTerminal';
import { FLAG_DEVWORKSPACE } from '../../const';
import { MAX_DETACHED_SESSIONS } from '../../redux/reducers/cloud-shell-reducer';
import { useDetachedSessions } from '../../redux/reducers/cloud-shell-selectors';

import './CloudShellDrawer.scss';

Expand Down Expand Up @@ -46,6 +50,9 @@ export const CloudShellDrawer: FC<CloudShellDrawerProps> = ({
const [height, setHeight] = useState<number>(385);
const { t } = useTranslation('webterminal-plugin');
const fireTelemetryEvent = useTelemetry();
const devWorkspaceAvailable = useFlag(FLAG_DEVWORKSPACE);
const detachedSessions = useDetachedSessions();
const detachedCount = detachedSessions.length;

const onMRButtonClick = (expandedState: boolean) => {
setExpanded(!expandedState);
Expand All @@ -70,17 +77,26 @@ export const CloudShellDrawer: FC<CloudShellDrawerProps> = ({
>
<DrawerHead className="co-cloud-shell-drawer__header pf-v6-u-p-0">
<Flex grow={{ default: 'grow' }} data-test="cloudshell-drawer-header">
<FlexItem className="pf-v6-u-px-sm">{t('OpenShift command line terminal')}</FlexItem>
<FlexItem className="pf-v6-u-px-sm">
{t('OpenShift command line terminal')}
{detachedCount > 0 && (
<span className="pf-v6-u-ml-sm pf-v6-u-font-size-sm pf-v6-u-color-200">
({detachedCount}/{MAX_DETACHED_SESSIONS} {t('detached')})
</span>
)}
</FlexItem>
<FlexItem align={{ default: 'alignRight' }}>
<DrawerActions className="pf-v6-u-m-0">
<Tooltip content={t('Open terminal in new tab')}>
<ExternalLinkButton
variant="plain"
href="/terminal"
aria-label={t('Open terminal in new tab')}
iconProps={{ title: undefined }} // aria-label is sufficient
/>
</Tooltip>
{devWorkspaceAvailable && (
<Tooltip content={t('Open terminal in new tab')}>
<ExternalLinkButton
variant="plain"
href="/terminal"
aria-label={t('Open terminal in new tab')}
iconProps={{ title: undefined }} // aria-label is sufficient
/>
</Tooltip>
)}
<MinimizeRestoreButton
minimize={expanded}
minimizeText={t('Minimize terminal')}
Expand Down
Loading