21 Commits

Author SHA1 Message Date
meilin.huang
98a4c92576 feat: redis支持sentinel 2022-08-23 18:50:07 +08:00
meilin.huang
b1ee9b65ff fix: 小问题优化 2022-08-21 21:00:28 +08:00
meilin.huang
99cc4c5e5e fix: script type调整 2022-08-19 22:00:37 +08:00
meilin.huang
226bb8f089 fix: 终端断连提示 2022-08-19 21:42:26 +08:00
meilin.huang
37ed5134e8 feat: 机器脚本入参支持选择框 2022-08-15 20:14:02 +08:00
meilin.huang
0f54d4a472 refactor: code rewiew&功能小优化 2022-08-13 19:31:16 +08:00
Coder慌
64805360d6 update README.md. 2022-08-11 05:52:35 +00:00
Coder慌
7f69fe2ad9 update README.md. 2022-08-11 02:58:06 +00:00
meilin.huang
f913510d3c refactor: code review 2022-08-10 19:46:17 +08:00
meilin.huang
f2d9e7786d refactor: redis hash类型使用hscan获取数据 2022-08-05 21:41:21 +08:00
meilin.huang
e1afb1ed54 fix: sql脚本默认账号密码调整&终端默认配色调整 2022-08-04 20:47:13 +08:00
meilin.huang
12f8cf0111 feat: 资源密码加密处理&登录密码加密加强等 2022-08-02 21:44:01 +08:00
meilin.huang
daa2ef5203 feat: 数据库支持选中数据生成insert语句 2022-07-27 15:36:56 +08:00
meilin.huang
1e3e183930 feat: 优化机器脚本添加参数的前端交互 2022-07-26 18:32:45 +08:00
meilin.huang
366563a0fe fix: sql文件字段名调整 2022-07-24 18:54:23 +08:00
meilin.huang
577802e5ad fix: 定时任务问题修复 2022-07-24 15:37:13 +08:00
meilin.huang
76d6fc3ba5 feat: linux支持ssh隧道访问&其他优化 2022-07-23 16:41:04 +08:00
meilin.huang
f0540559bb feat: 数据库、redis、mongo支持ssh隧道等 2022-07-20 23:25:52 +08:00
Coder慌
802e379f60 !8 feat: 新增mysql ssh代理连接方式
Merge pull request !8 from das/N/A
2022-07-20 03:13:29 +00:00
大圣之家
8c9253da80 feat: 新增mysql ssh代理连接方式 2022-07-20 01:37:25 +00:00
meilin.huang
5271bd21e8 feat: 登录强制校验弱密码&关键信息加密传输 2022-07-18 20:36:31 +08:00
104 changed files with 5138 additions and 2747 deletions

View File

@@ -1,6 +1,6 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

View File

@@ -1,36 +0,0 @@
# mayfly-go
#### Description
golang实现linux运维等
#### Software Architecture
Software architecture description
#### Installation
1. xxxx
2. xxxx
3. xxxx
#### Instructions
1. xxxx
2. xxxx
3. xxxx
#### Contribution
1. Fork the repository
2. Create Feat_xxx branch
3. Commit your code
4. Create Pull Request
#### Gitee Feature
1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md
2. Gitee blog [blog.gitee.com](https://blog.gitee.com)
3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore)
4. The most valuable open source project [GVP](https://gitee.com/gvp)
5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help)
6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)

View File

@@ -1,7 +1,25 @@
# 🌈mayfly-go
<p align="center">
<a href="https://gitee.com/objs/mayfly-go" target="_blank">
<img src="https://gitee.com/objs/mayfly-go/badge/star.svg?theme=white" alt="star"/>
<img src="https://gitee.com/objs/mayfly-go/badge/fork.svg" alt="fork"/>
</a>
<a href="https://github.com/may-fly/mayfly-go" target="_blank">
<img src="https://img.shields.io/github/stars/may-fly/mayfly-go.svg?style=social" alt="github star"/>
<img src="https://img.shields.io/github/forks/may-fly/mayfly-go.svg?style=social" alt="github fork"/>
</a>
<a href="https://github.com/golang/go" target="_blank">
<img src="https://img.shields.io/badge/Golang-1.18%2B-yellow.svg" alt="golang"/>
</a>
<a href="https://cn.vuejs.org" target="_blank">
<img src="https://img.shields.io/badge/Vue-3.x-green.svg" alt="vue">
</a>
</p>
### 介绍
简单基于DDD(领域驱动设计)分层架构实现的web版 **linux、数据库mysql postgres、redis(单机 集群)、mongo统一管理操作平台**
基于DDD分层实现的web版 **linux(终端 文件 脚本 进程)、数据库mysql postgres、redis(单机 哨兵 集群)、mongo统一管理操作平台**
### 开发语言与主要框架
@@ -10,7 +28,7 @@
### 交流及问题反馈加 QQ 群
<a target="_blank" href="https://qm.qq.com/cgi-bin/qm/qr?jump_from=webapi">119699946</a>
<a target="_blank" href="https://qm.qq.com/cgi-bin/qm/qr?k=IdJSHW0jTMhmWFHBUS9a83wxtrxDDhFj&jump_from=webapi">119699946</a>
### 系统相关资料

View File

@@ -18,8 +18,7 @@
</head>
<body>
<div id="app"></div>
<script type="text/javascript" src="./config.js"></script>
<script type="application/javascript" src="./config.js"></script>
<script type="module" src="/src/main.ts"></script>
<!-- <script type="text/javascript" src="https://api.map.baidu.com/api?v=3.0&ak=wsijQt8sLXrCW71YesmispvYHitfG9gv&s=1"></script> -->
</body>
</html>

View File

@@ -15,6 +15,7 @@
"cropperjs": "^1.5.11",
"echarts": "^5.3.3",
"element-plus": "^2.2.9",
"jsencrypt": "^3.2.1",
"jsoneditor": "^9.9.0",
"lodash": "^4.17.21",
"mitt": "^3.0.0",
@@ -24,7 +25,7 @@
"sql-formatter": "^7.0.3",
"vue": "^3.2.37",
"vue-clipboard3": "^1.0.1",
"vue-router": "^4.0.16",
"vue-router": "^4.1.2",
"vuex": "^4.0.2",
"xterm": "^4.19.0",
"xterm-addon-fit": "^0.5.0"
@@ -1880,6 +1881,11 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsencrypt": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/jsencrypt/-/jsencrypt-3.2.1.tgz",
"integrity": "sha512-k1sD5QV0KPn+D8uG9AdGzTQuamt82QZ3A3l6f7TRwMU6Oi2Vg0BsL+wZIQBONcraO1pc78ExMdvmBBJ8WhNYUA=="
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npm.taobao.org/json-schema-traverse/download/json-schema-traverse-0.4.1.tgz",
@@ -2931,15 +2937,11 @@
}
},
"node_modules/vue-router": {
"version": "4.0.16",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.0.16.tgz",
"integrity": "sha512-JcO7cb8QJLBWE+DfxGUL3xUDOae/8nhM1KVdnudadTAORbuxIC/xAydC5Zr/VLHUDQi1ppuTF5/rjBGzgzrJNA==",
"license": "MIT",
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.1.2.tgz",
"integrity": "sha512-5BP1qXFncVRwgV/XnqzsKApdMjQPqWIpoUBdL1ynz8HyLxIX/UDAx7Ql2BjmA5CXT/p61JfZvkpiFWFpaqcfag==",
"dependencies": {
"@vue/devtools-api": "^6.0.0"
},
"funding": {
"url": "https://github.com/sponsors/posva"
"@vue/devtools-api": "^6.1.4"
},
"peerDependencies": {
"vue": "^3.2.0"
@@ -4358,6 +4360,11 @@
"argparse": "^2.0.1"
}
},
"jsencrypt": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/jsencrypt/-/jsencrypt-3.2.1.tgz",
"integrity": "sha512-k1sD5QV0KPn+D8uG9AdGzTQuamt82QZ3A3l6f7TRwMU6Oi2Vg0BsL+wZIQBONcraO1pc78ExMdvmBBJ8WhNYUA=="
},
"json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npm.taobao.org/json-schema-traverse/download/json-schema-traverse-0.4.1.tgz",
@@ -5044,11 +5051,11 @@
}
},
"vue-router": {
"version": "4.0.16",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.0.16.tgz",
"integrity": "sha512-JcO7cb8QJLBWE+DfxGUL3xUDOae/8nhM1KVdnudadTAORbuxIC/xAydC5Zr/VLHUDQi1ppuTF5/rjBGzgzrJNA==",
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.1.2.tgz",
"integrity": "sha512-5BP1qXFncVRwgV/XnqzsKApdMjQPqWIpoUBdL1ynz8HyLxIX/UDAx7Ql2BjmA5CXT/p61JfZvkpiFWFpaqcfag==",
"requires": {
"@vue/devtools-api": "^6.0.0"
"@vue/devtools-api": "^6.1.4"
}
},
"vuex": {

View File

@@ -7,23 +7,24 @@
"lint-fix": "eslint --fix --ext .js --ext .jsx --ext .vue src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.0.6",
"@element-plus/icons-vue": "^2.0.9",
"axios": "^0.27.2",
"codemirror": "^5.65.5",
"countup.js": "^2.0.7",
"cropperjs": "^1.5.11",
"echarts": "^5.3.3",
"element-plus": "^2.2.9",
"element-plus": "^2.2.14",
"jsencrypt": "^3.2.1",
"jsoneditor": "^9.9.0",
"lodash": "^4.17.21",
"mitt": "^3.0.0",
"nprogress": "^0.2.0",
"screenfull": "^5.1.0",
"screenfull": "^6.0.2",
"sortablejs": "^1.13.0",
"sql-formatter": "^7.0.3",
"sql-formatter": "^9.2.0",
"vue": "^3.2.37",
"vue-clipboard3": "^1.0.1",
"vue-router": "^4.0.16",
"vue-router": "^4.1.2",
"vuex": "^4.0.2",
"xterm": "^4.19.0",
"xterm-addon-fit": "^0.5.0"

View File

@@ -2,6 +2,8 @@ import request from './request'
export default {
login: (param: any) => request.request('POST', '/sys/accounts/login', param, null),
changePwd: (param: any) => request.request('POST', '/sys/accounts/change-pwd', param, null),
getPublicKey: () => request.request('GET', '/common/public-key', null, null),
captcha: () => request.request('GET', '/sys/captcha', null, null),
logout: (param: any) => request.request('POST', '/sys/accounts/logout/{token}', param, null),
getMenuRoute: (param: any) => request.request('Get', '/sys/resources/account', param, null)

View File

@@ -0,0 +1,36 @@
import openApi from './openApi';
import JSEncrypt from 'jsencrypt'
import { notBlank } from './assert';
var encryptor: any = null
export async function getRsaPublicKey() {
let publicKey = sessionStorage.getItem('RsaPublicKey')
if (publicKey) {
return publicKey
}
publicKey = await openApi.getPublicKey() as string
sessionStorage.setItem('RsaPublicKey', publicKey)
return publicKey
}
/**
* 公钥加密指定值
*
* @param value value
* @returns 加密后的值
*/
export async function RsaEncrypt(value: any) {
// 不存在则返回空值
if (!value) {
return ""
}
if (encryptor != null) {
return encryptor.encrypt(value)
}
encryptor = new JSEncrypt()
const publicKey = await getRsaPublicKey() as string;
notBlank(publicKey, "获取公钥失败")
encryptor.setPublicKey(publicKey)//设置公钥
return encryptor.encrypt(value)
}

View File

@@ -52,6 +52,7 @@ export interface ThemeConfigState {
terminalBackground: string;
terminalCursor: string;
terminalFontSize: number;
terminalFontWeight: string;
};
}

View File

@@ -113,6 +113,7 @@ const themeConfigModule: Module<ThemeConfigState, RootStateTypes> = {
// ssh终端cursor色
terminalCursor: '#268F81',
terminalFontSize: 15,
terminalFontWeight: 'normal',
/* 后端控制路由

View File

@@ -947,12 +947,6 @@
.el-select-dropdown .el-scrollbar__wrap {
overflow-x: scroll !important;
}
.el-select-dropdown__wrap {
max-height: 274px !important; /*修复Select 选择器高度问题*/
}
.el-cascader-menu__wrap.el-scrollbar__wrap {
height: 204px !important; /*修复Cascader 级联选择器高度问题*/
}
/* Drawer 抽屉
------------------------------- */

View File

@@ -40,7 +40,7 @@
</el-input-number>
</div>
</div>
<!-- <div class="layout-breadcrumb-seting-bar-flex mt15">
<div class="layout-breadcrumb-seting-bar-flex mt15">
<div class="layout-breadcrumb-seting-bar-flex-label">字体粗细</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-select @change="setLocalThemeConfig" v-model="getThemeConfig.terminalFontWeight" size="small" style="width: 90px">
@@ -48,7 +48,7 @@
<el-option label="bold" value="bold"> </el-option>
</el-select>
</div>
</div> -->
</div>
<!-- 全局主题 -->
<el-divider content-position="left">全局主题</el-divider>

View File

@@ -14,11 +14,11 @@
</el-dropdown-menu>
</template>
</el-dropdown>
<div class="layout-navbars-breadcrumb-user-icon" @click="onSearchClick">
<!-- <div class="layout-navbars-breadcrumb-user-icon" @click="onSearchClick">
<el-icon title="菜单搜索">
<search />
</el-icon>
</div>
</div> -->
<div class="layout-navbars-breadcrumb-user-icon" @click="onLayoutSetingClick">
<el-icon title="布局设置">
<setting />
@@ -28,7 +28,7 @@
<el-popover
placement="bottom"
trigger="click"
v-model:visible="isShowUserNewsPopover"
:visible="isShowUserNewsPopover"
:width="300"
popper-class="el-popover-pupop-user-news"
>

View File

@@ -1,54 +1,75 @@
<template>
<el-form ref="loginFormRef" :model="loginForm" :rules="rules" class="login-content-form" size="large">
<el-form-item prop="username">
<el-input type="text" placeholder="请输入用户名" prefix-icon="user" v-model="loginForm.username" clearable autocomplete="off">
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
type="password"
placeholder="请输入密码"
prefix-icon="lock"
v-model="loginForm.password"
autocomplete="off"
show-password
>
</el-input>
</el-form-item>
<el-form-item prop="captcha">
<el-row :gutter="15">
<el-col :span="16">
<div>
<el-form ref="loginFormRef" :model="loginForm" :rules="rules" class="login-content-form" size="large">
<el-form-item prop="username">
<el-input type="text" placeholder="请输入用户名" prefix-icon="user" v-model="loginForm.username" clearable autocomplete="off">
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input type="password" placeholder="请输入密码" prefix-icon="lock" v-model="loginForm.password" autocomplete="off" show-password>
</el-input>
</el-form-item>
<el-form-item prop="captcha">
<el-row :gutter="15">
<el-col :span="16">
<el-input
type="text"
maxlength="6"
placeholder="请输入验证码"
prefix-icon="position"
v-model="loginForm.captcha"
clearable
autocomplete="off"
@keyup.enter="login"
></el-input>
</el-col>
<el-col :span="8">
<div class="login-content-code">
<img
class="login-content-code-img"
@click="getCaptcha"
width="130px"
height="40px"
:src="captchaImage"
style="cursor: pointer"
/>
</div>
</el-col>
</el-row>
</el-form-item>
<el-form-item>
<el-button type="primary" class="login-content-submit" round @click="login" :loading="loading.signIn">
<span> </span>
</el-button>
</el-form-item>
</el-form>
<el-dialog title="修改密码" v-model="changePwdDialog.visible" :close-on-click-modal="false" width="450px" :destroy-on-close="true">
<el-form :model="changePwdDialog.form" :rules="changePwdDialog.rules" ref="changePwdFormRef" label-width="65px">
<el-form-item prop="username" label="用户名" required>
<el-input v-model.trim="changePwdDialog.form.username" disabled></el-input>
</el-form-item>
<el-form-item prop="oldPassword" label="旧密码" required>
<el-input v-model.trim="changePwdDialog.form.oldPassword" autocomplete="new-password" type="password"></el-input>
</el-form-item>
<el-form-item prop="newPassword" label="新密码" required>
<el-input
type="text"
maxlength="6"
placeholder="请输入验证码"
prefix-icon="position"
v-model="loginForm.captcha"
clearable
autocomplete="off"
@keyup.enter="login"
v-model.trim="changePwdDialog.form.newPassword"
placeholder="须为8位以上且包含字⺟⼤⼩写+数字+特殊符号"
type="password"
autocomplete="new-password"
></el-input>
</el-col>
<el-col :span="8">
<div class="login-content-code">
<img
class="login-content-code-img"
@click="getCaptcha"
width="130px"
height="40px"
:src="captchaImage"
style="cursor: pointer"
/>
</div>
</el-col>
</el-row>
</el-form-item>
<el-form-item>
<el-button type="primary" class="login-content-submit" round @click="login" :loading="loading.signIn">
<span> </span>
</el-button>
</el-form-item>
</el-form>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelChangePwd"> </el-button>
<el-button @click="changePwd" type="primary" :loading="loading.changePwd"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
@@ -60,7 +81,9 @@ import { useStore } from '@/store/index.ts';
import { setSession } from '@/common/utils/storage.ts';
import { formatAxis } from '@/common/utils/formatTime.ts';
import openApi from '@/common/openApi';
import { RsaEncrypt } from '@/common/rsa';
import { letterAvatar } from '@/common/utils/string';
export default defineComponent({
name: 'AccountLogin',
setup() {
@@ -68,6 +91,8 @@ export default defineComponent({
const route = useRoute();
const router = useRouter();
const loginFormRef: any = ref(null);
const changePwdFormRef: any = ref(null);
const state = reactive({
captchaImage: '',
loginForm: {
@@ -76,6 +101,24 @@ export default defineComponent({
captcha: '',
cid: '',
},
changePwdDialog: {
visible: false,
form: {
username: '',
oldPassword: '',
newPassword: '',
},
rules: {
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{
pattern: /^(?=.*[A-Za-z])(?=.*\d)(?=.*[`~!@#$%^&*()_+<>?:"{},.\/\\;'[\]])[A-Za-z\d`~!@#$%^&*()_+<>?:"{},.\/\\;'[\]]{8,}$/,
message: '须为8位以上且包含字⺟⼤⼩写+数字+特殊符号',
trigger: 'blur',
},
],
},
},
rules: {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
@@ -83,10 +126,13 @@ export default defineComponent({
},
loading: {
signIn: false,
changePwd: false,
},
});
onMounted(() => {
// 移除公钥, 方便后续重新获取
sessionStorage.removeItem('RsaPublicKey')
getCaptcha();
});
@@ -116,15 +162,26 @@ export default defineComponent({
const onSignIn = async () => {
state.loading.signIn = true;
let loginRes;
const originPwd = state.loginForm.password;
try {
loginRes = await openApi.login(state.loginForm);
// // 存储 token 到浏览器缓存
const loginReq = { ...state.loginForm };
loginReq.password = await RsaEncrypt(originPwd);
loginRes = await openApi.login(loginReq);
// 存储 token 到浏览器缓存
setSession('token', loginRes.token);
setSession('menus', loginRes.menus);
} catch (e) {
} catch (e: any) {
state.loading.signIn = false;
state.loginForm.captcha = '';
getCaptcha();
// 密码强度不足
if (e.code && e.code == 401) {
state.changePwdDialog.form.username = state.loginForm.username;
state.changePwdDialog.form.oldPassword = originPwd;
state.changePwdDialog.form.newPassword = '';
state.changePwdDialog.visible = true;
} else {
getCaptcha();
}
return;
}
// 用户信息
@@ -174,11 +231,44 @@ export default defineComponent({
}, 300);
};
const changePwd = () => {
changePwdFormRef.value.validate(async (valid: boolean) => {
if (!valid) {
return false;
}
try {
state.loading.changePwd = true;
const form = state.changePwdDialog.form;
const changePwdReq: any = { ...form };
changePwdReq.oldPassword = await RsaEncrypt(form.oldPassword);
changePwdReq.newPassword = await RsaEncrypt(form.newPassword);
await openApi.changePwd(changePwdReq);
ElMessage.success('密码修改成功, 新密码已填充至登录密码框');
state.loginForm.password = state.changePwdDialog.form.newPassword;
state.changePwdDialog.visible = false;
getCaptcha();
} finally {
state.loading.changePwd = false;
}
});
};
const cancelChangePwd = () => {
state.changePwdDialog.visible = false;
state.changePwdDialog.form.newPassword = '';
state.changePwdDialog.form.oldPassword = '';
state.changePwdDialog.form.username = '';
getCaptcha();
};
return {
getCaptcha,
currentTime,
loginFormRef,
changePwdFormRef,
login,
changePwd,
cancelChangePwd,
...toRefs(state),
};
},

View File

@@ -23,10 +23,13 @@
</el-select>
</el-form-item>
<el-form-item prop="host" label="host:" required>
<el-input v-model.trim="form.host" placeholder="请输入主机ip" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="port" label="port:" required>
<el-input type="number" v-model.trim="form.port" placeholder="请输入端口"></el-input>
<el-col :span="18">
<el-input v-model.trim="form.host" placeholder="请输入主机ip" auto-complete="off"></el-input>
</el-col>
<el-col style="text-align: center" :span="1">:</el-col>
<el-col :span="5">
<el-input type="number" v-model.number="form.port" placeholder="请输入端口"></el-input>
</el-col>
</el-form-item>
<el-form-item prop="username" label="用户名:" required>
<el-input v-model.trim="form.username" placeholder="请输入用户名"></el-input>
@@ -38,34 +41,57 @@
v-model.trim="form.password"
placeholder="请输入密码,修改操作可不填"
autocomplete="new-password"
></el-input>
>
<template v-if="form.id && form.id != 0" #suffix>
<el-popover @hide="pwd = ''" placement="right" title="原密码" :width="200" trigger="click" :content="pwd">
<template #reference>
<el-link @click="getDbPwd" :underline="false" type="primary" class="mr5">原密码</el-link>
</template>
</el-popover>
</template>
</el-input>
</el-form-item>
<el-form-item prop="params" label="连接参数:">
<el-input v-model="form.params" placeholder="其他连接参数,形如: key1=value1&key2=value2"></el-input>
</el-form-item>
<el-form-item prop="database" label="数据库名:" required>
<el-tag
v-for="db in databaseList"
:key="db"
class="ml5 mt5"
type="success"
effect="plain"
closable
:disable-transitions="false"
@close="handleClose(db)"
>
{{ db }}
</el-tag>
<el-input
v-if="inputDbVisible"
ref="InputDbRef"
v-model="inputDbValue"
style="width: 120px; margin-left: 5px; margin-top: 5px"
size="small"
@keyup.enter="handleInputDbConfirm"
@blur="handleInputDbConfirm"
/>
<el-button v-else class="ml5 mt5" size="small" @click="showInputDb"> + 添加数据库 </el-button>
<el-col :span="19">
<el-select
@change="changeDatabase"
v-model="databaseList"
multiple
collapse-tags
collapse-tags-tooltip
filterable
allow-create
placeholder="请确保数据库实例信息填写完整后获取库名"
style="width: 100%"
>
<el-option v-for="db in allDatabases" :key="db" :label="db" :value="db" />
</el-select>
</el-col>
<el-col style="text-align: center" :span="1"><el-divider direction="vertical" border-style="dashed" /></el-col>
<el-col :span="4">
<el-link @click="getAllDatabase" :underline="false" type="success">获取库名</el-link>
</el-col>
</el-form-item>
<el-form-item prop="enableSshTunnel" label="SSH隧道:">
<el-col :span="3">
<el-checkbox @change="getSshTunnelMachines" v-model="form.enableSshTunnel" :true-label="1" :false-label="-1"></el-checkbox>
</el-col>
<el-col :span="2" v-if="form.enableSshTunnel == 1"> 机器: </el-col>
<el-col :span="19" v-if="form.enableSshTunnel == 1">
<el-select style="width: 100%" v-model="form.sshTunnelMachineId" placeholder="请选择SSH隧道机器">
<el-option
v-for="item in sshTunnelMachineList"
:key="item.id"
:label="`${item.ip}:${item.port} [${item.name}]`"
:value="item.id"
>
</el-option>
</el-select>
</el-col>
</el-form-item>
</el-form>
@@ -80,12 +106,13 @@
</template>
<script lang="ts">
import { toRefs, reactive, nextTick, watch, defineComponent, ref } from 'vue';
import { toRefs, reactive, watch, defineComponent, ref } from 'vue';
import { dbApi } from './api';
import { projectApi } from '../project/api.ts';
import { machineApi } from '../machine/api.ts';
import { ElMessage } from 'element-plus';
import type { ElInput } from 'element-plus';
import { notBlank } from '@/common/assert';
import { RsaEncrypt } from '@/common/rsa';
export default defineComponent({
name: 'DbEdit',
@@ -105,15 +132,14 @@ export default defineComponent({
},
setup(props: any, { emit }) {
const dbForm: any = ref(null);
const InputDbRef = ref<InstanceType<typeof ElInput>>();
const state = reactive({
dialogVisible: false,
projects: [],
envs: [],
allDatabases: [] as any,
databaseList: [] as any,
inputDbVisible: false,
inputDbValue: '',
sshTunnelMachineList: [],
form: {
id: null,
name: null,
@@ -126,7 +152,11 @@ export default defineComponent({
projectId: null,
envId: null,
env: null,
enableSshTunnel: null,
sshTunnelMachineId: null,
},
// 原密码
pwd: '',
btnLoading: false,
rules: {
projectId: [
@@ -160,14 +190,7 @@ export default defineComponent({
host: [
{
required: true,
message: '请输入主机ip',
trigger: ['change', 'blur'],
},
],
port: [
{
required: true,
message: '请输入端口',
message: '请输入主机ip和port',
trigger: ['change', 'blur'],
},
],
@@ -189,6 +212,10 @@ export default defineComponent({
});
watch(props, (newValue) => {
state.dialogVisible = newValue.visible;
if (!state.dialogVisible) {
return;
}
state.projects = newValue.projects;
if (newValue.db) {
getEnvs(newValue.db.projectId);
@@ -197,33 +224,12 @@ export default defineComponent({
state.databaseList = newValue.db.database.split(' ');
} else {
state.envs = [];
state.form = { port: 3306 } as any;
state.form = { port: 3306, enableSshTunnel: -1 } as any;
state.databaseList = [];
}
state.dialogVisible = newValue.visible;
getSshTunnelMachines();
});
const handleClose = (db: string) => {
state.databaseList.splice(state.databaseList.indexOf(db), 1);
changeDatabase();
};
const showInputDb = () => {
state.inputDbVisible = true;
nextTick(() => {
InputDbRef.value!.input!.focus();
});
};
const handleInputDbConfirm = () => {
if (state.inputDbValue) {
state.databaseList.push(state.inputDbValue);
changeDatabase();
}
state.inputDbVisible = false;
state.inputDbValue = '';
};
/**
* 改变表单中的数据库字段,方便表单错误提示。如全部删光,可提示请添加数据库
*/
@@ -231,6 +237,13 @@ export default defineComponent({
state.form.database = state.databaseList.length == 0 ? '' : state.databaseList.join(' ');
};
const getSshTunnelMachines = async () => {
if (state.form.enableSshTunnel == 1 && state.sshTunnelMachineList.length == 0) {
const res = await machineApi.list.request({ pageNum: 1, pageSize: 100 });
state.sshTunnelMachineList = res.list;
}
};
const getEnvs = async (projectId: any) => {
state.envs = await projectApi.projectEnvs.request({ projectId });
};
@@ -255,14 +268,26 @@ export default defineComponent({
}
};
const getAllDatabase = async () => {
const reqForm = { ...state.form };
reqForm.password = await RsaEncrypt(reqForm.password);
state.allDatabases = await dbApi.getAllDatabase.request(reqForm);
ElMessage.success('获取成功, 请选择需要管理操作的数据库')
};
const getDbPwd = async () => {
state.pwd = await dbApi.getDbPwd.request({ id: state.form.id });
};
const btnOk = async () => {
if (!state.form.id) {
notBlank(state.form.password, '新增操作,密码不可为空');
}
dbForm.value.validate((valid: boolean) => {
dbForm.value.validate(async (valid: boolean) => {
if (valid) {
state.form.port = Number.parseInt(state.form.port as any);
dbApi.saveDb.request(state.form).then(() => {
const reqForm = { ...state.form };
reqForm.password = await RsaEncrypt(reqForm.password);
dbApi.saveDb.request(reqForm).then(() => {
ElMessage.success('保存成功');
emit('val-change', state.form);
state.btnLoading = true;
@@ -280,9 +305,8 @@ export default defineComponent({
};
const resetInputDb = () => {
state.inputDbVisible = false;
state.databaseList = [];
state.inputDbValue = '';
state.allDatabases = [];
};
const cancel = () => {
@@ -296,10 +320,10 @@ export default defineComponent({
return {
...toRefs(state),
dbForm,
InputDbRef,
handleClose,
showInputDb,
handleInputDbConfirm,
getAllDatabase,
getDbPwd,
changeDatabase,
getSshTunnelMachines,
changeProject,
changeEnv,
btnOk,

View File

@@ -46,7 +46,7 @@
<el-table-column prop="username" label="用户名" min-width="100"></el-table-column>
<el-table-column min-width="115" prop="creator" label="创建账号"></el-table-column>
<el-table-column min-width="160" prop="createTime" label="创建时间">
<el-table-column min-width="160" prop="createTime" label="创建时间" show-overflow-tooltip>
<template #default="scope">
{{ $filters.dateFormat(scope.row.createTime) }}
</template>
@@ -100,9 +100,17 @@
<el-button type="primary" size="small" @click="tableCreateDialog.visible = true">创建表</el-button>
</el-row>
<el-table v-loading="tableInfoDialog.loading" border stripe :data="tableInfoDialog.infos" size="small">
<el-table-column property="tableName" label="表名" min-width="150" show-overflow-tooltip></el-table-column>
<el-table-column property="tableComment" label="备注" min-width="150" show-overflow-tooltip></el-table-column>
<el-table v-loading="tableInfoDialog.loading" border stripe :data="filterTableInfos" size="small">
<el-table-column property="tableName" label="表名" min-width="150" show-overflow-tooltip>
<template #header>
<el-input v-model="tableInfoDialog.tableNameSearch" size="small" placeholder="表名: 输入可过滤" clearable />
</template>
</el-table-column>
<el-table-column property="tableComment" label="备注" min-width="150" show-overflow-tooltip>
<template #header>
<el-input v-model="tableInfoDialog.tableCommentSearch" size="small" placeholder="备注: 输入可过滤" clearable />
</template>
</el-table-column>
<el-table-column
prop="tableRows"
label="Rows"
@@ -244,7 +252,7 @@
</template>
<script lang='ts'>
import { toRefs, reactive, onMounted, defineComponent } from 'vue';
import { toRefs, reactive, computed, onMounted, defineComponent } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatByteSize } from '@/common/utils/format';
import DbEdit from './DbEdit.vue';
@@ -317,6 +325,8 @@ export default defineComponent({
loading: false,
visible: false,
infos: [],
tableNameSearch: '',
tableCommentSearch: '',
},
columnDialog: {
visible: false,
@@ -345,6 +355,26 @@ export default defineComponent({
state.projects = await projectApi.accountProjects.request(null);
});
const filterTableInfos = computed(() => {
const infos = state.tableInfoDialog.infos;
const tableNameSearch = state.tableInfoDialog.tableNameSearch;
const tableCommentSearch = state.tableInfoDialog.tableCommentSearch;
if (!tableNameSearch && !tableCommentSearch) {
return infos;
}
return infos.filter((data: any) => {
let tnMatch = true;
let tcMatch = true;
if (tableNameSearch) {
tnMatch = data.tableName.toLowerCase().includes(tableNameSearch.toLowerCase());
}
if (tableCommentSearch) {
tcMatch = data.tableComment.includes(tableCommentSearch);
}
return tnMatch && tcMatch;
});
});
const choose = (item: any) => {
if (!item) {
return;
@@ -502,6 +532,8 @@ export default defineComponent({
state.tableInfoDialog.infos = await dbApi.tableInfos.request({ id: row.id, db });
state.dbId = row.id;
state.db = db;
} catch (e) {
state.tableInfoDialog.visible = false;
} finally {
state.tableInfoDialog.loading = false;
}
@@ -570,6 +602,7 @@ export default defineComponent({
return {
...toRefs(state),
filterTableInfos,
enums,
search,
choose,

View File

@@ -152,6 +152,10 @@
<el-tooltip class="box-item" effect="dark" content="commit" placement="top">
<el-link @click="onCommit" class="ml5" type="success" icon="check" :underline="false"></el-link>
</el-tooltip>
<el-tooltip class="box-item" effect="dark" content="生成insert sql" placement="top">
<el-link @click="onGenerateInsertSql" type="success" class="ml20" :underline="false">gi</el-link>
</el-tooltip>
</el-row>
<el-row class="mt5">
<el-input
@@ -161,9 +165,14 @@
size="small"
>
<template #prepend>
<el-popover trigger="click" :width="270" placement="right">
<el-popover :visible="dt.selectColumnPopoverVisible" :width="320" placement="right">
<template #reference>
<el-link type="success" :underline="false">选择列</el-link>
<el-link
@click="dt.selectColumnPopoverVisible = !dt.selectColumnPopoverVisible"
type="success"
:underline="false"
>选择列</el-link
>
</template>
<el-table
:data="getColumns4Map(dt.name)"
@@ -174,6 +183,7 @@
onConditionRowClick(event, dt);
}
"
style="cursor: pointer"
>
<el-table-column property="columnName" label="列名" show-overflow-tooltip> </el-table-column>
<el-table-column property="columnComment" label="备注" show-overflow-tooltip> </el-table-column>
@@ -233,6 +243,34 @@
</el-tab-pane>
</el-tabs>
</el-container>
<el-dialog v-model="conditionDialog.visible" :title="conditionDialog.title" width="420px">
<el-row>
<el-col :span="5">
<el-select v-model="conditionDialog.condition">
<el-option label="=" value="="> </el-option>
<el-option label="LIKE" value="LIKE"> </el-option>
<el-option label=">" value=">"> </el-option>
<el-option label=">=" value=">="> </el-option>
<el-option label="<" value="<"> </el-option>
<el-option label="<=" value="<="> </el-option>
</el-select>
</el-col>
<el-col :span="19">
<el-input v-model="conditionDialog.value" :placeholder="conditionDialog.placeholder" />
</el-col>
</el-row>
<template #footer>
<span class="dialog-footer">
<el-button @click="onCancelCondition">取消</el-button>
<el-button type="primary" @click="onConfirmCondition">确定</el-button>
</span>
</template>
</el-dialog>
<el-dialog @close="genSqlDialog.visible = false" v-model="genSqlDialog.visible" title="SQL" width="1000px">
<el-input v-model="genSqlDialog.sql" type="textarea" rows="20" />
</el-dialog>
</div>
</template>
@@ -313,6 +351,20 @@ export default defineComponent({
left: '',
top: '',
},
selectColumnPopoverVisible: false,
conditionDialog: {
title: '',
placeholder: '',
columnRow: null,
dataTab: null,
visible: false,
condition: '=',
value: null,
},
genSqlDialog: {
visible: false,
sql: '',
},
cmOptions: {
tabSize: 4,
mode: 'text/x-sql',
@@ -677,6 +729,7 @@ export default defineComponent({
columnNames: [],
pageNum: 1,
count: 0,
selectColumnPopoverVisible: false,
};
tab.columnNames = await getColumnNames(tableName);
state.dataTabs[tableName] = tab;
@@ -716,24 +769,36 @@ export default defineComponent({
* 条件查询,点击列信息后显示输入对应的值
*/
const onConditionRowClick = (event: any, dataTab: any) => {
dataTab.selectColumnPopoverVisible = false;
const row = event[0];
ElMessageBox.prompt(`请输入 [${row.columnName}] 的值`, '查询条件', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPlaceholder: `${row.columnType} ${row.columnComment}`,
})
.then(({ value }) => {
if (!value) {
value = '';
}
let condition = dataTab.condition;
if (condition) {
condition += ` AND `;
}
condition += `${row.columnName} = `;
dataTab.condition = condition + wrapColumnValue(row, value);
})
.catch(() => {});
state.conditionDialog.title = `请输入 [${row.columnName}] 的值`;
state.conditionDialog.placeholder = `${row.columnType} ${row.columnComment}`;
state.conditionDialog.columnRow = row;
state.conditionDialog.dataTab = dataTab;
state.conditionDialog.visible = true;
};
// 确认条件
const onConfirmCondition = () => {
const conditionDialog = state.conditionDialog;
const dataTab = state.conditionDialog.dataTab as any;
let condition = dataTab.condition;
if (condition) {
condition += ` AND `;
}
const row = conditionDialog.columnRow as any;
condition += `${row.columnName} ${conditionDialog.condition} `;
dataTab.condition = condition + wrapColumnValue(row, conditionDialog.value);
onCancelCondition();
};
const onCancelCondition = () => {
state.conditionDialog.visible = false;
state.conditionDialog.title = ``;
state.conditionDialog.placeholder = ``;
state.conditionDialog.value = null;
state.conditionDialog.columnRow = null;
state.conditionDialog.dataTab = null;
};
const onRefresh = async (tableName: string) => {
@@ -793,10 +858,10 @@ export default defineComponent({
const getDefaultSelectSql = (tableName: string, where: string = '', orderBy: string = '', pageNum: number = 1) => {
const baseSql = `SELECT * FROM ${tableName} ${where ? 'WHERE ' + where : ''} ${orderBy ? orderBy : ''}`;
if (state.dbType == 'mysql') {
return `${baseSql} LIMIT ${(pageNum - 1) * state.defalutLimit}, ${state.defalutLimit};`
return `${baseSql} LIMIT ${(pageNum - 1) * state.defalutLimit}, ${state.defalutLimit};`;
}
if (state.dbType == 'postgres') {
return `${baseSql} OFFSET ${(pageNum - 1) * state.defalutLimit} LIMIT ${state.defalutLimit};`
return `${baseSql} OFFSET ${(pageNum - 1) * state.defalutLimit} LIMIT ${state.defalutLimit};`;
}
return baseSql;
};
@@ -963,6 +1028,38 @@ export default defineComponent({
});
};
const onGenerateInsertSql = async () => {
const queryTab = isQueryTab();
const datas = queryTab ? state.queryTab.selectionDatas : state.dataTabs[state.activeName].selectionDatas;
isTrue(datas && datas.length > 0, '请先选择要生成insert语句的数据');
const tableName = state.nowTableName;
const columns: any = await getColumns(tableName);
const sqls = [];
for (let data of datas) {
let colNames = [];
let values = [];
for (let column of columns) {
const colName = column.columnName;
colNames.push(colName);
values.push(wrapValueByType(data[colName]));
}
sqls.push(`INSERT INTO ${tableName} (${colNames.join(', ')}) VALUES(${values.join(', ')})`);
}
state.genSqlDialog.sql = sqls.join(';\n') + ';';
state.genSqlDialog.visible = true;
};
const wrapValueByType = (val: any) => {
if (val == null) {
return 'NULL';
}
if (typeof val == 'number') {
return val;
}
return `'${val}'`;
};
/**
* 是否为查询tab
*/
@@ -1121,6 +1218,8 @@ export default defineComponent({
getColumnTip,
getColumns4Map,
onConditionRowClick,
onConfirmCondition,
onCancelCondition,
changeSqlTemplate,
deleteSql,
saveSql,
@@ -1137,6 +1236,7 @@ export default defineComponent({
onDataSelectionChange,
onDeleteData,
onTableSortChange,
onGenerateInsertSql,
showExecBtns,
closeExecBtns,
};

View File

@@ -4,6 +4,8 @@ export const dbApi = {
// 获取权限列表
dbs: Api.create("/dbs", 'get'),
saveDb: Api.create("/dbs", 'post'),
getAllDatabase: Api.create("/dbs/databases", 'post'),
getDbPwd: Api.create("/dbs/{id}/pwd", 'get'),
deleteDb: Api.create("/dbs/{id}", 'delete'),
dumpDb: Api.create("/dbs/{id}/dump", 'post'),
tableInfos: Api.create("/dbs/{id}/t-infos", 'get'),

View File

@@ -57,7 +57,7 @@
</el-row>
</el-dialog>
<el-dialog :title="tree.title" v-model="tree.visible" :close-on-click-modal="false" width="680px">
<el-dialog :title="tree.title" v-model="tree.visible" :close-on-click-modal="false" width="50%">
<el-progress
v-if="uploadProgressShow"
style="width: 90%; margin-left: 20px"

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-dialog :title="title" v-model="dialogVisible" :close-on-click-modal="false" :destroy-on-close="true" :before-close="cancel" width="35%">
<el-dialog :title="title" v-model="dialogVisible" :close-on-click-modal="false" :destroy-on-close="true" :before-close="cancel" width="38%">
<el-form :model="form" ref="machineForm" :rules="rules" label-width="85px">
<el-form-item prop="projectId" label="项目:" required>
<el-select style="width: 100%" v-model="form.projectId" placeholder="请选择项目" @change="changeProject" filterable>
@@ -11,26 +11,64 @@
<el-input v-model.trim="form.name" placeholder="请输入机器别名" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="ip" label="ip:" required>
<el-input v-model.trim="form.ip" placeholder="请输入主机ip" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="port" label="port:" required>
<el-input type="number" v-model.number="form.port" placeholder="请输入端口"></el-input>
<el-col :span="18">
<el-input v-model.trim="form.ip" placeholder="主机ip" auto-complete="off"></el-input>
</el-col>
<el-col style="text-align: center" :span="1">:</el-col>
<el-col :span="5">
<el-input type="number" v-model.number="form.port" placeholder="端口"></el-input>
</el-col>
</el-form-item>
<el-form-item prop="username" label="用户名:" required>
<el-input v-model.trim="form.username" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item prop="password" label="密码:">
<el-form-item prop="authMethod" label="认证方式:" required>
<el-select style="width: 100%" v-model="form.authMethod" placeholder="请选择认证方式">
<el-option key="1" label="Password" :value="1"> </el-option>
<el-option key="2" label="PublicKey" :value="2"> </el-option>
</el-select>
</el-form-item>
<el-form-item v-if="form.authMethod == 1" prop="password" label="密码:">
<el-input
type="password"
show-password
v-model.trim="form.password"
placeholder="请输入密码,修改操作可不填"
autocomplete="new-password"
></el-input>
>
<template v-if="form.id && form.id != 0" #suffix>
<el-popover @hide="pwd = ''" placement="right" title="原密码" :width="200" trigger="click" :content="pwd">
<template #reference>
<el-link @click="getPwd" :underline="false" type="primary" class="mr5">原密码</el-link>
</template>
</el-popover>
</template>
</el-input>
</el-form-item>
<el-form-item v-if="form.authMethod == 2" prop="password" label="秘钥:">
<el-input type="textarea" :rows="3" v-model="form.password" placeholder="请将私钥文件内容拷贝至此,修改操作可不填"></el-input>
</el-form-item>
<el-form-item prop="remark" label="备注:">
<el-input type="textarea" v-model="form.remark"></el-input>
</el-form-item>
<el-form-item prop="enableSshTunnel" label="SSH隧道:">
<el-col :span="3">
<el-checkbox @change="getSshTunnelMachines" v-model="form.enableSshTunnel" :true-label="1" :false-label="-1"></el-checkbox>
</el-col>
<el-col :span="2" v-if="form.enableSshTunnel == 1"> 机器: </el-col>
<el-col :span="19" v-if="form.enableSshTunnel == 1">
<el-select style="width: 100%" v-model="form.sshTunnelMachineId" placeholder="请选择SSH隧道机器">
<el-option
v-for="item in sshTunnelMachineList"
:key="item.id"
:label="`${item.ip}:${item.port} [${item.name}]`"
:value="item.id"
>
</el-option>
</el-select>
</el-col>
</el-form-item>
</el-form>
<template #footer>
@@ -48,6 +86,7 @@ import { toRefs, reactive, watch, defineComponent, ref } from 'vue';
import { machineApi } from './api';
import { ElMessage } from 'element-plus';
import { notBlank } from '@/common/assert';
import { RsaEncrypt } from '@/common/rsa';
export default defineComponent({
name: 'MachineEdit',
@@ -70,16 +109,21 @@ export default defineComponent({
const state = reactive({
dialogVisible: false,
projects: [],
sshTunnelMachineList: [],
form: {
id: null,
projectId: null,
projectName: null,
name: null,
authMethod: 1,
port: 22,
username: null,
password: null,
username: '',
password: '',
remark: '',
enableSshTunnel: null,
sshTunnelMachineId: null,
},
pwd: '',
btnLoading: false,
rules: {
projectId: [
@@ -106,14 +150,7 @@ export default defineComponent({
ip: [
{
required: true,
message: '请输入主机ip',
trigger: ['change', 'blur'],
},
],
port: [
{
required: true,
message: '请输入端口',
message: '请输入主机ip和端口',
trigger: ['change', 'blur'],
},
],
@@ -124,19 +161,45 @@ export default defineComponent({
trigger: ['change', 'blur'],
},
],
authMethod: [
{
required: true,
message: '请选择认证方式',
trigger: ['change', 'blur'],
},
],
},
});
watch(props, async (newValue) => {
state.dialogVisible = newValue.visible;
if (!state.dialogVisible) {
return;
}
state.projects = newValue.projects;
if (newValue.machine) {
state.form = { ...newValue.machine };
} else {
state.form = { port: 22 } as any;
state.form = { port: 22, authMethod: 1 } as any;
}
getSshTunnelMachines();
});
const getSshTunnelMachines = async () => {
if (state.form.enableSshTunnel == 1 && state.sshTunnelMachineList.length == 0) {
const res = await machineApi.list.request({ pageNum: 1, pageSize: 100 });
state.sshTunnelMachineList = res.list;
}
};
const getSshTunnelMachine = (machineId: any) => {
return state.sshTunnelMachineList.find((x: any) => x.id == machineId);
};
const getPwd = async () => {
state.pwd = await machineApi.getMachinePwd.request({ id: state.form.id });
};
const changeProject = (projectId: number) => {
for (let p of state.projects as any) {
if (p.id == projectId) {
@@ -149,18 +212,29 @@ export default defineComponent({
if (!state.form.id) {
notBlank(state.form.password, '新增操作,密码不可为空');
}
machineForm.value.validate((valid: boolean) => {
machineForm.value.validate(async (valid: boolean) => {
if (valid) {
machineApi.saveMachine.request(state.form).then(() => {
const form: any = state.form;
if (form.enableSshTunnel == 1) {
const tunnelMachine: any = getSshTunnelMachine(form.sshTunnelMachineId);
if (tunnelMachine.ip == form.ip && tunnelMachine.port == form.port) {
ElMessage.error('隧道机器不能与本机器一致');
return;
}
}
const reqForm: any = { ...form };
if (reqForm.authMethod == 1) {
reqForm.password = await RsaEncrypt(state.form.password);
}
state.btnLoading = true;
try {
await machineApi.saveMachine.request(reqForm);
ElMessage.success('保存成功');
emit('val-change', state.form);
state.btnLoading = true;
setTimeout(() => {
state.btnLoading = false;
}, 1000);
cancel();
});
} finally {
state.btnLoading = false;
}
} else {
ElMessage.error('请正确填写信息');
return false;
@@ -176,6 +250,8 @@ export default defineComponent({
return {
...toRefs(state),
machineForm,
getSshTunnelMachines,
getPwd,
changeProject,
btnOk,
cancel,

View File

@@ -42,7 +42,7 @@
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip></el-table-column>
<el-table-column prop="ip" label="ip:port" min-width="140">
<el-table-column prop="ip" label="ip:port" min-width="150">
<template #default="scope">
<el-link :disabled="scope.row.status == -1" @click="showMachineStats(scope.row)" type="primary" :underline="false">{{
`${scope.row.ip}:${scope.row.port}`
@@ -68,11 +68,6 @@
<el-table-column prop="username" label="用户名" min-width="90"></el-table-column>
<el-table-column prop="projectName" label="项目" min-width="120"></el-table-column>
<el-table-column prop="remark" label="备注" min-width="250" show-overflow-tooltip></el-table-column>
<el-table-column prop="ip" label="hasCli" width="70">
<template #default="scope">
{{ `${scope.row.hasCli ? '是' : '否'}` }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="165">
<template #default="scope">
{{ $filters.dateFormat(scope.row.createTime) }}
@@ -255,18 +250,23 @@ export default defineComponent({
};
const closeCli = async (row: any) => {
await ElMessageBox.confirm(`确定关闭该机器客户端连接?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await machineApi.closeCli.request({ id: row.id });
ElMessage.success('关闭成功');
search();
};
const openFormDialog = (redis: any) => {
const openFormDialog = (machine: any) => {
let dialogTitle;
if (redis) {
if (machine) {
state.machineEditDialog.data = state.currentData as any;
dialogTitle = '编辑机器';
} else {
state.machineEditDialog.data = { port: 22 } as any;
state.machineEditDialog.data = null;
dialogTitle = '添加机器';
}

View File

@@ -7,9 +7,9 @@
:before-close="cancel"
:show-close="true"
:destroy-on-close="true"
width="800px"
width="900px"
>
<el-form :model="form" ref="mockDataForm" label-width="70px">
<el-form :model="form" ref="scriptForm" label-width="70px" size="small">
<el-form-item prop="method" label="名称">
<el-input v-model.trim="form.name" placeholder="请输入名称"></el-input>
</el-form-item>
@@ -24,8 +24,23 @@
</el-select>
</el-form-item>
<el-form-item prop="params" label="参数">
<el-input v-model="form.params" placeholder="参数数组json若无可不填"></el-input>
<el-row style="margin-left: 30px; margin-bottom: 5px">
<el-button @click="onAddParam" size="small" type="success">新增占位符参数</el-button>
</el-row>
<el-form-item :key="param" v-for="(param, index) in params" prop="params" :label="`参数${index + 1}`">
<el-row>
<el-col :span="5"><el-input v-model="param.model" placeholder="内容中用{{.model}}替换"></el-input></el-col>
<el-divider :span="1" direction="vertical" border-style="dashed" />
<el-col :span="4"><el-input v-model="param.name" placeholder="字段名"></el-input></el-col>
<el-divider :span="1" direction="vertical" border-style="dashed" />
<el-col :span="4"><el-input v-model="param.placeholder" placeholder="字段说明"></el-input></el-col>
<el-divider :span="1" direction="vertical" border-style="dashed" />
<el-col :span="4">
<el-input v-model="param.options" placeholder="可选值 ,分割"></el-input>
</el-col>
<el-divider :span="1" direction="vertical" border-style="dashed" />
<el-col :span="2"><el-button @click="onDeleteParam(index)" size="small" type="danger">删除</el-button></el-col>
</el-row>
</el-form-item>
<el-form-item prop="script" label="内容" id="content">
@@ -35,13 +50,12 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel()" :disabled="submitDisabled" size="small"> </el-button>
<el-button @click="cancel()" :disabled="submitDisabled"> </el-button>
<el-button
v-auth="'machine:script:save'"
type="primary"
:loading="btnLoading"
@click="btnOk"
size="small"
:disabled="submitDisabled"
> </el-button
>
@@ -84,41 +98,59 @@ export default defineComponent({
},
setup(props: any, { emit }) {
const { isCommon, machineId } = toRefs(props);
const mockDataForm: any = ref(null);
const scriptForm: any = ref(null);
const state = reactive({
dialogVisible: false,
submitDisabled: false,
params: [] as any,
form: {
id: null,
name: '',
machineId: 0,
description: '',
script: '',
params: null,
params: '',
type: null,
},
btnLoading: false,
});
watch(props, (newValue) => {
state.dialogVisible = newValue.visible;
if (!newValue.visible) {
return;
}
if (newValue.data) {
state.form = { ...newValue.data };
if (state.form.params) {
state.params = JSON.parse(state.form.params);
}
} else {
state.form = {} as any;
state.form.script = '';
}
state.dialogVisible = newValue.visible;
});
const onAddParam = () => {
state.params.push({ name: '', model: '', placeholder: '' });
};
const onDeleteParam = (idx: number) => {
state.params.splice(idx, 1);
};
const btnOk = () => {
state.form.machineId = isCommon.value ? 9999999 : (machineId.value as any);
console.log('machineid:', machineId);
mockDataForm.value.validate((valid: any) => {
scriptForm.value.validate((valid: any) => {
if (valid) {
notEmpty(state.form.name, '名称不能为空');
notEmpty(state.form.description, '描述不能为空');
notEmpty(state.form.script, '内容不能为空');
if (state.params) {
state.form.params = JSON.stringify(state.params);
}
machineApi.saveScript.request(state.form).then(
() => {
ElMessage.success('保存成功');
@@ -139,12 +171,15 @@ export default defineComponent({
const cancel = () => {
emit('update:visible', false);
emit('cancel');
state.params = [];
};
return {
...toRefs(state),
enums,
mockDataForm,
onAddParam,
onDeleteParam,
scriptForm,
btnOk,
cancel,
};

View File

@@ -76,7 +76,24 @@
<el-dialog title="脚本参数" v-model="scriptParamsDialog.visible" width="400px">
<el-form ref="paramsForm" :model="scriptParamsDialog.params" label-width="70px" size="small">
<el-form-item v-for="item in scriptParamsDialog.paramsFormItem" :key="item.name" :prop="item.model" :label="item.name" required>
<el-input v-model="scriptParamsDialog.params[item.model]" :placeholder="item.placeholder" autocomplete="off"></el-input>
<el-input
v-if="!item.options"
v-model="scriptParamsDialog.params[item.model]"
:placeholder="item.placeholder"
autocomplete="off"
clearable
></el-input>
<el-select
v-else
v-model="scriptParamsDialog.params[item.model]"
:placeholder="item.placeholder"
filterable
autocomplete="off"
clearable
style="width: 100%"
>
<el-option v-for="option in item.options.split(',')" :key="option" :label="option" :value="option" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
@@ -88,7 +105,6 @@
<el-dialog title="执行结果" v-model="resultDialog.visible" width="50%">
<div style="white-space: pre-line; padding: 10px; color: #000000">
<!-- {{ resultDialog.result }} -->
<el-input v-model="resultDialog.result" :rows="20" type="textarea" />
</div>
</el-dialog>
@@ -97,12 +113,12 @@
v-if="terminalDialog.visible"
title="终端"
v-model="terminalDialog.visible"
width="70%"
width="80%"
:close-on-click-modal="false"
:modal="false"
@close="closeTermnial"
>
<ssh-terminal ref="terminal" :cmd="terminalDialog.cmd" :machineId="terminalDialog.machineId" height="600px" />
<ssh-terminal ref="terminal" :cmd="terminalDialog.cmd" :machineId="terminalDialog.machineId" height="560px" />
</el-dialog>
<script-edit
@@ -196,8 +212,10 @@ export default defineComponent({
// 如果存在参数,则弹窗输入参数后执行
if (script.params) {
state.scriptParamsDialog.paramsFormItem = JSON.parse(script.params);
state.scriptParamsDialog.visible = true;
return;
if (state.scriptParamsDialog.paramsFormItem && state.scriptParamsDialog.paramsFormItem.length > 0) {
state.scriptParamsDialog.visible = true;
return;
}
}
run(script);
@@ -268,8 +286,6 @@ export default defineComponent({
const closeTermnial = () => {
state.terminalDialog.visible = false;
state.terminalDialog.machineId = 0;
// const t: any = this.$refs['terminal']
// t.closeAll()
};
/**
@@ -295,8 +311,6 @@ export default defineComponent({
};
const submitSuccess = () => {
// this.delChoose()
// this.search()
getScripts();
};
@@ -326,6 +340,7 @@ export default defineComponent({
context.emit('update:machineId', null);
context.emit('cancel');
state.scriptTable = [];
state.scriptParamsDialog.paramsFormItem = [];
};
return {

View File

@@ -9,7 +9,7 @@ import { FitAddon } from 'xterm-addon-fit';
import { getSession } from '@/common/utils/storage.ts';
import config from '@/common/config';
import { useStore } from '@/store/index.ts';
import { toRefs, watch, computed, reactive, defineComponent, onMounted, onBeforeUnmount } from 'vue';
import { nextTick, toRefs, watch, computed, reactive, defineComponent, onMounted, onBeforeUnmount } from 'vue';
export default defineComponent({
name: 'SshTerminal',
@@ -27,22 +27,20 @@ export default defineComponent({
socket: null as any,
});
const resize = 1;
const data = 2;
const ping = 3;
watch(props, (newValue) => {
state.machineId = newValue.machineId;
state.cmd = newValue.cmd;
state.height = newValue.height;
if (state.machineId) {
initSocket();
}
});
onMounted(() => {
state.machineId = props.machineId;
state.height = props.height;
state.cmd = props.cmd;
if (state.machineId) {
initSocket();
}
});
onBeforeUnmount(() => {
@@ -56,13 +54,17 @@ export default defineComponent({
return store.state.themeConfig.themeConfig;
});
nextTick(() => {
initXterm();
initSocket();
});
function initXterm() {
const term: any = new Terminal({
fontSize: getThemeConfig.value.terminalFontSize || 15,
// fontWeight: getThemeConfig.value.terminalFontWeight || 'normal',
fontFamily: 'JetBrainsMono, Consolas, Menlo, Monaco',
fontWeight: getThemeConfig.value.terminalFontWeight || 'normal',
fontFamily: 'JetBrainsMono, monaco, Consolas, Lucida Console, monospace',
cursorBlink: true,
// cursorStyle: 'underline', //光标样式
disableStdin: false,
theme: {
foreground: getThemeConfig.value.terminalForeground || '#7e9192', //字体
@@ -82,6 +84,14 @@ export default defineComponent({
try {
// 窗口大小改变时触发xterm的resize方法使自适应
fitAddon.fit();
if (state.term) {
state.term.focus();
send({
type: resize,
Cols: parseInt(state.term.cols),
Rows: parseInt(state.term.rows),
});
}
} catch (e) {
console.log(e);
}
@@ -104,69 +114,52 @@ export default defineComponent({
term.onData((key: any) => {
sendCmd(key);
});
// 为解决窗体resize方法才会向后端发送列数和行数所以页面加载时也要触发此方法
send({
type: 'resize',
Cols: parseInt(term.cols),
Rows: parseInt(term.rows),
});
// 如果有初始要执行的命令,则发送执行命令
if (state.cmd) {
sendCmd(state.cmd + ' \r');
}
}
let pingInterval: any;
function initSocket() {
state.socket = new WebSocket(`${config.baseWsUrl}/machines/${state.machineId}/terminal?token=${getSession('token')}`);
state.socket = new WebSocket(
`${config.baseWsUrl}/machines/${state.machineId}/terminal?token=${getSession('token')}&cols=${state.term.cols}&rows=${
state.term.rows
}`
);
// 监听socket连接
state.socket.onopen = open;
state.socket.onopen = () => {
// 如果有初始要执行的命令,则发送执行命令
if (state.cmd) {
sendCmd(state.cmd + ' \r');
}
// 开启心跳
pingInterval = setInterval(() => {
send({ type: ping, msg: 'ping' });
}, 8000);
};
// 监听socket错误信息
state.socket.onerror = error;
// 监听socket消息
state.socket.onmessage = getMessage;
state.socket.onerror = (e: any) => {
console.log('连接错误', e);
};
state.socket.onclose = () => {
if (state.term) {
state.term.writeln('\r\n\x1b[31m提示: 连接已关闭...');
}
if (pingInterval) {
clearInterval(pingInterval);
}
};
// 发送socket消息
state.socket.onsend = send;
// 监听socket消息
state.socket.onmessage = getMessage;
}
function open() {
console.log('socket连接成功');
initXterm();
//开启心跳
// this.start();
}
function error() {
console.log('连接错误');
//重连
// reconnect();
}
function close() {
if (state.socket) {
state.socket.close();
console.log('socket关闭');
}
//重连
// this.reconnect()
}
function getMessage(msg: string) {
// console.log(msg)
state.term.write(msg['data']);
//msg是返回的数据
// msg = JSON.parse(msg.data);
// this.socket.send("ping");//有事没事ping一下看看ws还活着没
// //switch用于处理返回的数据根据返回数据的格式去判断
// switch (msg["operation"]) {
// case "stdout":
// this.term.write(msg["data"]);//这里write也许不是固定的失败后找后端看一下该怎么往term里面write
// break;
// default:
// console.error("Unexpected message type:", msg);//但是错误是固定的。。。。
// }
//收到服务器信息,心跳重置
// this.reset();
function getMessage(msg: any) {
// msg.data是真正后端返回的数据
state.term.write(msg.data);
}
function send(msg: any) {
@@ -175,11 +168,18 @@ export default defineComponent({
function sendCmd(key: any) {
send({
type: 'cmd',
type: data,
msg: key,
});
}
function close() {
if (state.socket) {
state.socket.close();
console.log('socket关闭');
}
}
function closeAll() {
close();
if (state.term) {

View File

@@ -3,6 +3,7 @@ import Api from '@/common/Api';
export const machineApi = {
// 获取权限列表
list: Api.create("/machines", 'get'),
getMachinePwd: Api.create("/machines/{id}/pwd", 'get'),
info: Api.create("/machines/{id}/sysinfo", 'get'),
stats: Api.create("/machines/{id}/stats", 'get'),
process: Api.create("/machines/{id}/process", 'get'),

View File

@@ -15,7 +15,7 @@
</el-form-item>
<el-form-item label="库" label-width="20px">
<el-select v-model="database" placeholder="请选择库" @change="changeDatabase">
<el-select v-model="database" placeholder="请选择库" @change="changeDatabase" filterable>
<el-option v-for="item in databases" :key="item.Name" :label="item.Name" :value="item.Name">
<span style="float: left">{{ item.Name }}</span>
<span style="float: right; color: #8492a6; margin-left: 4px; font-size: 13px">{{
@@ -26,12 +26,8 @@
</el-form-item>
<el-form-item label="集合" label-width="40px">
<el-select v-model="collection" placeholder="请选择集合" @change="changeCollection">
<el-select v-model="collection" placeholder="请选择集合" @change="changeCollection" filterable>
<el-option v-for="item in collections" :key="item" :label="item" :value="item">
<!-- <span style="float: left">{{ item.uri }}</span>
<span style="float: right; color: #8492a6; margin-left: 6px; font-size: 13px">{{
` [${item.name}]`
}}</span> -->
</el-option>
</el-select>
</el-form-item>
@@ -124,7 +120,7 @@
</template>
</el-dialog>
<el-dialog width="800px" title="json编辑器" v-model="jsoneditorDialog.visible" @close="onCloseJsonEditDialog" :close-on-click-modal="false">
<el-dialog width="70%" title="json编辑器" v-model="jsoneditorDialog.visible" @close="onCloseJsonEditDialog" :close-on-click-modal="false">
<json-edit v-model="jsoneditorDialog.doc" />
</el-dialog>

View File

@@ -1,7 +1,7 @@
<template>
<div>
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" :close-on-click-modal="false" width="35%" :destroy-on-close="true">
<el-form :model="form" ref="mongoForm" :rules="rules" label-width="65px">
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" :close-on-click-modal="false" width="38%" :destroy-on-close="true">
<el-form :model="form" ref="mongoForm" :rules="rules" label-width="85px">
<el-form-item prop="projectId" label="项目" required>
<el-select style="width: 100%" v-model="form.projectId" placeholder="请选择项目" @change="changeProject" filterable>
<el-option v-for="item in projects" :key="item.id" :label="`${item.name} [${item.remark}]`" :value="item.id"> </el-option>
@@ -25,6 +25,24 @@
auto-complete="off"
></el-input>
</el-form-item>
<el-form-item prop="enableSshTunnel" label="SSH隧道:">
<el-col :span="3">
<el-checkbox @change="getSshTunnelMachines" v-model="form.enableSshTunnel" :true-label="1" :false-label="-1"></el-checkbox>
</el-col>
<el-col :span="2" v-if="form.enableSshTunnel == 1"> 机器: </el-col>
<el-col :span="19" v-if="form.enableSshTunnel == 1">
<el-select style="width: 100%" v-model="form.sshTunnelMachineId" placeholder="请选择SSH隧道机器">
<el-option
v-for="item in sshTunnelMachineList"
:key="item.id"
:label="`${item.ip}:${item.port} [${item.name}]`"
:value="item.id"
>
</el-option>
</el-select>
</el-col>
</el-form-item>
</el-form>
<template #footer>
@@ -41,6 +59,7 @@
import { toRefs, reactive, watch, defineComponent, ref } from 'vue';
import { mongoApi } from './api';
import { projectApi } from '../project/api.ts';
import { machineApi } from '../machine/api.ts';
import { ElMessage } from 'element-plus';
export default defineComponent({
@@ -65,10 +84,13 @@ export default defineComponent({
dialogVisible: false,
projects: [],
envs: [],
sshTunnelMachineList: [],
form: {
id: null,
name: null,
uri: null,
enableSshTunnel: -1,
sshTunnelMachineId: null,
project: null,
projectId: null,
envId: null,
@@ -109,6 +131,9 @@ export default defineComponent({
watch(props, async (newValue) => {
state.dialogVisible = newValue.visible;
if (!state.dialogVisible) {
return;
}
state.projects = newValue.projects;
if (newValue.mongo) {
getEnvs(newValue.mongo.projectId);
@@ -117,8 +142,16 @@ export default defineComponent({
state.envs = [];
state.form = { db: 0 } as any;
}
getSshTunnelMachines();
});
const getSshTunnelMachines = async () => {
if (state.form.enableSshTunnel == 1 && state.sshTunnelMachineList.length == 0) {
const res = await machineApi.list.request({ pageNum: 1, pageSize: 100 });
state.sshTunnelMachineList = res.list;
}
};
const getEnvs = async (projectId: any) => {
state.envs = await projectApi.projectEnvs.request({ projectId });
};
@@ -144,9 +177,11 @@ export default defineComponent({
};
const btnOk = async () => {
mongoForm.value.validate((valid: boolean) => {
mongoForm.value.validate(async (valid: boolean) => {
if (valid) {
mongoApi.saveMongo.request(state.form).then(() => {
const reqForm = { ...state.form };
// reqForm.uri = await RsaEncrypt(reqForm.uri);
mongoApi.saveMongo.request(reqForm).then(() => {
ElMessage.success('保存成功');
emit('val-change', state.form);
state.btnLoading = true;
@@ -172,6 +207,7 @@ export default defineComponent({
...toRefs(state),
mongoForm,
changeProject,
getSshTunnelMachines,
changeEnv,
btnOk,
cancel,

View File

@@ -25,7 +25,7 @@
</div>
</div>
<el-table :data="projects" @current-change="choose" ref="table" style="width: 100%">
<el-table-column label="选择" width="50px">
<el-table-column label="选择" width="55px">
<template #default="scope">
<el-radio v-model="chooseId" :label="scope.row.id">
<i></i>

View File

@@ -1,313 +0,0 @@
<template>
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" :show-close="false" width="750px" :destroy-on-close="true">
<el-form label-width="85px">
<el-form-item prop="key" label="key:">
<el-input :disabled="operationType == 2" v-model="key.key"></el-input>
</el-form-item>
<el-form-item prop="timed" label="过期时间:">
<el-input v-model.number="key.timed" type="number"></el-input>
</el-form-item>
<el-form-item prop="dataType" label="数据类型:">
<el-select :disabled="operationType == 2" style="width: 100%" v-model="key.type" placeholder="请选择数据类型">
<el-option key="string" label="string" value="string"> </el-option>
<el-option key="hash" label="hash" value="hash"> </el-option>
<el-option key="set" label="set" value="set"> </el-option>
</el-select>
</el-form-item>
<el-form-item v-if="key.type == 'string'" prop="value" label="内容:">
<div id="string-value-text" style="width: 100%">
<el-input class="json-text" v-model="string.value" type="textarea" :autosize="{ minRows: 10, maxRows: 20 }"></el-input>
<el-select class="text-type-select" @change="onChangeTextType" v-model="string.type">
<el-option key="text" label="text" value="text"> </el-option>
<el-option key="json" label="json" value="json"> </el-option>
</el-select>
</div>
</el-form-item>
<span v-if="key.type == 'hash'">
<el-button @click="onAddHashValue" icon="plus" size="small" plain class="mt10">添加</el-button>
<el-table :data="hash.value" stripe style="width: 100%">
<el-table-column prop="key" label="key" width>
<template #default="scope">
<el-input v-model="scope.row.key" clearable size="small"></el-input>
</template>
</el-table-column>
<el-table-column prop="value" label="value" min-width="200">
<template #default="scope">
<el-input
v-model="scope.row.value"
clearable
type="textarea"
:autosize="{ minRows: 2, maxRows: 10 }"
size="small"
></el-input>
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="scope">
<el-button type="danger" @click="hash.value.splice(scope.$index, 1)" icon="delete" size="small" plain>删除</el-button>
</template>
</el-table-column>
</el-table>
</span>
<span v-if="key.type == 'set'">
<el-button @click="onAddSetValue" icon="plus" size="small" plain class="mt10">添加</el-button>
<el-table :data="set.value" stripe style="width: 100%">
<el-table-column prop="value" label="value" min-width="200">
<template #default="scope">
<el-input
v-model="scope.row.value"
clearable
type="textarea"
:autosize="{ minRows: 2, maxRows: 10 }"
size="small"
></el-input>
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="scope">
<el-button type="danger" @click="set.value.splice(scope.$index, 1)" icon="delete" size="small" plain>删除</el-button>
</template>
</el-table-column>
</el-table>
</span>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel()"> </el-button>
<el-button @click="saveValue" type="primary" v-auth="'redis:data:save'"> </el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts">
import { defineComponent, reactive, watch, toRefs } from 'vue';
import { redisApi } from './api';
import { ElMessage } from 'element-plus';
import { isTrue, notEmpty } from '@/common/assert';
import { formatJsonString } from '@/common/utils/format';
export default defineComponent({
name: 'DateEdit',
components: {},
props: {
visible: {
type: Boolean,
},
title: {
type: String,
},
redisId: {
type: [Number],
require: true,
},
keyInfo: {
type: [Object],
},
// 操作类型1新增2修改
operationType: {
type: [Number],
},
stringValue: {
type: [String],
},
setValue: {
type: [Array, Object],
},
hashValue: {
type: [Array, Object],
},
},
emits: ['valChange', 'cancel', 'update:visible'],
setup(props: any, { emit }) {
const state = reactive({
dialogVisible: false,
operationType: 1,
redisId: '',
key: {
key: '',
type: 'string',
timed: -1,
},
string: {
type: 'text',
value: '',
},
hash: {
value: [
{
key: '',
value: '',
},
],
},
set: {
value: [{ value: '' }],
},
});
const cancel = () => {
emit('update:visible', false);
emit('cancel');
setTimeout(() => {
state.key = {
key: '',
type: 'string',
timed: -1,
};
state.string.value = '';
state.string.type = 'text';
state.hash.value = [
{
key: '',
value: '',
},
];
}, 500);
};
watch(
() => props.visible,
(val) => {
state.dialogVisible = val;
}
);
watch(
() => props.redisId,
(val) => {
state.redisId = val;
}
);
watch(
() => props.operationType,
(val) => {
state.operationType = val;
}
);
watch(
() => props.keyInfo,
(val) => {
if (val) {
state.key = { ...val };
}
},
{
deep: true, // 深度监听的参数
}
);
watch(
() => props.stringValue,
(val) => {
if (val) {
state.string.value = val;
}
},
{
deep: true, // 深度监听的参数
}
);
watch(
() => props.setValue,
(val) => {
if (val) {
state.set.value = val;
}
},
{
deep: true, // 深度监听的参数
}
);
watch(
() => props.hashValue,
(val) => {
if (val) {
state.hash.value = val;
}
},
{
deep: true, // 深度监听的参数
}
);
const saveValue = async () => {
notEmpty(state.key.key, 'key不能为空');
if (state.key.type == 'string') {
notEmpty(state.string.value, 'value不能为空');
const sv = { value: formatJsonString(state.string.value, true), id: state.redisId };
Object.assign(sv, state.key);
await redisApi.saveStringValue.request(sv);
}
if (state.key.type == 'hash') {
isTrue(state.hash.value.length > 0, 'hash内容不能为空');
const sv = { value: state.hash.value, id: state.redisId };
Object.assign(sv, state.key);
await redisApi.saveHashValue.request(sv);
}
if (state.key.type == 'set') {
isTrue(state.set.value.length > 0, 'set内容不能为空');
const sv = { value: state.set.value.map((x) => x.value), id: state.redisId };
Object.assign(sv, state.key);
await redisApi.saveSetValue.request(sv);
}
ElMessage.success('数据保存成功');
cancel();
emit('valChange');
};
const onAddHashValue = () => {
state.hash.value.push({ key: '', value: '' });
};
const onAddSetValue = () => {
state.set.value.push({ value: '' });
};
// 更改文本类型
const onChangeTextType = (val: string) => {
if (val == 'json') {
state.string.value = formatJsonString(state.string.value, false);
return;
}
if (val == 'text') {
state.string.value = formatJsonString(state.string.value, true);
}
};
return {
...toRefs(state),
saveValue,
cancel,
onAddHashValue,
onAddSetValue,
onChangeTextType,
};
},
});
</script>
<style lang="scss">
#string-value-text {
flex-grow: 1;
display: flex;
position: relative;
.text-type-select {
position: absolute;
z-index: 2;
right: 10px;
top: 10px;
max-width: 70px;
}
}
</style>

View File

@@ -23,7 +23,7 @@
<el-form class="search-form" label-position="right" :inline="true" label-width="60px">
<el-form-item label="key" label-width="40px">
<el-input
placeholder="支持*模糊key"
placeholder="match 支持*模糊key"
style="width: 240px"
v-model="scanParam.match"
@clear="clear()"
@@ -36,7 +36,14 @@
<el-form-item>
<el-button @click="searchKey()" type="success" icon="search" plain></el-button>
<el-button @click="scan()" icon="bottom" plain>scan</el-button>
<el-button type="primary" icon="plus" @click="onAddData(false)" plain></el-button>
<el-popover placement="right" :width="200" trigger="click">
<template #reference>
<el-button type="primary" icon="plus" plain></el-button>
</template>
<el-tag @click="onAddData('string')" :color="getTypeColor('string')" style="cursor: pointer">string</el-tag>
<el-tag @click="onAddData('hash')" :color="getTypeColor('hash')" class="ml5" style="cursor: pointer">hash</el-tag>
<el-tag @click="onAddData('set')" :color="getTypeColor('set')" class="ml5" style="cursor: pointer">set</el-tag>
</el-popover>
</el-form-item>
<div style="float: right">
<span>keys: {{ dbsize }}</span>
@@ -69,17 +76,32 @@
<div style="text-align: center; margin-top: 10px"></div>
<!-- <value-dialog v-model:visible="valueDialog.visible" :keyValue="valueDialog.value" /> -->
<hash-value
v-model:visible="hashValueDialog.visible"
:operationType="dataEdit.operationType"
:title="dataEdit.title"
:keyInfo="dataEdit.keyInfo"
:redisId="scanParam.id"
@cancel="onCancelDataEdit"
@valChange="searchKey"
/>
<data-edit
v-model:visible="dataEdit.visible"
<string-value
v-model:visible="stringValueDialog.visible"
:operationType="dataEdit.operationType"
:title="dataEdit.title"
:keyInfo="dataEdit.keyInfo"
:redisId="scanParam.id"
@cancel="onCancelDataEdit"
@valChange="searchKey"
/>
<set-value
v-model:visible="setValueDialog.visible"
:title="dataEdit.title"
:keyInfo="dataEdit.keyInfo"
:redisId="scanParam.id"
:operationType="dataEdit.operationType"
:stringValue="dataEdit.stringValue"
:setValue="dataEdit.setValue"
:hashValue="dataEdit.hashValue"
@valChange="searchKey"
@cancel="onCancelDataEdit"
/>
@@ -91,13 +113,17 @@ import { redisApi } from './api';
import { toRefs, reactive, defineComponent } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import ProjectEnvSelect from '../component/ProjectEnvSelect.vue';
import DataEdit from './DataEdit.vue';
import HashValue from './HashValue.vue';
import StringValue from './StringValue.vue';
import SetValue from './SetValue.vue';
import { isTrue, notBlank, notNull } from '@/common/assert';
export default defineComponent({
name: 'DataOperation',
components: {
DataEdit,
StringValue,
HashValue,
SetValue,
ProjectEnvSelect,
},
setup() {
@@ -113,10 +139,6 @@ export default defineComponent({
count: 10,
cursor: {},
},
valueDialog: {
visible: false,
value: {},
},
dataEdit: {
visible: false,
title: '新增数据',
@@ -126,9 +148,15 @@ export default defineComponent({
timed: -1,
key: '',
},
stringValue: '',
hashValue: [{ key: '', value: '' }],
setValue: [{ value: '' }],
},
hashValueDialog: {
visible: false,
},
stringValueDialog: {
visible: false,
},
setValueDialog: {
visible: false,
},
keys: [],
dbsize: 0,
@@ -158,10 +186,15 @@ export default defineComponent({
const scan = async () => {
isTrue(state.scanParam.id != null, '请先选择redis');
notBlank(state.scanParam.count, 'count不能为空');
isTrue(state.scanParam.count < 20001, 'count不能超过20000');
const match = state.scanParam.match;
if (!match || match == '*') {
isTrue(state.scanParam.count <= 200, 'match为空或者*时, count不能超过200');
} else {
isTrue(state.scanParam.count <= 20000, 'count不能超过20000');
}
state.loading = true;
try {
const res = await redisApi.scan.request(state.scanParam);
state.keys = res.keys;
@@ -207,60 +240,43 @@ export default defineComponent({
const getValue = async (row: any) => {
const type = row.type;
const key = row.key;
let res: any;
const reqParam = {
key: row.key,
id: state.scanParam.id,
};
switch (type) {
case 'string':
res = await redisApi.getStringValue.request(reqParam);
break;
case 'hash':
res = await redisApi.getHashValue.request(reqParam);
break;
case 'set':
res = await redisApi.getSetValue.request(reqParam);
break;
default:
res = null;
break;
}
notNull(res, '暂不支持该类型数据查看');
if (type == 'string') {
state.dataEdit.stringValue = res;
}
if (type == 'set') {
state.dataEdit.setValue = res.map((x: any) => {
return {
value: x,
};
});
}
if (type == 'hash') {
const hash = [];
//遍历key和value
const keys = Object.keys(res);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = res[key];
hash.push({
key,
value,
});
}
state.dataEdit.hashValue = hash;
}
state.dataEdit.keyInfo.type = type;
state.dataEdit.keyInfo.timed = row.ttl;
state.dataEdit.keyInfo.key = key;
state.dataEdit.keyInfo.key = row.key;
state.dataEdit.operationType = 2;
state.dataEdit.title = '修改数据';
state.dataEdit.visible = true;
state.dataEdit.title = '查看数据';
if (type == 'hash') {
state.hashValueDialog.visible = true;
} else if (type == 'string') {
state.stringValueDialog.visible = true;
} else if (type == 'set') {
state.setValueDialog.visible = true;
} else {
ElMessage.warning('暂不支持该类型');
}
};
const onAddData = (type: string) => {
notNull(state.scanParam.id, '请先选择redis');
state.dataEdit.operationType = 1;
state.dataEdit.title = '新增数据';
state.dataEdit.keyInfo.type = type;
state.dataEdit.keyInfo.timed = -1;
if (type == 'hash') {
state.hashValueDialog.visible = true;
} else if (type == 'string') {
state.stringValueDialog.visible = true;
} else if (type == 'set') {
state.setValueDialog.visible = true;
} else {
ElMessage.warning('暂不支持该类型');
}
};
const onCancelDataEdit = () => {
state.dataEdit.keyInfo = {} as any;
};
const del = (key: string) => {
@@ -331,20 +347,6 @@ export default defineComponent({
}
};
const onAddData = () => {
notNull(state.scanParam.id, '请先选择redis');
state.dataEdit.operationType = 1;
state.dataEdit.title = '新增数据';
state.dataEdit.visible = true;
};
const onCancelDataEdit = () => {
state.dataEdit.keyInfo = {} as any;
state.dataEdit.stringValue = '';
state.dataEdit.setValue = [];
state.dataEdit.hashValue = [];
};
return {
...toRefs(state),
changeProjectEnv,

View File

@@ -0,0 +1,254 @@
<template>
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" width="800px" :destroy-on-close="true">
<el-form label-width="85px">
<el-form-item prop="key" label="key:">
<el-input :disabled="operationType == 2" v-model="key.key"></el-input>
</el-form-item>
<el-form-item prop="timed" label="过期时间:">
<el-input v-model.number="key.timed" type="number"></el-input>
</el-form-item>
<el-form-item prop="dataType" label="数据类型:">
<el-input v-model="key.type" disabled></el-input>
</el-form-item>
<el-row class="mt10">
<el-form label-position="right" :inline="true">
<el-form-item label="field" label-width="40px" v-if="operationType == 2">
<el-input placeholder="支持*模糊field" style="width: 140px" v-model="scanParam.match" clearable size="small"></el-input>
</el-form-item>
<el-form-item label="count" v-if="operationType == 2">
<el-input placeholder="count" style="width: 62px" v-model.number="scanParam.count" size="small"></el-input>
</el-form-item>
<el-form-item>
<el-button v-if="operationType == 2" @click="reHscan()" type="success" icon="search" plain size="small"></el-button>
<el-button v-if="operationType == 2" @click="hscan()" icon="bottom" plain size="small">scan</el-button>
<el-button @click="onAddHashValue" icon="plus" size="small" plain>添加</el-button>
</el-form-item>
<div v-if="operationType == 2" class="mt10" style="float: right">
<span>fieldSize: {{ keySize }}</span>
</div>
</el-form>
</el-row>
<el-table :data="hashValues" stripe style="width: 100%">
<el-table-column prop="field" label="field" width>
<template #default="scope">
<el-input v-model="scope.row.field" clearable size="small"></el-input>
</template>
</el-table-column>
<el-table-column prop="value" label="value" min-width="200">
<template #default="scope">
<el-input v-model="scope.row.value" clearable type="textarea" :autosize="{ minRows: 2, maxRows: 10 }" size="small"></el-input>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button v-if="operationType == 2" type="success" @click="hset(scope.row)" icon="check" size="small" plain></el-button>
<el-button type="danger" @click="hdel(scope.row.field, scope.$index)" icon="delete" size="small" plain></el-button>
</template>
</el-table-column>
</el-table>
</el-form>
<template #footer v-if="operationType == 1">
<div class="dialog-footer">
<el-button @click="cancel()"> </el-button>
<el-button @click="saveValue" type="primary" v-auth="'redis:data:save'"> </el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts">
import { defineComponent, reactive, watch, toRefs } from 'vue';
import { redisApi } from './api';
import { ElMessage, ElMessageBox } from 'element-plus';
import { isTrue, notEmpty } from '@/common/assert';
export default defineComponent({
name: 'HashValue',
components: {},
props: {
visible: {
type: Boolean,
},
title: {
type: String,
},
// 操作类型1新增2修改
operationType: {
type: [Number],
require: true,
},
redisId: {
type: [Number],
require: true,
},
keyInfo: {
type: [Object],
},
hashValue: {
type: [Array, Object],
},
},
emits: ['valChange', 'cancel', 'update:visible'],
setup(props: any, { emit }) {
const state = reactive({
dialogVisible: false,
operationType: 1,
redisId: 0,
key: {
key: '',
type: 'hash',
timed: -1,
},
scanParam: {
key: '',
id: 0,
cursor: 0,
match: '',
count: 10,
},
keySize: 0,
hashValues: [
{
field: '',
value: '',
},
],
});
const cancel = () => {
emit('update:visible', false);
emit('cancel');
setTimeout(() => {
state.hashValues = [];
state.key = {} as any;
}, 500);
};
watch(props, async (newValue) => {
const visible = newValue.visible;
state.redisId = newValue.redisId;
state.key = newValue.keyInfo;
state.operationType = newValue.operationType;
if (visible && state.operationType == 2) {
state.scanParam.id = props.redisId;
state.scanParam.key = state.key.key;
await reHscan();
}
state.dialogVisible = visible;
});
const reHscan = async () => {
state.scanParam.id = state.redisId;
state.scanParam.cursor = 0;
hscan();
};
const hscan = async () => {
const match = state.scanParam.match;
if (!match || match == '' || match == '*') {
if (state.scanParam.count > 100) {
ElMessage.error('match为空或者*时, count不能超过100');
return;
}
} else {
if (state.scanParam.count > 1000) {
ElMessage.error('count不能超过1000');
return;
}
}
const scanRes = await redisApi.hscan.request(state.scanParam);
state.scanParam.cursor = scanRes.cursor;
state.keySize = scanRes.keySize;
const keys = scanRes.keys;
const hashValue = [];
const fieldCount = keys.length / 2;
let nextFieldIndex = 0;
for (let i = 0; i < fieldCount; i++) {
hashValue.push({ field: keys[nextFieldIndex++], value: keys[nextFieldIndex++] });
}
state.hashValues = hashValue;
};
const hdel = async (field: any, index: any) => {
// 如果是新增操作,则直接数组移除即可
if (state.operationType == 1) {
state.hashValues.splice(index, 1);
return;
}
await ElMessageBox.confirm(`确定删除[${field}]?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await redisApi.hdel.request({
id: state.redisId,
key: state.key.key,
field,
});
ElMessage.success('删除成功');
reHscan();
};
const hset = async (row: any) => {
await redisApi.saveHashValue.request({
id: state.redisId,
key: state.key.key,
timed: state.key.timed,
value: [
{
field: row.field,
value: row.value,
},
],
});
ElMessage.success('保存成功');
};
const onAddHashValue = () => {
state.hashValues.unshift({ field: '', value: '' });
};
const saveValue = async () => {
notEmpty(state.key.key, 'key不能为空');
isTrue(state.hashValues.length > 0, 'hash内容不能为空');
const sv = { value: state.hashValues, id: state.redisId };
Object.assign(sv, state.key);
await redisApi.saveHashValue.request(sv);
ElMessage.success('保存成功');
cancel();
emit('valChange');
};
return {
...toRefs(state),
reHscan,
hscan,
cancel,
hdel,
hset,
onAddHashValue,
saveValue,
};
},
});
</script>
<style lang="scss">
#string-value-text {
flex-grow: 1;
display: flex;
position: relative;
.text-type-select {
position: absolute;
z-index: 2;
right: 10px;
top: 10px;
max-width: 70px;
}
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" :close-on-click-modal="false" :destroy-on-close="true" width="35%">
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" :close-on-click-modal="false" :destroy-on-close="true" width="38%">
<el-form :model="form" ref="redisForm" :rules="rules" label-width="85px">
<el-form-item prop="projectId" label="项目:" required>
<el-select style="width: 100%" v-model="form.projectId" placeholder="请选择项目" @change="changeProject" filterable>
@@ -17,19 +17,32 @@
<el-select style="width: 100%" v-model="form.mode" placeholder="请选择模式">
<el-option label="standalone" value="standalone"> </el-option>
<el-option label="cluster" value="cluster"> </el-option>
<el-option label="sentinel" value="sentinel"> </el-option>
</el-select>
</el-form-item>
<el-form-item prop="host" label="host:" required>
<el-input v-model.trim="form.host" placeholder="请输入host:port集群模式用','分割" auto-complete="off" type="textarea"></el-input>
<el-input
v-model.trim="form.host"
placeholder="请输入host:portsentinel模式为: mastername=sentinelhost:port若集群或哨兵需设多个节点可使用','分割"
auto-complete="off"
type="textarea"
></el-input>
</el-form-item>
<el-form-item prop="password" label="密码:">
<el-input
type="password"
show-password
v-model.trim="form.password"
placeholder="请输入密码"
placeholder="请输入密码, 修改操作可不填"
autocomplete="new-password"
></el-input>
><template v-if="form.id && form.id != 0" #suffix>
<el-popover @hide="pwd = ''" placement="right" title="原密码" :width="200" trigger="click" :content="pwd">
<template #reference>
<el-link @click="getPwd" :underline="false" type="primary" class="mr5">原密码</el-link>
</template>
</el-popover>
</template></el-input
>
</el-form-item>
<el-form-item prop="db" label="库号:" required>
<el-input v-model.number="form.db" placeholder="请输入库号"></el-input>
@@ -37,12 +50,29 @@
<el-form-item prop="remark" label="备注:">
<el-input v-model.trim="form.remark" auto-complete="off" type="textarea"></el-input>
</el-form-item>
<el-form-item prop="enableSshTunnel" label="SSH隧道:">
<el-col :span="3">
<el-checkbox @change="getSshTunnelMachines" v-model="form.enableSshTunnel" :true-label="1" :false-label="-1"></el-checkbox>
</el-col>
<el-col :span="2" v-if="form.enableSshTunnel == 1"> 机器: </el-col>
<el-col :span="19" v-if="form.enableSshTunnel == 1">
<el-select style="width: 100%" v-model="form.sshTunnelMachineId" placeholder="请选择SSH隧道机器">
<el-option
v-for="item in sshTunnelMachineList"
:key="item.id"
:label="`${item.ip}:${item.port} [${item.name}]`"
:value="item.id"
>
</el-option>
</el-select>
</el-col>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="btnLoading" @click="btnOk"> </el-button>
<el-button type="primary" :loading="btnLoading" @click="btnOk"> </el-button>
</div>
</template>
</el-dialog>
@@ -53,7 +83,9 @@
import { toRefs, reactive, watch, defineComponent, ref } from 'vue';
import { redisApi } from './api';
import { projectApi } from '../project/api.ts';
import { machineApi } from '../machine/api.ts';
import { ElMessage } from 'element-plus';
import { RsaEncrypt } from '@/common/rsa';
export default defineComponent({
name: 'RedisEdit',
@@ -77,18 +109,22 @@ export default defineComponent({
dialogVisible: false,
projects: [],
envs: [],
sshTunnelMachineList: [],
form: {
id: null,
name: null,
mode: "standalone",
host: null,
mode: 'standalone',
host: '',
password: null,
project: null,
projectId: null,
envId: null,
env: null,
remark: "",
remark: '',
enableSshTunnel: null,
sshTunnelMachineId: null,
},
pwd: '',
btnLoading: false,
rules: {
projectId: [
@@ -131,20 +167,35 @@ export default defineComponent({
watch(props, async (newValue) => {
state.dialogVisible = newValue.visible;
if (!state.dialogVisible) {
return;
}
state.projects = newValue.projects;
if (newValue.redis) {
getEnvs(newValue.redis.projectId);
state.form = { ...newValue.redis };
} else {
state.envs = [];
state.form = { db: 0 } as any;
state.form = { db: 0, enableSshTunnel: -1 } as any;
}
getSshTunnelMachines();
});
const getSshTunnelMachines = async () => {
if (state.form.enableSshTunnel == 1 && state.sshTunnelMachineList.length == 0) {
const res = await machineApi.list.request({ pageNum: 1, pageSize: 100 });
state.sshTunnelMachineList = res.list;
}
};
const getEnvs = async (projectId: any) => {
state.envs = await projectApi.projectEnvs.request({ projectId });
};
const getPwd = async () => {
state.pwd = await redisApi.getRedisPwd.request({ id: state.form.id });
};
const changeProject = (projectId: number) => {
for (let p of state.projects as any) {
if (p.id == projectId) {
@@ -166,9 +217,15 @@ export default defineComponent({
};
const btnOk = async () => {
redisForm.value.validate((valid: boolean) => {
redisForm.value.validate(async (valid: boolean) => {
if (valid) {
redisApi.saveRedis.request(state.form).then(() => {
const reqForm = { ...state.form };
if (reqForm.mode == 'sentinel' && reqForm.host.split('=').length != 2) {
ElMessage.error('sentinel模式host需为: mastername=sentinelhost:sentinelport模式');
return;
}
reqForm.password = await RsaEncrypt(reqForm.password);
redisApi.saveRedis.request(reqForm).then(() => {
ElMessage.success('保存成功');
emit('val-change', state.form);
state.btnLoading = true;
@@ -193,6 +250,8 @@ export default defineComponent({
return {
...toRefs(state),
redisForm,
getSshTunnelMachines,
getPwd,
changeProject,
changeEnv,
btnOk,

View File

@@ -22,7 +22,7 @@
<el-table-column prop="env" label="环境" min-width="100"></el-table-column>
<el-table-column prop="host" label="host:port" min-width="150" show-overflow-tooltip> </el-table-column>
<el-table-column prop="mode" label="mode" min-width="100"></el-table-column>
<el-table-column prop="remark" label="备注" min-width="100"></el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip></el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="160">
<template #default="scope">
{{ $filters.dateFormat(scope.row.createTime) }}
@@ -31,7 +31,7 @@
<el-table-column prop="creator" label="创建人" min-width="100"></el-table-column>
<el-table-column label="更多" min-width="130" fixed="right">
<template #default="scope">
<el-link v-if="scope.row.mode == 'standalone'" type="primary" @click="info(scope.row)" :underline="false">单机信息</el-link>
<el-link v-if="scope.row.mode == 'standalone' || scope.row.mode == 'sentinel'" type="primary" @click="info(scope.row)" :underline="false">单机信息</el-link>
<el-link @click="onShowClusterInfo(scope.row)" v-if="scope.row.mode == 'cluster'" type="success" :underline="false"
>集群信息</el-link
>

View File

@@ -0,0 +1,157 @@
<template>
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" width="800px" :destroy-on-close="true">
<el-form label-width="85px">
<el-form-item prop="key" label="key:">
<el-input :disabled="operationType == 2" v-model="key.key"></el-input>
</el-form-item>
<el-form-item prop="timed" label="过期时间:">
<el-input v-model.number="key.timed" type="number"></el-input>
</el-form-item>
<el-form-item prop="dataType" label="数据类型:">
<el-input v-model="key.type" disabled></el-input>
</el-form-item>
<el-button @click="onAddSetValue" icon="plus" size="small" plain class="mt10">添加</el-button>
<el-table :data="value" stripe style="width: 100%">
<el-table-column prop="value" label="value" min-width="200">
<template #default="scope">
<el-input v-model="scope.row.value" clearable type="textarea" :autosize="{ minRows: 2, maxRows: 10 }" size="small"></el-input>
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="scope">
<el-button type="danger" @click="set.value.splice(scope.$index, 1)" icon="delete" size="small" plain>删除</el-button>
</template>
</el-table-column>
</el-table>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel()"> </el-button>
<el-button @click="saveValue" type="primary" v-auth="'redis:data:save'"> </el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts">
import { defineComponent, reactive, watch, toRefs } from 'vue';
import { redisApi } from './api';
import { ElMessage } from 'element-plus';
import { isTrue, notEmpty } from '@/common/assert';
export default defineComponent({
name: 'SetValue',
components: {},
props: {
visible: {
type: Boolean,
},
title: {
type: String,
},
redisId: {
type: [Number],
require: true,
},
keyInfo: {
type: [Object],
},
// 操作类型1新增2修改
operationType: {
type: [Number],
},
setValue: {
type: [Array, Object],
},
},
emits: ['valChange', 'cancel', 'update:visible'],
setup(props: any, { emit }) {
const state = reactive({
dialogVisible: false,
operationType: 1,
redisId: '',
key: {
key: '',
type: 'string',
timed: -1,
},
value: [{ value: '' }],
});
const cancel = () => {
emit('update:visible', false);
emit('cancel');
setTimeout(() => {
state.key = {
key: '',
type: 'string',
timed: -1,
};
state.value = [];
}, 500);
};
watch(props, async (newValue) => {
state.dialogVisible = newValue.visible;
state.key = newValue.key;
state.redisId = newValue.redisId;
state.key = newValue.keyInfo;
state.operationType = newValue.operationType;
// 如果是查看编辑操作,则获取值
if (state.dialogVisible && state.operationType == 2) {
getSetValue();
}
});
const getSetValue = async () => {
const res = await redisApi.getSetValue.request({
id: state.redisId,
key: state.key.key,
});
state.value = res.map((x: any) => {
return {
value: x,
};
});
};
const saveValue = async () => {
notEmpty(state.key.key, 'key不能为空');
isTrue(state.value.length > 0, 'set内容不能为空');
const sv = { value: state.value.map((x) => x.value), id: state.redisId };
Object.assign(sv, state.key);
await redisApi.saveSetValue.request(sv);
ElMessage.success('数据保存成功');
cancel();
emit('valChange');
};
const onAddSetValue = () => {
state.value.unshift({ value: '' });
};
return {
...toRefs(state),
saveValue,
cancel,
onAddSetValue,
};
},
});
</script>
<style lang="scss">
#string-value-text {
flex-grow: 1;
display: flex;
position: relative;
.text-type-select {
position: absolute;
z-index: 2;
right: 10px;
top: 10px;
max-width: 70px;
}
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" width="800px" :destroy-on-close="true">
<el-form label-width="85px">
<el-form-item prop="key" label="key:">
<el-input :disabled="operationType == 2" v-model="key.key"></el-input>
</el-form-item>
<el-form-item prop="timed" label="过期时间:">
<el-input v-model.number="key.timed" type="number"></el-input>
</el-form-item>
<el-form-item prop="dataType" label="数据类型:">
<el-input v-model="key.type" disabled></el-input>
</el-form-item>
<div id="string-value-text" style="width: 100%">
<el-input class="json-text" v-model="string.value" type="textarea" :autosize="{ minRows: 10, maxRows: 20 }"></el-input>
<el-select class="text-type-select" @change="onChangeTextType" v-model="string.type">
<el-option key="text" label="text" value="text"> </el-option>
<el-option key="json" label="json" value="json"> </el-option>
</el-select>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel()"> </el-button>
<el-button @click="saveValue" type="primary" v-auth="'redis:data:save'"> </el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts">
import { defineComponent, reactive, watch, toRefs } from 'vue';
import { redisApi } from './api';
import { ElMessage } from 'element-plus';
import { notEmpty } from '@/common/assert';
import { formatJsonString } from '@/common/utils/format';
export default defineComponent({
name: 'StringValue',
components: {},
props: {
visible: {
type: Boolean,
},
title: {
type: String,
},
redisId: {
type: [Number],
require: true,
},
keyInfo: {
type: [Object],
},
// 操作类型1新增2修改
operationType: {
type: [Number],
},
},
emits: ['valChange', 'cancel', 'update:visible'],
setup(props: any, { emit }) {
const state = reactive({
dialogVisible: false,
operationType: 1,
redisId: '',
key: {
key: '',
type: 'string',
timed: -1,
},
string: {
type: 'text',
value: '',
},
});
const cancel = () => {
emit('update:visible', false);
emit('cancel');
setTimeout(() => {
state.key = {
key: '',
type: 'string',
timed: -1,
};
state.string.value = '';
state.string.type = 'text';
}, 500);
};
watch(
() => props.visible,
(val) => {
state.dialogVisible = val;
}
);
watch(
() => props.redisId,
(val) => {
state.redisId = val;
}
);
watch(props, async (newValue) => {
state.dialogVisible = newValue.visible;
state.key = newValue.key;
state.redisId = newValue.redisId;
state.key = newValue.keyInfo;
state.operationType = newValue.operationType
// 如果是查看编辑操作,则获取值
if (state.dialogVisible && state.operationType == 2) {
getStringValue();
}
});
const getStringValue = async () => {
state.string.value = await redisApi.getStringValue.request({
id: state.redisId,
key: state.key.key,
});
};
const saveValue = async () => {
notEmpty(state.key.key, 'key不能为空');
notEmpty(state.string.value, 'value不能为空');
const sv = { value: formatJsonString(state.string.value, true), id: state.redisId };
Object.assign(sv, state.key);
await redisApi.saveStringValue.request(sv);
ElMessage.success('数据保存成功');
cancel();
emit('valChange');
};
// 更改文本类型
const onChangeTextType = (val: string) => {
if (val == 'json') {
state.string.value = formatJsonString(state.string.value, false);
return;
}
if (val == 'text') {
state.string.value = formatJsonString(state.string.value, true);
}
};
return {
...toRefs(state),
saveValue,
cancel,
onChangeTextType,
};
},
});
</script>
<style lang="scss">
#string-value-text {
flex-grow: 1;
display: flex;
position: relative;
.text-type-select {
position: absolute;
z-index: 2;
right: 10px;
top: 10px;
max-width: 70px;
}
}
</style>

View File

@@ -2,6 +2,7 @@ import Api from '@/common/Api';
export const redisApi = {
redisList : Api.create("/redis", 'get'),
getRedisPwd: Api.create("/redis/{id}/pwd", 'get'),
redisInfo: Api.create("/redis/{id}/info", 'get'),
clusterInfo: Api.create("/redis/{id}/cluster-info", 'get'),
saveRedis: Api.create("/redis", 'post'),
@@ -11,6 +12,9 @@ export const redisApi = {
getStringValue: Api.create("/redis/{id}/string-value", 'get'),
saveStringValue: Api.create("/redis/{id}/string-value", 'post'),
getHashValue: Api.create("/redis/{id}/hash-value", 'get'),
hscan: Api.create("/redis/{id}/hscan", 'get'),
hget: Api.create("/redis/{id}/hget", 'get'),
hdel: Api.create("/redis/{id}/hdel", 'delete'),
saveHashValue: Api.create("/redis/{id}/hash-value", 'post'),
getSetValue: Api.create("/redis/{id}/set-value", 'get'),
saveSetValue: Api.create("/redis/{id}/set-value", 'post'),

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,3 @@
app:
name: mayfly-go
version: 1.2.0
server:
# debug release test
model: release
@@ -25,10 +21,13 @@ server:
filepath: ./static/config.js
jwt:
key: mykey
# jwt key不设置默认使用随机字符串
key:
# 过期时间单位分钟
expire-time: 1440
# 资源密码aes加密key
aes:
key: 1111111111111111
mysql:
host: localhost:3306
username: root

View File

@@ -3,22 +3,23 @@ module mayfly-go
go 1.18
require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible // jwt
github.com/gin-gonic/gin v1.8.1
github.com/go-redis/redis/v8 v8.11.5
github.com/go-sql-driver/mysql v1.6.0
github.com/golang-jwt/jwt/v4 v4.4.2
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.6
github.com/mojocn/base64Captcha v1.3.5 //
github.com/pkg/sftp v1.13.4
github.com/pkg/sftp v1.13.5
github.com/robfig/cron/v3 v3.0.1 //
github.com/sirupsen/logrus v1.8.1
github.com/sirupsen/logrus v1.9.0
github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2
go.mongodb.org/mongo-driver v1.9.1 // mongo
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // ssh
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // ssh
gopkg.in/yaml.v3 v3.0.1
// gorm
gorm.io/driver/mysql v1.3.4
gorm.io/gorm v1.23.5
gorm.io/driver/mysql v1.3.5
gorm.io/gorm v1.23.8
)
require (
@@ -28,13 +29,12 @@ require (
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.10.1 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/goccy/go-json v0.9.7 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/snappy v0.0.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.4 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.13.6 // indirect
github.com/kr/fs v0.1.0 // indirect
@@ -52,7 +52,7 @@ require (
golang.org/x/image v0.0.0-20220302094943-723b81ca9867 // indirect
golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 // indirect
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/protobuf v1.28.0 // indirect

View File

@@ -2,7 +2,7 @@ package initialize
import (
"fmt"
common_index_router "mayfly-go/internal/common/router"
common_router "mayfly-go/internal/common/router"
devops_router "mayfly-go/internal/devops/router"
sys_router "mayfly-go/internal/sys/router"
"mayfly-go/pkg/config"
@@ -46,7 +46,8 @@ func InitRouter() *gin.Engine {
// 设置路由组
api := router.Group("/api")
{
common_index_router.InitIndexRouter(api)
common_router.InitIndexRouter(api)
common_router.InitCommonRouter(api)
sys_router.InitCaptchaRouter(api)
sys_router.InitAccountRouter(api) // 注册account路由

View File

@@ -0,0 +1,16 @@
package api
import (
"mayfly-go/pkg/biz"
"mayfly-go/pkg/ctx"
"mayfly-go/pkg/utils"
)
type Common struct {
}
func (i *Common) RasPublicKey(rc *ctx.ReqCtx) {
publicKeyStr, err := utils.GetRsaPublicKey()
biz.ErrIsNilAppendErr(err, "rsa生成公私钥失败")
rc.ResData = publicKeyStr
}

View File

@@ -0,0 +1,21 @@
package router
import (
"mayfly-go/internal/common/api"
"mayfly-go/pkg/ctx"
"github.com/gin-gonic/gin"
)
func InitCommonRouter(router *gin.RouterGroup) {
common := router.Group("common")
c := &api.Common{}
{
// 获取公钥
common.GET("public-key", func(g *gin.Context) {
ctx.NewReqCtxWithGin(g).
WithNeedToken(false).
Handle(c.RasPublicKey)
})
}
}

View File

@@ -0,0 +1,35 @@
package utils
import (
"mayfly-go/pkg/biz"
"mayfly-go/pkg/config"
)
// 使用config.yml的aes.key进行密码加密
func PwdAesEncrypt(password string) string {
if password == "" {
return ""
}
aes := config.Conf.Aes
if aes == nil {
return password
}
encryptPwd, err := aes.EncryptBase64([]byte(password))
biz.ErrIsNilAppendErr(err, "密码加密失败: %s")
return encryptPwd
}
// 使用config.yml的aes.key进行密码解密
func PwdAesDecrypt(encryptPwd string) string {
if encryptPwd == "" {
return ""
}
aes := config.Conf.Aes
if aes == nil {
return encryptPwd
}
decryptPwd, err := aes.DecryptBase64(encryptPwd)
biz.ErrIsNilAppendErr(err, "密码解密失败: %s")
// 解密后的密码
return string(decryptPwd)
}

View File

@@ -0,0 +1,16 @@
package constant
import "time"
const (
MachineConnExpireTime = 60 * time.Minute
DbConnExpireTime = 45 * time.Minute
RedisConnExpireTime = 30 * time.Minute
MongoConnExpireTime = 30 * time.Minute
/**** 开发测试使用 ****/
// MachineConnExpireTime = 4 * time.Minute
// DbConnExpireTime = 2 * time.Minute
// RedisConnExpireTime = 2 * time.Minute
// MongoConnExpireTime = 2 * time.Minute
)

View File

@@ -47,6 +47,12 @@ func (d *Db) Save(rc *ctx.ReqCtx) {
db := new(entity.Db)
utils.Copy(db, form)
// 密码解密,并使用解密后的赋值
originPwd, err := utils.DefaultRsaDecrypt(form.Password, true)
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
db.Password = originPwd
// 密码脱敏记录日志
form.Password = "****"
rc.ReqParam = form
@@ -55,6 +61,34 @@ func (d *Db) Save(rc *ctx.ReqCtx) {
d.DbApp.Save(db)
}
// 获取数据库实例密码,由于数据库是加密存储,故提供该接口展示原文密码
func (d *Db) GetDbPwd(rc *ctx.ReqCtx) {
dbId := GetDbId(rc.GinCtx)
dbEntity := d.DbApp.GetById(dbId, "Password")
dbEntity.PwdDecrypt()
rc.ResData = dbEntity.Password
}
// 获取数据库实例的所有数据库名
func (d *Db) GetDatabaseNames(rc *ctx.ReqCtx) {
form := &form.DbForm{}
ginx.BindJsonAndValid(rc.GinCtx, form)
db := new(entity.Db)
utils.Copy(db, form)
// 密码解密,并使用解密后的赋值
originPwd, err := utils.DefaultRsaDecrypt(form.Password, true)
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
db.Password = originPwd
// 如果id不为空并且密码为空则从数据库查询
if form.Id != 0 && db.Password == "" {
db = d.DbApp.GetById(form.Id)
}
rc.ResData = d.DbApp.GetDatabases(db)
}
func (d *Db) DeleteDb(rc *ctx.ReqCtx) {
dbId := GetDbId(rc.GinCtx)
d.DbApp.Delete(dbId)
@@ -63,19 +97,19 @@ func (d *Db) DeleteDb(rc *ctx.ReqCtx) {
}
func (d *Db) TableInfos(rc *ctx.ReqCtx) {
rc.ResData = d.DbApp.GetDbInstance(GetIdAndDb(rc.GinCtx)).GetTableInfos()
rc.ResData = d.DbApp.GetDbInstance(GetIdAndDb(rc.GinCtx)).GetMeta().GetTableInfos()
}
func (d *Db) TableIndex(rc *ctx.ReqCtx) {
tn := rc.GinCtx.Query("tableName")
biz.NotEmpty(tn, "tableName不能为空")
rc.ResData = d.DbApp.GetDbInstance(GetIdAndDb(rc.GinCtx)).GetTableIndex(tn)
rc.ResData = d.DbApp.GetDbInstance(GetIdAndDb(rc.GinCtx)).GetMeta().GetTableIndex(tn)
}
func (d *Db) GetCreateTableDdl(rc *ctx.ReqCtx) {
tn := rc.GinCtx.Query("tableName")
biz.NotEmpty(tn, "tableName不能为空")
rc.ResData = d.DbApp.GetDbInstance(GetIdAndDb(rc.GinCtx)).GetCreateTableDdl(tn)
rc.ResData = d.DbApp.GetDbInstance(GetIdAndDb(rc.GinCtx)).GetMeta().GetCreateTableDdl(tn)
}
func (d *Db) ExecSql(rc *ctx.ReqCtx) {
@@ -203,11 +237,12 @@ func (d *Db) DumpSql(rc *ctx.ReqCtx) {
writer.WriteString(fmt.Sprintf("\n-- 导出数据库: %s ", db))
writer.WriteString("\n-- ----------------------------\n")
dbmeta := d.DbApp.GetDbInstance(GetIdAndDb(rc.GinCtx)).GetMeta()
for _, table := range tables {
if needStruct {
writer.WriteString(fmt.Sprintf("\n-- ----------------------------\n-- 表结构: %s \n-- ----------------------------\n", table))
writer.WriteString(fmt.Sprintf("DROP TABLE IF EXISTS `%s`;\n", table))
writer.WriteString(dbInstance.GetCreateTableDdl(table)[0]["Create Table"].(string) + ";\n")
writer.WriteString(dbmeta.GetCreateTableDdl(table)[0]["Create Table"].(string) + ";\n")
}
if !needData {
@@ -269,7 +304,7 @@ func (d *Db) DumpSql(rc *ctx.ReqCtx) {
func (d *Db) TableMA(rc *ctx.ReqCtx) {
dbi := d.DbApp.GetDbInstance(GetIdAndDb(rc.GinCtx))
biz.ErrIsNilAppendErr(d.ProjectApp.CanAccess(rc.LoginAccount.Id, dbi.ProjectId), "%s")
rc.ResData = dbi.GetTableMetedatas()
rc.ResData = dbi.GetMeta().GetTables()
}
// @router /api/db/:dbId/c-metadata [get]
@@ -280,16 +315,17 @@ func (d *Db) ColumnMA(rc *ctx.ReqCtx) {
dbi := d.DbApp.GetDbInstance(GetIdAndDb(rc.GinCtx))
biz.ErrIsNilAppendErr(d.ProjectApp.CanAccess(rc.LoginAccount.Id, dbi.ProjectId), "%s")
rc.ResData = dbi.GetColumnMetadatas(tn)
rc.ResData = dbi.GetMeta().GetColumns(tn)
}
// @router /api/db/:dbId/hint-tables [get]
func (d *Db) HintTables(rc *ctx.ReqCtx) {
dbi := d.DbApp.GetDbInstance(GetIdAndDb(rc.GinCtx))
biz.ErrIsNilAppendErr(d.ProjectApp.CanAccess(rc.LoginAccount.Id, dbi.ProjectId), "%s")
// 获取所有表
tables := dbi.GetTableMetedatas()
dm := dbi.GetMeta()
// 获取所有表
tables := dm.GetTables()
tableNames := make([]string, 0)
for _, v := range tables {
tableNames = append(tableNames, v["tableName"].(string))
@@ -304,7 +340,7 @@ func (d *Db) HintTables(rc *ctx.ReqCtx) {
}
// 获取所有表下的所有列信息
columnMds := dbi.GetColumnMetadatas(tableNames...)
columnMds := dm.GetColumns(tableNames...)
for _, v := range columnMds {
tName := v["tableName"].(string)
if res[tName] == nil {

View File

@@ -9,11 +9,14 @@ type DbForm struct {
Username string `binding:"required" json:"username"`
Password string `json:"password"`
Params string `json:"params"`
Database string `binding:"required" json:"database"`
Database string `json:"database"`
ProjectId uint64 `binding:"required" json:"projectId"`
Project string `json:"project"`
Env string `json:"env"`
EnvId uint64 `binding:"required" json:"envId"`
EnableSshTunnel int8 `json:"enableSshTunnel"`
SshTunnelMachineId uint64 `json:"sshTunnelMachineId"`
}
type DbSqlSaveForm struct {

View File

@@ -1,18 +1,18 @@
package form
type MachineForm struct {
Id uint64 `json:"id"`
ProjectId uint64 `json:"projectId"`
ProjectName string `json:"projectName"`
Name string `json:"name" binding:"required"`
// IP地址
Ip string `json:"ip" binding:"required"`
// 用户名
Username string `json:"username" binding:"required"`
Password string `json:"password"`
// 端口号
Port int `json:"port" binding:"required"`
Remark string `json:"remark"`
Id uint64 `json:"id"`
ProjectId uint64 `json:"projectId"`
ProjectName string `json:"projectName"`
Name string `json:"name" binding:"required"`
Ip string `json:"ip" binding:"required"` // IP地址
Username string `json:"username" binding:"required"` // 用户名
AuthMethod int8 `json:"authMethod" binding:"required"`
Password string `json:"password"`
Port int `json:"port" binding:"required"` // 端口号
Remark string `json:"remark"`
EnableSshTunnel int8 `json:"enableSshTunnel"` // 是否启用ssh隧道
SshTunnelMachineId uint64 `json:"sshTunnelMachineId"` // ssh隧道机器id
}
type MachineRunForm struct {

View File

@@ -1,13 +1,15 @@
package form
type Mongo struct {
Id uint64
Uri string `binding:"required" json:"uri"`
Name string `binding:"required" json:"name"`
ProjectId uint64 `binding:"required" json:"projectId"`
Project string `json:"project"`
Env string `json:"env"`
EnvId uint64 `binding:"required" json:"envId"`
Id uint64
Uri string `binding:"required" json:"uri"`
EnableSshTunnel int8 `json:"enableSshTunnel"` // 是否启用ssh隧道
SshTunnelMachineId uint64 `json:"sshTunnelMachineId"` // ssh隧道机器id
Name string `binding:"required" json:"name"`
ProjectId uint64 `binding:"required" json:"projectId"`
Project string `json:"project"`
Env string `json:"env"`
EnvId uint64 `binding:"required" json:"envId"`
}
type MongoCommand struct {

View File

@@ -1,15 +1,18 @@
package form
type Redis struct {
Id uint64
Host string `binding:"required" json:"host"`
Password string `json:"password"`
Mode string `json:"mode"`
Db int `json:"db"`
ProjectId uint64 `binding:"required" json:"projectId"`
Project string `json:"project"`
Env string `json:"env"`
EnvId uint64 `binding:"required" json:"envId"`
Id uint64
Host string `binding:"required" json:"host"`
Password string `json:"password"`
Mode string `json:"mode"`
Db int `json:"db"`
EnableSshTunnel int8 `json:"enableSshTunnel"` // 是否启用ssh隧道
SshTunnelMachineId uint64 `json:"sshTunnelMachineId"` // ssh隧道机器id
ProjectId uint64 `binding:"required" json:"projectId"`
Project string `json:"project"`
Env string `json:"env"`
EnvId uint64 `binding:"required" json:"envId"`
Remark string `json:"remark"`
}
type KeyInfo struct {

View File

@@ -54,15 +54,30 @@ func (m *Machine) SaveMachine(rc *ctx.ReqCtx) {
machineForm := new(form.MachineForm)
ginx.BindJsonAndValid(g, machineForm)
entity := new(entity.Machine)
utils.Copy(entity, machineForm)
me := new(entity.Machine)
utils.Copy(me, machineForm)
if me.AuthMethod == entity.MachineAuthMethodPassword {
// 密码解密,并使用解密后的赋值
originPwd, err := utils.DefaultRsaDecrypt(machineForm.Password, true)
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
me.Password = originPwd
}
// 密码脱敏记录日志
machineForm.Password = "****"
rc.ReqParam = machineForm
entity.SetBaseInfo(rc.LoginAccount)
m.MachineApp.Save(entity)
me.SetBaseInfo(rc.LoginAccount)
m.MachineApp.Save(me)
}
// 获取机器实例密码,由于数据库是加密存储,故提供该接口展示原文密码
func (m *Machine) GetMachinePwd(rc *ctx.ReqCtx) {
mid := GetMachineId(rc.GinCtx)
me := m.MachineApp.GetById(mid, "Password")
me.PwdDecrypt()
rc.ResData = me.Password
}
func (m *Machine) ChangeStatus(rc *ctx.ReqCtx) {
@@ -145,21 +160,16 @@ func (m *Machine) WsSSH(g *gin.Context) {
panic(biz.NewBizErr("\033[1;31m您没有权限操作该机器终端,请重新登录后再试~\033[0m"))
}
cols := ginx.QueryInt(g, "cols", 80)
rows := ginx.QueryInt(g, "rows", 40)
cli := m.MachineApp.GetCli(GetMachineId(g))
biz.ErrIsNilAppendErr(m.ProjectApp.CanAccess(rc.LoginAccount.Id, cli.GetMachine().ProjectId), "%s")
sws, err := machine.NewLogicSshWsSession(cols, rows, cli, wsConn)
biz.ErrIsNilAppendErr(err, "\033[1;31m连接失败%s\033[0m")
defer sws.Close()
cols := ginx.QueryInt(g, "cols", 80)
rows := ginx.QueryInt(g, "rows", 40)
quitChan := make(chan bool, 3)
sws.Start(quitChan)
go sws.Wait(quitChan)
<-quitChan
mts, err := machine.NewTerminalSession(utils.RandString(16), wsConn, cli, rows, cols)
biz.ErrIsNilAppendErr(err, "\033[1;31m连接失败: %s\033[0m")
mts.Start()
defer mts.Stop()
}
func GetMachineId(g *gin.Context) uint64 {

View File

@@ -38,6 +38,7 @@ func (m *Mongo) Save(rc *ctx.ReqCtx) {
mongo := new(entity.Mongo)
utils.Copy(mongo, form)
mongo.SetBaseInfo(rc.LoginAccount)
m.MongoApp.Save(mongo)
}

View File

@@ -38,6 +38,12 @@ func (r *Redis) Save(rc *ctx.ReqCtx) {
redis := new(entity.Redis)
utils.Copy(redis, form)
// 密码解密,并使用解密后的赋值
originPwd, err := utils.DefaultRsaDecrypt(redis.Password, true)
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
redis.Password = originPwd
// 密码脱敏记录日志
form.Password = "****"
rc.ReqParam = form
@@ -46,6 +52,14 @@ func (r *Redis) Save(rc *ctx.ReqCtx) {
r.RedisApp.Save(redis)
}
// 获取redis实例密码由于数据库是加密存储故提供该接口展示原文密码
func (r *Redis) GetRedisPwd(rc *ctx.ReqCtx) {
rid := uint64(ginx.PathParamInt(rc.GinCtx, "id"))
re := r.RedisApp.GetById(rid, "Password")
re.PwdDecrypt()
rc.ResData = re.Password
}
func (r *Redis) DeleteRedis(rc *ctx.ReqCtx) {
r.RedisApp.Delete(uint64(ginx.PathParamInt(rc.GinCtx, "id")))
}
@@ -179,7 +193,7 @@ func (r *Redis) Scan(rc *ctx.ReqCtx) {
kis := make([]*vo.KeyInfo, 0)
var cursorRes map[string]uint64 = make(map[string]uint64)
if ri.Mode == "" || ri.Mode == entity.RedisModeStandalone {
if ri.Mode == "" || ri.Mode == entity.RedisModeStandalone || ri.Mode == entity.RedisModeSentinel {
redisAddr := ri.Cli.Options().Addr
keys, cursor := ri.Scan(form.Cursor[redisAddr], form.Match, form.Count)
cursorRes[redisAddr] = cursor
@@ -271,13 +285,6 @@ func (r *Redis) GetStringValue(rc *ctx.ReqCtx) {
rc.ResData = str
}
func (r *Redis) GetHashValue(rc *ctx.ReqCtx) {
ri, key := r.checkKey(rc)
res, err := ri.GetCmdable().HGetAll(context.TODO(), key).Result()
biz.ErrIsNilAppendErr(err, "获取hash值失败: %s")
rc.ResData = res
}
func (r *Redis) SetStringValue(rc *ctx.ReqCtx) {
g := rc.GinCtx
keyValue := new(form.StringValue)
@@ -291,6 +298,45 @@ func (r *Redis) SetStringValue(rc *ctx.ReqCtx) {
rc.ResData = str
}
func (r *Redis) Hscan(rc *ctx.ReqCtx) {
ri, key := r.checkKey(rc)
g := rc.GinCtx
count := ginx.QueryInt(g, "count", 10)
match := g.Query("match")
cursor := ginx.QueryInt(g, "cursor", 0)
contextTodo := context.TODO()
cmdable := ri.GetCmdable()
keys, nextCursor, err := cmdable.HScan(contextTodo, key, uint64(cursor), match, int64(count)).Result()
biz.ErrIsNilAppendErr(err, "hcan err: %s")
keySize, err := cmdable.HLen(contextTodo, key).Result()
biz.ErrIsNilAppendErr(err, "hlen err: %s")
rc.ResData = map[string]interface{}{
"keys": keys,
"cursor": nextCursor,
"keySize": keySize,
}
}
func (r *Redis) Hdel(rc *ctx.ReqCtx) {
ri, key := r.checkKey(rc)
field := rc.GinCtx.Query("field")
delRes, err := ri.GetCmdable().HDel(context.TODO(), key, field).Result()
biz.ErrIsNilAppendErr(err, "hdel err: %s")
rc.ResData = delRes
}
func (r *Redis) Hget(rc *ctx.ReqCtx) {
ri, key := r.checkKey(rc)
field := rc.GinCtx.Query("field")
res, err := ri.GetCmdable().HGet(context.TODO(), key, field).Result()
biz.ErrIsNilAppendErr(err, "hget err: %s")
rc.ResData = res
}
func (r *Redis) SetHashValue(rc *ctx.ReqCtx) {
g := rc.GinCtx
hashValue := new(form.HashValue)
@@ -301,13 +347,12 @@ func (r *Redis) SetHashValue(rc *ctx.ReqCtx) {
cmd := ri.GetCmdable()
key := hashValue.Key
// 简单处理->先删除,后新增
cmd.Del(context.TODO(), key)
contextTodo := context.TODO()
for _, v := range hashValue.Value {
res := cmd.HSet(context.TODO(), key, v["key"].(string), v["value"])
res := cmd.HSet(contextTodo, key, v["field"].(string), v["value"])
biz.ErrIsNilAppendErr(res.Err(), "保存hash值失败: %s")
}
if hashValue.Timed != -1 {
if hashValue.Timed != 0 && hashValue.Timed != -1 {
cmd.Expire(context.TODO(), key, time.Second*time.Duration(hashValue.Timed))
}
}

View File

@@ -19,4 +19,7 @@ type SelectDataDbVO struct {
CreateTime *time.Time `json:"createTime"`
Creator *string `json:"creator"`
CreatorId *int64 `json:"creatorId"`
EnableSshTunnel *int8 `json:"enableSshTunnel"`
SshTunnelMachineId *uint64 `json:"sshTunnelMachineId"`
}

View File

@@ -5,17 +5,19 @@ import "time"
type Redis struct {
Id *int64 `json:"id"`
// Name *string `json:"name"`
Host *string `json:"host"`
Db int `json:"db"`
ProjectId *int64 `json:"projectId"`
Project *string `json:"project"`
Mode *string `json:"mode"`
Remark *string `json:"remark"`
Env *string `json:"env"`
EnvId *int64 `json:"envId"`
CreateTime *time.Time `json:"createTime"`
Creator *string `json:"creator"`
CreatorId *int64 `json:"creatorId"`
Host *string `json:"host"`
Db int `json:"db"`
ProjectId *int64 `json:"projectId"`
Project *string `json:"project"`
Mode *string `json:"mode"`
EnableSshTunnel *int8 `json:"enableSshTunnel"` // 是否启用ssh隧道
SshTunnelMachineId *uint64 `json:"sshTunnelMachineId"` // ssh隧道机器id
Remark *string `json:"remark"`
Env *string `json:"env"`
EnvId *int64 `json:"envId"`
CreateTime *time.Time `json:"createTime"`
Creator *string `json:"creator"`
CreatorId *int64 `json:"creatorId"`
}
type Keys struct {

View File

@@ -15,22 +15,25 @@ type AccountVO struct {
type MachineVO struct {
//models.BaseModel
Id *uint64 `json:"id"`
ProjectId uint64 `json:"projectId"`
ProjectName string `json:"projectName"`
Name *string `json:"name"`
Username *string `json:"username"`
Ip *string `json:"ip"`
Port *int `json:"port"`
Status *int8 `json:"status"`
CreateTime *time.Time `json:"createTime"`
Creator *string `json:"creator"`
CreatorId *int64 `json:"creatorId"`
UpdateTime *time.Time `json:"updateTime"`
Modifier *string `json:"modifier"`
ModifierId *int64 `json:"modifierId"`
HasCli bool `json:"hasCli" gorm:"-"`
Remark *string `json:"remark"`
Id *uint64 `json:"id"`
ProjectId uint64 `json:"projectId"`
ProjectName string `json:"projectName"`
Name *string `json:"name"`
Username *string `json:"username"`
Ip *string `json:"ip"`
Port *int `json:"port"`
AuthMethod *int8 `json:"authMethod"`
Status *int8 `json:"status"`
EnableSshTunnel *int8 `json:"enableSshTunnel"` // 是否启用ssh隧道
SshTunnelMachineId *uint64 `json:"sshTunnelMachineId"` // ssh隧道机器id
CreateTime *time.Time `json:"createTime"`
Creator *string `json:"creator"`
CreatorId *int64 `json:"creatorId"`
UpdateTime *time.Time `json:"updateTime"`
Modifier *string `json:"modifier"`
ModifierId *int64 `json:"modifierId"`
HasCli bool `json:"hasCli" gorm:"-"`
Remark *string `json:"remark"`
}
type MachineScriptVO struct {

View File

@@ -1,24 +1,29 @@
package application
import (
"context"
"database/sql"
"errors"
"fmt"
"mayfly-go/internal/constant"
"mayfly-go/internal/devops/domain/entity"
"mayfly-go/internal/devops/domain/repository"
"mayfly-go/internal/devops/infrastructure/machine"
"mayfly-go/internal/devops/infrastructure/persistence"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/cache"
"mayfly-go/pkg/global"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils"
"net"
"reflect"
"strconv"
"strings"
"sync"
"time"
_ "github.com/lib/pq"
"github.com/go-sql-driver/mysql"
"github.com/lib/pq"
)
type Db interface {
@@ -42,6 +47,9 @@ type Db interface {
// @param id 数据库实例id
// @param db 数据库
GetDbInstance(id uint64, db string) *DbInstance
// 获取数据库实例的所有数据库列表
GetDatabases(entity *entity.Db) []string
}
type dbAppImpl struct {
@@ -75,10 +83,11 @@ func (d *dbAppImpl) GetById(id uint64, cols ...string) *entity.Db {
func (d *dbAppImpl) Save(dbEntity *entity.Db) {
// 默认tcp连接
dbEntity.Network = "tcp"
dbEntity.Network = dbEntity.GetNetwork()
// 测试连接
if dbEntity.Password != "" {
TestConnection(*dbEntity)
TestConnection(dbEntity)
}
// 查找是否存在该库
@@ -88,6 +97,7 @@ func (d *dbAppImpl) Save(dbEntity *entity.Db) {
if dbEntity.Id == 0 {
biz.NotEmpty(dbEntity.Password, "密码不能为空")
biz.IsTrue(err != nil, "该数据库实例已存在")
dbEntity.PwdEncrypt()
d.dbRepo.Insert(dbEntity)
return
}
@@ -102,6 +112,8 @@ func (d *dbAppImpl) Save(dbEntity *entity.Db) {
var oldDbs []interface{}
for _, v := range strings.Split(old.Database, " ") {
// 关闭数据库连接
CloseDb(dbEntity.Id, v)
oldDbs = append(oldDbs, v)
}
@@ -114,14 +126,12 @@ func (d *dbAppImpl) Save(dbEntity *entity.Db) {
return i1.(string) == i2.(string)
})
for _, v := range delDb {
// 先关闭数据库连接
CloseDb(dbEntity.Id, v.(string))
// 删除该库关联的所有sql记录
d.dbSqlRepo.DeleteBy(&entity.DbSql{DbId: dbId, Db: v.(string)})
}
dbEntity.PwdEncrypt()
d.dbRepo.Update(dbEntity)
}
func (d *dbAppImpl) Delete(id uint64) {
@@ -136,11 +146,35 @@ func (d *dbAppImpl) Delete(id uint64) {
d.dbSqlRepo.DeleteBy(&entity.DbSql{DbId: id})
}
func (d *dbAppImpl) GetDatabases(ed *entity.Db) []string {
ed.Network = ed.GetNetwork()
databases := make([]string, 0)
var dbConn *sql.DB
var metaDb string
var getDatabasesSql string
if ed.Type == entity.DbTypeMysql {
metaDb = "information_schema"
getDatabasesSql = "SELECT SCHEMA_NAME AS dbname FROM SCHEMATA"
} else {
metaDb = "postgres"
getDatabasesSql = "SELECT datname AS dbname FROM pg_database"
}
dbConn, err := GetDbConn(ed, metaDb)
biz.ErrIsNilAppendErr(err, "数据库连接失败: %s")
defer dbConn.Close()
_, res, err := SelectDataByDb(dbConn, getDatabasesSql)
biz.ErrIsNilAppendErr(err, "获取数据库列表失败")
for _, re := range res {
databases = append(databases, re["dbname"].(string))
}
return databases
}
var mutex sync.Mutex
func (da *dbAppImpl) GetDbInstance(id uint64, db string) *DbInstance {
mutex.Lock()
defer mutex.Unlock()
// Id不为0则为需要缓存
needCache := id != 0
if needCache {
@@ -149,20 +183,23 @@ func (da *dbAppImpl) GetDbInstance(id uint64, db string) *DbInstance {
return load.(*DbInstance)
}
}
mutex.Lock()
defer mutex.Unlock()
d := da.GetById(id)
// 密码解密
d.PwdDecrypt()
biz.NotNil(d, "数据库信息不存在")
biz.IsTrue(strings.Contains(d.Database, db), "未配置该库的操作权限")
global.Log.Infof("连接db: %s:%d/%s", d.Host, d.Port, db)
// 将数据库替换为要访问的数据库,原本数据库为空格拼接的所有库
d.Database = db
DB, err := sql.Open(d.Type, getDsn(d))
biz.ErrIsNil(err, fmt.Sprintf("Open %s failed, err:%v\n", d.Type, err))
perr := DB.Ping()
if perr != nil {
cacheKey := GetDbCacheKey(id, db)
dbi := &DbInstance{Id: cacheKey, Type: d.Type, ProjectId: d.ProjectId, sshTunnelMachineId: d.SshTunnelMachineId}
DB, err := GetDbConn(d, db)
if err != nil {
dbi.Close()
global.Log.Errorf("连接db失败: %s:%d/%s", d.Host, d.Port, db)
panic(biz.NewBizErr(fmt.Sprintf("数据库连接失败: %s", perr.Error())))
panic(biz.NewBizErr(fmt.Sprintf("数据库连接失败: %s", err.Error())))
}
// 最大连接周期超过时间的连接就close
@@ -172,8 +209,8 @@ func (da *dbAppImpl) GetDbInstance(id uint64, db string) *DbInstance {
// 设置闲置连接数
DB.SetMaxIdleConns(1)
cacheKey := GetDbCacheKey(id, db)
dbi := &DbInstance{Id: cacheKey, Type: d.Type, ProjectId: d.ProjectId, db: DB}
dbi.db = DB
global.Log.Infof("连接db: %s:%d/%s", d.Host, d.Port, db)
if needCache {
dbCache.Put(cacheKey, dbi)
}
@@ -182,14 +219,30 @@ func (da *dbAppImpl) GetDbInstance(id uint64, db string) *DbInstance {
//------------------------------------------------------------------------------
// 客户端连接缓存30分钟内没有访问则会被关闭, key为数据库实例id:数据
var dbCache = cache.NewTimedCache(30*time.Minute, 5*time.Second).
// 单次最大查询数据
const Max_Rows = 2000
// 客户端连接缓存,指定时间内没有访问则会被关闭, key为数据库实例id:数据库
var dbCache = cache.NewTimedCache(constant.DbConnExpireTime, 5*time.Second).
WithUpdateAccessTime(true).
OnEvicted(func(key interface{}, value interface{}) {
global.Log.Info(fmt.Sprintf("删除db连接缓存 id = %s", key))
value.(*DbInstance).Close()
})
func init() {
machine.AddCheckSshTunnelMachineUseFunc(func(machineId uint64) bool {
// 遍历所有db连接实例若存在redis实例使用该ssh隧道机器则返回true表示还在使用中...
items := dbCache.Items()
for _, v := range items {
if v.Value.(*DbInstance).sshTunnelMachineId == machineId {
return true
}
}
return false
})
}
func GetDbCacheKey(dbId uint64, db string) string {
return fmt.Sprintf("%d:%s", dbId, db)
}
@@ -201,37 +254,45 @@ func GetDbInstanceByCache(id string) *DbInstance {
return nil
}
func TestConnection(d entity.Db) {
func TestConnection(d *entity.Db) {
// 验证第一个库是否可以连接即可
d.Database = strings.Split(d.Database, " ")[0]
DB, err := sql.Open(d.Type, getDsn(&d))
biz.ErrIsNil(err, "Open %s failed, err:%v\n", d.Type, err)
DB, err := GetDbConn(d, strings.Split(d.Database, " ")[0])
biz.ErrIsNilAppendErr(err, "数据库连接失败: %s")
defer DB.Close()
perr := DB.Ping()
biz.ErrIsNilAppendErr(perr, "数据库连接失败: %s")
}
// db实例
type DbInstance struct {
Id string
Type string
ProjectId uint64
db *sql.DB
}
// 执行查询语句
// 依次返回 列名数组结果map错误
func (d *DbInstance) SelectData(execSql string) ([]string, []map[string]interface{}, error) {
execSql = strings.Trim(execSql, " ")
isSelect := strings.HasPrefix(execSql, "SELECT") || strings.HasPrefix(execSql, "select")
isShow := strings.HasPrefix(execSql, "show")
isExplain := strings.HasPrefix(execSql, "explain")
if !isSelect && !isShow && !isExplain {
return nil, nil, errors.New("该sql非查询语句")
// 获取数据库连接
func GetDbConn(d *entity.Db, db string) (*sql.DB, error) {
// SSH Conect
if d.EnableSshTunnel == 1 && d.SshTunnelMachineId != 0 {
sshTunnelMachine := MachineApp.GetSshTunnelMachine(d.SshTunnelMachineId)
if d.Type == entity.DbTypeMysql {
mysql.RegisterDialContext(d.Network, func(ctx context.Context, addr string) (net.Conn, error) {
return sshTunnelMachine.GetDialConn("tcp", addr)
})
} else if d.Type == entity.DbTypePostgres {
_, err := pq.DialOpen(&PqSqlDialer{sshTunnelMachine: sshTunnelMachine}, getDsn(d, db))
if err != nil {
panic(biz.NewBizErr(fmt.Sprintf("postgres隧道连接失败: %s", err.Error())))
}
}
}
rows, err := d.db.Query(execSql)
DB, err := sql.Open(d.Type, getDsn(d, db))
if err != nil {
return nil, err
}
err = DB.Ping()
if err != nil {
DB.Close()
return nil, err
}
return DB, nil
}
func SelectDataByDb(db *sql.DB, selectSql string) ([]string, []map[string]interface{}, error) {
rows, err := db.Query(selectSql)
if err != nil {
return nil, nil, err
}
@@ -257,7 +318,11 @@ func (d *DbInstance) SelectData(execSql string) ([]string, []map[string]interfac
colNames := make([]string, 0)
// 是否第一次遍历,列名数组只需第一次遍历时加入
isFirst := true
rowNum := 0
for rows.Next() {
rowNum++
biz.IsTrue(rowNum <= Max_Rows, "结果集 > 2000, 请完善条件或分页信息")
// 不Scan也会导致等待该链接实际处于未工作的状态然后也会导致连接数迅速达到最大
err := rows.Scan(scans...)
if err != nil {
@@ -271,6 +336,7 @@ func (d *DbInstance) SelectData(execSql string) ([]string, []map[string]interfac
colName := colType.Name()
// 字段类型名
colScanType := colType.ScanType().Name()
// 如果是第一行则将列名加入到列信息中由于map是无序的所有需要返回列名的有序数组
if isFirst {
colNames = append(colNames, colName)
}
@@ -316,6 +382,45 @@ func (d *DbInstance) SelectData(execSql string) ([]string, []map[string]interfac
return colNames, result, nil
}
type PqSqlDialer struct {
sshTunnelMachine *machine.SshTunnelMachine
}
func (pd *PqSqlDialer) Dial(network, address string) (net.Conn, error) {
if sshConn, err := pd.sshTunnelMachine.GetDialConn("tcp", address); err == nil {
// 将ssh conn包装否则redis内部设置超时会报错,ssh conn不支持设置超时会返回错误: ssh: tcpChan: deadline not supported
return &utils.WrapSshConn{Conn: sshConn}, nil
} else {
return nil, err
}
}
func (pd *PqSqlDialer) DialTimeout(network, address string, timeout time.Duration) (net.Conn, error) {
return pd.Dial(network, address)
}
// db实例
type DbInstance struct {
Id string
Type string
ProjectId uint64
db *sql.DB
sshTunnelMachineId uint64
}
// 执行查询语句
// 依次返回 列名数组结果map错误
func (d *DbInstance) SelectData(execSql string) ([]string, []map[string]interface{}, error) {
execSql = strings.Trim(execSql, " ")
isSelect := strings.HasPrefix(execSql, "SELECT") || strings.HasPrefix(execSql, "select")
isShow := strings.HasPrefix(execSql, "show")
isExplain := strings.HasPrefix(execSql, "explain")
if !isSelect && !isShow && !isExplain {
return nil, nil, errors.New("该sql非查询语句")
}
return SelectDataByDb(d.db, execSql)
}
// 执行 update, insert, delete建表等sql
// 返回影响条数和错误
func (d *DbInstance) Exec(sql string) (int64, error) {
@@ -326,24 +431,41 @@ func (d *DbInstance) Exec(sql string) (int64, error) {
return res.RowsAffected()
}
// 获取数据库元信息实现接口
func (di *DbInstance) GetMeta() DbMetadata {
dbType := di.Type
if dbType == entity.DbTypeMysql {
return &MysqlMetadata{di: di}
}
if dbType == entity.DbTypePostgres {
return &PgsqlMetadata{di: di}
}
return nil
}
// 关闭连接
func (d *DbInstance) Close() {
d.db.Close()
if d.db != nil {
if err := d.db.Close(); err != nil {
global.Log.Errorf("关闭数据库实例[%s]连接失败: %s", d.Id, err.Error())
}
d.db = nil
}
}
// 获取dataSourceName
func getDsn(d *entity.Db) string {
func getDsn(d *entity.Db, db string) string {
var dsn string
if d.Type == "mysql" {
dsn = fmt.Sprintf("%s:%s@%s(%s:%d)/%s?timeout=8s", d.Username, d.Password, d.Network, d.Host, d.Port, d.Database)
if d.Type == entity.DbTypeMysql {
dsn = fmt.Sprintf("%s:%s@%s(%s:%d)/%s?timeout=8s", d.Username, d.Password, d.Network, d.Host, d.Port, db)
if d.Params != "" {
dsn = fmt.Sprintf("%s&%s", dsn, d.Params)
}
return dsn
}
if d.Type == "postgres" {
dsn = fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", d.Host, d.Port, d.Username, d.Password, d.Database)
if d.Type == entity.DbTypePostgres {
dsn = fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", d.Host, d.Port, d.Username, d.Password, db)
if d.Params != "" {
dsn = fmt.Sprintf("%s %s", dsn, strings.Join(strings.Split(d.Params, "&"), " "))
}
@@ -357,8 +479,32 @@ func CloseDb(dbId uint64, db string) {
dbCache.Delete(GetDbCacheKey(dbId, db))
}
//-----------------------------------元数据-------------------------------------------
// -----------------------------------元数据-------------------------------------------
// 数据库元信息接口(表、列等元信息)
type DbMetadata interface {
// 获取表基础元信息, 如表名等
GetTables() []map[string]interface{}
// 获取列元信息, 如列名等
GetColumns(tableNames ...string) []map[string]interface{}
// 获取表主键字段名,默认第一个字段
GetPrimaryKey(tablename string) string
// 获取表信息比GetTables获取更详细的表信息
GetTableInfos() []map[string]interface{}
// 获取表索引信息
GetTableIndex(tableName string) []map[string]interface{}
// 获取建表ddl
GetCreateTableDdl(tableName string) []map[string]interface{}
}
// 默认每次查询列元信息数量
const DEFAULT_COLUMN_SIZE = 2000
// ---------------------------------- mysql元数据 -----------------------------------
const (
// mysql 表信息元数据
MYSQL_TABLE_MA = `SELECT table_name tableName, engine, table_comment tableComment,
@@ -377,9 +523,6 @@ const (
FROM information_schema.STATISTICS
WHERE table_schema = (SELECT database()) AND table_name = '%s' LIMIT 500`
// 默认每次查询列元信息数量
DEFAULT_COLUMN_SIZE = 2000
// mysql 列信息元数据
MYSQL_COLUMN_MA = `SELECT table_name tableName, column_name columnName, column_type columnType,
column_comment columnComment, column_key columnKey, extra, is_nullable nullable from information_schema.columns
@@ -390,6 +533,74 @@ const (
WHERE table_name in (%s) AND table_schema = (SELECT database())`
)
type MysqlMetadata struct {
di *DbInstance
}
// 获取表基础元信息, 如表名等
func (mm *MysqlMetadata) GetTables() []map[string]interface{} {
_, res, _ := mm.di.SelectData(MYSQL_TABLE_MA)
return res
}
// 获取列元信息, 如列名等
func (mm *MysqlMetadata) GetColumns(tableNames ...string) []map[string]interface{} {
var sql, tableName string
for i := 0; i < len(tableNames); i++ {
if i != 0 {
tableName = tableName + ", "
}
tableName = tableName + "'" + tableNames[i] + "'"
}
pageNum := 1
// 如果大于一个表,则统计列数并分页获取
if len(tableNames) > 1 {
countSql := fmt.Sprintf(MYSQL_COLOUMN_MA_COUNT, tableName)
_, countRes, _ := mm.di.SelectData(countSql)
// 查询出所有列信息总数,手动分页获取所有数据
maCount := int(countRes[0]["maNum"].(int64))
// 计算需要查询的页数
pageNum = maCount / DEFAULT_COLUMN_SIZE
if maCount%DEFAULT_COLUMN_SIZE > 0 {
pageNum++
}
}
res := make([]map[string]interface{}, 0)
for index := 0; index < pageNum; index++ {
sql = fmt.Sprintf(MYSQL_COLUMN_MA, tableName, index*DEFAULT_COLUMN_SIZE, DEFAULT_COLUMN_SIZE)
_, result, err := mm.di.SelectData(sql)
biz.ErrIsNilAppendErr(err, "获取数据库列信息失败: %s")
res = append(res, result...)
}
return res
}
// 获取表主键字段名,默认第一个字段
func (mm *MysqlMetadata) GetPrimaryKey(tablename string) string {
return mm.GetColumns(tablename)[0]["columnName"].(string)
}
// 获取表信息比GetTableMetedatas获取更详细的表信息
func (mm *MysqlMetadata) GetTableInfos() []map[string]interface{} {
_, res, _ := mm.di.SelectData(MYSQL_TABLE_INFO)
return res
}
// 获取表索引信息
func (mm *MysqlMetadata) GetTableIndex(tableName string) []map[string]interface{} {
_, res, _ := mm.di.SelectData(MYSQL_INDEX_INFO)
return res
}
// 获取建表ddl
func (mm *MysqlMetadata) GetCreateTableDdl(tableName string) []map[string]interface{} {
_, res, _ := mm.di.SelectData(fmt.Sprintf("show create table %s ", tableName))
return res
}
// ---------------------------------- pgsql元数据 -----------------------------------
const (
// postgres 表信息元数据
PGSQL_TABLE_MA = `SELECT obj_description(c.oid) AS "tableComment", c.relname AS "tableName" FROM pg_class c
@@ -436,18 +647,18 @@ const (
`
)
func (d *DbInstance) GetTableMetedatas() []map[string]interface{} {
var sql string
if d.Type == "mysql" {
sql = MYSQL_TABLE_MA
} else if d.Type == "postgres" {
sql = PGSQL_TABLE_MA
}
_, res, _ := d.SelectData(sql)
type PgsqlMetadata struct {
di *DbInstance
}
// 获取表基础元信息, 如表名等
func (pm *PgsqlMetadata) GetTables() []map[string]interface{} {
_, res, _ := pm.di.SelectData(PGSQL_TABLE_MA)
return res
}
func (d *DbInstance) GetColumnMetadatas(tableNames ...string) []map[string]interface{} {
// 获取列元信息, 如列名等
func (pm *PgsqlMetadata) GetColumns(tableNames ...string) []map[string]interface{} {
var sql, tableName string
for i := 0; i < len(tableNames); i++ {
if i != 0 {
@@ -456,68 +667,48 @@ func (d *DbInstance) GetColumnMetadatas(tableNames ...string) []map[string]inter
tableName = tableName + "'" + tableNames[i] + "'"
}
var countSqlTmp string
var sqlTmp string
if d.Type == "mysql" {
countSqlTmp = MYSQL_COLOUMN_MA_COUNT
sqlTmp = MYSQL_COLUMN_MA
} else if d.Type == "postgres" {
countSqlTmp = PGSQL_COLUMN_MA_COUNT
sqlTmp = PGSQL_COLUMN_MA
}
countSql := fmt.Sprintf(countSqlTmp, tableName)
_, countRes, _ := d.SelectData(countSql)
// 查询出所有列信息总数,手动分页获取所有数据
maCount := int(countRes[0]["maNum"].(int64))
// 计算需要查询的页数
pageNum := maCount / DEFAULT_COLUMN_SIZE
if maCount%DEFAULT_COLUMN_SIZE > 0 {
pageNum++
pageNum := 1
// 如果大于一个表,则统计列数并分页获取
if len(tableNames) > 1 {
countSql := fmt.Sprintf(PGSQL_COLUMN_MA_COUNT, tableName)
_, countRes, _ := pm.di.SelectData(countSql)
// 查询出所有列信息总数,手动分页获取所有数据
maCount := int(countRes[0]["maNum"].(int64))
// 计算需要查询的页数
pageNum = maCount / DEFAULT_COLUMN_SIZE
if maCount%DEFAULT_COLUMN_SIZE > 0 {
pageNum++
}
}
res := make([]map[string]interface{}, 0)
for index := 0; index < pageNum; index++ {
sql = fmt.Sprintf(sqlTmp, tableName, index*DEFAULT_COLUMN_SIZE, DEFAULT_COLUMN_SIZE)
_, result, err := d.SelectData(sql)
sql = fmt.Sprintf(PGSQL_COLUMN_MA, tableName, index*DEFAULT_COLUMN_SIZE, DEFAULT_COLUMN_SIZE)
_, result, err := pm.di.SelectData(sql)
biz.ErrIsNilAppendErr(err, "获取数据库列信息失败: %s")
res = append(res, result...)
}
return res
}
// 获取表主键,目前默认第一列为主键
func (d *DbInstance) GetPrimaryKey(tablename string) string {
return d.GetColumnMetadatas(tablename)[0]["columnName"].(string)
// 获取表主键字段名,默认第一个字段
func (pm *PgsqlMetadata) GetPrimaryKey(tablename string) string {
return pm.GetColumns(tablename)[0]["columnName"].(string)
}
func (d *DbInstance) GetTableInfos() []map[string]interface{} {
var sql string
if d.Type == "mysql" {
sql = MYSQL_TABLE_INFO
} else if d.Type == "postgres" {
sql = PGSQL_TABLE_INFO
}
_, res, _ := d.SelectData(sql)
// 获取表信息比GetTables获取更详细的表信息
func (pm *PgsqlMetadata) GetTableInfos() []map[string]interface{} {
_, res, _ := pm.di.SelectData(PGSQL_TABLE_INFO)
return res
}
func (d *DbInstance) GetTableIndex(tableName string) []map[string]interface{} {
var sql string
if d.Type == "mysql" {
sql = fmt.Sprintf(MYSQL_INDEX_INFO, tableName)
} else if d.Type == "postgres" {
sql = fmt.Sprintf(PGSQL_INDEX_INFO, tableName)
}
_, res, _ := d.SelectData(sql)
// 获取表索引信息
func (pm *PgsqlMetadata) GetTableIndex(tableName string) []map[string]interface{} {
_, res, _ := pm.di.SelectData(PGSQL_INDEX_INFO)
return res
}
func (d *DbInstance) GetCreateTableDdl(tableName string) []map[string]interface{} {
var sql string
if d.Type == "mysql" {
sql = fmt.Sprintf("show create table %s ", tableName)
}
_, res, _ := d.SelectData(sql)
return res
// 获取建表ddl
func (mm *PgsqlMetadata) GetCreateTableDdl(tableName string) []map[string]interface{} {
return nil
}

View File

@@ -3,6 +3,7 @@ package application
import (
"encoding/json"
"fmt"
"mayfly-go/internal/devops/domain/entity"
"mayfly-go/internal/devops/domain/repository"
"mayfly-go/internal/devops/infrastructure/persistence"
@@ -89,7 +90,7 @@ func doUpdate(update *sqlparser.Update, dbInstance *DbInstance, dbSqlExec *entit
}
// 获取表主键列名,排除使用别名
primaryKey := dbInstance.GetPrimaryKey(tableName)
primaryKey := dbInstance.GetMeta().GetPrimaryKey(tableName)
updateColumnsAndPrimaryKey := strings.Join(updateColumns, ",") + "," + primaryKey
// 查询要更新字段数据的旧值,以及主键值

View File

@@ -32,6 +32,9 @@ type Machine interface {
// 获取机器连接
GetCli(id uint64) *machine.Cli
// 获取ssh隧道机器连接
GetSshTunnelMachine(id uint64) *machine.SshTunnelMachine
}
type machineAppImpl struct {
@@ -53,7 +56,7 @@ func (m *machineAppImpl) Count(condition *entity.Machine) int64 {
func (m *machineAppImpl) Save(me *entity.Machine) {
// ’修改机器信息且密码不为空‘ or ‘新增’需要测试是否可连接
if (me.Id != 0 && me.Password != "") || me.Id == 0 {
biz.ErrIsNilAppendErr(machine.TestConn(me), "该机器无法连接: %s")
biz.ErrIsNilAppendErr(machine.TestConn(*me, func(u uint64) *entity.Machine { return m.GetById(u) }), "该机器无法连接: %s")
}
oldMachine := &entity.Machine{Ip: me.Ip, Port: me.Port, Username: me.Username}
@@ -66,11 +69,13 @@ func (m *machineAppImpl) Save(me *entity.Machine) {
}
// 关闭连接
machine.DeleteCli(me.Id)
me.PwdEncrypt()
m.machineRepo.UpdateById(me)
} else {
biz.IsTrue(err != nil, "该机器信息已存在")
// 新增机器,默认启用状态
me.Status = entity.MachineStatusEnable
me.PwdEncrypt()
m.machineRepo.Create(me)
}
}
@@ -120,9 +125,21 @@ func (m *machineAppImpl) GetById(id uint64, cols ...string) *entity.Machine {
func (m *machineAppImpl) GetCli(id uint64) *machine.Cli {
cli, err := machine.GetCli(id, func(machineId uint64) *entity.Machine {
machine := m.GetById(machineId)
machine.PwdDecrypt()
biz.IsTrue(machine.Status == entity.MachineStatusEnable, "该机器已被停用")
return machine
})
biz.ErrIsNilAppendErr(err, "获取客户端错误: %s")
return cli
}
func (m *machineAppImpl) GetSshTunnelMachine(id uint64) *machine.SshTunnelMachine {
sshTunnel, err := machine.GetSshTunnelMachine(id, func(machineId uint64) *entity.Machine {
machine := m.GetById(machineId)
machine.PwdDecrypt()
biz.IsTrue(machine.Status == entity.MachineStatusEnable, "该机器已被停用")
return machine
})
biz.ErrIsNilAppendErr(err, "获取ssh隧道连接失败: %s")
return sshTunnel
}

View File

@@ -2,13 +2,17 @@ package application
import (
"context"
"mayfly-go/internal/constant"
"mayfly-go/internal/devops/domain/entity"
"mayfly-go/internal/devops/domain/repository"
"mayfly-go/internal/devops/infrastructure/machine"
"mayfly-go/internal/devops/infrastructure/persistence"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/cache"
"mayfly-go/pkg/global"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils"
"net"
"time"
"go.mongodb.org/mongo-driver/mongo"
@@ -80,36 +84,50 @@ func (d *mongoAppImpl) Save(m *entity.Mongo) {
}
func (d *mongoAppImpl) GetMongoCli(id uint64) *mongo.Client {
cli, err := GetMongoCli(id, func(u uint64) string {
mongo := d.GetById(id)
mongoInstance, err := GetMongoInstance(id, func(u uint64) *entity.Mongo {
mongo := d.GetById(u)
biz.NotNil(mongo, "mongo信息不存在")
return mongo.Uri
return mongo
})
biz.ErrIsNilAppendErr(err, "连接mongo失败: %s")
return cli
return mongoInstance.Cli
}
// -----------------------------------------------------------
//mongo客户端连接缓存30分钟内没有访问则会被关闭
var mongoCliCache = cache.NewTimedCache(30*time.Minute, 5*time.Second).
//mongo客户端连接缓存指定时间内没有访问则会被关闭
var mongoCliCache = cache.NewTimedCache(constant.MongoConnExpireTime, 5*time.Second).
WithUpdateAccessTime(true).
OnEvicted(func(key interface{}, value interface{}) {
global.Log.Info("关闭mongo连接: id = ", key)
value.(*mongo.Client).Disconnect(context.TODO())
global.Log.Info("删除mongo连接缓存: id = ", key)
value.(*MongoInstance).Close()
})
func GetMongoCli(mongoId uint64, getMongoUri func(uint64) string) (*mongo.Client, error) {
cli, err := mongoCliCache.ComputeIfAbsent(mongoId, func(key interface{}) (interface{}, error) {
c, err := connect(getMongoUri(mongoId))
func init() {
machine.AddCheckSshTunnelMachineUseFunc(func(machineId uint64) bool {
// 遍历所有mongo连接实例若存在redis实例使用该ssh隧道机器则返回true表示还在使用中...
items := mongoCliCache.Items()
for _, v := range items {
if v.Value.(*MongoInstance).sshTunnelMachineId == machineId {
return true
}
}
return false
})
}
// 获取mongo的连接实例
func GetMongoInstance(mongoId uint64, getMongoEntity func(uint64) *entity.Mongo) (*MongoInstance, error) {
mi, err := mongoCliCache.ComputeIfAbsent(mongoId, func(_ interface{}) (interface{}, error) {
c, err := connect(getMongoEntity(mongoId))
if err != nil {
return nil, err
}
return c, nil
})
if cli != nil {
return cli.(*mongo.Client), err
if mi != nil {
return mi.(*MongoInstance), err
}
return nil, err
}
@@ -118,16 +136,61 @@ func DeleteMongoCache(mongoId uint64) {
mongoCliCache.Delete(mongoId)
}
type MongoInstance struct {
Id uint64
ProjectId uint64
Cli *mongo.Client
sshTunnelMachineId uint64
}
func (mi *MongoInstance) Close() {
if mi.Cli != nil {
if err := mi.Cli.Disconnect(context.Background()); err != nil {
global.Log.Errorf("关闭mongo实例[%d]连接失败: %s", mi.Id, err)
}
mi.Cli = nil
}
}
// 连接mongo并返回client
func connect(uri string) (*mongo.Client, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
func connect(me *entity.Mongo) (*MongoInstance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri).SetMaxPoolSize(2))
mongoInstance := &MongoInstance{Id: me.Id, ProjectId: me.ProjectId}
mongoOptions := options.Client().ApplyURI(me.Uri).
SetMaxPoolSize(1)
// 启用ssh隧道则连接隧道机器
if me.EnableSshTunnel == 1 {
mongoInstance.sshTunnelMachineId = me.SshTunnelMachineId
mongoOptions.SetDialer(&MongoSshDialer{machineId: me.SshTunnelMachineId})
}
client, err := mongo.Connect(ctx, mongoOptions)
if err != nil {
mongoInstance.Close()
return nil, err
}
if err = client.Ping(context.TODO(), nil); err != nil {
mongoInstance.Close()
return nil, err
}
global.Log.Infof("连接mongo: %s", me.Uri)
mongoInstance.Cli = client
return mongoInstance, err
}
type MongoSshDialer struct {
machineId uint64
}
func (sd *MongoSshDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
if sshConn, err := MachineApp.GetSshTunnelMachine(sd.machineId).GetDialConn(network, address); err == nil {
// 将ssh conn包装否则内部部设置超时会报错,ssh conn不支持设置超时会返回错误: ssh: tcpChan: deadline not supported
return &utils.WrapSshConn{Conn: sshConn}, nil
} else {
return nil, err
}
return client, err
}

View File

@@ -3,13 +3,17 @@ package application
import (
"context"
"fmt"
"mayfly-go/internal/constant"
"mayfly-go/internal/devops/domain/entity"
"mayfly-go/internal/devops/domain/repository"
"mayfly-go/internal/devops/infrastructure/machine"
"mayfly-go/internal/devops/infrastructure/persistence"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/cache"
"mayfly-go/pkg/global"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils"
"net"
"strings"
"time"
@@ -65,7 +69,10 @@ func (r *redisAppImpl) GetRedisBy(condition *entity.Redis, cols ...string) error
}
func (r *redisAppImpl) Save(re *entity.Redis) {
TestRedisConnection(re)
// ’修改信息且密码不为空‘ or ‘新增’需要测试是否可连接
if (re.Id != 0 && re.Password != "") || re.Id == 0 {
TestRedisConnection(re)
}
// 查找是否存在该库
oldRedis := &entity.Redis{Host: re.Host, Db: re.Db}
@@ -73,6 +80,7 @@ func (r *redisAppImpl) Save(re *entity.Redis) {
if re.Id == 0 {
biz.IsTrue(err != nil, "该库已存在")
re.PwdEncrypt()
r.redisRepo.Insert(re)
} else {
// 如果存在该库,则校验修改的库是否为该库
@@ -81,6 +89,7 @@ func (r *redisAppImpl) Save(re *entity.Redis) {
}
// 先关闭数据库连接
CloseRedis(re.Id)
re.PwdEncrypt()
r.redisRepo.Update(re)
}
}
@@ -103,28 +112,35 @@ func (r *redisAppImpl) GetRedisInstance(id uint64) *RedisInstance {
}
// 缓存不存在则回调获取redis信息
re := r.GetById(id)
re.PwdDecrypt()
biz.NotNil(re, "redis信息不存在")
redisMode := re.Mode
ri := &RedisInstance{Id: id, ProjectId: re.ProjectId, Mode: redisMode}
var ri *RedisInstance
if redisMode == "" || redisMode == entity.RedisModeStandalone {
rcli := getRedisCient(re)
ri = getRedisCient(re)
// 测试连接
_, e := rcli.Ping(context.Background()).Result()
_, e := ri.Cli.Ping(context.Background()).Result()
if e != nil {
rcli.Close()
ri.Close()
panic(biz.NewBizErr(fmt.Sprintf("redis连接失败: %s", e.Error())))
}
ri.Cli = rcli
} else if redisMode == entity.RedisModeCluster {
ccli := getRedisClusterClient(re)
ri = getRedisClusterClient(re)
// 测试连接
_, e := ccli.Ping(context.Background()).Result()
_, e := ri.ClusterCli.Ping(context.Background()).Result()
if e != nil {
ccli.Close()
ri.Close()
panic(biz.NewBizErr(fmt.Sprintf("redis集群连接失败: %s", e.Error())))
}
ri.ClusterCli = ccli
} else if redisMode == entity.RedisModeSentinel {
ri = getRedisSentinelCient(re)
// 测试连接
_, e := ri.Cli.Ping(context.Background()).Result()
if e != nil {
ri.Close()
panic(biz.NewBizErr(fmt.Sprintf("redis sentinel连接失败: %s", e.Error())))
}
}
global.Log.Infof("连接redis: %s", re.Host)
@@ -134,27 +150,78 @@ func (r *redisAppImpl) GetRedisInstance(id uint64) *RedisInstance {
return ri
}
func getRedisCient(re *entity.Redis) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: re.Host,
Password: re.Password, // no password set
DB: re.Db, // use default DB
DialTimeout: 8 * time.Second,
})
func getRedisCient(re *entity.Redis) *RedisInstance {
ri := &RedisInstance{Id: re.Id, ProjectId: re.ProjectId, Mode: re.Mode}
redisOptions := &redis.Options{
Addr: re.Host,
Password: re.Password, // no password set
DB: re.Db, // use default DB
DialTimeout: 8 * time.Second,
ReadTimeout: -1, // Disable timeouts, because SSH does not support deadlines.
WriteTimeout: -1,
}
if re.EnableSshTunnel == 1 {
ri.sshTunnelMachineId = re.SshTunnelMachineId
redisOptions.Dialer = getRedisDialer(re.SshTunnelMachineId)
}
ri.Cli = redis.NewClient(redisOptions)
return ri
}
func getRedisClusterClient(re *entity.Redis) *redis.ClusterClient {
return redis.NewClusterClient(&redis.ClusterOptions{
func getRedisClusterClient(re *entity.Redis) *RedisInstance {
ri := &RedisInstance{Id: re.Id, ProjectId: re.ProjectId, Mode: re.Mode}
redisClusterOptions := &redis.ClusterOptions{
Addrs: strings.Split(re.Host, ","),
Password: re.Password,
DialTimeout: 8 * time.Second,
})
}
if re.EnableSshTunnel == 1 {
ri.sshTunnelMachineId = re.SshTunnelMachineId
redisClusterOptions.Dialer = getRedisDialer(re.SshTunnelMachineId)
}
ri.ClusterCli = redis.NewClusterClient(redisClusterOptions)
return ri
}
func getRedisSentinelCient(re *entity.Redis) *RedisInstance {
ri := &RedisInstance{Id: re.Id, ProjectId: re.ProjectId, Mode: re.Mode}
// sentinel模式host为 masterName=host:port,host:port
masterNameAndHosts := strings.Split(re.Host, "=")
sentinelOptions := &redis.FailoverOptions{
MasterName: masterNameAndHosts[0],
SentinelAddrs: strings.Split(masterNameAndHosts[1], ","),
Password: re.Password, // no password set
DB: re.Db, // use default DB
DialTimeout: 8 * time.Second,
ReadTimeout: -1, // Disable timeouts, because SSH does not support deadlines.
WriteTimeout: -1,
}
if re.EnableSshTunnel == 1 {
ri.sshTunnelMachineId = re.SshTunnelMachineId
sentinelOptions.Dialer = getRedisDialer(re.SshTunnelMachineId)
}
ri.Cli = redis.NewFailoverClient(sentinelOptions)
return ri
}
func getRedisDialer(machineId uint64) func(ctx context.Context, network, addr string) (net.Conn, error) {
sshTunnel := MachineApp.GetSshTunnelMachine(machineId)
return func(_ context.Context, network, addr string) (net.Conn, error) {
if sshConn, err := sshTunnel.GetDialConn(network, addr); err == nil {
// 将ssh conn包装否则redis内部设置超时会报错,ssh conn不支持设置超时会返回错误: ssh: tcpChan: deadline not supported
return &utils.WrapSshConn{Conn: sshConn}, nil
} else {
return nil, err
}
}
}
//------------------------------------------------------------------------------
// redis客户端连接缓存30分钟内没有访问则会被关闭
var redisCache = cache.NewTimedCache(30*time.Minute, 5*time.Second).
// redis客户端连接缓存指定时间内没有访问则会被关闭
var redisCache = cache.NewTimedCache(constant.RedisConnExpireTime, 5*time.Second).
WithUpdateAccessTime(true).
OnEvicted(func(key interface{}, value interface{}) {
global.Log.Info(fmt.Sprintf("删除redis连接缓存 id = %d", key))
@@ -166,16 +233,33 @@ func CloseRedis(id uint64) {
redisCache.Delete(id)
}
func init() {
machine.AddCheckSshTunnelMachineUseFunc(func(machineId uint64) bool {
// 遍历所有redis连接实例若存在redis实例使用该ssh隧道机器则返回true表示还在使用中...
items := redisCache.Items()
for _, v := range items {
if v.Value.(*RedisInstance).sshTunnelMachineId == machineId {
return true
}
}
return false
})
}
func TestRedisConnection(re *entity.Redis) {
var cmd redis.Cmdable
if re.Mode == "" || re.Mode == entity.RedisModeStandalone {
rcli := getRedisCient(re)
defer rcli.Close()
cmd = rcli
cmd = rcli.Cli
} else if re.Mode == entity.RedisModeCluster {
ccli := getRedisClusterClient(re)
defer ccli.Close()
cmd = ccli
cmd = ccli.ClusterCli
} else if re.Mode == entity.RedisModeSentinel {
rcli := getRedisSentinelCient(re)
defer rcli.Close()
cmd = rcli.Cli
}
// 测试连接
@@ -185,20 +269,21 @@ func TestRedisConnection(re *entity.Redis) {
// redis实例
type RedisInstance struct {
Id uint64
ProjectId uint64
Mode string
Cli *redis.Client
ClusterCli *redis.ClusterClient
Id uint64
ProjectId uint64
Mode string
Cli *redis.Client
ClusterCli *redis.ClusterClient
sshTunnelMachineId uint64
}
// 获取命令执行接口的具体实现
func (r *RedisInstance) GetCmdable() redis.Cmdable {
redisMode := r.Mode
if redisMode == "" || redisMode == entity.RedisModeStandalone {
if redisMode == "" || redisMode == entity.RedisModeStandalone || r.Mode == entity.RedisModeSentinel {
return r.Cli
}
if r.Mode == entity.RedisModeCluster {
if redisMode == entity.RedisModeCluster {
return r.ClusterCli
}
return nil
@@ -211,11 +296,16 @@ func (r *RedisInstance) Scan(cursor uint64, match string, count int64) ([]string
}
func (r *RedisInstance) Close() {
if r.Mode == entity.RedisModeStandalone {
r.Cli.Close()
return
if r.Mode == entity.RedisModeStandalone || r.Mode == entity.RedisModeSentinel {
if err := r.Cli.Close(); err != nil {
global.Log.Errorf("关闭redis单机实例[%d]连接失败: %s", r.Id, err.Error())
}
r.Cli = nil
}
if r.Mode == entity.RedisModeCluster {
r.ClusterCli.Close()
if err := r.ClusterCli.Close(); err != nil {
global.Log.Errorf("关闭redis集群实例[%d]连接失败: %s", r.Id, err.Error())
}
r.ClusterCli = nil
}
}

View File

@@ -1,6 +1,8 @@
package entity
import (
"fmt"
"mayfly-go/internal/common/utils"
"mayfly-go/pkg/model"
)
@@ -20,4 +22,35 @@ type Db struct {
Project string
EnvId uint64
Env string
EnableSshTunnel int8 `orm:"column(enable_ssh_tunnel)" json:"enableSshTunnel"` // 是否启用ssh隧道
SshTunnelMachineId uint64 `orm:"column(ssh_tunnel_machine_id)" json:"sshTunnelMachineId"` // ssh隧道机器id
}
// 获取数据库连接网络, 若没有使用ssh隧道则直接返回。否则返回拼接的网络需要注册至指定dial
func (d *Db) GetNetwork() string {
network := d.Network
if d.EnableSshTunnel == 0 || d.EnableSshTunnel == -1 {
if network == "" {
return "tcp"
} else {
return network
}
}
return fmt.Sprintf("%s+ssh:%d", d.Type, d.SshTunnelMachineId)
}
func (d *Db) PwdEncrypt() {
// 密码替换为加密后的密码
d.Password = utils.PwdAesEncrypt(d.Password)
}
func (d *Db) PwdDecrypt() {
// 密码替换为解密后的密码
d.Password = utils.PwdAesDecrypt(d.Password)
}
const (
DbTypeMysql = "mysql"
DbTypePostgres = "postgres"
)

View File

@@ -1,23 +1,39 @@
package entity
import (
"mayfly-go/internal/common/utils"
"mayfly-go/pkg/model"
)
type Machine struct {
model.Model
ProjectId uint64 `json:"projectId"`
ProjectName string `json:"projectName"`
Name string `json:"name"`
Ip string `json:"ip"` // IP地址
Username string `json:"username"` // 用户名
Password string `json:"-"`
Port int `json:"port"` // 端口号
Status int8 `json:"status"` // 状态 1:启用2:停用
Remark string `json:"remark"` // 备注
ProjectId uint64 `json:"projectId"`
ProjectName string `json:"projectName"`
Name string `json:"name"`
Ip string `json:"ip"` // IP地址
Username string `json:"username"` // 用户名
AuthMethod int8 `json:"authMethod"` // 授权认证方式
Password string `json:"-"`
Port int `json:"port"` // 端口号
Status int8 `json:"status"` // 状态 1:启用2:停用
Remark string `json:"remark"` // 备注
EnableSshTunnel int8 `json:"enableSshTunnel"` // 是否启用ssh隧道
SshTunnelMachineId uint64 `json:"sshTunnelMachineId"` // ssh隧道机器id
}
const (
MachineStatusEnable int8 = 1 // 启用状态
MachineStatusDisable int8 = -1 // 禁用状态
MachineStatusEnable int8 = 1 // 启用状态
MachineStatusDisable int8 = -1 // 禁用状态
MachineAuthMethodPassword int8 = 1 // 密码登录
MachineAuthMethodPublicKey int8 = 2 // 公钥免密登录
)
func (m *Machine) PwdEncrypt() {
// 密码替换为加密后的密码
m.Password = utils.PwdAesEncrypt(m.Password)
}
func (m *Machine) PwdDecrypt() {
// 密码替换为解密后的密码
m.Password = utils.PwdAesDecrypt(m.Password)
}

View File

@@ -5,10 +5,12 @@ import "mayfly-go/pkg/model"
type Mongo struct {
model.Model
Name string `orm:"column(name)" json:"name"`
Uri string `orm:"column(uri)" json:"uri"`
ProjectId uint64 `json:"projectId"`
Project string `json:"project"`
EnvId uint64 `json:"envId"`
Env string `json:"env"`
Name string `orm:"column(name)" json:"name"`
Uri string `orm:"column(uri)" json:"uri"`
EnableSshTunnel int8 `orm:"column(enable_ssh_tunnel)" json:"enableSshTunnel"` // 是否启用ssh隧道
SshTunnelMachineId uint64 `orm:"column(ssh_tunnel_machine_id)" json:"sshTunnelMachineId"` // ssh隧道机器id
ProjectId uint64 `json:"projectId"`
Project string `json:"project"`
EnvId uint64 `json:"envId"`
Env string `json:"env"`
}

View File

@@ -1,24 +1,38 @@
package entity
import (
"mayfly-go/internal/common/utils"
"mayfly-go/pkg/model"
)
type Redis struct {
model.Model
Host string `orm:"column(host)" json:"host"`
Mode string `json:"mode"`
Password string `orm:"column(password)" json:"-"`
Db int `orm:"column(database)" json:"db"`
Remark string
ProjectId uint64
Project string
EnvId uint64
Env string
Host string `orm:"column(host)" json:"host"`
Mode string `json:"mode"`
Password string `orm:"column(password)" json:"-"`
Db int `orm:"column(database)" json:"db"`
EnableSshTunnel int8 `orm:"column(enable_ssh_tunnel)" json:"enableSshTunnel"` // 是否启用ssh隧道
SshTunnelMachineId uint64 `orm:"column(ssh_tunnel_machine_id)" json:"sshTunnelMachineId"` // ssh隧道机器id
Remark string
ProjectId uint64
Project string
EnvId uint64
Env string
}
const (
RedisModeStandalone = "standalone"
RedisModeCluster = "cluster"
RedisModeSentinel = "sentinel"
)
func (r *Redis) PwdEncrypt() {
// 密码替换为加密后的密码
r.Password = utils.PwdAesEncrypt(r.Password)
}
func (r *Redis) PwdDecrypt() {
// 密码替换为解密后的密码
r.Password = utils.PwdAesDecrypt(r.Password)
}

View File

@@ -3,6 +3,7 @@ package machine
import (
"errors"
"fmt"
"mayfly-go/internal/constant"
"mayfly-go/internal/devops/domain/entity"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/cache"
@@ -18,62 +19,12 @@ import (
// 客户端信息
type Cli struct {
machine *entity.Machine
// ssh客户端
client *ssh.Client
sftpClient *sftp.Client
}
client *ssh.Client // ssh客户端
sftpClient *sftp.Client // sftp客户端
// 机器客户端连接缓存45分钟内没有访问则会被关闭
var cliCache = cache.NewTimedCache(45*time.Minute, 5*time.Second).
WithUpdateAccessTime(true).
OnEvicted(func(key interface{}, value interface{}) {
value.(*Cli).Close()
})
// 是否存在指定id的客户端连接
func HasCli(machineId uint64) bool {
if _, ok := cliCache.Get(machineId); ok {
return true
}
return false
}
// 删除指定机器客户端,并关闭客户端连接
func DeleteCli(id uint64) {
cliCache.Delete(id)
}
// 从缓存中获取客户端信息,不存在则回调获取机器信息函数,并新建
func GetCli(machineId uint64, getMachine func(uint64) *entity.Machine) (*Cli, error) {
cli, err := cliCache.ComputeIfAbsent(machineId, func(key interface{}) (interface{}, error) {
c, err := newClient(getMachine(machineId))
if err != nil {
return nil, err
}
return c, nil
})
if cli != nil {
return cli.(*Cli), err
}
return nil, err
}
//根据机器信息创建客户端对象
func newClient(machine *entity.Machine) (*Cli, error) {
if machine == nil {
return nil, errors.New("机器不存在")
}
global.Log.Infof("[%s]机器连接:%s:%d", machine.Name, machine.Ip, machine.Port)
cli := new(Cli)
cli.machine = machine
err := cli.connect()
if err != nil {
return nil, err
}
return cli, nil
enableSshTunnel int8
sshTunnelMachineId uint64
}
//连接
@@ -83,16 +34,7 @@ func (c *Cli) connect() error {
return nil
}
m := c.machine
config := ssh.ClientConfig{
User: m.Username,
Auth: []ssh.AuthMethod{ssh.Password(m.Password)},
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return nil
},
Timeout: 5 * time.Second,
}
addr := fmt.Sprintf("%s:%d", m.Ip, m.Port)
sshClient, err := ssh.Dial("tcp", addr, &config)
sshClient, err := GetSshClient(m)
if err != nil {
return err
}
@@ -100,26 +42,7 @@ func (c *Cli) connect() error {
return nil
}
// 测试连接
func TestConn(m *entity.Machine) error {
config := ssh.ClientConfig{
User: m.Username,
Auth: []ssh.AuthMethod{ssh.Password(m.Password)},
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return nil
},
Timeout: 5 * time.Second,
}
addr := fmt.Sprintf("%s:%d", m.Ip, m.Port)
sshClient, err := ssh.Dial("tcp", addr, &config)
if err != nil {
return err
}
defer sshClient.Close()
return nil
}
// 关闭client和并从缓存中移除
// 关闭client并从缓存中移除如果使用隧道则也关闭
func (c *Cli) Close() {
m := c.machine
global.Log.Info(fmt.Sprintf("关闭机器客户端连接-> id: %d, name: %s, ip: %s", m.Id, m.Name, m.Ip))
@@ -131,6 +54,9 @@ func (c *Cli) Close() {
c.sftpClient.Close()
c.sftpClient = nil
}
if c.enableSshTunnel == 1 {
CloseSshTunnelMachine(c.sshTunnelMachineId, c.machine.Id)
}
}
// 获取sftp client
@@ -184,3 +110,144 @@ func (c *Cli) Run(shell string) (*string, error) {
func (c *Cli) GetMachine() *entity.Machine {
return c.machine
}
// 机器客户端连接缓存,指定时间内没有访问则会被关闭
var cliCache = cache.NewTimedCache(constant.MachineConnExpireTime, 5*time.Second).
WithUpdateAccessTime(true).
OnEvicted(func(_, value interface{}) {
value.(*Cli).Close()
})
func init() {
AddCheckSshTunnelMachineUseFunc(func(machineId uint64) bool {
// 遍历所有机器连接实例若存在机器连接实例使用该ssh隧道机器则返回true表示还在使用中...
items := cliCache.Items()
for _, v := range items {
if v.Value.(*Cli).sshTunnelMachineId == machineId {
return true
}
}
return false
})
}
// 是否存在指定id的客户端连接
func HasCli(machineId uint64) bool {
if _, ok := cliCache.Get(machineId); ok {
return true
}
return false
}
// 删除指定机器客户端,并关闭客户端连接
func DeleteCli(id uint64) {
cliCache.Delete(id)
}
// 从缓存中获取客户端信息,不存在则回调获取机器信息函数,并新建
func GetCli(machineId uint64, getMachine func(uint64) *entity.Machine) (*Cli, error) {
cli, err := cliCache.ComputeIfAbsent(machineId, func(_ interface{}) (interface{}, error) {
me := getMachine(machineId)
err := IfUseSshTunnelChangeIpPort(me, getMachine)
if err != nil {
return nil, fmt.Errorf("ssh隧道连接失败: %s", err.Error())
}
c, err := newClient(me)
if err != nil {
CloseSshTunnelMachine(me.SshTunnelMachineId, me.Id)
return nil, err
}
c.enableSshTunnel = me.EnableSshTunnel
c.sshTunnelMachineId = me.SshTunnelMachineId
return c, nil
})
if cli != nil {
return cli.(*Cli), err
}
return nil, err
}
// 测试连接使用传值的方式而非引用。因为如果使用了ssh隧道则ip和端口会变为本地映射地址与端口
func TestConn(me entity.Machine, getSshTunnelMachine func(uint64) *entity.Machine) error {
originId := me.Id
if originId == 0 {
// 随机设置一个ip如果使用了隧道则用于临时保存隧道
me.Id = uint64(time.Now().Nanosecond())
}
err := IfUseSshTunnelChangeIpPort(&me, getSshTunnelMachine)
biz.ErrIsNilAppendErr(err, "ssh隧道连接失败: %s")
if me.EnableSshTunnel == 1 {
defer CloseSshTunnelMachine(me.SshTunnelMachineId, me.Id)
}
sshClient, err := GetSshClient(&me)
if err != nil {
return err
}
defer sshClient.Close()
return nil
}
// 如果使用了ssh隧道则修改机器ip port为暴露的ip port
func IfUseSshTunnelChangeIpPort(me *entity.Machine, getMachine func(uint64) *entity.Machine) error {
if me.EnableSshTunnel != 1 {
return nil
}
sshTunnelMachine, err := GetSshTunnelMachine(me.SshTunnelMachineId, func(u uint64) *entity.Machine {
return getMachine(u)
})
if err != nil {
return err
}
exposeIp, exposePort, err := sshTunnelMachine.OpenSshTunnel(me.Id, me.Ip, me.Port)
if err != nil {
return err
}
// 修改机器ip地址
me.Ip = exposeIp
me.Port = exposePort
return nil
}
func GetSshClient(m *entity.Machine) (*ssh.Client, error) {
config := ssh.ClientConfig{
User: m.Username,
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return nil
},
Timeout: 5 * time.Second,
}
if m.AuthMethod == entity.MachineAuthMethodPassword {
config.Auth = []ssh.AuthMethod{ssh.Password(m.Password)}
} else if m.AuthMethod == entity.MachineAuthMethodPublicKey {
if signer, err := ssh.ParsePrivateKey([]byte(m.Password)); err != nil {
return nil, err
} else {
config.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
}
}
addr := fmt.Sprintf("%s:%d", m.Ip, m.Port)
sshClient, err := ssh.Dial("tcp", addr, &config)
if err != nil {
return nil, err
}
return sshClient, nil
}
//根据机器信息创建客户端对象
func newClient(machine *entity.Machine) (*Cli, error) {
if machine == nil {
return nil, errors.New("机器不存在")
}
global.Log.Infof("[%s]机器连接:%s:%d", machine.Name, machine.Ip, machine.Port)
cli := new(Cli)
cli.machine = machine
err := cli.connect()
if err != nil {
return nil, err
}
return cli, nil
}

View File

@@ -1,19 +0,0 @@
package machine
const StatsShell = `
cat /proc/uptime
echo '-----'
/bin/hostname -f
echo '-----'
cat /proc/loadavg
echo '-----'
cat /proc/meminfo
echo '-----'
df -B1
echo '-----'
/sbin/ip -o addr
echo '-----'
/bin/cat /proc/net/dev
echo '-----'
top -b -n 1 | grep Cpu
`

View File

@@ -0,0 +1,242 @@
package machine
import (
"fmt"
"io"
"mayfly-go/internal/devops/domain/entity"
"mayfly-go/pkg/global"
"mayfly-go/pkg/scheduler"
"mayfly-go/pkg/utils"
"net"
"os"
"sync"
"golang.org/x/crypto/ssh"
)
var (
sshTunnelMachines map[uint64]*SshTunnelMachine = make(map[uint64]*SshTunnelMachine)
mutex sync.Mutex
// 所有检测ssh隧道机器是否被使用的函数
checkSshTunnelMachineHasUseFuncs []CheckSshTunnelMachineHasUseFunc
// 是否开启检查ssh隧道机器是否被使用只有使用到了隧道机器才启用
startCheckSshTunnelHasUse bool = false
)
// 检查ssh隧道机器是否有被使用
type CheckSshTunnelMachineHasUseFunc func(uint64) bool
func startCheckUse() {
global.Log.Info("开启定时检测ssh隧道机器是否还有被使用")
// 每十分钟检查一次隧道机器是否还有被使用
scheduler.AddFun("@every 10m", func() {
if !mutex.TryLock() {
return
}
defer mutex.Unlock()
// 遍历隧道机器,都未被使用将会被关闭
for mid, sshTunnelMachine := range sshTunnelMachines {
global.Log.Debugf("开始定时检查ssh隧道机器[%d]是否还有被使用...", mid)
hasUse := false
for _, checkUseFunc := range checkSshTunnelMachineHasUseFuncs {
// 如果一个在使用则返回不关闭,不继续后续检查
if checkUseFunc(mid) {
hasUse = true
break
}
}
if !hasUse {
// 都未被使用,则关闭
sshTunnelMachine.Close()
}
}
})
}
// 添加ssh隧道机器检测是否使用函数
func AddCheckSshTunnelMachineUseFunc(checkFunc CheckSshTunnelMachineHasUseFunc) {
if checkSshTunnelMachineHasUseFuncs == nil {
checkSshTunnelMachineHasUseFuncs = make([]CheckSshTunnelMachineHasUseFunc, 0)
}
checkSshTunnelMachineHasUseFuncs = append(checkSshTunnelMachineHasUseFuncs, checkFunc)
}
// ssh隧道机器
type SshTunnelMachine struct {
machineId uint64 // 隧道机器id
SshClient *ssh.Client
mutex sync.Mutex
tunnels map[uint64]*Tunnel // 机器id -> 隧道
}
func (stm *SshTunnelMachine) OpenSshTunnel(id uint64, ip string, port int) (exposedIp string, exposedPort int, err error) {
stm.mutex.Lock()
defer stm.mutex.Unlock()
localPort, err := utils.GetAvailablePort()
if err != nil {
return "", 0, err
}
hostname, err := os.Hostname()
if err != nil {
return "", 0, err
}
// debug
//hostname = "0.0.0.0"
localAddr := fmt.Sprintf("%s:%d", hostname, localPort)
listener, err := net.Listen("tcp", localAddr)
if err != nil {
return "", 0, err
}
tunnel := &Tunnel{
id: id,
machineId: stm.machineId,
localHost: hostname,
localPort: localPort,
remoteHost: ip,
remotePort: port,
listener: listener,
}
go tunnel.Open(stm.SshClient)
stm.tunnels[tunnel.id] = tunnel
return tunnel.localHost, tunnel.localPort, nil
}
func (st *SshTunnelMachine) GetDialConn(network string, addr string) (net.Conn, error) {
st.mutex.Lock()
defer st.mutex.Unlock()
return st.SshClient.Dial(network, addr)
}
func (stm *SshTunnelMachine) Close() {
stm.mutex.Lock()
defer stm.mutex.Unlock()
for id, tunnel := range stm.tunnels {
if tunnel != nil {
tunnel.Close()
delete(stm.tunnels, id)
}
}
if stm.SshClient != nil {
global.Log.Infof("ssh隧道机器[%d]未被使用, 关闭隧道...", stm.machineId)
err := stm.SshClient.Close()
if err != nil {
global.Log.Errorf("关闭ssh隧道机器[%d]发生错误: %s", stm.machineId, err.Error())
}
}
delete(sshTunnelMachines, stm.machineId)
}
// 获取ssh隧道机器方便统一管理充当ssh隧道的机器避免创建多个ssh client
func GetSshTunnelMachine(machineId uint64, getMachine func(uint64) *entity.Machine) (*SshTunnelMachine, error) {
sshTunnelMachine := sshTunnelMachines[machineId]
if sshTunnelMachine != nil {
return sshTunnelMachine, nil
}
mutex.Lock()
defer mutex.Unlock()
me := getMachine(machineId)
sshClient, err := GetSshClient(me)
if err != nil {
return nil, err
}
sshTunnelMachine = &SshTunnelMachine{SshClient: sshClient, machineId: machineId, tunnels: map[uint64]*Tunnel{}}
global.Log.Infof("初次连接ssh隧道机器[%d][%s:%d]", machineId, me.Ip, me.Port)
sshTunnelMachines[machineId] = sshTunnelMachine
// 如果实用了隧道机器且还没开始定时检查是否还被实用,则执行定时任务检测隧道是否还被使用
if !startCheckSshTunnelHasUse {
startCheckUse()
startCheckSshTunnelHasUse = true
}
return sshTunnelMachine, nil
}
// 关闭ssh隧道机器的指定隧道
func CloseSshTunnelMachine(machineId uint64, tunnelId uint64) {
sshTunnelMachine := sshTunnelMachines[machineId]
if sshTunnelMachine == nil {
return
}
sshTunnelMachine.mutex.Lock()
defer sshTunnelMachine.mutex.Unlock()
t := sshTunnelMachine.tunnels[tunnelId]
if t != nil {
t.Close()
delete(sshTunnelMachine.tunnels, tunnelId)
}
}
type Tunnel struct {
id uint64 // 唯一标识
machineId uint64 // 隧道机器id
localHost string // 本地监听地址
localPort int // 本地端口
remoteHost string // 远程连接地址
remotePort int // 远程端口
listener net.Listener
localConnections []net.Conn
remoteConnections []net.Conn
}
func (r *Tunnel) Open(sshClient *ssh.Client) {
localAddr := fmt.Sprintf("%s:%d", r.localHost, r.localPort)
for {
global.Log.Debugf("隧道 %v 等待客户端访问 %v", r.id, localAddr)
localConn, err := r.listener.Accept()
if err != nil {
global.Log.Debugf("隧道 %v 接受连接失败 %v, 退出循环", r.id, err.Error())
global.Log.Debug("-------------------------------------------------")
return
}
r.localConnections = append(r.localConnections, localConn)
global.Log.Debugf("隧道 %v 新增本地连接 %v", r.id, localConn.RemoteAddr().String())
remoteAddr := fmt.Sprintf("%s:%d", r.remoteHost, r.remotePort)
global.Log.Debugf("隧道 %v 连接远程地址 %v ...", r.id, remoteAddr)
remoteConn, err := sshClient.Dial("tcp", remoteAddr)
if err != nil {
global.Log.Debugf("隧道 %v 连接远程地址 %v, 退出循环", r.id, err.Error())
global.Log.Debug("-------------------------------------------------")
return
}
r.remoteConnections = append(r.remoteConnections, remoteConn)
global.Log.Debugf("隧道 %v 连接远程主机成功", r.id)
go copyConn(localConn, remoteConn)
go copyConn(remoteConn, localConn)
global.Log.Debugf("隧道 %v 开始转发数据 [%v]->[%v]", r.id, localAddr, remoteAddr)
global.Log.Debug("~~~~~~~~~~~~~~~~~~~~分割线~~~~~~~~~~~~~~~~~~~~~~~~")
}
}
func (r *Tunnel) Close() {
for i := range r.localConnections {
_ = r.localConnections[i].Close()
}
r.localConnections = nil
for i := range r.remoteConnections {
_ = r.remoteConnections[i].Close()
}
r.remoteConnections = nil
_ = r.listener.Close()
global.Log.Debugf("隧道 %d 监听器关闭", r.id)
}
func copyConn(writer, reader net.Conn) {
_, _ = io.Copy(writer, reader)
}

View File

@@ -53,6 +53,24 @@ type Stats struct {
CPU CPUInfo // or []CPUInfo to get all the cpu-core's stats?
}
const StatsShell = `
cat /proc/uptime
echo '-----'
/bin/hostname -f
echo '-----'
cat /proc/loadavg
echo '-----'
cat /proc/meminfo
echo '-----'
df -B1
echo '-----'
/sbin/ip -o addr
echo '-----'
/bin/cat /proc/net/dev
echo '-----'
top -b -n 1 | grep Cpu
`
func (c *Cli) GetAllStats() *Stats {
res, _ := c.Run(StatsShell)
infos := strings.Split(*res, "-----")

View File

@@ -0,0 +1,74 @@
package machine
import (
"bufio"
"io"
"golang.org/x/crypto/ssh"
)
type Terminal struct {
SshSession *ssh.Session
StdinPipe io.WriteCloser
StdoutReader *bufio.Reader
}
// 新建机器ssh终端
func NewTerminal(cli *Cli) (*Terminal, error) {
sshSession, err := cli.GetSession()
if err != nil {
return nil, err
}
stdoutPipe, err := sshSession.StdoutPipe()
if err != nil {
return nil, err
}
stdoutReader := bufio.NewReader(stdoutPipe)
stdinPipe, err := sshSession.StdinPipe()
if err != nil {
return nil, err
}
terminal := Terminal{
SshSession: sshSession,
StdinPipe: stdinPipe,
StdoutReader: stdoutReader,
}
return &terminal, nil
}
func (t *Terminal) Write(p []byte) (int, error) {
return t.StdinPipe.Write(p)
}
func (t *Terminal) ReadRune() (r rune, size int, err error) {
return t.StdoutReader.ReadRune()
}
func (t *Terminal) Close() error {
if t.SshSession != nil {
return t.SshSession.Close()
}
return nil
}
func (t *Terminal) WindowChange(h int, w int) error {
return t.SshSession.WindowChange(h, w)
}
func (t *Terminal) RequestPty(term string, h, w int) error {
modes := ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
return t.SshSession.RequestPty(term, h, w, modes)
}
func (t *Terminal) Shell() error {
return t.SshSession.Shell()
}

View File

@@ -0,0 +1,175 @@
package machine
import (
"context"
"encoding/json"
"io"
"mayfly-go/pkg/global"
"time"
"unicode/utf8"
"github.com/gorilla/websocket"
)
const (
Resize = 1
Data = 2
Ping = 3
)
type TerminalSession struct {
ID string
wsConn *websocket.Conn
terminal *Terminal
ctx context.Context
cancel context.CancelFunc
dataChan chan rune
tick *time.Ticker
}
func NewTerminalSession(sessionId string, ws *websocket.Conn, cli *Cli, rows, cols int) (*TerminalSession, error) {
terminal, err := NewTerminal(cli)
if err != nil {
return nil, err
}
err = terminal.RequestPty("xterm-256color", rows, cols)
if err != nil {
return nil, err
}
err = terminal.Shell()
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
tick := time.NewTicker(time.Millisecond * time.Duration(60))
ts := &TerminalSession{
ID: sessionId,
wsConn: ws,
terminal: terminal,
ctx: ctx,
cancel: cancel,
dataChan: make(chan rune),
tick: tick,
}
return ts, nil
}
func (r TerminalSession) Start() {
go r.readFormTerminal()
go r.writeToWebsocket()
r.receiveWsMsg()
}
func (r TerminalSession) Stop() {
global.Log.Debug("close machine ssh terminal session")
r.tick.Stop()
r.cancel()
if r.wsConn != nil {
r.wsConn.Close()
}
if r.terminal != nil {
if err := r.terminal.Close(); err != nil {
global.Log.Errorf("关闭机器ssh终端失败: %s", err.Error())
}
}
}
func (ts TerminalSession) readFormTerminal() {
for {
select {
case <-ts.ctx.Done():
return
default:
rn, size, err := ts.terminal.ReadRune()
if err != nil {
if err != io.EOF {
global.Log.Error("机器ssh终端读取消息失败: ", err)
}
return
}
if size > 0 {
ts.dataChan <- rn
}
}
}
}
func (ts TerminalSession) writeToWebsocket() {
var buf []byte
for {
select {
case <-ts.ctx.Done():
return
case <-ts.tick.C:
if len(buf) > 0 {
s := string(buf)
if err := WriteMessage(ts.wsConn, s); err != nil {
global.Log.Error("机器ssh终端发送消息至websocket失败: ", err)
return
}
buf = []byte{}
}
case data := <-ts.dataChan:
if data != utf8.RuneError {
p := make([]byte, utf8.RuneLen(data))
utf8.EncodeRune(p, data)
buf = append(buf, p...)
} else {
buf = append(buf, []byte("@")...)
}
}
}
}
type WsMsg struct {
Type int `json:"type"`
Msg string `json:"msg"`
Cols int `json:"cols"`
Rows int `json:"rows"`
}
func (ts *TerminalSession) receiveWsMsg() {
wsConn := ts.wsConn
for {
select {
case <-ts.ctx.Done():
return
default:
// read websocket msg
_, wsData, err := wsConn.ReadMessage()
if err != nil {
global.Log.Debug("机器ssh终端读取websocket消息失败: ", err)
return
}
// 解析消息
msgObj := WsMsg{}
if err := json.Unmarshal(wsData, &msgObj); err != nil {
global.Log.Error("机器ssh终端消息解析失败: ", err)
}
switch msgObj.Type {
case Resize:
if msgObj.Cols > 0 && msgObj.Rows > 0 {
if err := ts.terminal.WindowChange(msgObj.Rows, msgObj.Cols); err != nil {
global.Log.Error("ssh pty change windows size failed")
}
}
case Data:
_, err := ts.terminal.Write([]byte(msgObj.Msg))
if err != nil {
global.Log.Debug("机器ssh终端写入消息失败: %s", err)
}
case Ping:
_, err := ts.terminal.SshSession.SendRequest("ping", true, nil)
if err != nil {
WriteMessage(wsConn, "\r\n\033[1;31m提示: 终端连接已断开...\033[0m")
return
}
}
}
}
}
func WriteMessage(ws *websocket.Conn, msg string) error {
return ws.WriteMessage(websocket.TextMessage, []byte(msg))
}

View File

@@ -1,195 +0,0 @@
package machine
import (
"bytes"
"encoding/json"
"io"
"mayfly-go/pkg/global"
"sync"
"time"
"github.com/gorilla/websocket"
"golang.org/x/crypto/ssh"
)
type safeBuffer struct {
buffer bytes.Buffer
mu sync.Mutex
}
func (w *safeBuffer) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.buffer.Write(p)
}
func (w *safeBuffer) Bytes() []byte {
w.mu.Lock()
defer w.mu.Unlock()
return w.buffer.Bytes()
}
func (w *safeBuffer) Reset() {
w.mu.Lock()
defer w.mu.Unlock()
w.buffer.Reset()
}
const (
wsMsgCmd = "cmd"
wsMsgResize = "resize"
)
type WsMsg struct {
Type string `json:"type"`
Msg string `json:"msg"`
Cols int `json:"cols"`
Rows int `json:"rows"`
}
type LogicSshWsSession struct {
stdinPipe io.WriteCloser
comboOutput *safeBuffer //ssh 终端混合输出
inputFilterBuff *safeBuffer //用来过滤输入的命令和ssh_filter配置对比的
session *ssh.Session
wsConn *websocket.Conn
}
func NewLogicSshWsSession(cols, rows int, cli *Cli, wsConn *websocket.Conn) (*LogicSshWsSession, error) {
sshSession, err := cli.GetSession()
if err != nil {
return nil, err
}
stdinP, err := sshSession.StdinPipe()
if err != nil {
return nil, err
}
comboWriter := new(safeBuffer)
inputBuf := new(safeBuffer)
//ssh.stdout and stderr will write output into comboWriter
sshSession.Stdout = comboWriter
sshSession.Stderr = comboWriter
modes := ssh.TerminalModes{
ssh.ECHO: 1, // disable echo
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
}
// Request pseudo terminal
if err := sshSession.RequestPty("xterm", rows, cols, modes); err != nil {
return nil, err
}
// Start remote shell
if err := sshSession.Shell(); err != nil {
return nil, err
}
return &LogicSshWsSession{
stdinPipe: stdinP,
comboOutput: comboWriter,
inputFilterBuff: inputBuf,
session: sshSession,
wsConn: wsConn,
}, nil
}
//Close 关闭
func (sws *LogicSshWsSession) Close() {
if sws.session != nil {
sws.session.Close()
}
if sws.comboOutput != nil {
sws.comboOutput = nil
}
}
func (sws *LogicSshWsSession) Start(quitChan chan bool) {
go sws.receiveWsMsg(quitChan)
go sws.sendComboOutput(quitChan)
}
//receiveWsMsg receive websocket msg do some handling then write into ssh.session.stdin
func (sws *LogicSshWsSession) receiveWsMsg(exitCh chan bool) {
wsConn := sws.wsConn
//tells other go routine quit
defer setQuit(exitCh)
for {
select {
case <-exitCh:
return
default:
//read websocket msg
_, wsData, err := wsConn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
return
}
global.Log.Error("reading webSocket message failed: ", err)
return
}
//unmashal bytes into struct
msgObj := WsMsg{}
if err := json.Unmarshal(wsData, &msgObj); err != nil {
global.Log.Error("unmarshal websocket message failed", err)
}
switch msgObj.Type {
case wsMsgResize:
//handle xterm.js size change
if msgObj.Cols > 0 && msgObj.Rows > 0 {
if err := sws.session.WindowChange(msgObj.Rows, msgObj.Cols); err != nil {
global.Log.Error("ssh pty change windows size failed")
}
}
case wsMsgCmd:
sws.sendWebsocketInputCommandToSshSessionStdinPipe([]byte(msgObj.Msg))
}
}
}
}
//sendWebsocketInputCommandToSshSessionStdinPipe
func (sws *LogicSshWsSession) sendWebsocketInputCommandToSshSessionStdinPipe(cmdBytes []byte) {
if _, err := sws.stdinPipe.Write(cmdBytes); err != nil {
global.Log.Error("ws cmd bytes write to ssh.stdin pipe failed")
}
}
func (sws *LogicSshWsSession) sendComboOutput(exitCh chan bool) {
wsConn := sws.wsConn
//todo 优化成一个方法
//tells other go routine quit
defer setQuit(exitCh)
//every 120ms write combine output bytes into websocket response
tick := time.NewTicker(time.Millisecond * time.Duration(60))
//for range time.Tick(120 * time.Millisecond){}
defer tick.Stop()
for {
select {
case <-tick.C:
if sws.comboOutput == nil {
return
}
bs := sws.comboOutput.Bytes()
if len(bs) > 0 {
err := wsConn.WriteMessage(websocket.TextMessage, bs)
if err != nil {
global.Log.Error("ssh sending combo output to webSocket failed")
}
sws.comboOutput.buffer.Reset()
}
case <-exitCh:
return
}
}
}
func (sws *LogicSshWsSession) Wait(quitChan chan bool) {
if err := sws.session.Wait(); err != nil {
setQuit(quitChan)
}
}
func setQuit(ch chan bool) {
ch <- true
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"mayfly-go/internal/devops/domain/entity"
"mayfly-go/internal/devops/domain/repository"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/model"
)
@@ -51,9 +52,9 @@ func (m *machineRepo) GetById(id uint64, cols ...string) *entity.Machine {
}
func (m *machineRepo) Create(entity *entity.Machine) {
model.Insert(entity)
biz.ErrIsNilAppendErr(model.Insert(entity), "创建机器信息失败: %s")
}
func (m *machineRepo) UpdateById(entity *entity.Machine) {
model.UpdateById(entity)
biz.ErrIsNilAppendErr(model.UpdateById(entity), "更新机器信息失败: %s")
}

View File

@@ -1,27 +0,0 @@
package scheduler
func init() {
SaveMachineMonitor()
}
func SaveMachineMonitor() {
AddFun("@every 60s", func() {
// for _, m := range models.GetNeedMonitorMachine() {
// m := m
// go func() {
// cli, err := machine.GetCli(uint64(utils.GetInt4Map(m, "id")))
// if err != nil {
// mlog.Log.Error("获取客户端失败:", err.Error())
// return
// }
// mm := cli.GetMonitorInfo()
// if mm != nil {
// err := model.Insert(mm)
// if err != nil {
// mlog.Log.Error("保存机器监控信息失败: ", err.Error())
// }
// }
// }()
// }
})
}

View File

@@ -20,8 +20,7 @@ func InitDbRouter(router *gin.RouterGroup) {
}
// 获取所有数据库列表
db.GET("", func(c *gin.Context) {
rc := ctx.NewReqCtxWithGin(c)
rc.Handle(d.Dbs)
ctx.NewReqCtxWithGin(c).Handle(d.Dbs)
})
saveDb := ctx.NewLogInfo("保存数据库信息").WithSave(true)
@@ -31,6 +30,16 @@ func InitDbRouter(router *gin.RouterGroup) {
Handle(d.Save)
})
// 获取数据库实例的所有数据库名
db.POST("databases", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).
Handle(d.GetDatabaseNames)
})
db.GET(":dbId/pwd", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).Handle(d.GetDbPwd)
})
deleteDb := ctx.NewLogInfo("删除数据库信息").WithSave(true)
db.DELETE(":dbId", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).

View File

@@ -20,6 +20,10 @@ func InitMachineRouter(router *gin.RouterGroup) {
ctx.NewReqCtxWithGin(c).Handle(m.Machines)
})
machines.GET(":machineId/pwd", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).Handle(m.GetMachinePwd)
})
machines.GET(":machineId/stats", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).Handle(m.MachineStats)
})

View File

@@ -26,6 +26,10 @@ func InitRedisRouter(router *gin.RouterGroup) {
ctx.NewReqCtxWithGin(c).WithLog(save).Handle(rs.Save)
})
redis.GET(":id/pwd", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).Handle(rs.GetRedisPwd)
})
delRedis := ctx.NewLogInfo("删除redis信息").WithSave(true)
redis.DELETE(":id", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).WithLog(delRedis).Handle(rs.DeleteRedis)
@@ -60,9 +64,17 @@ func InitRedisRouter(router *gin.RouterGroup) {
ctx.NewReqCtxWithGin(c).Handle(rs.SetStringValue)
})
// 获取hash类型值
redis.GET(":id/hash-value", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).Handle(rs.GetHashValue)
// hscan
redis.GET(":id/hscan", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).Handle(rs.Hscan)
})
redis.GET(":id/hget", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).Handle(rs.Hget)
})
redis.DELETE(":id/hdel", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).Handle(rs.Hdel)
})
// 设置hash类型值

View File

@@ -12,6 +12,7 @@ import (
"mayfly-go/pkg/ginx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils"
"regexp"
"strconv"
"strings"
"time"
@@ -34,10 +35,18 @@ func (a *Account) Login(rc *ctx.ReqCtx) {
// 校验验证码
biz.IsTrue(captcha.Verify(loginForm.Cid, loginForm.Captcha), "验证码错误")
account := &entity.Account{Username: loginForm.Username, Password: utils.Md5(loginForm.Password)}
biz.ErrIsNil(a.AccountApp.GetAccount(account, "Id", "Username", "Status", "LastLoginTime", "LastLoginIp"), "用户名或密码错误")
originPwd, err := utils.DefaultRsaDecrypt(loginForm.Password, true)
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
account := &entity.Account{Username: loginForm.Username}
err = a.AccountApp.GetAccount(account, "Id", "Username", "Password", "Status", "LastLoginTime", "LastLoginIp")
biz.ErrIsNil(err, "用户名或密码错误")
biz.IsTrue(utils.CheckPwdHash(originPwd, account.Password), "用户名或密码错误")
biz.IsTrue(account.IsEnable(), "该账号不可用")
// 校验密码强度是否符合
biz.IsTrueBy(CheckPasswordLever(originPwd), biz.NewBizErrCode(401, "您的密码安全等级较低,请修改后重新登录"))
var resources vo.AccountResourceVOList
// 获取账号菜单资源
a.ResourceApp.GetAccountResources(account.Id, &resources)
@@ -72,6 +81,51 @@ func (a *Account) Login(rc *ctx.ReqCtx) {
}
}
func (a *Account) ChangePassword(rc *ctx.ReqCtx) {
form := new(form.AccountChangePasswordForm)
ginx.BindJsonAndValid(rc.GinCtx, form)
originOldPwd, err := utils.DefaultRsaDecrypt(form.OldPassword, true)
biz.ErrIsNilAppendErr(err, "解密旧密码错误: %s")
account := &entity.Account{Username: form.Username}
err = a.AccountApp.GetAccount(account, "Id", "Username", "Password", "Status")
biz.ErrIsNil(err, "旧密码错误")
biz.IsTrue(utils.CheckPwdHash(originOldPwd, account.Password), "旧密码错误")
biz.IsTrue(account.IsEnable(), "该账号不可用")
originNewPwd, err := utils.DefaultRsaDecrypt(form.NewPassword, true)
biz.ErrIsNilAppendErr(err, "解密新密码错误: %s")
biz.IsTrue(CheckPasswordLever(originNewPwd), "密码强度必须8位以上且包含字⺟⼤⼩写+数字+特殊符号")
updateAccount := new(entity.Account)
updateAccount.Id = account.Id
updateAccount.Password = utils.PwdHash(originNewPwd)
a.AccountApp.Update(updateAccount)
// 赋值loginAccount 主要用于记录操作日志,因为操作日志保存请求上下文没有该信息不保存日志
rc.LoginAccount = &model.LoginAccount{Id: account.Id, Username: account.Username}
}
func CheckPasswordLever(ps string) bool {
if len(ps) < 8 {
return false
}
num := `[0-9]{1}`
a_z := `[a-zA-Z]{1}`
symbol := `[!@#~$%^&*()+|_.,]{1}`
if b, err := regexp.MatchString(num, ps); !b || err != nil {
return false
}
if b, err := regexp.MatchString(a_z, ps); !b || err != nil {
return false
}
if b, err := regexp.MatchString(symbol, ps); !b || err != nil {
return false
}
return true
}
// 保存更新账号登录信息
func (a *Account) saveLogin(account *entity.Account, ip string) {
// 更新账号最后登录时间
@@ -105,7 +159,7 @@ func (a *Account) saveLogin(account *entity.Account, ip string) {
}
// 获取个人账号信息
func (a Account) AccountInfo(rc *ctx.ReqCtx) {
func (a *Account) AccountInfo(rc *ctx.ReqCtx) {
ap := new(vo.AccountPersonVO)
// 角色信息
roles := new([]vo.AccountRoleVO)
@@ -116,7 +170,7 @@ func (a Account) AccountInfo(rc *ctx.ReqCtx) {
}
// 更新个人账号信息
func (a Account) UpdateAccount(rc *ctx.ReqCtx) {
func (a *Account) UpdateAccount(rc *ctx.ReqCtx) {
updateForm := &form.AccountUpdateForm{}
ginx.BindJsonAndValid(rc.GinCtx, updateForm)
@@ -126,13 +180,14 @@ func (a Account) UpdateAccount(rc *ctx.ReqCtx) {
updateAccount.Id = rc.LoginAccount.Id
if updateAccount.Password != "" {
updateAccount.Password = utils.Md5(updateAccount.Password)
biz.IsTrue(CheckPasswordLever(updateAccount.Password), "密码强度必须8位以上且包含字⺟⼤⼩写+数字+特殊符号")
updateAccount.Password = utils.PwdHash(updateAccount.Password)
}
a.AccountApp.Update(updateAccount)
}
// 获取账号接收的消息列表
func (a Account) GetMsgs(rc *ctx.ReqCtx) {
func (a *Account) GetMsgs(rc *ctx.ReqCtx) {
condition := &entity.Msg{
RecipientId: int64(rc.LoginAccount.Id),
}

View File

@@ -7,3 +7,9 @@ type AccountCreateForm struct {
type AccountUpdateForm struct {
Password *string `json:"password" binding:"min=6,max=16"`
}
type AccountChangePasswordForm struct {
Username string `json:"username"`
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
}

View File

@@ -43,7 +43,7 @@ func (a *accountAppImpl) GetPageList(condition *entity.Account, pageParam *model
func (a *accountAppImpl) Create(account *entity.Account) {
biz.IsTrue(a.GetAccount(&entity.Account{Username: account.Username}) != nil, "该账号用户名已存在")
// 默认密码为账号用户名
account.Password = utils.Md5(account.Username)
account.Password = utils.PwdHash(account.Username)
account.Status = entity.AccountEnableStatus
a.accountRepo.Insert(account)
}

View File

@@ -26,6 +26,14 @@ func InitAccountRouter(router *gin.RouterGroup) {
Handle(a.Login)
})
changePwdLog := ctx.NewLogInfo("用户修改密码").WithSave(true)
account.POST("change-pwd", func(g *gin.Context) {
ctx.NewReqCtxWithGin(g).
WithNeedToken(false).
WithLog(changePwdLog).
Handle(a.ChangePassword)
})
// 获取个人账号信息
account.GET("/self", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).Handle(a.AccountInfo)

View File

@@ -5,7 +5,5 @@ import (
)
func main() {
starter.PrintBanner()
starter.InitDb()
starter.RunWebServer()
}

View File

@@ -29,7 +29,9 @@ CREATE TABLE `t_db` (
`type` varchar(20) COLLATE utf8mb4_bin NOT NULL COMMENT '数据库实例类型(mysql...)',
`database` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '数据库,空格分割多个数据库',
`params` varchar(125) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '其他连接参数',
`network` varchar(8) COLLATE utf8mb4_bin DEFAULT NULL,
`network` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
`enable_ssh_tunnel` tinyint(2) DEFAULT NULL COMMENT '是否启用ssh隧道',
`ssh_tunnel_machine_id` bigint(20) DEFAULT NULL COMMENT 'ssh隧道的机器id',
`project_id` bigint(20) DEFAULT NULL,
`project` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL,
`env_id` bigint(20) DEFAULT NULL COMMENT '环境id',
@@ -107,7 +109,10 @@ CREATE TABLE `t_machine` (
`ip` varchar(36) COLLATE utf8mb4_bin NOT NULL,
`port` int(12) NOT NULL,
`username` varchar(12) COLLATE utf8mb4_bin NOT NULL,
`password` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL,
`auth_method` tinyint(2) NULL DEFAULT NULL COMMENT '1.密码登录2.publickey登录',
`password` varchar(3200) COLLATE utf8mb4_bin DEFAULT NULL,
`enable_ssh_tunnel` tinyint(2) DEFAULT NULL COMMENT '是否启用ssh隧道',
`ssh_tunnel_machine_id` bigint(20) DEFAULT NULL COMMENT 'ssh隧道的机器id',
`status` tinyint(2) NOT NULL COMMENT '状态: 1:启用; -1:禁用',
`remark` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
`need_monitor` tinyint(2) DEFAULT NULL,
@@ -255,9 +260,11 @@ DROP TABLE IF EXISTS `t_redis`;
CREATE TABLE `t_redis` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`host` varchar(255) COLLATE utf8mb4_bin NOT NULL,
`password` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL,
`password` varchar(100) COLLATE utf8mb4_bin DEFAULT NULL,
`db` int(32) DEFAULT NULL,
`mode` varchar(32) DEFAULT NULL,
`enable_ssh_tunnel` tinyint(2) DEFAULT NULL COMMENT '是否启用ssh隧道',
`ssh_tunnel_machine_id` bigint(20) DEFAULT NULL COMMENT 'ssh隧道的机器id',
`remark` varchar(125) DEFAULT NULL,
`project_id` bigint(20) DEFAULT NULL,
`project` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL,
@@ -279,7 +286,7 @@ CREATE TABLE `t_redis` (
DROP TABLE IF EXISTS `t_sys_account`;
CREATE TABLE `t_sys_account` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(12) COLLATE utf8mb4_bin NOT NULL,
`username` varchar(30) COLLATE utf8mb4_bin NOT NULL,
`password` varchar(64) COLLATE utf8mb4_bin NOT NULL,
`status` tinyint(4) DEFAULT NULL,
`last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
@@ -297,7 +304,7 @@ CREATE TABLE `t_sys_account` (
-- Records of t_sys_account
-- ----------------------------
BEGIN;
INSERT INTO `t_sys_account` VALUES (1, 'admin', 'e10adc3949ba59abbe56e057f20f883e', 1, '2021-11-17 16:30:02', '12.0.216.228', '2020-01-01 19:00:00', 1, 'admin', '2020-01-01 19:00:00', 1, 'admin');
INSERT INTO `t_sys_account` VALUES (1, 'admin', '$2a$10$w3Wky2U.tinvR7c/s0aKPuwZsIu6pM1/DMJalwBDMbE6niHIxVrrm', 1, '2021-11-17 16:30:02', '12.0.216.228', '2020-01-01 19:00:00', 1, 'admin', '2020-01-01 19:00:00', 1, 'admin');
COMMIT;
-- ----------------------------
@@ -663,6 +670,8 @@ CREATE TABLE `t_mongo` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(36) COLLATE utf8mb4_bin NOT NULL COMMENT '名称',
`uri` varchar(255) COLLATE utf8mb4_bin NOT NULL COMMENT '连接uri',
`enable_ssh_tunnel` tinyint(2) DEFAULT NULL COMMENT '是否启用ssh隧道',
`ssh_tunnel_machine_id` bigint(20) DEFAULT NULL COMMENT 'ssh隧道的机器id',
`project_id` bigint(20) NOT NULL,
`project` varchar(36) COLLATE utf8mb4_bin DEFAULT NULL,
`env_id` bigint(20) DEFAULT NULL,

View File

@@ -7,28 +7,28 @@ type BizError struct {
}
var (
Success *BizError = NewBizErrCode(200, "success")
BizErr *BizError = NewBizErrCode(400, "biz error")
ServerError *BizError = NewBizErrCode(500, "server error")
PermissionErr *BizError = NewBizErrCode(501, "token error")
Success BizError = NewBizErrCode(200, "success")
BizErr BizError = NewBizErrCode(400, "biz error")
ServerError BizError = NewBizErrCode(500, "server error")
PermissionErr BizError = NewBizErrCode(501, "token error")
)
// 错误消息
func (e *BizError) Error() string {
func (e BizError) Error() string {
return e.err
}
// 错误码
func (e *BizError) Code() int16 {
func (e BizError) Code() int16 {
return e.code
}
// 创建业务逻辑错误结构体,默认为业务逻辑错误
func NewBizErr(msg string) *BizError {
return &BizError{code: BizErr.code, err: msg}
func NewBizErr(msg string) BizError {
return BizError{code: BizErr.code, err: msg}
}
// 创建业务逻辑错误结构体可设置指定错误code
func NewBizErrCode(code int16, msg string) *BizError {
return &BizError{code: code, err: msg}
func NewBizErrCode(code int16, msg string) BizError {
return BizError{code: code, err: msg}
}

27
server/pkg/config/aes.go Normal file
View File

@@ -0,0 +1,27 @@
package config
import (
"fmt"
"mayfly-go/pkg/utils"
"mayfly-go/pkg/utils/assert"
)
type Aes struct {
Key string `yaml:"key"`
}
// 编码并base64
func (a *Aes) EncryptBase64(data []byte) (string, error) {
return utils.AesEncryptBase64(data, []byte(a.Key))
}
// base64解码后再aes解码
func (a *Aes) DecryptBase64(data string) ([]byte, error) {
return utils.AesDecryptBase64(data, []byte(a.Key))
}
func (j *Aes) Valid() {
aesKeyLen := len(j.Key)
assert.IsTrue(aesKeyLen == 16 || aesKeyLen == 24 || aesKeyLen == 32,
fmt.Sprintf("config.yml之 [aes.key] 长度需为16、24、32位长度, 当前为%d位", aesKeyLen))
}

View File

@@ -2,11 +2,11 @@ package config
import "fmt"
type App struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
}
const (
AppName = "mayfly-go"
Version = "v1.2.6"
)
func (a *App) GetAppInfo() string {
return fmt.Sprintf("[%s:%s]", a.Name, a.Version)
func GetAppInfo() string {
return fmt.Sprintf("[%s:%s]", AppName, Version)
}

View File

@@ -11,12 +11,12 @@ import (
// 配置文件映射对象
var Conf *Config
func init() {
func Init() {
configFilePath := flag.String("e", "./config.yml", "配置文件路径,默认为可执行文件目录")
flag.Parse()
// 获取启动参数中,配置文件的绝对路径
path, _ := filepath.Abs(*configFilePath)
startConfigParam = &CmdConfigParam{ConfigFilePath: path}
startConfigParam := &CmdConfigParam{ConfigFilePath: path}
// 读取配置文件信息
yc := &Config{}
if err := utils.LoadYml(startConfigParam.ConfigFilePath, yc); err != nil {
@@ -32,14 +32,11 @@ type CmdConfigParam struct {
ConfigFilePath string // -e 配置文件路径
}
// 启动可执行文件时的参数
var startConfigParam *CmdConfigParam
// yaml配置文件映射对象
type Config struct {
App *App `yaml:"app"`
Server *Server `yaml:"server"`
Jwt *Jwt `yaml:"jwt"`
Aes *Aes `yaml:"aes"`
Redis *Redis `yaml:"redis"`
Mysql *Mysql `yaml:"mysql"`
Log *Log `yaml:"log"`
@@ -49,14 +46,7 @@ type Config struct {
func (c *Config) Valid() {
assert.IsTrue(c.Jwt != nil, "配置文件的[jwt]信息不能为空")
c.Jwt.Valid()
}
// 获取执行可执行文件时,指定的启动参数
func getStartConfig() *CmdConfigParam {
configFilePath := flag.String("e", "./config.yml", "配置文件路径,默认为可执行文件目录")
flag.Parse()
// 获取配置文件绝对路径
path, _ := filepath.Abs(*configFilePath)
sc := &CmdConfigParam{ConfigFilePath: path}
return sc
if c.Aes != nil {
c.Aes.Valid()
}
}

View File

@@ -8,6 +8,5 @@ type Jwt struct {
}
func (j *Jwt) Valid() {
assert.IsTrue(j.Key != "", "config.yml之 [jwt.key] 不能为空")
assert.IsTrue(j.ExpireTime != 0, "config.yml之 [jwt.expire-time] 不能为空")
}

View File

@@ -95,7 +95,7 @@ func getErrMsg(rc *ReqCtx, err interface{}) string {
var errMsg string
switch t := err.(type) {
case *biz.BizError:
case biz.BizError:
errMsg = fmt.Sprintf("\n<-e errCode: %d, errMsg: %s", t.Code(), t.Error())
case error:
errMsg = fmt.Sprintf("\n<-e errMsg: %s\n%s", t.Error(), string(debug.Stack()))

View File

@@ -5,15 +5,22 @@ import (
"mayfly-go/pkg/biz"
"mayfly-go/pkg/config"
"mayfly-go/pkg/global"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/golang-jwt/jwt/v4"
)
var (
JwtKey = config.Conf.Jwt.Key
func InitTokenConfig() {
JwtKey = config.Conf.Jwt.Key
ExpTime = config.Conf.Jwt.ExpireTime
}
var (
JwtKey string
ExpTime uint64
)
// 创建用户token
@@ -26,6 +33,11 @@ func CreateToken(userId uint64, username string) string {
"exp": time.Now().Add(time.Minute * time.Duration(ExpTime)).Unix(),
})
// 如果配置文件中的jwt key为空则随机生成字符串
if JwtKey == "" {
JwtKey = utils.RandString(32)
global.Log.Infof("config.yml未配置jwt.key, 随机生成key为: %s", JwtKey)
}
// 使用自定义字符串加密 and get the complete encoded token as a string
tokenString, err := token.SignedString([]byte(JwtKey))
biz.ErrIsNil(err, "token创建失败")

View File

@@ -62,7 +62,7 @@ func SuccessRes(g *gin.Context, data interface{}) {
// 返回失败结果集
func ErrorRes(g *gin.Context, err interface{}) {
switch t := err.(type) {
case *biz.BizError:
case biz.BizError:
g.JSON(http.StatusOK, model.Error(t))
case error:
g.JSON(http.StatusOK, model.ServerError())

View File

@@ -13,7 +13,7 @@ import (
var Log = logrus.New()
func init() {
func Init() {
Log.SetFormatter(new(LogFormatter))
Log.SetReportCaller(true)

View File

@@ -44,7 +44,7 @@ func SuccessNoData() *Result {
return &Result{Code: SuccessCode, Msg: SuccessMsg}
}
func Error(bizerr *biz.BizError) *Result {
func Error(bizerr biz.BizError) *Result {
return &Result{Code: bizerr.Code(), Msg: bizerr.Error()}
}

View File

@@ -6,6 +6,10 @@ import (
"github.com/robfig/cron/v3"
)
func init() {
Start()
}
var cronService = cron.New()
func Start() {

View File

@@ -1,16 +1,17 @@
package starter
import (
"fmt"
"mayfly-go/pkg/config"
"mayfly-go/pkg/global"
)
func PrintBanner() {
global.Log.Print(`
__ _
_ __ ___ __ _ _ _ / _| |_ _ __ _ ___
| '_ ' _ \ / _' | | | | |_| | | | |_____ / _' |/ _ \
| | | | | | (_| | |_| | _| | |_| |_____| (_| | (_) |
|_| |_| |_|\__,_|\__, |_| |_|\__, | \__, |\___/
|___/ |___/ |___/
`)
func printBanner() {
global.Log.Print(fmt.Sprintf(`
__ _
_ __ ___ __ _ _ _ / _| |_ _ __ _ ___
| '_ ' _ \ / _' | | | | |_| | | | |_____ / _' |/ _ \
| | | | | | (_| | |_| | _| | |_| |_____| (_| | (_) | version: %s
|_| |_| |_|\__,_|\__, |_| |_|\__, | \__, |\___/
|___/ |___/ |___/ `, config.Version))
}

View File

@@ -10,11 +10,11 @@ import (
"gorm.io/gorm/schema"
)
func InitDb() {
global.Db = GormMysql()
func initDb() {
global.Db = gormMysql()
}
func GormMysql() *gorm.DB {
func gormMysql() *gorm.DB {
m := config.Conf.Mysql
if m == nil || m.Dbname == "" {
global.Log.Panic("未找到数据库配置信息")

23
server/pkg/starter/run.go Normal file
View File

@@ -0,0 +1,23 @@
package starter
import (
"mayfly-go/pkg/config"
"mayfly-go/pkg/ctx"
"mayfly-go/pkg/logger"
)
func RunWebServer() {
// 初始化config.yml配置文件映射信息
config.Init()
// 初始化日志配置信息
logger.Init()
// 初始化jwt key与expire time等
ctx.InitTokenConfig()
// 打印banner
printBanner()
// 初始化并赋值数据库全局变量
initDb()
// 运行web服务
runWebServer()
}

View File

@@ -8,7 +8,7 @@ import (
"mayfly-go/pkg/global"
)
func RunWebServer() {
func runWebServer() {
// 权限处理器
ctx.UseBeforeHandlerInterceptor(ctx.PermissionHandler)
// 日志处理器
@@ -21,11 +21,7 @@ func RunWebServer() {
server := config.Conf.Server
port := server.GetPort()
if app := config.Conf.App; app != nil {
global.Log.Infof("%s- Listening and serving HTTP on %s", app.GetAppInfo(), port)
} else {
global.Log.Infof("Listening and serving HTTP on %s", port)
}
global.Log.Infof("Listening and serving HTTP on %s", port)
var err error
if server.Tls != nil && server.Tls.Enable {

View File

@@ -1,8 +1,19 @@
package utils
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"golang.org/x/crypto/bcrypt"
)
// md5
@@ -11,3 +22,207 @@ func Md5(str string) string {
h.Write([]byte(str))
return hex.EncodeToString(h.Sum(nil))
}
// bcrypt加密密码
func PwdHash(password string) string {
bytes, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes)
}
// 检查密码是否一致
func CheckPwdHash(password, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// 系统统一RSA秘钥对
var RsaPair []string
// 生成RSA私钥和公钥字符串
// bits 证书大小
// @return privateKeyStr publicKeyStr error
func GenerateRSAKey(bits int) (string, string, error) {
var privateKeyStr, publicKeyStr string
//GenerateKey函数使用随机数据生成器random生成一对具有指定字位数的RSA密钥
//Reader是一个全局、共享的密码用强随机数生成器
privateKey, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
return privateKeyStr, publicKeyStr, err
}
//保存私钥
//通过x509标准将得到的ras私钥序列化为ASN.1 的 DER编码字符串
X509PrivateKey := x509.MarshalPKCS1PrivateKey(privateKey)
//构建一个pem.Block结构体对象
privateBlock := pem.Block{Type: "RSA Private Key", Bytes: X509PrivateKey}
privateBuf := new(bytes.Buffer)
pem.Encode(privateBuf, &privateBlock)
privateKeyStr = privateBuf.String()
//保存公钥
//获取公钥的数据
publicKey := privateKey.PublicKey
//X509对公钥编码
X509PublicKey, err := x509.MarshalPKIXPublicKey(&publicKey)
if err != nil {
return publicKeyStr, privateKeyStr, err
}
//创建一个pem.Block结构体对象
publicBlock := pem.Block{Type: "RSA Public Key", Bytes: X509PublicKey}
publicBuf := new(bytes.Buffer)
pem.Encode(publicBuf, &publicBlock)
publicKeyStr = publicBuf.String()
return privateKeyStr, publicKeyStr, nil
}
// rsa解密
func RsaDecrypt(privateKeyStr string, data []byte) ([]byte, error) {
block, _ := pem.Decode([]byte(privateKeyStr))
if block == nil {
return nil, errors.New("private key error")
}
priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
return rsa.DecryptPKCS1v15(rand.Reader, priv, data)
}
// 使用系统默认的私钥解密
// @param base64 字符串是否使用base64编码
func DefaultRsaDecrypt(data string, useBase64 bool) (string, error) {
// 空字符串不解密
if data == "" {
return "", nil
}
if useBase64 {
if decodeBase64, err := base64.StdEncoding.DecodeString(data); err != nil {
return "", err
} else {
data = string(decodeBase64)
}
}
priKey, err := GetRsaPrivateKey()
if err != nil {
return "", err
}
val, err := RsaDecrypt(priKey, []byte(data))
if err != nil {
return "", err
}
return string(val), nil
}
// 获取系统的RSA公钥
func GetRsaPublicKey() (string, error) {
if len(RsaPair) == 2 {
return RsaPair[1], nil
}
privateKey, publicKey, err := GenerateRSAKey(1024)
if err != nil {
return "", err
}
RsaPair = append(RsaPair, privateKey)
RsaPair = append(RsaPair, publicKey)
return publicKey, nil
}
// 获取系统私钥
func GetRsaPrivateKey() (string, error) {
if len(RsaPair) == 2 {
return RsaPair[0], nil
}
privateKey, publicKey, err := GenerateRSAKey(1024)
if err != nil {
return "", err
}
RsaPair = append(RsaPair, privateKey)
RsaPair = append(RsaPair, publicKey)
return privateKey, nil
}
//AesEncrypt 加密
func AesEncrypt(data []byte, key []byte) ([]byte, error) {
//创建加密实例
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
//判断加密快的大小
blockSize := block.BlockSize()
//填充
encryptBytes := pkcs7Padding(data, blockSize)
//初始化加密数据接收切片
crypted := make([]byte, len(encryptBytes))
//使用cbc加密模式
blockMode := cipher.NewCBCEncrypter(block, key[:blockSize])
//执行加密
blockMode.CryptBlocks(crypted, encryptBytes)
return crypted, nil
}
//AesDecrypt 解密
func AesDecrypt(data []byte, key []byte) ([]byte, error) {
//创建实例
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
//获取块的大小
blockSize := block.BlockSize()
//使用cbc
blockMode := cipher.NewCBCDecrypter(block, key[:blockSize])
//初始化解密数据接收切片
crypted := make([]byte, len(data))
//执行解密
blockMode.CryptBlocks(crypted, data)
//去除填充
crypted, err = pkcs7UnPadding(crypted)
if err != nil {
return nil, err
}
return crypted, nil
}
// aes加密 后 再base64
func AesEncryptBase64(data []byte, key []byte) (string, error) {
res, err := AesEncrypt(data, key)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(res), nil
}
// base64解码后再 aes解码
func AesDecryptBase64(data string, key []byte) ([]byte, error) {
dataByte, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return nil, err
}
return AesDecrypt(dataByte, key)
}
//pkcs7Padding 填充
func pkcs7Padding(data []byte, blockSize int) []byte {
//判断缺少几位长度。最少1最多 blockSize
padding := blockSize - len(data)%blockSize
//补足位数。把切片[]byte{byte(padding)}复制padding个
padText := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, padText...)
}
//pkcs7UnPadding 填充的反向操作
func pkcs7UnPadding(data []byte) ([]byte, error) {
length := len(data)
if length == 0 {
return nil, errors.New("加密字符串错误!")
}
//获取填充的个数
unPadding := int(data[length-1])
return data[:(length - unPadding)], nil
}

21
server/pkg/utils/net.go Normal file
View File

@@ -0,0 +1,21 @@
package utils
import "net"
// GetAvailablePort 获取可用端口
func GetAvailablePort() (int, error) {
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
if err != nil {
return 0, err
}
l, err := net.ListenTCP("tcp", addr)
if err != nil {
return 0, err
}
defer func(l *net.TCPListener) {
_ = l.Close()
}(l)
return l.Addr().(*net.TCPAddr).Port, nil
}

25
server/pkg/utils/rand.go Normal file
View File

@@ -0,0 +1,25 @@
package utils
import (
"math/rand"
"time"
)
const randChar = "0123456789abcdefghigklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// 生成随机字符串
func RandString(l int) string {
strList := []byte(randChar)
result := []byte{}
i := 0
r := rand.New(rand.NewSource(time.Now().UnixNano()))
charLen := len(strList)
for i < l {
new := strList[r.Intn(charLen)]
result = append(result, new)
i = i + 1
}
return string(result)
}

Some files were not shown because too many files have changed in this diff Show More