-
Notifications
You must be signed in to change notification settings - Fork 188
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Signed-off-by: farodin91 <[email protected]>
- Loading branch information
Showing
18 changed files
with
443 additions
and
6 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.