1
0
mirror of https://github.com/1Panel-dev/1Panel.git synced 2025-01-19 08:19:15 +08:00

feat: 抽象出终端为独立组件

This commit is contained in:
Wankko Ree 2023-04-06 15:41:11 +08:00 committed by zhengkunwang223
parent 750a2a445e
commit fa83199d7b
3 changed files with 233 additions and 250 deletions

View File

@ -0,0 +1,224 @@
<template>
<div ref="terminalElement" @wheel="onTermWheel"></div>
</template>
<script lang="ts" setup>
import { ref, watch, onBeforeUnmount, nextTick } from 'vue';
import { Terminal } from 'xterm';
import 'xterm/css/xterm.css';
import { FitAddon } from 'xterm-addon-fit';
import { Base64 } from 'js-base64';
const terminalElement = ref<HTMLDivElement | null>(null);
const fitAddon = new FitAddon();
const termReady = ref(false);
const webSocketReady = ref(false);
const term = ref(
new Terminal({
lineHeight: 1.2,
fontSize: 12,
fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace",
theme: {
background: '#000000',
},
cursorBlink: true,
cursorStyle: 'underline',
scrollback: 100,
tabStopWidth: 4,
}),
);
const terminalSocket = ref<WebSocket>();
const heartbeatTimer = ref<number>();
const latency = ref(0);
const readyWatcher = watch(
() => webSocketReady.value && termReady.value,
(ready) => {
if (ready) {
changeTerminalSize();
readyWatcher(); // unwatch self
}
},
);
interface WsProps {
endpoint: string;
args: string;
error: string;
}
const acceptParams = (props: WsProps) => {
nextTick(() => {
if (props.error.length !== 0) {
initError(props.error);
} else {
init(props.endpoint, props.args);
}
});
};
const init = (endpoint: string, args: string) => {
if (initTerminal(true)) {
initWebSocket(endpoint, args);
}
};
const initError = (errorInfo: string) => {
if (initTerminal(false)) {
term.value.write(errorInfo);
}
};
function onClose() {
window.removeEventListener('resize', changeTerminalSize);
try {
terminalSocket.value?.close();
} catch {}
try {
term.value.dispose();
} catch {}
}
// terminal start
const initTerminal = (online: boolean = false): boolean => {
if (terminalElement.value) {
term.value.open(terminalElement.value);
term.value.loadAddon(fitAddon);
window.addEventListener('resize', changeTerminalSize);
if (online) {
term.value.onData((data) => sendMsg(data));
}
termReady.value = true;
}
return termReady.value;
};
function changeTerminalSize() {
fitAddon.fit();
if (isWsOpen()) {
const { cols, rows } = term.value;
terminalSocket.value!.send(
JSON.stringify({
type: 'resize',
cols: cols,
rows: rows,
}),
);
}
}
/**
* Support for Ctrl+MouseWheel to scaling fonts
* @param event WheelEvent
*/
const onTermWheel = (event: WheelEvent) => {
if (event.ctrlKey) {
event.preventDefault();
if (event.deltaY > 0) {
// web font-size mini 12px
if (term.value.options.fontSize > 12) {
term.value.options.fontSize = term.value.options.fontSize - 1;
}
} else {
term.value.options.fontSize = term.value.options.fontSize + 1;
}
}
};
// terminal end
// websocket start
const initWebSocket = (endpoint_: string, args: string = '') => {
const href = window.location.href;
const protocol = href.split('//')[0] === 'http:' ? 'ws' : 'wss';
const host = href.split('//')[1].split('/')[0];
const endpoint = endpoint_.replace(/^\/+/, '');
terminalSocket.value = new WebSocket(
`${protocol}://${host}/${endpoint}?cols=${term.value.cols}&rows=${term.value.rows}&${args}`,
);
terminalSocket.value.onopen = runRealTerminal;
terminalSocket.value.onmessage = onWSReceive;
terminalSocket.value.onclose = closeRealTerminal;
terminalSocket.value.onerror = errorRealTerminal;
heartbeatTimer.value = setInterval(() => {
if (isWsOpen()) {
terminalSocket.value!.send(
JSON.stringify({
type: 'heartbeat',
timestamp: `${new Date().getTime()}`,
}),
);
}
}, 1000 * 10);
};
const runRealTerminal = () => {
webSocketReady.value = true;
};
const onWSReceive = (message: MessageEvent) => {
const wsMsg = JSON.parse(message.data);
switch (wsMsg.type) {
case 'cmd': {
term.value.element && term.value.focus();
term.value.write(Base64.decode(wsMsg.data));
break;
}
case 'heartbeat': {
latency.value = new Date().getTime() - wsMsg.timestamp;
break;
}
}
};
const errorRealTerminal = (ex: any) => {
let message = ex.message;
if (!message) message = 'disconnected';
term.value.write(`\x1b[31m${message}\x1b[m\r\n`);
};
const closeRealTerminal = (ev: CloseEvent) => {
if (heartbeatTimer.value) {
clearInterval(heartbeatTimer.value);
}
term.value.write(ev.reason);
};
const isWsOpen = () => {
const readyState = terminalSocket.value && terminalSocket.value.readyState;
return readyState === 1;
};
function sendMsg(data: string) {
if (isWsOpen()) {
terminalSocket.value!.send(
JSON.stringify({
type: 'cmd',
data: Base64.encode(data),
}),
);
}
}
// websocket end
defineExpose({
acceptParams,
onClose,
isWsOpen,
sendMsg,
getLatency: () => latency.value,
});
onBeforeUnmount(() => {
onClose();
});
</script>
<style lang="scss" scoped>
#terminal {
width: 100%;
height: 100%;
}
</style>

View File

@ -131,7 +131,7 @@
<script setup lang="ts">
import { ref, getCurrentInstance, watch, nextTick } from 'vue';
import Terminal from '@/views/host/terminal/terminal/terminal.vue';
import Terminal from '@/components/terminal/index.vue';
import HostDialog from '@/views/host/terminal/terminal/host-create.vue';
import type Node from 'element-plus/es/components/tree/src/model/node';
import { ElTree } from 'element-plus';
@ -260,11 +260,11 @@ function quickInput(val: any) {
if (val !== '' && ctx) {
if (isBatch.value) {
for (const tab of terminalTabs.value) {
ctx.refs[`t-${tab.index}`] && ctx.refs[`t-${tab.index}`][0].onSendMsg(val + '\n');
ctx.refs[`t-${tab.index}`] && ctx.refs[`t-${tab.index}`][0].sendMsg(val + '\n');
}
return;
}
ctx.refs[`t-${terminalValue.value}`] && ctx.refs[`t-${terminalValue.value}`][0].onSendMsg(val + '\n');
ctx.refs[`t-${terminalValue.value}`] && ctx.refs[`t-${terminalValue.value}`][0].sendMsg(val + '\n');
quickCmd.value = '';
}
}
@ -275,12 +275,12 @@ function batchInput() {
}
if (isBatch.value) {
for (const tab of terminalTabs.value) {
ctx.refs[`t-${tab.index}`] && ctx.refs[`t-${tab.index}`][0].onSendMsg(batchVal.value + '\n');
ctx.refs[`t-${tab.index}`] && ctx.refs[`t-${tab.index}`][0].sendMsg(batchVal.value + '\n');
}
batchVal.value = '';
return;
}
ctx.refs[`t-${terminalValue.value}`] && ctx.refs[`t-${terminalValue.value}`][0].onSendMsg(batchVal.value + '\n');
ctx.refs[`t-${terminalValue.value}`] && ctx.refs[`t-${terminalValue.value}`][0].sendMsg(batchVal.value + '\n');
batchVal.value = '';
}
@ -313,8 +313,8 @@ const onReconnect = async (item: any) => {
nextTick(() => {
ctx.refs[`t-${item.index}`] &&
ctx.refs[`t-${item.index}`][0].acceptParams({
wsID: item.wsID,
terminalID: item.index,
endpoint: '/api/v1/terminals',
args: `id=${item.wsID}`,
error: res.data ? '' : 'Failed to set up the connection. Please check the host information',
});
});
@ -345,8 +345,8 @@ const onConnTerminal = async (title: string, wsID: number, isLocal?: boolean) =>
nextTick(() => {
ctx.refs[`t-${terminalValue.value}`] &&
ctx.refs[`t-${terminalValue.value}`][0].acceptParams({
wsID: wsID,
terminalID: terminalValue.value,
endpoint: '/api/v1/terminals',
args: `id=${wsID}`,
error: res.data ? '' : 'Authentication failed. Please check the host information !',
});
});

View File

@ -1,241 +0,0 @@
<template>
<div :id="'terminal-' + terminalID" @wheel="onTermWheel"></div>
</template>
<script setup lang="ts">
import { ref, nextTick, onBeforeUnmount, watch } from 'vue';
import { Terminal } from 'xterm';
import { Base64 } from 'js-base64';
import 'xterm/css/xterm.css';
import { FitAddon } from 'xterm-addon-fit';
const terminalID = ref();
const wsID = ref();
interface WsProps {
terminalID: string;
wsID: number;
error: string;
}
const acceptParams = (props: WsProps) => {
terminalID.value = props.terminalID;
wsID.value = props.wsID;
nextTick(() => {
if (props.error.length !== 0) {
initErrorTerm(props.error);
} else {
initTerm();
window.addEventListener('resize', changeTerminalSize);
}
});
};
const fitAddon = new FitAddon();
const webSocketReady = ref(false);
const termReady = ref(false);
const terminalSocket = ref<WebSocket>();
const term = ref<Terminal>();
const heartbeatTimer = ref<number>();
const latency = ref(0);
const readyWatcher = watch(
() => webSocketReady.value && termReady.value,
(ready) => {
if (ready) {
changeTerminalSize();
readyWatcher(); // unwatch self
}
},
);
const runRealTerminal = () => {
webSocketReady.value = true;
};
const onWSReceive = (message: MessageEvent) => {
const wsMsg = JSON.parse(message.data);
switch (wsMsg.type) {
case 'cmd': {
if (term.value) {
term.value.element && term.value.focus();
term.value.write(Base64.decode(wsMsg.data));
}
break;
}
case 'heartbeat': {
latency.value = new Date().getTime() - wsMsg.timestamp;
break;
}
}
};
const errorRealTerminal = (ex: any) => {
let message = ex.message;
if (!message) message = 'disconnected';
if (term.value) {
term.value.write(`\x1b[31m${message}\x1b[m\r\n`);
}
};
const closeRealTerminal = (ev: CloseEvent) => {
if (heartbeatTimer.value) {
clearInterval(heartbeatTimer.value);
}
if (term.value) {
term.value.write(ev.reason);
}
};
const initErrorTerm = (errorInfo: string) => {
let ifm = document.getElementById('terminal-' + terminalID.value) as HTMLInputElement | null;
term.value = new Terminal({
lineHeight: 1.2,
fontSize: 12,
fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace",
theme: {
background: '#000000',
},
cursorBlink: true,
cursorStyle: 'underline',
scrollback: 100,
tabStopWidth: 4,
});
if (ifm) {
term.value.open(ifm);
term.value.write(errorInfo);
term.value.loadAddon(fitAddon);
fitAddon.fit();
termReady.value = true;
}
};
const initTerm = () => {
let ifm = document.getElementById('terminal-' + terminalID.value) as HTMLInputElement | null;
let href = window.location.href;
let protocol = href.split('//')[0] === 'http:' ? 'ws' : 'wss';
let ipLocal = href.split('//')[1].split('/')[0];
term.value = new Terminal({
lineHeight: 1.2,
fontSize: 12,
fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace",
theme: {
background: '#000000',
},
cursorBlink: true,
cursorStyle: 'underline',
scrollback: 100,
tabStopWidth: 4,
});
if (ifm) {
term.value.open(ifm);
terminalSocket.value = new WebSocket(
`${protocol}://${ipLocal}/api/v1/terminals?id=${wsID.value}&cols=${term.value.cols}&rows=${term.value.rows}`,
);
terminalSocket.value.onopen = runRealTerminal;
terminalSocket.value.onmessage = onWSReceive;
terminalSocket.value.onclose = closeRealTerminal;
terminalSocket.value.onerror = errorRealTerminal;
heartbeatTimer.value = setInterval(() => {
if (isWsOpen()) {
terminalSocket.value!.send(
JSON.stringify({
type: 'heartbeat',
timestamp: `${new Date().getTime()}`,
}),
);
}
}, 1000 * 10);
term.value.onData((data: any) => {
if (isWsOpen()) {
terminalSocket.value!.send(
JSON.stringify({
type: 'cmd',
data: Base64.encode(data),
}),
);
}
});
term.value.loadAddon(fitAddon);
termReady.value = true;
}
};
const fitTerm = () => {
fitAddon.fit();
};
const isWsOpen = () => {
const readyState = terminalSocket.value && terminalSocket.value.readyState;
return readyState === 1;
};
function onClose() {
window.removeEventListener('resize', changeTerminalSize);
try {
terminalSocket.value?.close();
} catch {}
try {
term.value?.dispose();
} catch {}
}
function onSendMsg(command: string) {
terminalSocket.value?.send(
JSON.stringify({
type: 'cmd',
data: Base64.encode(command),
}),
);
}
function changeTerminalSize() {
fitTerm();
const { cols, rows } = term.value!;
if (isWsOpen()) {
terminalSocket.value!.send(
JSON.stringify({
type: 'resize',
cols: cols,
rows: rows,
}),
);
}
}
/**
* Support for Ctrl+MouseWheel to scaling fonts
* @param event WheelEvent
*/
const onTermWheel = (event: WheelEvent) => {
if (event.ctrlKey) {
event.preventDefault();
if (term.value) {
if (event.deltaY > 0) {
// web font-size mini 12px
if (term.value.options.fontSize > 12) {
term.value.options.fontSize = term.value.options.fontSize - 1;
}
} else {
term.value.options.fontSize = term.value.options.fontSize + 1;
}
}
}
};
defineExpose({
acceptParams,
onClose,
isWsOpen,
onSendMsg,
getLatency: () => latency.value,
});
onBeforeUnmount(() => {
onClose();
});
</script>
<style lang="scss" scoped>
#terminal {
width: 100%;
height: 100%;
}
</style>