Skip to content

Commit

Permalink
frontend: add node shell
Browse files Browse the repository at this point in the history
Signed-off-by: farodin91 <[email protected]>
  • Loading branch information
farodin91 committed Dec 18, 2023
1 parent fd2d35c commit 7f240c1
Show file tree
Hide file tree
Showing 18 changed files with 443 additions and 6 deletions.
11 changes: 11 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@types/react-router-dom": "^5.3.1",
"@types/react-window": "^1.8.5",
"@types/semver": "^7.3.8",
"@types/uuid": "^9.0.4",
"@types/webpack-env": "^1.16.2",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
Expand Down
307 changes: 307 additions & 0 deletions frontend/src/components/common/Shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
import 'xterm/css/xterm.css';
import { Box } from '@mui/material';
import { DialogProps } from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import makeStyles from '@mui/styles/makeStyles';
import _ from 'lodash';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Terminal as XTerminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import Node from '../../lib/k8s/node';
import { Dialog } from './Dialog';

const decoder = new TextDecoder('utf-8');
const encoder = new TextEncoder();

enum Channel {
StdIn = 0,
StdOut,
StdErr,
ServerError,
Resize,
}

const useStyle = makeStyles(theme => ({
dialogContent: {
height: '100%',
display: 'flex',
flexDirection: 'column',
'& .xterm ': {
height: '100vh', // So the terminal doesn't stay shrunk when shrinking vertically and maximizing again.
'& .xterm-viewport': {
width: 'initial !important', // BugFix: https://github.com/xtermjs/xterm.js/issues/3564#issuecomment-1004417440
},
},
'& #xterm-container': {
overflow: 'hidden',
width: '100%',
'& .terminal.xterm': {
padding: 10,
},
},
},
containerFormControl: {
minWidth: '11rem',
},
terminalBox: {
paddingTop: theme.spacing(1),
flex: 1,
width: '100%',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column-reverse',
},
}));

interface ShellProps extends DialogProps {
item: Node;
onClose?: () => void;
}

interface XTerminalConnected {
xterm: XTerminal;
connected: boolean;
reconnectOnEnter: boolean;
onClose?: () => void;
}

type execReturn = ReturnType<Node['exec']>;

export default function Shell(props: ShellProps) {
const { item, onClose, ...other } = props;
const classes = useStyle();
const fitAddonRef = React.useRef<FitAddon | null>(null);
const { t } = useTranslation(['translation', 'glossary']);
const [terminalContainerRef, setTerminalContainerRef] = React.useState<HTMLElement | null>(null);
const xtermRef = React.useRef<XTerminalConnected | null>(null);
const shellRef = React.useRef<execReturn | null>(null);

const wrappedOnClose = () => {
if (!!onClose) {
onClose();
}

if (!!xtermRef.current?.onClose) {
xtermRef.current?.onClose();
}
};

function onData(xtermc: XTerminalConnected, bytes: ArrayBuffer) {
const xterm = xtermc.xterm;
// Only show data from stdout, stderr and server error channel.
const channel: Channel = new Int8Array(bytes.slice(0, 1))[0];
if (channel < Channel.StdOut || channel > Channel.ServerError) {
return;
}

// The first byte is discarded because it just identifies whether
// this data is from stderr, stdout, or stdin.
const data = bytes.slice(1);
const text = decoder.decode(data);

// Send resize command to server once connection is establised.
if (!xtermc.connected) {
xterm.clear();
(async function () {
send(4, `{"Width":${xterm.cols},"Height":${xterm.rows}}`);
})();
// On server error, don't set it as connected
if (channel !== Channel.ServerError) {
xtermc.connected = true;
console.debug('Terminal is now connected');
}
}

if (isSuccessfulExitError(channel, text)) {
wrappedOnClose();

if (shellRef.current) {
shellRef.current?.cancel();
}

return;
}

if (isShellNotFoundError(channel, text)) {
shellConnectFailed(xtermc);
return;
}
xterm.write(text);
}

// @todo: Give the real exec type when we have it.
function setupTerminal(containerRef: HTMLElement, xterm: XTerminal, fitAddon: FitAddon) {
if (!containerRef) {
return;
}

xterm.open(containerRef);

xterm.onData(data => {
send(0, data);
});

xterm.onResize(size => {
send(4, `{"Width":${size.cols},"Height":${size.rows}}`);
});

// Allow copy/paste in terminal
xterm.attachCustomKeyEventHandler(arg => {
if (arg.ctrlKey && arg.type === 'keydown') {
if (arg.code === 'KeyC') {
const selection = xterm.getSelection();
if (selection) {
return false;
}
}
if (arg.code === 'KeyV') {
return false;
}
}

return true;
});

fitAddon.fit();
}

function isSuccessfulExitError(channel: number, text: string): boolean {
// Linux container Error
if (channel === 3) {
try {
const error = JSON.parse(text);
if (_.isEmpty(error.metadata) && error.status === 'Success') {
return true;
}
} catch {}
}
return false;
}

function isShellNotFoundError(channel: number, text: string): boolean {
// Linux container Error
if (channel === 3) {
try {
const error = JSON.parse(text);
if (error.code === 500 && error.status === 'Failure' && error.reason === 'InternalError') {
return true;
}
} catch {}
}
// Windows container Error
if (channel === 1) {
if (text.includes('The system cannot find the file specified')) {
return true;
}
}
return false;
}

function shellConnectFailed(xtermc: XTerminalConnected) {
const xterm = xtermc.xterm;
xterm.clear();
xterm.write(t('Failed to connect…') + '\r\n');
}

function send(channel: number, data: string) {
const socket = shellRef.current!.getSocket();

// We should only send data if the socket is ready.
if (!socket || socket.readyState !== 1) {
console.debug('Could not send data to exec: Socket not ready...', socket);
return;
}

const encoded = encoder.encode(data);
const buffer = new Uint8Array([channel, ...encoded]);

socket.send(buffer);
}

React.useEffect(
() => {
// We need a valid container ref for the terminal to add itself to it.
if (terminalContainerRef === null) {
return;
}

// Don't do anything if the dialog is not open.
if (!props.open) {
return;
}

if (xtermRef.current) {
xtermRef.current.xterm.dispose();
shellRef.current?.cancel();
}

const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator?.platform) >= 0;
xtermRef.current = {
xterm: new XTerminal({
cursorBlink: true,
cursorStyle: 'underline',
scrollback: 10000,
rows: 30, // initial rows before fit
windowsMode: isWindows,
}),
connected: false,
reconnectOnEnter: false,
};

fitAddonRef.current = new FitAddon();
xtermRef.current.xterm.loadAddon(fitAddonRef.current);

(async function () {
xtermRef?.current?.xterm.writeln(t('Trying to open a shell'));
const { stream, onClose } = await item.shell((items: ArrayBuffer) =>
onData(xtermRef.current!, items)
);
shellRef.current = stream;
xtermRef.current!.onClose = onClose;
setupTerminal(terminalContainerRef, xtermRef.current!.xterm, fitAddonRef.current!);
})();

const handler = () => {
fitAddonRef.current!.fit();
};

window.addEventListener('resize', handler);

return function cleanup() {
xtermRef.current?.xterm.dispose();
shellRef.current?.cancel();
window.removeEventListener('resize', handler);
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[terminalContainerRef, props.open]
);

return (
<Dialog
onClose={() => {
wrappedOnClose();
}}
onFullScreenToggled={() => {
setTimeout(() => {
fitAddonRef.current!.fit();
}, 1);
}}
keepMounted
withFullScreen
title={t('Shell: {{ itemName }}', { itemName: item.metadata.name })}
{...other}
>
<DialogContent className={classes.dialogContent}>
<Box className={classes.terminalBox}>
<div
id="xterm-container"
ref={x => setTerminalContainerRef(x)}
style={{ flex: 1, display: 'flex', flexDirection: 'column-reverse' }}
/>
</Box>
</DialogContent>
</Dialog>
);
}
1 change: 1 addition & 0 deletions frontend/src/components/common/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const checkExports = [
'SectionBox',
'SectionFilterHeader',
'SectionHeader',
'Shell',
'ShowHideLabel',
'SimpleTable',
'Tabs',
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export * from './SectionFilterHeader';
export { default as SectionFilterHeader } from './SectionFilterHeader';
export * from './SectionHeader';
export { default as SectionHeader } from './SectionHeader';
export * from './Shell';
export { default as Shell } from './Shell';
export * from './ShowHideLabel';
export { default as ShowHideLabel } from './ShowHideLabel';
export * from './SimpleTable';
Expand Down
Loading

0 comments on commit 7f240c1

Please sign in to comment.