From 5ea9c2203a3d66e5151522ad12e3a1adb67e4d6c Mon Sep 17 00:00:00 2001 From: Bharat Babbar Date: Mon, 13 Apr 2026 16:57:05 +0530 Subject: [PATCH 1/2] CONSOLE-5183: Add persistent pod terminal sessions to Cloud Shell drawer Integrates detachable terminal sessions into the existing Cloud Shell drawer, allowing pod and node debug terminals to persist across page navigation. Key changes: - WebSocket handoff: when detaching, the live WebSocket is transferred from PodConnect to DetachedPodExec via a global registry, avoiding the need for a second exec/attach connection. - PodConnect skips sending 'exit' on cleanup when a session is detached, keeping the underlying pod alive. - Node debug pods use stdinOnce:false so the container survives attach disconnection; namespace cleanup is skipped for detached sessions. - Debug terminal pod deletion is skipped when the pod is detached. - Session limit capped at 5 with the detach button disabled at the cap and a counter badge shown in the drawer header. - Cloud Shell keepalive ticks are suppressed for detached pod tabs. - Drawer close handler properly clears detached sessions. - Auto-cleanup: debug namespaces and pods are automatically deleted when a detached session is closed (tab close or drawer close), preventing orphaned resources from accumulating in etcd. --- .../src/components/nodes/NodeTerminal.tsx | 49 ++-- .../src/components/cloud-shell/CloudShell.tsx | 12 +- .../cloud-shell/CloudShellDrawer.tsx | 34 ++- .../cloud-shell/DetachedPodExec.tsx | 218 ++++++++++++++++++ .../cloud-shell/MultiTabbedTerminal.tsx | 208 +++++++++++++---- .../src/redux/actions/cloud-shell-actions.ts | 28 +++ .../redux/actions/cloud-shell-dispatchers.ts | 33 ++- .../src/redux/reducers/cloud-shell-reducer.ts | 28 ++- .../redux/reducers/cloud-shell-selectors.ts | 8 + frontend/public/components/debug-terminal.tsx | 31 ++- frontend/public/components/pod-connect.tsx | 81 ++++++- frontend/public/components/pod.tsx | 3 + .../public/module/detached-ws-registry.ts | 48 ++++ 13 files changed, 687 insertions(+), 94 deletions(-) create mode 100644 frontend/packages/webterminal-plugin/src/components/cloud-shell/DetachedPodExec.tsx create mode 100644 frontend/public/module/detached-ws-registry.ts 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/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/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/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/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); + } +} From a367b0b27054def2b4b97ca744f1d9959287a1f7 Mon Sep 17 00:00:00 2001 From: Bharat Babbar Date: Tue, 14 Apr 2026 16:20:51 +0530 Subject: [PATCH 2/2] CONSOLE-5183: Add unit tests and e2e scenarios for persistent terminal sessions Add comprehensive test coverage for the detach-to-Cloud-Shell feature: Unit tests (Jest): - Action creators: addDetachedSession, removeDetachedSession, clearDetachedSessions - Reducer: session add, duplicate rejection, MAX_DETACHED_SESSIONS limit, remove, clear - Selectors: getDetachedSessions with populated and empty state - detached-ws-registry: store/take/has one-shot semantics, cleanupDetachedResource with NamespaceModel and PodModel, error handling, no-op for undefined - MultiTabbedTerminal: detached tabs render, total tab count, cleanup on close - Fix pre-existing MultiTabbedTerminal test failures by adding useFlag mock E2E tests (Cypress + Cucumber): - Gherkin scenarios for detach workflow, session persistence across navigation, session limit enforcement, tab close, and drawer close cleanup - Step definitions and page object for detach button and drawer verification How to run tests: cd frontend && yarn jest packages/webterminal-plugin/src/redux/actions/__tests__/cloud-shell-actions.spec.ts cd frontend && yarn jest packages/webterminal-plugin/src/redux/reducers/__tests__/cloud-shell-reducer.spec.ts cd frontend && yarn jest packages/webterminal-plugin/src/redux/reducers/__tests__/cloud-shell-selectors.spec.ts cd frontend && yarn jest public/module/__tests__/detached-ws-registry.spec.ts cd frontend && yarn jest packages/webterminal-plugin/src/components/cloud-shell/__tests__/MultiTabbedTerminal.spec.tsx --- .../web-terminal/web-terminal-detach.feature | 40 ++++++++ .../pages/web-terminal/detachTerminal-page.ts | 38 ++++++++ .../web-terminal/web-terminal-detach.ts | 78 ++++++++++++++++ .../__tests__/CloudShellDrawer.spec.tsx | 1 + .../__tests__/MultiTabbedTerminal.spec.tsx | 78 +++++++++++++++- .../__tests__/cloud-shell-actions.spec.ts | 43 ++++++++- .../__tests__/cloud-shell-reducer.spec.ts | 68 +++++++++++++- .../__tests__/cloud-shell-selectors.spec.ts | 14 +++ .../__tests__/detached-ws-registry.spec.ts | 93 +++++++++++++++++++ 9 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 frontend/packages/webterminal-plugin/integration-tests/features/web-terminal/web-terminal-detach.feature create mode 100644 frontend/packages/webterminal-plugin/integration-tests/support/step-definitions/pages/web-terminal/detachTerminal-page.ts create mode 100644 frontend/packages/webterminal-plugin/integration-tests/support/step-definitions/web-terminal/web-terminal-detach.ts create mode 100644 frontend/public/module/__tests__/detached-ws-registry.spec.ts 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/__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/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/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(); + }); + }); +});