Skip to content

Commit 7f240c1

Browse files
committed
frontend: add node shell
Signed-off-by: farodin91 <github@jan-jansen.net>
1 parent fd2d35c commit 7f240c1

18 files changed

+443
-6
lines changed

frontend/package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@types/react-router-dom": "^5.3.1",
3232
"@types/react-window": "^1.8.5",
3333
"@types/semver": "^7.3.8",
34+
"@types/uuid": "^9.0.4",
3435
"@types/webpack-env": "^1.16.2",
3536
"@typescript-eslint/eslint-plugin": "^4.33.0",
3637
"@typescript-eslint/parser": "^4.33.0",
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import 'xterm/css/xterm.css';
2+
import { Box } from '@mui/material';
3+
import { DialogProps } from '@mui/material/Dialog';
4+
import DialogContent from '@mui/material/DialogContent';
5+
import makeStyles from '@mui/styles/makeStyles';
6+
import _ from 'lodash';
7+
import React from 'react';
8+
import { useTranslation } from 'react-i18next';
9+
import { Terminal as XTerminal } from 'xterm';
10+
import { FitAddon } from 'xterm-addon-fit';
11+
import Node from '../../lib/k8s/node';
12+
import { Dialog } from './Dialog';
13+
14+
const decoder = new TextDecoder('utf-8');
15+
const encoder = new TextEncoder();
16+
17+
enum Channel {
18+
StdIn = 0,
19+
StdOut,
20+
StdErr,
21+
ServerError,
22+
Resize,
23+
}
24+
25+
const useStyle = makeStyles(theme => ({
26+
dialogContent: {
27+
height: '100%',
28+
display: 'flex',
29+
flexDirection: 'column',
30+
'& .xterm ': {
31+
height: '100vh', // So the terminal doesn't stay shrunk when shrinking vertically and maximizing again.
32+
'& .xterm-viewport': {
33+
width: 'initial !important', // BugFix: https://github.com/xtermjs/xterm.js/issues/3564#issuecomment-1004417440
34+
},
35+
},
36+
'& #xterm-container': {
37+
overflow: 'hidden',
38+
width: '100%',
39+
'& .terminal.xterm': {
40+
padding: 10,
41+
},
42+
},
43+
},
44+
containerFormControl: {
45+
minWidth: '11rem',
46+
},
47+
terminalBox: {
48+
paddingTop: theme.spacing(1),
49+
flex: 1,
50+
width: '100%',
51+
overflow: 'hidden',
52+
display: 'flex',
53+
flexDirection: 'column-reverse',
54+
},
55+
}));
56+
57+
interface ShellProps extends DialogProps {
58+
item: Node;
59+
onClose?: () => void;
60+
}
61+
62+
interface XTerminalConnected {
63+
xterm: XTerminal;
64+
connected: boolean;
65+
reconnectOnEnter: boolean;
66+
onClose?: () => void;
67+
}
68+
69+
type execReturn = ReturnType<Node['exec']>;
70+
71+
export default function Shell(props: ShellProps) {
72+
const { item, onClose, ...other } = props;
73+
const classes = useStyle();
74+
const fitAddonRef = React.useRef<FitAddon | null>(null);
75+
const { t } = useTranslation(['translation', 'glossary']);
76+
const [terminalContainerRef, setTerminalContainerRef] = React.useState<HTMLElement | null>(null);
77+
const xtermRef = React.useRef<XTerminalConnected | null>(null);
78+
const shellRef = React.useRef<execReturn | null>(null);
79+
80+
const wrappedOnClose = () => {
81+
if (!!onClose) {
82+
onClose();
83+
}
84+
85+
if (!!xtermRef.current?.onClose) {
86+
xtermRef.current?.onClose();
87+
}
88+
};
89+
90+
function onData(xtermc: XTerminalConnected, bytes: ArrayBuffer) {
91+
const xterm = xtermc.xterm;
92+
// Only show data from stdout, stderr and server error channel.
93+
const channel: Channel = new Int8Array(bytes.slice(0, 1))[0];
94+
if (channel < Channel.StdOut || channel > Channel.ServerError) {
95+
return;
96+
}
97+
98+
// The first byte is discarded because it just identifies whether
99+
// this data is from stderr, stdout, or stdin.
100+
const data = bytes.slice(1);
101+
const text = decoder.decode(data);
102+
103+
// Send resize command to server once connection is establised.
104+
if (!xtermc.connected) {
105+
xterm.clear();
106+
(async function () {
107+
send(4, `{"Width":${xterm.cols},"Height":${xterm.rows}}`);
108+
})();
109+
// On server error, don't set it as connected
110+
if (channel !== Channel.ServerError) {
111+
xtermc.connected = true;
112+
console.debug('Terminal is now connected');
113+
}
114+
}
115+
116+
if (isSuccessfulExitError(channel, text)) {
117+
wrappedOnClose();
118+
119+
if (shellRef.current) {
120+
shellRef.current?.cancel();
121+
}
122+
123+
return;
124+
}
125+
126+
if (isShellNotFoundError(channel, text)) {
127+
shellConnectFailed(xtermc);
128+
return;
129+
}
130+
xterm.write(text);
131+
}
132+
133+
// @todo: Give the real exec type when we have it.
134+
function setupTerminal(containerRef: HTMLElement, xterm: XTerminal, fitAddon: FitAddon) {
135+
if (!containerRef) {
136+
return;
137+
}
138+
139+
xterm.open(containerRef);
140+
141+
xterm.onData(data => {
142+
send(0, data);
143+
});
144+
145+
xterm.onResize(size => {
146+
send(4, `{"Width":${size.cols},"Height":${size.rows}}`);
147+
});
148+
149+
// Allow copy/paste in terminal
150+
xterm.attachCustomKeyEventHandler(arg => {
151+
if (arg.ctrlKey && arg.type === 'keydown') {
152+
if (arg.code === 'KeyC') {
153+
const selection = xterm.getSelection();
154+
if (selection) {
155+
return false;
156+
}
157+
}
158+
if (arg.code === 'KeyV') {
159+
return false;
160+
}
161+
}
162+
163+
return true;
164+
});
165+
166+
fitAddon.fit();
167+
}
168+
169+
function isSuccessfulExitError(channel: number, text: string): boolean {
170+
// Linux container Error
171+
if (channel === 3) {
172+
try {
173+
const error = JSON.parse(text);
174+
if (_.isEmpty(error.metadata) && error.status === 'Success') {
175+
return true;
176+
}
177+
} catch {}
178+
}
179+
return false;
180+
}
181+
182+
function isShellNotFoundError(channel: number, text: string): boolean {
183+
// Linux container Error
184+
if (channel === 3) {
185+
try {
186+
const error = JSON.parse(text);
187+
if (error.code === 500 && error.status === 'Failure' && error.reason === 'InternalError') {
188+
return true;
189+
}
190+
} catch {}
191+
}
192+
// Windows container Error
193+
if (channel === 1) {
194+
if (text.includes('The system cannot find the file specified')) {
195+
return true;
196+
}
197+
}
198+
return false;
199+
}
200+
201+
function shellConnectFailed(xtermc: XTerminalConnected) {
202+
const xterm = xtermc.xterm;
203+
xterm.clear();
204+
xterm.write(t('Failed to connect…') + '\r\n');
205+
}
206+
207+
function send(channel: number, data: string) {
208+
const socket = shellRef.current!.getSocket();
209+
210+
// We should only send data if the socket is ready.
211+
if (!socket || socket.readyState !== 1) {
212+
console.debug('Could not send data to exec: Socket not ready...', socket);
213+
return;
214+
}
215+
216+
const encoded = encoder.encode(data);
217+
const buffer = new Uint8Array([channel, ...encoded]);
218+
219+
socket.send(buffer);
220+
}
221+
222+
React.useEffect(
223+
() => {
224+
// We need a valid container ref for the terminal to add itself to it.
225+
if (terminalContainerRef === null) {
226+
return;
227+
}
228+
229+
// Don't do anything if the dialog is not open.
230+
if (!props.open) {
231+
return;
232+
}
233+
234+
if (xtermRef.current) {
235+
xtermRef.current.xterm.dispose();
236+
shellRef.current?.cancel();
237+
}
238+
239+
const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator?.platform) >= 0;
240+
xtermRef.current = {
241+
xterm: new XTerminal({
242+
cursorBlink: true,
243+
cursorStyle: 'underline',
244+
scrollback: 10000,
245+
rows: 30, // initial rows before fit
246+
windowsMode: isWindows,
247+
}),
248+
connected: false,
249+
reconnectOnEnter: false,
250+
};
251+
252+
fitAddonRef.current = new FitAddon();
253+
xtermRef.current.xterm.loadAddon(fitAddonRef.current);
254+
255+
(async function () {
256+
xtermRef?.current?.xterm.writeln(t('Trying to open a shell'));
257+
const { stream, onClose } = await item.shell((items: ArrayBuffer) =>
258+
onData(xtermRef.current!, items)
259+
);
260+
shellRef.current = stream;
261+
xtermRef.current!.onClose = onClose;
262+
setupTerminal(terminalContainerRef, xtermRef.current!.xterm, fitAddonRef.current!);
263+
})();
264+
265+
const handler = () => {
266+
fitAddonRef.current!.fit();
267+
};
268+
269+
window.addEventListener('resize', handler);
270+
271+
return function cleanup() {
272+
xtermRef.current?.xterm.dispose();
273+
shellRef.current?.cancel();
274+
window.removeEventListener('resize', handler);
275+
};
276+
},
277+
// eslint-disable-next-line react-hooks/exhaustive-deps
278+
[terminalContainerRef, props.open]
279+
);
280+
281+
return (
282+
<Dialog
283+
onClose={() => {
284+
wrappedOnClose();
285+
}}
286+
onFullScreenToggled={() => {
287+
setTimeout(() => {
288+
fitAddonRef.current!.fit();
289+
}, 1);
290+
}}
291+
keepMounted
292+
withFullScreen
293+
title={t('Shell: {{ itemName }}', { itemName: item.metadata.name })}
294+
{...other}
295+
>
296+
<DialogContent className={classes.dialogContent}>
297+
<Box className={classes.terminalBox}>
298+
<div
299+
id="xterm-container"
300+
ref={x => setTerminalContainerRef(x)}
301+
style={{ flex: 1, display: 'flex', flexDirection: 'column-reverse' }}
302+
/>
303+
</Box>
304+
</DialogContent>
305+
</Dialog>
306+
);
307+
}

frontend/src/components/common/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const checkExports = [
3434
'SectionBox',
3535
'SectionFilterHeader',
3636
'SectionHeader',
37+
'Shell',
3738
'ShowHideLabel',
3839
'SimpleTable',
3940
'Tabs',

frontend/src/components/common/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export * from './SectionFilterHeader';
2828
export { default as SectionFilterHeader } from './SectionFilterHeader';
2929
export * from './SectionHeader';
3030
export { default as SectionHeader } from './SectionHeader';
31+
export * from './Shell';
32+
export { default as Shell } from './Shell';
3133
export * from './ShowHideLabel';
3234
export { default as ShowHideLabel } from './ShowHideLabel';
3335
export * from './SimpleTable';

0 commit comments

Comments
 (0)