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>
import 'xterm/css/xterm.css';
import { ITheme, Terminal } from 'xterm';
import { Terminal, ITheme } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { SearchAddon } from 'xterm-addon-search';
import { WebLinksAddon } from 'xterm-addon-web-links';
@@ -20,6 +20,7 @@ import TerminalSearch from './TerminalSearch.vue';
import { debounce } from 'lodash';
import { TerminalStatus } from './common';
import { useEventListener } from '@vueuse/core';
import themes from './themes';
const props = defineProps({
/**
@@ -76,6 +77,14 @@ watch(
}
);
// 监听 themeConfig terminalTheme配置的变化
watch(
() => themeConfig.value.terminalTheme,
() => {
term.options.theme = getTerminalTheme();
}
);
onBeforeUnmount(() => {
close();
});
@@ -93,44 +102,11 @@ function init() {
disableStdin: false,
allowProposedApi: true,
fastScrollModifier: 'ctrl',
theme: {
foreground: themeConfig.value.terminalForeground || '#7e9192', //字体
background: themeConfig.value.terminalBackground || '#002833', //背景色
cursor: themeConfig.value.terminalCursor || '#268F81', //设置光标
// cursorAccent: "red", // 光标停止颜色
} as ITheme,
theme: getTerminalTheme(),
});
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 事件
term.onResize((event) => sendResize(event.cols, event.rows));
term.onData((event) => sendCmd(event));
@@ -146,51 +122,45 @@ const onConnected = () => {
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);
// 如果有初始要执行的命令,则发送执行命令
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();
};
initSocket();
}
function initSocket() {
if (props.socketUrl) {
let socketUrl = `${props.socketUrl}&rows=${term?.rows}&cols=${term?.cols}`;
socket = new WebSocket(socketUrl);
socket = new WebSocket(`${props.socketUrl}`);
}
// 监听socket连接
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错误信息
@@ -202,19 +172,46 @@ function initSocket() {
socket.onclose = (e: CloseEvent) => {
console.log('terminal socket close...', e.reason);
// 清除 ping
pingInterval && clearInterval(pingInterval);
state.status = TerminalStatus.Disconnected;
};
// 监听socket消息
socket.onmessage = getMessage;
socket.onmessage = (msg: any) => {
// msg.data是真正后端返回的数据
term.write(msg.data);
};
}
function getMessage(msg: any) {
// msg.data是真正后端返回的数据
term.write(msg.data);
}
const getTerminalTheme = () => {
const terminalTheme = themeConfig.value.terminalTheme;
// 如果不是自定义主题,则返回内置主题
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 {
Resize = 1,
@@ -223,29 +220,19 @@ enum MsgType {
}
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) => {
send({
type: MsgType.Resize,
Cols: cols,
Rows: rows,
});
send(`${MsgType.Resize}|${rows}|${cols}`);
};
const sendPing = () => {
send({
type: MsgType.Ping,
msg: 'ping',
});
send(`${MsgType.Ping}|ping`);
};
function sendCmd(key: any) {
send({
type: MsgType.Data,
msg: key,
});
send(`${MsgType.Data}|${key}`);
}
function closeSocket() {
@@ -270,13 +257,7 @@ const getStatus = (): TerminalStatus => {
return state.status;
};
const resize = () => {
nextTick(() => {
state.addon.fit.fit();
});
};
defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize, resize });
defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize });
</script>
<style lang="scss">
#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终端主题 -->
<el-divider content-position="left">终端主题</el-divider>
<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">
<el-color-picker v-model="themeConfig.terminalForeground" size="small" @change="onColorPickerChange('terminalForeground')">
</el-color-picker>
<el-select @change="setLocalThemeConfig" v-model="themeConfig.terminalTheme" size="small" style="width: 140px">
<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 class="layout-breadcrumb-seting-bar-flex">
<div class="layout-breadcrumb-seting-bar-flex-label">背景颜色</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-color-picker v-model="themeConfig.terminalBackground" size="small" @change="onColorPickerChange('terminalBackground')">
</el-color-picker>
<template v-if="themeConfig.terminalTheme == 'custom'">
<div class="layout-breadcrumb-seting-bar-flex mt10">
<div class="layout-breadcrumb-seting-bar-flex-label">字体颜色</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-color-picker v-model="themeConfig.terminalForeground" size="small" @change="onColorPickerChange('terminalForeground')">
</el-color-picker>
</div>
</div>
</div>
<div class="layout-breadcrumb-seting-bar-flex">
<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 class="layout-breadcrumb-seting-bar-flex">
<div class="layout-breadcrumb-seting-bar-flex-label">背景颜色</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-color-picker v-model="themeConfig.terminalBackground" size="small" @change="onColorPickerChange('terminalBackground')">
</el-color-picker>
</div>
</div>
</div>
<div class="layout-breadcrumb-seting-bar-flex mt15">
<div class="layout-breadcrumb-seting-bar-flex">
<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-value">
<el-input-number
@@ -39,7 +52,7 @@
</el-input-number>
</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-value">
<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 { setLocal, getLocal, removeLocal } from '@/common/utils/storage';
import mittBus from '@/common/utils/mitt';
import themes from '@/components/terminal/themes';
const copyConfigBtnRef = ref();
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 = () => {
themeConfig.value.isDark = isDark.value;
if (isDark.value) {
themeConfig.value.editorTheme = 'vs-dark';
} else {
themeConfig.value.editorTheme = 'vs';
}
themeConfigStore.switchDark(isDark.value);
saveThemeConfig(themeConfig.value);
};

View File

@@ -114,6 +114,7 @@ export const useThemeConfig = defineStore('themeConfig', {
// 默认布局,可选 1、默认 defaults 2、经典 classic 3、横向 transverse 4、分栏 columns
layout: 'classic',
terminalTheme: 'solarizedLight',
// ssh终端字体颜色
terminalForeground: '#C5C8C6',
// ssh终端背景色
@@ -192,5 +193,23 @@ export const useThemeConfig = defineStore('themeConfig', {
setWatermarkNowTime() {
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;
globalI18n: string;
globalComponentSize: string;
terminalTheme: string;
terminalForeground: string;
terminalBackground: string;
terminalCursor: string;

View File

@@ -109,7 +109,7 @@ const toPage = (item: any) => {
break;
}
case 'machineNum': {
router.push('/machine/machines');
router.push('/machine/machines-op');
break;
}
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>
<el-dropdown-item
:command="{ type: 'closeCli', data }"
v-if="actionBtns[perms.closeCli]"
:disabled="!data.hasCli || data.status == -1"
>
关闭连接
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -223,7 +215,6 @@ const perms = {
updateMachine: 'machine:update',
delMachine: 'machine:del',
terminal: 'machine:terminal',
closeCli: 'machine:close-cli',
};
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无效
const actionBtns = hasPerms([perms.updateMachine, perms.closeCli]);
const actionBtns = hasPerms([perms.updateMachine]);
const state = reactive({
params: {
@@ -320,10 +311,6 @@ const handleCommand = (commond: any) => {
showRec(data);
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) => {
let dialogTitle;
if (machine) {

View File

@@ -220,16 +220,15 @@ const NodeTypeMachine = (machine: any) => {
let contextMenuItems = [];
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('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('status', '状态').withIcon('Compass').withOnClick(() => showMachineStats(machine)));
contextMenuItems.push(new ContextmenuItem('process', '进程').withIcon('DataLine').withOnClick(() => showProcess(machine)));
if (actionBtns[perms.updateMachine] && machine.enableRecorder == 1) {
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(() => {
// for (let k of state.tabs.keys()) {
// // 存在该机器相关的终端tab则直接激活该tab
@@ -369,7 +368,7 @@ const fitTerminal = () => {
setTimeout(() => {
let info = state.tabs.get(state.activeTermName);
if (info) {
terminalRefs[info.key]?.resize();
terminalRefs[info.key]?.fitTerminal();
terminalRefs[info.key]?.focus();
}
}, 100);

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import (
"mayfly-go/internal/machine/application"
"mayfly-go/internal/machine/config"
"mayfly-go/internal/machine/domain/entity"
"mayfly-go/internal/machine/mcm"
tagapp "mayfly-go/internal/tag/application"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/errorx"
@@ -53,7 +52,6 @@ func (m *Machine) Machines(rc *req.Ctx) {
}
for _, mv := range *res.List {
mv.HasCli = mcm.HasCli(mv.Id)
if machineStats, err := m.MachineApp.GetMachineStats(mv.Id); err == nil {
mv.Stat = collx.M{
"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) {
g := rc.GinCtx
@@ -165,20 +158,22 @@ func (m *Machine) WsSSH(g *gin.Context) {
wsConn.Close()
}
}()
biz.ErrIsNilAppendErr(err, "升级websocket失败: %s")
wsConn.WriteMessage(websocket.TextMessage, []byte("Connecting to host..."))
// 权限校验
rc := req.NewCtxWithGin(g).WithRequiredPermission(req.NewPermission("machine:terminal"))
if err = req.PermissionHandler(rc); err != nil {
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")
defer cli.Close()
biz.ErrIsNilAppendErr(m.TagApp.CanAccess(rc.GetLoginAccount().Id, cli.Info.TagPath...), "%s")
cols := ginx.QueryInt(g, "cols", 80)
rows := ginx.QueryInt(g, "rows", 40)
rows := ginx.QueryInt(g, "rows", 32)
// 记录系统操作日志
rc.WithLog(req.NewLogSave("机器-终端操作"))

View File

@@ -32,8 +32,7 @@ type MachineVO struct {
// TagId uint64 `json:"tagId"`
// 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 {

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)
// 获取机器连接
// 新建机器客户端连接需手动调用Close
NewCli(id uint64) (*mcm.Cli, error)
// 获取已缓存的机器连接,若不存在则新建客户端连接并缓存,主要用于定时获取状态等(避免频繁创建连接)
GetCli(id uint64) (*mcm.Cli, error)
// 获取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) {
return mcm.GetMachineCli(machineId, func(mid uint64) (*mcm.MachineInfo, error) {
return m.toMachineInfoById(mid)

View File

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

View File

@@ -3,6 +3,7 @@ package mcm
import (
"mayfly-go/internal/common/consts"
"mayfly-go/pkg/cache"
"mayfly-go/pkg/logx"
"time"
)
@@ -25,6 +26,7 @@ func init() {
}
return false
})
go checkClientAvailability(3 * time.Minute)
}
// 从缓存中获取客户端信息,不存在则回调获取机器信息函数,并新建
@@ -47,15 +49,26 @@ func GetMachineCli(machineId uint64, getMachine func(uint64) (*MachineInfo, erro
return c, nil
}
// 是否存在指定id的客户端连接
func HasCli(machineId uint64) bool {
if _, ok := cliCache.Get(machineId); ok {
return true
}
return false
}
// 删除指定机器客户端,并关闭客户端连接
// 删除指定机器缓存客户端,并关闭客户端连接
func DeleteCli(id uint64) {
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 (
"context"
"encoding/json"
"io"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/utils/conv"
"strings"
"time"
"unicode/utf8"
@@ -15,6 +17,8 @@ const (
Resize = 1
Data = 2
Ping = 3
MsgSplit = "|"
)
type TerminalSession struct {
@@ -58,6 +62,9 @@ func NewTerminalSession(sessionId string, ws *websocket.Conn, cli *Cli, rows, co
dataChan: make(chan rune),
tick: tick,
}
// 清除终端内容
WriteMessage(ws, "\033[2J\033[3J\033[1;1H")
return ts, nil
}
@@ -155,10 +162,13 @@ func (ts *TerminalSession) receiveWsMsg() {
return
}
// 解析消息
msgObj := WsMsg{}
if err := json.Unmarshal(wsData, &msgObj); err != nil {
msgObj, err := parseMsg(wsData)
if err != nil {
WriteMessage(wsConn, "\r\n\033[1;31m提示: 消息内容解析失败...\033[0m")
logx.Error("机器ssh终端消息解析失败: ", err)
return
}
switch msgObj.Type {
case Resize:
if msgObj.Cols > 0 && msgObj.Rows > 0 {
@@ -185,3 +195,27 @@ func (ts *TerminalSession) receiveWsMsg() {
func WriteMessage(ws *websocket.Conn, msg string) error {
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/close-cli", m.CloseCli).Log(req.NewLogSave("关闭机器客户端")).RequiredPermissionCode("machine:close-cli"),
// 获取机器终端回放记录列表,目前具有保存机器信息的权限标识才有权限查看终端回放
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(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(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(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);