Skip to content

Commit

Permalink
frontend: add node shell
Browse files Browse the repository at this point in the history
Signed-off-by: farodin91 <github@jan-jansen.net>
  • Loading branch information
farodin91 committed Jan 9, 2024
1 parent 61fe0ce commit dd556db
Show file tree
Hide file tree
Showing 20 changed files with 437 additions and 25,482 deletions.
25,479 changes: 3 additions & 25,476 deletions frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,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
28 changes: 28 additions & 0 deletions frontend/src/components/node/Details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { HeaderLabel, StatusLabel, ValueLabel } from '../common/Label';
import { DetailsGrid, OwnedPodsSection } from '../common/Resource';
import AuthVisible from '../common/Resource/AuthVisible';
import { SectionBox } from '../common/SectionBox';
import Shell from '../common/Shell';
import { NameValueTable } from '../common/SimpleTable';

function NodeConditionsLabel(props: { node: Node }) {
Expand All @@ -43,6 +44,7 @@ export default function NodeDetails() {
const [isNodeDrainInProgress, setisNodeDrainInProgress] = React.useState(false);
const [nodeFromAPI, nodeError] = Node.useGet(name);
const [node, setNode] = useState(nodeFromAPI);
const [showShell, setShowShell] = React.useState(false);
const noMetrics = metricsError?.status === 404;

useEffect(() => {
Expand Down Expand Up @@ -194,6 +196,19 @@ export default function NodeDetails() {
</AuthVisible>
),
},
{
id: DefaultHeaderAction.NODE_SHELL,
action: (
<ActionButton
description={t('Drop Node Shell')}
icon="mdi:console"
onClick={() => setShowShell(true)}
iconButtonProps={{
disabled: item?.status?.nodeInfo?.operatingSystem !== 'linux',
}}
/>
),
},
];
}}
extraInfo={item =>
Expand Down Expand Up @@ -223,6 +238,19 @@ export default function NodeDetails() {
id: 'headlamp.node-owned-pods',
section: <OwnedPodsSection resource={item?.jsonData} />,
},
{
id: 'headlamp.node-shell',
section: (
<Shell
key="terminal"
open={showShell}
item={item}
onClose={() => {
setShowShell(false);
}}
/>
),
},
]
}
/>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/de/glossary.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"Uncordon": "Planungssperre aufheben",
"Cordon": "Planungssperre setzen",
"Drain": "Entleeren",
"Drop Node Shell": "Drop Node Shell",
"Pod CIDR": "CIDR des Pods",
"Uptime": "Betriebszeit",
"System Info": "System-Informationen",
Expand Down
Loading

0 comments on commit dd556db

Please sign in to comment.