From 207c90e26c308ba60c5cd13445ad61c2b4d6ff70 Mon Sep 17 00:00:00 2001 From: jialin Date: Tue, 19 Aug 2025 11:13:51 +0800 Subject: [PATCH] feat: terminal --- src/components/x-terminal/index.tsx | 294 ++++++++++++++++++ .../components/cluster-detail-modal.tsx | 17 +- 2 files changed, 302 insertions(+), 9 deletions(-) create mode 100644 src/components/x-terminal/index.tsx diff --git a/src/components/x-terminal/index.tsx b/src/components/x-terminal/index.tsx new file mode 100644 index 00000000..e466a1c6 --- /dev/null +++ b/src/components/x-terminal/index.tsx @@ -0,0 +1,294 @@ +import useUserSettings from '@/hooks/use-user-settings'; +import { FitAddon } from '@xterm/addon-fit'; +import { Terminal } from '@xterm/xterm'; +import { createStyles } from 'antd-style'; +import _ from 'lodash'; +import qs from 'query-string'; +import React, { forwardRef, useEffect, useImperativeHandle } from 'react'; +import 'xterm/css/xterm.css'; + +const useStyles = createStyles(({ token, css }) => ({ + wrap: css` + position: relative; + text-align: left; + .xterm { + height: 100%; + .xterm-viewport { + overflow-y: auto; + } + } + `, + terminal: css` + height: 100%; + padding: 5px; + overflow: hidden; + overflow: hidden; + background-color: var(--color-logs-bg); + border-radius: 0 0 8px 8px; + ` +})); + +const terminalEnvList = ['bash', 'sh', 'powershell', 'pwsh', 'cmd', 'bash']; + +const RECONNECT_MSG = '--- press Y to reconnect! ---'; + +const colorBg2 = 'rgb(33, 36, 39)'; + +interface XTerminalProps { + height: number; + url: string; +} + +const XTerminal: React.FC = forwardRef((props, ref) => { + const { height, url } = props; + const { styles } = useStyles(); + const { userSettings } = useUserSettings(); + const terminalRef = React.useRef(null); + const WrapperRef = React.useRef(null); + const terminalInstance = React.useRef(null); + const wssURL = React.useRef(''); + const terminalEnvIndex = React.useRef(0); + const terminalSocket = React.useRef(null); + const retryCount = React.useRef(5); + const totalRetry = React.useRef(5); + const first = React.useRef(true); + const loading = React.useRef(false); + const timer = React.useRef(null); + const toRetry = React.useRef(false); + const conReadyState = React.useRef(0); + const [statusCode, setStatusCode] = React.useState(0); + + const fitAddon = new FitAddon(); + + const setWssUrl = (flag?: boolean) => { + if (terminalEnvIndex.current >= terminalEnvList.length - 1) return; + if (flag) { + terminalEnvIndex.current = 0; + } else { + terminalEnvIndex.current += 1; + } + const params = qs.stringify({ + shell: terminalEnvList[terminalEnvIndex.current] + }); + wssURL.current = `${url}&${params}`; + }; + + // readyState: 0 1 2 3 + const isWsOpen = () => { + const readyState = + terminalSocket.current && terminalSocket.current.readyState; + return readyState === 1; + }; + + const resizeRemoteTerminal = () => { + const { cols, rows } = terminalInstance.current || {}; + if (isWsOpen()) { + terminalSocket.current?.send(`#{"width":${cols},"height":${rows}}#`); + } + }; + + const fitTerm = () => { + fitAddon.fit(); + resizeRemoteTerminal(); + }; + + const onResize = _.throttle(() => fitTerm(), 100); + + const removeResizeListener = () => { + window.removeEventListener('resize', onResize); + }; + + const destoryedTerm = () => { + removeResizeListener(); + terminalSocket.current?.close?.(); + }; + + const setData = (data: string) => { + return `${data}\x1B[1;3;31m\x1B[0m`; + }; + + const setErrorData = (data: string) => { + return `\x1b[31m${data}\x1b[m`; + }; + + const runRealTerminal = () => { + terminalInstance.current?.clear?.(); + loading.current = false; + toRetry.current = false; + }; + + const onWSReceive = (message: MessageEvent) => { + if (first.current === true) { + first.current = false; + resizeRemoteTerminal(); + } + + const data = { Data: message.data }; + conReadyState.current = terminalSocket.current?.readyState || 0; + + // const data = JSON.parse(message.data) || ''; + if (terminalInstance.current?.element) terminalInstance.current.focus(); + const output = data.Data; + terminalInstance.current?.write?.(setData(output)); + }; + + const closeRealTerminal = (data: any) => { + setStatusCode(_.get(data, 'code')); + conReadyState.current = terminalSocket.current?.readyState || 0; + if ([1011, 1006, 1000].includes(statusCode)) { + toRetry.current = true; + if (first.current) { + terminalInstance.current?.reset?.(); + } + if (!loading.current) { + terminalInstance.current?.write?.( + setData(`(${statusCode})${data.reason}\r\n`) + ); + terminalInstance.current?.write?.(setErrorData(`\r${RECONNECT_MSG}`)); + } + first.current = true; + } else if (data.reason) { + terminalInstance.current?.write?.( + setData(`(${statusCode})${data.reason}\r\n`) + ); + } + loading.current = false; + }; + + const errorRealTerminal = (ex: any) => { + let { message } = ex; + if (!message) { + message = 'disconnected!'; + toRetry.current = true; + first.current = true; + loading.current = false; + } + conReadyState.current = terminalSocket.current!.readyState; + terminalInstance.current?.write?.(setErrorData(`\r${message}`)); + }; + + const createWS = () => { + if (!props.url) return; + terminalInstance.current?.write?.(''); + setStatusCode(0); + terminalSocket.current = new WebSocket(wssURL.current); + terminalSocket.current.onopen = runRealTerminal; + terminalSocket.current.onmessage = onWSReceive; + terminalSocket.current.onclose = closeRealTerminal; + terminalSocket.current.onerror = errorRealTerminal; + }; + + const initWS = () => { + if (!terminalSocket.current) { + createWS(); + return; + } + terminalSocket.current?.close?.(); + createWS(); + }; + + const retry = () => { + loading.current = true; + terminalInstance.current?.reset?.(); + initWS(); + }; + + const registerTermHandler = () => { + terminalInstance.current?.onData((data: string) => { + if (isWsOpen()) { + terminalSocket.current?.send(data); + } + }); + terminalInstance.current?.onKey((e: any) => { + if (toRetry.current && e.domEvent.code === 'KeyY') { + retry(); + } + }); + }; + + const onTerminalResize = () => { + window.addEventListener('resize', onResize); + }; + + const initTerm = () => { + terminalInstance.current?.dispose?.(); + terminalInstance.current = new Terminal({ + lineHeight: 1.2, + fontSize: 12, + fontFamily: + "monospace,Menlo,Courier,'Courier New',Consolas,Monaco, 'Liberation Mono'", + theme: { + background: userSettings.theme === 'realDark' ? colorBg2 : '#181d28', + foreground: + userSettings.theme === 'realDark' + ? 'rgba(255, 255, 255, 0.7)' + : '#fff' + }, + cursorBlink: true, + cursorStyle: 'underline', + scrollback: 100, + tabStopWidth: 4 + }); + terminalInstance.current?.open?.(terminalRef.current!); + terminalInstance.current?.loadAddon?.(fitAddon); + fitAddon.fit(); + }; + + const init = () => { + setWssUrl(); + initWS(); + initTerm(); + registerTermHandler(); + onTerminalResize(); + }; + + const debounceCall = _.debounce(() => { + first.current = true; + loading.current = true; + setWssUrl(true); + initWS(); + }, 100); + + useEffect(() => { + if (!url) { + terminalInstance.current?.reset?.(); + terminalSocket.current?.close?.(); + terminalSocket.current = null; + } else { + // reset retry count + retryCount.current = totalRetry.current; + clearTimeout(timer.current); + + terminalInstance.current?.reset?.(); + debounceCall(); + } + return () => { + destoryedTerm(); + removeResizeListener(); + }; + }, [url]); + + useImperativeHandle(ref, () => ({ + fit: () => { + fitAddon.fit(); + } + })); + + return ( +
+
+
+ ); +}); + +export default XTerminal; diff --git a/src/pages/cluster-management/components/cluster-detail-modal.tsx b/src/pages/cluster-management/components/cluster-detail-modal.tsx index 9534a0db..7718cf42 100644 --- a/src/pages/cluster-management/components/cluster-detail-modal.tsx +++ b/src/pages/cluster-management/components/cluster-detail-modal.tsx @@ -14,14 +14,13 @@ const ClusterDetailModal: React.FC = ({ id: clusterId }) => { const [data, setData] = React.useState({ - id: 1, - name: 'kubernetes-cluster', - provider: 'kubernetes', - clusterType: 'Kubernetes', - workers: 2, - gpus: 4, - status: 'ready', - deployments: 1 + id: 2, + name: 'Digital-Ocean-cluster', + provider: 'digitalocean', + workers: 3, + gpus: 6, + status: 'error', + deployments: 2 }); const handleOnClose = () => { onClose?.(); @@ -29,7 +28,7 @@ const ClusterDetailModal: React.FC = ({ return (