Skip to content

Commit 6dd5268

Browse files
committed
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.
1 parent d50b991 commit 6dd5268

12 files changed

Lines changed: 597 additions & 81 deletions

File tree

frontend/packages/console-app/src/components/nodes/NodeTerminal.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { LoadingBox } from '@console/internal/components/utils/status-box';
88
import { ImageStreamTagModel, NamespaceModel, PodModel } from '@console/internal/models';
99
import type { NodeKind, PodKind } from '@console/internal/module/k8s';
1010
import { k8sCreate, k8sGet, k8sKillByName } from '@console/internal/module/k8s';
11+
import store from '@console/internal/redux';
1112
import PaneBody from '@console/shared/src/components/layout/PaneBody';
13+
import { getDetachedSessions } from '@console/webterminal-plugin/src/redux/reducers/cloud-shell-selectors';
1214

1315
type NodeTerminalErrorProps = {
1416
error: ReactNode;
@@ -77,7 +79,7 @@ const getDebugPod = async (
7779
runAsUser: 0,
7880
},
7981
stdin: true,
80-
stdinOnce: true,
82+
stdinOnce: false,
8183
tty: true,
8284
volumeMounts: [
8385
{
@@ -207,7 +209,10 @@ const NodeTerminal: FC<NodeTerminalProps> = ({ obj: node }) => {
207209
};
208210
const closeTab = (event) => {
209211
event.preventDefault();
210-
deleteNamespace(namespace.metadata.name);
212+
const detached = getDetachedSessions(store.getState());
213+
if (!detached.some((s) => s.podName === name)) {
214+
deleteNamespace(namespace.metadata.name);
215+
}
211216
};
212217
const createDebugPod = async () => {
213218
try {
@@ -244,7 +249,11 @@ const NodeTerminal: FC<NodeTerminalProps> = ({ obj: node }) => {
244249
createDebugPod();
245250
window.addEventListener('beforeunload', closeTab);
246251
return () => {
247-
deleteNamespace(namespace.metadata.name);
252+
const detached = getDetachedSessions(store.getState());
253+
const isDetached = detached.some((s) => s.podName === name);
254+
if (!isDetached) {
255+
deleteNamespace(namespace.metadata.name);
256+
}
248257
window.removeEventListener('beforeunload', closeTab);
249258
};
250259
}, [nodeName, isWindows]);

frontend/packages/webterminal-plugin/src/components/cloud-shell/CloudShell.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import type { FC } from 'react';
22
import { useFlag } from '@console/shared/src/hooks/useFlag';
33
import { FLAG_DEVWORKSPACE } from '../../const';
44
import { useToggleCloudShellExpanded } from '../../redux/actions/cloud-shell-dispatchers';
5-
import { useIsCloudShellExpanded } from '../../redux/reducers/cloud-shell-selectors';
5+
import {
6+
useIsCloudShellExpanded,
7+
useDetachedSessions,
8+
} from '../../redux/reducers/cloud-shell-selectors';
69
import { CloudShellDrawer } from './CloudShellDrawer';
710

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

17-
if (!devWorkspaceAvailable) {
21+
const hasDetachedSessions = detachedSessions.length > 0;
22+
23+
if (!devWorkspaceAvailable && !hasDetachedSessions) {
1824
return <>{children}</>;
1925
}
2026
return (
21-
<CloudShellDrawer onClose={onClose} open={open}>
27+
<CloudShellDrawer onClose={onClose} open={open || hasDetachedSessions}>
2228
{children}
2329
</CloudShellDrawer>
2430
);

frontend/packages/webterminal-plugin/src/components/cloud-shell/CloudShellDrawer.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import { css } from '@patternfly/react-styles';
1616
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';
1717
import { useTranslation } from 'react-i18next';
1818
import { ExternalLinkButton } from '@console/shared/src/components/links/ExternalLinkButton';
19+
import { useFlag } from '@console/shared/src/hooks/useFlag';
1920
import { useTelemetry } from '@console/shared/src/hooks/useTelemetry';
21+
import { FLAG_DEVWORKSPACE } from '../../const';
22+
import { MAX_DETACHED_SESSIONS } from '../../redux/reducers/cloud-shell-reducer';
23+
import { useDetachedSessions } from '../../redux/reducers/cloud-shell-selectors';
2024
import { MinimizeRestoreButton } from '@console/webterminal-plugin/src/components/cloud-shell/MinimizeRestoreButton';
2125
import { MultiTabbedTerminal } from '@console/webterminal-plugin/src/components/cloud-shell/MultiTabbedTerminal';
2226

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

5057
const onMRButtonClick = (expandedState: boolean) => {
5158
setExpanded(!expandedState);
@@ -70,17 +77,26 @@ export const CloudShellDrawer: FC<CloudShellDrawerProps> = ({
7077
>
7178
<DrawerHead className="co-cloud-shell-drawer__header pf-v6-u-p-0">
7279
<Flex grow={{ default: 'grow' }} data-test="cloudshell-drawer-header">
73-
<FlexItem className="pf-v6-u-px-sm">{t('OpenShift command line terminal')}</FlexItem>
80+
<FlexItem className="pf-v6-u-px-sm">
81+
{t('OpenShift command line terminal')}
82+
{detachedCount > 0 && (
83+
<span className="pf-v6-u-ml-sm pf-v6-u-font-size-sm pf-v6-u-color-200">
84+
({detachedCount}/{MAX_DETACHED_SESSIONS} {t('detached')})
85+
</span>
86+
)}
87+
</FlexItem>
7488
<FlexItem align={{ default: 'alignRight' }}>
7589
<DrawerActions className="pf-v6-u-m-0">
76-
<Tooltip content={t('Open terminal in new tab')}>
77-
<ExternalLinkButton
78-
variant="plain"
79-
href="/terminal"
80-
aria-label={t('Open terminal in new tab')}
81-
iconProps={{ title: undefined }} // aria-label is sufficient
82-
/>
83-
</Tooltip>
90+
{devWorkspaceAvailable && (
91+
<Tooltip content={t('Open terminal in new tab')}>
92+
<ExternalLinkButton
93+
variant="plain"
94+
href="/terminal"
95+
aria-label={t('Open terminal in new tab')}
96+
iconProps={{ title: undefined }} // aria-label is sufficient
97+
/>
98+
</Tooltip>
99+
)}
84100
<MinimizeRestoreButton
85101
minimize={expanded}
86102
minimizeText={t('Minimize terminal')}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import type { FC } from 'react';
2+
import { useState, useRef, useCallback, useEffect } from 'react';
3+
import { Button, EmptyState, EmptyStateBody, EmptyStateFooter } from '@patternfly/react-core';
4+
import { Base64 } from 'js-base64';
5+
import { useTranslation } from 'react-i18next';
6+
import { getImpersonate } from '@console/dynamic-plugin-sdk';
7+
import { PodModel } from '@console/internal/models';
8+
import { resourceURL } from '@console/internal/module/k8s';
9+
import { WSFactory } from '@console/internal/module/ws-factory';
10+
import { takeDetachedWebSocket } from '@console/internal/module/detached-ws-registry';
11+
import store from '@console/internal/redux';
12+
import { FLAGS } from '@console/shared';
13+
import { useFlag } from '@console/shared/src/hooks/useFlag';
14+
import type { ImperativeTerminalType } from './Terminal';
15+
import Terminal from './Terminal';
16+
import TerminalLoadingBox from './TerminalLoadingBox';
17+
import './CloudShellExec.scss';
18+
19+
const NO_SH =
20+
'starting container process caused "exec: \\"sh\\": executable file not found in $PATH"';
21+
22+
type DetachedPodExecProps = {
23+
sessionId: string;
24+
podName: string;
25+
namespace: string;
26+
containerName: string;
27+
command?: string[];
28+
};
29+
30+
const DetachedPodExec: FC<DetachedPodExecProps> = ({
31+
sessionId,
32+
podName,
33+
namespace,
34+
containerName,
35+
command,
36+
}) => {
37+
const [wsOpen, setWsOpen] = useState(false);
38+
const [wsError, setWsError] = useState<string>();
39+
const [reconnecting, setReconnecting] = useState(false);
40+
const ws = useRef<WSFactory>();
41+
const terminal = useRef<ImperativeTerminalType>();
42+
const { t } = useTranslation();
43+
const isOpenShift = useFlag(FLAGS.OPENSHIFT);
44+
45+
const onData = useCallback((data: string): void => {
46+
ws.current?.send(`0${Base64.encode(data)}`);
47+
}, []);
48+
49+
const handleResize = useCallback((cols: number, rows: number) => {
50+
const data = Base64.encode(JSON.stringify({ Height: rows, Width: cols }));
51+
ws.current?.send(`4${data}`);
52+
}, []);
53+
54+
useEffect(() => {
55+
let unmounted = false;
56+
const usedClient = isOpenShift ? 'oc' : 'kubectl';
57+
const cmd = command || ['sh', '-i', '-c', 'TERM=xterm sh'];
58+
59+
const transferred = takeDetachedWebSocket(sessionId);
60+
let websocket: any;
61+
62+
if (transferred) {
63+
websocket = transferred;
64+
websocket
65+
.onmessage((msg: string) => {
66+
const data = Base64.decode(msg.slice(1));
67+
terminal.current?.onDataReceived(data);
68+
})
69+
.onclose((evt: any) => {
70+
if (!evt || evt.wasClean === true) {
71+
return;
72+
}
73+
const error = evt.reason || t('webterminal-plugin~The terminal connection has closed.');
74+
terminal.current?.onConnectionClosed(error);
75+
websocket.destroy();
76+
if (!unmounted) {
77+
setWsOpen(false);
78+
setWsError(error);
79+
}
80+
})
81+
// eslint-disable-next-line no-console
82+
.onerror((evt: any) => console.error(`WS error?! ${evt}`));
83+
84+
ws.current?.destroy();
85+
ws.current = websocket;
86+
if (!unmounted) {
87+
setWsOpen(true);
88+
setTimeout(() => {
89+
websocket.send(`0${Base64.encode('\n')}`);
90+
}, 200);
91+
}
92+
} else {
93+
const impersonate = getImpersonate(store.getState()) || { subprotocols: [] };
94+
const subprotocols = (impersonate.subprotocols || []).concat('base64.channel.k8s.io');
95+
96+
const urlOpts = {
97+
ns: namespace,
98+
name: podName,
99+
path: 'exec',
100+
queryParams: {
101+
stdout: '1',
102+
stdin: '1',
103+
stderr: '1',
104+
tty: '1',
105+
container: containerName,
106+
command: cmd.map((c) => encodeURIComponent(c)).join('&command='),
107+
},
108+
};
109+
110+
const path = resourceURL(PodModel, urlOpts);
111+
websocket = new WSFactory(`${podName}-detached-terminal`, {
112+
host: 'auto',
113+
reconnect: true,
114+
jsonParse: false,
115+
path,
116+
subprotocols,
117+
});
118+
119+
let previous = '';
120+
121+
websocket
122+
.onmessage((msg: string) => {
123+
if (msg[0] === '3') {
124+
if (previous.includes(NO_SH)) {
125+
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 <command>`;
126+
terminal.current?.reset();
127+
terminal.current?.onConnectionClosed(errMsg);
128+
websocket.destroy();
129+
previous = '';
130+
return;
131+
}
132+
}
133+
const data = Base64.decode(msg.slice(1));
134+
terminal.current?.onDataReceived(data);
135+
previous = data;
136+
})
137+
.onopen(() => {
138+
terminal.current?.reset();
139+
previous = '';
140+
if (!unmounted) {
141+
setWsOpen(true);
142+
}
143+
})
144+
.onclose((evt: any) => {
145+
if (!evt || evt.wasClean === true) {
146+
return;
147+
}
148+
const error = evt.reason || t('webterminal-plugin~The terminal connection has closed.');
149+
terminal.current?.onConnectionClosed(error);
150+
websocket.destroy();
151+
if (!unmounted) {
152+
setWsOpen(false);
153+
setWsError(error);
154+
}
155+
})
156+
// eslint-disable-next-line no-console
157+
.onerror((evt: any) => console.error(`WS error?! ${evt}`));
158+
159+
if (ws.current !== websocket) {
160+
ws.current?.destroy();
161+
ws.current = websocket;
162+
terminal.current?.onConnectionClosed(
163+
t('webterminal-plugin~connecting to {{container}}', { container: containerName }),
164+
);
165+
}
166+
}
167+
168+
setReconnecting(false);
169+
170+
return () => {
171+
unmounted = true;
172+
websocket.destroy();
173+
};
174+
// eslint-disable-next-line react-hooks/exhaustive-deps
175+
}, [podName, namespace, containerName, command, isOpenShift, t, reconnecting]);
176+
177+
if (wsError) {
178+
return (
179+
<div className="co-cloudshell-exec__container-error">
180+
<EmptyState>
181+
<EmptyStateBody className="co-cloudshell-exec__error-msg">{wsError}</EmptyStateBody>
182+
<EmptyStateFooter>
183+
<Button
184+
variant="primary"
185+
onClick={() => {
186+
setWsError(undefined);
187+
setReconnecting(true);
188+
}}
189+
>
190+
{t('webterminal-plugin~Reconnect to terminal')}
191+
</Button>
192+
</EmptyStateFooter>
193+
</EmptyState>
194+
</div>
195+
);
196+
}
197+
198+
if (wsOpen) {
199+
return (
200+
<div className="co-cloudshell-terminal__container">
201+
<Terminal onData={onData} onResize={handleResize} ref={terminal} />
202+
</div>
203+
);
204+
}
205+
206+
return (
207+
<div className="co-cloudshell-terminal__container">
208+
<TerminalLoadingBox
209+
message={t('webterminal-plugin~Connecting to {{podName}}/{{containerName}}...', {
210+
podName,
211+
containerName,
212+
})}
213+
/>
214+
</div>
215+
);
216+
};
217+
218+
export default DetachedPodExec;

0 commit comments

Comments
 (0)