mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2025-01-19 08:19:15 +08:00
feat: 抽象出终端为独立组件
This commit is contained in:
parent
750a2a445e
commit
fa83199d7b
224
frontend/src/components/terminal/index.vue
Normal file
224
frontend/src/components/terminal/index.vue
Normal 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>
|
@ -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 !',
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
Loading…
x
Reference in New Issue
Block a user