misc_pterodactyl-panel/resources/scripts/components/server/console/Console.tsx

246 lines
9.1 KiB
TypeScript
Raw Normal View History

2022-11-25 20:25:03 +00:00
import { ChevronDoubleRightIcon } from '@heroicons/react/solid';
import classNames from 'classnames';
import { debounce } from 'debounce';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ITerminalInitOnlyOptions, ITerminalOptions, ITheme } from 'xterm';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { SearchAddon } from 'xterm-addon-search';
import { SearchBarAddon } from 'xterm-addon-search-bar';
2020-11-24 21:04:44 +00:00
import { WebLinksAddon } from 'xterm-addon-web-links';
import { theme as th } from 'twin.macro';
2022-11-25 20:25:03 +00:00
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { SocketEvent, SocketRequest } from '@/components/server/events';
import { ScrollDownHelperAddon } from '@/plugins/XtermScrollDownHelperAddon';
import useEventListener from '@/plugins/useEventListener';
2022-11-25 20:25:03 +00:00
import { usePermissions } from '@/plugins/usePermissions';
2020-10-26 12:30:30 +00:00
import { usePersistedState } from '@/plugins/usePersistedState';
2022-11-25 20:25:03 +00:00
import { ServerContext } from '@/state/server';
import 'xterm/css/xterm.css';
import styles from './style.module.css';
2022-11-25 20:25:03 +00:00
const theme: ITheme = {
2020-10-17 20:54:34 +00:00
background: th`colors.black`.toString(),
cursor: 'transparent',
2020-10-17 20:54:34 +00:00
black: th`colors.black`.toString(),
red: '#E54B4B',
green: '#9ECE58',
yellow: '#FAED70',
blue: '#396FE2',
magenta: '#BB80B3',
cyan: '#2DDAFD',
white: '#d0d0d0',
brightBlack: 'rgba(255, 255, 255, 0.2)',
brightRed: '#FF5370',
brightGreen: '#C3E88D',
brightYellow: '#FFCB6B',
brightBlue: '#82AAFF',
brightMagenta: '#C792EA',
brightCyan: '#89DDFF',
brightWhite: '#ffffff',
2022-11-25 20:25:03 +00:00
selectionBackground: '#FAF089',
};
const terminalProps: ITerminalOptions = {
2019-09-06 06:05:24 +00:00
disableStdin: true,
cursorStyle: 'underline',
allowTransparency: true,
fontSize: 12,
fontFamily: th('fontFamily.mono'),
2019-09-06 06:05:24 +00:00
theme: theme,
2022-11-25 20:25:03 +00:00
allowProposedApi: true,
};
const terminalInitOnlyProps: ITerminalInitOnlyOptions = {
rows: 30,
};
2019-09-06 06:05:24 +00:00
export default () => {
2020-01-18 23:26:15 +00:00
const TERMINAL_PRELUDE = '\u001b[1m\u001b[33mcontainer@pterodactyl~ \u001b[0m';
const ref = useRef<HTMLDivElement>(null);
2022-11-25 20:25:03 +00:00
const terminal = useMemo(() => new Terminal({ ...terminalProps, ...terminalInitOnlyProps }), []);
const fitAddon = new FitAddon();
const searchAddon = new SearchAddon();
2020-10-15 20:41:11 +00:00
const searchBar = new SearchBarAddon({ searchAddon });
2020-11-24 21:04:44 +00:00
const webLinksAddon = new WebLinksAddon();
const scrollDownHelperAddon = new ScrollDownHelperAddon();
2022-11-25 20:25:03 +00:00
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const [canSendCommands] = usePermissions(['control.console']);
2022-11-25 20:25:03 +00:00
const serverId = ServerContext.useStoreState(state => state.server.data!.id);
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
const [history, setHistory] = usePersistedState<string[]>(`${serverId}:command_history`, []);
const [historyIndex, setHistoryIndex] = useState(-1);
// SearchBarAddon has hardcoded z-index: 999 :(
const zIndex = `
.xterm-search-bar__addon {
z-index: 10;
}`;
const handleConsoleOutput = (line: string, prelude = false) =>
terminal.writeln((prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m');
2019-09-06 06:05:24 +00:00
const handleTransferStatus = (status: string) => {
switch (status) {
// Sent by either the source or target node if a failure occurs.
case 'failure':
terminal.writeln(TERMINAL_PRELUDE + 'Transfer has failed.\u001b[0m');
return;
}
};
const handleDaemonErrorOutput = (line: string) =>
terminal.writeln(
2022-11-25 20:25:03 +00:00
TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
);
2019-09-28 20:09:47 +00:00
const handlePowerChangeEvent = (state: string) =>
terminal.writeln(TERMINAL_PRELUDE + 'Server marked as ' + state + '...\u001b[0m');
2022-11-25 20:25:03 +00:00
const handleCommandKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
2020-10-26 12:30:30 +00:00
if (e.key === 'ArrowUp') {
const newIndex = Math.min(historyIndex + 1, history!.length - 1);
setHistoryIndex(newIndex);
e.currentTarget.value = history![newIndex] || '';
2022-11-25 20:25:03 +00:00
// By default, up arrow will also bring the cursor to the start of the line,
// so we'll preventDefault to keep it at the end.
e.preventDefault();
2020-10-26 12:30:30 +00:00
}
if (e.key === 'ArrowDown') {
const newIndex = Math.max(historyIndex - 1, -1);
setHistoryIndex(newIndex);
e.currentTarget.value = history![newIndex] || '';
2019-09-18 05:54:23 +00:00
}
2020-10-26 12:30:30 +00:00
const command = e.currentTarget.value;
if (e.key === 'Enter' && command.length > 0) {
2022-11-25 20:25:03 +00:00
setHistory(prevHistory => [command, ...prevHistory!].slice(0, 32));
2020-10-26 12:30:30 +00:00
setHistoryIndex(-1);
instance && instance.send('send command', command);
e.currentTarget.value = '';
}
2019-09-18 05:54:23 +00:00
};
2019-09-06 06:05:24 +00:00
useEffect(() => {
if (connected && ref.current && !terminal.element) {
terminal.loadAddon(fitAddon);
terminal.loadAddon(searchAddon);
2020-10-15 20:41:11 +00:00
terminal.loadAddon(searchBar);
2020-11-24 21:04:44 +00:00
terminal.loadAddon(webLinksAddon);
terminal.loadAddon(scrollDownHelperAddon);
terminal.open(ref.current);
fitAddon.fit();
searchBar.addNewStyle(zIndex);
// Add support for capturing keys
terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
document.execCommand('copy');
return false;
} else if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
2021-02-18 05:32:36 +00:00
e.preventDefault();
2020-10-15 20:41:11 +00:00
searchBar.show();
return false;
2021-02-18 05:32:36 +00:00
} else if (e.key === 'Escape') {
2020-10-15 20:41:11 +00:00
searchBar.hidden();
}
return true;
});
}
}, [terminal, connected]);
useEventListener(
'resize',
debounce(() => {
if (terminal.element) {
fitAddon.fit();
}
2022-11-25 20:25:03 +00:00
}, 100),
);
2019-09-06 06:05:24 +00:00
useEffect(() => {
const listeners: Record<string, (s: string) => void> = {
[SocketEvent.STATUS]: handlePowerChangeEvent,
[SocketEvent.CONSOLE_OUTPUT]: handleConsoleOutput,
[SocketEvent.INSTALL_OUTPUT]: handleConsoleOutput,
[SocketEvent.TRANSFER_LOGS]: handleConsoleOutput,
[SocketEvent.TRANSFER_STATUS]: handleTransferStatus,
2022-11-25 20:25:03 +00:00
[SocketEvent.DAEMON_MESSAGE]: line => handleConsoleOutput(line, true),
[SocketEvent.DAEMON_ERROR]: handleDaemonErrorOutput,
};
2019-09-06 06:05:24 +00:00
if (connected && instance) {
// Do not clear the console if the server is being transferred.
if (!isTransferring) {
terminal.clear();
}
Object.keys(listeners).forEach((key: string) => {
2022-11-25 20:25:03 +00:00
const listener = listeners[key];
if (listener === undefined) {
return;
}
instance.addListener(key, listener);
});
instance.send(SocketRequest.SEND_LOGS);
}
2019-09-06 06:05:24 +00:00
return () => {
if (instance) {
Object.keys(listeners).forEach((key: string) => {
2022-11-25 20:25:03 +00:00
const listener = listeners[key];
if (listener === undefined) {
return;
}
instance.removeListener(key, listener);
});
}
2019-09-06 06:05:24 +00:00
};
}, [connected, instance]);
2019-09-06 06:05:24 +00:00
return (
<div className={classNames(styles.terminal, 'relative')}>
<SpinnerOverlay visible={!connected} size={'large'} />
<div
className={classNames(styles.container, styles.overflows_container, { 'rounded-b': !canSendCommands })}
>
<div className={'h-full'}>
<div id={styles.terminal} ref={ref} />
</div>
2019-09-06 06:05:24 +00:00
</div>
{canSendCommands && (
<div className={classNames('relative', styles.overflows_container)}>
<input
className={classNames('peer', styles.command_input)}
type={'text'}
placeholder={'Type a command...'}
aria-label={'Console command input.'}
disabled={!instance || !connected}
onKeyDown={handleCommandKeyDown}
autoCorrect={'off'}
autoCapitalize={'none'}
/>
<div
className={classNames(
'text-gray-100 peer-focus:text-gray-50 peer-focus:animate-pulse',
2022-11-25 20:25:03 +00:00
styles.command_icon,
)}
>
<ChevronDoubleRightIcon className={'w-4 h-4'} />
</div>
</div>
)}
2019-09-06 06:05:24 +00:00
</div>
);
};