9 Commits

Author SHA1 Message Date
meilin.huang
d711a36749 feat: v1.7.3 2024-02-08 09:53:48 +08:00
meilin.huang
9dbf104ef1 refactor: 机器操作界面调整 2024-02-07 21:14:29 +08:00
zongyangleo
20eb06fb28 !101 feat: 新增机器操作菜单
* feat: 新增机器操作菜单
2024-02-07 06:37:59 +00:00
meilin.huang
9c20bdef39 Merge branch 'dev' of https://gitee.com/objs/mayfly-go into dev 2024-02-06 15:33:31 +08:00
zongyangleo
3fdd98a390 !99 feat: DBMS新增kingbaseES、vastbase,还有一些优化
* refactor: 重构机器列表展示
* fix:修复编辑表问题
* refactor: 优化下拉实例显示
* feat: DBMS新增kingbaseES(已测试postgres、oracle兼容模式) 、vastbase
2024-02-06 07:32:03 +00:00
meilin.huang
d4f456c0cf Merge branch 'dev' of https://gitee.com/objs/mayfly-go into dev 2024-02-06 15:17:39 +08:00
kanzihuang
f2b6e15cf4 !100 定时清理数据库备份数据
* feat: 优化数据库 BINLOG 同步机制
* feat: 删除数据库实例前需删除关联的数据库备份与恢复任务
* refactor: 重构数据库备份与恢复模块
* feat: 定时清理数据库备份历史和本地 Binlog 文件
* feat: 压缩数据库备份文件
2024-02-06 07:16:56 +00:00
meilin.huang
6be0ea6aed fix: dbms数据行编辑 2024-02-01 12:05:41 +08:00
meilin.huang
eee08be2cc feat: 数据库支持编辑行数据 2024-01-31 20:41:41 +08:00
87 changed files with 1497 additions and 527 deletions

View File

@@ -17,7 +17,7 @@
"countup.js": "^2.7.0",
"cropperjs": "^1.5.11",
"echarts": "^5.4.3",
"element-plus": "^2.5.3",
"element-plus": "^2.5.5",
"js-base64": "^3.7.5",
"jsencrypt": "^3.3.2",
"lodash": "^4.17.21",

File diff suppressed because one or more lines are too long

View File

@@ -88,6 +88,20 @@
"font_class": "gauss",
"unicode": "e683",
"unicode_decimal": 59011
},
{
"icon_id": "34836637",
"name": "kingbase",
"font_class": "kingbase",
"unicode": "e882",
"unicode_decimal": 59522
},
{
"icon_id": "33047500",
"name": "vastbase",
"font_class": "vastbase",
"unicode": "e62b",
"unicode_decimal": 58923
}
]
}

View File

@@ -15,7 +15,7 @@ const config = {
baseWsUrl: `${(window as any).globalConfig.BaseWsUrl || `${location.protocol == 'https:' ? 'wss:' : 'ws:'}//${getBaseApiUrl()}`}/api`,
// 系统版本
version: 'v1.7.2',
version: 'v1.7.3',
};
export default config;

View File

@@ -119,8 +119,8 @@ const open = (optionProps: MonacoEditorDialogProps) => {
}
setTimeout(() => {
editorRef.value?.format();
editorRef.value?.focus();
editorRef.value?.format();
}, 300);
state.dialogVisible = true;

View File

@@ -189,7 +189,7 @@ const emit = defineEmits(['update:queryForm', 'update:selectionData', 'pageChang
export interface PageTableProps {
size?: string;
pageApi: Api; // 请求表格数据的 api
pageApi?: Api; // 请求表格数据的 api
columns: TableColumn[]; // 列配置项 ==> 必传
showSelection?: boolean;
selectable?: (row: any) => boolean; // 是否可选
@@ -257,7 +257,7 @@ const changeSimpleFormItem = (searchItem: SearchItem) => {
nowSearchItem.value = searchItem;
};
const { tableData, total, loading, search, reset, getTableData, handlePageNumChange, handlePageSizeChange } = usePageTable(
let { tableData, total, loading, search, reset, getTableData, handlePageNumChange, handlePageSizeChange } = usePageTable(
props.pageable,
props.pageApi,
queryForm,
@@ -288,6 +288,13 @@ watch(isShowSearch, () => {
calcuTableHeight();
});
watch(
() => props.data,
(newValue: any) => {
tableData = newValue;
}
);
onMounted(async () => {
calcuTableHeight();
useEventListener(window, 'resize', calcuTableHeight);

View File

@@ -8,7 +8,7 @@
<script lang="ts" setup>
import 'xterm/css/xterm.css';
import { Terminal } from 'xterm';
import { ITheme, Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { SearchAddon } from 'xterm-addon-search';
import { WebLinksAddon } from 'xterm-addon-web-links';
@@ -92,12 +92,13 @@ function init() {
cursorBlink: true,
disableStdin: false,
allowProposedApi: true,
fastScrollModifier: 'ctrl',
theme: {
foreground: themeConfig.value.terminalForeground || '#7e9192', //字体
background: themeConfig.value.terminalBackground || '#002833', //背景色
cursor: themeConfig.value.terminalCursor || '#268F81', //设置光标
// cursorAccent: "red", // 光标停止颜色
} as any,
} as ITheme,
});
term.open(terminalRef.value);
@@ -105,7 +106,7 @@ function init() {
const fitAddon = new FitAddon();
state.addon.fit = fitAddon;
term.loadAddon(fitAddon);
fitTerminal();
resize();
// 注册搜索组件
const searchAddon = new SearchAddon();
@@ -146,7 +147,7 @@ const onConnected = () => {
state.status = TerminalStatus.Connected;
// 注册窗口大小监听器
useEventListener('resize', debounce(fitTerminal, 400));
useEventListener('resize', debounce(resize, 400));
focus();
@@ -158,17 +159,11 @@ const onConnected = () => {
// 自适应终端
const fitTerminal = () => {
const dimensions = state.addon.fit && state.addon.fit.proposeDimensions();
if (!dimensions) {
return;
}
if (dimensions?.cols && dimensions?.rows) {
term.resize(dimensions.cols, dimensions.rows);
}
resize();
};
const focus = () => {
setTimeout(() => term.focus(), 400);
setTimeout(() => term.focus(), 100);
};
const clear = () => {
@@ -265,7 +260,13 @@ const getStatus = (): TerminalStatus => {
return state.status;
};
defineExpose({ init, fitTerminal, focus, clear, close, getStatus });
const resize = () => {
nextTick(() => {
state.addon.fit.fit();
});
};
defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize, resize });
</script>
<style lang="scss">
#terminal-body {

View File

@@ -259,6 +259,10 @@ defineExpose({
padding: 10px;
}
.el-dialog {
padding: 1px 1px;
}
// 取消body最大高度否则全屏有问题
.el-dialog__body {
max-height: 100% !important;

View File

@@ -615,6 +615,9 @@ const setLocalThemeConfigStyle = () => {
};
// 一键复制配置
const onCopyConfigClick = (target: any) => {
if (!target) {
return;
}
let copyThemeConfig = getLocal('themeConfig');
copyThemeConfig.isDrawer = false;
const clipboard = new ClipboardJS(target, {

View File

@@ -17,7 +17,7 @@
@node-contextmenu="nodeContextmenu"
>
<template #default="{ node, data }">
<span>
<span @dblclick="treeNodeDblclick(data)" :class="data.type.nodeDblclickFunc ? 'none-select' : ''">
<span v-if="data.type.value == TagTreeNode.TagPath">
<tag-info :tag-path="data.label" />
</span>
@@ -25,7 +25,13 @@
<slot v-else :node="node" :data="data" name="prefix"></slot>
<span class="ml3" :title="data.labelRemark">
<slot name="label" :data="data"> {{ data.label }}</slot>
<slot name="label" :data="data" v-if="!data.disabled"> {{ data.label }}</slot>
<!-- 禁用状态 -->
<slot name="disabledLabel" :data="data" v-else>
<el-link type="danger" disabled :underline="false">
{{ `${data.label}` }}
</el-link>
</slot>
</span>
<slot :node="node" :data="data" name="suffix"></slot>
@@ -135,15 +141,29 @@ const loadNode = async (node: any, resolve: any) => {
const treeNodeClick = (data: any) => {
emit('nodeClick', data);
if (data.type.nodeClickFunc) {
if (!data.disabled && !data.type.nodeDblclickFunc && data.type.nodeClickFunc) {
data.type.nodeClickFunc(data);
}
// 关闭可能存在的右击菜单
contextmenuRef.value.closeContextmenu();
};
// 树节点双击事件
const treeNodeDblclick = (data: any) => {
// emit('nodeDblick', data);
if (!data.disabled && data.type.nodeDblclickFunc) {
data.type.nodeDblclickFunc(data);
}
// 关闭可能存在的右击菜单
contextmenuRef.value.closeContextmenu();
};
// 树节点右击事件
const nodeContextmenu = (event: any, data: any) => {
if (data.disabled) {
return;
}
// 加载当前节点是否需要显示右击菜单
let items = data.type.contextMenuItems;
if (!items || items.length == 0) {

View File

@@ -14,6 +14,9 @@
v-model="modelValue"
@change="changeNode"
>
<template #prefix="{ node, data }">
<slot name="iconPrefix" :node="node" :data="data" />
</template>
<template #default="{ node, data }">
<span>
<span v-if="data.type.value == TagTreeNode.TagPath">
@@ -33,7 +36,7 @@
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref, watch, toRefs } from 'vue';
import { onMounted, reactive, ref, toRefs, watch } from 'vue';
import { NodeType, TagTreeNode } from './tag';
import TagInfo from './TagInfo.vue';
import { tagApi } from '../tag/api';

View File

@@ -28,6 +28,11 @@ export class TagTreeNode {
*/
isLeaf: boolean = false;
/**
* 是否禁用状态
*/
disabled: boolean = false;
/**
* 额外需要传递的参数
*/
@@ -53,6 +58,11 @@ export class TagTreeNode {
return this;
}
withDisabled(disabled: boolean) {
this.disabled = disabled;
return this;
}
withParams(params: any) {
this.params = params;
return this;
@@ -91,8 +101,14 @@ export class NodeType {
loadNodesFunc: (parentNode: TagTreeNode) => Promise<TagTreeNode[]>;
/**
* 节点点击事件
*/
nodeClickFunc: (node: TagTreeNode) => void;
// 节点双击事件
nodeDblclickFunc: (node: TagTreeNode) => void;
constructor(value: number) {
this.value = value;
}
@@ -117,6 +133,16 @@ export class NodeType {
return this;
}
/**
* 赋值节点双击事件回调函数
* @param func 节点双击事件回调函数
* @returns this
*/
withNodeDblclickFunc(func: (node: TagTreeNode) => void) {
this.nodeDblclickFunc = func;
return this;
}
/**
* 赋值右击菜单按钮选项
* @param contextMenuItems 右击菜单按钮选项

View File

@@ -28,8 +28,11 @@
<el-form-item prop="startTime" label="开始时间">
<el-date-picker v-model="state.form.startTime" type="datetime" placeholder="开始时间" />
</el-form-item>
<el-form-item prop="intervalDay" label="备份周期">
<el-input v-model.number="state.form.intervalDay" type="number" placeholder="备份周期(单位:天"></el-input>
<el-form-item prop="intervalDay" label="备份周期(天)">
<el-input v-model.number="state.form.intervalDay" type="number" placeholder="单位:天"></el-input>
</el-form-item>
<el-form-item prop="maxSaveDays" label="备份历史保留天数">
<el-input v-model.number="state.form.maxSaveDays" type="number" placeholder="0: 永久保留"></el-input>
</el-form-item>
</el-form>
@@ -92,6 +95,14 @@ const rules = {
trigger: ['change', 'blur'],
},
],
maxSaveDays: [
{
required: true,
pattern: /^[0-9]\d*$/,
message: '请输入非负整数',
trigger: ['change', 'blur'],
},
],
};
const backupForm: any = ref(null);
@@ -102,9 +113,10 @@ const state = reactive({
dbId: 0,
dbNames: '',
name: '',
intervalDay: null,
intervalDay: 1,
startTime: null as any,
repeated: null as any,
repeated: true,
maxSaveDays: 0,
},
btnLoading: false,
dbNamesSelected: [] as any,
@@ -137,12 +149,14 @@ const init = (data: any) => {
state.form.name = data.name;
state.form.intervalDay = data.intervalDay;
state.form.startTime = data.startTime;
state.form.maxSaveDays = data.maxSaveDays;
} else {
state.editOrCreate = false;
state.form.name = '';
state.form.intervalDay = null;
state.form.intervalDay = 1;
const now = new Date();
state.form.startTime = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
state.form.maxSaveDays = 0;
getDbNamesWithoutBackup();
}
};

View File

@@ -17,7 +17,7 @@
</template>
<template #type="{ data }">
<el-tooltip :content="data.type" placement="top">
<el-tooltip :content="getDbDialect(data.type).getInfo().name" placement="top">
<SvgIcon :name="getDbDialect(data.type).getInfo().icon" :size="20" />
</el-tooltip>
</template>
@@ -25,6 +25,7 @@
<template #action="{ data }">
<el-button @click="showInfo(data)" link>详情</el-button>
<el-button v-if="actionBtns[perms.saveInstance]" @click="editInstance(data)" type="primary" link>编辑</el-button>
<el-button v-if="actionBtns[perms.delInstance]" @click="deleteInstance(data)" type="primary" link>删除</el-button>
</template>
</page-table>
@@ -91,7 +92,7 @@ const columns = ref([
]);
// 该用户拥有的的操作列按钮权限
const actionBtns = hasPerms([perms.saveInstance]);
const actionBtns = hasPerms(Object.values(perms));
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(110).fixedRight().alignCenter();
const pageTableRef: Ref<any> = ref(null);
@@ -150,14 +151,26 @@ const editInstance = async (data: any) => {
state.instanceEditDialog.visible = true;
};
const deleteInstance = async () => {
const deleteInstance = async (data: any) => {
try {
await ElMessageBox.confirm(`确定删除数据库实例【${state.selectionData.map((x: any) => x.name).join(', ')}】?`, '提示', {
let instanceName: string;
if (data) {
instanceName = data.name;
} else {
instanceName = state.selectionData.map((x: any) => x.name).join(', ');
}
await ElMessageBox.confirm(`确定删除数据库实例【${instanceName}】?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.deleteInstance.request({ id: state.selectionData.map((x: any) => x.id).join(',') });
let instanceId: string;
if (data) {
instanceId = data.id;
} else {
instanceId = state.selectionData.map((x: any) => x.id).join(',');
}
await dbApi.deleteInstance.request({ id: instanceId });
ElMessage.success('删除成功');
search();
} catch (err) {

View File

@@ -71,7 +71,7 @@
<el-descriptions-item label-align="right">
<template #label>
<div>
<SvgIcon :name="getDbDialect(nowDbInst.type).getInfo().icon" :size="18" />
<SvgIcon :name="nowDbInst.getDialect().getInfo().icon" :size="18" />
实例
</div>
</template>

View File

@@ -45,6 +45,7 @@
<db-select-tree
placeholder="请选择源数据库"
v-model:db-id="form.srcDbId"
v-model:inst-name="form.srcInstName"
v-model:db-name="form.srcDbName"
v-model:tag-path="form.srcTagPath"
v-model:db-type="form.srcDbType"
@@ -56,8 +57,10 @@
<db-select-tree
placeholder="请选择目标数据库"
v-model:db-id="form.targetDbId"
v-model:inst-name="form.targetInstName"
v-model:db-name="form.targetDbName"
v-model:tag-path="form.targetTagPath"
v-model:db-type="form.targetDbType"
@select-db="onSelectTargetDb"
/>
</el-form-item>
@@ -182,7 +185,7 @@ import { ElMessage } from 'element-plus';
import DbSelectTree from '@/views/ops/db/component/DbSelectTree.vue';
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import { DbInst, registerDbCompletionItemProvider } from '@/views/ops/db/db';
import {DbType, getDbDialect} from '@/views/ops/db/dialect'
import { DbType, getDbDialect } from '@/views/ops/db/dialect';
import CrontabInput from '@/components/crontab/CrontabInput.vue';
const props = defineProps({
@@ -227,13 +230,16 @@ type FormData = {
taskName?: string;
taskCron: string;
srcDbId?: number;
srcInstName?: string;
srcDbName?: string;
srcDbType?: string;
srcTagPath?: string;
targetDbId?: number;
targetInstName?: string;
targetDbName?: string;
targetTagPath?: string;
targetTableName?: string;
targetDbType?: string;
dataSql?: string;
pageSize?: number;
updField?: string;
@@ -304,7 +310,8 @@ watch(dialogVisible, async (newValue: boolean) => {
// 初始化实例
db.databases = db.database?.split(' ').sort() || [];
state.srcDbInst = DbInst.getOrNewInst(db);
state.form.srcDbType = state.srcDbInst.type
state.form.srcDbType = state.srcDbInst.type;
state.form.srcInstName = db.instanceName;
}
// 初始化target数据源
@@ -315,6 +322,8 @@ watch(dialogVisible, async (newValue: boolean) => {
// 初始化实例
db.databases = db.database?.split(' ').sort() || [];
state.targetDbInst = DbInst.getOrNewInst(db);
state.form.targetDbType = state.targetDbInst.type;
state.form.targetInstName = db.instanceName;
}
if (targetDbId && state.form.targetDbName) {
@@ -414,15 +423,15 @@ const handleGetSrcFields = async () => {
// 执行sql
// oracle的分页关键字不一样
let limit = ' limit 1'
if(state.form.srcDbType === DbType.oracle){
limit = ' where rownum <= 1'
let limit = ' limit 1';
if (state.form.srcDbType === DbType.oracle) {
limit = ' where rownum <= 1';
}
const res = await dbApi.sqlExec.request({
id: state.form.srcDbId,
db: state.form.srcDbName,
sql: `select * from (${state.form.dataSql}) t ${limit}`
sql: `select * from (${state.form.dataSql}) t ${limit}`,
});
if (!res.columns) {

View File

@@ -6,6 +6,9 @@
:resource-type="TagResourceTypeEnum.Db.value"
:tag-path-node-type="NodeTypeTagPath"
>
<template #iconPrefix>
<SvgIcon v-if="dbType && getDbDialect(dbType)" :name="getDbDialect(dbType).getInfo().icon" :size="18" />
</template>
<template #prefix="{ data }">
<SvgIcon v-if="data.type.value == SqlExecNodeType.DbInst" :name="getDbDialect(data.params.type).getInfo().icon" :size="18" />
<SvgIcon v-if="data.icon" :name="data.icon.name" :color="data.icon.color" />
@@ -27,6 +30,9 @@ const props = defineProps({
dbId: {
type: Number,
},
instName: {
type: String,
},
dbName: {
type: String,
},
@@ -38,7 +44,7 @@ const props = defineProps({
},
});
const emits = defineEmits(['update:dbName', 'update:tagPath', 'update:dbId', 'update:dbType', 'selectDb']);
const emits = defineEmits(['update:dbName', 'update:tagPath', 'update:instName', 'update:dbId', 'update:dbType', 'selectDb']);
/**
* 树节点类型
@@ -56,7 +62,7 @@ class SqlExecNodeType {
const selectNode = computed({
get: () => {
return props.dbName ? `${props.tagPath} - ${props.dbId} - ${props.dbName}` : '';
return props.dbName ? `${props.tagPath} > ${props.instName} > ${props.dbName}` : '';
},
set: () => {
//
@@ -151,6 +157,7 @@ const changeNode = (nodeData: TagTreeNode) => {
const params = nodeData.params;
// postgres
emits('update:dbName', params.db);
emits('update:instName', params.name);
emits('update:dbId', params.id);
emits('update:tagPath', params.tagPath);
emits('update:dbType', params.type);

View File

@@ -148,7 +148,6 @@ import { buildProgressProps } from '@/components/progress-notify/progress-notify
import ProgressNotify from '@/components/progress-notify/progress-notify.vue';
import syssocket from '@/common/syssocket';
import SvgIcon from '@/components/svgIcon/index.vue';
import { getDbDialect } from '../../dialect';
import { Pane, Splitpanes } from 'splitpanes';
const emits = defineEmits(['saveSqlSuccess']);
@@ -453,7 +452,7 @@ const formatSql = () => {
return;
}
const formatDialect = getDbDialect(getNowDbInst().type).getInfo().formatSqlDialect;
const formatDialect = getNowDbInst().getDialect().getInfo().formatSqlDialect;
let sql = monacoEditor.getModel()?.getValueInRange(selection);
// 有选中sql则格式化并替换选中sql, 否则格式化编辑器所有内容

View File

@@ -6,7 +6,6 @@
:disabled="disabled"
@blur="handleBlur"
:class="`w100 mb4 ${showEditorIcon ? 'string-input-container-show-icon' : ''}`"
input-style="text-align: center; height: 26px;"
size="small"
v-model="itemValue"
:placeholder="placeholder"
@@ -20,7 +19,6 @@
:disabled="disabled"
@blur="handleBlur"
class="w100 mb4"
input-style="text-align: center; height: 26px;"
size="small"
v-model.number="itemValue"
:placeholder="placeholder"
@@ -185,9 +183,6 @@ const getEditorLangByValue = (value: any) => {
.el-input__prefix {
display: none;
}
.el-input__inner {
text-align: center;
}
}
.edit-time-picker-popper {

View File

@@ -137,13 +137,25 @@
<el-input v-model="state.genTxtDialog.txt" type="textarea" rows="20" />
</el-dialog>
<DbTableDataForm
v-if="state.tableDataFormDialog.visible"
:db-inst="getNowDbInst()"
:db-name="db"
:columns="columns!"
:title="state.tableDataFormDialog.title"
:table-name="table"
v-model:visible="state.tableDataFormDialog.visible"
v-model="state.tableDataFormDialog.data"
@submit-success="emits('changeUpdatedField')"
/>
<contextmenu :dropdown="state.contextmenu.dropdown" :items="state.contextmenu.items" ref="contextmenuRef" />
</div>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, reactive, ref, toRefs, watch } from 'vue';
import { ElInput } from 'element-plus';
import { ElInput, ElMessage } from 'element-plus';
import { copyToClipboard } from '@/common/utils/string';
import { DbInst } from '@/views/ops/db/db';
import { Contextmenu, ContextmenuItem } from '@/components/contextmenu';
@@ -153,6 +165,7 @@ import { dateStrFormat } from '@/common/utils/date';
import { useIntervalFn, useStorage } from '@vueuse/core';
import { ColumnTypeSubscript, compatibleMysql, DataType, DbDialect, getDbDialect } from '../../dialect/index';
import ColumnFormItem from './ColumnFormItem.vue';
import DbTableDataForm from './DbTableDataForm.vue';
const emits = defineEmits(['dataDelete', 'sortChange', 'deleteData', 'selectionChange', 'changeUpdatedField']);
@@ -246,6 +259,13 @@ const cmDataDel = new ContextmenuItem('deleteData', '删除')
return state.table == '';
});
const cmDataEdit = new ContextmenuItem('editData', '编辑行')
.withIcon('edit')
.withOnClick(() => onEditRowData())
.withHideFunc(() => {
return state.table == '';
});
const cmDataGenInsertSql = new ContextmenuItem('genInsertSql', 'Insert SQL')
.withIcon('tickets')
.withOnClick(() => onGenerateInsertSql())
@@ -332,7 +352,11 @@ const state = reactive({
},
items: [] as ContextmenuItem[],
},
tableDataFormDialog: {
data: {},
title: '',
visible: false,
},
genTxtDialog: {
title: 'SQL',
visible: false,
@@ -443,7 +467,7 @@ const formatDataValues = (datas: any) => {
};
const setTableData = (datas: any) => {
tableRef.value.scrollTo({ scrollLeft: 0, scrollTop: 0 });
tableRef.value?.scrollTo({ scrollLeft: 0, scrollTop: 0 });
selectionRowsMap.clear();
cellUpdateMap.clear();
formatDataValues(datas);
@@ -575,7 +599,7 @@ const dataContextmenuClick = (event: any, rowIndex: number, column: any, data: a
const { clientX, clientY } = event;
state.contextmenu.dropdown.x = clientX;
state.contextmenu.dropdown.y = clientY;
state.contextmenu.items = [cmDataCopyCell, cmDataDel, cmDataGenInsertSql, cmDataGenJson, cmDataExportCsv, cmDataExportSql];
state.contextmenu.items = [cmDataCopyCell, cmDataDel, cmDataEdit, cmDataGenInsertSql, cmDataGenJson, cmDataExportCsv, cmDataExportSql];
contextmenuRef.value.openContextmenu({ column, rowData: data });
};
@@ -600,6 +624,18 @@ const onDeleteData = async () => {
});
};
const onEditRowData = () => {
const selectionDatas = Array.from(selectionRowsMap.values());
if (selectionDatas.length > 1) {
ElMessage.warning('只能编辑一行数据');
return;
}
const data = selectionDatas[0];
state.tableDataFormDialog.data = data;
state.tableDataFormDialog.title = `编辑表'${props.table}'数据`;
state.tableDataFormDialog.visible = true;
};
const onGenerateInsertSql = async () => {
const selectionDatas = Array.from(selectionRowsMap.values());
state.genTxtDialog.txt = await getNowDbInst().genInsertSql(state.db, state.table, selectionDatas);
@@ -713,40 +749,21 @@ const submitUpdateFields = async () => {
const db = state.db;
let res = '';
const dbDialect = getDbDialect(dbInst.type);
let schema = '';
let dbArr = db.split('/');
if (dbArr.length == 2) {
schema = dbInst.wrapName(dbArr[1]) + '.';
}
for (let updateRow of cellUpdateMap.values()) {
let sql = `UPDATE ${schema}${dbInst.wrapName(state.table)} SET `;
const rowData = updateRow.rowData;
// 主键列信息
const primaryKey = await dbInst.loadTableColumn(db, state.table);
let primaryKeyType = primaryKey.columnType;
let primaryKeyName = primaryKey.columnName;
let primaryKeyValue = rowData[primaryKeyName];
const rowData = { ...updateRow.rowData };
let updateColumnValue = {};
for (let k of updateRow.columnsMap.keys()) {
const v = updateRow.columnsMap.get(k);
if (!v) {
continue;
}
// 更新字段列信息
const updateColumn = await dbInst.loadTableColumn(db, state.table, k);
sql += ` ${dbInst.wrapName(k)} = ${DbInst.wrapColumnValue(updateColumn.columnType, rowData[k], dbDialect)},`;
// 如果修改的字段是主键
if (k === primaryKeyName) {
primaryKeyValue = v.oldValue;
}
updateColumnValue[k] = rowData[k];
// 将更新的字段对应的原始数据还原(主要应对可能更新修改了主键等)
rowData[k] = v.oldValue;
}
sql = sql.substring(0, sql.length - 1);
sql += ` WHERE ${dbInst.wrapName(primaryKeyName)} = ${DbInst.wrapColumnValue(primaryKeyType, primaryKeyValue)} ;`;
res += sql;
res += await dbInst.genUpdateSql(db, state.table, updateColumnValue, rowData);
}
dbInst.promptExeSql(

View File

@@ -0,0 +1,121 @@
<template>
<el-dialog v-model="visible" :title="title" :destroy-on-close="true" width="600px">
<el-form ref="dataForm" :model="modelValue" :show-message="false" label-width="auto" size="small">
<el-form-item
v-for="column in columns"
:key="column.columnName"
class="w100 mb5"
:prop="column.columnName"
:required="column.nullable != 'YES' && !column.isPrimaryKey && !column.isIdentity"
>
<template #label>
<span class="pointer" :title="`${column.columnType} | ${column.columnComment}`">
{{ column.columnName }}
</span>
</template>
<ColumnFormItem
v-model="modelValue[`${column.columnName}`]"
:data-type="dbInst.getDialect().getDataType(column.columnType)"
:placeholder="`${column.columnType} ${column.columnComment}`"
:column-name="column.columnName"
:disabled="column.isIdentity"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="confirm">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, watch, onMounted } from 'vue';
import ColumnFormItem from './ColumnFormItem.vue';
import { DbInst } from '../../db';
import { ElMessage } from 'element-plus';
export interface ColumnFormItemProps {
dbInst: DbInst;
dbName: string;
tableName: string;
columns: any[];
title?: string; // dialog title
}
const props = withDefaults(defineProps<ColumnFormItemProps>(), {
title: '',
});
const modelValue = defineModel<any>('modelValue');
const visible = defineModel<boolean>('visible', {
default: false,
});
const emit = defineEmits(['submitSuccess']);
const dataForm: any = ref(null);
let oldValue = null as any;
onMounted(() => {
setOldValue();
});
watch(visible, (newValue) => {
if (newValue) {
setOldValue();
}
});
const setOldValue = () => {
// 空对象则为insert操作否则为update
if (Object.keys(modelValue.value).length > 0) {
oldValue = Object.assign({}, modelValue.value);
}
};
const closeDialog = () => {
visible.value = false;
modelValue.value = {};
};
const confirm = async () => {
dataForm.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请正确填写数据信息');
return false;
}
const dbInst = props.dbInst;
const data = modelValue.value;
const db = props.dbName;
const tableName = props.tableName;
let sql = '';
if (oldValue) {
const updateColumnValue = {};
Object.keys(oldValue).forEach((key) => {
// 如果新旧值不相等,则为需要更新的字段
if (oldValue[key] !== modelValue.value[key]) {
updateColumnValue[key] = modelValue.value[key];
}
});
sql = await dbInst.genUpdateSql(db, tableName, updateColumnValue, oldValue);
} else {
sql = await dbInst.genInsertSql(db, tableName, [data], true);
}
dbInst.promptExeSql(db, sql, null, () => {
closeDialog();
emit('submitSuccess');
});
});
};
</script>
<style lang="scss"></style>

View File

@@ -234,32 +234,16 @@
</template>
</el-dialog>
<el-dialog v-model="addDataDialog.visible" :title="addDataDialog.title" :destroy-on-close="true" width="600px">
<el-form ref="dataForm" :model="addDataDialog.data" :show-message="false" label-width="auto" size="small">
<el-form-item
v-for="column in columns"
:key="column.columnName"
class="w100 mb5"
:prop="column.columnName"
:label="column.columnName"
:required="column.nullable != 'YES' && !column.isPrimaryKey && !column.isIdentity"
>
<ColumnFormItem
v-model="addDataDialog.data[`${column.columnName}`]"
:data-type="dbDialect.getDataType(column.columnType)"
:placeholder="`${column.columnType} ${column.columnComment}`"
:column-name="column.columnName"
:disabled="column.isIdentity"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="closeAddDataDialog">取消</el-button>
<el-button type="primary" @click="addRow">确定</el-button>
</span>
</template>
</el-dialog>
<DbTableDataForm
:db-inst="getNowDbInst()"
:db-name="dbName"
:columns="columns"
:title="addDataDialog.title"
:table-name="tableName"
v-model:visible="addDataDialog.visible"
v-model="addDataDialog.data"
@submit-success="onRefresh"
/>
</div>
</template>
@@ -269,11 +253,11 @@ import { ElMessage } from 'element-plus';
import { DbInst } from '@/views/ops/db/db';
import DbTableData from './DbTableData.vue';
import { DbDialect, getDbDialect } from '@/views/ops/db/dialect';
import { DbDialect } from '@/views/ops/db/dialect';
import SvgIcon from '@/components/svgIcon/index.vue';
import ColumnFormItem from './ColumnFormItem.vue';
import { useEventListener, useStorage } from '@vueuse/core';
import { copyToClipboard } from '@/common/utils/string';
import DbTableDataForm from './DbTableDataForm.vue';
const props = defineProps({
dbId: {
@@ -294,7 +278,6 @@ const props = defineProps({
},
});
const dataForm: any = ref(null);
const dbTableRef: Ref = ref(null);
const condInputRef: Ref = ref(null);
const columnNameSearchInputRef: Ref = ref(null);
@@ -341,7 +324,6 @@ const state = reactive({
addDataDialog: {
data: {},
title: '',
placeholder: '',
visible: false,
},
tableHeight: '600px',
@@ -349,7 +331,7 @@ const state = reactive({
dbDialect: {} as DbDialect,
});
const { datas, condition, loading, columns, pageNum, pageSize, pageSizes, sql, hasUpdatedFileds, conditionDialog, addDataDialog, dbDialect } = toRefs(state);
const { datas, condition, loading, columns, pageNum, pageSize, pageSizes, sql, hasUpdatedFileds, conditionDialog, addDataDialog } = toRefs(state);
watch(
() => props.tableHeight,
@@ -367,7 +349,7 @@ onMounted(async () => {
state.tableHeight = props.tableHeight;
await onRefresh();
state.dbDialect = getDbDialect(getNowDbInst().type);
state.dbDialect = getNowDbInst().getDialect();
useEventListener('click', handlerWindowClick);
});
@@ -601,46 +583,6 @@ const onShowAddDataDialog = async () => {
state.addDataDialog.title = `添加'${props.tableName}'表数据`;
state.addDataDialog.visible = true;
};
const closeAddDataDialog = () => {
state.addDataDialog.visible = false;
state.addDataDialog.data = {};
};
// 添加新数据行
const addRow = async () => {
dataForm.value.validate(async (valid: boolean) => {
if (valid) {
const dbInst = getNowDbInst();
const data = state.addDataDialog.data;
// key: 字段名value: 字段名提示
let obj: any = {};
for (let item of state.columns) {
const value = data[item.columnName];
if (!value) {
continue;
}
obj[`${dbInst.wrapName(item.columnName)}`] = DbInst.wrapValueByType(value);
}
let columnNames = Object.keys(obj).join(',');
let values = Object.values(obj).join(',');
// 获取schema
let schema = '';
let arr = props.dbName?.split('/');
if (arr && arr.length > 1) {
schema = dbInst.wrapName(arr[1]) + '.';
}
let sql = `INSERT INTO ${schema}${dbInst.wrapName(props.tableName)} (${columnNames}) VALUES (${values});`;
dbInst.promptExeSql(props.dbName, sql, null, () => {
closeAddDataDialog();
onRefresh();
});
} else {
ElMessage.error('请正确填写数据信息');
return false;
}
});
};
</script>
<style lang="scss">

View File

@@ -272,6 +272,18 @@ watch(props, async (newValue) => {
dbDialect = getDbDialect(newValue.dbType);
});
// 切换到索引tab时刷新索引字段下拉选项
watch(
() => state.activeName,
(newValue) => {
if (newValue === '2') {
state.tableData.indexs.columns = state.tableData.fields.res.map((a) => {
return { name: a.name, remark: a.remark };
});
}
}
);
const cancel = () => {
emit('update:visible', false);
reset();
@@ -391,22 +403,22 @@ const genSql = () => {
let data = state.tableData;
// 创建表
if (!props.data?.edit) {
if (state.activeName === '1') {
return dbDialect.getCreateTableSql(data);
} else if (state.activeName === '2' && data.indexs.res.length > 0) {
return dbDialect.getCreateIndexSql(data);
let createTable = dbDialect.getCreateTableSql(data);
let createIndex = '';
if (data.indexs.res.length > 0) {
createIndex = dbDialect.getCreateIndexSql(data);
}
return createTable + ';' + createIndex;
} else {
// 修改
if (state.activeName === '1') {
// 修改列
let changeData = filterChangedData(state.tableData.fields.oldFields, state.tableData.fields.res, 'name');
return dbDialect.getModifyColumnSql(data, data.tableName, changeData);
} else if (state.activeName === '2') {
// 修改索引
let changeData = filterChangedData(state.tableData.indexs.oldIndexs, state.tableData.indexs.res, 'indexName');
return dbDialect.getModifyIndexSql(data, data.tableName, changeData);
}
// 修改
let changeColData = filterChangedData(state.tableData.fields.oldFields, state.tableData.fields.res, 'name');
let colSql = dbDialect.getModifyColumnSql(data, data.tableName, changeColData);
// 修改索引
let changeIdxData = filterChangedData(state.tableData.indexs.oldIndexs, state.tableData.indexs.res, 'indexName');
let idxSql = dbDialect.getModifyIndexSql(data, data.tableName, changeIdxData);
// 修改表名
return colSql + ';' + idxSql;
}
};

View File

@@ -74,6 +74,11 @@ export class DbInst {
return db;
}
// 获取数据库实例方言
getDialect(): DbDialect {
return getDbDialect(this.type);
}
/**
* 加载数据库表信息
* @param dbName 数据库名
@@ -257,7 +262,7 @@ export class DbInst {
* @param table 表名
* @param datas 要生成的数据
*/
async genInsertSql(dbName: string, table: string, datas: any[]) {
async genInsertSql(dbName: string, table: string, datas: any[], skipNull = false) {
if (!datas) {
return '';
}
@@ -269,6 +274,9 @@ export class DbInst {
let values = [];
for (let column of columns) {
const colName = column.columnName;
if (skipNull && data[colName] == null) {
continue;
}
colNames.push(this.wrapName(colName));
values.push(DbInst.wrapValueByType(data[colName]));
}
@@ -277,6 +285,38 @@ export class DbInst {
return sqls.join(';\n') + ';';
}
/**
* 生成根据主键更新语句
* @param dbName 数据库名
* @param table 表名
* @param columnValue 要更新的列以及对应的值 field->columnName; value->columnValue
* @param rowData 表的一行完整数据(需要获取主键信息)
*/
async genUpdateSql(dbName: string, table: string, columnValue: {}, rowData: {}) {
let schema = '';
let dbArr = dbName.split('/');
if (dbArr.length == 2) {
schema = this.wrapName(dbArr[1]) + '.';
}
let sql = `UPDATE ${schema}${this.wrapName(table)} SET `;
// 主键列信息
const primaryKey = await this.loadTableColumn(dbName, table);
let primaryKeyType = primaryKey.columnType;
let primaryKeyName = primaryKey.columnName;
let primaryKeyValue = rowData[primaryKeyName];
const dialect = this.getDialect();
for (let k of Object.keys(columnValue)) {
const v = columnValue[k];
// 更新字段列信息
const updateColumn = await this.loadTableColumn(dbName, table, k);
sql += ` ${this.wrapName(k)} = ${DbInst.wrapColumnValue(updateColumn.columnType, v, dialect)},`;
}
sql = sql.substring(0, sql.length - 1);
return (sql += ` WHERE ${this.wrapName(primaryKeyName)} = ${DbInst.wrapColumnValue(primaryKeyType, primaryKeyValue)} ;`);
}
/**
* 生成根据主键删除的sql语句
* @param table 表名
@@ -297,7 +337,7 @@ export class DbInst {
sql,
dbId: this.id,
db,
dbType: getDbDialect(this.type).getInfo().formatSqlDialect,
dbType: this.getDialect().getInfo().formatSqlDialect,
runSuccessCallback: successFunc,
cancelCallback: cancelFunc,
});
@@ -310,7 +350,7 @@ export class DbInst {
* @returns
*/
wrapName = (name: string) => {
return getDbDialect(this.type).quoteIdentifier(name);
return this.getDialect().quoteIdentifier(name);
};
/**

View File

@@ -6,6 +6,8 @@ import { MariadbDialect } from '@/views/ops/db/dialect/mariadb_dialect';
import { SqliteDialect } from '@/views/ops/db/dialect/sqlite_dialect';
import { MssqlDialect } from '@/views/ops/db/dialect/mssql_dialect';
import { GaussDialect } from '@/views/ops/db/dialect/gauss_dialect';
import { KingbaseEsDialect } from '@/views/ops/db/dialect/kingbaseES_dialect';
import { VastbaseDialect } from '@/views/ops/db/dialect/vastbase_dialect';
export interface sqlColumnType {
udtName: string;
@@ -122,13 +124,15 @@ export const DbType = {
oracle: 'oracle',
sqlite: 'sqlite',
mssql: 'mssql', // ms sqlserver
kingbaseEs: 'kingbaseEs', // 人大金仓 pgsql模式 https://help.kingbase.com.cn/v8/index.html
vastbase: 'vastbase', // https://docs.vastdata.com.cn/zh/docs/VastbaseG100Ver2.2.5/doc/%E5%BC%80%E5%8F%91%E8%80%85%E6%8C%87%E5%8D%97/SQL%E5%8F%82%E8%80%83/SQL%E5%8F%82%E8%80%83.html
};
// mysql兼容的数据库
export const noSchemaTypes = [DbType.mysql, DbType.mariadb, DbType.sqlite];
// 有schema层的数据库
export const schemaDbTypes = [DbType.postgresql, DbType.gauss, DbType.dm, DbType.oracle, DbType.mssql];
export const schemaDbTypes = [DbType.postgresql, DbType.gauss, DbType.dm, DbType.oracle, DbType.mssql, DbType.kingbaseEs, DbType.vastbase];
export const editDbTypes = [...noSchemaTypes, ...schemaDbTypes];
@@ -218,8 +222,8 @@ export const getDbDialectMap = () => {
return dbType2DialectMap;
};
export const getDbDialect = (dbType: string): DbDialect => {
return dbType2DialectMap.get(dbType) || mysqlDialect;
export const getDbDialect = (dbType?: string): DbDialect => {
return dbType2DialectMap.get(dbType!) || mysqlDialect;
};
(function () {
@@ -232,4 +236,6 @@ export const getDbDialect = (dbType: string): DbDialect => {
registerDbDialect(DbType.oracle, new OracleDialect());
registerDbDialect(DbType.sqlite, new SqliteDialect());
registerDbDialect(DbType.mssql, new MssqlDialect());
registerDbDialect(DbType.kingbaseEs, new KingbaseEsDialect());
registerDbDialect(DbType.vastbase, new VastbaseDialect());
})();

View File

@@ -0,0 +1,18 @@
import { DialectInfo } from './index';
import { PostgresqlDialect } from '@/views/ops/db/dialect/postgres_dialect';
let kbpgDialectInfo: DialectInfo;
export class KingbaseEsDialect extends PostgresqlDialect {
getInfo(): DialectInfo {
if (kbpgDialectInfo) {
return kbpgDialectInfo;
}
kbpgDialectInfo = {} as DialectInfo;
Object.assign(kbpgDialectInfo, super.getInfo());
kbpgDialectInfo.name = 'KingbaseES';
kbpgDialectInfo.icon = 'iconfont icon-kingbase';
return kbpgDialectInfo;
}
}

View File

@@ -303,11 +303,15 @@ class PostgresqlDialect implements DbDialect {
// CREATE UNIQUE INDEX idx_column_name ON your_table (column1, column2);
// COMMENT ON INDEX idx_column_name IS 'Your index comment here';
// 创建索引
let schema = tableData.db.split('/')[1];
let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableData.tableName)}`;
let sql: string[] = [];
tableData.indexs.res.forEach((a: any) => {
sql.push(` create ${a.unique ? 'UNIQUE' : ''} index ${a.indexName} ("${a.columnNames.join('","')})"`);
// 字段名用双引号包裹
let colArr = a.columnNames.map((a: string) => `${this.quoteIdentifier(a)}`);
sql.push(`CREATE ${a.unique ? 'UNIQUE' : ''} INDEX ${this.quoteIdentifier(a.indexName)} on ${dbTable} (${colArr.join(',')})`);
if (a.indexComment) {
sql.push(`COMMENT ON INDEX ${a.indexName} IS '${a.indexComment}'`);
sql.push(`COMMENT ON INDEX ${schema}.${this.quoteIdentifier(a.indexName)} IS '${a.indexComment}'`);
}
});
return sql.join(';');
@@ -367,6 +371,9 @@ class PostgresqlDialect implements DbDialect {
}
getModifyIndexSql(tableData: any, tableName: string, changeData: { del: any[]; add: any[]; upd: any[] }): string {
let schema = tableData.db.split('/')[1];
let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableName)}`;
// 不能直接修改索引名或字段、需要先删后加
let dropIndexNames: string[] = [];
let addIndexs: any[] = [];
@@ -400,9 +407,11 @@ class PostgresqlDialect implements DbDialect {
if (addIndexs.length > 0) {
addIndexs.forEach((a) => {
sql.push(`CREATE ${a.unique ? 'UNIQUE' : ''} INDEX ${a.indexName}(${a.columnNames.join(',')})`);
// 字段名用双引号包裹
let colArr = a.columnNames.map((a: string) => `${this.quoteIdentifier(a)}`);
sql.push(`CREATE ${a.unique ? 'UNIQUE' : ''} INDEX ${this.quoteIdentifier(a.indexName)} on ${dbTable} (${colArr.join(',')})`);
if (a.indexComment) {
sql.push(`COMMENT ON INDEX ${a.indexName} IS '${a.indexComment}'`);
sql.push(`COMMENT ON INDEX ${schema}.${this.quoteIdentifier(a.indexName)} IS '${a.indexComment}'`);
}
});
}
@@ -431,7 +440,7 @@ class PostgresqlDialect implements DbDialect {
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars,no-unused-vars
wrapStrValue(value: string, type: string): string {
wrapStrValue(columnType: string, value: string): string {
return `'${value}'`;
}
}

View File

@@ -0,0 +1,18 @@
import { DialectInfo } from './index';
import { PostgresqlDialect } from '@/views/ops/db/dialect/postgres_dialect';
let vastDialectInfo: DialectInfo;
export class VastbaseDialect extends PostgresqlDialect {
getInfo(): DialectInfo {
if (vastDialectInfo) {
return vastDialectInfo;
}
vastDialectInfo = {} as DialectInfo;
Object.assign(vastDialectInfo, super.getInfo());
vastDialectInfo.name = 'VastbaseG100';
vastDialectInfo.icon = 'iconfont icon-vastbase';
return vastDialectInfo;
}
}

View File

@@ -13,6 +13,7 @@
tagSelectRef.validate();
}
"
:tag-path="form.tagPath"
:resource-code="form.code"
:resource-type="TagResourceTypeEnum.Machine.value"
style="width: 100%"
@@ -153,6 +154,7 @@ const state = reactive({
form: {
id: null,
code: '',
tagPath: '',
ip: null,
port: 22,
name: null,

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="machine-list">
<page-table
ref="pageTableRef"
:page-api="machineApi.list"
@@ -25,7 +25,7 @@
<span v-if="!data.stat">-</span>
<div v-else>
<el-row>
<el-text size="small" style="font-size: 10px">
<el-text size="small" class="font11">
内存(可用/):
<span :class="getStatsFontClass(data.stat.memAvailable, data.stat.memTotal)"
>{{ formatByteSize(data.stat.memAvailable, 1) }}/{{ formatByteSize(data.stat.memTotal, 1) }}
@@ -33,7 +33,7 @@
</el-text>
</el-row>
<el-row>
<el-text style="font-size: 10px" size="small">
<el-text class="font11" size="small">
CPU(空闲): <span :class="getStatsFontClass(data.stat.cpuIdle, 100)">{{ data.stat.cpuIdle.toFixed(0) }}%</span>
</el-text>
</el-row>
@@ -44,7 +44,7 @@
<span v-if="!data.stat?.fsInfos">-</span>
<div v-else>
<el-row v-for="(i, idx) in data.stat.fsInfos.slice(0, 2)" :key="i.mountPoint">
<el-text style="font-size: 10px" size="small" :class="getStatsFontClass(i.free, i.used + i.free)">
<el-text class="font11" size="small" :class="getStatsFontClass(i.free, i.used + i.free)">
{{ i.mountPoint }} => {{ formatByteSize(i.free, 0) }}/{{ formatByteSize(i.used + i.free, 0) }}
</el-text>
@@ -55,7 +55,7 @@
</template>
<el-row v-for="i in data.stat.fsInfos.slice(2)" :key="i.mountPoint">
<el-text style="font-size: 10px" size="small" :class="getStatsFontClass(i.free, i.used + i.free)">
<el-text class="font11" size="small" :class="getStatsFontClass(i.free, i.used + i.free)">
{{ i.mountPoint }} => {{ formatByteSize(i.free, 0) }}/{{ formatByteSize(i.used + i.free, 0) }}
</el-text>
</el-row>
@@ -231,8 +231,8 @@ const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Machine.value), Se
const columns = [
TableColumn.new('name', '名称'),
TableColumn.new('ipPort', 'ip:port').isSlot().setAddWidth(50),
TableColumn.new('stat', '运行状态').isSlot().setAddWidth(50),
TableColumn.new('fs', '磁盘(挂载点=>可用/总)').isSlot().setAddWidth(20),
TableColumn.new('stat', '运行状态').isSlot().setAddWidth(55),
TableColumn.new('fs', '磁盘(挂载点=>可用/总)').isSlot().setAddWidth(25),
TableColumn.new('username', '用户名'),
TableColumn.new('status', '状态').isSlot().setMinWidth(85),
TableColumn.new('tagPath', '关联标签').isSlot().setAddWidth(10).alignCenter(),
@@ -464,10 +464,6 @@ const showRec = (row: any) => {
</script>
<style>
.el-dialog__body {
padding: 2px 2px;
}
.el-dropdown-link-machine-list {
cursor: pointer;
color: var(--el-color-primary);

View File

@@ -0,0 +1,402 @@
<template>
<div class="flex-all-center">
<!-- 文档 https://antoniandre.github.io/splitpanes/ -->
<Splitpanes class="default-theme" @resized="onResizeTagTree">
<Pane size="20" max-size="30">
<tag-tree
class="machine-terminal-tree"
ref="tagTreeRef"
:resource-type="TagResourceTypeEnum.Machine.value"
:tag-path-node-type="NodeTypeTagPath"
>
<template #prefix="{ data }">
<SvgIcon v-if="data.icon && data.params.status == 1" :name="data.icon.name" :color="data.icon.color" />
<SvgIcon v-if="data.icon && data.params.status == -1" :name="data.icon.name" color="var(--el-color-danger)" />
</template>
<template #suffix="{ data }">
<span style="color: #c4c9c4; font-size: 9px" v-if="data.type.value == MachineNodeType.Machine">{{
` ${data.params.username}@${data.params.ip}:${data.params.port}`
}}</span>
</template>
</tag-tree>
</Pane>
<Pane>
<div class="machine-terminal-tabs card pd5">
<el-tabs
v-if="state.tabs.size > 0"
type="card"
@tab-remove="onRemoveTab"
@tab-change="onTabChange"
style="width: 100%"
v-model="state.activeTermName"
class="h100"
>
<el-tab-pane class="h100" closable v-for="dt in state.tabs.values()" :label="dt.label" :name="dt.key" :key="dt.key">
<template #label>
<el-popconfirm @confirm="handleReconnect(dt.key)" title="确认重新连接?">
<template #reference>
<el-icon class="mr5" :color="dt.status == 1 ? '#67c23a' : '#f56c6c'" :title="dt.status == 1 ? '' : '点击重连'"
><Connection />
</el-icon>
</template>
</el-popconfirm>
<el-popover :show-after="1000" placement="bottom-start" trigger="hover" :width="250">
<template #reference>
<div>
<span class="machine-terminal-tab-label">{{ dt.label }}</span>
</div>
</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?.ip }} : {{ dt.params?.port }} </el-descriptions-item>
<el-descriptions-item label="username"> {{ dt.params?.username }} </el-descriptions-item>
<el-descriptions-item label="remark"> {{ dt.params?.remark }} </el-descriptions-item>
</el-descriptions>
</template>
</el-popover>
</template>
<div class="terminal-wrapper" :style="{ height: `calc(100vh - 155px)` }">
<TerminalBody
@status-change="terminalStatusChange(dt.key, $event)"
:ref="(el) => setTerminalRef(el, dt.key)"
:socket-url="dt.socketUrl"
/>
</div>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="infoDialog.visible">
<el-descriptions title="详情" :column="3" border>
<el-descriptions-item :span="1.5" label="机器id">{{ infoDialog.data.id }}</el-descriptions-item>
<el-descriptions-item :span="1.5" label="名称">{{ infoDialog.data.name }}</el-descriptions-item>
<el-descriptions-item :span="3" label="标签路径">{{ infoDialog.data.tagPath }}</el-descriptions-item>
<el-descriptions-item :span="2" label="IP">{{ infoDialog.data.ip }}</el-descriptions-item>
<el-descriptions-item :span="1" label="端口">{{ infoDialog.data.port }}</el-descriptions-item>
<el-descriptions-item :span="2" label="用户名">{{ infoDialog.data.username }}</el-descriptions-item>
<el-descriptions-item :span="1" label="认证方式">
{{ infoDialog.data.authCertId > 1 ? '授权凭证' : '密码' }}
</el-descriptions-item>
<el-descriptions-item :span="3" label="备注">{{ infoDialog.data.remark }}</el-descriptions-item>
<el-descriptions-item :span="1.5" label="SSH隧道">{{ infoDialog.data.sshTunnelMachineId > 0 ? '是' : '否' }} </el-descriptions-item>
<el-descriptions-item :span="1.5" label="终端回放">{{ infoDialog.data.enableRecorder == 1 ? '是' : '否' }} </el-descriptions-item>
<el-descriptions-item :span="2" label="创建时间">{{ dateFormat(infoDialog.data.createTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" label="创建者">{{ infoDialog.data.creator }}</el-descriptions-item>
<el-descriptions-item :span="2" label="更新时间">{{ dateFormat(infoDialog.data.updateTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" label="修改者">{{ infoDialog.data.modifier }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<process-list v-model:visible="processDialog.visible" v-model:machineId="processDialog.machineId" />
<script-manage :title="serviceDialog.title" v-model:visible="serviceDialog.visible" v-model:machineId="serviceDialog.machineId" />
<file-conf-list :title="fileDialog.title" v-model:visible="fileDialog.visible" v-model:machineId="fileDialog.machineId" />
<machine-stats v-model:visible="machineStatsDialog.visible" :machineId="machineStatsDialog.machineId" :title="machineStatsDialog.title" />
<machine-rec v-model:visible="machineRecDialog.visible" :machineId="machineRecDialog.machineId" :title="machineRecDialog.title" />
</div>
</Pane>
</Splitpanes>
</div>
</template>
<script lang="ts" setup>
import { ref, toRefs, reactive, defineAsyncComponent } from 'vue';
import { useRouter } from 'vue-router';
import { machineApi, getMachineTerminalSocketUrl } from './api';
import { dateFormat } from '@/common/utils/date';
import { hasPerms } from '@/components/auth/auth';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { NodeType, TagTreeNode } from '../component/tag';
import TagTree from '../component/TagTree.vue';
import { Splitpanes, Pane } from 'splitpanes';
import { ContextmenuItem } from '@/components/contextmenu/index';
// 组件
const ScriptManage = defineAsyncComponent(() => import('./ScriptManage.vue'));
const FileConfList = defineAsyncComponent(() => import('./file/FileConfList.vue'));
const MachineStats = defineAsyncComponent(() => import('./MachineStats.vue'));
const MachineRec = defineAsyncComponent(() => import('./MachineRec.vue'));
const ProcessList = defineAsyncComponent(() => import('./ProcessList.vue'));
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import { TerminalStatus } from '@/components/terminal/common';
const router = useRouter();
const perms = {
addMachine: 'machine:add',
updateMachine: 'machine:update',
delMachine: 'machine:del',
terminal: 'machine:terminal',
closeCli: 'machine:close-cli',
};
// 该用户拥有的的操作列按钮权限使用v-if进行判断v-auth对el-dropdown-item无效
const actionBtns = hasPerms([perms.updateMachine, perms.closeCli]);
class MachineNodeType {
static Machine = 1;
}
const state = reactive({
params: {
pageNum: 1,
pageSize: 0,
ip: null,
name: null,
tagPath: '',
},
infoDialog: {
visible: false,
data: null as any,
},
serviceDialog: {
visible: false,
machineId: 0,
title: '',
},
processDialog: {
visible: false,
machineId: 0,
},
fileDialog: {
visible: false,
machineId: 0,
title: '',
},
machineStatsDialog: {
visible: false,
stats: null,
title: '',
machineId: 0,
},
machineRecDialog: {
visible: false,
machineId: 0,
title: '',
},
activeTermName: '',
tabs: new Map<string, any>(),
});
const { infoDialog, serviceDialog, processDialog, fileDialog, machineStatsDialog, machineRecDialog } = toRefs(state);
const tagTreeRef: any = ref(null);
const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(async (node: any) => {
// 加载标签树下的机器列表
state.params.tagPath = node.key;
state.params.pageNum = 1;
state.params.pageSize = 1000;
const res = await search();
// 把list 根据name字段排序
res.list = res.list.sort((a: any, b: any) => a.name.localeCompare(b.name));
return res.list.map((x: any) =>
new TagTreeNode(x.id, x.name, NodeTypeMachine(x))
.withParams(x)
.withDisabled(x.status == -1)
.withIcon({
name: 'Monitor',
color: '#409eff',
})
.withIsLeaf(true)
);
});
let openIds = {};
const NodeTypeMachine = (machine: any) => {
let contextMenuItems = [];
contextMenuItems.push(new ContextmenuItem('term', '打开终端').withIcon('Monitor').withOnClick(() => openTerminal(machine)));
contextMenuItems.push(new ContextmenuItem('term-ex', '打开终端(新窗口)').withIcon('Monitor').withOnClick(() => openTerminal(machine, true)));
contextMenuItems.push(new ContextmenuItem('detail', '详情').withIcon('More').withOnClick(() => showInfo(machine)));
contextMenuItems.push(new ContextmenuItem('status', '状态').withIcon('Compass').withOnClick(() => showMachineStats(machine)));
contextMenuItems.push(new ContextmenuItem('process', '进程').withIcon('DataLine').withOnClick(() => showProcess(machine)));
if (actionBtns[perms.updateMachine] && machine.enableRecorder == 1) {
contextMenuItems.push(new ContextmenuItem('edit', '终端回放').withIcon('Compass').withOnClick(() => showRec(machine)));
}
contextMenuItems.push(new ContextmenuItem('files', '文件管理').withIcon('FolderOpened').withOnClick(() => showFileManage(machine)));
contextMenuItems.push(new ContextmenuItem('scripts', '脚本管理').withIcon('Files').withOnClick(() => serviceManager(machine)));
return new NodeType(MachineNodeType.Machine).withContextMenuItems(contextMenuItems).withNodeDblclickFunc(() => {
// for (let k of state.tabs.keys()) {
// // 存在该机器相关的终端tab则直接激活该tab
// if (k.startsWith(`${machine.id}_${machine.username}_`)) {
// state.activeTermName = k;
// onTabChange();
// return;
// }
// }
openTerminal(machine);
});
};
const openTerminal = (machine: any, ex?: boolean) => {
// 新窗口打开
if (ex) {
const { href } = router.resolve({
path: `/machine/terminal`,
query: {
id: machine.id,
name: machine.name,
},
});
window.open(href, '_blank');
return;
}
let { name, id, username } = machine;
// 同一个机器的终端打开多次key后添加下划线和数字区分
openIds[id] = openIds[id] ? ++openIds[id] : 1;
let sameIndex = openIds[id];
let key = `${id}_${username}_${sameIndex}`;
// 只保留name的10个字超出部分只保留前后4个字符中间用省略号代替
let label = name.length > 10 ? name.slice(0, 4) + '...' + name.slice(-4) : name;
state.tabs.set(key, {
key,
label: `${label}${sameIndex === 1 ? '' : ':' + sameIndex}`, // label组成为:总打开term次数+name+同一个机器打开的次数
params: machine,
socketUrl: getMachineTerminalSocketUrl(id),
});
state.activeTermName = key;
fitTerminal();
};
const serviceManager = (row: any) => {
state.serviceDialog.machineId = row.id;
state.serviceDialog.visible = true;
state.serviceDialog.title = `${row.name} => ${row.ip}`;
};
/**
* 显示机器状态统计信息
*/
const showMachineStats = async (machine: any) => {
state.machineStatsDialog.machineId = machine.id;
state.machineStatsDialog.title = `机器状态: ${machine.name} => ${machine.ip}`;
state.machineStatsDialog.visible = true;
};
const search = async () => {
const res = await machineApi.list.request(state.params);
return res;
};
const showFileManage = (selectionData: any) => {
state.fileDialog.visible = true;
state.fileDialog.machineId = selectionData.id;
state.fileDialog.title = `${selectionData.name} => ${selectionData.ip}`;
};
const showInfo = (info: any) => {
state.infoDialog.data = info;
state.infoDialog.visible = true;
};
const showProcess = (row: any) => {
state.processDialog.machineId = row.id;
state.processDialog.visible = true;
};
const showRec = (row: any) => {
state.machineRecDialog.title = `${row.name}[${row.ip}]-终端回放记录`;
state.machineRecDialog.machineId = row.id;
state.machineRecDialog.visible = true;
};
const onRemoveTab = (targetName: string) => {
let activeTermName = state.activeTermName;
const tabNames = [...state.tabs.keys()];
for (let i = 0; i < tabNames.length; i++) {
const tabName = tabNames[i];
if (tabName !== targetName) {
continue;
}
const nextTab = tabNames[i + 1] || tabNames[i - 1];
if (nextTab) {
activeTermName = nextTab;
} else {
activeTermName = '';
}
let info = state.tabs.get(targetName);
if (info) {
terminalRefs[info.key]?.close();
}
state.tabs.delete(targetName);
state.activeTermName = activeTermName;
onTabChange();
}
};
const terminalStatusChange = (key: string, status: TerminalStatus) => {
state.tabs.get(key).status = status;
};
const terminalRefs: any = {};
const setTerminalRef = (el: any, key: any) => {
if (key) {
terminalRefs[key] = el;
}
};
const onResizeTagTree = () => {
fitTerminal();
};
const onTabChange = () => {
fitTerminal();
};
const fitTerminal = () => {
setTimeout(() => {
let info = state.tabs.get(state.activeTermName);
if (info) {
terminalRefs[info.key]?.resize();
terminalRefs[info.key]?.focus();
}
}, 100);
};
const handleReconnect = (key: string) => {
terminalRefs[key].init();
};
</script>
<style lang="scss">
.machine-terminal-tabs {
height: calc(100vh - 108px);
--el-tabs-header-height: 30px;
.el-tabs {
--el-tabs-header-height: 30px;
}
.machine-terminal-tab-label {
font-size: 12px;
}
.el-tabs__header {
margin-bottom: 5px;
}
.el-tabs__item {
padding: 0 8px !important;
}
}
</style>

View File

@@ -38,7 +38,6 @@ require (
// gorm
gorm.io/driver/mysql v1.5.2
gorm.io/gorm v1.25.6
)
require (

View File

@@ -78,8 +78,6 @@ func (d *Db) DeleteDb(rc *req.Ctx) {
d.DbApp.Delete(ctx, dbId)
// 删除该库的sql执行记录
d.DbSqlExecApp.DeleteBy(ctx, &entity.DbSqlExec{DbId: dbId})
// todo delete restore task and histories
}
}

View File

@@ -81,6 +81,7 @@ func (d *DbBackup) Update(rc *req.Ctx) {
job.Name = backupForm.Name
job.StartTime = backupForm.StartTime
job.Interval = backupForm.Interval
job.MaxSaveDays = backupForm.MaxSaveDays
biz.ErrIsNilAppendErr(d.backupApp.Update(rc.MetaCtx, job), "保存数据库备份任务失败: %v")
}
@@ -178,7 +179,7 @@ func (d *DbBackup) GetHistoryPageList(rc *req.Ctx) {
rc.ResData = res
}
// RestoreHistories 删除数据库备份历史
// RestoreHistories 数据库备份历史中恢复数据库
// @router /api/dbs/:dbId/backup-histories/:backupHistoryId/restore [POST]
func (d *DbBackup) RestoreHistories(rc *req.Ctx) {
pm := ginx.PathParam(rc.GinCtx, "backupHistoryId")

View File

@@ -87,16 +87,10 @@ func (d *Instance) DeleteInstance(rc *req.Ctx) {
for _, v := range ids {
value, err := strconv.Atoi(v)
biz.ErrIsNilAppendErr(err, "string类型转换为int异常: %s")
biz.ErrIsNilAppendErr(err, "删除数据库实例失败: %s")
instanceId := uint64(value)
if d.DbApp.Count(&entity.DbQuery{InstanceId: instanceId}) != 0 {
instance, err := d.InstanceApp.GetById(new(entity.DbInstance), instanceId, "name")
biz.ErrIsNil(err, "获取数据库实例错误数据库实例ID为: %d", instance.Id)
biz.IsTrue(false, "不能删除数据库实例【%s】请先删除其关联的数据库资源。", instance.Name)
}
// todo check if backup task has been disabled and backup histories have been deleted
d.InstanceApp.Delete(rc.MetaCtx, instanceId)
err = d.InstanceApp.Delete(rc.MetaCtx, instanceId)
biz.ErrIsNilAppendErr(err, "删除数据库实例失败: %s")
}
}

View File

@@ -14,6 +14,7 @@ type DbBackupForm struct {
Interval time.Duration `json:"-"` // 间隔时间: 为零表示单次执行,为正表示反复执行
IntervalDay uint64 `json:"intervalDay"` // 间隔天数: 为零表示单次执行,为正表示反复执行
Repeated bool `json:"repeated"` // 是否重复执行
MaxSaveDays int `json:"maxSaveDays"` // 数据库备份历史保留天数,过期将自动删除
}
func (restore *DbBackupForm) UnmarshalJSON(data []byte) error {

View File

@@ -15,6 +15,7 @@ type DbBackup struct {
StartTime time.Time `json:"startTime"` // 开始时间
Interval time.Duration `json:"-"` // 间隔时间
IntervalDay uint64 `json:"intervalDay" gorm:"-"` // 间隔天数
MaxSaveDays int `json:"maxSaveDays"` // 数据库备份历史保留天数,过期将自动删除
Enabled bool `json:"enabled"` // 是否启用
EnabledDesc string `json:"enabledDesc"` // 启用状态描述
LastTime timex.NullTime `json:"lastTime"` // 最近一次执行时间
@@ -29,9 +30,9 @@ func (backup *DbBackup) MarshalJSON() ([]byte, error) {
backup.IntervalDay = uint64(backup.Interval / time.Hour / 24)
if len(backup.EnabledDesc) == 0 {
if backup.Enabled {
backup.EnabledDesc = "任务已启用"
backup.EnabledDesc = "已启用"
} else {
backup.EnabledDesc = "任务已禁用"
backup.EnabledDesc = "已禁用"
}
}
return json.Marshal((*dbBackup)(backup))

View File

@@ -30,9 +30,9 @@ func (restore *DbRestore) MarshalJSON() ([]byte, error) {
restore.IntervalDay = uint64(restore.Interval / time.Hour / 24)
if len(restore.EnabledDesc) == 0 {
if restore.Enabled {
restore.EnabledDesc = "任务已启用"
restore.EnabledDesc = "已启用"
} else {
restore.EnabledDesc = "任务已禁用"
restore.EnabledDesc = "已禁用"
}
}
return json.Marshal((*dbBackup)(restore))

View File

@@ -25,10 +25,13 @@ func InitIoc() {
func Init() {
sync.OnceFunc(func() {
if err := GetDbBackupApp().Init(); err != nil {
panic(fmt.Sprintf("初始化 dbBackupApp 失败: %v", err))
panic(fmt.Sprintf("初始化 DbBackupApp 失败: %v", err))
}
if err := GetDbRestoreApp().Init(); err != nil {
panic(fmt.Sprintf("初始化 dbRestoreApp 失败: %v", err))
panic(fmt.Sprintf("初始化 DbRestoreApp 失败: %v", err))
}
if err := GetDbBinlogApp().Init(); err != nil {
panic(fmt.Sprintf("初始化 DbBinlogApp 失败: %v", err))
}
GetDataSyncTaskApp().InitCronJob()
})()

View File

@@ -154,7 +154,7 @@ func (d *dbAppImpl) GetDbConn(dbId uint64, dbName string) (*dbi.DbConn, error) {
checkDb := dbName
// 兼容pgsql/dm db/schema模式
if dbi.DbTypePostgres.Equal(instance.Type) || dbi.DbTypeGauss.Equal(instance.Type) || dbi.DbTypeDM.Equal(instance.Type) || dbi.DbTypeOracle.Equal(instance.Type) || dbi.DbTypeMssql.Equal(instance.Type) {
if dbi.DbTypePostgres.Equal(instance.Type) || dbi.DbTypeGauss.Equal(instance.Type) || dbi.DbTypeDM.Equal(instance.Type) || dbi.DbTypeOracle.Equal(instance.Type) || dbi.DbTypeMssql.Equal(instance.Type) || dbi.DbTypeKingbaseEs.Equal(instance.Type) || dbi.DbTypeVastbase.Equal(instance.Type) {
ss := strings.Split(dbName, "/")
if len(ss) > 1 {
checkDb = ss[0]

View File

@@ -6,15 +6,24 @@ import (
"errors"
"fmt"
"gorm.io/gorm"
"math"
"mayfly-go/internal/db/domain/entity"
"mayfly-go/internal/db/domain/repository"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils/timex"
"sync"
"time"
"github.com/google/uuid"
)
const maxBackupHistoryDays = 30
var (
errRestoringBackupHistory = errors.New("正在从备份历史中恢复数据库")
)
type DbBackupApp struct {
scheduler *dbScheduler `inject:"DbScheduler"`
backupRepo repository.DbBackup `inject:"DbBackupRepo"`
@@ -22,6 +31,10 @@ type DbBackupApp struct {
restoreRepo repository.DbRestore `inject:"DbRestoreRepo"`
dbApp Db `inject:"DbApp"`
mutex sync.Mutex
closed chan struct{}
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
}
func (app *DbBackupApp) Init() error {
@@ -32,11 +45,68 @@ func (app *DbBackupApp) Init() error {
if err := app.scheduler.AddJob(context.Background(), jobs); err != nil {
return err
}
app.ctx, app.cancel = context.WithCancel(context.Background())
app.wg.Add(1)
go func() {
defer app.wg.Done()
for app.ctx.Err() == nil {
if err := app.prune(app.ctx); err != nil {
logx.Errorf("清理数据库备份历史失败: %s", err.Error())
timex.SleepWithContext(app.ctx, time.Minute*15)
continue
}
timex.SleepWithContext(app.ctx, time.Hour*24)
}
}()
return nil
}
func (app *DbBackupApp) prune(ctx context.Context) error {
var jobs []*entity.DbBackup
if err := app.backupRepo.ListByCond(map[string]any{}, &jobs); err != nil {
return err
}
for _, job := range jobs {
if ctx.Err() != nil {
return nil
}
var histories []*entity.DbBackupHistory
historyCond := map[string]any{
"db_backup_id": job.Id,
}
if err := app.backupHistoryRepo.ListByCondOrder(historyCond, &histories, "id"); err != nil {
return err
}
expiringTime := time.Now().Add(-math.MaxInt64)
if job.MaxSaveDays > 0 {
expiringTime = time.Now().Add(-time.Hour * 24 * time.Duration(job.MaxSaveDays+1))
}
for _, history := range histories {
if ctx.Err() != nil {
return nil
}
if history.CreateTime.After(expiringTime) {
break
}
err := app.DeleteHistory(ctx, history.Id)
if errors.Is(err, errRestoringBackupHistory) {
break
}
if err != nil {
return err
}
}
}
return nil
}
func (app *DbBackupApp) Close() {
app.scheduler.Close()
if app.cancel != nil {
app.cancel()
app.cancel = nil
}
app.wg.Wait()
}
func (app *DbBackupApp) Create(ctx context.Context, jobs []*entity.DbBackup) error {
@@ -61,7 +131,6 @@ func (app *DbBackupApp) Update(ctx context.Context, job *entity.DbBackup) error
}
func (app *DbBackupApp) Delete(ctx context.Context, jobId uint64) error {
// todo: 删除数据库备份历史文件
app.mutex.Lock()
defer app.mutex.Unlock()
@@ -76,7 +145,7 @@ func (app *DbBackupApp) Delete(ctx context.Context, jobId uint64) error {
default:
return err
case err == nil:
return fmt.Errorf("数据库备份存在历史记录【%s】无法删除该任务", history.Name)
return fmt.Errorf("请先删除关联的数据库备份历史【%s】", history.Name)
case errors.Is(err, gorm.ErrRecordNotFound):
}
if err := app.backupRepo.DeleteById(ctx, jobId); err != nil {
@@ -184,27 +253,18 @@ func NewIncUUID() (uuid.UUID, error) {
}
func (app *DbBackupApp) DeleteHistory(ctx context.Context, historyId uint64) (retErr error) {
// todo: 删除数据库备份历史文件
app.mutex.Lock()
defer app.mutex.Unlock()
if _, err := app.backupHistoryRepo.UpdateDeleting(false, historyId); err != nil {
return err
}
ok, err := app.backupHistoryRepo.UpdateDeleting(true, historyId)
if err != nil {
return err
}
defer func() {
_, err = app.backupHistoryRepo.UpdateDeleting(false, historyId)
if err == nil {
return
}
if retErr == nil {
retErr = err
return
}
retErr = fmt.Errorf("%w, %w", retErr, err)
}()
if !ok {
return errors.New("正在从备份历史中恢复数据库")
return errRestoringBackupHistory
}
job := &entity.DbBackupHistory{}
if err := app.backupHistoryRepo.GetById(job, historyId); err != nil {
@@ -214,7 +274,10 @@ func (app *DbBackupApp) DeleteHistory(ctx context.Context, historyId uint64) (re
if err != nil {
return err
}
dbProgram := conn.GetDialect().GetDbProgram()
dbProgram, err := conn.GetDialect().GetDbProgram()
if err != nil {
return err
}
if err := dbProgram.RemoveBackupHistory(ctx, job.DbBackupId, job.Uuid); err != nil {
return err
}

View File

@@ -2,6 +2,7 @@ package application
import (
"context"
"math"
"mayfly-go/internal/db/domain/entity"
"mayfly-go/internal/db/domain/repository"
"mayfly-go/pkg/logx"
@@ -11,9 +12,13 @@ import (
)
type DbBinlogApp struct {
scheduler *dbScheduler `inject:"DbScheduler"`
binlogRepo repository.DbBinlog `inject:"DbBinlogRepo"`
backupRepo repository.DbBackup `inject:"DbBackupRepo"`
scheduler *dbScheduler `inject:"DbScheduler"`
binlogRepo repository.DbBinlog `inject:"DbBinlogRepo"`
binlogHistoryRepo repository.DbBinlogHistory `inject:"DbBinlogHistoryRepo"`
backupRepo repository.DbBackup `inject:"DbBackupRepo"`
backupHistoryRepo repository.DbBackupHistory `inject:"DbBackupHistoryRepo"`
instanceRepo repository.Instance `inject:"DbInstanceRepo"`
dbApp Db `inject:"DbApp"`
context context.Context
cancel context.CancelFunc
@@ -26,41 +31,113 @@ func newDbBinlogApp() *DbBinlogApp {
context: ctx,
cancel: cancel,
}
svc.waitGroup.Add(1)
go svc.run()
return svc
}
func (app *DbBinlogApp) Init() error {
app.context, app.cancel = context.WithCancel(context.Background())
app.waitGroup.Add(1)
go app.run()
return nil
}
func (app *DbBinlogApp) run() {
defer app.waitGroup.Done()
// todo: 实现 binlog 并发下载
timex.SleepWithContext(app.context, time.Minute)
for !app.closed() {
jobs, err := app.loadJobs()
if err != nil {
logx.Errorf("DbBinlogApp: 加载 BINLOG 同步任务失败: %s", err.Error())
for app.context.Err() == nil {
if err := app.fetchBinlog(app.context); err != nil {
timex.SleepWithContext(app.context, time.Minute)
continue
}
if app.closed() {
break
}
if err := app.scheduler.AddJob(app.context, jobs); err != nil {
logx.Error("DbBinlogApp: 添加 BINLOG 同步任务失败: ", err.Error())
if err := app.pruneBinlog(app.context); err != nil {
timex.SleepWithContext(app.context, time.Minute)
continue
}
timex.SleepWithContext(app.context, entity.BinlogDownloadInterval)
}
}
func (app *DbBinlogApp) loadJobs() ([]*entity.DbBinlog, error) {
func (app *DbBinlogApp) fetchBinlog(ctx context.Context) error {
jobs, err := app.loadJobs(ctx)
if err != nil {
logx.Errorf("DbBinlogApp: 加载 BINLOG 同步任务失败: %s", err.Error())
timex.SleepWithContext(app.context, time.Minute)
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
if err := app.scheduler.AddJob(app.context, jobs); err != nil {
logx.Error("DbBinlogApp: 添加 BINLOG 同步任务失败: ", err.Error())
return err
}
return nil
}
func (app *DbBinlogApp) pruneBinlog(ctx context.Context) error {
var jobs []*entity.DbBinlog
if err := app.binlogRepo.ListByCond(map[string]any{}, &jobs); err != nil {
logx.Error("DbBinlogApp: 获取 BINLOG 同步任务失败: ", err.Error())
return err
}
for _, instance := range jobs {
if ctx.Err() != nil {
return ctx.Err()
}
var histories []*entity.DbBinlogHistory
backupHistory, backupHistoryExists, err := app.backupHistoryRepo.GetEarliestHistoryForBinlog(instance.Id)
if err != nil {
logx.Errorf("DbBinlogApp: 获取数据库备份历史失败: %s", err.Error())
return err
}
var binlogSeq int64 = math.MaxInt64
if backupHistoryExists {
binlogSeq = backupHistory.BinlogSequence
}
if err := app.binlogHistoryRepo.GetHistoriesBeforeSequence(ctx, instance.Id, binlogSeq, &histories); err != nil {
logx.Errorf("DbBinlogApp: 获取数据库 BINLOG 历史失败: %s", err.Error())
return err
}
conn, err := app.dbApp.GetDbConnByInstanceId(instance.Id)
if err != nil {
logx.Errorf("DbBinlogApp: 创建数据库连接失败: %s", err.Error())
return err
}
dbProgram, err := conn.GetDialect().GetDbProgram()
if err != nil {
logx.Errorf("DbBinlogApp: 获取数据库备份与恢复程序失败: %s", err.Error())
return err
}
for i, history := range histories {
// todo: 在避免并发访问的前提下删除本地最新的 BINLOG 文件
if !backupHistoryExists && i == len(histories)-1 {
// 暂不删除本地最新的 BINLOG 文件
break
}
if ctx.Err() != nil {
return ctx.Err()
}
if err := dbProgram.PruneBinlog(history); err != nil {
logx.Errorf("清理 BINLOG 文件失败: %v", err)
continue
}
if err := app.binlogHistoryRepo.DeleteById(ctx, history.Id); err != nil {
logx.Errorf("删除 BINLOG 历史失败: %v", err)
continue
}
}
}
return nil
}
func (app *DbBinlogApp) loadJobs(ctx context.Context) ([]*entity.DbBinlog, error) {
var instanceIds []uint64
if err := app.backupRepo.ListDbInstances(true, true, &instanceIds); err != nil {
return nil, err
}
jobs := make([]*entity.DbBinlog, 0, len(instanceIds))
for _, id := range instanceIds {
if app.closed() {
if ctx.Err() != nil {
break
}
binlog := entity.NewDbBinlog(id)
@@ -73,14 +150,15 @@ func (app *DbBinlogApp) loadJobs() ([]*entity.DbBinlog, error) {
}
func (app *DbBinlogApp) Close() {
app.cancel()
cancel := app.cancel
if cancel == nil {
return
}
app.cancel = nil
cancel()
app.waitGroup.Wait()
}
func (app *DbBinlogApp) closed() bool {
return app.context.Err() != nil
}
func (app *DbBinlogApp) AddJobIfNotExists(ctx context.Context, job *entity.DbBinlog) error {
if err := app.binlogRepo.AddJobIfNotExists(ctx, job); err != nil {
return err
@@ -90,11 +168,3 @@ func (app *DbBinlogApp) AddJobIfNotExists(ctx context.Context, job *entity.DbBin
}
return nil
}
func (app *DbBinlogApp) Delete(ctx context.Context, jobId uint64) error {
// todo: 删除 Binlog 历史文件
if err := app.binlogRepo.DeleteById(ctx, jobId); err != nil {
return err
}
return nil
}

View File

@@ -2,11 +2,14 @@ package application
import (
"context"
"errors"
"gorm.io/gorm"
"mayfly-go/internal/db/dbm"
"mayfly-go/internal/db/dbm/dbi"
"mayfly-go/internal/db/domain/entity"
"mayfly-go/internal/db/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/model"
)
@@ -32,6 +35,10 @@ type Instance interface {
type instanceAppImpl struct {
base.AppImpl[*entity.DbInstance, repository.Instance]
dbApp Db `inject:"DbApp"`
backupApp *DbBackupApp `inject:"DbBackupApp"`
restoreApp *DbRestoreApp `inject:"DbRestoreApp"`
}
// 注入DbInstanceRepo
@@ -96,8 +103,50 @@ func (app *instanceAppImpl) Save(ctx context.Context, instanceEntity *entity.DbI
return app.UpdateById(ctx, instanceEntity)
}
func (app *instanceAppImpl) Delete(ctx context.Context, id uint64) error {
return app.DeleteById(ctx, id)
func (app *instanceAppImpl) Delete(ctx context.Context, instanceId uint64) error {
instance, err := app.GetById(new(entity.DbInstance), instanceId, "name")
biz.ErrIsNil(err, "获取数据库实例错误数据库实例ID为: %d", instance.Id)
restore := &entity.DbRestore{
DbInstanceId: instanceId,
}
err = app.restoreApp.restoreRepo.GetBy(restore)
switch {
case err == nil:
biz.ErrNotNil(err, "不能删除数据库实例【%s】请先删除关联的数据库恢复任务。", instance.Name)
case errors.Is(err, gorm.ErrRecordNotFound):
break
default:
biz.ErrIsNil(err, "删除数据库实例失败: %v", err)
}
backup := &entity.DbBackup{
DbInstanceId: instanceId,
}
err = app.backupApp.backupRepo.GetBy(backup)
switch {
case err == nil:
biz.ErrNotNil(err, "不能删除数据库实例【%s】请先删除关联的数据库备份任务。", instance.Name)
case errors.Is(err, gorm.ErrRecordNotFound):
break
default:
biz.ErrIsNil(err, "删除数据库实例失败: %v", err)
}
db := &entity.Db{
InstanceId: instanceId,
}
err = app.dbApp.GetBy(db)
switch {
case err == nil:
biz.ErrNotNil(err, "不能删除数据库实例【%s】请先删除关联的数据库资源。", instance.Name)
case errors.Is(err, gorm.ErrRecordNotFound):
break
default:
biz.ErrIsNil(err, "删除数据库实例失败: %v", err)
}
return app.DeleteById(ctx, instanceId)
}
func (app *instanceAppImpl) GetDatabases(ed *entity.DbInstance) ([]string, error) {

View File

@@ -55,7 +55,6 @@ func (app *DbRestoreApp) Update(ctx context.Context, job *entity.DbRestore) erro
}
func (app *DbRestoreApp) Delete(ctx context.Context, jobId uint64) error {
// todo: 删除数据库恢复历史文件
app.mutex.Lock()
defer app.mutex.Unlock()

View File

@@ -4,12 +4,14 @@ import (
"context"
"errors"
"fmt"
"golang.org/x/sync/singleflight"
"gorm.io/gorm"
"mayfly-go/internal/db/dbm/dbi"
"mayfly-go/internal/db/domain/entity"
"mayfly-go/internal/db/domain/repository"
"mayfly-go/pkg/runner"
"reflect"
"strconv"
"sync"
"time"
)
@@ -28,6 +30,7 @@ type dbScheduler struct {
restoreHistoryRepo repository.DbRestoreHistory `inject:"DbRestoreHistoryRepo"`
binlogRepo repository.DbBinlog `inject:"DbBinlogRepo"`
binlogHistoryRepo repository.DbBinlogHistory `inject:"DbBinlogHistoryRepo"`
sfGroup singleflight.Group
}
func newDbScheduler() *dbScheduler {
@@ -76,7 +79,6 @@ func (s *dbScheduler) AddJob(ctx context.Context, jobs any) error {
}
func (s *dbScheduler) RemoveJob(ctx context.Context, jobType entity.DbJobType, jobId uint64) error {
// todo: 删除数据库备份历史文件
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -110,12 +112,11 @@ func (s *dbScheduler) StartJobNow(ctx context.Context, job entity.DbJob) error {
return nil
}
func (s *dbScheduler) backup(ctx context.Context, dbProgram dbi.DbProgram, job entity.DbJob) error {
func (s *dbScheduler) backup(ctx context.Context, dbProgram dbi.DbProgram, backup *entity.DbBackup) error {
id, err := NewIncUUID()
if err != nil {
return err
}
backup := job.(*entity.DbBackup)
history := &entity.DbBackupHistory{
Uuid: id.String(),
DbBackupId: backup.Id,
@@ -143,45 +144,29 @@ func (s *dbScheduler) backup(ctx context.Context, dbProgram dbi.DbProgram, job e
return nil
}
func (s *dbScheduler) restore(ctx context.Context, dbProgram dbi.DbProgram, job entity.DbJob) error {
restore := job.(*entity.DbRestore)
func (s *dbScheduler) singleFlightFetchBinlog(ctx context.Context, dbProgram dbi.DbProgram, instanceId uint64, targetTime time.Time) error {
key := strconv.FormatUint(instanceId, 10)
for ctx.Err() == nil {
c := s.sfGroup.DoChan(key, func() (interface{}, error) {
if err := s.fetchBinlog(ctx, dbProgram, instanceId, true, targetTime); err != nil {
return targetTime, err
}
return targetTime, nil
})
select {
case res := <-c:
if targetTime.Compare(res.Val.(time.Time)) <= 0 {
return res.Err
}
case <-ctx.Done():
}
}
return ctx.Err()
}
func (s *dbScheduler) restore(ctx context.Context, dbProgram dbi.DbProgram, restore *entity.DbRestore) error {
if restore.PointInTime.Valid {
//if enabled, err := dbProgram.CheckBinlogEnabled(ctx); err != nil {
// return err
//} else if !enabled {
// return errors.New("数据库未启用 BINLOG")
//}
//if enabled, err := dbProgram.CheckBinlogRowFormat(ctx); err != nil {
// return err
//} else if !enabled {
// return errors.New("数据库未启用 BINLOG 行模式")
//}
//
//latestBinlogSequence, earliestBackupSequence := int64(-1), int64(-1)
//binlogHistory, ok, err := s.binlogHistoryRepo.GetLatestHistory(restore.DbInstanceId)
//if err != nil {
// return err
//}
//if ok {
// latestBinlogSequence = binlogHistory.Sequence
//} else {
// backupHistory, ok, err := s.backupHistoryRepo.GetEarliestHistory(restore.DbInstanceId)
// if err != nil {
// return err
// }
// if !ok {
// return nil
// }
// earliestBackupSequence = backupHistory.BinlogSequence
//}
//binlogFiles, err := dbProgram.FetchBinlogs(ctx, true, earliestBackupSequence, latestBinlogSequence)
//if err != nil {
// return err
//}
//if err := s.binlogHistoryRepo.InsertWithBinlogFiles(ctx, restore.DbInstanceId, binlogFiles); err != nil {
// return err
//}
if err := s.fetchBinlog(ctx, dbProgram, job.GetInstanceId(), true); err != nil {
if err := s.fetchBinlog(ctx, dbProgram, restore.DbInstanceId, true, restore.PointInTime.Time); err != nil {
return err
}
if err := s.restorePointInTime(ctx, dbProgram, restore); err != nil {
@@ -210,102 +195,68 @@ func (s *dbScheduler) restore(ctx context.Context, dbProgram dbi.DbProgram, job
return nil
}
//func (s *dbScheduler) updateLastStatus(ctx context.Context, job entity.DbJob) error {
// switch typ := job.GetJobType(); typ {
// case entity.DbJobTypeBackup:
// return s.backupRepo.UpdateLastStatus(ctx, job)
// case entity.DbJobTypeRestore:
// return s.restoreRepo.UpdateLastStatus(ctx, job)
// case entity.DbJobTypeBinlog:
// return s.binlogRepo.UpdateLastStatus(ctx, job)
// default:
// panic(fmt.Errorf("无效的数据库任务类型: %v", typ))
// }
//}
func (s *dbScheduler) updateJob(ctx context.Context, job entity.DbJob) error {
switch typ := job.GetJobType(); typ {
case entity.DbJobTypeBackup:
return s.backupRepo.UpdateById(ctx, job)
case entity.DbJobTypeRestore:
return s.restoreRepo.UpdateById(ctx, job)
case entity.DbJobTypeBinlog:
return s.binlogRepo.UpdateById(ctx, job)
switch t := job.(type) {
case *entity.DbBackup:
return s.backupRepo.UpdateById(ctx, t)
case *entity.DbRestore:
return s.restoreRepo.UpdateById(ctx, t)
case *entity.DbBinlog:
return s.binlogRepo.UpdateById(ctx, t)
default:
return fmt.Errorf("无效的数据库任务类型: %v", typ)
return fmt.Errorf("无效的数据库任务类型: %T", t)
}
}
func (s *dbScheduler) runJob(ctx context.Context, job entity.DbJob) error {
//job.SetLastStatus(entity.DbJobRunning, nil)
//if err := s.updateLastStatus(ctx, job); err != nil {
// logx.Errorf("failed to update job status: %v", err)
// return
//}
//var errRun error
conn, err := s.dbApp.GetDbConnByInstanceId(job.GetInstanceId())
if err != nil {
return err
}
dbProgram := conn.GetDialect().GetDbProgram()
switch typ := job.GetJobType(); typ {
case entity.DbJobTypeBackup:
return s.backup(ctx, dbProgram, job)
case entity.DbJobTypeRestore:
return s.restore(ctx, dbProgram, job)
case entity.DbJobTypeBinlog:
return s.fetchBinlog(ctx, dbProgram, job.GetInstanceId(), false)
default:
return fmt.Errorf("无效的数据库任务类型: %v", typ)
dbProgram, err := conn.GetDialect().GetDbProgram()
if err != nil {
return err
}
switch t := job.(type) {
case *entity.DbBackup:
return s.backup(ctx, dbProgram, t)
case *entity.DbRestore:
return s.restore(ctx, dbProgram, t)
case *entity.DbBinlog:
return s.fetchBinlog(ctx, dbProgram, t.DbInstanceId, false, time.Now())
default:
return fmt.Errorf("无效的数据库任务类型: %T", t)
}
//status := entity.DbJobSuccess
//if errRun != nil {
// status = entity.DbJobFailed
//}
//job.SetLastStatus(status, errRun)
//if err := s.updateLastStatus(ctx, job); err != nil {
// logx.Errorf("failed to update job status: %v", err)
// return
//}
}
func (s *dbScheduler) runnableJob(job entity.DbJob, next runner.NextJobFunc[entity.DbJob]) (bool, error) {
func (s *dbScheduler) runnableJob(job entity.DbJob, nextRunning runner.NextJobFunc[entity.DbJob]) (bool, error) {
if job.IsExpired() {
return false, runner.ErrJobExpired
}
const maxCountByInstanceId = 4
const maxCountByDbName = 1
var countByInstanceId, countByDbName int
for item, ok := next(); ok; item, ok = next() {
for item, ok := nextRunning(); ok; item, ok = nextRunning() {
if job.GetInstanceId() == item.GetInstanceId() {
countByInstanceId++
if countByInstanceId >= maxCountByInstanceId {
return false, nil
}
if relatedToBinlog(job.GetJobType()) {
// todo: 恢复数据库前触发 BINLOG 同步BINLOG 同步完成后才能恢复数据库
if relatedToBinlog(item.GetJobType()) {
return false, nil
}
}
if job.GetDbName() == item.GetDbName() {
countByDbName++
if countByDbName >= maxCountByDbName {
return false, nil
}
}
if (job.GetJobType() == entity.DbJobTypeBinlog && item.GetJobType() == entity.DbJobTypeRestore) ||
(job.GetJobType() == entity.DbJobTypeRestore && item.GetJobType() == entity.DbJobTypeBinlog) {
return false, nil
}
}
}
return true, nil
}
func relatedToBinlog(typ entity.DbJobType) bool {
return typ == entity.DbJobTypeRestore || typ == entity.DbJobTypeBinlog
}
func (s *dbScheduler) restorePointInTime(ctx context.Context, dbProgram dbi.DbProgram, job *entity.DbRestore) error {
binlogHistory, err := s.binlogHistoryRepo.GetHistoryByTime(job.DbInstanceId, job.PointInTime.Time)
if err != nil {
@@ -320,7 +271,7 @@ func (s *dbScheduler) restorePointInTime(ctx context.Context, dbProgram dbi.DbPr
Sequence: binlogHistory.Sequence,
Position: position,
}
backupHistory, err := s.backupHistoryRepo.GetLatestHistory(job.DbInstanceId, job.DbName, target)
backupHistory, err := s.backupHistoryRepo.GetLatestHistoryForBinlog(job.DbInstanceId, job.DbName, target)
if err != nil {
return err
}
@@ -364,6 +315,9 @@ func (s *dbScheduler) restorePointInTime(ctx context.Context, dbProgram dbi.DbPr
}
func (s *dbScheduler) restoreBackupHistory(ctx context.Context, program dbi.DbProgram, backupHistory *entity.DbBackupHistory) (retErr error) {
if _, err := s.backupHistoryRepo.UpdateRestoring(false, backupHistory.Id); err != nil {
return err
}
ok, err := s.backupHistoryRepo.UpdateRestoring(true, backupHistory.Id)
if err != nil {
return err
@@ -385,7 +339,7 @@ func (s *dbScheduler) restoreBackupHistory(ctx context.Context, program dbi.DbPr
return program.RestoreBackupHistory(ctx, backupHistory.DbName, backupHistory.DbBackupId, backupHistory.Uuid)
}
func (s *dbScheduler) fetchBinlog(ctx context.Context, dbProgram dbi.DbProgram, instanceId uint64, downloadLatestBinlogFile bool) error {
func (s *dbScheduler) fetchBinlog(ctx context.Context, dbProgram dbi.DbProgram, instanceId uint64, downloadLatestBinlogFile bool, targetTime time.Time) error {
if enabled, err := dbProgram.CheckBinlogEnabled(ctx); err != nil {
return err
} else if !enabled {
@@ -397,15 +351,17 @@ func (s *dbScheduler) fetchBinlog(ctx context.Context, dbProgram dbi.DbProgram,
return errors.New("数据库未启用 BINLOG 行模式")
}
latestBinlogSequence, earliestBackupSequence := int64(-1), int64(-1)
earliestBackupSequence := int64(-1)
binlogHistory, ok, err := s.binlogHistoryRepo.GetLatestHistory(instanceId)
if err != nil {
return err
}
if ok {
latestBinlogSequence = binlogHistory.Sequence
} else {
backupHistory, ok, err := s.backupHistoryRepo.GetEarliestHistory(instanceId)
if downloadLatestBinlogFile && targetTime.Before(binlogHistory.LastEventTime) {
return nil
}
if !ok {
backupHistory, ok, err := s.backupHistoryRepo.GetEarliestHistoryForBinlog(instanceId)
if err != nil {
return err
}
@@ -414,7 +370,9 @@ func (s *dbScheduler) fetchBinlog(ctx context.Context, dbProgram dbi.DbProgram,
}
earliestBackupSequence = backupHistory.BinlogSequence
}
binlogFiles, err := dbProgram.FetchBinlogs(ctx, downloadLatestBinlogFile, earliestBackupSequence, latestBinlogSequence)
// todo: 将循环从 dbProgram.FetchBinlogs 中提取出来,实现 BINLOG 同步成功后逐一保存 binlogHistory
binlogFiles, err := dbProgram.FetchBinlogs(ctx, downloadLatestBinlogFile, earliestBackupSequence, binlogHistory)
if err != nil {
return err
}

View File

@@ -13,7 +13,7 @@ type DbProgram interface {
Backup(ctx context.Context, backupHistory *entity.DbBackupHistory) (*entity.BinlogInfo, error)
FetchBinlogs(ctx context.Context, downloadLatestBinlogFile bool, earliestBackupSequence, latestBinlogSequence int64) ([]*entity.BinlogFile, error)
FetchBinlogs(ctx context.Context, downloadLatestBinlogFile bool, earliestBackupSequence int64, latestBinlogHistory *entity.DbBinlogHistory) ([]*entity.BinlogFile, error)
ReplayBinlog(ctx context.Context, originalDatabase, targetDatabase string, restoreInfo *RestoreInfo) error
@@ -22,6 +22,8 @@ type DbProgram interface {
RemoveBackupHistory(ctx context.Context, dbBackupId uint64, dbBackupHistoryUuid string) error
GetBinlogEventPositionAtOrAfterTime(ctx context.Context, binlogName string, targetTime time.Time) (position int64, parseErr error)
PruneBinlog(history *entity.DbBinlogHistory) error
}
type RestoreInfo struct {

View File

@@ -11,14 +11,16 @@ import (
type DbType string
const (
DbTypeMysql DbType = "mysql"
DbTypeMariadb DbType = "mariadb"
DbTypePostgres DbType = "postgres"
DbTypeGauss DbType = "gauss"
DbTypeDM DbType = "dm"
DbTypeOracle DbType = "oracle"
DbTypeSqlite DbType = "sqlite"
DbTypeMssql DbType = "mssql"
DbTypeMysql DbType = "mysql"
DbTypeMariadb DbType = "mariadb"
DbTypePostgres DbType = "postgres"
DbTypeGauss DbType = "gauss"
DbTypeDM DbType = "dm"
DbTypeOracle DbType = "oracle"
DbTypeSqlite DbType = "sqlite"
DbTypeMssql DbType = "mssql"
DbTypeKingbaseEs DbType = "kingbaseEs"
DbTypeVastbase DbType = "vastbase"
)
func ToDbType(dbType string) DbType {
@@ -44,7 +46,7 @@ func (dbType DbType) QuoteIdentifier(name string) string {
switch dbType {
case DbTypeMysql, DbTypeMariadb:
return quoteIdentifier(name, "`")
case DbTypePostgres, DbTypeGauss:
case DbTypePostgres, DbTypeGauss, DbTypeKingbaseEs, DbTypeVastbase:
return quoteIdentifier(name, `"`)
case DbTypeMssql:
return fmt.Sprintf("[%s]", name)
@@ -57,7 +59,7 @@ func (dbType DbType) RemoveQuote(name string) string {
switch dbType {
case DbTypeMysql, DbTypeMariadb:
return removeQuote(name, "`")
case DbTypePostgres, DbTypeGauss:
case DbTypePostgres, DbTypeGauss, DbTypeKingbaseEs, DbTypeVastbase:
return removeQuote(name, `"`)
default:
return removeQuote(name, `"`)
@@ -70,7 +72,7 @@ func (dbType DbType) QuoteLiteral(literal string) string {
literal = strings.ReplaceAll(literal, `\`, `\\`)
literal = strings.ReplaceAll(literal, `'`, `''`)
return "'" + literal + "'"
case DbTypePostgres, DbTypeGauss:
case DbTypePostgres, DbTypeGauss, DbTypeKingbaseEs, DbTypeVastbase:
return pq.QuoteLiteral(literal)
default:
return pq.QuoteLiteral(literal)
@@ -85,6 +87,10 @@ func (dbType DbType) MetaDbName() string {
return "postgres"
case DbTypeDM:
return ""
case DbTypeKingbaseEs:
return "security"
case DbTypeVastbase:
return "vastbase"
default:
return ""
}
@@ -94,7 +100,7 @@ func (dbType DbType) Dialect() sqlparser.Dialect {
switch dbType {
case DbTypeMysql, DbTypeMariadb:
return sqlparser.MysqlDialect{}
case DbTypePostgres, DbTypeGauss:
case DbTypePostgres, DbTypeGauss, DbTypeKingbaseEs, DbTypeVastbase:
return sqlparser.PostgresDialect{}
default:
return sqlparser.PostgresDialect{}
@@ -122,7 +128,7 @@ func (dbType DbType) StmtSetForeignKeyChecks(check bool) string {
} else {
return "SET FOREIGN_KEY_CHECKS = 0;\n"
}
case DbTypePostgres, DbTypeGauss:
case DbTypePostgres, DbTypeGauss, DbTypeKingbaseEs, DbTypeVastbase:
// not currently supported postgres
return ""
default:
@@ -134,10 +140,19 @@ func (dbType DbType) StmtUseDatabase(dbName string) string {
switch dbType {
case DbTypeMysql, DbTypeMariadb:
return fmt.Sprintf("USE %s;\n", dbType.QuoteIdentifier(dbName))
case DbTypePostgres, DbTypeGauss:
case DbTypePostgres, DbTypeGauss, DbTypeKingbaseEs, DbTypeVastbase:
// not currently supported postgres
return ""
default:
return ""
}
}
func (dbType DbType) SupportingBackup() bool {
switch dbType {
case DbTypeMysql, DbTypeMariadb:
return true
default:
return false
}
}

View File

@@ -108,7 +108,7 @@ type Dialect interface {
GetSchemas() ([]string, error)
// GetDbProgram 获取数据库程序模块,用于数据库备份与恢复
GetDbProgram() DbProgram
GetDbProgram() (DbProgram, error)
// 批量保存数据
BatchInsert(tx *sql.Tx, tableName string, columns []string, values [][]any) (int64, error)

View File

@@ -1,8 +1,6 @@
--MSSQL_DBS
SELECT name AS dbname
FROM sys.databases
WHERE owner_sid = SUSER_SID()
and name not in ('master', 'tempdb', 'model', 'msdb')
---------------------------------------
--MSSQL_TABLE_DETAIL 查询表名和表注释
SELECT t.name AS tableName,

View File

@@ -1,7 +1,7 @@
--ORACLE_DB_SCHEMAS schemas
select distinct owner as SCHEMA_NAME
from all_objects
order by owner
select USERNAME
from sys.all_users
order by USERNAME
---------------------------------------
--ORACLE_TABLE_INFO 表详细信息
select a.TABLE_NAME,
@@ -10,9 +10,9 @@ select a.TABLE_NAME,
d.BYTES as DATA_LENGTH,
0 as INDEX_LENGTH,
a.NUM_ROWS as TABLE_ROWS
from all_tables a
from ALL_TABLES a
left join ALL_TAB_COMMENTS b on b.TABLE_NAME = a.TABLE_NAME AND b.OWNER = a.OWNER
left join all_objects c on c.OBJECT_TYPE = 'TABLE' AND c.OWNER = a.OWNER AND c.OBJECT_NAME = a.TABLE_NAME
left join ALL_OBJECTS c on c.OBJECT_TYPE = 'TABLE' AND c.OWNER = a.OWNER AND c.OBJECT_NAME = a.TABLE_NAME
left join dba_segments d on d.SEGMENT_TYPE = 'TABLE' AND d.OWNER = a.OWNER AND d.SEGMENT_NAME = a.TABLE_NAME
where a.owner = (SELECT sys_context('USERENV', 'CURRENT_SCHEMA') FROM dual)
ORDER BY a.TABLE_NAME
@@ -55,12 +55,12 @@ SELECT a.TABLE_NAME as TABLE_NAME,
a.DATA_SCALE as NUM_SCALE,
CASE WHEN d.pri IS NOT NULL THEN 1 ELSE 0 END as IS_PRIMARY_KEY,
CASE WHEN a.IDENTITY_COLUMN = 'YES' THEN 1 ELSE 0 END as IS_IDENTITY
FROM all_tab_columns a
LEFT JOIN all_col_comments b
FROM ALL_TAB_COLUMNS a
LEFT JOIN ALL_COL_COMMENTS b
on a.OWNER = b.OWNER AND a.TABLE_NAME = b.TABLE_NAME AND a.COLUMN_NAME = b.COLUMN_NAME
LEFT JOIN (select ac.TABLE_NAME, ac.OWNER, cc.COLUMN_NAME, 1 as pri
from all_constraints ac
join all_cons_columns cc on cc.CONSTRAINT_NAME = ac.CONSTRAINT_NAME AND cc.OWNER = ac.OWNER
from ALL_CONSTRAINTS ac
join ALL_CONS_COLUMNS cc on cc.CONSTRAINT_NAME = ac.CONSTRAINT_NAME AND cc.OWNER = ac.OWNER
where cc.CONSTRAINT_NAME IS NOT NULL
AND ac.CONSTRAINT_TYPE = 'P') d
on d.OWNER = a.OWNER AND d.TABLE_NAME = a.TABLE_NAME AND d.COLUMN_NAME = a.COLUMN_NAME

View File

@@ -248,8 +248,8 @@ func (dd *DMDialect) GetSchemas() ([]string, error) {
}
// GetDbProgram 获取数据库程序模块,用于数据库备份与恢复
func (dd *DMDialect) GetDbProgram() dbi.DbProgram {
panic("implement me")
func (dd *DMDialect) GetDbProgram() (dbi.DbProgram, error) {
return nil, fmt.Errorf("该数据库类型不支持数据库备份与恢复: %v", dd.dc.Info.Type)
}
var (

View File

@@ -285,8 +285,8 @@ func (md *MssqlDialect) GetSchemas() ([]string, error) {
}
// GetDbProgram 获取数据库程序模块,用于数据库备份与恢复
func (md *MssqlDialect) GetDbProgram() dbi.DbProgram {
panic("implement me")
func (md *MssqlDialect) GetDbProgram() (dbi.DbProgram, error) {
return nil, fmt.Errorf("该数据库类型不支持数据库备份与恢复: %v", md.dc.Info.Type)
}
func (md *MssqlDialect) BatchInsert(tx *sql.Tx, tableName string, columns []string, values [][]any) (int64, error) {

View File

@@ -38,8 +38,17 @@ func (md *Meta) GetSqlDb(d *dbi.DbInfo) (*sql.DB, error) {
query.Add("database", d.Database)
}
}
params := query.Encode()
if d.Params != "" {
if !strings.HasPrefix(d.Params, "&") {
params = params + "&" + d.Params
} else {
params = params + d.Params
}
}
const driverName = "mssql"
dsn := fmt.Sprintf("sqlserver://%s:%s@%s:%d?%s", url.PathEscape(d.Username), url.PathEscape(d.Password), d.Host, d.Port, query.Encode())
dsn := fmt.Sprintf("sqlserver://%s:%s@%s:%d?%s", url.PathEscape(d.Username), url.PathEscape(d.Password), d.Host, d.Port, params)
return sql.Open(driverName, dsn)
}

View File

@@ -169,8 +169,8 @@ func (md *MysqlDialect) GetSchemas() ([]string, error) {
}
// GetDbProgram 获取数据库程序模块,用于数据库备份与恢复
func (md *MysqlDialect) GetDbProgram() dbi.DbProgram {
return NewDbProgramMysql(md.dc)
func (md *MysqlDialect) GetDbProgram() (dbi.DbProgram, error) {
return NewDbProgramMysql(md.dc), nil
}
func (md *MysqlDialect) BatchInsert(tx *sql.Tx, tableName string, columns []string, values [][]any) (int64, error) {

View File

@@ -2,6 +2,7 @@ package mysql
import (
"bufio"
"compress/gzip"
"context"
"database/sql"
"fmt"
@@ -130,22 +131,46 @@ func (svc *DbProgramMysql) Backup(ctx context.Context, backupHistory *entity.DbB
if binlogEnabled && rowFormatEnabled {
binlogInfo, err = readBinlogInfoFromBackup(reader)
}
_ = reader.Close()
if err != nil {
_ = reader.Close()
return nil, errors.Wrapf(err, "从备份文件中读取 binlog 信息失败")
}
fileName := filepath.Join(dir, fmt.Sprintf("%s.sql", backupHistory.Uuid))
if err := os.Rename(tmpFile, fileName); err != nil {
return nil, errors.Wrap(err, "备份文件改名失败")
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
_ = reader.Close()
return nil, errors.Wrapf(err, "跳转到备份文件开始处失败")
}
gzipTmpFile := tmpFile + ".gz"
writer, err := os.Create(gzipTmpFile)
if err != nil {
_ = reader.Close()
return nil, errors.Wrapf(err, "创建备份压缩文件失败")
}
defer func() {
_ = os.Remove(gzipTmpFile)
}()
gzipWriter := gzip.NewWriter(writer)
gzipWriter.Name = backupHistory.Uuid + ".sql"
_, err = io.Copy(gzipWriter, reader)
_ = gzipWriter.Close()
_ = writer.Close()
_ = reader.Close()
if err != nil {
return nil, errors.Wrapf(err, "压缩备份文件失败")
}
destPath := filepath.Join(dir, backupHistory.Uuid+".sql")
if err := os.Rename(gzipTmpFile, destPath+".gz"); err != nil {
return nil, errors.Wrap(err, "备份文件更名失败")
}
return binlogInfo, nil
}
func (svc *DbProgramMysql) RemoveBackupHistory(_ context.Context, dbBackupId uint64, dbBackupHistoryUuid string) error {
fileName := filepath.Join(svc.getDbBackupDir(svc.dbInfo().InstanceId, dbBackupId),
fmt.Sprintf("%v.sql", dbBackupHistoryUuid))
return os.Remove(fileName)
_ = os.Remove(fileName)
_ = os.Remove(fileName + ".gz")
return nil
}
func (svc *DbProgramMysql) RestoreBackupHistory(ctx context.Context, dbName string, dbBackupId uint64, dbBackupHistoryUuid string) error {
@@ -158,18 +183,33 @@ func (svc *DbProgramMysql) RestoreBackupHistory(ctx context.Context, dbName stri
"--password=" + dbInfo.Password,
}
compressed := false
fileName := filepath.Join(svc.getDbBackupDir(svc.dbInfo().InstanceId, dbBackupId),
fmt.Sprintf("%v.sql", dbBackupHistoryUuid))
_, err := os.Stat(fileName)
if err != nil {
compressed = true
fileName += ".gz"
}
file, err := os.Open(fileName)
if err != nil {
return errors.Wrap(err, "打开备份文件失败")
}
defer func() {
_ = file.Close()
}()
defer func() { _ = file.Close() }()
var reader io.ReadCloser
if compressed {
reader, err = gzip.NewReader(file)
if err != nil {
return errors.Wrap(err, "解压缩备份文件失败")
}
defer func() { _ = reader.Close() }()
} else {
reader = file
}
cmd := exec.CommandContext(ctx, svc.getMysqlBin().MysqlPath, args...)
cmd.Stdin = file
cmd.Stdin = reader
logx.Debug("恢复数据库: ", cmd.String())
if err := runCmd(cmd); err != nil {
logx.Errorf("运行 mysql 程序失败: %v", err)
@@ -205,13 +245,17 @@ func (svc *DbProgramMysql) downloadBinlogFilesOnServer(ctx context.Context, binl
}
// Parse the first binlog eventTs of a local binlog file.
func (svc *DbProgramMysql) parseLocalBinlogLastEventTime(ctx context.Context, filePath string) (eventTime time.Time, parseErr error) {
// todo: implement me
return time.Now(), nil
func (svc *DbProgramMysql) parseLocalBinlogLastEventTime(ctx context.Context, filePath string, lastEventTime time.Time) (eventTime time.Time, parseErr error) {
return svc.parseLocalBinlogEventTime(ctx, filePath, false, lastEventTime)
}
// Parse the first binlog eventTs of a local binlog file.
func (svc *DbProgramMysql) parseLocalBinlogFirstEventTime(ctx context.Context, filePath string) (eventTime time.Time, parseErr error) {
return svc.parseLocalBinlogEventTime(ctx, filePath, true, time.Time{})
}
// Parse the first binlog eventTs of a local binlog file.
func (svc *DbProgramMysql) parseLocalBinlogEventTime(ctx context.Context, filePath string, firstOrLast bool, startTime time.Time) (eventTime time.Time, parseErr error) {
args := []string{
// Local binlog file path.
filePath,
@@ -220,6 +264,9 @@ func (svc *DbProgramMysql) parseLocalBinlogFirstEventTime(ctx context.Context, f
// Tell mysqlbinlog to suppress the BINLOG statements for row events, which reduces the unneeded output.
"--base64-output=DECODE-ROWS",
}
if !startTime.IsZero() {
args = append(args, "--start-datetime", startTime.Local().Format(time.DateTime))
}
cmd := exec.CommandContext(ctx, svc.getMysqlBin().MysqlbinlogPath, args...)
var stderr strings.Builder
cmd.Stderr = &stderr
@@ -237,22 +284,30 @@ func (svc *DbProgramMysql) parseLocalBinlogFirstEventTime(ctx context.Context, f
parseErr = errors.Wrap(parseErr, stderr.String())
}
}()
lastEventTime := time.Time{}
for s := bufio.NewScanner(pr); s.Scan(); {
line := s.Text()
eventTimeParsed, found, err := parseBinlogEventTimeInLine(line)
if err != nil {
return time.Time{}, errors.Wrap(err, "解析 binlog 文件失败")
}
if found {
return eventTimeParsed, nil
if !found {
continue
}
if !firstOrLast {
lastEventTime = eventTimeParsed
continue
}
return eventTimeParsed, nil
}
return time.Time{}, errors.New("解析 binlog 文件失败")
if lastEventTime.IsZero() {
return time.Time{}, errors.New("解析 binlog 文件失败")
}
return lastEventTime, nil
}
// FetchBinlogs downloads binlog files from startingFileName on server to `binlogDir`.
func (svc *DbProgramMysql) FetchBinlogs(ctx context.Context, downloadLatestBinlogFile bool, earliestBackupSequence, latestBinlogSequence int64) ([]*entity.BinlogFile, error) {
func (svc *DbProgramMysql) FetchBinlogs(ctx context.Context, downloadLatestBinlogFile bool, earliestBackupSequence int64, latestBinlogHistory *entity.DbBinlogHistory) ([]*entity.BinlogFile, error) {
// Read binlog files list on server.
binlogFilesOnServerSorted, err := svc.GetSortedBinlogFilesOnServer(ctx)
if err != nil {
@@ -264,8 +319,11 @@ func (svc *DbProgramMysql) FetchBinlogs(ctx context.Context, downloadLatestBinlo
}
indexHistory := -1
for i, file := range binlogFilesOnServerSorted {
if latestBinlogSequence == file.Sequence {
if latestBinlogHistory.Sequence == file.Sequence {
indexHistory = i + 1
file.FirstEventTime = latestBinlogHistory.FirstEventTime
file.LastEventTime = latestBinlogHistory.LastEventTime
file.LocalSize = latestBinlogHistory.FileSize
break
}
if earliestBackupSequence == file.Sequence {
@@ -274,10 +332,15 @@ func (svc *DbProgramMysql) FetchBinlogs(ctx context.Context, downloadLatestBinlo
}
}
if indexHistory < 0 {
return nil, errors.New(fmt.Sprintf("在数据库服务器上未找到 binlog 文件: %d, %d", earliestBackupSequence, latestBinlogSequence))
// todo: 数据库服务器上 binlog 序列已被删除, 导致 binlog 同步失败,如何处理?
return nil, errors.New(fmt.Sprintf("数据库服务器上的 binlog 序列已被删除: %d, %d", earliestBackupSequence, latestBinlogHistory.Sequence))
}
if indexHistory > len(binlogFilesOnServerSorted)-1 {
if indexHistory >= len(binlogFilesOnServerSorted)-1 {
indexHistory = len(binlogFilesOnServerSorted) - 1
if binlogFilesOnServerSorted[indexHistory].LocalSize == binlogFilesOnServerSorted[indexHistory].RemoteSize {
// 没有新的事件,不需要重新下载
return nil, nil
}
}
binlogFilesOnServerSorted = binlogFilesOnServerSorted[indexHistory:]
@@ -331,13 +394,14 @@ func (svc *DbProgramMysql) downloadBinlogFile(ctx context.Context, binlogFileToD
logx.Error("未找到 binlog 文件", logx.String("path", binlogFilePathTemp), logx.String("error", err.Error()))
return errors.Wrapf(err, "未找到 binlog 文件: %q", binlogFilePathTemp)
}
if !isLast && binlogFileTempInfo.Size() != binlogFileToDownload.Size {
if (isLast && binlogFileTempInfo.Size() < binlogFileToDownload.RemoteSize) || (!isLast && binlogFileTempInfo.Size() != binlogFileToDownload.RemoteSize) {
logx.Error("Downloaded archived binlog file size is not equal to size queried on the MySQL server earlier.",
logx.String("binlog", binlogFileToDownload.Name),
logx.Int64("sizeInfo", binlogFileToDownload.Size),
logx.Int64("sizeInfo", binlogFileToDownload.RemoteSize),
logx.Int64("downloadedSize", binlogFileTempInfo.Size()),
)
return errors.Errorf("下载的 binlog 文件 %q 与服务上的文件大小不一致 %d != %d", binlogFilePathTemp, binlogFileTempInfo.Size(), binlogFileToDownload.Size)
return errors.Errorf("下载的 binlog 文件 %q 与服务上的文件大小不一致 %d != %d", binlogFilePathTemp, binlogFileTempInfo.Size(), binlogFileToDownload.RemoteSize)
}
binlogFilePath := svc.GetBinlogFilePath(binlogFileToDownload.Name)
@@ -348,7 +412,7 @@ func (svc *DbProgramMysql) downloadBinlogFile(ctx context.Context, binlogFileToD
if err != nil {
return err
}
lastEventTime, err := svc.parseLocalBinlogLastEventTime(ctx, binlogFilePath)
lastEventTime, err := svc.parseLocalBinlogLastEventTime(ctx, binlogFilePath, binlogFileToDownload.LastEventTime)
if err != nil {
return err
}
@@ -394,9 +458,9 @@ func (svc *DbProgramMysql) GetSortedBinlogFilesOnServer(_ context.Context) ([]*e
return nil, errors.Wrapf(err, "SQL 语句 %q 执行结果解析失败", query)
}
binlogFile := &entity.BinlogFile{
Name: name,
Size: int64(size),
Sequence: seq,
Name: name,
RemoteSize: int64(size),
Sequence: seq,
}
binlogFiles = append(binlogFiles, binlogFile)
}
@@ -781,3 +845,9 @@ func (svc *DbProgramMysql) getDbBackupDir(instanceId, backupId uint64) string {
fmt.Sprintf("instance-%d", instanceId),
fmt.Sprintf("backup-%d", backupId))
}
func (svc *DbProgramMysql) PruneBinlog(history *entity.DbBinlogHistory) error {
binlogFilePath := filepath.Join(svc.getBinlogDir(history.DbInstanceId), history.FileName)
_ = os.Remove(binlogFilePath)
return nil
}

View File

@@ -47,11 +47,11 @@ func (s *DbInstanceSuite) SetupSuite() {
Username: "test",
Password: "test",
}
dbConn, err := dbInfo.Conn(GetMeta())
dbConn, err := dbInfo.Conn(dbi.GetMeta(dbi.DbTypeMysql))
s.Require().NoError(err)
s.dbConn = dbConn
s.repositories = &repository.Repositories{
Instance: persistence.GetInstanceRepo(),
Instance: persistence.NewInstanceRepo(),
Backup: persistence.NewDbBackupRepo(),
BackupHistory: persistence.NewDbBackupHistoryRepo(),
Restore: persistence.NewDbRestoreRepo(),
@@ -111,7 +111,7 @@ func (s *DbInstanceSuite) testBackup(backupHistory *entity.DbBackupHistory) {
binlogInfo, err := s.instanceSvc.Backup(context.Background(), backupHistory)
require.NoError(err)
fileName := filepath.Join(s.instanceSvc.getDbBackupDir(s.dbConn.Info.InstanceId, backupHistory.Id), dbNameBackupTest+".sql")
fileName := filepath.Join(s.instanceSvc.getDbBackupDir(s.dbConn.Info.InstanceId, backupHistory.Id), dbNameBackupTest+".sql.gz")
_, err = os.Stat(fileName)
require.NoError(err)

View File

@@ -242,14 +242,14 @@ func (od *OracleDialect) GetSchemas() ([]string, error) {
}
schemaNames := make([]string, 0)
for _, re := range res {
schemaNames = append(schemaNames, anyx.ConvString(re["SCHEMA_NAME"]))
schemaNames = append(schemaNames, anyx.ConvString(re["USERNAME"]))
}
return schemaNames, nil
}
// GetDbProgram 获取数据库程序模块,用于数据库备份与恢复
func (od *OracleDialect) GetDbProgram() dbi.DbProgram {
panic("implement me")
func (od *OracleDialect) GetDbProgram() (dbi.DbProgram, error) {
return nil, fmt.Errorf("该数据库类型不支持数据库备份与恢复: %v", od.dc.Info.Type)
}
func (od *OracleDialect) BatchInsert(tx *sql.Tx, tableName string, columns []string, values [][]any) (int64, error) {

View File

@@ -26,7 +26,7 @@ type PgsqlDialect struct {
}
func (md *PgsqlDialect) GetDbServer() (*dbi.DbServer, error) {
_, res, err := md.dc.Query("SHOW server_version")
_, res, err := md.dc.Query("SELECT version() as server_version")
if err != nil {
return nil, err
}
@@ -188,8 +188,8 @@ func (md *PgsqlDialect) GetSchemas() ([]string, error) {
}
// GetDbProgram 获取数据库程序模块,用于数据库备份与恢复
func (md *PgsqlDialect) GetDbProgram() dbi.DbProgram {
panic("implement me")
func (md *PgsqlDialect) GetDbProgram() (dbi.DbProgram, error) {
return nil, fmt.Errorf("该数据库类型不支持数据库备份与恢复: %v", md.dc.Info.Type)
}
func (md *PgsqlDialect) BatchInsert(tx *sql.Tx, tableName string, columns []string, values [][]any) (int64, error) {

View File

@@ -15,7 +15,10 @@ import (
)
func init() {
dbi.Register(dbi.DbTypePostgres, new(PostgresMeta))
meta := new(PostgresMeta)
dbi.Register(dbi.DbTypePostgres, meta)
dbi.Register(dbi.DbTypeKingbaseEs, meta)
dbi.Register(dbi.DbTypeVastbase, meta)
gauss := new(PostgresMeta)
gauss.Param = "dbtype=gauss"
@@ -40,16 +43,17 @@ func (md *PostgresMeta) GetSqlDb(d *dbi.DbInfo) (*sql.DB, error) {
db := d.Database
var dbParam string
exsitSchema := false
if 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
}
existSchema := false
if db == "" {
db = d.Type.MetaDbName()
}
// postgres database可以使用db/schema表示方便连接指定schema, 若不存在schema则使用默认schema
ss := strings.Split(db, "/")
if len(ss) > 1 {
existSchema = 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 connect_timeout=8", d.Host, d.Port, d.Username, d.Password, dbParam)
@@ -62,7 +66,7 @@ func (md *PostgresMeta) GetSqlDb(d *dbi.DbInfo) (*sql.DB, error) {
if strings.HasPrefix(param, "dbname=") {
return true
}
if exsitSchema && strings.HasPrefix(param, "search_path") {
if existSchema && strings.HasPrefix(param, "search_path") {
return true
}
return false

View File

@@ -180,8 +180,8 @@ func (sd *SqliteDialect) GetSchemas() ([]string, error) {
}
// GetDbProgram 获取数据库程序模块,用于数据库备份与恢复
func (sd *SqliteDialect) GetDbProgram() dbi.DbProgram {
panic("implement me")
func (sd *SqliteDialect) GetDbProgram() (dbi.DbProgram, error) {
return nil, fmt.Errorf("该数据库类型不支持数据库备份与恢复: %v", sd.dc.Info.Type)
}
func (sd *SqliteDialect) BatchInsert(tx *sql.Tx, tableName string, columns []string, values [][]any) (int64, error) {

View File

@@ -18,6 +18,7 @@ type DbBackup struct {
EnabledDesc string // 启用状态描述
StartTime time.Time // 开始时间
Interval time.Duration // 间隔时间
MaxSaveDays int // 数据库备份历史保留天数,过期将自动删除
Repeated bool // 是否重复执行
}
@@ -81,10 +82,6 @@ func (b *DbBackup) GetInterval() time.Duration {
return b.Interval
}
func (b *DbBackup) SetLastStatus(status DbJobStatus, err error) {
b.setLastStatus(b.GetJobType(), status, err)
}
func (b *DbBackup) GetKey() DbJobKey {
return b.getKey(b.GetJobType())
}

View File

@@ -11,14 +11,16 @@ const (
// BinlogFile is the metadata of the MySQL binlog file.
type BinlogFile struct {
Name string
Size int64
Name string
RemoteSize int64
LocalSize int64
// Sequence is parsed from Name and is for the sorting purpose.
Sequence int64
FirstEventTime time.Time
LastEventTime time.Time
Downloaded bool
Downloaded bool
}
var _ DbJob = (*DbBinlog)(nil)
@@ -76,10 +78,6 @@ func (b *DbBinlog) GetJobType() DbJobType {
return DbJobTypeBinlog
}
func (b *DbBinlog) SetLastStatus(status DbJobStatus, err error) {
b.setLastStatus(b.GetJobType(), status, err)
}
func (b *DbBinlog) GetKey() DbJobKey {
return b.getKey(b.GetJobType())
}

View File

@@ -14,6 +14,7 @@ type DbBinlogHistory struct {
FileSize int64
Sequence int64
FirstEventTime time.Time
LastEventTime time.Time
DbInstanceId uint64 `json:"dbInstanceId"`
}

View File

@@ -45,7 +45,6 @@ var _ runner.Job = (DbJob)(nil)
type DbJobBase interface {
model.ModelI
GetLastStatus() DbJobStatus
}
type DbJob interface {
@@ -62,7 +61,6 @@ type DbJob interface {
SetEnabled(enabled bool, desc string)
Update(job runner.Job)
GetInterval() time.Duration
SetLastStatus(status DbJobStatus, err error)
}
var _ DbJobBase = (*DbJobBaseImpl)(nil)
@@ -84,10 +82,6 @@ func (d *DbJobBaseImpl) getJobType() DbJobType {
return job.GetJobType()
}
func (d *DbJobBaseImpl) GetLastStatus() DbJobStatus {
return d.LastStatus
}
func (d *DbJobBaseImpl) setLastStatus(jobType DbJobType, status DbJobStatus, err error) {
var statusName, jobName string
switch status {

View File

@@ -79,10 +79,6 @@ func (r *DbRestore) GetJobType() DbJobType {
return DbJobTypeRestore
}
func (r *DbRestore) SetLastStatus(status DbJobStatus, err error) {
r.setLastStatus(r.GetJobType(), status, err)
}
func (r *DbRestore) GetKey() DbJobKey {
return r.getKey(r.GetJobType())
}

View File

@@ -6,7 +6,7 @@ import (
)
type DbBackup interface {
DbJob
DbJob[*entity.DbBackup]
ListToDo(jobs any) error
ListDbInstances(enabled bool, repeated bool, instanceIds *[]uint64) error
@@ -14,4 +14,6 @@ type DbBackup interface {
// GetPageList 分页获取数据库任务列表
GetPageList(condition *entity.DbBackupQuery, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error)
ListByCond(cond any, listModels any, cols ...string) error
}

View File

@@ -12,12 +12,13 @@ type DbBackupHistory interface {
// GetPageList 分页获取数据备份历史
GetPageList(condition *entity.DbBackupHistoryQuery, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error)
GetLatestHistory(instanceId uint64, dbName string, bi *entity.BinlogInfo) (*entity.DbBackupHistory, error)
GetLatestHistoryForBinlog(instanceId uint64, dbName string, bi *entity.BinlogInfo) (*entity.DbBackupHistory, error)
GetEarliestHistory(instanceId uint64) (*entity.DbBackupHistory, bool, error)
GetEarliestHistoryForBinlog(instanceId uint64) (*entity.DbBackupHistory, bool, error)
GetHistories(backupHistoryIds []uint64, toEntity any) error
UpdateDeleting(deleting bool, backupHistoryId ...uint64) (bool, error)
UpdateRestoring(restoring bool, backupHistoryId ...uint64) (bool, error)
ZeroBinlogInfo(backupHistoryId uint64) error
}

View File

@@ -6,7 +6,7 @@ import (
)
type DbBinlog interface {
DbJob
DbJob[*entity.DbBinlog]
AddJobIfNotExists(ctx context.Context, job *entity.DbBinlog) error
}

View File

@@ -19,4 +19,6 @@ type DbBinlogHistory interface {
InsertWithBinlogFiles(ctx context.Context, instanceId uint64, binlogFiles []*entity.BinlogFile) error
Upsert(ctx context.Context, history *entity.DbBinlogHistory) error
GetHistoriesBeforeSequence(ctx context.Context, instanceId uint64, binlogSeq int64, histories *[]*entity.DbBinlogHistory) error
}

View File

@@ -3,24 +3,18 @@ package repository
import (
"context"
"mayfly-go/internal/db/domain/entity"
"mayfly-go/pkg/base"
)
type DbJobBase interface {
// GetById 根据实体id查询
GetById(e entity.DbJob, id uint64, cols ...string) error
// UpdateById 根据实体id更新实体信息
UpdateById(ctx context.Context, e entity.DbJob, columns ...string) error
// DeleteById 根据实体主键删除实体
DeleteById(ctx context.Context, id uint64) error
type DbJobBase[T entity.DbJob] interface {
base.Repo[T]
// UpdateLastStatus 更新任务执行状态
UpdateLastStatus(ctx context.Context, job entity.DbJob) error
}
type DbJob interface {
DbJobBase
type DbJob[T entity.DbJob] interface {
DbJobBase[T]
// AddJob 添加数据库任务
AddJob(ctx context.Context, jobs any) error

View File

@@ -6,7 +6,7 @@ import (
)
type DbRestore interface {
DbJob
DbJob[*entity.DbRestore]
ListToDo(jobs any) error
GetDbNamesWithoutRestore(instanceId uint64, dbNames []string) ([]string, error)

View File

@@ -64,7 +64,6 @@ func (d *dbBackupRepoImpl) ListToDo(jobs any) error {
// GetPageList 分页获取数据库备份任务列表
func (d *dbBackupRepoImpl) GetPageList(condition *entity.DbBackupQuery, pageParam *model.PageParam, toEntity any, _ ...string) (*model.PageResult[any], error) {
d.GetModel()
qd := gormx.NewQuery(d.GetModel()).
Eq("id", condition.Id).
Eq0("db_instance_id", condition.DbInstanceId).
@@ -83,12 +82,16 @@ func (d *dbBackupRepoImpl) UpdateEnabled(_ context.Context, jobId uint64, enable
cond := map[string]any{
"id": jobId,
}
desc := "任务已禁用"
desc := "已禁用"
if enabled {
desc = "任务已启用"
desc = "已启用"
}
return d.Updates(cond, map[string]any{
"enabled": enabled,
"enabled_desc": desc,
})
}
func (d *dbBackupRepoImpl) ListByCond(cond any, listModels any, cols ...string) error {
return d.dbJobBaseImpl.ListByCond(cond, listModels, cols...)
}

View File

@@ -34,12 +34,13 @@ func (repo *dbBackupHistoryRepoImpl) GetPageList(condition *entity.DbBackupHisto
func (repo *dbBackupHistoryRepoImpl) GetHistories(backupHistoryIds []uint64, toEntity any) error {
return global.Db.Model(repo.GetModel()).
Where("id in ?", backupHistoryIds).
Where("deleting = false").
Scopes(gormx.UndeleteScope).
Find(toEntity).
Error
}
func (repo *dbBackupHistoryRepoImpl) GetLatestHistory(instanceId uint64, dbName string, bi *entity.BinlogInfo) (*entity.DbBackupHistory, error) {
func (repo *dbBackupHistoryRepoImpl) GetLatestHistoryForBinlog(instanceId uint64, dbName string, bi *entity.BinlogInfo) (*entity.DbBackupHistory, error) {
history := &entity.DbBackupHistory{}
db := global.Db
err := db.Model(repo.GetModel()).
@@ -48,6 +49,8 @@ func (repo *dbBackupHistoryRepoImpl) GetLatestHistory(instanceId uint64, dbName
Where(db.Where("binlog_sequence < ?", bi.Sequence).
Or(db.Where("binlog_sequence = ?", bi.Sequence).
Where("binlog_position <= ?", bi.Position))).
Where("binlog_sequence > 0").
Where("deleting = false").
Scopes(gormx.UndeleteScope).
Order("binlog_sequence desc, binlog_position desc").
First(history).Error
@@ -57,10 +60,12 @@ func (repo *dbBackupHistoryRepoImpl) GetLatestHistory(instanceId uint64, dbName
return history, err
}
func (repo *dbBackupHistoryRepoImpl) GetEarliestHistory(instanceId uint64) (*entity.DbBackupHistory, bool, error) {
func (repo *dbBackupHistoryRepoImpl) GetEarliestHistoryForBinlog(instanceId uint64) (*entity.DbBackupHistory, bool, error) {
history := &entity.DbBackupHistory{}
db := global.Db.Model(repo.GetModel())
err := db.Where("db_instance_id = ?", instanceId).
Where("binlog_sequence > 0").
Where("deleting = false").
Scopes(gormx.UndeleteScope).
Order("binlog_sequence").
First(history).Error
@@ -79,7 +84,7 @@ func (repo *dbBackupHistoryRepoImpl) UpdateDeleting(deleting bool, backupHistory
Where("id in ?", backupHistoryId).
Where("restoring = false").
Scopes(gormx.UndeleteScope).
Update("restoring", deleting)
Update("deleting", deleting)
if db.Error != nil {
return false, db.Error
}
@@ -103,3 +108,15 @@ func (repo *dbBackupHistoryRepoImpl) UpdateRestoring(restoring bool, backupHisto
}
return true, nil
}
func (repo *dbBackupHistoryRepoImpl) ZeroBinlogInfo(backupHistoryId uint64) error {
return global.Db.Model(repo.GetModel()).
Where("id = ?", backupHistoryId).
Where("restoring = false").
Scopes(gormx.UndeleteScope).
Updates(&map[string]any{
"binlog_file_name": "",
"binlog_sequence": 0,
"binlog_position": 0,
}).Error
}

View File

@@ -7,6 +7,7 @@ import (
"mayfly-go/internal/db/domain/entity"
"mayfly-go/internal/db/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/global"
"mayfly-go/pkg/gormx"
"time"
)
@@ -82,7 +83,7 @@ func (repo *dbBinlogHistoryRepoImpl) Upsert(_ context.Context, history *entity.D
First(old).Error
switch {
case err == nil:
return db.Model(old).Select("create_time", "file_size", "first_event_time").Updates(history).Error
return db.Model(old).Select("create_time", "file_size", "first_event_time", "last_event_time").Updates(history).Error
case errors.Is(err, gorm.ErrRecordNotFound):
return db.Create(history).Error
default:
@@ -103,9 +104,10 @@ func (repo *dbBinlogHistoryRepoImpl) InsertWithBinlogFiles(ctx context.Context,
history := &entity.DbBinlogHistory{
CreateTime: time.Now(),
FileName: fileOnServer.Name,
FileSize: fileOnServer.Size,
FileSize: fileOnServer.RemoteSize,
Sequence: fileOnServer.Sequence,
FirstEventTime: fileOnServer.FirstEventTime,
LastEventTime: fileOnServer.LastEventTime,
DbInstanceId: instanceId,
}
histories = append(histories, history)
@@ -122,3 +124,13 @@ func (repo *dbBinlogHistoryRepoImpl) InsertWithBinlogFiles(ctx context.Context,
}
return nil
}
func (repo *dbBinlogHistoryRepoImpl) GetHistoriesBeforeSequence(ctx context.Context, instanceId uint64, binlogSeq int64, histories *[]*entity.DbBinlogHistory) error {
return global.Db.Model(repo.GetModel()).
Where("db_instance_id = ?", instanceId).
Where("sequence < ?", binlogSeq).
Scopes(gormx.UndeleteScope).
Order("id").
Find(histories).
Error
}

View File

@@ -12,20 +12,12 @@ import (
"reflect"
)
var _ repository.DbJobBase = (*dbJobBaseImpl[entity.DbJob])(nil)
var _ repository.DbJobBase[entity.DbJob] = (*dbJobBaseImpl[entity.DbJob])(nil)
type dbJobBaseImpl[T entity.DbJob] struct {
base.RepoImpl[T]
}
func (d *dbJobBaseImpl[T]) GetById(e entity.DbJob, id uint64, cols ...string) error {
return d.RepoImpl.GetById(e.(T), id, cols...)
}
func (d *dbJobBaseImpl[T]) UpdateById(ctx context.Context, e entity.DbJob, columns ...string) error {
return d.RepoImpl.UpdateById(ctx, e.(T), columns...)
}
func (d *dbJobBaseImpl[T]) UpdateLastStatus(ctx context.Context, job entity.DbJob) error {
return d.UpdateById(ctx, job.(T), "last_status", "last_result", "last_time")
}

View File

@@ -84,9 +84,9 @@ func (d *dbRestoreRepoImpl) UpdateEnabled(_ context.Context, jobId uint64, enabl
cond := map[string]any{
"id": jobId,
}
desc := "任务已禁用"
desc := "已禁用"
if enabled {
desc = "任务已启用"
desc = "已启用"
}
return d.Updates(cond, map[string]any{
"enabled": enabled,

View File

@@ -12,7 +12,7 @@ type instanceRepoImpl struct {
base.RepoImpl[*entity.DbInstance]
}
func newInstanceRepo() repository.Instance {
func NewInstanceRepo() repository.Instance {
return &instanceRepoImpl{base.RepoImpl[*entity.DbInstance]{M: new(entity.DbInstance)}}
}

View File

@@ -5,7 +5,7 @@ import (
)
func Init() {
ioc.Register(newInstanceRepo(), ioc.WithComponentName("DbInstanceRepo"))
ioc.Register(NewInstanceRepo(), ioc.WithComponentName("DbInstanceRepo"))
ioc.Register(newDbRepo(), ioc.WithComponentName("DbRepo"))
ioc.Register(newDbSqlRepo(), ioc.WithComponentName("DbSqlRepo"))
ioc.Register(newDbSqlExecRepo(), ioc.WithComponentName("DbSqlExecRepo"))

View File

@@ -104,8 +104,16 @@ func (c *Cli) Close() {
c.sftpClient.Close()
c.sftpClient = nil
}
var sshTunnelMachineId uint64
if c.Info.SshTunnelMachine != nil {
logx.Infof("关闭机器的隧道信息: machineId=%d, sshTunnelMachineId=%d", c.Info.Id, c.Info.SshTunnelMachine.Id)
sshTunnelMachineId = c.Info.SshTunnelMachine.Id
}
if c.Info.TempSshMachineId != 0 {
sshTunnelMachineId = c.Info.TempSshMachineId
}
if sshTunnelMachineId != 0 {
logx.Infof("关闭机器的隧道信息: machineId=%d, sshTunnelMachineId=%d", c.Info.Id, sshTunnelMachineId)
CloseSshTunnelMachine(int(c.Info.SshTunnelMachine.Id), c.Info.GetTunnelId())
}
}

View File

@@ -24,6 +24,12 @@ func ErrIsNil(err error, msgAndParams ...any) {
}
}
func ErrNotNil(err error, msg string, params ...any) {
if err == nil {
panic(errorx.NewBiz(fmt.Sprintf(msg, params...)))
}
}
func ErrIsNilAppendErr(err error, msg string) {
if err != nil {
panic(errorx.NewBiz(fmt.Sprintf(msg, err.Error())))

View File

@@ -4,7 +4,7 @@ import "fmt"
const (
AppName = "mayfly-go"
Version = "v1.7.2"
Version = "v1.7.3"
)
func GetAppInfo() string {

View File

@@ -22,7 +22,7 @@ var (
type JobKey = string
type RunJobFunc[T Job] func(ctx context.Context, job T) error
type NextJobFunc[T Job] func() (T, bool)
type RunnableJobFunc[T Job] func(job T, next NextJobFunc[T]) (bool, error)
type RunnableJobFunc[T Job] func(job T, nextRunning NextJobFunc[T]) (bool, error)
type ScheduleJobFunc[T Job] func(job T) (deadline time.Time, err error)
type UpdateJobFunc[T Job] func(ctx context.Context, job T) error

View File

@@ -50,6 +50,7 @@ CREATE TABLE IF NOT EXISTS "t_db_backup" (
"db_name" text(64) NOT NULL,
"repeated" integer(1),
"interval" integer(20),
"max_save_days" integer(8) NOT NULL DEFAULT '0',
"start_time" datetime,
"enabled" integer(1),
"enabled_desc" text(64),
@@ -81,8 +82,8 @@ CREATE TABLE IF NOT EXISTS "t_db_backup_history" (
"create_time" datetime,
"is_deleted" integer(1) NOT NULL,
"delete_time" datetime,
"restoring" integer(1),
"deleting" integer(1),
"restoring" integer(1) NOT NULL DEFAULT '0',
"deleting" integer(1) NOT NULL DEFAULT '0',
PRIMARY KEY ("id")
);
@@ -112,6 +113,7 @@ CREATE TABLE IF NOT EXISTS "t_db_binlog_history" (
"file_size" integer(20),
"sequence" integer(20),
"first_event_time" datetime,
"last_event_time" datetime,
"create_time" datetime,
"is_deleted" integer(4) NOT NULL,
"delete_time" datetime,
@@ -738,6 +740,8 @@ CREATE TABLE IF NOT EXISTS "t_sys_resource" (
);
INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES (1, 0, 'Aexqq77l/', 1, 1, '首页', '/home', 10000000, '{"component":"home/Home","icon":"HomeFilled","isAffix":true,"isKeepAlive":true,"routeName":"Home"}', 1, 'admin', 1, 'admin', '2021-05-25 16:44:41', '2023-03-14 14:27:07', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES (2, 0, '12sSjal1/', 1, 1, '机器管理', '/machine', 49999998, '{"icon":"Monitor","isKeepAlive":true,"redirect":"machine/list","routeName":"Machine"}', 1, 'admin', 1, 'admin', '2021-05-25 16:48:16', '2022-10-06 14:58:49', 0, NULL);
INSERT INTO t_sys_resource (id, pid, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, ui_path, is_deleted, delete_time) VALUES(1707206386, 2, 1, 1, '机器操作', 'machines-op', 1, '{"component":"ops/machine/MachineOp","icon":"Monitor","isKeepAlive":true,"routeName":"MachineOp"}', 12, 'liuzongyang', 12, 'liuzongyang', '2024-02-06 15:59:46', '2024-02-06 16:24:21', 'PDPt6217/', 0, NULL);
INSERT INTO t_sys_resource (id, pid, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, ui_path, is_deleted, delete_time) VALUES(1707206421, 1707206386, 2, 1, '基本权限', 'machine-op', 1707206421, 'null', 12, 'liuzongyang', 12, 'liuzongyang', '2024-02-06 16:00:22', '2024-02-06 16:00:22', 'PDPt6217/kQXTYvuM/', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES (3, 2, '12sSjal1/lskeiql1/', 1, 1, '机器列表', 'machines', 20000000, '{"component":"ops/machine/MachineList","icon":"Monitor","isKeepAlive":true,"routeName":"MachineList"}', 2, 'admin', 1, 'admin', '2021-05-25 16:50:04', '2023-03-15 17:14:44', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES (4, 0, 'Xlqig32x/', 1, 1, '系统管理', '/sys', 60000001, '{"icon":"Setting","isKeepAlive":true,"redirect":"/sys/resources","routeName":"sys"}', 1, 'admin', 1, 'admin', '2021-05-26 15:20:20', '2022-10-06 14:59:53', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES (5, 4, 'Xlqig32x/UGxla231/', 1, 1, '资源管理', 'resources', 9999999, '{"component":"system/resource/ResourceList","icon":"Menu","isKeepAlive":true,"routeName":"ResourceList"}', 1, 'admin', 1, 'admin', '2021-05-26 15:23:07', '2023-03-14 15:44:34', 0, NULL);
@@ -830,8 +834,8 @@ INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight,
INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES (153, 150, 'Jra0n7De/pLOA2UYz/', 2, 1, '删除', 'db:sync:del', 1703641342, 'null', 12, 'liuzongyang', 12, 'liuzongyang', '2023-12-27 09:42:22', '2023-12-27 09:42:22', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES (154, 150, 'Jra0n7De/VBt68CDx/', 2, 1, '启停', 'db:sync:status', 1703641364, 'null', 12, 'liuzongyang', 12, 'liuzongyang', '2023-12-27 09:42:45', '2023-12-27 09:42:45', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES (155, 150, 'Jra0n7De/PigmSGVg/', 2, 1, '日志', 'db:sync:log', 1704266866, 'null', 12, 'liuzongyang', 12, 'liuzongyang', '2024-01-03 15:27:47', '2024-01-03 15:27:47', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES (161, 49, 'dbms23ax/xleaiec2/3NUXQFIO/', 2, 1, '数据库备份', 'db:backup', 1705973876, 'null', 1, 'admin', 1, 'admin', '2024-01-23 09:37:56', '2024-01-23 09:37:56', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES (160, 49, 'dbms23ax/xleaiec2/ghErkTdb/', 2, 1, '数据库恢复', 'db:restore', 1705973909, 'null', 1, 'admin', 1, 'admin', '2024-01-23 09:38:29', '2024-01-23 09:38:29', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES (160, 49, 'dbms23ax/xleaiec2/3NUXQFIO/', 2, 1, '数据库备份', 'db:backup', 1705973876, 'null', 1, 'admin', 1, 'admin', '2024-01-23 09:37:56', '2024-01-23 09:37:56', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, type, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES (161, 49, 'dbms23ax/xleaiec2/ghErkTdb/', 2, 1, '数据库恢复', 'db:restore', 1705973909, 'null', 1, 'admin', 1, 'admin', '2024-01-23 09:38:29', '2024-01-23 09:38:29', 0, NULL);
-- Table: t_sys_role
CREATE TABLE IF NOT EXISTS "t_sys_role" (

View File

@@ -108,6 +108,7 @@ CREATE TABLE `t_db_backup` (
`db_name` varchar(64) NOT NULL COMMENT '数据库名称',
`repeated` tinyint(1) DEFAULT NULL COMMENT '是否重复执行',
`interval` bigint(20) DEFAULT NULL COMMENT '备份周期',
`max_save_days` int(8) NOT NULL DEFAULT '0' COMMENT '最大保留天数',
`start_time` datetime DEFAULT NULL COMMENT '首次备份时间',
`enabled` tinyint(1) DEFAULT NULL COMMENT '是否启用',
`enabled_desc` varchar(64) NULL COMMENT '任务启用描述',
@@ -144,8 +145,8 @@ CREATE TABLE `t_db_backup_history` (
`create_time` datetime DEFAULT NULL COMMENT '历史备份创建时间',
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
`delete_time` datetime DEFAULT NULL,
`restoring` int(1) NOT NULL DEFAULT '0' COMMENT '备份历史恢复标识',
`deleting` int(1) NOT NULL DEFAULT '0' COMMENT '备份历史删除标识',
`restoring` tinyint(1) NOT NULL DEFAULT '0' COMMENT '备份历史恢复标识',
`deleting` tinyint(1) NOT NULL DEFAULT '0' COMMENT '备份历史删除标识',
PRIMARY KEY (`id`),
KEY `idx_db_backup_id` (`db_backup_id`) USING BTREE,
KEY `idx_db_instance_id` (`db_instance_id`) USING BTREE,
@@ -232,6 +233,7 @@ CREATE TABLE `t_db_binlog_history` (
`file_size` bigint(20) DEFAULT NULL COMMENT 'BINLOG文件大小',
`sequence` bigint(20) DEFAULT NULL COMMENT 'BINLOG序列号',
`first_event_time` datetime DEFAULT NULL COMMENT '首次事件时间',
`last_event_time` datetime DEFAULT NULL COMMENT '最新事件时间',
`create_time` datetime DEFAULT NULL,
`is_deleted` tinyint(4) NOT NULL DEFAULT 0,
`delete_time` datetime DEFAULT NULL,
@@ -701,6 +703,8 @@ BEGIN;
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(1, 0, 'Aexqq77l/', 1, 1, '首页', '/home', 10000000, '{"component":"home/Home","icon":"HomeFilled","isAffix":true,"isKeepAlive":true,"routeName":"Home"}', 1, 'admin', 1, 'admin', '2021-05-25 16:44:41', '2023-03-14 14:27:07', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(2, 0, '12sSjal1/', 1, 1, '机器管理', '/machine', 49999998, '{"icon":"Monitor","isKeepAlive":true,"redirect":"machine/list","routeName":"Machine"}', 1, 'admin', 1, 'admin', '2021-05-25 16:48:16', '2022-10-06 14:58:49', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(3, 2, '12sSjal1/lskeiql1/', 1, 1, '机器列表', 'machines', 20000000, '{"component":"ops/machine/MachineList","icon":"Monitor","isKeepAlive":true,"routeName":"MachineList"}', 2, 'admin', 1, 'admin', '2021-05-25 16:50:04', '2023-03-15 17:14:44', 0, NULL);
INSERT INTO `t_sys_resource` (`id`, `pid`, `type`, `status`, `name`, `code`, `weight`, `meta`, `creator_id`, `creator`, `modifier_id`, `modifier`, `create_time`, `update_time`, `ui_path`, `is_deleted`, `delete_time`) VALUES(1707206386, 2, 1, 1, '机器操作', 'machines-op', 1, '{"component":"ops/machine/MachineOp","icon":"Monitor","isKeepAlive":true,"routeName":"MachineOp"}', 12, 'liuzongyang', 12, 'liuzongyang', '2024-02-06 15:59:46', '2024-02-06 16:24:21', 'PDPt6217/', 0, NULL);
INSERT INTO `t_sys_resource` (`id`, `pid`, `type`, `status`, `name`, `code`, `weight`, `meta`, `creator_id`, `creator`, `modifier_id`, `modifier`, `create_time`, `update_time`, `ui_path`, `is_deleted`, `delete_time`) VALUES(1707206421, 1707206386, 2, 1, '基本权限', 'machine-op', 1707206421, 'null', 12, 'liuzongyang', 12, 'liuzongyang', '2024-02-06 16:00:22', '2024-02-06 16:00:22', 'PDPt6217/kQXTYvuM/', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(4, 0, 'Xlqig32x/', 1, 1, '系统管理', '/sys', 60000001, '{"icon":"Setting","isKeepAlive":true,"redirect":"/sys/resources","routeName":"sys"}', 1, 'admin', 1, 'admin', '2021-05-26 15:20:20', '2022-10-06 14:59:53', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(5, 4, 'Xlqig32x/UGxla231/', 1, 1, '资源管理', 'resources', 9999999, '{"component":"system/resource/ResourceList","icon":"Menu","isKeepAlive":true,"routeName":"ResourceList"}', 1, 'admin', 1, 'admin', '2021-05-26 15:23:07', '2023-03-14 15:44:34', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(11, 4, 'Xlqig32x/lxqSiae1/', 1, 1, '角色管理', 'roles', 10000001, '{"component":"system/role/RoleList","icon":"Menu","isKeepAlive":true,"routeName":"RoleList"}', 1, 'admin', 1, 'admin', '2021-05-27 11:15:35', '2023-03-14 15:44:22', 0, NULL);
@@ -792,8 +796,8 @@ INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(152, 150, 'Jra0n7De/zvAMo2vk/', 2, 1, '编辑', 'db:sync:save', 1703641320, 'null', 12, 'liuzongyang', 12, 'liuzongyang', '2023-12-27 09:42:00', '2023-12-27 09:42:12', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(151, 150, 'Jra0n7De/uAnHZxEV/', 2, 1, '基本权限', 'db:sync', 1703641202, 'null', 12, 'liuzongyang', 12, 'liuzongyang', '2023-12-27 09:40:02', '2023-12-27 09:40:02', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(150, 36, 'Jra0n7De/', 1, 1, '数据同步', 'sync', 1693040707, '{"component":"ops/db/SyncTaskList","icon":"Coin","isKeepAlive":true,"routeName":"SyncTaskList"}', 12, 'liuzongyang', 12, 'liuzongyang', '2023-12-22 09:51:34', '2023-12-27 10:16:57', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(161, 49, 'dbms23ax/xleaiec2/3NUXQFIO/', 2, 1, '数据库备份', 'db:backup', 1705973876, 'null', 1, 'admin', 1, 'admin', '2024-01-23 09:37:56', '2024-01-23 09:37:56', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(160, 49, 'dbms23ax/xleaiec2/ghErkTdb/', 2, 1, '数据库恢复', 'db:restore', 1705973909, 'null', 1, 'admin', 1, 'admin', '2024-01-23 09:38:29', '2024-01-23 09:38:29', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(160, 49, 'dbms23ax/xleaiec2/3NUXQFIO/', 2, 1, '数据库备份', 'db:backup', 1705973876, 'null', 1, 'admin', 1, 'admin', '2024-01-23 09:37:56', '2024-01-23 09:37:56', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(161, 49, 'dbms23ax/xleaiec2/ghErkTdb/', 2, 1, '数据库恢复', 'db:restore', 1705973909, 'null', 1, 'admin', 1, 'admin', '2024-01-23 09:38:29', '2024-01-23 09:38:29', 0, NULL);
COMMIT;
-- ----------------------------

View File

@@ -1,6 +1,5 @@
INSERT INTO `t_sys_resource` (`id`, `pid`, `ui_path`, `type`, `status`, `name`, `code`, `weight`, `meta`, `creator_id`, `creator`, `modifier_id`, `modifier`, `create_time`, `update_time`, `is_deleted`, `delete_time`)
VALUES (161, 49, 'dbms23ax/xleaiec2/3NUXQFIO/', 2, 1, '数据库备份', 'db:backup', 1705973876, 'null', 1, 'admin', 1, 'admin', '2024-01-23 09:37:56', '2024-01-23 09:37:56', 0, NULL),
(160, 49, 'dbms23ax/xleaiec2/ghErkTdb/', 2, 1, '数据库恢复', 'db:restore', 1705973909, 'null', 1, 'admin', 1, 'admin', '2024-01-23 09:38:29', '2024-01-23 09:38:29', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(160, 49, 'dbms23ax/xleaiec2/3NUXQFIO/', 2, 1, '数据库备份', 'db:backup', 1705973876, 'null', 1, 'admin', 1, 'admin', '2024-01-23 09:37:56', '2024-01-23 09:37:56', 0, NULL);
INSERT INTO t_sys_resource (id, pid, ui_path, `type`, status, name, code, weight, meta, creator_id, creator, modifier_id, modifier, create_time, update_time, is_deleted, delete_time) VALUES(161, 49, 'dbms23ax/xleaiec2/ghErkTdb/', 2, 1, '数据库恢复', 'db:restore', 1705973909, 'null', 1, 'admin', 1, 'admin', '2024-01-23 09:38:29', '2024-01-23 09:38:29', 0, NULL);
ALTER TABLE `t_db_backup`
ADD COLUMN `enabled_desc` varchar(64) NULL COMMENT '任务启用描述' AFTER `enabled`;
@@ -9,5 +8,5 @@ ALTER TABLE `t_db_restore`
ADD COLUMN `enabled_desc` varchar(64) NULL COMMENT '任务启用描述' AFTER `enabled`;
ALTER TABLE `t_db_backup_history`
ADD COLUMN `restoring` int(1) NOT NULL DEFAULT '0' COMMENT '备份历史恢复标识',
ADD COLUMN `deleting` int(1) NOT NULL DEFAULT '0' COMMENT '备份历史删除标识';
ADD COLUMN `restoring` tinyint(1) NOT NULL DEFAULT '0' COMMENT '备份历史恢复标识',
ADD COLUMN `deleting` tinyint(1) NOT NULL DEFAULT '0' COMMENT '备份历史删除标识';

View File

@@ -0,0 +1,8 @@
ALTER TABLE `t_db_backup`
ADD COLUMN `max_save_days` int(8) NOT NULL DEFAULT '0' COMMENT '最大保存天数' AFTER `interval`;
ALTER TABLE `t_db_binlog_history`
ADD COLUMN `last_event_time` datetime NULL DEFAULT NULL COMMENT '最新事件时间' AFTER `first_event_time`;
INSERT INTO `t_sys_resource` (`id`, `pid`, `type`, `status`, `name`, `code`, `weight`, `meta`, `creator_id`, `creator`, `modifier_id`, `modifier`, `create_time`, `update_time`, `ui_path`, `is_deleted`, `delete_time`) VALUES(1707206386, 2, 1, 1, '机器操作', 'machines-op', 1, '{"component":"ops/machine/MachineOp","icon":"Monitor","isKeepAlive":true,"routeName":"MachineOp"}', 12, 'liuzongyang', 12, 'liuzongyang', '2024-02-06 15:59:46', '2024-02-06 16:24:21', 'PDPt6217/', 0, NULL);
INSERT INTO `t_sys_resource` (`id`, `pid`, `type`, `status`, `name`, `code`, `weight`, `meta`, `creator_id`, `creator`, `modifier_id`, `modifier`, `create_time`, `update_time`, `ui_path`, `is_deleted`, `delete_time`) VALUES(1707206421, 1707206386, 2, 1, '基本权限', 'machine-op', 1707206421, 'null', 12, 'liuzongyang', 12, 'liuzongyang', '2024-02-06 16:00:22', '2024-02-06 16:00:22', 'PDPt6217/kQXTYvuM/', 0, NULL);