refactor: 机器终端操作优化

This commit is contained in:
meilin.huang
2024-02-23 22:53:17 +08:00
parent 878985f7c5
commit 7e7f02b502
21 changed files with 335 additions and 193 deletions

View File

@@ -8,7 +8,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import 'xterm/css/xterm.css'; import 'xterm/css/xterm.css';
import { ITheme, Terminal } from 'xterm'; import { Terminal, ITheme } from 'xterm';
import { FitAddon } from 'xterm-addon-fit'; import { FitAddon } from 'xterm-addon-fit';
import { SearchAddon } from 'xterm-addon-search'; import { SearchAddon } from 'xterm-addon-search';
import { WebLinksAddon } from 'xterm-addon-web-links'; import { WebLinksAddon } from 'xterm-addon-web-links';
@@ -20,6 +20,7 @@ import TerminalSearch from './TerminalSearch.vue';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { TerminalStatus } from './common'; import { TerminalStatus } from './common';
import { useEventListener } from '@vueuse/core'; import { useEventListener } from '@vueuse/core';
import themes from './themes';
const props = defineProps({ const props = defineProps({
/** /**
@@ -76,6 +77,14 @@ watch(
} }
); );
// 监听 themeConfig terminalTheme配置的变化
watch(
() => themeConfig.value.terminalTheme,
() => {
term.options.theme = getTerminalTheme();
}
);
onBeforeUnmount(() => { onBeforeUnmount(() => {
close(); close();
}); });
@@ -93,44 +102,11 @@ function init() {
disableStdin: false, disableStdin: false,
allowProposedApi: true, allowProposedApi: true,
fastScrollModifier: 'ctrl', fastScrollModifier: 'ctrl',
theme: { theme: getTerminalTheme(),
foreground: themeConfig.value.terminalForeground || '#7e9192', //字体
background: themeConfig.value.terminalBackground || '#002833', //背景色
cursor: themeConfig.value.terminalCursor || '#268F81', //设置光标
// cursorAccent: "red", // 光标停止颜色
} as ITheme,
}); });
term.open(terminalRef.value); term.open(terminalRef.value);
// 注册自适应组件
const fitAddon = new FitAddon();
state.addon.fit = fitAddon;
term.loadAddon(fitAddon);
// 注册搜索组件
const searchAddon = new SearchAddon();
state.addon.search = searchAddon;
term.loadAddon(searchAddon);
// 注册 url link组件
const weblinks = new WebLinksAddon();
state.addon.weblinks = weblinks;
term.loadAddon(weblinks);
setTimeout(() => {
fitTerminal();
// 初始化websocket
initSocket();
}, 100);
}
/**
* 连接成功
*/
const onConnected = () => {
// 注册心跳
pingInterval = setInterval(sendPing, 15000);
// 注册 terminal 事件 // 注册 terminal 事件
term.onResize((event) => sendResize(event.cols, event.rows)); term.onResize((event) => sendResize(event.cols, event.rows));
term.onData((event) => sendCmd(event)); term.onData((event) => sendCmd(event));
@@ -146,51 +122,45 @@ const onConnected = () => {
return true; return true;
}); });
state.status = TerminalStatus.Connected; // 注册自适应组件
const fitAddon = new FitAddon();
state.addon.fit = fitAddon;
term.loadAddon(fitAddon);
// 注册窗口大小监听器 // 注册搜索组件
useEventListener('resize', debounce(resize, 400)); const searchAddon = new SearchAddon();
state.addon.search = searchAddon;
term.loadAddon(searchAddon);
focus(); // 注册 url link组件
const weblinks = new WebLinksAddon();
state.addon.weblinks = weblinks;
term.loadAddon(weblinks);
// 如果有初始要执行的命令,则发送执行命令 initSocket();
if (props.cmd) { }
sendCmd(props.cmd + ' \r');
}
};
// 自适应终端
const fitTerminal = () => {
// 获取建议的宽度和高度
const dimensions = state.addon.fit?.proposeDimensions();
if (!dimensions) {
return;
}
if (dimensions?.cols && dimensions?.rows) {
// 调整终端的列数和行数
term.resize(dimensions.cols, dimensions.rows);
}
};
const focus = () => {
setTimeout(() => term.focus(), 100);
};
const clear = () => {
term.clear();
term.clearSelection();
term.focus();
};
function initSocket() { function initSocket() {
if (props.socketUrl) { if (props.socketUrl) {
let socketUrl = `${props.socketUrl}&rows=${term?.rows}&cols=${term?.cols}`; socket = new WebSocket(`${props.socketUrl}`);
socket = new WebSocket(socketUrl);
} }
// 监听socket连接 // 监听socket连接
socket.onopen = () => { socket.onopen = () => {
onConnected(); // 注册心跳
pingInterval = setInterval(sendPing, 15000);
state.status = TerminalStatus.Connected;
// // 注册窗口大小监听器
useEventListener('resize', debounce(fitTerminal, 400));
focus();
fitTerminal();
sendResize(term.cols, term.rows);
// 如果有初始要执行的命令,则发送执行命令
if (props.cmd) {
sendCmd(props.cmd + ' \r');
}
}; };
// 监听socket错误信息 // 监听socket错误信息
@@ -202,19 +172,46 @@ function initSocket() {
socket.onclose = (e: CloseEvent) => { socket.onclose = (e: CloseEvent) => {
console.log('terminal socket close...', e.reason); console.log('terminal socket close...', e.reason);
// 清除 ping
pingInterval && clearInterval(pingInterval);
state.status = TerminalStatus.Disconnected; state.status = TerminalStatus.Disconnected;
}; };
// 监听socket消息 // 监听socket消息
socket.onmessage = getMessage; socket.onmessage = (msg: any) => {
// msg.data是真正后端返回的数据
term.write(msg.data);
};
} }
function getMessage(msg: any) { const getTerminalTheme = () => {
// msg.data是真正后端返回的数据 const terminalTheme = themeConfig.value.terminalTheme;
term.write(msg.data); // 如果不是自定义主题,则返回内置主题
} if (terminalTheme != 'custom') {
return themes[terminalTheme];
}
// 自定义主题
return {
foreground: themeConfig.value.terminalForeground || '#7e9192', //字体
background: themeConfig.value.terminalBackground || '#002833', //背景色
cursor: themeConfig.value.terminalCursor || '#268F81', //设置光标
// cursorAccent: "red", // 光标停止颜色
} as ITheme;
};
// 自适应终端
const fitTerminal = () => {
state.addon.fit.fit();
};
const focus = () => {
setTimeout(() => term.focus(), 300);
};
const clear = () => {
term.clear();
term.clearSelection();
term.focus();
};
enum MsgType { enum MsgType {
Resize = 1, Resize = 1,
@@ -223,29 +220,19 @@ enum MsgType {
} }
const send = (msg: any) => { const send = (msg: any) => {
state.status == TerminalStatus.Connected && socket.send(JSON.stringify(msg)); state.status == TerminalStatus.Connected && socket.send(msg);
}; };
const sendResize = (cols: number, rows: number) => { const sendResize = (cols: number, rows: number) => {
send({ send(`${MsgType.Resize}|${rows}|${cols}`);
type: MsgType.Resize,
Cols: cols,
Rows: rows,
});
}; };
const sendPing = () => { const sendPing = () => {
send({ send(`${MsgType.Ping}|ping`);
type: MsgType.Ping,
msg: 'ping',
});
}; };
function sendCmd(key: any) { function sendCmd(key: any) {
send({ send(`${MsgType.Data}|${key}`);
type: MsgType.Data,
msg: key,
});
} }
function closeSocket() { function closeSocket() {
@@ -270,13 +257,7 @@ const getStatus = (): TerminalStatus => {
return state.status; return state.status;
}; };
const resize = () => { defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize });
nextTick(() => {
state.addon.fit.fit();
});
};
defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize, resize });
</script> </script>
<style lang="scss"> <style lang="scss">
#terminal-body { #terminal-body {

View File

@@ -0,0 +1,92 @@
export default {
dark: {
foreground: '#c7c7c7',
background: '#000000',
cursor: '#c7c7c7',
selectionBackground: '#686868',
black: '#000000',
brightBlack: '#676767',
red: '#c91b00',
brightRed: '#ff6d67',
green: '#00c200',
brightGreen: '#5ff967',
yellow: '#c7c400',
brightYellow: '#fefb67',
blue: '#0225c7',
brightBlue: '#6871ff',
magenta: '#c930c7',
brightMagenta: '#ff76ff',
cyan: '#00c5c7',
brightCyan: '#5ffdff',
white: '#c7c7c7',
brightWhite: '#fffefe',
},
light: {
foreground: '#000000',
background: '#fffefe',
cursor: '#000000',
selectionBackground: '#c7c7c7',
black: '#000000',
brightBlack: '#676767',
red: '#c91b00',
brightRed: '#ff6d67',
green: '#00c200',
brightGreen: '#5ff967',
yellow: '#c7c400',
brightYellow: '#fefb67',
blue: '#0225c7',
brightBlue: '#6871ff',
magenta: '#c930c7',
brightMagenta: '#ff76ff',
cyan: '#00c5c7',
brightCyan: '#5ffdff',
white: '#c7c7c7',
brightWhite: '#fffefe',
},
solarizedLight: {
foreground: '#657b83',
background: '#fdf6e3',
cursor: '#657b83',
selectionBackground: '#c7c7c7',
black: '#073642',
brightBlack: '#002b36',
red: '#dc322f',
brightRed: '#cb4b16',
green: '#859900',
brightGreen: '#586e75',
yellow: '#b58900',
brightYellow: '#657b83',
blue: '#268bd2',
brightBlue: '#839496',
magenta: '#d33682',
brightMagenta: '#6c71c4',
cyan: '#2aa198',
brightCyan: '#93a1a1',
white: '#eee8d5',
brightWhite: '#fdf6e3',
},
};

View File

@@ -5,26 +5,39 @@
<!-- ssh终端主题 --> <!-- ssh终端主题 -->
<el-divider content-position="left">终端主题</el-divider> <el-divider content-position="left">终端主题</el-divider>
<div class="layout-breadcrumb-seting-bar-flex"> <div class="layout-breadcrumb-seting-bar-flex">
<div class="layout-breadcrumb-seting-bar-flex-label">字体颜色</div> <div class="layout-breadcrumb-seting-bar-flex-label">主题</div>
<div class="layout-breadcrumb-seting-bar-flex-value"> <div class="layout-breadcrumb-seting-bar-flex-value">
<el-color-picker v-model="themeConfig.terminalForeground" size="small" @change="onColorPickerChange('terminalForeground')"> <el-select @change="setLocalThemeConfig" v-model="themeConfig.terminalTheme" size="small" style="width: 140px">
</el-color-picker> <el-option v-for="(_, k) in themes" :key="k" :label="k" :value="k"> </el-option>
<el-option label="自定义" value="custom"> </el-option>
</el-select>
</div> </div>
</div> </div>
<div class="layout-breadcrumb-seting-bar-flex"> <template v-if="themeConfig.terminalTheme == 'custom'">
<div class="layout-breadcrumb-seting-bar-flex-label">背景颜色</div> <div class="layout-breadcrumb-seting-bar-flex mt10">
<div class="layout-breadcrumb-seting-bar-flex-value"> <div class="layout-breadcrumb-seting-bar-flex-label">字体颜色</div>
<el-color-picker v-model="themeConfig.terminalBackground" size="small" @change="onColorPickerChange('terminalBackground')"> <div class="layout-breadcrumb-seting-bar-flex-value">
</el-color-picker> <el-color-picker v-model="themeConfig.terminalForeground" size="small" @change="onColorPickerChange('terminalForeground')">
</el-color-picker>
</div>
</div> </div>
</div> <div class="layout-breadcrumb-seting-bar-flex">
<div class="layout-breadcrumb-seting-bar-flex"> <div class="layout-breadcrumb-seting-bar-flex-label">背景颜色</div>
<div class="layout-breadcrumb-seting-bar-flex-label">cursor颜色</div> <div class="layout-breadcrumb-seting-bar-flex-value">
<div class="layout-breadcrumb-seting-bar-flex-value"> <el-color-picker v-model="themeConfig.terminalBackground" size="small" @change="onColorPickerChange('terminalBackground')">
<el-color-picker v-model="themeConfig.terminalCursor" size="small" @change="onColorPickerChange('terminalCursor')"> </el-color-picker> </el-color-picker>
</div>
</div> </div>
</div> <div class="layout-breadcrumb-seting-bar-flex">
<div class="layout-breadcrumb-seting-bar-flex mt15"> <div class="layout-breadcrumb-seting-bar-flex-label">cursor颜色</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-color-picker v-model="themeConfig.terminalCursor" size="small" @change="onColorPickerChange('terminalCursor')">
</el-color-picker>
</div>
</div>
</template>
<div class="layout-breadcrumb-seting-bar-flex mt10">
<div class="layout-breadcrumb-seting-bar-flex-label">字体大小</div> <div class="layout-breadcrumb-seting-bar-flex-label">字体大小</div>
<div class="layout-breadcrumb-seting-bar-flex-value"> <div class="layout-breadcrumb-seting-bar-flex-value">
<el-input-number <el-input-number
@@ -39,7 +52,7 @@
</el-input-number> </el-input-number>
</div> </div>
</div> </div>
<div class="layout-breadcrumb-seting-bar-flex mt15"> <div class="layout-breadcrumb-seting-bar-flex mt10">
<div class="layout-breadcrumb-seting-bar-flex-label">字体粗细</div> <div class="layout-breadcrumb-seting-bar-flex-label">字体粗细</div>
<div class="layout-breadcrumb-seting-bar-flex-value"> <div class="layout-breadcrumb-seting-bar-flex-value">
<el-select @change="setLocalThemeConfig" v-model="themeConfig.terminalFontWeight" size="small" style="width: 90px"> <el-select @change="setLocalThemeConfig" v-model="themeConfig.terminalFontWeight" size="small" style="width: 90px">
@@ -418,6 +431,7 @@ import { useThemeConfig } from '@/store/themeConfig';
import { getLightColor } from '@/common/utils/theme'; import { getLightColor } from '@/common/utils/theme';
import { setLocal, getLocal, removeLocal } from '@/common/utils/storage'; import { setLocal, getLocal, removeLocal } from '@/common/utils/storage';
import mittBus from '@/common/utils/mitt'; import mittBus from '@/common/utils/mitt';
import themes from '@/components/terminal/themes';
const copyConfigBtnRef = ref(); const copyConfigBtnRef = ref();
const { themeConfig } = storeToRefs(useThemeConfig()); const { themeConfig } = storeToRefs(useThemeConfig());

View File

@@ -0,0 +1,20 @@
import { saveThemeConfig } from '@/common/utils/storage';
import { isDark } from './user.vue';
export const switchDark = () => {
themeConfig.value.isDark = isDark.value;
if (isDark.value) {
themeConfig.value.editorTheme = 'vs-dark';
} else {
themeConfig.value.editorTheme = 'vs';
}
// 如果终端主题不是自定义主题,则切换主题
if (themeConfig.value.terminalTheme != 'custom') {
if (isDark.value) {
themeConfig.value.terminalTheme = 'dark';
} else {
themeConfig.value.terminalTheme = 'solarizedLight';
}
}
saveThemeConfig(themeConfig.value);
};

View File

@@ -174,12 +174,7 @@ watch(preDark, (newValue) => {
}); });
const switchDark = () => { const switchDark = () => {
themeConfig.value.isDark = isDark.value; themeConfigStore.switchDark(isDark.value);
if (isDark.value) {
themeConfig.value.editorTheme = 'vs-dark';
} else {
themeConfig.value.editorTheme = 'vs';
}
saveThemeConfig(themeConfig.value); saveThemeConfig(themeConfig.value);
}; };

View File

@@ -114,6 +114,7 @@ export const useThemeConfig = defineStore('themeConfig', {
// 默认布局,可选 1、默认 defaults 2、经典 classic 3、横向 transverse 4、分栏 columns // 默认布局,可选 1、默认 defaults 2、经典 classic 3、横向 transverse 4、分栏 columns
layout: 'classic', layout: 'classic',
terminalTheme: 'solarizedLight',
// ssh终端字体颜色 // ssh终端字体颜色
terminalForeground: '#C5C8C6', terminalForeground: '#C5C8C6',
// ssh终端背景色 // ssh终端背景色
@@ -192,5 +193,23 @@ export const useThemeConfig = defineStore('themeConfig', {
setWatermarkNowTime() { setWatermarkNowTime() {
this.themeConfig.watermarkText[1] = dateFormat2('yyyy-MM-dd HH:mm:ss', new Date()); this.themeConfig.watermarkText[1] = dateFormat2('yyyy-MM-dd HH:mm:ss', new Date());
}, },
// 切换暗黑模式
switchDark(isDark: boolean) {
this.themeConfig.isDark = isDark;
// 切换编辑器主题
if (isDark) {
this.themeConfig.editorTheme = 'vs-dark';
} else {
this.themeConfig.editorTheme = 'vs';
}
// 如果终端主题不是自定义主题,则切换主题
if (this.themeConfig.terminalTheme != 'custom') {
if (isDark) {
this.themeConfig.terminalTheme = 'dark';
} else {
this.themeConfig.terminalTheme = 'solarizedLight';
}
}
},
}, },
}); });

View File

@@ -52,6 +52,7 @@ declare interface ThemeConfigState {
logoIcon: string; logoIcon: string;
globalI18n: string; globalI18n: string;
globalComponentSize: string; globalComponentSize: string;
terminalTheme: string;
terminalForeground: string; terminalForeground: string;
terminalBackground: string; terminalBackground: string;
terminalCursor: string; terminalCursor: string;

View File

@@ -109,7 +109,7 @@ const toPage = (item: any) => {
break; break;
} }
case 'machineNum': { case 'machineNum': {
router.push('/machine/machines'); router.push('/machine/machines-op');
break; break;
} }
case 'dbNum': { case 'dbNum': {

View File

@@ -118,14 +118,6 @@
<el-dropdown-item :command="{ type: 'terminalRec', data }" v-if="actionBtns[perms.updateMachine] && data.enableRecorder == 1"> <el-dropdown-item :command="{ type: 'terminalRec', data }" v-if="actionBtns[perms.updateMachine] && data.enableRecorder == 1">
终端回放 终端回放
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item
:command="{ type: 'closeCli', data }"
v-if="actionBtns[perms.closeCli]"
:disabled="!data.hasCli || data.status == -1"
>
关闭连接
</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
@@ -223,7 +215,6 @@ const perms = {
updateMachine: 'machine:update', updateMachine: 'machine:update',
delMachine: 'machine:del', delMachine: 'machine:del',
terminal: 'machine:terminal', terminal: 'machine:terminal',
closeCli: 'machine:close-cli',
}; };
const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Machine.value), SearchItem.input('ip', 'IP'), SearchItem.input('name', '名称')]; const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Machine.value), SearchItem.input('ip', 'IP'), SearchItem.input('name', '名称')];
@@ -241,7 +232,7 @@ const columns = [
]; ];
// 该用户拥有的的操作列按钮权限使用v-if进行判断v-auth对el-dropdown-item无效 // 该用户拥有的的操作列按钮权限使用v-if进行判断v-auth对el-dropdown-item无效
const actionBtns = hasPerms([perms.updateMachine, perms.closeCli]); const actionBtns = hasPerms([perms.updateMachine]);
const state = reactive({ const state = reactive({
params: { params: {
@@ -320,10 +311,6 @@ const handleCommand = (commond: any) => {
showRec(data); showRec(data);
return; return;
} }
case 'closeCli': {
closeCli(data);
return;
}
} }
}; };
@@ -351,17 +338,6 @@ const showTerminal = (row: any, event: PointerEvent) => {
}); });
}; };
const closeCli = async (row: any) => {
await ElMessageBox.confirm(`确定关闭该机器客户端连接?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await machineApi.closeCli.request({ id: row.id });
ElMessage.success('关闭成功');
search();
};
const openFormDialog = async (machine: any) => { const openFormDialog = async (machine: any) => {
let dialogTitle; let dialogTitle;
if (machine) { if (machine) {

View File

@@ -220,16 +220,15 @@ const NodeTypeMachine = (machine: any) => {
let contextMenuItems = []; let contextMenuItems = [];
contextMenuItems.push(new ContextmenuItem('term', '打开终端').withIcon('Monitor').withOnClick(() => openTerminal(machine))); contextMenuItems.push(new ContextmenuItem('term', '打开终端').withIcon('Monitor').withOnClick(() => openTerminal(machine)));
contextMenuItems.push(new ContextmenuItem('term-ex', '打开终端(新窗口)').withIcon('Monitor').withOnClick(() => openTerminal(machine, true))); contextMenuItems.push(new ContextmenuItem('term-ex', '打开终端(新窗口)').withIcon('Monitor').withOnClick(() => openTerminal(machine, true)));
contextMenuItems.push(new ContextmenuItem('files', '文件管理').withIcon('FolderOpened').withOnClick(() => showFileManage(machine)));
contextMenuItems.push(new ContextmenuItem('scripts', '脚本管理').withIcon('Files').withOnClick(() => serviceManager(machine)));
contextMenuItems.push(new ContextmenuItem('detail', '详情').withIcon('More').withOnClick(() => showInfo(machine))); contextMenuItems.push(new ContextmenuItem('detail', '详情').withIcon('More').withOnClick(() => showInfo(machine)));
contextMenuItems.push(new ContextmenuItem('status', '状态').withIcon('Compass').withOnClick(() => showMachineStats(machine))); contextMenuItems.push(new ContextmenuItem('status', '状态').withIcon('Compass').withOnClick(() => showMachineStats(machine)));
contextMenuItems.push(new ContextmenuItem('process', '进程').withIcon('DataLine').withOnClick(() => showProcess(machine))); contextMenuItems.push(new ContextmenuItem('process', '进程').withIcon('DataLine').withOnClick(() => showProcess(machine)));
if (actionBtns[perms.updateMachine] && machine.enableRecorder == 1) { if (actionBtns[perms.updateMachine] && machine.enableRecorder == 1) {
contextMenuItems.push(new ContextmenuItem('edit', '终端回放').withIcon('Compass').withOnClick(() => showRec(machine))); contextMenuItems.push(new ContextmenuItem('edit', '终端回放').withIcon('Compass').withOnClick(() => showRec(machine)));
} }
contextMenuItems.push(new ContextmenuItem('files', '文件管理').withIcon('FolderOpened').withOnClick(() => showFileManage(machine)));
contextMenuItems.push(new ContextmenuItem('scripts', '脚本管理').withIcon('Files').withOnClick(() => serviceManager(machine)));
return new NodeType(MachineNodeType.Machine).withContextMenuItems(contextMenuItems).withNodeDblclickFunc(() => { return new NodeType(MachineNodeType.Machine).withContextMenuItems(contextMenuItems).withNodeDblclickFunc(() => {
// for (let k of state.tabs.keys()) { // for (let k of state.tabs.keys()) {
// // 存在该机器相关的终端tab则直接激活该tab // // 存在该机器相关的终端tab则直接激活该tab
@@ -369,7 +368,7 @@ const fitTerminal = () => {
setTimeout(() => { setTimeout(() => {
let info = state.tabs.get(state.activeTermName); let info = state.tabs.get(state.activeTermName);
if (info) { if (info) {
terminalRefs[info.key]?.resize(); terminalRefs[info.key]?.fitTerminal();
terminalRefs[info.key]?.focus(); terminalRefs[info.key]?.focus();
} }
}, 100); }, 100);

View File

@@ -103,8 +103,8 @@ const playRec = async (rec: any) => {
idleTimeLimit: 2, idleTimeLimit: 2,
// fit: false, // fit: false,
// terminalFontSize: 'small', // terminalFontSize: 'small',
// cols: 100, cols: 144,
// rows: 33, rows: 32,
}); });
}); });
} finally { } finally {

View File

@@ -12,7 +12,6 @@ export const machineApi = {
process: Api.newGet('/machines/{id}/process'), process: Api.newGet('/machines/{id}/process'),
// 终止进程 // 终止进程
killProcess: Api.newDelete('/machines/{id}/process'), killProcess: Api.newDelete('/machines/{id}/process'),
closeCli: Api.newDelete('/machines/{id}/close-cli'),
testConn: Api.newPost('/machines/test-conn'), testConn: Api.newPost('/machines/test-conn'),
// 保存按钮 // 保存按钮
saveMachine: Api.newPost('/machines'), saveMachine: Api.newPost('/machines'),

View File

@@ -9,7 +9,6 @@ import (
"mayfly-go/internal/machine/application" "mayfly-go/internal/machine/application"
"mayfly-go/internal/machine/config" "mayfly-go/internal/machine/config"
"mayfly-go/internal/machine/domain/entity" "mayfly-go/internal/machine/domain/entity"
"mayfly-go/internal/machine/mcm"
tagapp "mayfly-go/internal/tag/application" tagapp "mayfly-go/internal/tag/application"
"mayfly-go/pkg/biz" "mayfly-go/pkg/biz"
"mayfly-go/pkg/errorx" "mayfly-go/pkg/errorx"
@@ -53,7 +52,6 @@ func (m *Machine) Machines(rc *req.Ctx) {
} }
for _, mv := range *res.List { for _, mv := range *res.List {
mv.HasCli = mcm.HasCli(mv.Id)
if machineStats, err := m.MachineApp.GetMachineStats(mv.Id); err == nil { if machineStats, err := m.MachineApp.GetMachineStats(mv.Id); err == nil {
mv.Stat = collx.M{ mv.Stat = collx.M{
"cpuIdle": machineStats.CPU.Idle, "cpuIdle": machineStats.CPU.Idle,
@@ -109,11 +107,6 @@ func (m *Machine) DeleteMachine(rc *req.Ctx) {
} }
} }
// 关闭机器客户端
func (m *Machine) CloseCli(rc *req.Ctx) {
mcm.DeleteCli(GetMachineId(rc.GinCtx))
}
// 获取进程列表信息 // 获取进程列表信息
func (m *Machine) GetProcess(rc *req.Ctx) { func (m *Machine) GetProcess(rc *req.Ctx) {
g := rc.GinCtx g := rc.GinCtx
@@ -165,20 +158,22 @@ func (m *Machine) WsSSH(g *gin.Context) {
wsConn.Close() wsConn.Close()
} }
}() }()
biz.ErrIsNilAppendErr(err, "升级websocket失败: %s") biz.ErrIsNilAppendErr(err, "升级websocket失败: %s")
wsConn.WriteMessage(websocket.TextMessage, []byte("Connecting to host..."))
// 权限校验 // 权限校验
rc := req.NewCtxWithGin(g).WithRequiredPermission(req.NewPermission("machine:terminal")) rc := req.NewCtxWithGin(g).WithRequiredPermission(req.NewPermission("machine:terminal"))
if err = req.PermissionHandler(rc); err != nil { if err = req.PermissionHandler(rc); err != nil {
panic(errorx.NewBiz("\033[1;31m您没有权限操作该机器终端,请重新登录后再试~\033[0m")) panic(errorx.NewBiz("\033[1;31m您没有权限操作该机器终端,请重新登录后再试~\033[0m"))
} }
cli, err := m.MachineApp.GetCli(GetMachineId(g)) cli, err := m.MachineApp.NewCli(GetMachineId(g))
biz.ErrIsNilAppendErr(err, "获取客户端连接失败: %s") biz.ErrIsNilAppendErr(err, "获取客户端连接失败: %s")
defer cli.Close()
biz.ErrIsNilAppendErr(m.TagApp.CanAccess(rc.GetLoginAccount().Id, cli.Info.TagPath...), "%s") biz.ErrIsNilAppendErr(m.TagApp.CanAccess(rc.GetLoginAccount().Id, cli.Info.TagPath...), "%s")
cols := ginx.QueryInt(g, "cols", 80) cols := ginx.QueryInt(g, "cols", 80)
rows := ginx.QueryInt(g, "rows", 40) rows := ginx.QueryInt(g, "rows", 32)
// 记录系统操作日志 // 记录系统操作日志
rc.WithLog(req.NewLogSave("机器-终端操作")) rc.WithLog(req.NewLogSave("机器-终端操作"))

View File

@@ -32,8 +32,7 @@ type MachineVO struct {
// TagId uint64 `json:"tagId"` // TagId uint64 `json:"tagId"`
// TagPath string `json:"tagPath"` // TagPath string `json:"tagPath"`
HasCli bool `json:"hasCli" gorm:"-"` Stat map[string]any `json:"stat" gorm:"-"`
Stat map[string]any `json:"stat" gorm:"-"`
} }
type MachineScriptVO struct { type MachineScriptVO struct {

View File

@@ -36,7 +36,10 @@ type Machine interface {
// 分页获取机器信息列表 // 分页获取机器信息列表
GetMachineList(condition *entity.MachineQuery, pageParam *model.PageParam, toEntity *[]*vo.MachineVO, orderBy ...string) (*model.PageResult[*[]*vo.MachineVO], error) GetMachineList(condition *entity.MachineQuery, pageParam *model.PageParam, toEntity *[]*vo.MachineVO, orderBy ...string) (*model.PageResult[*[]*vo.MachineVO], error)
// 获取机器连接 // 新建机器客户端连接需手动调用Close
NewCli(id uint64) (*mcm.Cli, error)
// 获取已缓存的机器连接,若不存在则新建客户端连接并缓存,主要用于定时获取状态等(避免频繁创建连接)
GetCli(id uint64) (*mcm.Cli, error) GetCli(id uint64) (*mcm.Cli, error)
// 获取ssh隧道机器连接 // 获取ssh隧道机器连接
@@ -158,6 +161,14 @@ func (m *machineAppImpl) Delete(ctx context.Context, id uint64) error {
}) })
} }
func (m *machineAppImpl) NewCli(machineId uint64) (*mcm.Cli, error) {
if mi, err := m.toMachineInfoById(machineId); err != nil {
return nil, err
} else {
return mi.Conn()
}
}
func (m *machineAppImpl) GetCli(machineId uint64) (*mcm.Cli, error) { func (m *machineAppImpl) GetCli(machineId uint64) (*mcm.Cli, error) {
return mcm.GetMachineCli(machineId, func(mid uint64) (*mcm.MachineInfo, error) { return mcm.GetMachineCli(machineId, func(mid uint64) (*mcm.MachineInfo, error) {
return m.toMachineInfoById(mid) return m.toMachineInfoById(mid)

View File

@@ -1,7 +1,6 @@
package mcm package mcm
import ( import (
"fmt"
"mayfly-go/pkg/errorx" "mayfly-go/pkg/errorx"
"mayfly-go/pkg/logx" "mayfly-go/pkg/logx"
"strings" "strings"
@@ -44,10 +43,8 @@ func (c *Cli) GetSession() (*ssh.Session, error) {
} }
session, err := c.sshClient.NewSession() session, err := c.sshClient.NewSession()
if err != nil { if err != nil {
// 获取session失败则关闭cli重试
DeleteCli(c.Info.Id)
logx.Errorf("获取机器客户端session失败: %s", err.Error()) logx.Errorf("获取机器客户端session失败: %s", err.Error())
return nil, errorx.NewBiz("获取会话失败, 请重试...") return nil, errorx.NewBiz("获取会话失败, 请稍后重试...")
} }
return session, nil return session, nil
} }
@@ -95,7 +92,7 @@ func (c *Cli) GetAllStats() *Stats {
// 关闭client并从缓存中移除如果使用隧道则也关闭 // 关闭client并从缓存中移除如果使用隧道则也关闭
func (c *Cli) Close() { func (c *Cli) Close() {
m := c.Info m := c.Info
logx.Info(fmt.Sprintf("关闭机器客户端连接-> id: %d, name: %s, ip: %s", m.Id, m.Name, m.Ip)) logx.Debugf("close machine cli -> id=%d, name=%s, ip=%s", m.Id, m.Name, m.Ip)
if c.sshClient != nil { if c.sshClient != nil {
c.sshClient.Close() c.sshClient.Close()
c.sshClient = nil c.sshClient = nil
@@ -106,14 +103,14 @@ func (c *Cli) Close() {
} }
var sshTunnelMachineId uint64 var sshTunnelMachineId uint64
if c.Info.SshTunnelMachine != nil { if m.SshTunnelMachine != nil {
sshTunnelMachineId = c.Info.SshTunnelMachine.Id sshTunnelMachineId = m.SshTunnelMachine.Id
} }
if c.Info.TempSshMachineId != 0 { if m.TempSshMachineId != 0 {
sshTunnelMachineId = c.Info.TempSshMachineId sshTunnelMachineId = m.TempSshMachineId
} }
if sshTunnelMachineId != 0 { if sshTunnelMachineId != 0 {
logx.Infof("关闭机器的隧道信息: machineId=%d, sshTunnelMachineId=%d", c.Info.Id, sshTunnelMachineId) logx.Debugf("close machine ssh tunnel -> machineId=%d, sshTunnelMachineId=%d", m.Id, sshTunnelMachineId)
CloseSshTunnelMachine(int(c.Info.SshTunnelMachine.Id), c.Info.GetTunnelId()) CloseSshTunnelMachine(int(sshTunnelMachineId), m.GetTunnelId())
} }
} }

View File

@@ -3,6 +3,7 @@ package mcm
import ( import (
"mayfly-go/internal/common/consts" "mayfly-go/internal/common/consts"
"mayfly-go/pkg/cache" "mayfly-go/pkg/cache"
"mayfly-go/pkg/logx"
"time" "time"
) )
@@ -25,6 +26,7 @@ func init() {
} }
return false return false
}) })
go checkClientAvailability(3 * time.Minute)
} }
// 从缓存中获取客户端信息,不存在则回调获取机器信息函数,并新建 // 从缓存中获取客户端信息,不存在则回调获取机器信息函数,并新建
@@ -47,15 +49,26 @@ func GetMachineCli(machineId uint64, getMachine func(uint64) (*MachineInfo, erro
return c, nil return c, nil
} }
// 是否存在指定id的客户端连接 // 删除指定机器缓存客户端,并关闭客户端连接
func HasCli(machineId uint64) bool {
if _, ok := cliCache.Get(machineId); ok {
return true
}
return false
}
// 删除指定机器客户端,并关闭客户端连接
func DeleteCli(id uint64) { func DeleteCli(id uint64) {
cliCache.Delete(id) cliCache.Delete(id)
} }
// 检查缓存中的客户端是否可用,不可用则关闭客户端连接
func checkClientAvailability(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
// 遍历所有机器连接实例若存在机器连接实例使用该ssh隧道机器则返回true表示还在使用中...
items := cliCache.Items()
for _, v := range items {
cli := v.Value.(*Cli)
if _, _, err := cli.sshClient.Conn.SendRequest("ping", true, nil); err != nil {
logx.Errorf("machine[%s] cache client is not available: %s", cli.Info.Name, err.Error())
DeleteCli(cli.Info.Id)
}
logx.Debugf("machine[%s] cache client is available", cli.Info.Name)
}
}
}

View File

@@ -2,9 +2,11 @@ package mcm
import ( import (
"context" "context"
"encoding/json"
"io" "io"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/logx" "mayfly-go/pkg/logx"
"mayfly-go/pkg/utils/conv"
"strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -15,6 +17,8 @@ const (
Resize = 1 Resize = 1
Data = 2 Data = 2
Ping = 3 Ping = 3
MsgSplit = "|"
) )
type TerminalSession struct { type TerminalSession struct {
@@ -58,6 +62,9 @@ func NewTerminalSession(sessionId string, ws *websocket.Conn, cli *Cli, rows, co
dataChan: make(chan rune), dataChan: make(chan rune),
tick: tick, tick: tick,
} }
// 清除终端内容
WriteMessage(ws, "\033[2J\033[3J\033[1;1H")
return ts, nil return ts, nil
} }
@@ -155,10 +162,13 @@ func (ts *TerminalSession) receiveWsMsg() {
return return
} }
// 解析消息 // 解析消息
msgObj := WsMsg{} msgObj, err := parseMsg(wsData)
if err := json.Unmarshal(wsData, &msgObj); err != nil { if err != nil {
WriteMessage(wsConn, "\r\n\033[1;31m提示: 消息内容解析失败...\033[0m")
logx.Error("机器ssh终端消息解析失败: ", err) logx.Error("机器ssh终端消息解析失败: ", err)
return
} }
switch msgObj.Type { switch msgObj.Type {
case Resize: case Resize:
if msgObj.Cols > 0 && msgObj.Rows > 0 { if msgObj.Cols > 0 && msgObj.Rows > 0 {
@@ -185,3 +195,27 @@ func (ts *TerminalSession) receiveWsMsg() {
func WriteMessage(ws *websocket.Conn, msg string) error { func WriteMessage(ws *websocket.Conn, msg string) error {
return ws.WriteMessage(websocket.TextMessage, []byte(msg)) return ws.WriteMessage(websocket.TextMessage, []byte(msg))
} }
// 解析消息
func parseMsg(msg []byte) (*WsMsg, error) {
// 消息格式为 msgType|msgContent 如果msgType为resize则为msgType|rows|cols
msgStr := string(msg)
// 查找第一个 "|" 的位置
index := strings.Index(msgStr, MsgSplit)
if index == -1 {
return nil, errorx.NewBiz("消息内容不符合指定规则")
}
// 获取消息类型, 提取第一个 "|" 之前的内容
msgType := conv.Str2Int(msgStr[:index], Ping)
// 其余内容则为消息内容
msgContent := msgStr[index+1:]
wsMsg := &WsMsg{Type: msgType, Msg: msgContent}
if msgType == Resize {
rowsAndCols := strings.Split(msgContent, MsgSplit)
wsMsg.Rows = conv.Str2Int(rowsAndCols[0], 80)
wsMsg.Cols = conv.Str2Int(rowsAndCols[1], 80)
}
return wsMsg, nil
}

View File

@@ -39,8 +39,6 @@ func InitMachineRouter(router *gin.RouterGroup) {
req.NewDelete(":machineId", m.DeleteMachine).Log(req.NewLogSave("删除机器")), req.NewDelete(":machineId", m.DeleteMachine).Log(req.NewLogSave("删除机器")),
req.NewDelete(":machineId/close-cli", m.CloseCli).Log(req.NewLogSave("关闭机器客户端")).RequiredPermissionCode("machine:close-cli"),
// 获取机器终端回放记录列表,目前具有保存机器信息的权限标识才有权限查看终端回放 // 获取机器终端回放记录列表,目前具有保存机器信息的权限标识才有权限查看终端回放
req.NewGet(":machineId/term-recs", m.MachineTermOpRecords).RequiredPermission(saveMachineP), req.NewGet(":machineId/term-recs", m.MachineTermOpRecords).RequiredPermission(saveMachineP),

View File

@@ -779,7 +779,6 @@ INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(105, 103, '12sSjal1/exahgl32/yglxahg2/', 2, 1, '保存权限', 'authcert:save', 20000000, 'null', 1, 'admin', 1, 'admin', '2023-02-23 11:37:54', '2023-02-23 11:37:54', 0, NULL); INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(105, 103, '12sSjal1/exahgl32/yglxahg2/', 2, 1, '保存权限', 'authcert:save', 20000000, 'null', 1, 'admin', 1, 'admin', '2023-02-23 11:37:54', '2023-02-23 11:37:54', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(106, 103, '12sSjal1/exahgl32/Glxag234/', 2, 1, '删除权限', 'authcert:del', 30000000, 'null', 1, 'admin', 1, 'admin', '2023-02-23 11:38:09', '2023-02-23 11:38:09', 0, NULL); INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(106, 103, '12sSjal1/exahgl32/Glxag234/', 2, 1, '删除权限', 'authcert:del', 30000000, 'null', 1, 'admin', 1, 'admin', '2023-02-23 11:38:09', '2023-02-23 11:38:09', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(108, 61, 'RedisXq4/Exitx4al/Gxlagheg/', 2, 1, '数据删除', 'redis:data:del', 30000000, 'null', 1, 'admin', 1, 'admin', '2023-03-14 17:20:00', '2023-03-14 17:20:00', 0, NULL); INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(108, 61, 'RedisXq4/Exitx4al/Gxlagheg/', 2, 1, '数据删除', 'redis:data:del', 30000000, 'null', 1, 'admin', 1, 'admin', '2023-03-14 17:20:00', '2023-03-14 17:20:00', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(109, 3, '12sSjal1/lskeiql1/KMdsix43/', 2, 1, '关闭连接', 'machine:close-cli', 60000000, 'null', 1, 'admin', 1, 'admin', '2023-03-16 16:11:04', '2023-03-16 16:11:04', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(128, 87, 'Xlqig32x/Ulxaee23/MoOWr2N0/', 2, 1, '配置保存', 'config:save', 1687315135, 'null', 1, 'admin', 1, 'admin', '2023-06-21 10:38:55', '2023-06-21 10:38:55', 0, NULL); INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(128, 87, 'Xlqig32x/Ulxaee23/MoOWr2N0/', 2, 1, '配置保存', 'config:save', 1687315135, 'null', 1, 'admin', 1, 'admin', '2023-06-21 10:38:55', '2023-06-21 10:38:55', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(132, 130, '12sSjal1/W9XKiabq/zxXM23i0/', 2, 1, '删除计划任务', 'machine:cronjob:del', 1689860102, 'null', 1, 'admin', 1, 'admin', '2023-07-20 21:35:02', '2023-07-20 21:35:02', 0, NULL); INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(132, 130, '12sSjal1/W9XKiabq/zxXM23i0/', 2, 1, '删除计划任务', 'machine:cronjob:del', 1689860102, 'null', 1, 'admin', 1, 'admin', '2023-07-20 21:35:02', '2023-07-20 21:35:02', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(131, 130, '12sSjal1/W9XKiabq/gEOqr2pD/', 2, 1, '保存计划任务', 'machine:cronjob:save', 1689860087, 'null', 1, 'admin', 1, 'admin', '2023-07-20 21:34:47', '2023-07-20 21:34:47', 0, NULL); INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(131, 130, '12sSjal1/W9XKiabq/gEOqr2pD/', 2, 1, '保存计划任务', 'machine:cronjob:save', 1689860087, 'null', 1, 'admin', 1, 'admin', '2023-07-20 21:34:47', '2023-07-20 21:34:47', 0, NULL);