diff --git a/frontend/packages/console-app/src/components/nodes/NodeTerminal.tsx b/frontend/packages/console-app/src/components/nodes/NodeTerminal.tsx index 29b5dc338c8..22497970d44 100644 --- a/frontend/packages/console-app/src/components/nodes/NodeTerminal.tsx +++ b/frontend/packages/console-app/src/components/nodes/NodeTerminal.tsx @@ -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; @@ -18,6 +20,7 @@ type NodeTerminalInnerProps = { pod?: PodKind; loaded: boolean; loadError?: unknown; + debugNamespace?: string; }; type NodeTerminalProps = { @@ -77,7 +80,7 @@ const getDebugPod = async ( runAsUser: 0, }, stdin: true, - stdinOnce: true, + stdinOnce: false, tty: true, volumeMounts: [ { @@ -126,7 +129,12 @@ const NodeTerminalError: FC = ({ error }) => { ); }; -const NodeTerminalInner: FC = ({ pod, loaded, loadError }) => { +const NodeTerminalInner: FC = ({ + pod, + loaded, + loadError, + debugNamespace, +}) => { const { t } = useTranslation(); const message = ( @@ -166,7 +174,14 @@ const NodeTerminalInner: FC = ({ pod, loaded, loadError /> ); case 'Running': - return ; + return ( + + ); default: return ; } @@ -207,7 +222,10 @@ const NodeTerminal: FC = ({ 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 { @@ -244,22 +262,25 @@ const NodeTerminal: FC = ({ obj: node }) => { createDebugPod(); window.addEventListener('beforeunload', closeTab); return () => { - if (namespace) { + 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]); - if (errorMessage) { - return ; - } - - if (!podName) { - return ; - } - - return ; + return errorMessage ? ( + + ) : ( + + ); }; export default NodeTerminal; diff --git a/frontend/packages/webterminal-plugin/integration-tests/features/web-terminal/web-terminal-detach.feature b/frontend/packages/webterminal-plugin/integration-tests/features/web-terminal/web-terminal-detach.feature new file mode 100644 index 00000000000..38abcccdb15 --- /dev/null +++ b/frontend/packages/webterminal-plugin/integration-tests/features/web-terminal/web-terminal-detach.feature @@ -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 diff --git a/frontend/packages/webterminal-plugin/integration-tests/support/step-definitions/pages/web-terminal/detachTerminal-page.ts b/frontend/packages/webterminal-plugin/integration-tests/support/step-definitions/pages/web-terminal/detachTerminal-page.ts new file mode 100644 index 00000000000..d4f6c4f5c0a --- /dev/null +++ b/frontend/packages/webterminal-plugin/integration-tests/support/step-definitions/pages/web-terminal/detachTerminal-page.ts @@ -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'); + }, +}; diff --git a/frontend/packages/webterminal-plugin/integration-tests/support/step-definitions/web-terminal/web-terminal-detach.ts b/frontend/packages/webterminal-plugin/integration-tests/support/step-definitions/web-terminal/web-terminal-detach.ts new file mode 100644 index 00000000000..620c615611d --- /dev/null +++ b/frontend/packages/webterminal-plugin/integration-tests/support/step-definitions/web-terminal/web-terminal-detach.ts @@ -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(); +}); diff --git a/frontend/packages/webterminal-plugin/src/components/cloud-shell/CloudShell.tsx b/frontend/packages/webterminal-plugin/src/components/cloud-shell/CloudShell.tsx index faee57f6098..f10cda2ce45 100644 --- a/frontend/packages/webterminal-plugin/src/components/cloud-shell/CloudShell.tsx +++ b/frontend/packages/webterminal-plugin/src/components/cloud-shell/CloudShell.tsx @@ -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 { @@ -13,12 +16,15 @@ const CloudShell: FC = ({ 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 ( - + {children} ); diff --git a/frontend/packages/webterminal-plugin/src/components/cloud-shell/CloudShellDrawer.tsx b/frontend/packages/webterminal-plugin/src/components/cloud-shell/CloudShellDrawer.tsx index 0cb7b4dd39c..000a766a62b 100644 --- a/frontend/packages/webterminal-plugin/src/components/cloud-shell/CloudShellDrawer.tsx +++ b/frontend/packages/webterminal-plugin/src/components/cloud-shell/CloudShellDrawer.tsx @@ -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'; @@ -46,6 +50,9 @@ export const CloudShellDrawer: FC = ({ const [height, setHeight] = useState(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); @@ -70,17 +77,26 @@ export const CloudShellDrawer: FC = ({ > - {t('OpenShift command line terminal')} + + {t('OpenShift command line terminal')} + {detachedCount > 0 && ( + + ({detachedCount}/{MAX_DETACHED_SESSIONS} {t('detached')}) + + )} + - - - + {devWorkspaceAvailable && ( + + + + )} = ({ + sessionId, + podName, + namespace, + containerName, + command, +}) => { + const [wsOpen, setWsOpen] = useState(false); + const [wsError, setWsError] = useState(); + const [reconnecting, setReconnecting] = useState(false); + const ws = useRef(); + const terminal = useRef(); + const { t } = useTranslation(); + const isOpenShift = useFlag(FLAGS.OPENSHIFT); + + const onData = useCallback((data: string): void => { + ws.current?.send(`0${Base64.encode(data)}`); + }, []); + + const handleResize = useCallback((cols: number, rows: number) => { + const data = Base64.encode(JSON.stringify({ Height: rows, Width: cols })); + ws.current?.send(`4${data}`); + }, []); + + useEffect(() => { + let unmounted = false; + const usedClient = isOpenShift ? 'oc' : 'kubectl'; + const cmd = command || ['sh', '-i', '-c', 'TERM=xterm sh']; + + const transferred = takeDetachedWebSocket(sessionId); + let websocket: any; + + if (transferred) { + websocket = transferred; + websocket + .onmessage((msg: string) => { + const data = Base64.decode(msg.slice(1)); + terminal.current?.onDataReceived(data); + }) + .onclose((evt: any) => { + if (!evt || evt.wasClean === true) { + return; + } + const error = evt.reason || t('webterminal-plugin~The terminal connection has closed.'); + terminal.current?.onConnectionClosed(error); + websocket.destroy(); + if (!unmounted) { + setWsOpen(false); + setWsError(error); + } + }) + // eslint-disable-next-line no-console + .onerror((evt: any) => console.error(`WS error?! ${evt}`)); + + ws.current?.destroy(); + ws.current = websocket; + if (!unmounted) { + setWsOpen(true); + setTimeout(() => { + websocket.send(`0${Base64.encode('\n')}`); + }, 200); + } + } else { + const impersonate = getImpersonate(store.getState()) || { subprotocols: [] }; + const subprotocols = (impersonate.subprotocols || []).concat('base64.channel.k8s.io'); + + const urlOpts = { + ns: namespace, + name: podName, + path: 'exec', + queryParams: { + stdout: '1', + stdin: '1', + stderr: '1', + tty: '1', + container: containerName, + command: cmd.map((c) => encodeURIComponent(c)).join('&command='), + }, + }; + + const path = resourceURL(PodModel, urlOpts); + websocket = new WSFactory(`${podName}-detached-terminal`, { + host: 'auto', + reconnect: true, + jsonParse: false, + path, + subprotocols, + }); + + let previous = ''; + + websocket + .onmessage((msg: string) => { + if (msg[0] === '3') { + if (previous.includes(NO_SH)) { + const errMsg = `This container doesn't have a /bin/sh shell. Try specifying your command in a terminal with:\r\n\r\n ${usedClient} -n ${namespace} exec ${podName} -ti `; + terminal.current?.reset(); + terminal.current?.onConnectionClosed(errMsg); + websocket.destroy(); + previous = ''; + return; + } + } + const data = Base64.decode(msg.slice(1)); + terminal.current?.onDataReceived(data); + previous = data; + }) + .onopen(() => { + terminal.current?.reset(); + previous = ''; + if (!unmounted) { + setWsOpen(true); + } + }) + .onclose((evt: any) => { + if (!evt || evt.wasClean === true) { + return; + } + const error = evt.reason || t('webterminal-plugin~The terminal connection has closed.'); + terminal.current?.onConnectionClosed(error); + websocket.destroy(); + if (!unmounted) { + setWsOpen(false); + setWsError(error); + } + }) + // eslint-disable-next-line no-console + .onerror((evt: any) => console.error(`WS error?! ${evt}`)); + + if (ws.current !== websocket) { + ws.current?.destroy(); + ws.current = websocket; + terminal.current?.onConnectionClosed( + t('webterminal-plugin~connecting to {{container}}', { container: containerName }), + ); + } + } + + setReconnecting(false); + + return () => { + unmounted = true; + websocket.destroy(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [podName, namespace, containerName, command, isOpenShift, t, reconnecting]); + + if (wsError) { + return ( +
+ + {wsError} + + + + +
+ ); + } + + if (wsOpen) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +}; + +export default DetachedPodExec; diff --git a/frontend/packages/webterminal-plugin/src/components/cloud-shell/MultiTabbedTerminal.tsx b/frontend/packages/webterminal-plugin/src/components/cloud-shell/MultiTabbedTerminal.tsx index 1a3bb43a6b2..9b58a5b5d92 100644 --- a/frontend/packages/webterminal-plugin/src/components/cloud-shell/MultiTabbedTerminal.tsx +++ b/frontend/packages/webterminal-plugin/src/components/cloud-shell/MultiTabbedTerminal.tsx @@ -1,30 +1,47 @@ import type { FC } from 'react'; -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { Tabs, Tab } from '@patternfly/react-core'; import { useTranslation } from 'react-i18next'; +import { cleanupDetachedResource } from '@console/internal/module/detached-ws-registry'; +import { useConsoleDispatch } from '@console/shared/src/hooks/useConsoleDispatch'; +import { useFlag } from '@console/shared/src/hooks/useFlag'; import { useTelemetry } from '@console/shared/src/hooks/useTelemetry'; +import { FLAG_DEVWORKSPACE } from '../../const'; +import { removeDetachedSession } from '../../redux/actions/cloud-shell-actions'; +import { useDetachedSessions } from '../../redux/reducers/cloud-shell-selectors'; import { sendActivityTick } from './cloud-shell-utils'; import CloudShellTerminal from './CloudShellTerminal'; +import DetachedPodExec from './DetachedPodExec'; import { TICK_INTERVAL } from './useActivityTick'; import './MultiTabbedTerminal.scss'; const MAX_TERMINAL_TABS = 8; +const DETACHED_PREFIX = 'detached-'; interface MultiTabbedTerminalProps { onClose?: () => void; } export const MultiTabbedTerminal: FC = ({ onClose }) => { - const [terminalTabs, setTerminalTabs] = useState([1]); - const [activeTabKey, setActiveTabKey] = useState(1); + const devWorkspaceAvailable = useFlag(FLAG_DEVWORKSPACE); + const [terminalTabs, setTerminalTabs] = useState(devWorkspaceAvailable ? [1] : []); + const [activeTabKey, setActiveTabKey] = useState(devWorkspaceAvailable ? 1 : 0); const [tickNamespace, setTickNamespace] = useState(null); const [tickWorkspace, setTickWorkspace] = useState(null); const { t } = useTranslation('webterminal-plugin'); const fireTelemetryEvent = useTelemetry(); + const dispatch = useConsoleDispatch(); + const detachedSessions = useDetachedSessions(); + const prevDetachedCountRef = useRef(detachedSessions.length); const tick = useCallback(() => { - return tickNamespace && tickWorkspace && sendActivityTick(tickWorkspace, tickNamespace); - }, [tickWorkspace, tickNamespace]); + return ( + typeof activeTabKey === 'number' && + tickNamespace && + tickWorkspace && + sendActivityTick(tickWorkspace, tickNamespace) + ); + }, [activeTabKey, tickWorkspace, tickNamespace]); useEffect(() => { let startTime; @@ -44,26 +61,80 @@ export const MultiTabbedTerminal: FC = ({ onClose }) = }; }, [tick]); - const addNewTerminal = () => { - if (terminalTabs.length < MAX_TERMINAL_TABS) { - const tabs = [...terminalTabs]; - const newTerminalNumber = terminalTabs[terminalTabs.length - 1] + 1; - tabs.push(newTerminalNumber); - setTerminalTabs(tabs); - setActiveTabKey(newTerminalNumber); - fireTelemetryEvent('Web Terminal New Tab'); + useEffect(() => { + if (detachedSessions.length > prevDetachedCountRef.current) { + const newest = detachedSessions[detachedSessions.length - 1]; + setActiveTabKey(`${DETACHED_PREFIX}${newest.id}`); } - }; + prevDetachedCountRef.current = detachedSessions.length; + }, [detachedSessions]); - const removeCurrentTerminal = (_, eventKey: number) => { - const tabIndex = terminalTabs.indexOf(eventKey); - const tabs = [...terminalTabs]; - if (tabs[tabIndex] === activeTabKey) { - setActiveTabKey(tabIndex > 0 ? tabs[tabIndex - 1] : tabs[tabs.length - 1]); - } - tabs.splice(tabIndex, 1); - setTerminalTabs(tabs); - }; + const totalTabCount = terminalTabs.length + detachedSessions.length; + + const addNewTerminal = devWorkspaceAvailable + ? () => { + if (totalTabCount < MAX_TERMINAL_TABS) { + const tabs = [...terminalTabs]; + const newTerminalNumber = (terminalTabs[terminalTabs.length - 1] || 0) + 1; + tabs.push(newTerminalNumber); + setTerminalTabs(tabs); + setActiveTabKey(newTerminalNumber); + fireTelemetryEvent('Web Terminal New Tab'); + } + } + : undefined; + + const handleTabClose = useCallback( + (_, eventKey: string | number) => { + const isDetached = typeof eventKey === 'string' && eventKey.startsWith(DETACHED_PREFIX); + + if (isDetached) { + const sessionId = (eventKey as string).slice(DETACHED_PREFIX.length); + const closedSession = detachedSessions.find((s) => s.id === sessionId); + const remaining = detachedSessions.filter((s) => s.id !== sessionId); + if (closedSession?.cleanup) { + cleanupDetachedResource(closedSession.cleanup); + } + if (remaining.length === 0 && terminalTabs.length === 0) { + dispatch(removeDetachedSession(sessionId)); + onClose?.(); + return; + } + dispatch(removeDetachedSession(sessionId)); + if (activeTabKey === eventKey) { + if (remaining.length > 0) { + setActiveTabKey(`${DETACHED_PREFIX}${remaining[0].id}`); + } else if (terminalTabs.length > 0) { + setActiveTabKey(terminalTabs[terminalTabs.length - 1]); + } + } + return; + } + + const numKey = eventKey as number; + const tabIndex = terminalTabs.indexOf(numKey); + if (tabIndex === -1) return; + + if (terminalTabs.length === 1 && detachedSessions.length === 0) { + onClose?.(); + return; + } + + const tabs = [...terminalTabs]; + if (numKey === activeTabKey) { + if (tabIndex > 0) { + setActiveTabKey(tabs[tabIndex - 1]); + } else if (tabs.length > 1) { + setActiveTabKey(tabs[1]); + } else if (detachedSessions.length > 0) { + setActiveTabKey(`${DETACHED_PREFIX}${detachedSessions[0].id}`); + } + } + tabs.splice(tabIndex, 1); + setTerminalTabs(tabs); + }, + [activeTabKey, terminalTabs, detachedSessions, dispatch, onClose], + ); const getWorkspaceNamespace = (namespace: string, terminal: number) => { terminal === activeTabKey && namespace !== tickNamespace && setTickNamespace(namespace); @@ -73,7 +144,7 @@ export const MultiTabbedTerminal: FC = ({ onClose }) = terminal === activeTabKey && name !== tickWorkspace && setTickWorkspace(name); }; - const removeTabFunction = terminalTabs.length > 1 ? removeCurrentTerminal : onClose; + const closeHandler = totalTabCount > 1 ? handleTabClose : onClose; return ( = ({ onClose }) = isBox data-test="multi-tab-terminal" className="co-cloud-shell-drawer__header" - onClose={removeTabFunction} - onAdd={terminalTabs.length < MAX_TERMINAL_TABS ? addNewTerminal : undefined} + onClose={closeHandler} + onAdd={addNewTerminal && totalTabCount < MAX_TERMINAL_TABS ? addNewTerminal : undefined} addButtonAriaLabel={t('Add new tab')} > - {terminalTabs.map((terminalNumber) => ( - setActiveTabKey(terminalNumber)} - onMouseDown={(event) => { - // middle click to close - if (event.button === 1) { - event.preventDefault(); - if (typeof removeTabFunction === 'function') { - removeTabFunction(event, terminalNumber); + {[ + ...terminalTabs.map((terminalNumber) => ( + setActiveTabKey(terminalNumber)} + onMouseDown={(event) => { + if (event.button === 1) { + event.preventDefault(); + if (typeof closeHandler === 'function') { + closeHandler(event, terminalNumber); + } } - } - }} - title={t('Terminal {{number}}', { number: terminalNumber })} - > - - - ))} + }} + title={t('Terminal {{number}}', { number: terminalNumber })} + > + + + )), + ...detachedSessions.map((session) => { + const tabKey = `${DETACHED_PREFIX}${session.id}`; + const label = + session.podName.length > 20 + ? `${session.podName.slice(0, 17)}.../${session.containerName}` + : `${session.podName}/${session.containerName}`; + return ( + setActiveTabKey(tabKey)} + onMouseDown={(event) => { + if (event.button === 1) { + event.preventDefault(); + if (typeof closeHandler === 'function') { + closeHandler(event, tabKey); + } + } + }} + title={label} + > + + + ); + }), + ]} ); }; diff --git a/frontend/packages/webterminal-plugin/src/components/cloud-shell/__tests__/CloudShellDrawer.spec.tsx b/frontend/packages/webterminal-plugin/src/components/cloud-shell/__tests__/CloudShellDrawer.spec.tsx index 94a8c1602ae..d070ccb91ae 100644 --- a/frontend/packages/webterminal-plugin/src/components/cloud-shell/__tests__/CloudShellDrawer.spec.tsx +++ b/frontend/packages/webterminal-plugin/src/components/cloud-shell/__tests__/CloudShellDrawer.spec.tsx @@ -21,6 +21,7 @@ jest.mock('@console/webterminal-plugin/src/redux/actions/cloud-shell-dispatchers jest.mock('@console/webterminal-plugin/src/redux/reducers/cloud-shell-selectors', () => ({ useIsCloudShellExpanded: jest.fn(() => true), + useDetachedSessions: jest.fn(() => []), })); const mockUseFlag = useFlag as jest.Mock; diff --git a/frontend/packages/webterminal-plugin/src/components/cloud-shell/__tests__/MultiTabbedTerminal.spec.tsx b/frontend/packages/webterminal-plugin/src/components/cloud-shell/__tests__/MultiTabbedTerminal.spec.tsx index 864bfe97e46..f83e49ebd8f 100644 --- a/frontend/packages/webterminal-plugin/src/components/cloud-shell/__tests__/MultiTabbedTerminal.spec.tsx +++ b/frontend/packages/webterminal-plugin/src/components/cloud-shell/__tests__/MultiTabbedTerminal.spec.tsx @@ -13,10 +13,33 @@ jest.mock('@console/webterminal-plugin/src/components/cloud-shell/CloudShellTerm default: () => 'Terminal content', })); +jest.mock('@console/webterminal-plugin/src/components/cloud-shell/DetachedPodExec', () => ({ + default: ({ sessionId }: { sessionId: string }) => `Detached ${sessionId}`, +})); + +jest.mock('@console/shared/src/hooks/useFlag', () => ({ + useFlag: () => true, +})); + +const mockCleanup = jest.fn(); +jest.mock('@console/internal/module/detached-ws-registry', () => ({ + cleanupDetachedResource: (...args: unknown[]) => mockCleanup(...args), +})); + +const mockUseDetachedSessions = jest.fn(); +jest.mock('@console/webterminal-plugin/src/redux/reducers/cloud-shell-selectors', () => { + const actual = jest.requireActual( + '@console/webterminal-plugin/src/redux/reducers/cloud-shell-selectors', + ); + return { + ...actual, + useDetachedSessions: () => mockUseDetachedSessions(), + }; +}); + const originalWindowRequestAnimationFrame = window.requestAnimationFrame; const originalWindowCancelAnimationFrame = window.cancelAnimationFrame; -// Helper to click an element multiple times sequentially const clickMultipleTimes = async ( user: ReturnType, getElement: () => HTMLElement | null, @@ -25,7 +48,7 @@ const clickMultipleTimes = async ( for (let i = 0; i < times; i++) { // eslint-disable-next-line no-await-in-loop const element = getElement(); - if (!element) break; // Stop if element disappears + if (!element) break; // eslint-disable-next-line no-await-in-loop await user.click(element); } @@ -50,6 +73,11 @@ describe('MultiTabTerminal', () => { window.cancelAnimationFrame = originalWindowCancelAnimationFrame; }); + beforeEach(() => { + mockUseDetachedSessions.mockReturnValue([]); + mockCleanup.mockClear(); + }); + it('should initially load with only one console', () => { const multiTabTerminalWrapper = renderWithProviders(); @@ -104,5 +132,51 @@ describe('MultiTabTerminal', () => { expect(multiTabTerminalWrapper.getAllByText('Terminal content').length).toBe(5); }); + describe('detached session tabs', () => { + const detachedSessions = [ + { + id: 'pod1-c1', + podName: 'my-pod', + namespace: 'ns-1', + containerName: 'main', + cleanup: { type: 'namespace' as const, name: 'openshift-debug-abc' }, + }, + { + id: 'pod2-c2', + podName: 'other-pod', + namespace: 'ns-2', + containerName: 'sidecar', + }, + ]; + + it('should render detached sessions as additional tabs', () => { + mockUseDetachedSessions.mockReturnValue(detachedSessions); + const wrapper = renderWithProviders(); + + expect(wrapper.getByText('Detached pod1-c1')).toBeTruthy(); + expect(wrapper.getByText('Detached pod2-c2')).toBeTruthy(); + }); + + it('should include detached sessions in total tab count', () => { + mockUseDetachedSessions.mockReturnValue(detachedSessions); + const wrapper = renderWithProviders(); + + // 1 Cloud Shell tab + 2 detached = 3 total + const closeBtns = wrapper.getAllByLabelText('Close terminal tab'); + expect(closeBtns).toHaveLength(3); + }); + + it('should call cleanupDetachedResource when closing a detached tab with cleanup metadata', async () => { + mockUseDetachedSessions.mockReturnValue(detachedSessions); + const wrapper = renderWithProviders(); + + const closeBtns = wrapper.getAllByLabelText('Close terminal tab'); + // Index 0 = Cloud Shell tab, Index 1 = first detached, Index 2 = second detached + await user.click(closeBtns[1]); + + expect(mockCleanup).toHaveBeenCalledWith(detachedSessions[0].cleanup); + }); + }); + jest.clearAllTimers(); }); diff --git a/frontend/packages/webterminal-plugin/src/redux/actions/__tests__/cloud-shell-actions.spec.ts b/frontend/packages/webterminal-plugin/src/redux/actions/__tests__/cloud-shell-actions.spec.ts index 036e022e40a..53fe536a26c 100644 --- a/frontend/packages/webterminal-plugin/src/redux/actions/__tests__/cloud-shell-actions.spec.ts +++ b/frontend/packages/webterminal-plugin/src/redux/actions/__tests__/cloud-shell-actions.spec.ts @@ -1,4 +1,11 @@ -import { setCloudShellExpanded, setCloudShellActive, Actions } from '../cloud-shell-actions'; +import { + setCloudShellExpanded, + setCloudShellActive, + addDetachedSession, + removeDetachedSession, + clearDetachedSessions, + Actions, +} from '../cloud-shell-actions'; describe('Cloud shell actions', () => { it('should create expand action', () => { @@ -38,4 +45,38 @@ describe('Cloud shell actions', () => { }), ); }); + + it('should create addDetachedSession action', () => { + const session = { + id: 'test-pod-container-123', + podName: 'test-pod', + namespace: 'default', + containerName: 'container', + command: ['sh', '-i'], + cleanup: { type: 'namespace' as const, name: 'openshift-debug-abc' }, + }; + expect(addDetachedSession(session)).toEqual( + expect.objectContaining({ + type: Actions.AddDetachedSession, + payload: session, + }), + ); + }); + + it('should create removeDetachedSession action', () => { + expect(removeDetachedSession('session-1')).toEqual( + expect.objectContaining({ + type: Actions.RemoveDetachedSession, + payload: { id: 'session-1' }, + }), + ); + }); + + it('should create clearDetachedSessions action', () => { + expect(clearDetachedSessions()).toEqual( + expect.objectContaining({ + type: Actions.ClearDetachedSessions, + }), + ); + }); }); diff --git a/frontend/packages/webterminal-plugin/src/redux/actions/cloud-shell-actions.ts b/frontend/packages/webterminal-plugin/src/redux/actions/cloud-shell-actions.ts index 8d285086ec1..cf39eb45234 100644 --- a/frontend/packages/webterminal-plugin/src/redux/actions/cloud-shell-actions.ts +++ b/frontend/packages/webterminal-plugin/src/redux/actions/cloud-shell-actions.ts @@ -1,10 +1,28 @@ import type { ActionType } from 'typesafe-actions'; import { action } from 'typesafe-actions'; +export type DetachedSessionCleanup = { + type: 'namespace' | 'pod'; + name: string; + namespace?: string; +}; + +export type DetachedSession = { + id: string; + podName: string; + namespace: string; + containerName: string; + command?: string[]; + cleanup?: DetachedSessionCleanup; +}; + export enum Actions { SetCloudShellExpanded = 'setCloudShellExpanded', SetCloudShellActive = 'setCloudShellActive', SetCloudShellCommand = 'setCloudShellCommand', + AddDetachedSession = 'addDetachedSession', + RemoveDetachedSession = 'removeDetachedSession', + ClearDetachedSessions = 'clearDetachedSessions', } export const setCloudShellCommand = (command: string | null) => @@ -16,10 +34,20 @@ export const setCloudShellExpanded = (isExpanded: boolean) => export const setCloudShellActive = (isActive: boolean) => action(Actions.SetCloudShellActive, { isActive }); +export const addDetachedSession = (session: DetachedSession) => + action(Actions.AddDetachedSession, session); + +export const removeDetachedSession = (id: string) => action(Actions.RemoveDetachedSession, { id }); + +export const clearDetachedSessions = () => action(Actions.ClearDetachedSessions); + const actions = { setCloudShellExpanded, setCloudShellActive, setCloudShellCommand, + addDetachedSession, + removeDetachedSession, + clearDetachedSessions, }; export type CloudShellActions = ActionType; diff --git a/frontend/packages/webterminal-plugin/src/redux/actions/cloud-shell-dispatchers.ts b/frontend/packages/webterminal-plugin/src/redux/actions/cloud-shell-dispatchers.ts index 8a1b73295e8..461f030db50 100644 --- a/frontend/packages/webterminal-plugin/src/redux/actions/cloud-shell-dispatchers.ts +++ b/frontend/packages/webterminal-plugin/src/redux/actions/cloud-shell-dispatchers.ts @@ -1,10 +1,19 @@ import { useCallback } from 'react'; import { ButtonVariant } from '@patternfly/react-core'; import { useTranslation } from 'react-i18next'; +import { cleanupDetachedResource } from '@console/internal/module/detached-ws-registry'; import { useConsoleDispatch } from '@console/shared/src/hooks/useConsoleDispatch'; import { useWarningModal } from '@console/shared/src/hooks/useWarningModal'; -import { useIsCloudShellActive, useIsCloudShellExpanded } from '../reducers/cloud-shell-selectors'; -import { setCloudShellExpanded, setCloudShellCommand } from './cloud-shell-actions'; +import { + useIsCloudShellActive, + useIsCloudShellExpanded, + useDetachedSessions, +} from '../reducers/cloud-shell-selectors'; +import { + setCloudShellExpanded, + setCloudShellCommand, + clearDetachedSessions, +} from './cloud-shell-actions'; export const useCloudShellCommandDispatch = (): ((command: string | null) => void) => { const dispatch = useConsoleDispatch(); @@ -19,8 +28,22 @@ export const useCloudShellCommandDispatch = (): ((command: string | null) => voi export const useToggleCloudShellExpanded = (): (() => void) => { const isExpanded = useIsCloudShellExpanded(); const isActive = useIsCloudShellActive(); + const detachedSessions = useDetachedSessions(); const dispatch = useConsoleDispatch(); const { t } = useTranslation('webterminal-plugin'); + + const doClose = useCallback(() => { + dispatch(setCloudShellExpanded(false)); + if (detachedSessions.length > 0) { + detachedSessions.forEach((s) => { + if (s.cleanup) { + cleanupDetachedResource(s.cleanup); + } + }); + dispatch(clearDetachedSessions()); + } + }, [dispatch, detachedSessions]); + const confirmClose = useWarningModal({ title: t('Close terminal?'), children: t( @@ -29,15 +52,15 @@ export const useToggleCloudShellExpanded = (): (() => void) => { confirmButtonVariant: ButtonVariant.danger, confirmButtonLabel: t('Yes'), cancelButtonLabel: t('No'), - onConfirm: () => dispatch(setCloudShellExpanded(false)), + onConfirm: doClose, ouiaId: 'WebTerminalCloseConfirmation', }); return useCallback(() => { - if (isExpanded && isActive) { + if (detachedSessions.length > 0 || (isExpanded && isActive)) { confirmClose(); } else { dispatch(setCloudShellExpanded(!isExpanded)); } - }, [dispatch, isExpanded, isActive, confirmClose]); + }, [dispatch, isExpanded, isActive, detachedSessions.length, confirmClose]); }; diff --git a/frontend/packages/webterminal-plugin/src/redux/reducers/__tests__/cloud-shell-reducer.spec.ts b/frontend/packages/webterminal-plugin/src/redux/reducers/__tests__/cloud-shell-reducer.spec.ts index e98830fb458..b1890731374 100644 --- a/frontend/packages/webterminal-plugin/src/redux/reducers/__tests__/cloud-shell-reducer.spec.ts +++ b/frontend/packages/webterminal-plugin/src/redux/reducers/__tests__/cloud-shell-reducer.spec.ts @@ -1,12 +1,26 @@ -import { setCloudShellExpanded, setCloudShellActive } from '../../actions/cloud-shell-actions'; -import reducer from '../cloud-shell-reducer'; +import { + setCloudShellExpanded, + setCloudShellActive, + addDetachedSession, + removeDetachedSession, + clearDetachedSessions, +} from '../../actions/cloud-shell-actions'; +import type { DetachedSession } from '../../actions/cloud-shell-actions'; +import reducer, { MAX_DETACHED_SESSIONS } from '../cloud-shell-reducer'; + +const makeSession = (id: string): DetachedSession => ({ + id, + podName: `pod-${id}`, + namespace: 'default', + containerName: 'container-00', +}); describe('Cloud shell reducer', () => { it('should have initial state', () => { - // create an unsupported test action const state = reducer(undefined, { type: 'test' } as any); expect(state.isExpanded).toBe(false); expect(state.isActive).toBe(false); + expect(state.detachedSessions).toEqual([]); }); it('should set expanded', () => { @@ -22,4 +36,52 @@ describe('Cloud shell reducer', () => { state = reducer(state, setCloudShellActive(false)); expect(state.isActive).toBe(false); }); + + describe('detached sessions', () => { + it('should add a detached session', () => { + const session = makeSession('s1'); + const state = reducer(undefined, addDetachedSession(session)); + expect(state.detachedSessions).toHaveLength(1); + expect(state.detachedSessions[0]).toEqual(session); + }); + + it('should reject duplicate session ids', () => { + const session = makeSession('s1'); + let state = reducer(undefined, addDetachedSession(session)); + state = reducer(state, addDetachedSession(session)); + expect(state.detachedSessions).toHaveLength(1); + }); + + it('should block additions at MAX_DETACHED_SESSIONS', () => { + let state = reducer(undefined, { type: 'test' } as any); + for (let i = 0; i < MAX_DETACHED_SESSIONS; i++) { + state = reducer(state, addDetachedSession(makeSession(`s${i}`))); + } + expect(state.detachedSessions).toHaveLength(MAX_DETACHED_SESSIONS); + + state = reducer(state, addDetachedSession(makeSession('overflow'))); + expect(state.detachedSessions).toHaveLength(MAX_DETACHED_SESSIONS); + expect(state.detachedSessions.some((s) => s.id === 'overflow')).toBe(false); + }); + + it('should remove a detached session by id', () => { + let state = reducer(undefined, addDetachedSession(makeSession('s1'))); + state = reducer(state, addDetachedSession(makeSession('s2'))); + expect(state.detachedSessions).toHaveLength(2); + + state = reducer(state, removeDetachedSession('s1')); + expect(state.detachedSessions).toHaveLength(1); + expect(state.detachedSessions[0].id).toBe('s2'); + }); + + it('should clear all detached sessions', () => { + let state = reducer(undefined, addDetachedSession(makeSession('s1'))); + state = reducer(state, addDetachedSession(makeSession('s2'))); + state = reducer(state, addDetachedSession(makeSession('s3'))); + expect(state.detachedSessions).toHaveLength(3); + + state = reducer(state, clearDetachedSessions()); + expect(state.detachedSessions).toEqual([]); + }); + }); }); diff --git a/frontend/packages/webterminal-plugin/src/redux/reducers/__tests__/cloud-shell-selectors.spec.ts b/frontend/packages/webterminal-plugin/src/redux/reducers/__tests__/cloud-shell-selectors.spec.ts index b5b051471fb..67aed157225 100644 --- a/frontend/packages/webterminal-plugin/src/redux/reducers/__tests__/cloud-shell-selectors.spec.ts +++ b/frontend/packages/webterminal-plugin/src/redux/reducers/__tests__/cloud-shell-selectors.spec.ts @@ -1,6 +1,7 @@ import { isCloudShellExpanded, isCloudShellActive, + getDetachedSessions, cloudShellReducerName, } from '../cloud-shell-selectors'; @@ -22,4 +23,17 @@ describe('Cloud shell selectors', () => { } as any), ).toBe(true); }); + + it('should select detachedSessions', () => { + expect(getDetachedSessions({} as any)).toEqual([]); + + const sessions = [{ id: 's1', podName: 'pod-1', namespace: 'ns', containerName: 'c' }]; + expect( + getDetachedSessions({ + plugins: { + webterminal: { [cloudShellReducerName]: { detachedSessions: sessions } }, + }, + } as any), + ).toEqual(sessions); + }); }); diff --git a/frontend/packages/webterminal-plugin/src/redux/reducers/cloud-shell-reducer.ts b/frontend/packages/webterminal-plugin/src/redux/reducers/cloud-shell-reducer.ts index 56d149d4686..8864150b7c4 100644 --- a/frontend/packages/webterminal-plugin/src/redux/reducers/cloud-shell-reducer.ts +++ b/frontend/packages/webterminal-plugin/src/redux/reducers/cloud-shell-reducer.ts @@ -1,16 +1,20 @@ -import type { CloudShellActions } from '../actions/cloud-shell-actions'; +import type { CloudShellActions, DetachedSession } from '../actions/cloud-shell-actions'; import { Actions } from '../actions/cloud-shell-actions'; type State = { isExpanded: boolean; isActive: boolean; command: string | null; + detachedSessions: DetachedSession[]; }; +export const MAX_DETACHED_SESSIONS = 5; + const initialState: State = { isExpanded: false, isActive: false, command: null, + detachedSessions: [], }; export default (state = initialState, action: CloudShellActions): State => { @@ -36,6 +40,28 @@ export default (state = initialState, action: CloudShellActions): State => { command, }; } + case Actions.AddDetachedSession: { + if (state.detachedSessions.some((s) => s.id === action.payload.id)) { + return state; + } + if (state.detachedSessions.length >= MAX_DETACHED_SESSIONS) { + return state; + } + return { + ...state, + detachedSessions: [...state.detachedSessions, action.payload], + }; + } + case Actions.RemoveDetachedSession: + return { + ...state, + detachedSessions: state.detachedSessions.filter((s) => s.id !== action.payload.id), + }; + case Actions.ClearDetachedSessions: + return { + ...state, + detachedSessions: [], + }; default: return state; } diff --git a/frontend/packages/webterminal-plugin/src/redux/reducers/cloud-shell-selectors.ts b/frontend/packages/webterminal-plugin/src/redux/reducers/cloud-shell-selectors.ts index c0e166e2a5f..2ad14a84ed5 100644 --- a/frontend/packages/webterminal-plugin/src/redux/reducers/cloud-shell-selectors.ts +++ b/frontend/packages/webterminal-plugin/src/redux/reducers/cloud-shell-selectors.ts @@ -1,5 +1,6 @@ import type { RootState } from '@console/internal/redux'; import { useConsoleSelector } from '@console/shared/src/hooks/useConsoleSelector'; +import type { DetachedSession } from '../actions/cloud-shell-actions'; export const cloudShellReducerName = 'cloudShell'; @@ -23,3 +24,10 @@ export const getCloudShellCommand = (state: RootState): string | null => export const useGetCloudShellCommand = (): string | null => { return useConsoleSelector(getCloudShellCommand); }; + +export const getDetachedSessions = (state: RootState): DetachedSession[] => + state.plugins?.webterminal?.[cloudShellReducerName]?.detachedSessions ?? []; + +export const useDetachedSessions = (): DetachedSession[] => { + return useConsoleSelector(getDetachedSessions); +}; diff --git a/frontend/public/components/debug-terminal.tsx b/frontend/public/components/debug-terminal.tsx index d6286c0a008..12720a58bfe 100644 --- a/frontend/public/components/debug-terminal.tsx +++ b/frontend/public/components/debug-terminal.tsx @@ -10,6 +10,8 @@ import { ConnectedPageHeading } from '@console/internal/components/utils/heading import { ObjectMetadata, PodKind, k8sCreate, k8sKillByName } from '@console/internal/module/k8s'; import { PodConnectLoader } from '@console/internal/components/pod'; import { PodModel } from '@console/internal/models'; +import store from '@console/internal/redux'; +import { getDetachedSessions } from '@console/webterminal-plugin/src/redux/reducers/cloud-shell-selectors'; import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; import PaneBody from '@console/shared/src/components/layout/PaneBody'; @@ -70,7 +72,12 @@ const DebugTerminalError: FC = ({ error, description }) ); }; -const DebugTerminalInner: FC = ({ debugPod, initialContainer }) => { +const DebugTerminalInner: FC = ({ + debugPod, + initialContainer, + debugPodName, + debugPodNamespace, +}) => { const { t } = useTranslation(); const infoMessage = ( = ({ debugPod, initialCont obj={debugPod} initialContainer={initialContainer} infoMessage={infoMessage} + cleanupOnDetach={ + debugPodName && debugPodNamespace + ? { type: 'pod', name: debugPodName, namespace: debugPodNamespace } + : undefined + } /> ); case 'Pending': @@ -147,7 +159,11 @@ export const DebugTerminal: FC = ({ podData, containerName } window.addEventListener('beforeunload', closeTab); return () => { if (newDebugPod) { - deleteDebugPod(newDebugPod.metadata.name); + const detached = getDetachedSessions(store.getState()); + const isDetached = detached.some((s) => s.podName === newDebugPod.metadata.name); + if (!isDetached) { + deleteDebugPod(newDebugPod.metadata.name); + } } window.removeEventListener('beforeunload', closeTab); }; @@ -173,7 +189,14 @@ export const DebugTerminal: FC = ({ podData, containerName } return ; } if (loaded) { - return ; + return ( + + ); } } @@ -230,6 +253,8 @@ type DebugTerminalErrorProps = { type DebugTerminalInnerProps = { debugPod: PodKind; initialContainer?: string; + debugPodName?: string; + debugPodNamespace?: string; }; type DebugTerminalProps = { diff --git a/frontend/public/components/pod-connect.tsx b/frontend/public/components/pod-connect.tsx index a79dca21bc8..72b162711c6 100644 --- a/frontend/public/components/pod-connect.tsx +++ b/frontend/public/components/pod-connect.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; import * as _ from 'lodash'; import { Base64 } from 'js-base64'; import { useTranslation } from 'react-i18next'; -import { ExpandIcon } from '@patternfly/react-icons'; +import { ExpandIcon, ExternalLinkAltIcon } from '@patternfly/react-icons'; import { Button, Alert, @@ -14,10 +14,19 @@ import { ToolbarItem, Flex, FlexItem, + Tooltip, } from '@patternfly/react-core'; import { getImpersonate } from '@console/dynamic-plugin-sdk'; +import { useConsoleDispatch } from '@console/shared/src/hooks/useConsoleDispatch'; +import { + addDetachedSession, + setCloudShellExpanded, +} from '@console/webterminal-plugin/src/redux/actions/cloud-shell-actions'; +import { useDetachedSessions } from '@console/webterminal-plugin/src/redux/reducers/cloud-shell-selectors'; +import { MAX_DETACHED_SESSIONS } from '@console/webterminal-plugin/src/redux/reducers/cloud-shell-reducer'; import store from '../redux'; +import { storeDetachedWebSocket } from '../module/detached-ws-registry'; import { ContainerLabel, ContainerSelect } from './utils/container-select'; import { LoadingBox } from './utils/status-box'; import { FLAGS } from '@console/shared/src/constants/common'; @@ -47,6 +56,7 @@ type PodConnectProps = { initialContainer?: string; message?: React.ReactNode; infoMessage?: React.ReactNode; + cleanupOnDetach?: { type: 'namespace' | 'pod'; name: string; namespace?: string }; }; export const PodConnect: FC = ({ @@ -55,12 +65,18 @@ export const PodConnect: FC = ({ initialContainer, message, infoMessage, + cleanupOnDetach, }) => { const { t } = useTranslation('public'); const terminalRef = useRef(null); const wsRef = useRef(null); const isOpenShift = useFlag(FLAGS.OPENSHIFT); const [fullscreenRef, toggleFullscreen, isFullscreen, canUseFullScreen] = useFullscreen(); + const dispatch = useConsoleDispatch(); + const [detached, setDetached] = useState(false); + const detachedRef = useRef(false); + const detachedSessions = useDetachedSessions(); + const atSessionLimit = detachedSessions.length >= MAX_DETACHED_SESSIONS; const [open, setOpen] = useState(false); const [error, setError] = useState(null); @@ -149,13 +165,37 @@ export const PodConnect: FC = ({ .onerror((evt: any) => console.error(`WS error?! ${evt}`)); }, [podName, namespace, isWindows, attach, activeContainer, t, isOpenShift]); - // Connect on mount and when dependencies change + const detachToCloudShell = useCallback(() => { + if (!wsRef.current || !open) { + return; + } + const command = isWindows ? ['cmd'] : ['sh', '-i', '-c', 'TERM=xterm sh']; + const id = `${podName}-${activeContainer}-${Date.now()}`; + storeDetachedWebSocket(id, wsRef.current); + wsRef.current = null; + detachedRef.current = true; + dispatch( + addDetachedSession({ + id, + podName, + namespace, + containerName: activeContainer, + command, + cleanup: cleanupOnDetach, + }), + ); + dispatch(setCloudShellExpanded(true)); + setDetached(true); + }, [dispatch, podName, namespace, activeContainer, open, isWindows, cleanupOnDetach]); + useEffect(() => { connect(); return () => { - const exitCode = 'exit\r'; if (wsRef.current) { - exitCode.split('').forEach((char) => wsRef.current.send(`0${Base64.encode(char)}`)); + if (!detachedRef.current) { + const exitCode = 'exit\r'; + exitCode.split('').forEach((char) => wsRef.current.send(`0${Base64.encode(char)}`)); + } wsRef.current.destroy(); } }; @@ -228,8 +268,33 @@ export const PodConnect: FC = ({ )}
- {!error && canUseFullScreen && ( - + + {!error && open && ( + + + + + + )} + {!error && canUseFullScreen && ( - - )} + )} + {error && ( diff --git a/frontend/public/components/pod.tsx b/frontend/public/components/pod.tsx index a794ce70baf..85e4ed591a3 100644 --- a/frontend/public/components/pod.tsx +++ b/frontend/public/components/pod.tsx @@ -471,6 +471,7 @@ export const PodConnectLoader: FC = ({ initialContainer, infoMessage, attach = false, + cleanupOnDetach, }) => ( @@ -483,6 +484,7 @@ export const PodConnectLoader: FC = ({ infoMessage={infoMessage} initialContainer={initialContainer} attach={attach} + cleanupOnDetach={cleanupOnDetach} /> @@ -581,6 +583,7 @@ type PodConnectLoaderProps = { infoMessage?: ReactElement; initialContainer?: string; attach?: boolean; + cleanupOnDetach?: { type: 'namespace' | 'pod'; name: string; namespace?: string }; }; type PodDetailsProps = { diff --git a/frontend/public/module/__tests__/detached-ws-registry.spec.ts b/frontend/public/module/__tests__/detached-ws-registry.spec.ts new file mode 100644 index 00000000000..9a52496dc44 --- /dev/null +++ b/frontend/public/module/__tests__/detached-ws-registry.spec.ts @@ -0,0 +1,93 @@ +import { + storeDetachedWebSocket, + takeDetachedWebSocket, + hasDetachedWebSocket, + cleanupDetachedResource, +} from '../detached-ws-registry'; + +const mockK8sKillByName = jest.fn().mockResolvedValue(undefined); + +jest.mock('../k8s', () => ({ + k8sKillByName: (...args: unknown[]) => mockK8sKillByName(...args), +})); + +jest.mock('../../models', () => ({ + NamespaceModel: { kind: 'Namespace', apiVersion: 'v1' }, + PodModel: { kind: 'Pod', apiVersion: 'v1' }, +})); + +describe('detached-ws-registry', () => { + afterEach(() => { + // drain any leftover entries between tests + ['a', 'b', 'c'].forEach((id) => takeDetachedWebSocket(id)); + mockK8sKillByName.mockClear(); + }); + + describe('storeDetachedWebSocket / takeDetachedWebSocket / hasDetachedWebSocket', () => { + it('should store and retrieve a websocket', () => { + const ws = { fake: 'ws' }; + storeDetachedWebSocket('a', ws); + expect(hasDetachedWebSocket('a')).toBe(true); + expect(takeDetachedWebSocket('a')).toBe(ws); + }); + + it('should return undefined on second take (one-shot semantics)', () => { + storeDetachedWebSocket('b', { fake: 'ws2' }); + takeDetachedWebSocket('b'); + expect(takeDetachedWebSocket('b')).toBeUndefined(); + }); + + it('should report false for unknown ids', () => { + expect(hasDetachedWebSocket('nonexistent')).toBe(false); + }); + + it('hasDetachedWebSocket returns false after take', () => { + storeDetachedWebSocket('c', {}); + takeDetachedWebSocket('c'); + expect(hasDetachedWebSocket('c')).toBe(false); + }); + }); + + describe('cleanupDetachedResource', () => { + it('should be a no-op for undefined cleanup', async () => { + await cleanupDetachedResource(undefined); + expect(mockK8sKillByName).not.toHaveBeenCalled(); + }); + + it('should call k8sKillByName with NamespaceModel for type "namespace"', async () => { + await cleanupDetachedResource({ type: 'namespace', name: 'openshift-debug-ns' }); + expect(mockK8sKillByName).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'Namespace' }), + 'openshift-debug-ns', + ); + }); + + it('should call k8sKillByName with PodModel for type "pod"', async () => { + await cleanupDetachedResource({ + type: 'pod', + name: 'debug-pod-abc', + namespace: 'my-ns', + }); + expect(mockK8sKillByName).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'Pod' }), + 'debug-pod-abc', + 'my-ns', + ); + }); + + it('should not call k8sKillByName for type "pod" without namespace', async () => { + await cleanupDetachedResource({ type: 'pod', name: 'debug-pod-abc' }); + expect(mockK8sKillByName).not.toHaveBeenCalled(); + }); + + it('should swallow errors from k8sKillByName', async () => { + mockK8sKillByName.mockRejectedValueOnce(new Error('API error')); + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + await expect( + cleanupDetachedResource({ type: 'namespace', name: 'ns-fail' }), + ).resolves.toBeUndefined(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/frontend/public/module/detached-ws-registry.ts b/frontend/public/module/detached-ws-registry.ts new file mode 100644 index 00000000000..9b8145f1f4a --- /dev/null +++ b/frontend/public/module/detached-ws-registry.ts @@ -0,0 +1,48 @@ +/** + * Global registry for WebSocket instances transferred from PodConnect to + * DetachedPodExec. Keyed by detached session ID. + * + * When a user clicks "Detach to Cloud Shell", PodConnect stores its + * live WSFactory here so that DetachedPodExec can adopt it instead of + * opening a second connection (which may fail on privileged debug pods). + */ + +import type { DetachedSessionCleanup } from '@console/webterminal-plugin/src/redux/actions/cloud-shell-actions'; +import { k8sKillByName } from './k8s'; +import { NamespaceModel, PodModel } from '../models'; + +const registry = new Map(); + +export function storeDetachedWebSocket(id: string, ws: any): void { + registry.set(id, ws); +} + +export function takeDetachedWebSocket(id: string): any | undefined { + const ws = registry.get(id); + if (ws) { + registry.delete(id); + } + return ws; +} + +export function hasDetachedWebSocket(id: string): boolean { + return registry.has(id); +} + +export async function cleanupDetachedResource( + cleanup: DetachedSessionCleanup | undefined, +): Promise { + if (!cleanup) { + return; + } + try { + if (cleanup.type === 'namespace') { + await k8sKillByName(NamespaceModel, cleanup.name); + } else if (cleanup.type === 'pod' && cleanup.namespace) { + await k8sKillByName(PodModel, cleanup.name, cleanup.namespace); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not clean up detached debug resource:', e); + } +}