Compare commits

...

17 Commits

Author SHA1 Message Date
meilin.huang
cf2bc6785c feat: 使用embed将静态资源打包进二进制文件&其他小功能优化 2022-08-24 20:55:42 +08:00
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
138 changed files with 3587 additions and 1098 deletions

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

@@ -30,9 +30,9 @@ function buildWeb() {
echo_yellow "-------------------打包前端开始-------------------"
yarn run build
if [ "${copy2Server}" == "1" ] ; then
echo_green '将打包后的静态文件拷贝至server/static'
rm -rf ${server_folder}/static && mkdir -p ${server_folder}/static && cp -r ${web_folder}/dist/* ${server_folder}/static
if [ "${copy2Server}" == "2" ] ; then
echo_green '将打包后的静态文件拷贝至server/static/static'
rm -rf ${server_folder}/static/static && mkdir -p ${server_folder}/static/static && cp -r ${web_folder}/dist/* ${server_folder}/static/static
fi
echo_yellow ">>>>>>>>>>>>>>>>>>>打包前端结束<<<<<<<<<<<<<<<<<<<<\n"
}
@@ -44,6 +44,7 @@ function build() {
toFolder=$1
os=$2
arch=$3
copyStatic=$4
echo_yellow "-------------------${os}-${arch}打包构建开始-------------------"
@@ -67,8 +68,10 @@ function build() {
echo_green "移动二进制文件至'${toFolder}'"
mv ${server_folder}/${execFileName} ${toFolder}
echo_green "拷贝前端静态页面至'${toFolder}/static'"
mkdir -p ${toFolder}/static && cp -r ${web_folder}/dist/* ${toFolder}/static
if [ "${copy2Server}" == "1" ] ; then
echo_green "拷贝前端静态页面至'${toFolder}/static'"
mkdir -p ${toFolder}/static && cp -r ${web_folder}/dist/* ${toFolder}/static
fi
echo_green "拷贝脚本等资源文件[config.yml、mayfly-go.sql、readme.txt、startup.sh、shutdown.sh]"
cp ${server_folder}/config.yml ${toFolder}
@@ -81,15 +84,15 @@ function build() {
}
function buildLinuxAmd64() {
build "$1/mayfly-go-linux-amd64" "linux" "amd64"
build "$1/mayfly-go-linux-amd64" "linux" "amd64" $2
}
function buildLinuxArm64() {
build "$1/mayfly-go-linux-arm64" "linux" "arm64"
build "$1/mayfly-go-linux-arm64" "linux" "arm64" $2
}
function buildWindows() {
build "$1/mayfly-go-windows" "windows" "amd64"
build "$1/mayfly-go-windows" "windows" "amd64" $2
}
function runBuild() {
@@ -103,28 +106,23 @@ function runBuild() {
cd ${toPath}
toPath=`pwd`
read -p "是否构建前端[0|其他->否 1->是 2->构建并拷贝至server/static]: " runBuildWeb
read -p "是否构建前端[0|其他->否 1->是 2->构建并拷贝至server/static/static]: " runBuildWeb
read -p "请选择构建版本[0|其他->全部 1->linux-amd64 2->linux-arm64 3->windows]: " buildType
if [ "${runBuildWeb}" == "1" ];then
buildWeb
fi
if [ "${runBuildWeb}" == "2" ];then
buildWeb 1
fi
buildWeb ${runBuildWeb}
if [ "${buildType}" == "1" ];then
buildLinuxAmd64 ${toPath}
buildLinuxAmd64 ${toPath} ${runBuildWeb}
exit;
fi
if [ "${buildType}" == "2" ];then
buildLinuxArm64 ${toPath}
buildLinuxArm64 ${toPath} ${runBuildWeb}
exit;
fi
if [ "${buildType}" == "3" ];then
buildWindows ${toPath}
buildWindows ${toPath} ${runBuildWeb}
exit;
fi

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

@@ -7,21 +7,21 @@
"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.10",
"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.1.2",

View File

@@ -28,7 +28,6 @@ export async function RsaEncrypt(value: any) {
if (encryptor != null) {
return encryptor.encrypt(value)
}
console.log(value)
encryptor = new JSEncrypt()
const publicKey = await getRsaPublicKey() as string;
notBlank(publicKey, "获取公钥失败")

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

@@ -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

@@ -131,6 +131,8 @@ export default defineComponent({
});
onMounted(() => {
// 移除公钥, 方便后续重新获取
sessionStorage.removeItem('RsaPublicKey')
getCaptcha();
});

View File

@@ -41,26 +41,39 @@
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-select
@change="changeDatabase"
@focus="getAllDatabase"
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 :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隧道:">
@@ -142,6 +155,8 @@ export default defineComponent({
enableSshTunnel: null,
sshTunnelMachineId: null,
},
// 原密码
pwd: '',
btnLoading: false,
rules: {
projectId: [
@@ -254,12 +269,14 @@ export default defineComponent({
};
const getAllDatabase = async () => {
if (state.allDatabases.length != 0) {
return;
}
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 () => {
@@ -304,6 +321,7 @@ export default defineComponent({
...toRefs(state),
dbForm,
getAllDatabase,
getDbPwd,
changeDatabase,
getSshTunnelMachines,
changeProject,

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,
@@ -342,7 +352,26 @@ export default defineComponent({
onMounted(async () => {
search();
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) => {
@@ -369,7 +398,8 @@ export default defineComponent({
search();
};
const editDb = (isAdd = false) => {
const editDb = async (isAdd = false) => {
state.projects = await projectApi.accountProjects.request(null);
if (isAdd) {
state.dbEditDialog.data = null;
state.dbEditDialog.title = '新增数据库资源';
@@ -572,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

@@ -5,6 +5,7 @@ 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

@@ -35,7 +35,15 @@
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>
@@ -115,6 +123,7 @@ export default defineComponent({
enableSshTunnel: null,
sshTunnelMachineId: null,
},
pwd: '',
btnLoading: false,
rules: {
projectId: [
@@ -187,6 +196,10 @@ export default defineComponent({
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) {
@@ -238,6 +251,7 @@ export default defineComponent({
...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) }}
@@ -232,7 +227,6 @@ export default defineComponent({
onMounted(async () => {
search();
state.projects = await projectApi.accountProjects.request(null);
});
const choose = (item: any) => {
@@ -255,12 +249,18 @@ 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 = (machine: any) => {
const openFormDialog = async (machine: any) => {
state.projects = await projectApi.accountProjects.request(null);
let dialogTitle;
if (machine) {
state.machineEditDialog.data = state.currentData as any;

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

@@ -61,7 +61,6 @@ import { mongoApi } 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: 'MongoEdit',
@@ -181,7 +180,7 @@ export default defineComponent({
mongoForm.value.validate(async (valid: boolean) => {
if (valid) {
const reqForm = { ...state.form };
reqForm.uri = await RsaEncrypt(reqForm.uri);
// reqForm.uri = await RsaEncrypt(reqForm.uri);
mongoApi.saveMongo.request(reqForm).then(() => {
ElMessage.success('保存成功');
emit('val-change', state.form);

View File

@@ -250,7 +250,6 @@ export default defineComponent({
onMounted(async () => {
search();
state.projects = await projectApi.accountProjects.request(null);
});
const handlePageChange = (curPage: number) => {
@@ -371,7 +370,8 @@ export default defineComponent({
state.total = res.total;
};
const editMongo = (isAdd = false) => {
const editMongo = async (isAdd = false) => {
state.projects = await projectApi.accountProjects.request(null);
if (isAdd) {
state.mongoEditDialog.data = null;
state.mongoEditDialog.title = '新增mongo';

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>
@@ -152,7 +152,7 @@
:remote-method="getAccount"
v-model="showMemDialog.memForm.accountId"
filterable
placeholder="请选择"
placeholder="请输入账号模糊搜索并选择"
>
<el-option v-for="item in showMemDialog.accounts" :key="item.id" :label="item.username" :value="item.id"> </el-option>
</el-select>

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

@@ -17,12 +17,13 @@
<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,集群模式用','分割"
placeholder="请输入host:portsentinel模式为: mastername=sentinelhost:port若集群或哨兵需设多个节点可使用','分割"
auto-complete="off"
type="textarea"
></el-input>
@@ -34,7 +35,14 @@
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 prop="db" label="库号:" required>
<el-input v-model.number="form.db" placeholder="请输入库号"></el-input>
@@ -106,7 +114,7 @@ export default defineComponent({
id: null,
name: null,
mode: 'standalone',
host: null,
host: '',
password: null,
project: null,
projectId: null,
@@ -116,6 +124,7 @@ export default defineComponent({
enableSshTunnel: null,
sshTunnelMachineId: null,
},
pwd: '',
btnLoading: false,
rules: {
projectId: [
@@ -183,6 +192,10 @@ export default defineComponent({
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) {
@@ -207,6 +220,10 @@ export default defineComponent({
redisForm.value.validate(async (valid: boolean) => {
if (valid) {
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('保存成功');
@@ -234,6 +251,7 @@ export default defineComponent({
...toRefs(state),
redisForm,
getSshTunnelMachines,
getPwd,
changeProject,
changeEnv,
btnOk,

View File

@@ -31,7 +31,13 @@
<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
>
@@ -202,7 +208,6 @@ export default defineComponent({
onMounted(async () => {
search();
state.projects = await projectApi.accountProjects.request(null);
});
const handlePageChange = (curPage: number) => {
@@ -258,7 +263,8 @@ export default defineComponent({
state.total = res.total;
};
const editRedis = (isAdd = false) => {
const editRedis = async (isAdd = false) => {
state.projects = await projectApi.accountProjects.request(null);
if (isAdd) {
state.redisEditDialog.data = null;
state.redisEditDialog.title = '新增redis';

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'),

View File

@@ -17,6 +17,11 @@
resolved "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.0.6.tgz"
integrity sha512-lPpG8hYkjL/Z97DH5Ei6w6o22Z4YdNglWCNYOPcB33JCF2A4wye6HFgSI7hEt9zdLyxlSpiqtgf9XcYU+m5mew==
"@element-plus/icons-vue@^2.0.9":
version "2.0.9"
resolved "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.0.9.tgz#b7777c57534522e387303d194451d50ff549d49a"
integrity sha512-okdrwiVeKBmW41Hkl0eMrXDjzJwhQMuKiBOu17rOszqM+LS/yBYpNQNV5Jvoh06Wc+89fMmb/uhzf8NZuDuUaQ==
"@eslint/eslintrc@^1.0.5":
version "1.0.5"
resolved "https://registry.npmmirror.com/@eslint/eslintrc/download/@eslint/eslintrc-1.0.5.tgz"
@@ -126,10 +131,10 @@
resolved "https://registry.npmmirror.com/@types/sortablejs/download/@types/sortablejs-1.10.7.tgz"
integrity sha1-q5A5yFQp8FFpVextvAuyATlBexU=
"@types/web-bluetooth@^0.0.14":
version "0.0.14"
resolved "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.14.tgz"
integrity sha512-5d2RhCard1nQUC3aHcq/gHzWYO6K0WJmAbjO7mQJgCQKtZpgXxv1rOM6O/dBDhDYYVutk1sciOgNSe+5YyfM8A==
"@types/web-bluetooth@^0.0.15":
version "0.0.15"
resolved "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.15.tgz#d60330046a6ed8a13b4a53df3813c44942ebdf72"
integrity sha512-w7hEHXnPMEZ+4nGKl/KDRVpxkwYxYExuHOYXyzIzCDzEZ9ZCGMAewulr9IqJu2LR4N37fcnb1XVeuZ09qgOxhA==
"@typescript-eslint/eslint-plugin@^4.23.0":
version "4.33.0"
@@ -364,25 +369,25 @@
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.2.37.tgz"
integrity sha512-4rSJemR2NQIo9Klm1vabqWjD8rs/ZaJSzMxkMNeJS6lHiUjjUeYFbooN19NgFjztubEKh3WlZUeOLVdbbUWHsw==
"@vueuse/core@^8.7.5":
version "8.7.5"
resolved "https://registry.npmmirror.com/@vueuse/core/-/core-8.7.5.tgz"
integrity sha512-tqgzeZGoZcXzoit4kOGLWJibDMLp0vdm6ZO41SSUQhkhtrPhAg6dbIEPiahhUu6sZAmSYvVrZgEr5aKD51nrLA==
"@vueuse/core@^9.1.0":
version "9.1.0"
resolved "https://registry.npmmirror.com/@vueuse/core/-/core-9.1.0.tgz#f0fb13fd99768c0eb617169a2d2c1cbd5f5a52eb"
integrity sha512-BIroqvXEqt826aE9r3K5cox1zobuPuAzdYJ36kouC2TVhlXvFKIILgFVWrpp9HZPwB3aLzasmG3K87q7TSyXZg==
dependencies:
"@types/web-bluetooth" "^0.0.14"
"@vueuse/metadata" "8.7.5"
"@vueuse/shared" "8.7.5"
"@types/web-bluetooth" "^0.0.15"
"@vueuse/metadata" "9.1.0"
"@vueuse/shared" "9.1.0"
vue-demi "*"
"@vueuse/metadata@8.7.5":
version "8.7.5"
resolved "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-8.7.5.tgz"
integrity sha512-emJZKRQSaEnVqmlu39NpNp8iaW+bPC2kWykWoWOZMSlO/0QVEmO/rt8A5VhOEJTKLX3vwTevqbiRy9WJRwVOQg==
"@vueuse/metadata@9.1.0":
version "9.1.0"
resolved "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-9.1.0.tgz#194d4bd47f7acb91e348c0f436e678ddf7ee235b"
integrity sha512-8OEhlog1iaAGTD3LICZ8oBGQdYeMwByvXetOtAOZCJOzyCRSwqwdggTsmVZZ1rkgYIEqgUBk942AsAPwM21s6A==
"@vueuse/shared@8.7.5":
version "8.7.5"
resolved "https://registry.npmmirror.com/@vueuse/shared/-/shared-8.7.5.tgz"
integrity sha512-THXPvMBFmg6Gf6AwRn/EdTh2mhqwjGsB2Yfp374LNQSQVKRHtnJ0I42bsZTn7nuEliBxqUrGQm/lN6qUHmhJLw==
"@vueuse/shared@9.1.0":
version "9.1.0"
resolved "https://registry.npmmirror.com/@vueuse/shared/-/shared-9.1.0.tgz#d8459a45324f32fb05a2a56ed754637c3d0efaeb"
integrity sha512-pB/3njQu4tfJJ78ajELNda0yMG6lKfpToQW7Soe09CprF1k3QuyoNi1tBNvo75wBDJWD+LOnr+c4B5HZ39jY/Q==
dependencies:
vue-demi "*"
@@ -421,7 +426,7 @@ ansi-regex@^5.0.1:
resolved "https://registry.nlark.com/ansi-regex/download/ansi-regex-5.0.1.tgz"
integrity sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=
ansi-styles@^4.1.0:
ansi-styles@^4.0.0, ansi-styles@^4.1.0:
version "4.3.0"
resolved "https://registry.nlark.com/ansi-styles/download/ansi-styles-4.3.0.tgz"
integrity sha1-7dgDYornHATIWuegkG7a00tkiTc=
@@ -526,6 +531,15 @@ clipboard@^2.0.6:
select "^1.1.2"
tiny-emitter "^2.0.0"
cliui@^7.0.2:
version "7.0.4"
resolved "https://registry.npmmirror.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==
dependencies:
string-width "^4.2.0"
strip-ansi "^6.0.0"
wrap-ansi "^7.0.0"
codemirror@^5.65.5:
version "5.65.5"
resolved "https://registry.npmmirror.com/codemirror/-/codemirror-5.65.5.tgz"
@@ -596,6 +610,11 @@ deep-is@^0.1.3:
resolved "https://registry.nlark.com/deep-is/download/deep-is-0.1.4.tgz"
integrity sha1-pvLc5hL63S7x9Rm3NVHxfoUZmDE=
define-lazy-prop@^2.0.0:
version "2.0.0"
resolved "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz"
@@ -633,10 +652,10 @@ echarts@^5.3.3:
tslib "2.3.0"
zrender "5.3.2"
element-plus@^2.2.10:
version "2.2.10"
resolved "https://registry.npmmirror.com/element-plus/-/element-plus-2.2.10.tgz#0b06a006b67b7ad3d5f071545a910782f9ba471b"
integrity sha512-hJ+LlbRN3POu4Idl1LXB+SHSWdi+wwmdsoDXdQT2ynGuwzZsMYiusOooYXyEsPlrizeLibdnNGNDx4TIjXQvUg==
element-plus@^2.2.14:
version "2.2.14"
resolved "https://registry.npmmirror.com/element-plus/-/element-plus-2.2.14.tgz#161f2cbf2c12608a570af303f8191c7d7eae725b"
integrity sha512-V5Pis0OHhePg1RgVogZrcefaVl8vjVn4Pn9Qsh/t2CbFgjg9kKOYFqf/tuP3ObSXGm3X89hpe0W+nLVAsaFnpw==
dependencies:
"@ctrl/tinycolor" "^3.4.1"
"@element-plus/icons-vue" "^2.0.6"
@@ -644,7 +663,7 @@ element-plus@^2.2.10:
"@popperjs/core" "npm:@sxzz/popperjs-es@^2.11.7"
"@types/lodash" "^4.14.182"
"@types/lodash-es" "^4.17.6"
"@vueuse/core" "^8.7.5"
"@vueuse/core" "^9.1.0"
async-validator "^4.2.5"
dayjs "^1.11.3"
escape-html "^1.0.3"
@@ -652,7 +671,12 @@ element-plus@^2.2.10:
lodash-es "^4.17.21"
lodash-unified "^1.0.2"
memoize-one "^6.0.0"
normalize-wheel-es "^1.1.2"
normalize-wheel-es "^1.2.0"
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
enquirer@^2.3.5:
version "2.3.6"
@@ -787,6 +811,11 @@ esbuild@^0.14.27:
esbuild-windows-64 "0.14.38"
esbuild-windows-arm64 "0.14.38"
escalade@^3.1.1:
version "3.1.1"
resolved "https://registry.npmmirror.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
escape-html@^1.0.3:
version "1.0.3"
resolved "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz"
@@ -1029,6 +1058,11 @@ functional-red-black-tree@^1.0.1:
resolved "https://registry.npm.taobao.org/functional-red-black-tree/download/functional-red-black-tree-1.0.1.tgz?cache=0&sync_timestamp=1577806294691&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffunctional-red-black-tree%2Fdownload%2Ffunctional-red-black-tree-1.0.1.tgz"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
get-caller-file@^2.0.5:
version "2.0.5"
resolved "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.npmmirror.com/glob-parent/download/glob-parent-5.1.2.tgz"
@@ -1148,11 +1182,21 @@ is-core-module@^2.8.1:
dependencies:
has "^1.0.3"
is-docker@^2.0.0, is-docker@^2.1.1:
version "2.2.1"
resolved "https://registry.npmmirror.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.npm.taobao.org/is-extglob/download/is-extglob-2.1.1.tgz"
integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
is-fullwidth-code-point@^3.0.0:
version "3.0.0"
resolved "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.npmmirror.com/is-glob/download/is-glob-4.0.3.tgz"
@@ -1165,6 +1209,13 @@ is-number@^7.0.0:
resolved "https://registry.nlark.com/is-number/download/is-number-7.0.0.tgz"
integrity sha1-dTU0W4lnNNX4DE0GxQlVUnoU8Ss=
is-wsl@^2.2.0:
version "2.2.0"
resolved "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
dependencies:
is-docker "^2.0.0"
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.npm.taobao.org/isexe/download/isexe-2.0.0.tgz"
@@ -1351,10 +1402,10 @@ normalize-path@^3.0.0, normalize-path@~3.0.0:
resolved "https://registry.npm.taobao.org/normalize-path/download/normalize-path-3.0.0.tgz"
integrity sha1-Dc1p/yOhybEf0JeDFmRKA4ghamU=
normalize-wheel-es@^1.1.2:
version "1.1.2"
resolved "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.1.2.tgz"
integrity sha512-scX83plWJXYH1J4+BhAuIHadROzxX0UBF3+HuZNY2Ks8BciE7tSTQ+5JhTsvzjaO0/EJdm4JBGrfObKxFf3Png==
normalize-wheel-es@^1.2.0:
version "1.2.0"
resolved "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz#0fa2593d619f7245a541652619105ab076acf09e"
integrity sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==
nprogress@^0.2.0:
version "0.2.0"
@@ -1368,6 +1419,15 @@ once@^1.3.0:
dependencies:
wrappy "1"
open@^8.4.0:
version "8.4.0"
resolved "https://registry.npmmirror.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8"
integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==
dependencies:
define-lazy-prop "^2.0.0"
is-docker "^2.1.1"
is-wsl "^2.2.0"
optionator@^0.9.1:
version "0.9.1"
resolved "https://registry.npm.taobao.org/optionator/download/optionator-0.9.1.tgz"
@@ -1477,6 +1537,11 @@ regexpp@^3.1.0, regexpp@^3.2.0:
resolved "https://registry.nlark.com/regexpp/download/regexpp-3.2.0.tgz?cache=0&sync_timestamp=1623668872577&other_urls=https%3A%2F%2Fregistry.nlark.com%2Fregexpp%2Fdownload%2Fregexpp-3.2.0.tgz"
integrity sha1-BCWido2PI7rXDKS5BGH6LxIT4bI=
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
resolve-from@^4.0.0:
version "4.0.0"
resolved "https://registry.nlark.com/resolve-from/download/resolve-from-4.0.0.tgz"
@@ -1503,6 +1568,16 @@ rimraf@^3.0.2:
dependencies:
glob "^7.1.3"
rollup-plugin-visualizer@^5.8.0:
version "5.8.0"
resolved "https://registry.npmmirror.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.8.0.tgz#32f2fe23d4299e977c06c59c07255590354e3445"
integrity sha512-pY6j/7qHz5I9rB7d/bQoA5gX+2FbV3MBG055wrsFxDn550bgl0FNViRj6wDHh85PMswv+JVdZjhnMBzz/hdAHA==
dependencies:
nanoid "^3.3.4"
open "^8.4.0"
source-map "^0.7.3"
yargs "^17.5.1"
rollup@^2.59.0:
version "2.61.1"
resolved "https://registry.npmmirror.com/rollup/download/rollup-2.61.1.tgz"
@@ -1534,10 +1609,10 @@ sass@^1.45.1:
immutable "^4.0.0"
source-map-js ">=0.6.2 <2.0.0"
screenfull@^5.1.0:
version "5.2.0"
resolved "https://registry.npmmirror.com/screenfull/download/screenfull-5.2.0.tgz?cache=0&sync_timestamp=1635923453416&other_urls=https%3A%2F%2Fregistry.npmmirror.com%2Fscreenfull%2Fdownload%2Fscreenfull-5.2.0.tgz"
integrity sha1-ZTPVJNMGIfwSg7lpIUbz8TqT0bo=
screenfull@^6.0.2:
version "6.0.2"
resolved "https://registry.npmmirror.com/screenfull/-/screenfull-6.0.2.tgz#3dbe4b8c4f8f49fb8e33caa8f69d0bca730ab238"
integrity sha512-AQdy8s4WhNvUZ6P8F6PB21tSPIYKniic+Ogx0AacBMjKP1GUHN2E9URxQHtCusiwxudnCKkdy4GrHXPPJSkCCw==
select@^1.1.2:
version "1.1.2"
@@ -1588,19 +1663,33 @@ source-map@^0.6.1:
resolved "https://registry.npm.taobao.org/source-map/download/source-map-0.6.1.tgz"
integrity sha1-dHIq8y6WFOnCh6jQu95IteLxomM=
source-map@^0.7.3:
version "0.7.4"
resolved "https://registry.npmmirror.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656"
integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==
sourcemap-codec@^1.4.4:
version "1.4.8"
resolved "https://registry.npm.taobao.org/sourcemap-codec/download/sourcemap-codec-1.4.8.tgz"
integrity sha1-6oBL2UhXQC5pktBaOO8a41qatMQ=
sql-formatter@^7.0.3:
version "7.0.3"
resolved "https://registry.npmmirror.com/sql-formatter/-/sql-formatter-7.0.3.tgz"
integrity sha512-E9zotLB0dy9ZZhs1sY4ZqzSzJGF2uC4Vzj0mEzXJC9rlE+Jjmz6t64qT2dzm/IPQosYvZknDbBOrWkygIJz67A==
sql-formatter@^9.2.0:
version "9.2.0"
resolved "https://registry.npmmirror.com/sql-formatter/-/sql-formatter-9.2.0.tgz#18a398ae71436dc1936a45e6f230236b4347231b"
integrity sha512-Dn4lEpUeAhfNDR2LnEs9Uaq92TSHjhcNrzhllPuMnp188P4sLU7UcdcB9UqIfMfcN62gWXJlJ3KocaAf/SOzXQ==
dependencies:
argparse "^2.0.1"
strip-ansi@^6.0.1:
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmmirror.com/strip-ansi/download/strip-ansi-6.0.1.tgz"
integrity sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=
@@ -1768,6 +1857,15 @@ word-wrap@^1.2.3:
resolved "https://registry.npm.taobao.org/word-wrap/download/word-wrap-1.2.3.tgz"
integrity sha1-YQY29rH3A4kb00dxzLF/uTtHB5w=
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.nlark.com/wrappy/download/wrappy-1.0.2.tgz?cache=0&sync_timestamp=1619133505879&other_urls=https%3A%2F%2Fregistry.nlark.com%2Fwrappy%2Fdownload%2Fwrappy-1.0.2.tgz"
@@ -1783,11 +1881,34 @@ xterm@^4.19.0:
resolved "https://registry.npmmirror.com/xterm/-/xterm-4.19.0.tgz"
integrity sha512-c3Cp4eOVsYY5Q839dR5IejghRPpxciGmLWWaP9g+ppfMeBChMeLa1DCA+pmX/jyDZ+zxFOmlJL/82qVdayVoGQ==
y18n@^5.0.5:
version "5.0.8"
resolved "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.nlark.com/yallist/download/yallist-4.0.0.tgz"
integrity sha1-m7knkNnA7/7GO+c1GeEaNQGaOnI=
yargs-parser@^21.0.0:
version "21.1.1"
resolved "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
yargs@^17.5.1:
version "17.5.1"
resolved "https://registry.npmmirror.com/yargs/-/yargs-17.5.1.tgz#e109900cab6fcb7fd44b1d8249166feb0b36e58e"
integrity sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA==
dependencies:
cliui "^7.0.2"
escalade "^3.1.1"
get-caller-file "^2.0.5"
require-directory "^2.1.1"
string-width "^4.2.3"
y18n "^5.0.5"
yargs-parser "^21.0.0"
zrender@5.3.2:
version "5.3.2"
resolved "https://registry.npmmirror.com/zrender/-/zrender-5.3.2.tgz"

View File

@@ -1,7 +1,3 @@
app:
name: mayfly-go
version: 1.2.3
server:
# debug release test
model: release
@@ -11,24 +7,14 @@ server:
enable: false
key-file: ./default.key
cert-file: ./default.pem
# 静态资源
static:
- relative-path: /assets
root: ./static/assets
# 静态文件
static-file:
- relative-path: /
filepath: ./static/index.html
- relative-path: /favicon.ico
filepath: ./static/favicon.ico
- relative-path: /config.js
filepath: ./static/config.js
jwt:
key: mykey
# jwt key不设置默认使用随机字符串
key:
# 过期时间单位分钟
expire-time: 1440
# 资源密码aes加密key
aes:
key: 1111111111111111
mysql:
host: localhost:3306
username: root
@@ -36,7 +22,6 @@ mysql:
db-name: mayfly-go
config: charset=utf8&loc=Local&parseTime=true
max-idle-conns: 5
log:
# 日志等级, trace, debug, info, warn, error, fatal
level: info

View File

@@ -3,23 +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.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-20220622213112-05595931fe9d // 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 (
@@ -34,7 +34,7 @@ require (
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,16 +2,25 @@ package initialize
import (
"fmt"
"io/fs"
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"
"mayfly-go/pkg/middleware"
"mayfly-go/static"
"net/http"
"github.com/gin-gonic/gin"
)
func WrapStaticHandler(h http.Handler) gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Cache-Control", `public, max-age=31536000`)
h.ServeHTTP(c.Writer, c.Request)
}
}
func InitRouter() *gin.Engine {
// server配置
serverConfig := config.Conf.Server
@@ -25,12 +34,21 @@ func InitRouter() *gin.Engine {
g.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": fmt.Sprintf("not found '%s:%s'", g.Request.Method, g.Request.URL.Path)})
})
// 使用embed打包静态资源至二进制文件中
fsys, _ := fs.Sub(static.Static, "static")
fileServer := http.FileServer(http.FS(fsys))
handler := WrapStaticHandler(fileServer)
router.GET("/", handler)
router.GET("/favicon.ico", handler)
router.GET("/config.js", handler)
// 所有/assets/**开头的都是静态资源文件
router.GET("/assets/*file", handler)
// 设置静态资源
if staticConfs := serverConfig.Static; staticConfs != nil {
for _, scs := range *staticConfs {
router.Static(scs.RelativePath, scs.Root)
}
}
// 设置静态文件
if staticFileConfs := serverConfig.StaticFile; staticFileConfs != nil {
@@ -38,6 +56,7 @@ func InitRouter() *gin.Engine {
router.StaticFile(sfs.RelativePath, sfs.Filepath)
}
}
// 是否允许跨域
if serverConfig.Cors {
router.Use(middleware.Cors())

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

@@ -9,8 +9,8 @@ const (
MongoConnExpireTime = 30 * time.Minute
/**** 开发测试使用 ****/
// MachineConnExpireTime = 20 * time.Second
// DbConnExpireTime = 20 * time.Second
// RedisConnExpireTime = 20 * time.Second
// MongoConnExpireTime = 20 * time.Second
// MachineConnExpireTime = 4 * time.Minute
// DbConnExpireTime = 2 * time.Minute
// RedisConnExpireTime = 2 * time.Minute
// MongoConnExpireTime = 2 * time.Minute
)

View File

@@ -61,6 +61,14 @@ 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{}
@@ -89,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) {
@@ -229,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 {
@@ -295,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]
@@ -306,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))
@@ -330,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

@@ -72,6 +72,14 @@ func (m *Machine) SaveMachine(rc *ctx.ReqCtx) {
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) {
g := rc.GinCtx
id := uint64(ginx.PathParamInt(g, "machineId"))
@@ -152,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,10 +38,6 @@ func (m *Mongo) Save(rc *ctx.ReqCtx) {
mongo := new(entity.Mongo)
utils.Copy(mongo, form)
// 解密uri并使用解密后的赋值
originUri, err := utils.DefaultRsaDecrypt(form.Uri, true)
biz.ErrIsNilAppendErr(err, "解密uri错误: %s")
mongo.Uri = originUri
mongo.SetBaseInfo(rc.LoginAccount)
m.MongoApp.Save(mongo)

View File

@@ -52,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")))
}
@@ -185,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
@@ -277,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)
@@ -297,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)
@@ -307,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

@@ -97,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
}
@@ -129,6 +130,7 @@ func (d *dbAppImpl) Save(dbEntity *entity.Db) {
d.dbSqlRepo.DeleteBy(&entity.DbSql{DbId: dbId, Db: v.(string)})
}
dbEntity.PwdEncrypt()
d.dbRepo.Update(dbEntity)
}
@@ -145,6 +147,7 @@ func (d *dbAppImpl) Delete(id uint64) {
}
func (d *dbAppImpl) GetDatabases(ed *entity.Db) []string {
ed.Network = ed.GetNetwork()
databases := make([]string, 0)
var dbConn *sql.DB
var metaDb string
@@ -180,10 +183,12 @@ func (da *dbAppImpl) GetDbInstance(id uint64, db string) *DbInstance {
return load.(*DbInstance)
}
}
biz.IsTrue(mutex.TryLock(), "有数据库实例在连接中...请稍后重试")
mutex.Lock()
defer mutex.Unlock()
d := da.GetById(id)
// 密码解密
d.PwdDecrypt()
biz.NotNil(d, "数据库信息不存在")
biz.IsTrue(strings.Contains(d.Database, db), "未配置该库的操作权限")
@@ -214,6 +219,9 @@ func (da *dbAppImpl) GetDbInstance(id uint64, db string) *DbInstance {
//------------------------------------------------------------------------------
// 单次最大查询数据集
const Max_Rows = 2000
// 客户端连接缓存,指定时间内没有访问则会被关闭, key为数据库实例id:数据库
var dbCache = cache.NewTimedCache(constant.DbConnExpireTime, 5*time.Second).
WithUpdateAccessTime(true).
@@ -258,10 +266,9 @@ func GetDbConn(d *entity.Db, db string) (*sql.DB, error) {
// SSH Conect
if d.EnableSshTunnel == 1 && d.SshTunnelMachineId != 0 {
sshTunnelMachine := MachineApp.GetSshTunnelMachine(d.SshTunnelMachineId)
defer machine.CloseSshTunnelMachine(d.SshTunnelMachineId, 0)
if d.Type == entity.DbTypeMysql {
mysql.RegisterDialContext(d.Network, func(ctx context.Context, addr string) (net.Conn, error) {
return MachineApp.GetSshTunnelMachine(d.SshTunnelMachineId).GetDialConn("tcp", addr)
return sshTunnelMachine.GetDialConn("tcp", addr)
})
} else if d.Type == entity.DbTypePostgres {
_, err := pq.DialOpen(&PqSqlDialer{sshTunnelMachine: sshTunnelMachine}, getDsn(d, db))
@@ -311,7 +318,11 @@ func SelectDataByDb(db *sql.DB, selectSql string) ([]string, []map[string]interf
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 {
@@ -325,6 +336,7 @@ func SelectDataByDb(db *sql.DB, selectSql string) ([]string, []map[string]interf
colName := colType.Name()
// 字段类型名
colScanType := colType.ScanType().Name()
// 如果是第一行则将列名加入到列信息中由于map是无序的所有需要返回列名的有序数组
if isFirst {
colNames = append(colNames, colName)
}
@@ -419,6 +431,18 @@ 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() {
if d.db != nil {
@@ -455,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,
@@ -475,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
@@ -488,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
@@ -534,18 +647,18 @@ const (
`
)
func (d *DbInstance) GetTableMetedatas() []map[string]interface{} {
var sql string
if d.Type == entity.DbTypeMysql {
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 {
@@ -554,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 == entity.DbTypeMysql {
countSqlTmp = MYSQL_COLOUMN_MA_COUNT
sqlTmp = MYSQL_COLUMN_MA
} else if d.Type == entity.DbTypePostgres {
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 == entity.DbTypeMysql {
sql = MYSQL_TABLE_INFO
} else if d.Type == entity.DbTypePostgres {
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 == entity.DbTypeMysql {
sql = fmt.Sprintf(MYSQL_INDEX_INFO, tableName)
} else if d.Type == entity.DbTypePostgres {
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 == entity.DbTypeMysql {
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

@@ -69,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)
}
}
@@ -123,6 +125,7 @@ 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
})
@@ -133,6 +136,7 @@ func (m *machineAppImpl) GetCli(id uint64) *machine.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
})

View File

@@ -80,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 {
// 如果存在该库,则校验修改的库是否为该库
@@ -88,6 +89,7 @@ func (r *redisAppImpl) Save(re *entity.Redis) {
}
// 先关闭数据库连接
CloseRedis(re.Id)
re.PwdEncrypt()
r.redisRepo.Update(re)
}
}
@@ -110,6 +112,7 @@ func (r *redisAppImpl) GetRedisInstance(id uint64) *RedisInstance {
}
// 缓存不存在则回调获取redis信息
re := r.GetById(id)
re.PwdDecrypt()
biz.NotNil(re, "redis信息不存在")
redisMode := re.Mode
@@ -130,6 +133,14 @@ func (r *redisAppImpl) GetRedisInstance(id uint64) *RedisInstance {
ri.Close()
panic(biz.NewBizErr(fmt.Sprintf("redis集群连接失败: %s", e.Error())))
}
} 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)
@@ -174,6 +185,27 @@ func getRedisClusterClient(re *entity.Redis) *RedisInstance {
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) {
@@ -224,6 +256,10 @@ func TestRedisConnection(re *entity.Redis) {
ccli := getRedisClusterClient(re)
defer ccli.Close()
cmd = ccli.ClusterCli
} else if re.Mode == entity.RedisModeSentinel {
rcli := getRedisSentinelCient(re)
defer rcli.Close()
cmd = rcli.Cli
}
// 测试连接
@@ -244,10 +280,10 @@ type RedisInstance struct {
// 获取命令执行接口的具体实现
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
@@ -260,7 +296,7 @@ func (r *RedisInstance) Scan(cursor uint64, match string, count int64) ([]string
}
func (r *RedisInstance) Close() {
if r.Mode == entity.RedisModeStandalone {
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())
}

View File

@@ -2,6 +2,7 @@ package entity
import (
"fmt"
"mayfly-go/internal/common/utils"
"mayfly-go/pkg/model"
)
@@ -27,9 +28,9 @@ type Db struct {
}
// 获取数据库连接网络, 若没有使用ssh隧道则直接返回。否则返回拼接的网络需要注册至指定dial
func (d Db) GetNetwork() string {
func (d *Db) GetNetwork() string {
network := d.Network
if d.EnableSshTunnel == -1 {
if d.EnableSshTunnel == 0 || d.EnableSshTunnel == -1 {
if network == "" {
return "tcp"
} else {
@@ -39,6 +40,16 @@ func (d Db) GetNetwork() string {
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,6 +1,7 @@
package entity
import (
"mayfly-go/internal/common/utils"
"mayfly-go/pkg/model"
)
@@ -26,3 +27,13 @@ const (
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

@@ -1,6 +1,7 @@
package entity
import (
"mayfly-go/internal/common/utils"
"mayfly-go/pkg/model"
)
@@ -23,4 +24,15 @@ type Redis struct {
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

@@ -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

@@ -5,11 +5,11 @@ import (
"io"
"mayfly-go/internal/devops/domain/entity"
"mayfly-go/pkg/global"
"mayfly-go/pkg/scheduler"
"mayfly-go/pkg/utils"
"net"
"os"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
@@ -31,30 +31,29 @@ type CheckSshTunnelMachineHasUseFunc func(uint64) bool
func startCheckUse() {
global.Log.Info("开启定时检测ssh隧道机器是否还有被使用")
heartbeat := time.Duration(10) * time.Minute
tick := time.NewTicker(heartbeat)
go func() {
for range tick.C {
func() {
if !mutex.TryLock() {
return
}
defer mutex.Unlock()
// 遍历隧道机器,都未被使用将会被关闭
for mid, sshTunnelMachine := range sshTunnelMachines {
global.Log.Debugf("开始定时检查ssh隧道机器[%d]是否还有被使用...", mid)
for _, checkUseFunc := range checkSshTunnelMachineHasUseFuncs {
// 如果一个在使用则返回不关闭,不继续后续检查
if checkUseFunc(mid) {
return
}
}
// 都未被使用,则关闭
sshTunnelMachine.Close()
}
}()
// 每十分钟检查一次隧道机器是否还有被使用
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隧道机器检测是否使用函数
@@ -129,7 +128,10 @@ func (stm *SshTunnelMachine) Close() {
if stm.SshClient != nil {
global.Log.Infof("ssh隧道机器[%d]未被使用, 关闭隧道...", stm.machineId)
stm.SshClient.Close()
err := stm.SshClient.Close()
if err != nil {
global.Log.Errorf("关闭ssh隧道机器[%d]发生错误: %s", stm.machineId, err.Error())
}
}
delete(sshTunnelMachines, stm.machineId)
}

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

@@ -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,11 +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

@@ -38,8 +38,10 @@ func (a *Account) Login(rc *ctx.ReqCtx) {
originPwd, err := utils.DefaultRsaDecrypt(loginForm.Password, true)
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
account := &entity.Account{Username: loginForm.Username, Password: utils.Md5(originPwd)}
biz.ErrIsNil(a.AccountApp.GetAccount(account, "Id", "Username", "Status", "LastLoginTime", "LastLoginIp"), "用户名或密码错误")
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(), "该账号不可用")
// 校验密码强度是否符合
@@ -86,8 +88,11 @@ func (a *Account) ChangePassword(rc *ctx.ReqCtx) {
originOldPwd, err := utils.DefaultRsaDecrypt(form.OldPassword, true)
biz.ErrIsNilAppendErr(err, "解密旧密码错误: %s")
account := &entity.Account{Username: form.Username, Password: utils.Md5(originOldPwd)}
biz.ErrIsNil(a.AccountApp.GetAccount(account, "Id", "Username", "Status"), "旧密码不正确")
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")
@@ -95,7 +100,7 @@ func (a *Account) ChangePassword(rc *ctx.ReqCtx) {
updateAccount := new(entity.Account)
updateAccount.Id = account.Id
updateAccount.Password = utils.Md5(originNewPwd)
updateAccount.Password = utils.PwdHash(originNewPwd)
a.AccountApp.Update(updateAccount)
// 赋值loginAccount 主要用于记录操作日志,因为操作日志保存请求上下文没有该信息不保存日志
@@ -176,7 +181,7 @@ func (a *Account) UpdateAccount(rc *ctx.ReqCtx) {
if updateAccount.Password != "" {
biz.IsTrue(CheckPasswordLever(updateAccount.Password), "密码强度必须8位以上且包含字⺟⼤⼩写+数字+特殊符号")
updateAccount.Password = utils.Md5(updateAccount.Password)
updateAccount.Password = utils.PwdHash(updateAccount.Password)
}
a.AccountApp.Update(updateAccount)
}

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

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

View File

@@ -30,8 +30,8 @@ CREATE TABLE `t_db` (
`database` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '数据库,空格分割多个数据库',
`params` varchar(125) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '其他连接参数',
`network` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
`enableSshTunnel` tinyint(2) DEFAULT NULL COMMENT '是否启用ssh隧道',
`sshTunnelMachineId` bigint(20) DEFAULT NULL COMMENT 'ssh隧道的机器id',
`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',
@@ -111,8 +111,8 @@ CREATE TABLE `t_machine` (
`username` varchar(12) COLLATE utf8mb4_bin NOT NULL,
`auth_method` tinyint(2) NULL DEFAULT NULL COMMENT '1.密码登录2.publickey登录',
`password` varchar(3200) COLLATE utf8mb4_bin DEFAULT NULL,
`enableSshTunnel` tinyint(2) DEFAULT NULL COMMENT '是否启用ssh隧道',
`sshTunnelMachineId` bigint(20) DEFAULT NULL COMMENT 'ssh隧道的机器id',
`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,
@@ -260,11 +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,
`enableSshTunnel` tinyint(2) DEFAULT NULL COMMENT '是否启用ssh隧道',
`sshTunnelMachineId` bigint(20) DEFAULT NULL COMMENT 'ssh隧道的机器id',
`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,
@@ -286,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 '最后登录时间',
@@ -304,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;
-- ----------------------------
@@ -670,8 +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',
`enableSshTunnel` tinyint(2) DEFAULT NULL COMMENT '是否启用ssh隧道',
`sshTunnelMachineId` bigint(20) DEFAULT NULL COMMENT 'ssh隧道的机器id',
`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,

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

@@ -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

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

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

@@ -2,6 +2,8 @@ package utils
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"crypto/rand"
"crypto/rsa"
@@ -10,6 +12,8 @@ import (
"encoding/hex"
"encoding/pem"
"errors"
"golang.org/x/crypto/bcrypt"
)
// md5
@@ -19,6 +23,17 @@ func Md5(str string) string {
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
@@ -130,3 +145,84 @@ func GetRsaPrivateKey() (string, error) {
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
}

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)
}

View File

@@ -56,7 +56,7 @@ func checkConn() {
// 删除ws连接
func Delete(userid uint64) {
global.Log.Info("移除websocket连接uid = ", userid)
global.Log.Debug("移除websocket连接uid = ", userid)
conn := conns[userid]
if conn != nil {
conn.Close()

View File

@@ -1,11 +1,13 @@
相关配置文件:
后端:
config.yml: 服务端口mysql等信息在此配置即可。
config.yml: 服务端口mysqlaeskey(16 24 32位)jwtkey等信息在此配置即可。
建议务必将aes.key(资源密码加密如机器、数据库、redis等密码)与jwt.key(jwt秘钥)两信息使用随机字符串替换。
前端:
static/config.js: 若前后端分开部署则将该文件中的api地址配成后端服务的真实地址即可否则无需修改。
服务启动:./startup.sh
服务启动&重启./startup.sh
服务关闭:./shutdown.sh
直接通过 host:ip即可访问项目
初始账号 admin/123456
初始账号 admin/admin123.

View File

@@ -2,6 +2,12 @@
execfile=./mayfly-go
pid=`ps ax | grep -i 'mayfly-go' | grep -v grep | awk '{print $1}'`
if [ ! -z "${pid}" ] ; then
echo "The mayfly-go already running, shutdown and restart..."
kill ${pid}
fi
if [ ! -x "${execfile}" ]; then
sudo chmod +x "${execfile}"
fi

9
server/static/static.go Normal file
View File

@@ -0,0 +1,9 @@
package static
import "embed"
// 使用1.16特性编译阶段将静态资源文件打包进编译好的程序
var (
//go:embed static/**
Static embed.FS
)

View File

@@ -0,0 +1 @@
.error[data-v-6ec92039]{height:100%;background-color:#fff;display:flex}.error .error-flex[data-v-6ec92039]{margin:auto;display:flex;height:350px;width:900px}.error .error-flex .left[data-v-6ec92039]{flex:1;height:100%;align-items:center;display:flex}.error .error-flex .left .left-item .left-item-animation[data-v-6ec92039]{opacity:0;animation-name:error-num;animation-duration:.5s;animation-fill-mode:forwards}.error .error-flex .left .left-item .left-item-num[data-v-6ec92039]{color:#d6e0f6;font-size:55px}.error .error-flex .left .left-item .left-item-title[data-v-6ec92039]{font-size:20px;color:#333;margin:15px 0 5px;animation-delay:.1s}.error .error-flex .left .left-item .left-item-msg[data-v-6ec92039]{color:#c0bebe;font-size:12px;margin-bottom:30px;animation-delay:.2s}.error .error-flex .left .left-item .left-item-btn[data-v-6ec92039]{animation-delay:.2s}.error .error-flex .right[data-v-6ec92039]{flex:1;opacity:0;animation-name:error-img;animation-duration:2s;animation-fill-mode:forwards}.error .error-flex .right img[data-v-6ec92039]{width:100%;height:100%}

View File

@@ -0,0 +1 @@
import{_ as s,u as n,b as l,e as c,h as e,g as d,w as f,Y as m,Q as u,R as _,d as p,B as h}from"./index.1661345446364.js";var x="assets/401.1661345446364.png";const v={name:"401",setup(){const t=n();return{onSetAuth:()=>{m(),t.push("/login")}}}},o=t=>(u("data-v-6ec92039"),t=t(),_(),t),g={class:"error"},y={class:"error-flex"},b={class:"left"},C={class:"left-item"},B=o(()=>e("div",{class:"left-item-animation left-item-num"},"401",-1)),w=o(()=>e("div",{class:"left-item-animation left-item-title"},"\u60A8\u672A\u88AB\u6388\u6743\u6216\u767B\u5F55\u8D85\u65F6\uFF0C\u6CA1\u6709\u64CD\u4F5C\u6743\u9650",-1)),A=o(()=>e("div",{class:"left-item-animation left-item-msg"},null,-1)),S={class:"left-item-animation left-item-btn"},F=h("\u91CD\u65B0\u767B\u5F55"),k=o(()=>e("div",{class:"right"},[e("img",{src:x})],-1));function I(t,r,z,a,D,N){const i=l("el-button");return p(),c("div",g,[e("div",y,[e("div",b,[e("div",C,[B,w,A,e("div",S,[d(i,{type:"primary",round:"",onClick:a.onSetAuth},{default:f(()=>[F]),_:1},8,["onClick"])])])]),k])])}var $=s(v,[["render",I],["__scopeId","data-v-6ec92039"]]);export{$ as default};

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -0,0 +1 @@
.error[data-v-69e91ac8]{height:100%;background-color:#fff;display:flex}.error .error-flex[data-v-69e91ac8]{margin:auto;display:flex;height:350px;width:900px}.error .error-flex .left[data-v-69e91ac8]{flex:1;height:100%;align-items:center;display:flex}.error .error-flex .left .left-item .left-item-animation[data-v-69e91ac8]{opacity:0;animation-name:error-num;animation-duration:.5s;animation-fill-mode:forwards}.error .error-flex .left .left-item .left-item-num[data-v-69e91ac8]{color:#d6e0f6;font-size:55px}.error .error-flex .left .left-item .left-item-title[data-v-69e91ac8]{font-size:20px;color:#333;margin:15px 0 5px;animation-delay:.1s}.error .error-flex .left .left-item .left-item-msg[data-v-69e91ac8]{color:#c0bebe;font-size:12px;margin-bottom:30px;animation-delay:.2s}.error .error-flex .left .left-item .left-item-btn[data-v-69e91ac8]{animation-delay:.2s}.error .error-flex .right[data-v-69e91ac8]{flex:1;opacity:0;animation-name:error-img;animation-duration:2s;animation-fill-mode:forwards}.error .error-flex .right img[data-v-69e91ac8]{width:100%;height:100%}

View File

@@ -0,0 +1 @@
import{_ as s,u as n,b as l,e as c,h as e,g as d,w as m,Q as f,R as u,d as _,B as p}from"./index.1661345446364.js";var h="assets/404.1661345446364.png";const x={name:"404",setup(){const t=n();return{onGoHome:()=>{t.push("/")}}}},o=t=>(f("data-v-69e91ac8"),t=t(),u(),t),v={class:"error"},g={class:"error-flex"},y={class:"left"},F={class:"left-item"},b=o(()=>e("div",{class:"left-item-animation left-item-num"},"404",-1)),C=o(()=>e("div",{class:"left-item-animation left-item-title"},"\u5730\u5740\u8F93\u5165\u6709\u8BEF\uFF0C\u8BF7\u91CD\u65B0\u8F93\u5165\u5730\u5740~",-1)),B=o(()=>e("div",{class:"left-item-animation left-item-msg"},"\u60A8\u53EF\u4EE5\u5148\u68C0\u67E5\u7F51\u5740\uFF0C\u7136\u540E\u91CD\u65B0\u8F93\u5165",-1)),E={class:"left-item-animation left-item-btn"},w=p("\u8FD4\u56DE\u9996\u9875"),k=o(()=>e("div",{class:"right"},[e("img",{src:h})],-1));function D(t,a,I,r,z,G){const i=l("el-button");return _(),c("div",v,[e("div",g,[e("div",y,[e("div",F,[b,C,B,e("div",E,[d(i,{type:"primary",round:"",onClick:r.onGoHome},{default:m(()=>[w]),_:1},8,["onClick"])])])]),k])])}var N=s(x,[["render",D],["__scopeId","data-v-69e91ac8"]]);export{N as default};

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1 @@
import{p as r}from"./index.1661345446364.js";class s{constructor(t,e){this.url=t,this.method=e}setUrl(t){return this.url=t,this}setMethod(t){return this.method=t,this}getUrl(){return r.getApiUrl(this.url)}request(t=null,e=null){return r.send(this,t,e)}requestWithHeaders(t,e){return r.sendWithHeaders(this,t,e)}static create(t,e){return new s(t,e)}}export{s as A};

View File

@@ -0,0 +1 @@
#string-value-text{flex-grow:1;display:flex;position:relative}#string-value-text .text-type-select{position:absolute;z-index:2;right:10px;top:10px;max-width:70px}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
class n{add(t,e,r){return this[t]={label:e,value:r},this}getLabelByValue(t){if(t==null)return"";for(const e in this){const r=this[e];if(r&&r.value===t)return r.label}return""}}export{n as E};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
var P=Object.defineProperty,V=Object.defineProperties;var w=Object.getOwnPropertyDescriptors;var v=Object.getOwnPropertySymbols;var B=Object.prototype.hasOwnProperty,$=Object.prototype.propertyIsEnumerable;var h=(o,e,n)=>e in o?P(o,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):o[e]=n,j=(o,e)=>{for(var n in e||(e={}))B.call(e,n)&&h(o,n,e[n]);if(v)for(var n of v(e))$.call(e,n)&&h(o,n,e[n]);return o},_=(o,e)=>V(o,w(e));import{p as g}from"./api.16613454463644.js";import{A as S,r as F,o as A,t as N,_ as q,b as p,d as r,e as u,g as s,w as a,F as b,j as y,k as E,h as I,i as k,a3 as U}from"./index.1661345446364.js";const z=S({name:"ProjectEnvSelect",props:{visible:{type:Boolean},data:{type:Object},title:{type:String},machineId:{type:Number},isCommon:{type:Boolean}},setup(o,{emit:e}){const n=F({projects:[],envs:[],projectId:null,envId:null});A(async()=>{n.projects=await g.accountProjects.request(null)});const c=async l=>{e("update:projectId",l),e("changeProjectEnv",n.projectId,null),n.envId=null,n.envs=await g.projectEnvs.request({projectId:l})},d=l=>{e("update:envId",l),e("changeProjectEnv",n.projectId,l)};return _(j({},N(n)),{changeProject:c,changeEnv:d})}}),D={style:{float:"left"}},L={style:{float:"right",color:"#8492a6","font-size":"13px"}};function M(o,e,n,c,d,l){const i=p("el-option"),f=p("el-select"),m=p("el-form-item"),C=p("el-form");return r(),u("div",null,[s(C,{class:"search-form","label-position":"right",inline:!0},{default:a(()=>[s(m,{prop:"project",label:"\u9879\u76EE","label-width":"40px"},{default:a(()=>[s(f,{modelValue:o.projectId,"onUpdate:modelValue":e[0]||(e[0]=t=>o.projectId=t),placeholder:"\u8BF7\u9009\u62E9\u9879\u76EE",onChange:o.changeProject,filterable:""},{default:a(()=>[(r(!0),u(b,null,y(o.projects,t=>(r(),E(i,{key:t.id,label:`${t.name} [${t.remark}]`,value:t.id},null,8,["label","value"]))),128))]),_:1},8,["modelValue","onChange"])]),_:1}),s(m,{prop:"env",label:"env","label-width":"33px"},{default:a(()=>[s(f,{style:{width:"80px"},modelValue:o.envId,"onUpdate:modelValue":e[1]||(e[1]=t=>o.envId=t),placeholder:"\u73AF\u5883",onChange:o.changeEnv,filterable:""},{default:a(()=>[(r(!0),u(b,null,y(o.envs,t=>(r(),E(i,{key:t.id,label:t.name,value:t.id},{default:a(()=>[I("span",D,k(t.name),1),I("span",L,k(t.remark),1)]),_:2},1032,["label","value"]))),128))]),_:1},8,["modelValue","onChange"])]),_:1}),U(o.$slots,"default")]),_:3})])}var H=q(z,[["render",M]]);export{H as P};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.cm-s-base16-light.CodeMirror{background:#f5f5f5;color:#202020}.cm-s-base16-light div.CodeMirror-selected{background:#e0e0e0}.cm-s-base16-light .CodeMirror-line::selection,.cm-s-base16-light .CodeMirror-line>span::selection,.cm-s-base16-light .CodeMirror-line>span>span::selection{background:#e0e0e0}.cm-s-base16-light .CodeMirror-line::-moz-selection,.cm-s-base16-light .CodeMirror-line>span::-moz-selection,.cm-s-base16-light .CodeMirror-line>span>span::-moz-selection{background:#e0e0e0}.cm-s-base16-light .CodeMirror-gutters{background:#f5f5f5;border-right:0px}.cm-s-base16-light .CodeMirror-guttermarker{color:#ac4142}.cm-s-base16-light .CodeMirror-guttermarker-subtle,.cm-s-base16-light .CodeMirror-linenumber{color:#b0b0b0}.cm-s-base16-light .CodeMirror-cursor{border-left:1px solid #505050}.cm-s-base16-light span.cm-comment{color:#8f5536}.cm-s-base16-light span.cm-atom,.cm-s-base16-light span.cm-number{color:#aa759f}.cm-s-base16-light span.cm-property,.cm-s-base16-light span.cm-attribute{color:#90a959}.cm-s-base16-light span.cm-keyword{color:#ac4142}.cm-s-base16-light span.cm-string{color:#f4bf75}.cm-s-base16-light span.cm-variable{color:#90a959}.cm-s-base16-light span.cm-variable-2{color:#6a9fb5}.cm-s-base16-light span.cm-def{color:#d28445}.cm-s-base16-light span.cm-bracket{color:#202020}.cm-s-base16-light span.cm-tag{color:#ac4142}.cm-s-base16-light span.cm-link{color:#aa759f}.cm-s-base16-light span.cm-error{background:#ac4142;color:#505050}.cm-s-base16-light .CodeMirror-activeline-background{background:#DDDCDC}.cm-s-base16-light .CodeMirror-matchingbracket{color:#f5f5f5!important;background-color:#6a9fb5!important}.codesql{font-size:9pt;font-weight:600}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility,.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:.5}.xterm-underline{text-decoration:underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-decoration-overview-ruler{z-index:7;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}

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