refactor: 机器相关配置迁移至系统配置、pgsql数据操作完善、新增context-path

This commit is contained in:
meilin.huang
2023-11-12 20:14:44 +08:00
parent 27c53385f2
commit 76527d95bd
55 changed files with 793 additions and 404 deletions

View File

@@ -3,6 +3,10 @@ function getBaseApiUrl() {
if (path == '/') {
return window.location.host;
}
if (path.endsWith('/')) {
// 去除最后一个/
return window.location.host + path.replace(/\/$/, '');
}
return window.location.host + path;
}

View File

@@ -78,7 +78,7 @@
size="small"
@click="maximize(minimizeTerminal.terminalId)"
>
<el-tooltip effect="customized" :content="minimizeTerminal.desc" placement="top">
<el-tooltip :content="minimizeTerminal.desc" placement="top">
<span>
{{ minimizeTerminal.title }}
</span>

View File

@@ -7,7 +7,7 @@
</el-icon>
</template>
<span v-for="(v, i) in tags" :key="i">
<el-tooltip effect="customized" :content="v.remark" placement="top">
<el-tooltip :content="v.remark" placement="top">
<span class="color-success">{{ v.name }}</span>
</el-tooltip>
<span v-if="i != state.tags.length - 1" class="color-primary"> / </span>

View File

@@ -24,6 +24,8 @@ export class TagTreeNode {
*/
params: any;
icon: any;
static TagPath = -1;
constructor(key: any, label: string, type?: NodeType) {
@@ -42,6 +44,11 @@ export class TagTreeNode {
return this;
}
withIcon(icon: any) {
this.icon = icon;
return this;
}
/**
* 加载子节点使用节点类型的loadNodesFunc去加载子节点
* @returns 子节点信息

View File

@@ -57,34 +57,14 @@
</template>
<template #action="{ data }">
<el-popover placement="left" trigger="click" :width="300">
<template #reference>
<el-button type="primary" @click="selectDb(data.dbs)" link>库操作</el-button>
</template>
<el-input v-model="filterDb.param" @keyup="filterSchema" class="w-50 m-2" placeholder="搜索" size="small">
<template #prefix>
<el-icon class="el-input__icon">
<search-icon />
</el-icon>
</template>
</el-input>
<div
class="el-tag--plain el-tag--success"
v-for="db in filterDb.list"
:key="db"
style="border: 1px var(--color-success-light-3) solid; margin-top: 3px; border-radius: 5px; padding: 2px; position: relative"
>
<el-link type="success" plain size="small" :underline="false">{{ db }}</el-link>
<el-link type="primary" plain size="small" :underline="false" @click="showTableInfo(data, db)" style="position: absolute; right: 4px"
>操作
</el-link>
</div>
</el-popover>
<span v-if="actionBtns[perms.saveDb]">
<el-button type="primary" @click="editDb(data)" link>编辑</el-button>
<el-divider direction="vertical" border-style="dashed" />
</span>
<el-divider direction="vertical" border-style="dashed" />
<el-button type="primary" @click="onShowSqlExec(data)" link>SQL记录</el-button>
<el-divider direction="vertical" border-style="dashed" />
<el-dropdown @command="handleMoreActionCommand">
<span class="el-dropdown-link-more">
更多
@@ -96,7 +76,7 @@
<el-dropdown-menu>
<el-dropdown-item :command="{ type: 'detail', data }"> 详情 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'edit', data }" v-if="actionBtns[perms.saveDb]"> 编辑 </el-dropdown-item>
<!-- <el-dropdown-item :command="{ type: 'edit', data }" v-if="actionBtns[perms.saveDb]"> 编辑 </el-dropdown-item> -->
<el-dropdown-item :command="{ type: 'dumpDb', data }" v-if="data.type == 'mysql'"> 导出 </el-dropdown-item>
</el-dropdown-menu>
@@ -105,10 +85,6 @@
</template>
</page-table>
<el-dialog width="80%" :title="`${db} 表信息`" :before-close="closeTableInfo" v-model="tableInfoDialog.visible">
<db-table-list :db-id="dbId" :db="db" :db-type="state.row.type" />
</el-dialog>
<el-dialog width="620" :title="`${db} 数据库导出`" v-model="exportDialog.visible">
<el-row justify="space-between">
<el-col :span="9">
@@ -190,7 +166,6 @@ import { dbApi } from './api';
import config from '@/common/config';
import { joinClientParams } from '@/common/request';
import { isTrue } from '@/common/assert';
import { Search as SearchIcon } from '@element-plus/icons-vue';
import { dateFormat } from '@/common/utils/date';
import TagInfo from '../component/TagInfo.vue';
import PageTable from '@/components/pagetable/PageTable.vue';
@@ -199,7 +174,6 @@ import { hasPerms } from '@/components/auth/auth';
import DbSqlExecLog from './DbSqlExecLog.vue';
const DbEdit = defineAsyncComponent(() => import('./DbEdit.vue'));
const DbTableList = defineAsyncComponent(() => import('./table/DbTableList.vue'));
const perms = {
base: 'db',
@@ -254,13 +228,6 @@ const state = reactive({
instanceId: 0,
},
},
showDumpInfo: false,
dumpInfo: {
id: 0,
db: '',
type: 3,
tables: [],
},
// sql执行记录弹框
sqlExecLogDialog: {
title: '',
@@ -268,10 +235,6 @@ const state = reactive({
dbs: [],
dbId: 0,
},
chooseTableName: '',
tableInfoDialog: {
visible: false,
},
exportDialog: {
visible: false,
dbId: 0,
@@ -293,8 +256,7 @@ const state = reactive({
},
});
const { dbId, db, tags, selectionData, query, datas, total, infoDialog, sqlExecLogDialog, tableInfoDialog, exportDialog, dbEditDialog, filterDb } =
toRefs(state);
const { db, tags, selectionData, query, datas, total, infoDialog, sqlExecLogDialog, exportDialog, dbEditDialog } = toRefs(state);
onMounted(async () => {
if (Object.keys(actionBtns).length > 0) {
@@ -449,36 +411,6 @@ const dumpDbs = () => {
a.click();
state.exportDialog.visible = false;
};
const showTableInfo = async (row: any, db: string) => {
state.dbId = row.id;
state.row = row;
state.db = db;
state.tableInfoDialog.visible = true;
};
const closeTableInfo = () => {
state.showDumpInfo = false;
state.tableInfoDialog.visible = false;
};
// 点击查看时初始化数据
const selectDb = (row: any) => {
state.filterDb.param = '';
state.filterDb.cache = row;
state.filterDb.list = row;
};
// 输入字符过滤schema
const filterSchema = () => {
if (state.filterDb.param) {
state.filterDb.list = state.filterDb.cache.filter((a) => {
return String(a).toLowerCase().indexOf(state.filterDb.param) > -1;
});
} else {
state.filterDb.list = state.filterDb.cache;
}
};
</script>
<style lang="scss">
.el-dropdown-link-more {

View File

@@ -39,7 +39,7 @@
</template>
<script lang="ts" setup>
import { ref, toRefs,watch, reactive, computed, onMounted, defineAsyncComponent } from 'vue';
import { toRefs, watch, reactive, onMounted } from 'vue';
import { dbApi } from './api';
import { DbSqlExecTypeEnum } from './enums';
import PageTable from '@/components/pagetable/PageTable.vue';
@@ -103,13 +103,12 @@ onMounted(async () => {
searchSqlExecLog();
});
watch(props, async (newValue: any) => {
watch(props, async () => {
await searchSqlExecLog();
});
const searchSqlExecLog = async () => {
state.query.dbId = props.dbId
state.query.dbId = props.dbId;
const res = await dbApi.getSqlExecs.request(state.query);
state.data = res.list;
state.total = res.total;

View File

@@ -69,6 +69,7 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="testConn" :loading="state.testConnBtnLoading" type="success">测试连接</el-button>
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="btnLoading" @click="btnOk"> </el-button>
</div>
@@ -153,6 +154,7 @@ const state = reactive({
// 原用户名
oldUserName: null,
btnLoading: false,
testConnBtnLoading: false,
});
const { dialogVisible, tabActiveName, form, pwd, btnLoading } = toRefs(state);
@@ -176,6 +178,32 @@ const getDbPwd = async () => {
state.pwd = await dbApi.getInstancePwd.request({ id: state.form.id });
};
const getReqForm = async () => {
const reqForm = { ...state.form };
reqForm.password = await RsaEncrypt(reqForm.password);
if (!state.form.sshTunnelMachineId) {
reqForm.sshTunnelMachineId = -1;
}
return reqForm;
};
const testConn = async () => {
dbForm.value.validate(async (valid: boolean) => {
if (valid) {
state.testConnBtnLoading = true;
try {
await dbApi.testConn.request(await getReqForm());
ElMessage.success('连接成功');
} finally {
state.testConnBtnLoading = false;
}
} else {
ElMessage.error('请正确填写信息');
return false;
}
});
};
const btnOk = async () => {
if (!state.form.id) {
notBlank(state.form.password, '新增操作,密码不可为空');
@@ -185,12 +213,7 @@ const btnOk = async () => {
dbForm.value.validate(async (valid: boolean) => {
if (valid) {
const reqForm = { ...state.form };
reqForm.password = await RsaEncrypt(reqForm.password);
if (!state.form.sshTunnelMachineId) {
reqForm.sshTunnelMachineId = -1;
}
dbApi.saveInstance.request(reqForm).then(() => {
dbApi.saveInstance.request(await getReqForm()).then(() => {
ElMessage.success('保存成功');
emit('val-change', state.form);
state.btnLoading = true;

View File

@@ -87,7 +87,7 @@ const columns = ref([
// 该用户拥有的的操作列按钮权限
const actionBtns = hasPerms([perms.saveInstance]);
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(65).fixedRight().alignCenter();
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(110).fixedRight().alignCenter();
const pageTableRef: any = ref(null);

View File

@@ -34,7 +34,7 @@
<tag-tree ref="tagTreeRef" :loadTags="loadTags" @current-contextmenu-click="onCurrentContextmenuClick" :height="state.tagTreeHeight">
<template #prefix="{ data }">
<span v-if="data.type.value == SqlExecNodeType.DbInst">
<el-popover :show-after="500" placement="right-start" title="数据库实例信息" trigger="hover" :width="210">
<el-popover :show-after="500" placement="right-start" title="数据库实例信息" trigger="hover" :width="250">
<template #reference>
<SvgIcon v-if="data.params.type === 'mysql'" name="iconfont icon-op-mysql" :size="18" />
<SvgIcon v-if="data.params.type === 'postgres'" name="iconfont icon-op-postgres" :size="18" />
@@ -42,32 +42,31 @@
<SvgIcon name="InfoFilled" v-else />
</template>
<template #default>
<el-form class="instances-pop-form" label-width="auto" :size="'small'">
<el-form-item label="类型:">{{ data.params.type }}</el-form-item>
<el-form-item label="host:">{{ `${data.params.host}:${data.params.port}` }}</el-form-item>
<el-form-item label="user:">{{ data.params.username }}</el-form-item>
<el-form-item label="名称:">{{ data.params.name }}</el-form-item>
<el-form-item v-if="data.params.remark" label="备注:">{{ data.params.remark }}</el-form-item>
</el-form>
<el-descriptions :column="1" size="small">
<el-descriptions-item label="名称">
{{ data.params.name }}
</el-descriptions-item>
<el-descriptions-item label="host">
{{ `${data.params.host}:${data.params.port}` }}
</el-descriptions-item>
<el-descriptions-item label="user">
{{ data.params.username }}
</el-descriptions-item>
<el-descriptions-item label="备注">
{{ data.params.remark }}
</el-descriptions-item>
</el-descriptions>
</template>
</el-popover>
</span>
<SvgIcon v-if="data.type.value == SqlExecNodeType.Db" name="Coin" color="#67c23a" />
<SvgIcon v-if="data.icon" :name="data.icon.name" :color="data.icon.color" />
</template>
<SvgIcon name="Calendar" v-if="data.type.value == SqlExecNodeType.TableMenu" color="#409eff" />
<el-tooltip
:show-after="500"
v-if="data.type.value == SqlExecNodeType.Table"
effect="customized"
:content="data.params.tableComment"
placement="top-end"
>
<SvgIcon name="Calendar" color="#409eff" />
<template #label="{ data }">
<el-tooltip placement="left" :show-after="1000" v-if="data.type.value == SqlExecNodeType.Table" :content="data.params.tableComment">
{{ data.label }}
</el-tooltip>
<SvgIcon name="Files" v-if="data.type.value == SqlExecNodeType.SqlMenu || data.type.value == SqlExecNodeType.Sql" color="#f56c6c" />
</template>
<template #suffix="{ data }">
@@ -81,32 +80,61 @@
<el-col :span="20">
<el-container id="data-exec" class="mt5 ml5">
<el-tabs @tab-remove="onRemoveTab" @tab-change="onTabChange" style="width: 100%" v-model="state.activeName">
<el-tab-pane closable v-for="dt in state.tabs.values()" :key="dt.key" :label="dt.key" :name="dt.key">
<table-data
v-if="dt.type === TabType.TableData"
@gen-insert-sql="onGenerateInsertSql"
:data="dt"
:table-height="state.dataTabsTableHeight"
></table-data>
<el-tabs type="card" @tab-remove="onRemoveTab" @tab-change="onTabChange" style="width: 100%" v-model="state.activeName">
<el-tab-pane closable v-for="dt in state.tabs.values()" :label="dt.label" :name="dt.key" :key="dt.key">
<!-- <template #label>
<el-popover :show-after="500" placement="right-start" title="数据库实例信息" trigger="hover" :width="250">
<template #reference> {{ dt.label }} </template>
<template #default>
<el-descriptions :column="1" size="small">
<el-descriptions-item label="名称">
{{ dt.params.name }}
</el-descriptions-item>
<el-descriptions-item label="host">
{{ `${dt.params.host}:${dt.params.port}` }}
</el-descriptions-item>
<el-descriptions-item label="user">
{{ dt.params.username }}
</el-descriptions-item>
<el-descriptions-item label="备注">
{{ dt.params.remark }}
</el-descriptions-item>
</el-descriptions>
</template>
</el-popover>
</template> -->
<query
v-else
<db-table-data-op
v-if="dt.type === TabType.TableData"
:db-id="dt.dbId"
:db-name="dt.db"
:table-name="dt.params.table"
:table-height="state.dataTabsTableHeight"
></db-table-data-op>
<db-sql-editor
v-if="dt.type === TabType.Query"
:db-id="dt.dbId"
:db-name="dt.db"
:sql-name="dt.params.sqlName"
@save-sql-success="reloadSqls"
@delete-sql-success="deleteSqlScript(dt)"
:data="dt"
:editor-height="state.editorHeight"
>
</query>
</db-sql-editor>
<db-tables-op
v-if="dt.type == TabType.TablesOp"
:db-id="dt.params.id"
:db="dt.params.db"
:db-type="dt.params.type"
:height="state.tablesOpHeight"
/>
</el-tab-pane>
</el-tabs>
</el-container>
</el-col>
</el-row>
<el-dialog @close="state.genSqlDialog.visible = false" v-model="state.genSqlDialog.visible" title="SQL" width="1000px">
<el-input v-model="state.genSqlDialog.sql" type="textarea" rows="20" />
</el-dialog>
</div>
</template>
@@ -120,8 +148,10 @@ import TagTree from '../component/TagTree.vue';
import { dbApi } from './api';
import { dispposeCompletionItemProvider } from '../../../components/monaco/completionItemProvider';
const Query = defineAsyncComponent(() => import('./component/tab/Query.vue'));
const TableData = defineAsyncComponent(() => import('./component/tab/TableData.vue'));
const DbSqlEditor = defineAsyncComponent(() => import('./component/sqleditor/DbSqlEditor.vue'));
const DbTableDataOp = defineAsyncComponent(() => import('./component/table/DbTableDataOp.vue'));
const DbTablesOp = defineAsyncComponent(() => import('./component/table/DbTablesOp.vue'));
/**
* 树节点类型
*/
@@ -132,16 +162,40 @@ class SqlExecNodeType {
static SqlMenu = 4;
static Table = 5;
static Sql = 6;
static PgSchemaMenu = 7;
static PgSchema = 8;
}
const DbIcon = {
name: 'Coin',
color: '#67c23a',
};
// pgsql schema icon
const SchemaIcon = {
name: 'List',
color: '#67c23a',
};
const TableIcon = {
name: 'Calendar',
color: '#409eff',
};
const SqlIcon = {
name: 'Files',
color: '#f56c6c',
};
class ContextmenuClickId {
static ReloadTable = 0;
static TableOp = 1;
}
// node节点点击时触发改变db事件
const changeDb = (nodeData: TagTreeNode) => {
const nodeClickChangeDb = (nodeData: TagTreeNode) => {
const params = nodeData.params;
changeSchema({ id: params.id, name: params.name, type: params.type, tagPath: params.tagPath, databases: params.database }, params.db);
changeDb({ id: params.id, name: params.name, type: params.type, tagPath: params.tagPath, databases: params.database }, params.db);
};
// tagpath 节点类型
@@ -161,54 +215,93 @@ const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst)
const params = parentNode.params;
const dbs = params.database.split(' ')?.sort();
return dbs.map((x: any) => {
return new TagTreeNode(`${parentNode.key}.${x}`, x, NodeTypeDb).withParams({
tagPath: params.tagPath,
id: params.id,
name: params.name,
type: params.type,
dbs: dbs,
db: x,
});
return new TagTreeNode(`${parentNode.key}.${x}`, x, NodeTypeDb)
.withParams({
tagPath: params.tagPath,
id: params.id,
name: params.name,
type: params.type,
dbs: dbs,
db: x,
})
.withIcon(DbIcon);
});
})
.withNodeClickFunc(changeDb);
.withNodeClickFunc(nodeClickChangeDb);
// 数据库节点
const NodeTypeDb = new NodeType(SqlExecNodeType.Db)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
if (params.type == 'postgres') {
return [new TagTreeNode(`${params.id}.${params.db}.schema-menu`, 'schema', NodeTypePostgresScheamMenu).withParams(params).withIcon(SchemaIcon)];
}
return [
new TagTreeNode(`${params.id}.${params.db}.table-menu`, '表', NodeTypeTableMenu).withParams(params),
new TagTreeNode(getSqlMenuNodeKey(params.id, params.db), 'SQL', NodeTypeSqlMenu).withParams(params),
new TagTreeNode(`${params.id}.${params.db}.table-menu`, '表', NodeTypeTableMenu).withParams(params).withIcon(TableIcon),
new TagTreeNode(getSqlMenuNodeKey(params.id, params.db), 'SQL', NodeTypeSqlMenu).withParams(params).withIcon(SqlIcon),
];
})
.withNodeClickFunc(changeDb);
.withNodeClickFunc(nodeClickChangeDb);
// 数据库表菜单节点
const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
.withContextMenuItems([{ contextMenuClickId: ContextmenuClickId.ReloadTable, txt: '刷新', icon: 'RefreshRight' }] as any)
// postgres schema模式菜单
const NodeTypePostgresScheamMenu = new NodeType(SqlExecNodeType.PgSchemaMenu)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
const { id, db } = params;
const schemaNames = await dbApi.pgSchemas.request({ id, db });
return schemaNames.map((sn: any) => {
// 将db变更为 db/schema;
const nParams = { ...params };
nParams.schema = sn;
nParams.db = nParams.db + '/' + sn;
return new TagTreeNode(`${params.id}.${params.db}.schema.${sn}`, sn, NodeTypePostgresScheam).withParams(nParams).withIcon(SchemaIcon);
});
})
.withNodeClickFunc(nodeClickChangeDb);
// postgres schema模式
const NodeTypePostgresScheam = new NodeType(SqlExecNodeType.PgSchema)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
return [
new TagTreeNode(`${params.id}.${params.db}.table-menu`, '表', NodeTypeTableMenu).withParams(params).withIcon(TableIcon),
new TagTreeNode(getSqlMenuNodeKey(params.id, params.db), 'SQL', NodeTypeSqlMenu).withParams(params).withIcon(SqlIcon),
];
})
.withNodeClickFunc(nodeClickChangeDb);
// 数据库表菜单节点
const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
.withContextMenuItems([
{ contextMenuClickId: ContextmenuClickId.ReloadTable, txt: '刷新', icon: 'RefreshRight' },
{ contextMenuClickId: ContextmenuClickId.TableOp, txt: '表操作', icon: 'Setting' },
] as any)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
let { id, db } = params;
// 获取当前库的所有表信息
let tables = await DbInst.getInst(id).loadTables(db, state.reloadStatus);
state.reloadStatus = false;
let dbTableSize = 0;
const tablesNode = tables.map((x: any) => {
dbTableSize += x.dataLength + x.indexLength;
return new TagTreeNode(`${id}.${db}.${x.tableName}`, x.tableName, NodeTypeTable).withIsLeaf(true).withParams({
id,
db,
tableName: x.tableName,
tableComment: x.tableComment,
size: formatByteSize(x.dataLength + x.indexLength, 1),
});
return new TagTreeNode(`${id}.${db}.${x.tableName}`, x.tableName, NodeTypeTable)
.withIsLeaf(true)
.withParams({
id,
db,
tableName: x.tableName,
tableComment: x.tableComment,
size: formatByteSize(x.dataLength + x.indexLength, 1),
})
.withIcon(TableIcon);
});
// 设置父节点参数的表大小
parentNode.params.dbTableSize = formatByteSize(dbTableSize);
return tablesNode;
})
.withNodeClickFunc(changeDb);
.withNodeClickFunc(nodeClickChangeDb);
// 数据库sql模板菜单节点
const NodeTypeSqlMenu = new NodeType(SqlExecNodeType.SqlMenu)
@@ -220,15 +313,18 @@ const NodeTypeSqlMenu = new NodeType(SqlExecNodeType.SqlMenu)
// 加载用户保存的sql脚本
const sqls = await dbApi.getSqlNames.request({ id: id, db: db });
return sqls.map((x: any) => {
return new TagTreeNode(`${id}.${db}.${x.name}`, x.name, NodeTypeSql).withIsLeaf(true).withParams({
id,
db,
dbs,
sqlName: x.name,
});
return new TagTreeNode(`${id}.${db}.${x.name}`, x.name, NodeTypeSql)
.withIsLeaf(true)
.withParams({
id,
db,
dbs,
sqlName: x.name,
})
.withIcon(SqlIcon);
});
})
.withNodeClickFunc(changeDb);
.withNodeClickFunc(nodeClickChangeDb);
// 表节点类型
const NodeTypeTable = new NodeType(SqlExecNodeType.Table).withNodeClickFunc((nodeData: TagTreeNode) => {
@@ -256,11 +352,8 @@ const state = reactive({
tabs,
dataTabsTableHeight: '600',
editorHeight: '600',
tablesOpHeight: '600',
tagTreeHeight: window.innerHeight - 178 + 'px',
genSqlDialog: {
visible: false,
sql: '',
},
});
const { nowDbInst } = toRefs(state);
@@ -280,7 +373,8 @@ onBeforeUnmount(() => {
*/
const setHeight = () => {
state.editorHeight = window.innerHeight - 518 + 'px';
state.dataTabsTableHeight = window.innerHeight - 256 + 'px';
state.dataTabsTableHeight = window.innerHeight - 262 + 'px';
state.tablesOpHeight = window.innerHeight - 240 + 'px';
state.tagTreeHeight = window.innerHeight - 165 + 'px';
};
@@ -318,54 +412,62 @@ const onCurrentContextmenuClick = (clickData: any) => {
const clickId = clickData.id;
if (clickId == ContextmenuClickId.ReloadTable) {
reloadTables(clickData.item.key);
return;
}
if (clickId == ContextmenuClickId.TableOp) {
const params = clickData.item.params;
addTablesOpTab({ id: params.id, db: params.db, type: params.type, nodeKey: clickData.item.key });
}
};
// 选择数据库
const changeSchema = (inst: any, schema: string) => {
state.nowDbInst = DbInst.getOrNewInst(inst);
state.db = schema;
const changeDb = (db: any, dbName: string) => {
state.nowDbInst = DbInst.getOrNewInst(db);
state.db = dbName;
};
// 加载选中的表数据即新增表数据操作tab
const loadTableData = async (inst: any, schema: string, tableName: string) => {
changeSchema(inst, schema);
const loadTableData = async (db: any, dbName: string, tableName: string) => {
changeDb(db, dbName);
if (tableName == '') {
return;
}
const label = `${inst.id}:\`${schema}\`.${tableName}`;
let tab = state.tabs.get(label);
state.activeName = label;
const key = `${db.id}:\`${dbName}\`.${tableName}`;
let tab = state.tabs.get(key);
state.activeName = key;
// 如果存在该表tab则直接返回
if (tab) {
return;
}
tab = new TabInfo();
tab.key = label;
tab.treeNodeKey = inst.nodeKey;
tab.dbId = inst.id;
tab.db = schema;
tab.label = tableName;
tab.key = key;
tab.treeNodeKey = db.nodeKey;
tab.dbId = db.id;
tab.db = dbName;
tab.type = TabType.TableData;
tab.params = {
table: tableName,
};
state.tabs.set(label, tab);
state.tabs.set(key, tab);
};
// 新建查询panel
const addQueryTab = async (inst: any, db: string, sqlName: string = '') => {
if (!db || !inst.id) {
// 新建查询tab
const addQueryTab = async (db: any, dbName: string, sqlName: string = '') => {
if (!dbName || !db.id) {
ElMessage.warning('请选择数据库实例及对应的schema');
return;
}
changeSchema(inst, db);
changeDb(db, dbName);
const dbId = inst.id;
const dbId = db.id;
let label;
let key;
// 存在sql模板名则该模板名只允许一个tab
if (sqlName) {
label = `查询:${dbId}:${db}.${sqlName}`;
label = `查询-${sqlName}`;
key = `查询:${dbId}:${dbName}.${sqlName}`;
} else {
let count = 1;
state.tabs.forEach((v) => {
@@ -373,29 +475,66 @@ const addQueryTab = async (inst: any, db: string, sqlName: string = '') => {
count++;
}
});
label = `新查询${count}:${dbId}:${db}`;
label = `新查询-${count}`;
key = `新查询${count}:${dbId}:${dbName}`;
}
state.activeName = label;
let tab = state.tabs.get(label);
state.activeName = key;
let tab = state.tabs.get(key);
if (tab) {
return;
}
tab = new TabInfo();
tab.key = label;
tab.treeNodeKey = inst.nodeKey;
tab.key = key;
tab.label = label;
tab.treeNodeKey = db.nodeKey;
tab.dbId = dbId;
tab.db = db;
tab.db = dbName;
tab.type = TabType.Query;
tab.params = {
sqlName: sqlName,
dbs: inst.dbs,
dbs: db.dbs,
};
state.tabs.set(label, tab);
state.tabs.set(key, tab);
// 注册当前sql编辑框提示词
registerDbCompletionItemProvider('sql', tab.dbId, tab.db, tab.params.dbs);
};
/**
* 添加数据操作tab
* @param inst
*/
const addTablesOpTab = async (db: any) => {
const dbName = db.db;
if (!db || !db.id) {
ElMessage.warning('请选择数据库实例及对应的schema');
return;
}
changeDb(db, dbName);
const dbId = db.id;
let key = `表操作:${dbId}:${dbName}.tablesOp`;
state.activeName = key;
let tab = state.tabs.get(key);
if (tab) {
return;
}
tab = new TabInfo();
tab.key = key;
tab.label = `表操作-${dbName}`;
tab.treeNodeKey = db.nodeKey;
tab.dbId = dbId;
tab.db = dbName;
tab.type = TabType.TablesOp;
tab.params = {
id: db.id,
db: dbName,
type: db.type,
};
state.tabs.set(key, tab);
};
const onRemoveTab = (targetName: string) => {
let activeName = state.activeName;
const tabNames = [...state.tabs.keys()];
@@ -412,6 +551,7 @@ const onRemoveTab = (targetName: string) => {
}
state.tabs.delete(targetName);
state.activeName = activeName;
onTabChange();
}
};
@@ -432,11 +572,6 @@ const onTabChange = () => {
}
};
const onGenerateInsertSql = async (sql: string) => {
state.genSqlDialog.sql = sql;
state.genSqlDialog.visible = true;
};
const reloadSqls = (dbId: number, db: string) => {
tagTreeRef.value.reloadNode(getSqlMenuNodeKey(dbId, db));
};
@@ -467,10 +602,10 @@ const reloadTables = (nodeKey: string) => {
min-height: calc(100vh - 155px);
.el-tabs__header {
margin: 0 0 5px;
margin: 0 0 10px;
.el-tabs__item {
padding: 0 5px;
padding: 0 10px;
}
}
}
@@ -478,11 +613,5 @@ const reloadTables = (nodeKey: string) => {
.update_field_active {
background-color: var(--el-color-success);
}
.instances-pop-form {
.el-form-item {
margin-bottom: unset;
}
}
}
</style>

View File

@@ -11,6 +11,7 @@ export const dbApi = {
tableIndex: Api.newGet('/dbs/{id}/t-index'),
tableDdl: Api.newGet('/dbs/{id}/t-create-ddl'),
columnMetadata: Api.newGet('/dbs/{id}/c-metadata'),
pgSchemas: Api.newGet('/dbs/{id}/pg/schemas'),
// 获取表即列提示
hintTables: Api.newGet('/dbs/{id}/hint-tables'),
sqlExec: Api.newPost('/dbs/{id}/exec-sql'),
@@ -28,6 +29,7 @@ export const dbApi = {
instances: Api.newGet('/instances'),
getInstance: Api.newGet('/instances/{instanceId}'),
getAllDatabase: Api.newGet('/instances/{instanceId}/databases'),
testConn: Api.newPost('/instances/test-conn'),
saveInstance: Api.newPost('/instances'),
getInstancePwd: Api.newGet('/instances/{id}/pwd'),
deleteInstance: Api.newDelete('/instances/{id}'),

View File

@@ -44,7 +44,7 @@
</div>
</div>
<MonacoEditor ref="monacoEditorRef" class="mt5" v-model="state.sql" language="sql" :height="state.editorHeight" :id="'MonacoTextarea-' + ti.key" />
<MonacoEditor ref="monacoEditorRef" class="mt5" v-model="state.sql" language="sql" :height="state.editorHeight" :id="'MonacoTextarea-' + getKey()" />
<div class="editor-move-resize" @mousedown="onDragSetHeight">
<el-icon>
@@ -69,10 +69,10 @@
<el-link type="warning" :underline="false" @click="cancelUpdateFields"><span style="font-size: 12px">取消</span></el-link>
</span>
</el-row>
<db-table
<db-table-data
ref="dbTableRef"
:db-id="state.ti.dbId"
:db="state.ti.db"
:db-id="dbId"
:db="dbName"
:data="execRes.data"
:table="state.table"
:columns="execRes.tableColumn"
@@ -81,7 +81,7 @@
empty-text="tips: select *开头的单表查询或点击表名默认查询的数据,可双击数据在线修改"
@selection-change="onDataSelectionChange"
@change-updated-field="changeUpdatedField"
></db-table>
></db-table-data>
</div>
</div>
</template>
@@ -97,8 +97,8 @@ import { ElMessage, ElMessageBox } from 'element-plus';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { editor } from 'monaco-editor';
import DbTable from '../DbTable.vue';
import { TabInfo } from '../../db';
import DbTableData from '@/views/ops/db/component/table/DbTableData.vue';
import { DbInst } from '../../db';
import { exportCsv } from '@/common/utils/export';
import { dateStrFormat } from '@/common/utils/date';
import { dbApi } from '../../api';
@@ -113,15 +113,18 @@ import syssocket from '@/common/syssocket';
const emits = defineEmits(['saveSqlSuccess', 'deleteSqlSuccess']);
const props = defineProps({
data: {
type: TabInfo,
dbId: {
type: Number,
required: true,
},
dbName: {
type: String,
required: true,
},
// sqlsql
// sqlName: {
// type: String,
// default: '',
// },
sqlName: {
type: String,
},
editorHeight: {
type: String,
default: '600',
@@ -136,9 +139,6 @@ let monacoEditor: editor.IStandaloneCodeEditor;
const state = reactive({
token,
ti: {} as TabInfo,
dbs: [],
dbId: null, //
table: '', // sql
sqlName: '',
sql: '', // sql
@@ -153,7 +153,7 @@ const state = reactive({
hasUpdatedFileds: false,
});
const { tableDataHeight, ti, execRes, table, sqlName, loading, hasUpdatedFileds } = toRefs(state);
const { tableDataHeight, execRes, table, loading, hasUpdatedFileds } = toRefs(state);
watch(
() => props.editorHeight,
@@ -162,22 +162,22 @@ watch(
}
);
const getNowDbInst = () => {
return DbInst.getInst(props.dbId);
};
onMounted(async () => {
console.log('in query mounted');
state.ti = props.data;
state.editorHeight = props.editorHeight;
const params = state.ti.params;
state.dbs = params && params.dbs;
if (params && params.sqlName) {
state.sqlName = params.sqlName;
const res = await dbApi.getSql.request({ id: state.ti.dbId, type: 1, name: state.sqlName, db: state.ti.db });
if (props.sqlName) {
const res = await dbApi.getSql.request({ id: props.dbId, type: 1, name: state.sqlName, db: props.dbName });
state.sql = res.sql;
}
nextTick(() => {
setTimeout(() => initMonacoEditor(), 50);
});
await state.ti.getNowDbInst().loadDbHints(state.ti.db);
await getNowDbInst().loadDbHints(props.dbName);
});
const initMonacoEditor = () => {
@@ -186,7 +186,8 @@ const initMonacoEditor = () => {
// ctrl + R sql
monacoEditor.addAction({
// An unique identifier of the contributed action.
id: 'run-sql-action' + state.ti.key,
// id: 'run-sql-action' + state.ti.key,
id: 'run-sql-action' + getKey(),
// A label of the action that will be presented to the user.
label: '执行SQL',
// A precondition for this action.
@@ -213,7 +214,7 @@ const initMonacoEditor = () => {
// ctrl + shift + f sql
monacoEditor.addAction({
// An unique identifier of the contributed action.
id: 'format-sql-action' + state.ti.key,
id: 'format-sql-action' + getKey(),
// A label of the action that will be presented to the user.
label: '格式化SQL',
// A precondition for this action.
@@ -245,7 +246,7 @@ const onDragSetHeight = () => {
document.onmousemove = (e) => {
e.preventDefault();
//
state.editorHeight = `${document.getElementById('MonacoTextarea-' + state.ti.key)!.clientHeight + e.movementY}px`;
state.editorHeight = `${document.getElementById('MonacoTextarea-' + getKey())!.clientHeight + e.movementY}px`;
state.tableDataHeight -= e.movementY;
};
document.onmouseup = () => {
@@ -253,6 +254,10 @@ const onDragSetHeight = () => {
};
};
const getKey = () => {
return props.dbId + ':' + props.dbName;
};
/**
* 执行sql
*/
@@ -290,7 +295,7 @@ const onRunSql = async () => {
try {
state.loading = true;
const colAndData: any = await state.ti.getNowDbInst().runSql(state.ti.db, sql, execRemark);
const colAndData: any = await getNowDbInst().runSql(props.dbName, sql, execRemark);
if (!colAndData.res || colAndData.res.length === 0) {
ElMessage.warning('未查询到结果集');
}
@@ -370,16 +375,17 @@ const saveSql = async () => {
}
}
await dbApi.saveSql.request({ id: state.ti.dbId, db: state.ti.db, sql: sql, type: 1, name: sqlName });
await dbApi.saveSql.request({ id: props.dbId, db: props.dbName, sql: sql, type: 1, name: sqlName });
ElMessage.success('保存成功');
// sql
emits('saveSqlSuccess', state.ti.dbId, state.ti.db);
emits('saveSqlSuccess', props.dbId, props.dbName);
};
const deleteSql = async () => {
const sqlName = state.sqlName;
notBlank(sqlName, '该sql内容未保存');
const { dbId, db } = state.ti;
const dbId = props.dbId;
const db = props.dbName;
try {
await ElMessageBox.confirm(`确定删除【${sqlName}】该SQL内容?`, '提示', {
confirmButtonText: '确定',
@@ -415,7 +421,7 @@ const formatSql = () => {
* 提交事务用于没有开启自动提交事务
*/
const onCommit = () => {
state.ti.getNowDbInst().runSql(state.ti.db, 'COMMIT;');
getNowDbInst().runSql(props.dbName, 'COMMIT;');
ElMessage.success('COMMIT success');
};
@@ -528,7 +534,7 @@ const execSqlFileSuccess = (res: any) => {
// sqlurl
const getUploadSqlFileUrl = () => {
return `${config.baseApiUrl}/dbs/${state.ti.dbId}/exec-sql-file?db=${state.ti.db}&${joinClientParams()}`;
return `${config.baseApiUrl}/dbs/${props.dbId}/exec-sql-file?db=${props.dbName}&${joinClientParams()}`;
};
const onDataSelectionChange = (datas: []) => {
@@ -546,8 +552,8 @@ const changeUpdatedField = (updatedFields: []) => {
const onDeleteData = async () => {
const deleteDatas = state.selectionDatas;
isTrue(deleteDatas && deleteDatas.length > 0, '请先选择要删除的数据');
const { db } = state.ti;
const dbInst = state.ti.getNowDbInst();
const db = props.dbName;
const dbInst = getNowDbInst();
const primaryKey = await dbInst.loadTableColumn(db, state.table);
const primaryKeyColumnName = primaryKey.columnName;
dbInst.promptExeSql(db, dbInst.genDeleteByPrimaryKeysSql(db, state.table, deleteDatas), null, () => {

View File

@@ -15,7 +15,7 @@
<script lang="ts" setup>
import { toRefs, ref, nextTick, reactive } from 'vue';
import { dbApi } from '../api';
import { dbApi } from '@/views/ops/db/api';
import { ElDialog, ElButton, ElInput, ElMessage, InputInstance } from 'element-plus';
// import base style
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';

View File

@@ -30,7 +30,7 @@
:sortable="sortable"
>
<template #header v-if="showColumnTip">
<el-tooltip :show-after="500" raw-content placement="top" effect="customized">
<el-tooltip :show-after="500" raw-content placement="top">
<template #content> {{ getColumnTip(item) }} </template>
{{ item.columnName }}
</el-tooltip>
@@ -43,7 +43,7 @@
<script lang="ts" setup>
import { onMounted, watch, reactive, toRefs } from 'vue';
import { DbInst, UpdateFieldsMeta, FieldsMeta } from '../db';
import { DbInst, UpdateFieldsMeta, FieldsMeta } from '@/views/ops/db/db';
const emits = defineEmits(['sortChange', 'deleteData', 'selectionChange', 'changeUpdatedField']);

View File

@@ -94,12 +94,12 @@
</el-col>
</el-row>
<db-table
<db-table-data
ref="dbTableRef"
:db-id="state.ti.dbId"
:db="state.ti.db"
:db-id="dbId"
:db="dbName"
:data="datas"
:table="state.table"
:table="tableName"
:columns="columns"
:loading="loading"
:height="tableHeight"
@@ -108,7 +108,7 @@
@sort-change="(sort: any) => onTableSortChange(sort)"
@selection-change="onDataSelectionChange"
@change-updated-field="changeUpdatedField"
></db-table>
></db-table-data>
<el-row type="flex" class="mt5" justify="center">
<el-pagination
@@ -177,26 +177,37 @@
</span>
</template>
</el-dialog>
<el-dialog @close="state.genSqlDialog.visible = false" v-model="state.genSqlDialog.visible" title="SQL" width="1000px">
<el-input v-model="state.genSqlDialog.sql" type="textarea" rows="20" />
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { onMounted, watch, reactive, toRefs, ref, Ref, onUnmounted } from 'vue';
import { isTrue, notEmpty, notBlank } from '@/common/assert';
import { isTrue, notEmpty } from '@/common/assert';
import { ElMessage } from 'element-plus';
import { DbInst, TabInfo } from '../../db';
import { DbInst } from '@/views/ops/db/db';
import { exportCsv } from '@/common/utils/export';
import { dateStrFormat } from '@/common/utils/date';
import DbTable from '../DbTable.vue';
import DbTableData from './DbTableData.vue';
const emits = defineEmits(['genInsertSql']);
const dataForm: any = ref(null);
const conditionInputRef: any = ref();
const props = defineProps({
data: {
type: TabInfo,
dbId: {
type: Number,
required: true,
},
dbName: {
type: String,
required: true,
},
tableName: {
type: String,
required: true,
},
tableHeight: {
@@ -208,8 +219,6 @@ const props = defineProps({
const dbTableRef = ref(null) as Ref;
const state = reactive({
ti: {} as TabInfo,
table: '', //
datas: [],
sql: '', // tabsql
orderBy: '',
@@ -237,6 +246,10 @@ const state = reactive({
placeholder: '',
visible: false,
},
genSqlDialog: {
visible: false,
sql: '',
},
tableHeight: '600',
hasUpdatedFileds: false,
});
@@ -250,14 +263,15 @@ watch(
}
);
const getNowDbInst = () => {
return DbInst.getInst(props.dbId);
};
onMounted(async () => {
console.log('in table data mounted');
state.ti = props.data;
state.tableHeight = props.tableHeight;
state.table = state.ti.params.table;
notBlank(state.table, 'TableData组件params.table信息不能为空');
const columns = await state.ti.getNowDbInst().loadColumns(state.ti.db, state.table);
const columns = await getNowDbInst().loadColumns(props.dbName, props.tableName);
columns.forEach((x: any) => {
x.show = true;
});
@@ -297,12 +311,13 @@ const pageChange = async () => {
*/
const selectData = async () => {
state.loading = true;
const dbInst = state.ti.getNowDbInst();
const { db } = state.ti;
const dbInst = getNowDbInst();
const db = props.dbName;
const table = props.tableName;
try {
const countRes = await dbInst.runSql(db, dbInst.getDefaultCountSql(state.table, state.condition));
const countRes = await dbInst.runSql(db, dbInst.getDefaultCountSql(table, state.condition));
state.count = countRes.res[0].count;
let sql = dbInst.getDefaultSelectSql(state.table, state.condition, state.orderBy, state.pageNum, state.pageSize);
let sql = dbInst.getDefaultSelectSql(table, state.condition, state.orderBy, state.pageNum, state.pageSize);
state.sql = sql;
if (state.count > 0) {
const colAndData: any = await dbInst.runSql(db, sql);
@@ -333,7 +348,7 @@ const exportData = () => {
columnNames.push(column.columnName);
}
}
exportCsv(`数据导出-${state.table}-${dateStrFormat('yyyyMMddHHmm', new Date().toString())}`, columnNames, dataList);
exportCsv(`数据导出-${props.tableName}-${dateStrFormat('yyyyMMddHHmm', new Date().toString())}`, columnNames, dataList);
};
/**
@@ -376,7 +391,7 @@ const onCancelCondition = () => {
* 提交事务用于没有开启自动提交事务
*/
const onCommit = () => {
state.ti.getNowDbInst().runSql(state.ti.db, 'COMMIT;');
getNowDbInst().runSql(props.dbName, 'COMMIT;');
ElMessage.success('COMMIT success');
};
@@ -413,16 +428,17 @@ const changeUpdatedField = (updatedFields: []) => {
const onDeleteData = async () => {
const deleteDatas = state.selectionDatas;
isTrue(deleteDatas && deleteDatas.length > 0, '请先选择要删除的数据');
const { db } = state.ti;
const dbInst = state.ti.getNowDbInst();
dbInst.promptExeSql(db, dbInst.genDeleteByPrimaryKeysSql(db, state.table, deleteDatas), null, () => {
const db = props.dbName;
const dbInst = getNowDbInst();
dbInst.promptExeSql(db, dbInst.genDeleteByPrimaryKeysSql(db, props.tableName, deleteDatas), null, () => {
onRefresh();
});
};
const onGenerateInsertSql = async () => {
isTrue(state.selectionDatas && state.selectionDatas.length > 0, '请先选择数据');
emits('genInsertSql', state.ti.getNowDbInst().genInsertSql(state.ti.db, state.table, state.selectionDatas));
state.genSqlDialog.sql = getNowDbInst().genInsertSql(props.dbName, props.tableName, state.selectionDatas);
state.genSqlDialog.visible = true;
};
const submitUpdateFields = () => {
@@ -434,7 +450,7 @@ const cancelUpdateFields = () => {
};
const onShowAddDataDialog = async () => {
state.addDataDialog.title = `添加'${state.table}'表数据`;
state.addDataDialog.title = `添加'${props.tableName}'表数据`;
state.addDataDialog.visible = true;
};
@@ -447,7 +463,7 @@ const closeAddDataDialog = () => {
const addRow = async () => {
dataForm.value.validate(async (valid: boolean) => {
if (valid) {
const dbInst = state.ti.getNowDbInst();
const dbInst = getNowDbInst();
const data = state.addDataDialog.data;
// key: value:
let obj: any = {};
@@ -460,8 +476,8 @@ const addRow = async () => {
}
let columnNames = Object.keys(obj).join(',');
let values = Object.values(obj).join(',');
let sql = `INSERT INTO ${dbInst.wrapName(state.table)} (${columnNames}) VALUES (${values});`;
dbInst.promptExeSql(state.ti.db, sql, null, () => {
let sql = `INSERT INTO ${dbInst.wrapName(props.tableName)} (${columnNames}) VALUES (${values});`;
dbInst.promptExeSql(props.dbName, sql, null, () => {
closeAddDataDialog();
onRefresh();
});

View File

@@ -136,7 +136,7 @@
import { watch, toRefs, reactive, ref } from 'vue';
import { TYPE_LIST, CHARACTER_SET_NAME_LIST, COLLATION_SUFFIX_LIST } from './service';
import { ElMessage } from 'element-plus';
import SqlExecBox from '../component/SqlExecBox';
import SqlExecBox from '../sqleditor/SqlExecBox';
const props = defineProps({
visible: {

View File

@@ -30,7 +30,7 @@
<el-button type="primary" size="small" @click="openEditTable(false)">创建表</el-button>
</el-row>
<el-table v-loading="loading" border stripe :data="filterTableInfos" size="small" height="65vh">
<el-table v-loading="loading" border stripe :data="filterTableInfos" size="small" :height="height">
<el-table-column property="tableName" label="表名" min-width="150" show-overflow-tooltip>
<template #header>
<el-input v-model="tableNameSearch" size="small" placeholder="表名: 输入可过滤" clearable />
@@ -104,7 +104,7 @@
<el-input disabled type="textarea" :autosize="{ minRows: 15, maxRows: 30 }" v-model="ddlDialog.ddl" size="small"> </el-input>
</el-dialog>
<db-table-edit
<db-table-op
:title="tableCreateDialog.title"
:active-name="tableCreateDialog.activeName"
:dbId="dbId"
@@ -113,7 +113,7 @@
v-model:visible="tableCreateDialog.visible"
@submit-sql="onSubmitSql"
>
</db-table-edit>
</db-table-op>
</div>
</template>
@@ -121,15 +121,19 @@
import { toRefs, reactive, watch, computed, onMounted, defineAsyncComponent } from 'vue';
import { ElMessageBox } from 'element-plus';
import { formatByteSize } from '@/common/utils/format';
import { dbApi } from '../api';
import SqlExecBox from '../component/SqlExecBox';
import { dbApi } from '@/views/ops/db/api';
import SqlExecBox from '../sqleditor/SqlExecBox';
import config from '@/common/config';
import { joinClientParams } from '@/common/request';
import { isTrue } from '@/common/assert';
const DbTableEdit = defineAsyncComponent(() => import('./DbTableEdit.vue'));
const DbTableOp = defineAsyncComponent(() => import('./DbTableOp.vue'));
const props = defineProps({
height: {
type: [String],
default: '65vh',
},
dbId: {
type: [Number],
required: true,

View File

@@ -1,7 +1,7 @@
/* eslint-disable no-unused-vars */
import { dbApi } from './api';
import { getTextWidth } from '@/common/utils/string';
import SqlExecBox from './component/SqlExecBox';
import SqlExecBox from './component/sqleditor/SqlExecBox';
import { language as sqlLanguage } from 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js';
import { language as addSqlLanguage } from './lang/mysql.js';
@@ -39,11 +39,11 @@ export class DbInst {
type: string;
/**
* schema -> db
* dbName -> db
*/
dbs: Map<string, Db> = new Map();
/** 数据库schema,多个用空格隔开 */
/** 数据库,多个用空格隔开 */
databases: string;
/**
@@ -281,9 +281,9 @@ export class DbInst {
if (this.type == 'mysql') {
return `\`${name}\``;
}
if (this.type == 'postgres') {
return `"${name}"`;
}
// if (this.type == 'postgres') {
// return `"${name}"`;
// }
return name;
};
@@ -451,11 +451,18 @@ export enum TabType {
* 查询框
*/
Query,
/**
* 表操作
*/
TablesOp,
}
export class TabInfo {
label: string;
/**
* tab唯一key。与label、name都一致
* tab唯一key。与name都一致
*/
key: string;

View File

@@ -84,7 +84,7 @@
<template #action="{ data }">
<span v-auth="'machine:terminal'">
<el-tooltip :show-after="500" effect="customized" content="按住ctrl则为新标签打开" placement="top">
<el-tooltip :show-after="500" content="按住ctrl则为新标签打开" placement="top">
<el-button :disabled="data.status == -1" type="primary" @click="showTerminal(data, $event)" link>终端</el-button>
</el-tooltip>

View File

@@ -5,15 +5,19 @@
<tag-tree :loadTags="loadTags">
<template #prefix="{ data }">
<span v-if="data.type.value == MongoNodeType.Mongo">
<el-popover :show-after="500" placement="right-start" title="mongo实例信息" trigger="hover" :width="210">
<el-popover :show-after="500" placement="right-start" title="mongo实例信息" trigger="hover" :width="250">
<template #reference>
<SvgIcon name="iconfont icon-op-mongo" :size="18" />
</template>
<template #default>
<el-form class="instances-pop-form" label-width="auto" :size="'small'">
<el-form-item label="名称:">{{ data.params.name }}</el-form-item>
<el-form-item label="链接:">{{ data.params.uri }}</el-form-item>
</el-form>
<el-descriptions :column="1" size="small">
<el-descriptions-item label="名称">
{{ data.params.name }}
</el-descriptions-item>
<el-descriptions-item label="链接">
{{ data.params.uri }}
</el-descriptions-item>
</el-descriptions>
</template>
</el-popover>
</span>

View File

@@ -32,6 +32,7 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="testConn" :loading="state.testConnBtnLoading" type="success">测试连接</el-button>
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="btnLoading" @click="btnOk"> </el-button>
</div>
@@ -99,6 +100,7 @@ const state = reactive({
tagPath: null as any,
},
btnLoading: false,
testConnBtnLoading: false,
});
const { dialogVisible, tabActiveName, form, btnLoading } = toRefs(state);
@@ -116,15 +118,35 @@ watch(props, async (newValue: any) => {
}
});
const getReqForm = () => {
const reqForm = { ...state.form };
if (!state.form.sshTunnelMachineId || state.form.sshTunnelMachineId <= 0) {
reqForm.sshTunnelMachineId = -1;
}
return reqForm;
};
const testConn = async () => {
mongoForm.value.validate(async (valid: boolean) => {
if (valid) {
state.testConnBtnLoading = true;
try {
await mongoApi.testConn.request(getReqForm());
ElMessage.success('连接成功');
} finally {
state.testConnBtnLoading = false;
}
} else {
ElMessage.error('请正确填写信息');
return false;
}
});
};
const btnOk = async () => {
mongoForm.value.validate(async (valid: boolean) => {
if (valid) {
const reqForm = { ...state.form };
if (!state.form.sshTunnelMachineId || state.form.sshTunnelMachineId <= 0) {
reqForm.sshTunnelMachineId = -1;
}
// reqForm.uri = await RsaEncrypt(reqForm.uri);
mongoApi.saveMongo.request(reqForm).then(() => {
mongoApi.saveMongo.request(getReqForm).then(() => {
ElMessage.success('保存成功');
emit('val-change', state.form);
state.btnLoading = true;

View File

@@ -3,6 +3,7 @@ import Api from '@/common/Api';
export const mongoApi = {
mongoList: Api.newGet('/mongos'),
mongoTags: Api.newGet('/mongos/tags'),
testConn: Api.newPost('/mongos/test-conn'),
saveMongo: Api.newPost('/mongos'),
deleteMongo: Api.newDelete('/mongos/{id}'),
databases: Api.newGet('/mongos/{id}/databases'),

View File

@@ -7,17 +7,25 @@
<tag-tree :loadTags="loadTags">
<template #prefix="{ data }">
<span v-if="data.type.value == RedisNodeType.Redis">
<el-popover :show-after="500" placement="right-start" title="redis实例信息" trigger="hover" :width="210">
<el-popover :show-after="500" placement="right-start" title="redis实例信息" trigger="hover" :width="250">
<template #reference>
<SvgIcon name="iconfont icon-op-redis" :size="18" />
</template>
<template #default>
<el-form class="instances-pop-form" label-width="auto" :size="'small'">
<el-form-item label="名称:">{{ data.params.name }}</el-form-item>
<el-form-item label="模式:">{{ data.params.mode }}</el-form-item>
<el-form-item label="链接:">{{ data.params.host }}</el-form-item>
<el-form-item label="备注:">{{ data.params.remark }}</el-form-item>
</el-form>
<el-descriptions :column="1" size="small">
<el-descriptions-item label="名称">
{{ data.params.name }}
</el-descriptions-item>
<el-descriptions-item label="模式">
{{ data.params.mode }}
</el-descriptions-item>
<el-descriptions-item label="host">
{{ data.params.host }}
</el-descriptions-item>
<el-descriptions-item label="备注" label-align="right">
{{ data.params.remark }}
</el-descriptions-item>
</el-descriptions>
</template>
</el-popover>
</span>
@@ -615,12 +623,6 @@ const delKey = (key: string) => {
</script>
<style lang="scss">
.instances-pop-form {
.el-form-item {
margin-bottom: unset;
}
}
.key-list-vtree {
height: calc(100vh - 250px);
}

View File

@@ -73,6 +73,7 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="testConn" :loading="state.testConnBtnLoading" type="success">测试连接</el-button>
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="btnLoading" @click="btnOk"> </el-button>
</div>
@@ -161,6 +162,7 @@ const state = reactive({
dbList: [0],
pwd: '',
btnLoading: false,
testConnBtnLoading: false,
});
const { dialogVisible, tabActiveName, form, dbList, pwd, btnLoading } = toRefs(state);
@@ -195,19 +197,40 @@ const getPwd = async () => {
state.pwd = await redisApi.getRedisPwd.request({ id: state.form.id });
};
const getReqForm = async () => {
const reqForm = { ...state.form };
if (reqForm.mode == 'sentinel' && reqForm.host.split('=').length != 2) {
ElMessage.error('sentinel模式host需为: mastername=sentinelhost:sentinelport模式');
return;
}
if (!state.form.sshTunnelMachineId || state.form.sshTunnelMachineId <= 0) {
reqForm.sshTunnelMachineId = -1;
}
reqForm.password = await RsaEncrypt(reqForm.password);
return reqForm;
};
const testConn = async () => {
redisForm.value.validate(async (valid: boolean) => {
if (valid) {
state.testConnBtnLoading = true;
try {
await redisApi.testConn.request(await getReqForm());
ElMessage.success('连接成功');
} finally {
state.testConnBtnLoading = false;
}
} else {
ElMessage.error('请正确填写信息');
return false;
}
});
};
const btnOk = async () => {
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;
}
if (!state.form.sshTunnelMachineId || state.form.sshTunnelMachineId <= 0) {
reqForm.sshTunnelMachineId = -1;
}
reqForm.password = await RsaEncrypt(reqForm.password);
redisApi.saveRedis.request(reqForm).then(() => {
redisApi.saveRedis.request(await getReqForm()).then(() => {
ElMessage.success('保存成功');
emit('val-change', state.form);
state.btnLoading = true;

View File

@@ -6,6 +6,7 @@ export const redisApi = {
getRedisPwd: Api.newGet('/redis/{id}/pwd'),
redisInfo: Api.newGet('/redis/{id}/info'),
clusterInfo: Api.newGet('/redis/{id}/cluster-info'),
testConn: Api.newPost('/redis/test-conn'),
saveRedis: Api.newPost('/redis'),
delRedis: Api.newDelete('/redis/{id}'),

View File

@@ -2,13 +2,13 @@ server:
# debug release test
model: release
port: 18888
# 上下文路径, 若设置了该值, 则请求地址为ip:port/context-path
# context-path: /mayfly
cors: true
tls:
enable: false
key-file: ./default.key
cert-file: ./default.pem
# 机器终端操作回放文件存储路径
machine-rec-path: ./rec
jwt:
# jwt key不设置默认使用随机字符串
key:

View File

@@ -34,7 +34,7 @@ func InitRouter() *gin.Engine {
})
// 设置静态资源
setStatic(router)
setStatic(serverConfig.ContextPath, router)
// 是否允许跨域
if serverConfig.Cors {
@@ -42,7 +42,7 @@ func InitRouter() *gin.Engine {
}
// 设置路由组
api := router.Group("/api")
api := router.Group(serverConfig.ContextPath + "/api")
{
common_router.Init(api)
@@ -61,16 +61,17 @@ func InitRouter() *gin.Engine {
return router
}
func setStatic(router *gin.Engine) {
func setStatic(contextPath string, router *gin.Engine) {
// 使用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)
handler := WrapStaticHandler(http.StripPrefix(contextPath, fileServer))
router.GET(contextPath+"/", handler)
router.GET(contextPath+"/favicon.ico", handler)
router.GET(contextPath+"/config.js", handler)
// 所有/assets/**开头的都是静态资源文件
router.GET("/assets/*file", handler)
router.GET(contextPath+"/assets/*file", handler)
// 设置静态资源
if staticConfs := config.Conf.Server.Static; staticConfs != nil {

View File

@@ -445,6 +445,14 @@ func (d *Db) GetCreateTableDdl(rc *req.Ctx) {
rc.ResData = res
}
func (d *Db) GetPgsqlSchemas(rc *req.Ctx) {
conn := d.getDbConn(rc.GinCtx)
biz.IsTrue(conn.Info.Type == dbm.DbTypePostgres, "非postgres无法获取该schemas")
res, err := d.getDbConn(rc.GinCtx).GetMeta().(*dbm.PgsqlMetadata).GetSchemas()
biz.ErrIsNilAppendErr(err, "获取schemas失败: %s")
rc.ResData = res
}
func getDbId(g *gin.Context) uint64 {
dbId, _ := strconv.Atoi(g.Param("dbId"))
biz.IsTrue(dbId > 0, "dbId错误")

View File

@@ -29,6 +29,18 @@ func (d *Instance) Instances(rc *req.Ctx) {
rc.ResData = res
}
func (d *Instance) TestConn(rc *req.Ctx) {
form := &form.InstanceForm{}
instance := ginx.BindJsonAndCopyTo[*entity.DbInstance](rc.GinCtx, form, new(entity.DbInstance))
// 密码解密,并使用解密后的赋值
originPwd, err := cryptox.DefaultRsaDecrypt(form.Password, true)
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
instance.Password = originPwd
biz.ErrIsNil(d.InstanceApp.TestConn(instance))
}
// SaveInstance 保存数据库实例信息
// @router /api/instances [post]
func (d *Instance) SaveInstance(rc *req.Ctx) {

View File

@@ -118,14 +118,24 @@ func (d *dbAppImpl) GetDbConn(dbId uint64, dbName string) (*dbm.DbConn, error) {
if err != nil {
return nil, errorx.NewBiz("数据库信息不存在")
}
if !strings.Contains(" "+db.Database+" ", " "+dbName+" ") {
return nil, errorx.NewBiz("未配置数据库【%s】的操作权限", dbName)
}
instance, err := d.dbInstanceApp.GetById(new(entity.DbInstance), db.InstanceId)
if err != nil {
return nil, errorx.NewBiz("数据库实例不存在")
}
checkDb := dbName
// 兼容pgsql db/schema模式
if instance.Type == dbm.DbTypePostgres {
ss := strings.Split(dbName, "/")
if len(ss) > 1 {
checkDb = ss[0]
}
}
if !strings.Contains(" "+db.Database+" ", " "+checkDb+" ") {
return nil, errorx.NewBiz("未配置数据库【%s】的操作权限", dbName)
}
// 密码解密
instance.PwdDecrypt()
return toDbInfo(instance, dbId, dbName, db.TagPath), nil

View File

@@ -17,6 +17,8 @@ type Instance interface {
Count(condition *entity.InstanceQuery) int64
TestConn(instanceEntity *entity.DbInstance) error
Save(ctx context.Context, instanceEntity *entity.DbInstance) error
// Delete 删除数据库信息
@@ -45,19 +47,19 @@ func (app *instanceAppImpl) Count(condition *entity.InstanceQuery) int64 {
return app.CountByCond(condition)
}
func (app *instanceAppImpl) TestConn(instanceEntity *entity.DbInstance) error {
dbConn, err := toDbInfo(instanceEntity, 0, "", "").Conn()
if err != nil {
return err
}
dbConn.Close()
return nil
}
func (app *instanceAppImpl) Save(ctx context.Context, instanceEntity *entity.DbInstance) error {
// 默认tcp连接
instanceEntity.Network = instanceEntity.GetNetwork()
// 测试连接
if instanceEntity.Password != "" {
dbConn, err := toDbInfo(instanceEntity, 0, "", "").Conn()
if err != nil {
return err
}
defer dbConn.Close()
}
// 查找是否存在该库
oldInstance := &entity.DbInstance{Host: instanceEntity.Host, Port: instanceEntity.Port, Username: instanceEntity.Username}
if instanceEntity.SshTunnelMachineId > 0 {

View File

@@ -55,7 +55,7 @@ func (dbType DbType) StmtSelectDbName() string {
case DbTypeMysql:
return "SELECT SCHEMA_NAME AS dbname FROM SCHEMATA"
case DbTypePostgres:
return "SELECT datname AS dbname FROM pg_database"
return "SELECT datname AS dbname FROM pg_database WHERE datistemplate = false AND has_database_privilege(datname, 'CONNECT')"
default:
panic(fmt.Sprintf("invalid database type: %s", dbType))
}

View File

@@ -1,3 +1,13 @@
--PGSQL_DB_SCHEMAS schemas
select
n.nspname as "schemaName"
from
pg_namespace n
where
has_schema_privilege(n.nspname, 'USAGE')
and n.nspname not like 'pg_%'
and n.nspname != 'information_schema'
---------------------------------------
--PGSQL_TABLE_INFO 表详细信息
select
c.relname as "tableName",
@@ -10,12 +20,10 @@ from
join pg_namespace n on
c.relnamespace = n.oid
join pg_stat_user_tables psut on
psut.relid = c."oid"
psut.relid = c.oid
where
n.nspname = (
select
current_schema ()
)
has_table_privilege(CAST(c.oid AS regclass), 'SELECT')
and n.nspname = current_schema()
and c.reltype > 0
---------------------------------------
--PGSQL_INDEX_INFO 表索引信息

View File

@@ -29,13 +29,38 @@ func getPgsqlDB(d *DbInfo) (*sql.DB, error) {
db := d.Database
var dbParam string
exsitSchema := false
if db != "" {
dbParam = "dbname=" + db
// postgres database可以使用db/schema表示方便连接指定schema, 若不存在schema则使用默认schema
ss := strings.Split(db, "/")
if len(ss) > 1 {
exsitSchema = true
dbParam = fmt.Sprintf("dbname=%s search_path=%s", ss[0], ss[len(ss)-1])
} else {
dbParam = "dbname=" + db
}
}
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s %s sslmode=disable", d.Host, d.Port, d.Username, d.Password, dbParam)
// 存在额外指定参数,则拼接该连接参数
if d.Params != "" {
// 存在指定的db则需要将dbInstance配置中的parmas排除掉dbname和search_path
if db != "" {
paramArr := strings.Split(d.Params, "&")
paramArr = collx.ArrayRemoveFunc(paramArr, func(param string) bool {
if strings.HasPrefix(param, "dbname=") {
return true
}
if exsitSchema && strings.HasPrefix(param, "search_path") {
return true
}
return false
})
d.Params = strings.Join(paramArr, " ")
}
dsn = fmt.Sprintf("%s %s", dsn, strings.Join(strings.Split(d.Params, "&"), " "))
}
return sql.Open(driverName, dsn)
}
@@ -67,6 +92,7 @@ func (pd *PqSqlDialer) DialTimeout(network, address string, timeout time.Duratio
// ---------------------------------- pgsql元数据 -----------------------------------
const (
PGSQL_META_FILE = "metasql/pgsql_meta.sql"
PGSQL_DB_SCHEMAS = "PGSQL_DB_SCHEMAS"
PGSQL_TABLE_INFO_KEY = "PGSQL_TABLE_INFO"
PGSQL_INDEX_INFO_KEY = "PGSQL_INDEX_INFO"
PGSQL_COLUMN_MA_KEY = "PGSQL_COLUMN_MA"
@@ -192,3 +218,17 @@ func (pm *PgsqlMetadata) GetTableRecord(tableName string, pageNum, pageSize int)
func (pm *PgsqlMetadata) WalkTableRecord(tableName string, walk func(record map[string]any, columns []string)) error {
return pm.dc.WalkTableRecord(fmt.Sprintf("SELECT * FROM %s", tableName), walk)
}
// 获取pgsql当前连接的库可访问的schemaNames
func (pm *PgsqlMetadata) GetSchemas() ([]string, error) {
sql := GetLocalSql(PGSQL_META_FILE, PGSQL_DB_SCHEMAS)
_, res, err := pm.dc.SelectData(sql)
if err != nil {
return nil, err
}
schemaNames := make([]string, 0)
for _, re := range res {
schemaNames = append(schemaNames, anyx.ConvString(re["schemaName"]))
}
return schemaNames, nil
}

View File

@@ -18,6 +18,11 @@ func newDbSqlExecRepo() repository.DbSqlExec {
// 分页获取
func (d *dbSqlExecRepoImpl) GetPageList(condition *entity.DbSqlExecQuery, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error) {
qd := gormx.NewQuery(new(entity.DbSqlExec)).WithCondModel(condition).WithOrderBy(orderBy...)
qd := gormx.NewQuery(new(entity.DbSqlExec)).
Eq("db_id", condition.DbId).
Eq("`table`", condition.Table).
Eq("type", condition.Type).
Eq("creator_id", condition.CreatorId).
RLike("db", condition.Db).WithOrderBy(orderBy...)
return gormx.PageQuery(qd, pageParam, toEntity)
}

View File

@@ -33,6 +33,8 @@ func InitDbRouter(router *gin.RouterGroup) {
req.NewGet(":dbId/t-create-ddl", d.GetCreateTableDdl),
req.NewGet(":dbId/pg/schemas", d.GetPgsqlSchemas),
req.NewPost(":dbId/exec-sql", d.ExecSql).Log(req.NewLog("db-执行Sql")),
req.NewPost(":dbId/exec-sql-file", d.ExecSqlFile).Log(req.NewLogSave("db-执行Sql文件")),

View File

@@ -20,6 +20,8 @@ func InitInstanceRouter(router *gin.RouterGroup) {
// 获取数据库列表
req.NewGet("", d.Instances),
req.NewPost("/test-conn", d.TestConn),
req.NewPost("", d.SaveInstance).Log(req.NewLogSave("db-保存数据库实例信息")),
req.NewGet(":instanceId", d.GetInstance),

View File

@@ -6,11 +6,11 @@ import (
"mayfly-go/internal/machine/api/form"
"mayfly-go/internal/machine/api/vo"
"mayfly-go/internal/machine/application"
"mayfly-go/internal/machine/config"
"mayfly-go/internal/machine/domain/entity"
"mayfly-go/internal/machine/mcm"
tagapp "mayfly-go/internal/tag/application"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/config"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/ginx"
"mayfly-go/pkg/model"
@@ -189,7 +189,7 @@ func (m *Machine) WsSSH(g *gin.Context) {
if cli.Info.EnableRecorder == 1 {
now := time.Now()
// 回放文件路径为: 基础配置路径/机器id/操作日期/操作者账号/操作时间.cast
recPath := fmt.Sprintf("%s/%d/%s/%s", config.Conf.Server.GetMachineRecPath(), cli.Info.Id, now.Format("20060102"), rc.GetLoginAccount().Username)
recPath := fmt.Sprintf("%s/%d/%s/%s", config.GetMachine().TerminalRecPath, cli.Info.Id, now.Format("20060102"), rc.GetLoginAccount().Username)
os.MkdirAll(recPath, 0766)
fileName := path.Join(recPath, fmt.Sprintf("%s.cast", now.Format("20060102_150405")))
f, err := os.OpenFile(fileName, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0766)
@@ -214,7 +214,7 @@ func (m *Machine) WsSSH(g *gin.Context) {
func (m *Machine) MachineRecDirNames(rc *req.Ctx) {
readPath := rc.GinCtx.Query("path")
biz.NotEmpty(readPath, "path不能为空")
path_ := path.Join(config.Conf.Server.GetMachineRecPath(), readPath)
path_ := path.Join(config.GetMachine().TerminalRecPath, readPath)
// 如果是读取文件内容,则读取对应回放记录文件内容,否则读取文件夹名列表。小小偷懒一会不想再加个接口
isFile := rc.GinCtx.Query("isFile")

View File

@@ -7,6 +7,7 @@ import (
"mayfly-go/internal/machine/api/form"
"mayfly-go/internal/machine/api/vo"
"mayfly-go/internal/machine/application"
"mayfly-go/internal/machine/config"
"mayfly-go/internal/machine/domain/entity"
"mayfly-go/internal/machine/mcm"
msgapp "mayfly-go/internal/msg/application"
@@ -175,8 +176,6 @@ func (m *MachineFile) WriteFileContent(rc *req.Ctx) {
biz.ErrIsNilAppendErr(err, "打开文件失败: %s")
}
const MaxUploadFileSize int64 = 1024 * 1024 * 1024
func (m *MachineFile) UploadFile(rc *req.Ctx) {
g := rc.GinCtx
fid := GetMachineFileId(g)
@@ -184,7 +183,9 @@ func (m *MachineFile) UploadFile(rc *req.Ctx) {
fileheader, err := g.FormFile("file")
biz.ErrIsNilAppendErr(err, "读取文件失败: %s")
biz.IsTrue(fileheader.Size <= MaxUploadFileSize, "文件大小不能超过%d字节", MaxUploadFileSize)
maxUploadFileSize := config.GetMachine().UploadMaxFileSize
biz.IsTrue(fileheader.Size <= maxUploadFileSize, "文件大小不能超过%d字节", maxUploadFileSize)
file, _ := fileheader.Open()
defer file.Close()
@@ -223,7 +224,9 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) {
allFileSize := collx.ArrayReduce(fileheaders, 0, func(i int64, fh *multipart.FileHeader) int64 {
return i + fh.Size
})
biz.IsTrue(allFileSize <= MaxUploadFileSize, "文件夹总大小不能超过%d字节", MaxUploadFileSize)
maxUploadFileSize := config.GetMachine().UploadMaxFileSize
biz.IsTrue(allFileSize <= maxUploadFileSize, "文件夹总大小不能超过%d字节", maxUploadFileSize)
paths := mf.Value["paths"]

View File

@@ -0,0 +1,43 @@
package config
import (
sysapp "mayfly-go/internal/sys/application"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/utils/bytex"
)
const (
ConfigKeyMachine string = "MachineConfig" // 机器相关配置
)
type Machine struct {
TerminalRecPath string // 终端操作记录存储位置
UploadMaxFileSize int64 // 允许上传的最大文件size
}
// 获取机器相关配置
func GetMachine() *Machine {
c := sysapp.GetConfigApp().GetConfig(ConfigKeyMachine)
jm := c.GetJsonMap()
mc := new(Machine)
terminalRecPath := jm["terminalRecPath"]
if terminalRecPath == "" {
terminalRecPath = "./rec"
}
mc.TerminalRecPath = terminalRecPath
// 将1GB等字符串转为int64的byte
uploadMaxFileSizeStr := jm["uploadMaxFileSize"]
var uploadMaxFileSize int64 = 1 * bytex.GB
if uploadMaxFileSizeStr != "" {
var err error
uploadMaxFileSize, err = bytex.ParseSize(uploadMaxFileSizeStr)
if err != nil {
logx.Errorf("解析机器配置的最大上传文件大小失败: uploadMaxFileSize=%s, 使用系统默认值1GB", uploadMaxFileSizeStr)
}
}
mc.UploadMaxFileSize = uploadMaxFileSize
return mc
}

View File

@@ -46,6 +46,12 @@ func (m *Mongo) MongoTags(rc *req.Ctx) {
rc.ResData = m.TagApp.ListTagByAccountIdAndResource(rc.GetLoginAccount().Id, new(entity.Mongo))
}
func (m *Mongo) TestConn(rc *req.Ctx) {
form := &form.Mongo{}
mongo := ginx.BindJsonAndCopyTo[*entity.Mongo](rc.GinCtx, form, new(entity.Mongo))
biz.ErrIsNilAppendErr(m.MongoApp.TestConn(mongo), "连接失败: %s")
}
func (m *Mongo) Save(rc *req.Ctx) {
form := &form.Mongo{}
mongo := ginx.BindJsonAndCopyTo[*entity.Mongo](rc.GinCtx, form, new(entity.Mongo))

View File

@@ -18,6 +18,8 @@ type Mongo interface {
Count(condition *entity.MongoQuery) int64
TestConn(entity *entity.Mongo) error
Save(ctx context.Context, entity *entity.Mongo) error
// 删除数据库信息
@@ -52,6 +54,15 @@ func (d *mongoAppImpl) Delete(ctx context.Context, id uint64) error {
return d.GetRepo().DeleteById(ctx, id)
}
func (d *mongoAppImpl) TestConn(me *entity.Mongo) error {
conn, err := me.ToMongoInfo().Conn()
if err != nil {
return err
}
conn.Close()
return nil
}
func (d *mongoAppImpl) Save(ctx context.Context, m *entity.Mongo) error {
if m.Id == 0 {
return d.GetRepo().Insert(ctx, m)

View File

@@ -26,7 +26,7 @@ type MongoInfo struct {
}
func (mi *MongoInfo) Conn() (*MongoConn, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
mongoOptions := options.Client().ApplyURI(mi.Uri).
@@ -40,7 +40,7 @@ func (mi *MongoInfo) Conn() (*MongoConn, error) {
if err != nil {
return nil, err
}
if err = client.Ping(context.TODO(), nil); err != nil {
if err = client.Ping(ctx, nil); err != nil {
client.Disconnect(ctx)
return nil, err
}

View File

@@ -25,6 +25,8 @@ func InitMongoRouter(router *gin.RouterGroup) {
req.NewGet("/tags", ma.MongoTags),
req.NewPost("/test-conn", ma.TestConn),
req.NewPost("", ma.Save).Log(req.NewLogSave("mongo-保存信息")),
req.NewDelete(":id", ma.DeleteMongo).Log(req.NewLogSave("mongo-删除信息")),

View File

@@ -47,6 +47,18 @@ func (r *Redis) RedisTags(rc *req.Ctx) {
rc.ResData = r.TagApp.ListTagByAccountIdAndResource(rc.GetLoginAccount().Id, new(entity.Redis))
}
func (r *Redis) TestConn(rc *req.Ctx) {
form := &form.Redis{}
redis := ginx.BindJsonAndCopyTo[*entity.Redis](rc.GinCtx, form, new(entity.Redis))
// 密码解密,并使用解密后的赋值
originPwd, err := cryptox.DefaultRsaDecrypt(redis.Password, true)
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
redis.Password = originPwd
biz.ErrIsNil(r.RedisApp.TestConn(redis))
}
func (r *Redis) Save(rc *req.Ctx) {
form := &form.Redis{}
redis := ginx.BindJsonAndCopyTo[*entity.Redis](rc.GinCtx, form, new(entity.Redis))

View File

@@ -20,6 +20,9 @@ type Redis interface {
Count(condition *entity.RedisQuery) int64
// 测试连接
TestConn(re *entity.Redis) error
Save(ctx context.Context, re *entity.Redis) error
// 删除数据库信息
@@ -29,9 +32,6 @@ type Redis interface {
// id: 数据库实例id
// db: 库号
GetRedisConn(id uint64, db int) (*rdm.RedisConn, error)
// 测试连接
TestConn(re *entity.Redis) error
}
func newRedisApp(redisRepo repository.Redis) Redis {
@@ -53,14 +53,21 @@ func (r *redisAppImpl) Count(condition *entity.RedisQuery) int64 {
return r.GetRepo().Count(condition)
}
func (r *redisAppImpl) Save(ctx context.Context, re *entity.Redis) error {
// ’修改信息且密码不为空‘ or ‘新增’需要测试是否可连接
if (re.Id != 0 && re.Password != "") || re.Id == 0 {
if err := r.TestConn(re); err != nil {
return errorx.NewBiz("Redis连接失败: %s", err.Error())
}
func (r *redisAppImpl) TestConn(re *entity.Redis) error {
db := 0
if re.Db != "" {
db, _ = strconv.Atoi(strings.Split(re.Db, ",")[0])
}
rc, err := re.ToRedisInfo(db).Conn()
if err != nil {
return err
}
rc.Close()
return nil
}
func (r *redisAppImpl) Save(ctx context.Context, re *entity.Redis) error {
// 查找是否存在该库
oldRedis := &entity.Redis{Host: re.Host}
if re.SshTunnelMachineId > 0 {
@@ -118,17 +125,3 @@ func (r *redisAppImpl) GetRedisConn(id uint64, db int) (*rdm.RedisConn, error) {
return re.ToRedisInfo(db), nil
})
}
func (r *redisAppImpl) TestConn(re *entity.Redis) error {
db := 0
if re.Db != "" {
db, _ = strconv.Atoi(strings.Split(re.Db, ",")[0])
}
rc, err := re.ToRedisInfo(db).Conn()
if err != nil {
return err
}
rc.Close()
return nil
}

View File

@@ -28,6 +28,8 @@ func InitRedisRouter(router *gin.RouterGroup) {
req.NewGet("/tags", rs.RedisTags),
req.NewPost("/test-conn", rs.TestConn),
req.NewPost("", rs.Save).Log(req.NewLogSave("redis-保存信息")),
req.NewGet(":id/pwd", rs.GetRedisPwd),

View File

@@ -5,13 +5,13 @@ import (
)
type Server struct {
Port int `yaml:"port"`
Model string `yaml:"model"`
Cors bool `yaml:"cors"`
Tls *Tls `yaml:"tls"`
Static *[]*Static `yaml:"static"`
StaticFile *[]*StaticFile `yaml:"static-file"`
MachineRecPath string `yaml:"machine-rec-path"` // 机器终端操作回放文件存储路径
Port int `yaml:"port"`
Model string `yaml:"model"`
ContextPath string `yaml:"context-path"` // 请求路径上下文
Cors bool `yaml:"cors"`
Tls *Tls `yaml:"tls"`
Static *[]*Static `yaml:"static"`
StaticFile *[]*StaticFile `yaml:"static-file"`
}
func (s *Server) Default() {
@@ -21,24 +21,12 @@ func (s *Server) Default() {
if s.Port == 0 {
s.Port = 8888
}
if s.MachineRecPath == "" {
s.MachineRecPath = "./rec"
}
}
func (s *Server) GetPort() string {
return fmt.Sprintf(":%d", s.Port)
}
// 获取终端回访记录存放基础路径, 如果配置文件未配置,则默认为./rec
func (s *Server) GetMachineRecPath() string {
path := s.MachineRecPath
if path == "" {
return "./rec"
}
return path
}
type Static struct {
RelativePath string `yaml:"relative-path"`
Root string `yaml:"root"`

View File

@@ -28,7 +28,7 @@ func runWebServer() {
server := config.Conf.Server
port := server.GetPort()
logx.Infof("Listening and serving HTTP on %s", port)
logx.Infof("Listening and serving HTTP on %s", port+server.ContextPath)
var err error
if server.Tls != nil && server.Tls.Enable {

View File

@@ -0,0 +1,43 @@
package bytex
import (
"fmt"
"strconv"
"strings"
)
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
// 解析字符串byte size
//
// 1kb -> 1024
// 1mb -> 1024 * 1024
func ParseSize(sizeStr string) (int64, error) {
sizeStr = strings.TrimSpace(sizeStr)
unit := sizeStr[len(sizeStr)-2:]
valueStr := sizeStr[:len(sizeStr)-2]
value, err := strconv.ParseInt(valueStr, 10, 64)
if err != nil {
return 0, err
}
var bytes int64
switch strings.ToUpper(unit) {
case "KB":
bytes = value * KB
case "MB":
bytes = value * MB
case "GB":
bytes = value * GB
default:
return 0, fmt.Errorf("invalid size unit")
}
return bytes, nil
}

View File

@@ -0,0 +1,11 @@
package bytex
import (
"fmt"
"testing"
)
func TestParseSize(t *testing.T) {
res, _ := ParseSize("1MB")
fmt.Println(res)
}

View File

@@ -469,6 +469,7 @@ INSERT INTO `t_sys_config` (name, `key`, params, value, remark, permission, crea
INSERT INTO `t_sys_config` (name, `key`, params, value, remark, permission, create_time, creator_id, creator, update_time, modifier_id, modifier, is_deleted, delete_time) VALUES('是否启用水印', 'UseWatermark', '[{"name":"是否启用","model":"isUse","placeholder":"是否启用水印","options":"true,false"},{"name":"自定义信息","model":"content","placeholder":"额外添加的水印内容,可添加公司名称等"}]', '', '水印信息配置', 'all', '2022-08-25 23:36:35', 1, 'admin', '2022-08-26 10:02:52', 1, 'admin', 0, NULL);
INSERT INTO `t_sys_config` (name, `key`, params, value, remark, create_time, creator_id, creator, update_time, modifier_id, modifier)VALUES ('数据库查询最大结果集', 'DbQueryMaxCount', '[]', '200', '允许sql查询的最大结果集数。注: 0=不限制', '2023-02-11 14:29:03', 1, 'admin', '2023-02-11 14:40:56', 1, 'admin');
INSERT INTO `t_sys_config` (name, `key`, params, value, remark, create_time, creator_id, creator, update_time, modifier_id, modifier)VALUES ('数据库是否记录查询SQL', 'DbSaveQuerySQL', '[]', '0', '1: 记录、0:不记录', '2023-02-11 16:07:14', 1, 'admin', '2023-02-11 16:44:17', 1, 'admin');
INSERT INTO `t_sys_config` (name, `key`, params, value, remark, permission, create_time, creator_id, creator, update_time, modifier_id, modifier, is_deleted, delete_time) VALUES('机器相关配置', 'MachineConfig', '[{"name":"终端回放存储路径","model":"terminalRecPath","placeholder":"终端回放存储路径"},{"name":"uploadMaxFileSize","model":"uploadMaxFileSize","placeholder":"允许上传的最大文件大小(1MB\\\\2GB等)"}]', '{"terminalRecPath":"./rec","uploadMaxFileSize":"1GB"}', '机器相关配置,如终端回放路径等', 'admin,', '2023-07-13 16:26:44', 1, 'admin', '2023-11-09 22:01:31', 1, 'admin', 0, NULL);
COMMIT;
-- ----------------------------

View File

@@ -0,0 +1,2 @@
-- 新增机器相关系统配置
INSERT INTO `t_sys_config` (name, `key`, params, value, remark, permission, create_time, creator_id, creator, update_time, modifier_id, modifier, is_deleted, delete_time) VALUES('机器相关配置', 'MachineConfig', '[{"name":"终端回放存储路径","model":"terminalRecPath","placeholder":"终端回放存储路径"},{"name":"uploadMaxFileSize","model":"uploadMaxFileSize","placeholder":"允许上传的最大文件大小(1MB\\\\2GB等)"}]', '{"terminalRecPath":"./rec","uploadMaxFileSize":"1GB"}', '机器相关配置,如终端回放路径等', 'admin,', '2023-07-13 16:26:44', 1, 'admin', '2023-11-09 22:01:31', 1, 'admin', 0, NULL);