feat: 容器操作优化等

This commit is contained in:
meilin.huang
2025-09-06 21:32:48 +08:00
parent 25195b6360
commit 66d5fd6ca4
64 changed files with 1208 additions and 1856 deletions

View File

@@ -51,42 +51,36 @@ http://go.mayfly.run
#### 首页 #### 首页
![首页](https://foruda.gitee.com/images/1714378104294194769/149fd257_1240250.png "屏幕截图") ![首页](https://foruda.gitee.com/images/1757163736351080323/afb6b330_1240250.png "屏幕截图")
#### 机器操作 #### 资源管理
##### 状态查看 ![资源树](https://foruda.gitee.com/images/1757163958991119284/83eb2171_1240250.png "屏幕截图")
![机器状态查看](https://foruda.gitee.com/images/1714378556642584686/93c46ec0_1240250.png "屏幕截图") #### 资源操作
##### ssh 终端 ![终端操作](https://foruda.gitee.com/images/1757164093410206293/1c7dda30_1240250.png)
![终端操作](https://foruda.gitee.com/images/1714378353790214943/2864ba66_1240250.png "屏幕截图") ![文件操作](https://foruda.gitee.com/images/1757164149388450531/0542398c_1240250.png)
##### 文件操作
![文件操作](https://foruda.gitee.com/images/1714378417206086701/74a188d8_1240250.png "屏幕截图")
![文件查看](https://foruda.gitee.com/images/1714378482611638688/7753faf6_1240250.png "屏幕截图") ![文件查看](https://foruda.gitee.com/images/1714378482611638688/7753faf6_1240250.png "屏幕截图")
#### 数据库操作
##### sql 编辑器 ![sql编辑器](https://foruda.gitee.com/images/1757164386318836686/c3b17a52_1240250.png)
![sql编辑器](https://foruda.gitee.com/images/1714378747473077515/3c9387c0_1240250.png "屏幕截图")
##### 在线增删改查数据 ![选表查数据](https://foruda.gitee.com/images/1757164281011401749/5109485f_1240250.png)
![选表查数据](https://foruda.gitee.com/images/1714378625059063750/3951e5a8_1240250.png "屏幕截图")
#### Redis 操作 ![redis操作](https://foruda.gitee.com/images/1757164442298752845/4af1b296_1240250.png)
![redis操作](https://foruda.gitee.com/images/1714378855845451114/4c3f0097_1240250.png "屏幕截图")
#### Mongo 操作
![mongo操作](https://foruda.gitee.com/images/1714378916425714642/77fc0ed9_1240250.png "屏幕截图") ![mongo操作](https://foruda.gitee.com/images/1714378916425714642/77fc0ed9_1240250.png "屏幕截图")
![es操作](https://foruda.gitee.com/images/1757164553845346963/b5b70381_1240250.png)
![容器操作](https://foruda.gitee.com/images/1757164625186816754/2b195e25_1240250.png)
#### 工单流程审批 #### 工单流程审批
![流程审批](https://foruda.gitee.com/images/1714379057627690037/ad136862_1240250.png "屏幕截图") ![流程审批](https://foruda.gitee.com/images/1714379057627690037/ad136862_1240250.png "屏幕截图")

View File

@@ -46,40 +46,35 @@ account/passwordtest/test123.
![首页](https://foruda.gitee.com/images/1714378104294194769/149fd257_1240250.png "屏幕截图") ![首页](https://foruda.gitee.com/images/1714378104294194769/149fd257_1240250.png "屏幕截图")
#### Machine Operation #### Resource Manage
##### Status ![资源树](https://foruda.gitee.com/images/1757163958991119284/83eb2171_1240250.png "屏幕截图")
![机器状态查看](https://foruda.gitee.com/images/1714378556642584686/93c46ec0_1240250.png "屏幕截图") #### Resource Operation
##### SSH Terminal ![终端操作](https://foruda.gitee.com/images/1757164093410206293/1c7dda30_1240250.png)
![终端操作](https://foruda.gitee.com/images/1714378353790214943/2864ba66_1240250.png "屏幕截图") ![文件操作](https://foruda.gitee.com/images/1757164149388450531/0542398c_1240250.png)
##### File Operation
![文件操作](https://foruda.gitee.com/images/1714378417206086701/74a188d8_1240250.png "屏幕截图")
![文件查看](https://foruda.gitee.com/images/1714378482611638688/7753faf6_1240250.png "屏幕截图") ![文件查看](https://foruda.gitee.com/images/1714378482611638688/7753faf6_1240250.png "屏幕截图")
#### Database Operation
##### SQL Editor ![sql编辑器](https://foruda.gitee.com/images/1757164386318836686/c3b17a52_1240250.png)
![sql编辑器](https://foruda.gitee.com/images/1714378747473077515/3c9387c0_1240250.png "屏幕截图")
##### Add, delete, update and check data online ![选表查数据](https://foruda.gitee.com/images/1757164281011401749/5109485f_1240250.png)
![选表查数据](https://foruda.gitee.com/images/1714378625059063750/3951e5a8_1240250.png "屏幕截图")
#### Redis Operation ![redis操作](https://foruda.gitee.com/images/1757164442298752845/4af1b296_1240250.png)
![redis操作](https://foruda.gitee.com/images/1714378855845451114/4c3f0097_1240250.png "屏幕截图")
#### Mongo Operation
![mongo操作](https://foruda.gitee.com/images/1714378916425714642/77fc0ed9_1240250.png "屏幕截图") ![mongo操作](https://foruda.gitee.com/images/1714378916425714642/77fc0ed9_1240250.png "屏幕截图")
![es操作](https://foruda.gitee.com/images/1757164553845346963/b5b70381_1240250.png)
![容器操作](https://foruda.gitee.com/images/1757164625186816754/2b195e25_1240250.png)
#### Work order process approval #### Work order process approval
![流程审批](https://foruda.gitee.com/images/1714379057627690037/ad136862_1240250.png "屏幕截图") ![流程审批](https://foruda.gitee.com/images/1714379057627690037/ad136862_1240250.png "屏幕截图")

View File

@@ -13,7 +13,7 @@
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"@logicflow/core": "^2.1.1", "@logicflow/core": "^2.1.1",
"@logicflow/extension": "^2.1.2", "@logicflow/extension": "^2.1.2",
"@vueuse/core": "^13.8.0", "@vueuse/core": "^13.9.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0", "@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
@@ -24,7 +24,7 @@
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"element-plus": "^2.11.1", "element-plus": "^2.11.2",
"js-base64": "^3.7.7", "js-base64": "^3.7.7",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
@@ -59,8 +59,8 @@
"eslint-plugin-vue": "^10.4.0", "eslint-plugin-vue": "^10.4.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.6.1", "prettier": "^3.6.1",
"sass": "^1.90.0", "sass": "^1.92.1",
"tailwindcss": "^4.1.12", "tailwindcss": "^4.1.13",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "npm:rolldown-vite@latest", "vite": "npm:rolldown-vite@latest",
"vite-plugin-progress": "0.0.7", "vite-plugin-progress": "0.0.7",

View File

@@ -22,6 +22,7 @@ export const ResourceTypeEnum = {
Mongo: EnumValue.of(4, 'mongo').setExtra({ icon: 'icon mongo/mongo', iconColor: 'var(--el-color-success)' }).tagTypeDanger(), Mongo: EnumValue.of(4, 'mongo').setExtra({ icon: 'icon mongo/mongo', iconColor: 'var(--el-color-success)' }).tagTypeDanger(),
AuthCert: EnumValue.of(5, 'ac.ac').setExtra({ icon: 'Ticket', iconColor: 'var(--el-color-success)' }), AuthCert: EnumValue.of(5, 'ac.ac').setExtra({ icon: 'Ticket', iconColor: 'var(--el-color-success)' }),
Es: EnumValue.of(6, 'tag.es').setExtra({ icon: 'icon es/es-color', iconColor: 'var(--el-color-warning)' }).tagTypeWarning(), Es: EnumValue.of(6, 'tag.es').setExtra({ icon: 'icon es/es-color', iconColor: 'var(--el-color-warning)' }).tagTypeWarning(),
Container: EnumValue.of(7, 'tag.container').setExtra({ icon: 'icon docker/docker', iconColor: 'var(--el-color-primary)' }),
}; };
// 标签关联的资源类型 // 标签关联的资源类型
@@ -35,6 +36,7 @@ export const TagResourceTypeEnum = {
Redis: ResourceTypeEnum.Redis, Redis: ResourceTypeEnum.Redis,
Mongo: ResourceTypeEnum.Mongo, Mongo: ResourceTypeEnum.Mongo,
AuthCert: ResourceTypeEnum.AuthCert, AuthCert: ResourceTypeEnum.AuthCert,
Container: ResourceTypeEnum.Container,
Db: EnumValue.of(22, '数据库').setExtra({ icon: 'icon db/db' }), Db: EnumValue.of(22, '数据库').setExtra({ icon: 'icon db/db' }),
}; };

View File

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

View File

@@ -12,7 +12,13 @@ import { ElMessage } from 'element-plus';
export function templateResolve(template: string, param: any) { export function templateResolve(template: string, param: any) {
return template.replace(/\{\w+\}/g, (word) => { return template.replace(/\{\w+\}/g, (word) => {
const key = word.substring(1, word.length - 1); const key = word.substring(1, word.length - 1);
const value = param[key]; let value;
// 兼容FormData类型的参数
if (param instanceof FormData) {
value = param.get(key);
} else {
value = param[key];
}
if (value != null || value != undefined) { if (value != null || value != undefined) {
return value; return value;
} }

View File

@@ -12,7 +12,7 @@ const props = defineProps({
required: true, required: true,
}, },
value: { value: {
type: [Object, String, Number, null], type: [Object, String, Number, null, Boolean],
required: true, required: true,
default: () => null, default: () => null,
}, },

View File

@@ -1,5 +1,5 @@
<template> <template>
<div> <div class="h-full">
<monaco-editor <monaco-editor
ref="editorRef" ref="editorRef"
:height="props.height" :height="props.height"
@@ -22,7 +22,7 @@ import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
const props = defineProps({ const props = defineProps({
height: { height: {
type: String, type: String,
default: 'calc(100vh - 200px)', default: '100%',
}, },
wsUrl: { wsUrl: {
type: String, type: String,
@@ -45,14 +45,20 @@ watch(data, (value) => {
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
modelValue.value = modelValue.value + value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); modelValue.value = modelValue.value + value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
setTimeout(() => { setTimeout(() => {
editorRef.value?.revealLastLine(); revealLastLine();
}, 200); }, 200);
}); });
const reload = (wsUrl: string) => { const reload = (wsUrl: string) => {
modelValue.value = ''; modelValue.value = '';
editorRef.value?.revealLastLine();
websocketUrl.value = wsUrl; websocketUrl.value = wsUrl;
revealLastLine();
};
const revealLastLine = () => {
const editor = editorRef.value.getEditor();
const lineCount = editor?.getModel().getLineCount();
editor.revealLine(lineCount);
}; };
defineExpose({ defineExpose({

View File

@@ -65,7 +65,7 @@ export default {
resultSet: 'Result Set', resultSet: 'Result Set',
tableDataEmptyTextTips: tableDataEmptyTextTips:
'tips: Single table query at the beginning of select * or click the default query data of the table name, double-click the data online modification', 'tips: Single table query at the beginning of select * or click the default query data of the table name, double-click the data online modification',
noSelctRunSqlMsg: 'Select the sql you want to execute', noSelectRunSqlMsg: 'Select the sql you want to execute or move the cursor near the sql you want to execute',
enterExecRemarkTips: 'Please enter remark', enterExecRemarkTips: 'Please enter remark',
execRemarkPlaceholder: 'Enter the remark to execute the sql', execRemarkPlaceholder: 'Enter the remark to execute the sql',
currentSqlTabIsRunning: 'The current result set tab is being executed, please use the new TAB to execute', currentSqlTabIsRunning: 'The current result set tab is being executed, please use the new TAB to execute',

View File

@@ -1,5 +1,9 @@
export default { export default {
docker: { docker: {
containerConf: 'Container Config',
addr: 'Address',
addrTips: 'eg: unix:///var/run/docker.sock 、tcp://192.168.1.1',
container: 'Container', container: 'Container',
containerName: 'Container Name', containerName: 'Container Name',
running: 'Running', running: 'Running',

View File

@@ -92,6 +92,8 @@ export default {
mongoManage: 'Mongo Manage', mongoManage: 'Mongo Manage',
mongoManageBase: 'Base Permission', mongoManageBase: 'Base Permission',
containerManageBase: 'Container Manage - Base Permission',
flow: 'Flow', flow: 'Flow',
myTask: 'My Task', myTask: 'My Task',
myFlow: 'My Flow', myFlow: 'My Flow',

View File

@@ -64,7 +64,7 @@ export default {
times: '耗时', times: '耗时',
resultSet: '结果集', resultSet: '结果集',
tableDataEmptyTextTips: 'tips: select *开头的单表查询或点击表名默认查询的数据,可双击数据在线修改', tableDataEmptyTextTips: 'tips: select *开头的单表查询或点击表名默认查询的数据,可双击数据在线修改',
noSelctRunSqlMsg: '请选中需要执行的sql', noSelectRunSqlMsg: '请选中需要执行的sql或将光标移动到要执行sql附近',
enterExecRemarkTips: '请输入备注', enterExecRemarkTips: '请输入备注',
execRemarkPlaceholder: '输入执行该sql的备注信息', execRemarkPlaceholder: '输入执行该sql的备注信息',
currentSqlTabIsRunning: '当前结果集tab正在执行, 请使用新标签执行', currentSqlTabIsRunning: '当前结果集tab正在执行, 请使用新标签执行',

View File

@@ -1,5 +1,9 @@
export default { export default {
docker: { docker: {
containerConf: '容器配置',
addr: '地址',
addrTips: '如unix:///var/run/docker.sock 、tcp://192.168.1.1',
container: '容器', container: '容器',
containerName: '容器名', containerName: '容器名',
running: '运行中', running: '运行中',

View File

@@ -92,6 +92,8 @@ export default {
mongoManage: 'Mongo管理', mongoManage: 'Mongo管理',
mongoManageBase: 'Mongo-管理-基本权限', mongoManageBase: 'Mongo-管理-基本权限',
containerManageBase: '容器-管理-基本权限',
flow: '工单流程', flow: '工单流程',
myTask: '我的任务', myTask: '我的任务',
myFlow: '我的流程', myFlow: '我的流程',

View File

@@ -10,6 +10,7 @@ export default {
machine: '机器', machine: '机器',
db: '数据库', db: '数据库',
es: 'ES', es: 'ES',
container: '容器',
code: '编号', code: '编号',
createSubTag: '创建子标签', createSubTag: '创建子标签',
createSubTagTitle: '创建【{codePath}】的子标签', createSubTagTitle: '创建【{codePath}】的子标签',
@@ -20,6 +21,7 @@ export default {
redisDataOp: 'Redis操作', redisDataOp: 'Redis操作',
esDataOp: 'ES操作', esDataOp: 'ES操作',
mongoDataOp: 'Mongo操作', mongoDataOp: 'Mongo操作',
containerOp: '容器操作',
allResource: '所有资源', allResource: '所有资源',
}, },
team: { team: {

View File

@@ -1,29 +0,0 @@
<template>
<el-splitter @resize="handleResize">
<el-splitter-panel :size="leftPaneSize + '%'" max="40%">
<slot name="left"></slot>
</el-splitter-panel>
<el-splitter-panel>
<slot name="right"></slot>
</el-splitter-panel>
</el-splitter>
</template>
<script lang="ts" setup>
import { useWindowSize } from '@vueuse/core';
import { computed } from 'vue';
const emit = defineEmits(['resize']);
const { width } = useWindowSize();
const leftPaneSize = computed(() => (width.value >= 1600 ? 20 : 24));
// 处理 resize 事件
const handleResize = (event: any) => {
emit('resize', event);
};
</script>
<style lang="scss"></style>

View File

@@ -1,276 +0,0 @@
<template>
<el-card class="h-full flex tag-tree-card" body-class="!p-0 flex flex-col w-full">
<div class="tag-tree-header">
<el-input v-model="filterText" :placeholder="$t('tag.tagFilterPlaceholder')" clearable size="small" class="tag-tree-search w-full">
<template #prefix>
<SvgIcon class="tag-tree-search-icon" name="search" />
</template>
</el-input>
</div>
<el-scrollbar>
<el-tree
class="min-w-full inline-block"
ref="treeRef"
:highlight-current="true"
:indent="10"
:load="loadNode"
:props="treeProps"
lazy
node-key="key"
:expand-on-click-node="false"
:filter-node-method="filterNode"
@node-click="treeNodeClick"
@node-expand="treeNodeClick"
@node-contextmenu="nodeContextmenu"
:default-expanded-keys="props.defaultExpandedKeys"
>
<template #default="{ node, data }">
<div
:id="node.key"
class="w-full node-container flex items-center cursor-pointer select-none"
:class="data.type.nodeDblclickFunc ? 'select-none' : ''"
>
<span v-if="data.type.value == TagTreeNode.TagPath">
<tag-info :tag-path="data.label" />
</span>
<slot v-else :node="node" :data="data" name="prefix"></slot>
<span class="ml-1" :title="data.labelRemark">
<slot name="label" :data="data" v-if="!data.disabled"> {{ $t(data.label) }}</slot>
<!-- 禁用状态 -->
<slot name="disabledLabel" :data="data" v-else>
<el-link type="danger" disabled underline="never">
{{ `${$t(data.label)}` }}
</el-link>
</slot>
</span>
<span class="ml-auto pr-1.5 text-[10px] text-gray-400">
<slot :node="node" :data="data" name="suffix"></slot>
</span>
</div>
</template>
</el-tree>
<contextmenu :dropdown="state.dropdown" :items="state.contextmenuItems" ref="contextmenuRef" @currentContextmenuClick="onCurrentContextmenuClick" />
</el-scrollbar>
</el-card>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, reactive, ref, toRefs, watch } from 'vue';
import { NodeType, TagTreeNode } from './tag';
import TagInfo from './TagInfo.vue';
import { Contextmenu } from '@/components/contextmenu';
import { tagApi } from '../tag/api';
import { isPrefixSubsequence } from '@/common/utils/string';
import SvgIcon from '@/components/svgIcon/index.vue';
const props = defineProps({
resourceType: {
type: [Number, String],
required: true,
},
defaultExpandedKeys: {
type: [Array],
},
tagPathNodeType: {
type: [NodeType],
required: true,
},
load: {
type: Function,
required: false,
},
loadContextmenuItems: {
type: Function,
required: false,
},
});
const treeProps = {
label: 'name',
children: 'zones',
isLeaf: 'isLeaf',
};
const emit = defineEmits(['nodeClick', 'currentContextmenuClick']);
const treeRef: any = ref(null);
const contextmenuRef = ref();
const state = reactive({
height: 600 as any,
filterText: '',
dropdown: {
x: 0,
y: 0,
},
contextmenuItems: [],
opend: {},
});
const { filterText } = toRefs(state);
onMounted(async () => {});
watch(filterText, (val) => {
treeRef.value?.filter(val);
});
const filterNode = (value: string, data: any) => {
return !value || isPrefixSubsequence(value, data.label);
};
/**
* 加载标签树节点
*/
const loadTags = async () => {
const tags = await tagApi.getResourceTagPaths.request({ resourceType: props.resourceType });
const tagNodes = [];
for (let tagPath of tags) {
tagNodes.push(new TagTreeNode(tagPath, tagPath, props.tagPathNodeType));
}
return tagNodes;
};
/**
* 加载树节点
* @param { Object } node
* @param { Object } resolve
*/
const loadNode = async (node: any, resolve: (data: any) => void, reject: () => void) => {
if (typeof resolve !== 'function') {
return;
}
let nodes = [];
try {
if (node.level == 0) {
nodes = await loadTags();
} else if (props.load) {
nodes = await props.load(node);
} else {
nodes = await node.data.loadChildren();
}
} catch (e: any) {
console.error(e);
// 调用 reject 以保持节点状态,并允许远程加载继续。
return reject();
}
return resolve(nodes);
};
let lastNodeClickTime = 0;
const treeNodeClick = async (data: any, node: any) => {
const currentClickNodeTime = Date.now();
if (currentClickNodeTime - lastNodeClickTime < 300) {
treeNodeDblclick(data, node);
return;
}
lastNodeClickTime = currentClickNodeTime;
if (!data.disabled && !data.type.nodeDblclickFunc && data.type.nodeClickFunc) {
emit('nodeClick', data);
await data.type.nodeClickFunc(data);
}
// 关闭可能存在的右击菜单
contextmenuRef.value.closeContextmenu();
};
// 树节点双击事件
const treeNodeDblclick = (data: any, node: any) => {
if (node.expanded) {
node.collapse();
} else {
node.expand();
}
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) {
if (props.loadContextmenuItems) {
items = props.loadContextmenuItems(data);
}
}
if (!items) {
return;
}
state.contextmenuItems = items;
const { clientX, clientY } = event;
state.dropdown.x = clientX;
state.dropdown.y = clientY;
contextmenuRef.value.openContextmenu(data);
};
const onCurrentContextmenuClick = (clickData: any) => {
emit('currentContextmenuClick', clickData);
};
const reloadNode = (nodeKey: any) => {
let node = getNode(nodeKey);
node.loaded = false;
node.expand();
};
const getNode = (nodeKey: any) => {
let node = treeRef.value.getNode(nodeKey);
if (!node) {
throw new Error('未找到节点: ' + nodeKey);
}
return node;
};
const setCurrentKey = (nodeKey: any) => {
treeRef.value.setCurrentKey(nodeKey);
// 通过Id获取到对应的dom元素
const node = document.getElementById(nodeKey);
if (node) {
setTimeout(() => {
nextTick(() => {
// 通过scrollIntoView方法将对应的dom元素定位到可见区域 【block: 'center'】这个属性是在垂直方向居中显示
node.scrollIntoView({ block: 'center' });
});
}, 100);
}
};
defineExpose({
reloadNode,
getNode,
setCurrentKey,
});
</script>
<style lang="scss" scoped>
.tag-tree-card {
:deep(.el-card__body) {
padding: 0;
}
}
.tag-tree-header {
padding: 4px 6px;
border-bottom: 1px solid var(--el-border-color-light);
}
.tag-tree-search {
:deep(.el-input__wrapper) {
border-radius: 14px;
height: 24px;
}
}
</style>

View File

@@ -110,6 +110,7 @@
:data="dt.data" :data="dt.data"
:table="dt.table" :table="dt.table"
:columns="dt.tableColumn" :columns="dt.tableColumn"
:column-more-actions="['fixed']"
:loading="dt.loading" :loading="dt.loading"
:abort-fn="dt.abortFn" :abort-fn="dt.abortFn"
:height="tableDataHeight" :height="tableDataHeight"
@@ -305,13 +306,8 @@ const getKey = () => {
* 执行sql * 执行sql
*/ */
const onRunSql = async (newTab = false) => { const onRunSql = async (newTab = false) => {
// 没有选中的文本,则为全部文本 const sqls = getSql();
let sql = getSql() as string; notBlank(sqls, t('db.noSelectRunSqlMsg'));
notBlank(sql && sql.trim(), t('db.noSelctRunSqlTips'));
// 去除字符串前的空格、换行等
sql = sql.replace(/(^\s*)/g, '');
const sqls = splitSql(sql);
if (sqls.length == 1) { if (sqls.length == 1) {
const oneSql = sqls[0]; const oneSql = sqls[0];
@@ -522,83 +518,26 @@ const runSql = async (sql: string, remark = '', newTab = false) => {
} }
}; };
function splitSql(sql: string, delimiter: string = ';') {
let state = 'normal';
let buffer = '';
let result = [];
let inString = null; // 用于记录当前字符串的引号类型(' 或 "
for (let i = 0; i < sql.length; i++) {
const char = sql[i];
const nextChar = sql[i + 1];
if (state === 'normal') {
if (char === '-' && nextChar === '-') {
state = 'singleLineComment';
i++; // 跳过下一个字符
} else if (char === '/' && nextChar === '*') {
state = 'multiLineComment';
i++; // 跳过下一个字符
} else if (char === "'" || char === '"') {
state = 'string';
inString = char;
buffer += char;
} else if (char === delimiter) {
if (buffer.trim()) {
result.push(buffer.trim());
}
buffer = '';
} else {
buffer += char;
}
} else if (state === 'string') {
buffer += char;
if (char === '\\') {
// 处理转义字符
buffer += nextChar;
i++;
} else if (char === inString) {
state = 'normal';
inString = null;
}
} else if (state === 'singleLineComment') {
if (char === '\n') {
state = 'normal';
}
} else if (state === 'multiLineComment') {
if (char === '*' && nextChar === '/') {
state = 'normal';
i++; // 跳过下一个字符
}
}
}
if (buffer.trim()) {
result.push(buffer.trim());
}
return result;
}
/** /**
* 获取sql如果有鼠标选中则返回选中内容否则返回输入框内所有内容 * 获取sql如果有鼠标选中则返回选中内容否则返回当前光标附近的sql
*/ */
const getSql = () => { const getSql = (): string[] => {
let res = '' as string | undefined;
// 编辑器还没初始化 // 编辑器还没初始化
if (!monacoEditor?.getModel()) { if (!monacoEditor?.getModel()) {
return res; return [];
} }
let sql = '' as string | undefined;
// 选择选中的sql // 选择选中的sql
let selection = monacoEditor.getSelection(); let selection = monacoEditor.getSelection();
if (selection) { if (selection) {
res = monacoEditor.getModel()?.getValueInRange(selection); sql = monacoEditor.getModel()?.getValueInRange(selection);
sql = sql?.replace(/(^\s*)/g, '');
} }
// 如果有选中的内容且不为空,直接返回 // 如果有选中的内容且不为空,直接返回
if (res && res.trim()) { if (sql && sql.trim()) {
return res; return splitSqlStatements(sql).map((x) => x.text);
} }
// 没有选中任何内容时自动选择当前光标所在的SQL语句行 // 没有选中任何内容时自动选择当前光标所在的SQL语句行
@@ -609,52 +548,50 @@ const getSql = () => {
const fullSql = model.getValue(); const fullSql = model.getValue();
const sqlStatement = getCurrentStatement(fullSql, currentPosition, model); const sqlStatement = getCurrentStatement(fullSql, currentPosition, model);
if (sqlStatement) { if (sqlStatement) {
return sqlStatement; return [sqlStatement];
} }
} }
} }
// 整个编辑器的sql return [];
return res;
}; };
/** /**
* 获取光标所在的SQL语句 * 通用SQL解析器用于提取SQL语句及其位置信息
* @param fullSql 完整的SQL文本 * @param sql 完整的SQL文本
* @param position 光标位置 * @param delimiter SQL语句分隔符默认为分号
* @param model Monaco编辑器模型 * @param withPosition 是否需要返回位置信息
*/ */
function getCurrentStatement(fullSql: string, position: monaco.Position, model: monaco.editor.ITextModel): string | null { function splitSqlStatements(sql: string, delimiter: string = ';') {
// 使用与splitSql相同的逻辑来分割SQL语句但同时记录每个语句的位置
let state = 'normal'; let state = 'normal';
let buffer = ''; let buffer = '';
let statements: { text: string; start: number; end: number }[] = []; let result = [];
let inString = null; let inString = null; // 用于记录当前字符串的引号类型(' 或 "
let startPos = 0; let startPos = 0;
for (let i = 0; i < fullSql.length; i++) { for (let i = 0; i < sql.length; i++) {
const char = fullSql[i]; const char = sql[i];
const nextChar = fullSql[i + 1]; const nextChar = sql[i + 1];
if (state === 'normal') { if (state === 'normal') {
if (char === '-' && nextChar === '-') { if (char === '-' && nextChar === '-') {
state = 'singleLineComment'; state = 'singleLineComment';
buffer += char + nextChar; // buffer += char + nextChar;
i++; // 跳过下一个字符 i++; // 跳过下一个字符
} else if (char === '/' && nextChar === '*') { } else if (char === '/' && nextChar === '*') {
state = 'multiLineComment'; state = 'multiLineComment';
buffer += char + nextChar; // buffer += char + nextChar;
i++; // 跳过下一个字符 i++; // 跳过下一个字符
} else if (char === "'" || char === '"') { } else if (char === "'" || char === '"') {
state = 'string'; state = 'string';
inString = char; inString = char;
buffer += char; buffer += char;
} else if (char === ';') { } else if (char === delimiter) {
if (buffer.trim()) { if (buffer.trim()) {
statements.push({ result.push({
text: buffer.trim(), text: buffer.trim(),
start: startPos, start: startPos,
end: i end: i,
}); });
} }
buffer = ''; buffer = '';
@@ -673,12 +610,12 @@ function getCurrentStatement(fullSql: string, position: monaco.Position, model:
inString = null; inString = null;
} }
} else if (state === 'singleLineComment') { } else if (state === 'singleLineComment') {
buffer += char; // buffer += char;
if (char === '\n') { if (char === '\n') {
state = 'normal'; state = 'normal';
} }
} else if (state === 'multiLineComment') { } else if (state === 'multiLineComment') {
buffer += char; // buffer += char;
if (char === '*' && nextChar === '/') { if (char === '*' && nextChar === '/') {
buffer += nextChar; buffer += nextChar;
state = 'normal'; state = 'normal';
@@ -688,15 +625,27 @@ function getCurrentStatement(fullSql: string, position: monaco.Position, model:
} }
// 处理最后一个语句(没有以分号结尾的情况) // 处理最后一个语句(没有以分号结尾的情况)
const endPos = fullSql.length;
if (buffer.trim()) { if (buffer.trim()) {
statements.push({ result.push({
text: buffer.trim(), text: buffer.trim(),
start: startPos, start: startPos,
end: endPos end: sql.length,
}); });
} }
return result;
}
/**
* 获取光标所在的SQL语句
* @param fullSql 完整的SQL文本
* @param position 光标位置
* @param model Monaco编辑器模型
*/
function getCurrentStatement(fullSql: string, position: monaco.Position, model: monaco.editor.ITextModel): string | null {
// 使用通用SQL解析器来分割SQL语句并记录每个语句的位置
const statements: { text: string; start: number; end: number }[] = splitSqlStatements(fullSql);
// 根据光标位置找到对应的SQL语句 // 根据光标位置找到对应的SQL语句
if (position) { if (position) {
const offset = model.getOffsetAt(position); const offset = model.getOffsetAt(position);

View File

@@ -97,19 +97,19 @@
</span> </span>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="sort-asc"> <el-dropdown-item v-if="showColumnActionSort" command="sort-asc">
<SvgIcon name="top" class="mr-1" /> <SvgIcon name="top" class="mr-1" />
{{ $t('db.asc') }} {{ $t('db.asc') }}
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item command="sort-desc"> <el-dropdown-item v-if="showColumnActionSort" command="sort-desc">
<SvgIcon name="bottom" class="mr-1" /> <SvgIcon name="bottom" class="mr-1" />
{{ $t('db.desc') }} {{ $t('db.desc') }}
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item v-if="!column.fixed" command="fix"> <el-dropdown-item v-if="showColumnActionFixed && !column.fixed" command="fix">
<SvgIcon name="Paperclip" class="mr-1" /> <SvgIcon name="Paperclip" class="mr-1" />
{{ $t('db.fixed') }} {{ $t('db.fixed') }}
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item v-else command="unfix"> <el-dropdown-item v-if="showColumnActionFixed && column.fixed" command="unfix">
<SvgIcon name="Minus" class="mr-1" /> <SvgIcon name="Minus" class="mr-1" />
{{ $t('db.cancelFiexd') }} {{ $t('db.cancelFiexd') }}
</el-dropdown-item> </el-dropdown-item>
@@ -201,7 +201,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onBeforeUnmount, onMounted, reactive, ref, toRefs, watch, Ref } from 'vue'; import { onBeforeUnmount, onMounted, reactive, ref, toRefs, watch, Ref, computed } from 'vue';
import { ElInput, ElMessage } from 'element-plus'; import { ElInput, ElMessage } from 'element-plus';
import { copyToClipboard } from '@/common/utils/string'; import { copyToClipboard } from '@/common/utils/string';
import { DbInst, DbThemeConfig } from '@/views/ops/db/db'; import { DbInst, DbThemeConfig } from '@/views/ops/db/db';
@@ -238,6 +238,10 @@ const props = defineProps({
columns: { columns: {
type: Array<any>, type: Array<any>,
}, },
columnMoreActions: {
type: Array,
default: () => ['sort', 'fixed'],
},
loading: { loading: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -452,6 +456,16 @@ watch(
} }
); );
// 显示列排序
const showColumnActionSort = computed(() => {
return props.columnMoreActions.includes('sort');
});
// 显示列固定
const showColumnActionFixed = computed(() => {
return props.columnMoreActions.includes('fixed');
});
onMounted(async () => { onMounted(async () => {
console.log('in DbTable mounted'); console.log('in DbTable mounted');
state.tableHeight = props.height; state.tableHeight = props.height;

View File

@@ -0,0 +1,13 @@
<template>
<BaseTreeNode v-bind="$attrs">
<template #suffix="{ data }">
<span v-if="data.params.username">{{ ` ${data.params.username}` }}</span>
</template>
</BaseTreeNode>
</template>
<script lang="ts" setup>
import BaseTreeNode from '@/views/ops/resource/BaseTreeNode.vue';
</script>
<style lang="scss"></style>

View File

@@ -16,9 +16,9 @@
<el-descriptions-item label="version"> <el-descriptions-item label="version">
<span v-loading="loadingServerInfo"> {{ `${dbServerInfo?.version}` }}</span> <span v-loading="loadingServerInfo"> {{ `${dbServerInfo?.version}` }}</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item :label="$t('db.acName')"> <!-- <el-descriptions-item :label="$t('db.acName')">
{{ data.params.authCertName }} {{ data.params.authCertName }}
</el-descriptions-item> </el-descriptions-item> -->
<el-descriptions-item :label="$t('common.remark')"> <el-descriptions-item :label="$t('common.remark')">
{{ data.params.remark }} {{ data.params.remark }}
</el-descriptions-item> </el-descriptions-item>
@@ -45,7 +45,7 @@ const showDbInfo = async (db: any) => {
if (dbServerInfo.value) { if (dbServerInfo.value) {
dbServerInfo.value.version = ''; dbServerInfo.value.version = '';
} }
serverInfoReqParam.value.instanceId = db.instanceId; serverInfoReqParam.value.instanceId = db.id;
await getDbServerInfo(); await getDbServerInfo();
}; };
</script> </script>

View File

@@ -1,7 +1,7 @@
import { ContextmenuItem } from '@/components/contextmenu'; import { ContextmenuItem } from '@/components/contextmenu';
import { NodeType, TagTreeNode, ResourceConfig } from '../../component/tag'; import { NodeType, TagTreeNode, ResourceConfig } from '../../component/tag';
import { ResourceTypeEnum } from '@/common/commonEnum'; import { ResourceTypeEnum, TagResourceTypeEnum } from '@/common/commonEnum';
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from 'vue';
import { dbApi } from '../api'; import { dbApi } from '../api';
import { sleep } from '@/common/utils/loading'; import { sleep } from '@/common/utils/loading';
@@ -13,6 +13,7 @@ import { formatByteSize } from '@/common/utils/format';
const DbInstList = defineAsyncComponent(() => import('../InstanceList.vue')); const DbInstList = defineAsyncComponent(() => import('../InstanceList.vue'));
const DbDataOp = defineAsyncComponent(() => import('./DbDataOp.vue')); const DbDataOp = defineAsyncComponent(() => import('./DbDataOp.vue'));
const NodeDbInst = defineAsyncComponent(() => import('./NodeDbInst.vue')); const NodeDbInst = defineAsyncComponent(() => import('./NodeDbInst.vue'));
const NodeDb = defineAsyncComponent(() => import('./NodeDb.vue'));
const NodeDbTable = defineAsyncComponent(() => import('./NodeDbTable.vue')); const NodeDbTable = defineAsyncComponent(() => import('./NodeDbTable.vue'));
const DbIcon = { const DbIcon = {
@@ -65,34 +66,58 @@ const ContextmenuItemRefresh = new ContextmenuItem('refresh', 'common.refresh')
.withIcon('RefreshRight') .withIcon('RefreshRight')
.withOnClick(async (node: TagTreeNode) => (await node.ctx?.addResourceComponent(DbDataOpComp)).reloadNode(node.key)); .withOnClick(async (node: TagTreeNode) => (await node.ctx?.addResourceComponent(DbDataOpComp)).reloadNode(node.key));
// tagpath 节点类型 // 数据库实例节点类型
const NodeTypeDbTag = new NodeType(TagTreeNode.TagPath) const NodeTypeDbInst = new NodeType(TagResourceTypeEnum.DbInstance.value).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
.withLoadNodesFunc(async (parentNode: TagTreeNode) => { parentNode.ctx?.addResourceComponent(DbDataOpComp);
parentNode.ctx?.addResourceComponent(DbDataOpComp); const tagPath = parentNode.params.tagPath;
const tagPath = parentNode.params.tagPath; const dbInstancesRes = await dbApi.instances.request({ tagPath, pageSize: 100 });
const dbInfoRes = await dbApi.dbs.request({ tagPath }); const dbInstances = dbInstancesRes.list;
if (!dbInstances) {
return [];
}
// 防止过快加载会出现一闪而过,对眼睛不好
await sleep(100);
return dbInstances?.map((x: any) => {
x.tagPath = tagPath;
return TagTreeNode.new(parentNode, `${x.code}`, x.name, NodeTypeDbConf).withParams(x).withNodeComponent(NodeDbInst);
});
});
// 数据库配置节点类型
const NodeTypeDbConf = new NodeType(TagResourceTypeEnum.Db.value)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
const tagPath = params.tagPath;
const authCerts = {} as any;
for (let authCert of params.authCerts) {
authCerts[authCert.name] = authCert;
}
const dbInfoRes = await dbApi.dbs.request({
tagPath: `${tagPath}${TagResourceTypeEnum.DbInstance.value}|${params.code}`,
});
const dbInfos = dbInfoRes.list; const dbInfos = dbInfoRes.list;
if (!dbInfos) { if (!dbInfos) {
return []; return [];
} }
// 防止过快加载会出现一闪而过,对眼睛不好
await sleep(100);
return dbInfos?.map((x: any) => { return dbInfos?.map((x: any) => {
x.tagPath = tagPath; x.tagPath = tagPath;
return TagTreeNode.new(parentNode, `${x.code}`, x.name, NodeTypeDbInst).withParams(x).withNodeComponent(NodeDbInst); x.username = authCerts[x.authCertName]?.username;
return TagTreeNode.new(parentNode, `${x.code}`, x.name, NodeTypeDbs).withParams(x).withIcon(DbIcon).withNodeComponent(NodeDb);
}); });
}) })
.withContextMenuItems([ContextmenuItemRefresh]); .withContextMenuItems([ContextmenuItemRefresh]);
// 数据库实例节点类型 // 数据库列表名类型
const NodeTypeDbInst = new NodeType(1).withLoadNodesFunc(async (parentNode: TagTreeNode) => { const NodeTypeDbs = new NodeType(222).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params; const params = parentNode.params;
const dbs = (await DbInst.getDbNames(params))?.sort(); const dbs = (await DbInst.getDbNames(params))?.sort();
// 查询数据库版本信息 // 查询数据库版本信息
const version = await dbApi.getCompatibleDbVersion.request({ id: params.id, db: dbs[0] }); const version = await dbApi.getCompatibleDbVersion.request({ id: params.id, db: dbs[0] });
return dbs.map((x: any) => { return dbs.map((x: any) => {
return TagTreeNode.new(parentNode, `${parentNode.key}.${x}`, x, NodeTypeDb) return TagTreeNode.new(parentNode, `${parentNode.key}.${x}`, x, NodeTypeDb)
.withParams({ .withParams({
@@ -282,7 +307,7 @@ const getSqlMenuNodeKey = (dbId: number, db: string) => {
export default { export default {
order: 2, order: 2,
resourceType: ResourceTypeEnum.Db.value, resourceType: ResourceTypeEnum.Db.value,
rootNodeType: NodeTypeDbTag, rootNodeType: NodeTypeDbInst,
manager: { manager: {
componentConf: { componentConf: {
component: DbInstList, component: DbInstList,

View File

@@ -0,0 +1,170 @@
<template>
<div class="h-full">
<page-table
ref="pageTableRef"
:page-api="dockerApi.page"
:before-query-fn="checkRouteTagPath"
:searchItems="searchItems"
v-model:query-form="query"
:show-selection="true"
v-model:selection-data="selectionData"
:columns="columns"
lazy
>
<template #tableHeader>
<el-button type="primary" icon="plus" @click="editContainerConf(false)" plain>{{ $t('common.create') }}</el-button>
<el-button type="danger" icon="delete" :disabled="selectionData.length < 1" @click="deleteConf" plain>{{ $t('common.delete') }}</el-button>
</template>
<template #tagPath="{ data }">
<resource-tags :tags="data.tags" />
</template>
<template #action="{ data }">
<el-button @click="showDetail(data)" link>{{ $t('common.detail') }}</el-button>
<el-button type="primary" link @click="editContainerConf(data)">{{ $t('common.edit') }}</el-button>
</template>
</page-table>
<el-dialog v-if="detailDialog.visible" v-model="detailDialog.visible">
<el-descriptions :title="$t('common.detail')" :column="3" border>
<el-descriptions-item :span="1.5" label="id">{{ detailDialog.data.id }}</el-descriptions-item>
<el-descriptions-item :span="1.5" :label="$t('common.name')">{{ detailDialog.data.name }}</el-descriptions-item>
<el-descriptions-item :span="3" :label="$t('tag.relateTag')"><ResourceTags :tags="detailDialog.data.tags" /></el-descriptions-item>
<el-descriptions-item :span="3" label="Host">{{ detailDialog.data.host }}</el-descriptions-item>
<el-descriptions-item :span="3" label="DB">{{ detailDialog.data.db }}</el-descriptions-item>
<el-descriptions-item :span="3" :label="$t('common.remark')">{{ detailDialog.data.remark }}</el-descriptions-item>
<el-descriptions-item :span="3" :label="$t('machine.sshTunnel')">
{{ detailDialog.data.sshTunnelMachineId > 0 ? $t('common.yes') : $t('common.no') }}
</el-descriptions-item>
<el-descriptions-item :span="2" :label="$t('common.createTime')">{{ formatDate(detailDialog.data.createTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('common.creator')">{{ detailDialog.data.creator }}</el-descriptions-item>
<el-descriptions-item :span="2" :label="$t('common.updateTime')">{{ formatDate(detailDialog.data.updateTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('common.modifier')">{{ detailDialog.data.modifier }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<ContainerConfEdit
@val-change="search()"
:title="containerConfEditDialog.title"
v-model:visible="containerConfEditDialog.visible"
v-model:container="containerConfEditDialog.data"
></ContainerConfEdit>
</div>
</template>
<script lang="ts" setup>
import { dockerApi } from './api';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { formatDate } from '@/common/utils/format';
import ResourceTags from '../component/ResourceTags.vue';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { useRoute } from 'vue-router';
import { getTagPathSearchItem } from '../component/tag';
import { SearchItem } from '@/components/pagetable/SearchForm';
import { useI18nCreateTitle, useI18nDeleteConfirm, useI18nDeleteSuccessMsg, useI18nEditTitle } from '@/hooks/useI18n';
const ContainerConfEdit = defineAsyncComponent(() => import('./CotainerConfEdit.vue'));
const props = defineProps({
lazy: {
type: [Boolean],
default: false,
},
});
const route = useRoute();
const pageTableRef: Ref<any> = ref(null);
const searchItems = [
SearchItem.input('keyword', 'common.keyword').withPlaceholder('redis.keywordPlaceholder'),
getTagPathSearchItem(TagResourceTypeEnum.Container.value),
];
const columns = ref([
TableColumn.new('tags[0].tagPath', 'tag.relateTag').isSlot('tagPath').setAddWidth(20),
TableColumn.new('name', 'common.name'),
TableColumn.new('addr', 'docker.addr'),
TableColumn.new('remark', 'common.remark'),
TableColumn.new('code', 'common.code'),
TableColumn.new('action', 'common.operation').isSlot().setMinWidth(200).fixedRight().alignCenter(),
]);
const state = reactive({
selectionData: [],
query: {
tagPath: '',
pageNum: 1,
pageSize: 0,
},
detailDialog: {
visible: false,
data: null as any,
},
containerConfEditDialog: {
visible: false,
data: null as any,
title: '',
},
});
const { selectionData, query, detailDialog, containerConfEditDialog } = toRefs(state);
onMounted(() => {
if (!props.lazy) {
search();
}
});
const checkRouteTagPath = (query: any) => {
if (route.query.tagPath) {
query.tagPath = route.query.tagPath as string;
}
return query;
};
const showDetail = (detail: any) => {
state.detailDialog.data = detail;
state.detailDialog.visible = true;
};
const deleteConf = async () => {
try {
await useI18nDeleteConfirm(state.selectionData.map((x: any) => x.name).join('、'));
await dockerApi.delConf.request({ id: state.selectionData.map((x: any) => x.id).join(',') });
useI18nDeleteSuccessMsg();
search();
} catch (err) {
//
}
};
const search = async (tagPath: string = '') => {
if (tagPath) {
state.query.tagPath = tagPath;
}
pageTableRef.value.search();
};
const editContainerConf = async (data: any) => {
if (!data) {
state.containerConfEditDialog.data = null;
state.containerConfEditDialog.title = useI18nCreateTitle('docker.containerConf');
} else {
state.containerConfEditDialog.data = data;
state.containerConfEditDialog.title = useI18nEditTitle('docker.containerConf');
}
state.containerConfEditDialog.visible = true;
};
defineExpose({ search });
</script>
<style></style>

View File

@@ -0,0 +1,115 @@
<template>
<div>
<el-drawer :title="title" v-model="dialogVisible" :before-close="onCancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="onCancel" />
</template>
<el-form :model="form" ref="formRef" :rules="rules" label-width="auto">
<el-form-item prop="tagCodePaths" :label="$t('tag.relateTag')" required>
<tag-tree-select multiple v-model="form.tagCodePaths" />
</el-form-item>
<el-form-item prop="name" :label="$t('common.name')" required>
<el-input v-model.trim="form.name" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="addr" :label="$t('docker.addr')" required>
<el-input v-model.trim="form.addr" :placeholder="$t('docker.addrTips')" auto-complete="off" type="textarea"></el-input>
</el-form-item>
<el-form-item prop="remark" :label="$t('common.remark')">
<el-input v-model.trim="form.remark" auto-complete="off" type="textarea"></el-input>
</el-form-item>
</el-form>
<template #footer>
<!-- <el-button @click="onTestConn" :loading="testConnBtnLoading" type="success">{{ $t('ac.testConn') }}</el-button> -->
<el-button @click="onCancel()">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="onConfirm">{{ $t('common.confirm') }}</el-button>
</template>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, watch, useTemplateRef } from 'vue';
import { dockerApi } from './api';
import { ElMessage } from 'element-plus';
import TagTreeSelect from '../component/TagTreeSelect.vue';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import { useI18nFormValidate, useI18nSaveSuccessMsg } from '@/hooks/useI18n';
import { useI18n } from 'vue-i18n';
import { Rules } from '@/common/rule';
const { t } = useI18n();
const props = defineProps({
container: {
type: [Boolean, Object],
},
title: {
type: String,
},
});
const dialogVisible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits(['val-change', 'cancel']);
const rules = {
tagCodePaths: [Rules.requiredSelect('tag.relateTag')],
name: [Rules.requiredInput('common.name')],
addr: [Rules.requiredInput('addr')],
};
const formRef: any = useTemplateRef('formRef');
const state = reactive({
form: {
id: null,
code: '',
tagCodePaths: [],
name: null,
addr: '',
remark: '',
},
dbList: [0],
pwd: '',
});
const { form } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveConfExec } = dockerApi.saveConf.useApi(form);
watch(dialogVisible, () => {
if (!dialogVisible.value) {
return;
}
const container: any = props.container;
if (container) {
state.form = { ...container };
state.form.tagCodePaths = container.tags.map((t: any) => t.codePath);
} else {
state.form = {} as any;
}
});
const onTestConn = async () => {
await useI18nFormValidate(formRef);
// await testConnExec();
ElMessage.success(t('ac.connSuccess'));
};
const onConfirm = async () => {
await useI18nFormValidate(formRef);
await saveConfExec();
useI18nSaveSuccessMsg();
emit('val-change', state.form);
onCancel();
};
const onCancel = () => {
dialogVisible.value = false;
emit('cancel');
};
</script>
<style lang="scss"></style>

View File

@@ -3,25 +3,29 @@ import config from '@/common/config';
import { joinClientParams } from '@/common/request'; import { joinClientParams } from '@/common/request';
export const dockerApi = { export const dockerApi = {
info: Api.newGet('/docker/info'), page: Api.newGet('/docker/container-conf/page'),
saveConf: Api.newPost('/docker/container-conf/save'),
delConf: Api.newDelete('/docker/container-conf/del/{id}'),
containers: Api.newGet('/docker/containers'), info: Api.newGet('/docker/{id}/info'),
containersStats: Api.newGet('/docker/containers/stats'),
containerStop: Api.newPost('/docker/containers/stop'),
containerRemove: Api.newPost('/docker/containers/remove'),
containerRestart: Api.newPost('/docker/containers/restart'),
containerCreate: Api.newPost('/docker/containers/create'),
images: Api.newGet('/docker/images'), containers: Api.newGet('/docker/{id}/containers'),
imageRemove: Api.newPost('/docker/images/remove'), containersStats: Api.newGet('/docker/{id}/containers/stats'),
imageSave: Api.newPost('/docker/images/save'), containerStop: Api.newPost('/docker/{id}/containers/stop'),
imageUpload: Api.newPost('/docker/images/load'), containerRemove: Api.newPost('/docker/{id}/containers/remove'),
containerRestart: Api.newPost('/docker/{id}/containers/restart'),
containerCreate: Api.newPost('/docker/{id}/containers/create'),
images: Api.newGet('/docker/{id}/images'),
imageRemove: Api.newPost('/docker/{id}/images/remove'),
imageSave: Api.newPost('/docker/{id}/images/save'),
imageUpload: Api.newPost('/docker/{id}/images/load'),
}; };
export function getDockerExecSocketUrl(host: any, containerId: string) { export function getDockerExecSocketUrl(id: number, containerId: string) {
return `/docker/containers/exec?host=${host}&containerId=${containerId}`; return `/docker/${id}/containers/exec?id=${id}&containerId=${containerId}`;
} }
export function getContainerLogSocketUrl(host: any, containerId: string) { export function getContainerLogSocketUrl(id: number, containerId: string) {
return `${config.baseWsUrl}/docker/containers/logs?${joinClientParams()}&host=${host}&containerId=${containerId}`; return `${config.baseWsUrl}/docker/${id}/containers/logs?${joinClientParams()}&id=${id}&containerId=${containerId}`;
} }

View File

@@ -17,7 +17,7 @@
<template #label> <template #label>
{{ $t('docker.image') }} {{ $t('docker.image') }}
<el-tooltip :content="$t('docker.imageTips')" placement="top"> <el-tooltip :content="$t('docker.imageTips')" placement="top">
<SvgIcon class="mb10" name="question-filled" /> <SvgIcon class="mb-1" name="question-filled" />
</el-tooltip> </el-tooltip>
</template> </template>
@@ -34,9 +34,7 @@
</el-form-item> </el-form-item>
<el-form-item prop="cmdStr" :label="$t('Command')"> <el-form-item prop="cmdStr" :label="$t('Command')">
<el-select v-model="form.cmdStr" filterable allow-create> <el-input v-model="form.cmdStr" />
<el-option v-for="item in defaultCommands" :key="item" :label="item" :value="item"></el-option>
</el-select>
</el-form-item> </el-form-item>
<el-form-item :label="$t('docker.port')"> <el-form-item :label="$t('docker.port')">
@@ -283,8 +281,8 @@ const rules = {
}; };
const props = defineProps({ const props = defineProps({
host: { id: {
type: String, type: Number,
required: true, required: true,
}, },
}); });
@@ -311,8 +309,6 @@ const defaultForm = {
envsStr: '', envsStr: '',
}; };
const defaultCommands = ["start.sh jupyter notebook --NotebookApp.token=''"];
const state = reactive({ const state = reactive({
dockerInfo: {} as any, dockerInfo: {} as any,
images: [] as any, images: [] as any,
@@ -349,10 +345,10 @@ const runtimeSelect = computed(() => {
const init = async () => { const init = async () => {
state.form = deepClone(defaultForm); state.form = deepClone(defaultForm);
state.submitForm = {}; state.submitForm = {};
dockerApi.info.request({ host: props.host }).then((res) => { dockerApi.info.request({ id: props.id }).then((res) => {
state.dockerInfo = res; state.dockerInfo = res;
}); });
state.images = await dockerApi.images.request({ host: props.host }); state.images = await dockerApi.images.request({ id: props.id });
}; };
const handlePortsAdd = () => { const handlePortsAdd = () => {
@@ -398,7 +394,7 @@ const btnOk = async () => {
await useI18nFormValidate(formRef); await useI18nFormValidate(formRef);
state.submitForm = { ...state.form }; state.submitForm = { ...state.form };
state.submitForm.host = props.host; state.submitForm.id = props.id;
if (state.submitForm.exposedPorts) { if (state.submitForm.exposedPorts) {
state.submitForm.exposedPorts = state.form.exposedPorts.map((item: any) => { state.submitForm.exposedPorts = state.form.exposedPorts.map((item: any) => {

View File

@@ -27,7 +27,7 @@
<el-table-column prop="name" :label="$t('docker.name')" :min-width="120" show-overflow-tooltip> </el-table-column> <el-table-column prop="name" :label="$t('docker.name')" :min-width="120" show-overflow-tooltip> </el-table-column>
<el-table-column prop="imageName" :label="$t('docker.image')" :min-width="150" show-overflow-tooltip> </el-table-column> <el-table-column prop="imageName" :label="$t('docker.image')" :min-width="150" show-overflow-tooltip> </el-table-column>
<el-table-column prop="state" :label="$t('common.status')" :min-width="80"> <el-table-column prop="state" :label="$t('common.status')" :min-width="110">
<template #default="{ row }"> <template #default="{ row }">
<el-dropdown @command="handleCommand"> <el-dropdown @command="handleCommand">
<el-button :type="EnumValue.getEnumByValue(ContainerStateEnum, row.state)?.tag.type" round plain size="small"> <el-button :type="EnumValue.getEnumByValue(ContainerStateEnum, row.state)?.tag.type" round plain size="small">
@@ -50,7 +50,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-loading="true" prop="stats" :label="$t('docker.stats')" :min-width="90"> <el-table-column v-loading="true" prop="stats" :label="$t('docker.stats')" :min-width="130">
<template #default="{ row }"> <template #default="{ row }">
<SvgIcon v-if="getLoadingState(row.containerId)" class="is-loading" name="loading" color="var(--el-color-primary)" /> <SvgIcon v-if="getLoadingState(row.containerId)" class="is-loading" name="loading" color="var(--el-color-primary)" />
@@ -122,7 +122,7 @@
<el-table-column prop="status" label="运行时长" :min-width="120"> </el-table-column> <el-table-column prop="status" label="运行时长" :min-width="120"> </el-table-column>
<el-table-column :label="$t('common.operation')" :min-width="140"> <el-table-column :label="$t('common.operation')" :min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<el-row> <el-row>
<el-button @click="openTerminal(row)" :disabled="row.state != ContainerStateEnum.Running.value" type="primary" link plain> SSH </el-button> <el-button @click="openTerminal(row)" :disabled="row.state != ContainerStateEnum.Running.value" type="primary" link plain> SSH </el-button>
@@ -157,16 +157,16 @@
draggable draggable
append-to-body append-to-body
> >
<TerminalBody ref="terminal" :socket-url="getDockerExecSocketUrl(params.host, terminalDialog.containerId)" /> <TerminalBody ref="terminal" :socket-url="getDockerExecSocketUrl(props.id, terminalDialog.containerId)" />
</el-dialog> </el-dialog>
<ContainerLog v-model:visible="logDialog.visible" :host="params.host" :container-id="logDialog.containerId" /> <ContainerLog v-model:visible="logDialog.visible" :id="props.id" :container-id="logDialog.containerId" :title="logDialog.title" />
<ContainerCreate v-model:visible="containerCreateDialog.visible" :host="params.host" @success="getContainers" /> <ContainerCreate v-model:visible="containerCreateDialog.visible" :id="props.id" @success="getContainers" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted, reactive, toRefs } from 'vue'; import { computed, defineAsyncComponent, onMounted, reactive, toRefs, watch } from 'vue';
import { dockerApi, getDockerExecSocketUrl } from '../api'; import { dockerApi, getDockerExecSocketUrl } from '../api';
import { formatByteSize, formatDate } from '@/common/utils/format'; import { formatByteSize, formatDate } from '@/common/utils/format';
import EnumSelect from '@/components/enumselect/EnumSelect.vue'; import EnumSelect from '@/components/enumselect/EnumSelect.vue';
@@ -182,15 +182,15 @@ const ContainerLog = defineAsyncComponent(() => import('./ContainerLog.vue'));
const ContainerCreate = defineAsyncComponent(() => import('./ContainerCreate.vue')); const ContainerCreate = defineAsyncComponent(() => import('./ContainerCreate.vue'));
const props = defineProps({ const props = defineProps({
host: { id: {
type: String, type: Number,
default: '', default: 0,
}, },
}); });
const state = reactive({ const state = reactive({
params: { params: {
host: props.host, id: props.id,
name: '', name: '',
state: null, state: null,
}, },
@@ -222,6 +222,13 @@ onMounted(() => {
getContainers(); getContainers();
}); });
watch(
() => props.id,
() => {
getContainers();
}
);
const filterTableDatas = computed(() => { const filterTableDatas = computed(() => {
let tables: any = state.containers; let tables: any = state.containers;
const nameSearch = state.params.name; const nameSearch = state.params.name;
@@ -241,6 +248,10 @@ const filterTableDatas = computed(() => {
}); });
const getContainers = async () => { const getContainers = async () => {
if (!props.id) {
return;
}
state.params.id = props.id;
state.loadingContainers = true; state.loadingContainers = true;
try { try {
state.containers = await dockerApi.containers.request(state.params); state.containers = await dockerApi.containers.request(state.params);
@@ -281,21 +292,21 @@ const setContainersStats = () => {
}; };
const containerRestart = async (param: any) => { const containerRestart = async (param: any) => {
await dockerApi.containerRestart.request({ host: state.params.host, containerId: param.containerId }); await dockerApi.containerRestart.request({ id: props.id, containerId: param.containerId });
useI18nOperateSuccessMsg(); useI18nOperateSuccessMsg();
getContainers(); getContainers();
}; };
const containerStop = async (param: any) => { const containerStop = async (param: any) => {
await useI18nConfirm('docker.stopContainerConfirm', { name: param.name }); await useI18nConfirm('docker.stopContainerConfirm', { name: param.name });
await dockerApi.containerStop.request({ host: state.params.host, containerId: param.containerId }); await dockerApi.containerStop.request({ id: props.id, containerId: param.containerId });
useI18nOperateSuccessMsg(); useI18nOperateSuccessMsg();
getContainers(); getContainers();
}; };
const containerRemove = async (param: any) => { const containerRemove = async (param: any) => {
await useI18nConfirm('docker.removeContainerConfirm', { name: param.name }); await useI18nConfirm('docker.removeContainerConfirm', { name: param.name });
await dockerApi.containerRemove.request({ host: state.params.host, containerId: param.containerId }); await dockerApi.containerRemove.request({ id: props.id, containerId: param.containerId });
useI18nDeleteSuccessMsg(); useI18nDeleteSuccessMsg();
getContainers(); getContainers();
}; };
@@ -316,11 +327,6 @@ const openLog = (row: any) => {
state.logDialog.visible = true; state.logDialog.visible = true;
}; };
const openUrl = (row: any) => {
const port = row.ports[0];
window.open('http://' + props.host.split('//')[1].split(':')[0] + ':' + port.split('->')[0]?.split(':')[1] + '/lab');
};
const handleCommand = async (commond: any) => { const handleCommand = async (commond: any) => {
const row = commond.row; const row = commond.row;
const type = commond.type; const type = commond.type;

View File

@@ -2,32 +2,34 @@
<div> <div>
<el-drawer title="logs" v-model="visible" @close="close" :destroy-on-close="true" :close-on-click-modal="true" size="60%"> <el-drawer title="logs" v-model="visible" @close="close" :destroy-on-close="true" :close-on-click-modal="true" size="60%">
<template #header> <template #header>
<DrawerHeader :header="props.host" :back="() => (visible = false)"> <DrawerHeader :header="`${props.title}`" :back="() => (visible = false)">
<template #extra> <template #extra>
<div class="mr20"></div> <div class="mr20"></div>
</template> </template>
</DrawerHeader> </DrawerHeader>
</template> </template>
<el-row :gutter="10" class="mb20"> <div class="flex flex-col flex-1">
<el-col :span="6"> <el-row :gutter="10" class="mb-2">
<el-select @change="searchLog" v-model.number="state.tail"> <el-col :span="6">
<template #prefix>{{ $t('docker.lines') }}</template> <el-select @change="searchLog" v-model.number="state.tail">
<el-option :value="100" :label="100" /> <template #prefix>{{ $t('docker.lines') }}</template>
<el-option :value="200" :label="200" /> <el-option :value="100" :label="100" />
<el-option :value="500" :label="500" /> <el-option :value="200" :label="200" />
<el-option :value="1000" :label="1000" /> <el-option :value="500" :label="500" />
</el-select> <el-option :value="1000" :label="1000" />
</el-col> </el-select>
</el-col>
<el-col :span="6"> <el-col :span="6">
<el-checkbox @change="searchLog" border v-model="state.isWatch"> <el-checkbox @change="searchLog" border v-model="state.isWatch">
{{ $t('docker.follow') }} {{ $t('docker.follow') }}
</el-checkbox> </el-checkbox>
</el-col> </el-col>
</el-row> </el-row>
<RealLogViewer ref="realLogViewerRef" :ws-url="wsUrl" height="calc(100vh - 200px)" /> <RealLogViewer ref="realLogViewerRef" :ws-url="wsUrl" height="calc(100vh - 200px)" />
</div>
</el-drawer> </el-drawer>
</div> </div>
</template> </template>
@@ -39,7 +41,11 @@ import { getContainerLogSocketUrl } from '../api';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue'; import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
const props = defineProps({ const props = defineProps({
host: { id: {
type: Number,
default: '',
},
title: {
type: String, type: String,
default: '', default: '',
}, },
@@ -60,7 +66,7 @@ const state = reactive({
}); });
const wsUrl = computed( const wsUrl = computed(
() => `${getContainerLogSocketUrl(props.host, props.containerId)}&tail=${state.tail}&follow=${state.isWatch ? '1' : '0'}&since=${state.since}` () => `${getContainerLogSocketUrl(props.id, props.containerId)}&tail=${state.tail}&follow=${state.isWatch ? '1' : '0'}&since=${state.since}`
); );
const searchLog = () => { const searchLog = () => {

View File

@@ -35,7 +35,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="size" :label="$t('docker.size')" :min-width="50"> <el-table-column prop="size" :label="$t('docker.size')" :min-width="60">
<template #default="{ row }"> <template #default="{ row }">
{{ formatByteSize(row.size) }} {{ formatByteSize(row.size) }}
</template> </template>
@@ -79,7 +79,7 @@
draggable draggable
append-to-body append-to-body
> >
<TerminalBody ref="terminal" :socket-url="getDockerExecSocketUrl(params.host, terminalDialog.containerId)" height="560px" /> <TerminalBody ref="terminal" :socket-url="getDockerExecSocketUrl(props.id, terminalDialog.containerId)" height="560px" />
</el-dialog> </el-dialog>
</template> </template>
@@ -99,15 +99,15 @@ import { ElMessage } from 'element-plus';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
const props = defineProps({ const props = defineProps({
host: { id: {
type: String, type: Number,
default: '', default: '',
}, },
}); });
const state = reactive({ const state = reactive({
params: { params: {
host: props.host, id: 0,
name: '', name: '',
state: null, state: null,
}, },
@@ -147,6 +147,10 @@ const filterTableDatas = computed(() => {
}); });
const getImages = async () => { const getImages = async () => {
if (!props.id) {
return;
}
state.params.id = props.id;
state.loadingImages = true; state.loadingImages = true;
try { try {
state.images = await dockerApi.images.request(state.params); state.images = await dockerApi.images.request(state.params);
@@ -157,7 +161,7 @@ const getImages = async () => {
const exportImage = async (row: any) => { const exportImage = async (row: any) => {
const a = document.createElement('a'); const a = document.createElement('a');
a.setAttribute('href', `${config.baseApiUrl}/docker/images/save?host=${state.params.host}&tag=${row.tags[0]}&${joinClientParams()}`); a.setAttribute('href', `${config.baseApiUrl}/docker/${props.id}/images/save?id=${props.id}&tag=${row.tags[0]}&${joinClientParams()}`);
a.setAttribute('target', '_blank'); a.setAttribute('target', '_blank');
a.click(); a.click();
}; };
@@ -166,7 +170,7 @@ const uploadImage = (content: any) => {
const params = new FormData(); const params = new FormData();
// const path = state.nowPath; // const path = state.nowPath;
params.append('file', content.file); params.append('file', content.file);
params.append('host', state.params.host); params.append('id', props.id + '');
params.append('token', token); params.append('token', token);
dockerApi.imageUpload dockerApi.imageUpload
.xhrReq(params, { .xhrReq(params, {
@@ -193,7 +197,7 @@ const uploadSuccess = (res: any) => {
}; };
const imageRemove = async (row: any) => { const imageRemove = async (row: any) => {
await dockerApi.imageRemove.request({ host: state.params.host, imageId: row.id }); await dockerApi.imageRemove.request({ id: props.id, imageId: row.id });
getImages(); getImages();
}; };

View File

@@ -0,0 +1,47 @@
<template>
<div class="card h-full">
<el-tabs v-model="activeName" @tab-change="handleTabChange">
<el-tab-pane :label="$t('docker.container')" :name="containerTab">
<ContainerList :id="containerConfId" />
</el-tab-pane>
<el-tab-pane :label="$t('docker.image')" :name="imageTab">
<ImageList v-if="activeName == imageTab" :id="containerConfId" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script lang="ts" setup>
import { ContainerOpComp } from '@/views/ops/docker/resource';
import { toRefs, reactive, onMounted, defineAsyncComponent, ref, getCurrentInstance } from 'vue';
const ContainerList = defineAsyncComponent(() => import('../container/ContainerList.vue'));
const ImageList = defineAsyncComponent(() => import('../image/ImageList.vue'));
const emits = defineEmits(['init']);
const containerTab = 'containerTab';
const imageTab = 'imageTab';
const containerConfId = ref<number>(0);
const state = reactive({
activeName: containerTab,
cmdConfs: [],
});
const { activeName } = toRefs(state);
onMounted(async () => {
emits('init', { name: ContainerOpComp.name, ref: getCurrentInstance()?.exposed });
state.activeName = containerTab;
});
const handleTabChange = (tabName: any) => {};
defineExpose({
init: function (id: number) {
containerConfId.value = id;
},
});
</script>

View File

@@ -0,0 +1,46 @@
import { ResourceTypeEnum } from '@/common/commonEnum';
import { defineAsyncComponent } from 'vue';
import { NodeType, TagTreeNode, ResourceComponentConfig, ResourceConfig } from '@/views/ops/component/tag';
import { dockerApi } from '@/views/ops/docker/api';
const ContainerConfList = defineAsyncComponent(() => import('../ContainerConfList.vue'));
const ContainerOp = defineAsyncComponent(() => import('./ContainerOp.vue'));
const Icon = {
name: ResourceTypeEnum.Container.extra.icon,
color: ResourceTypeEnum.Container.extra.iconColor,
};
export const ContainerOpComp: ResourceComponentConfig = {
name: 'tag.containerOp',
component: ContainerOp,
icon: Icon,
};
export const NodeTypeContainerTag = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(async (node: TagTreeNode) => {
// 加载标签树下的容器列表
const res = await dockerApi.page.request({ tagPath: node.params.tagPath });
// 把list 根据name字段排序
return res?.list
.sort((a: any, b: any) => a.name.localeCompare(b.name))
.map((x: any) => TagTreeNode.new(node, x.code, x.name, NodeTypeContainer).withIsLeaf(true).withParams(x).withIcon(Icon));
});
const NodeTypeContainer = new NodeType(11).withNodeClickFunc(async (node: TagTreeNode) => {
(await node.ctx?.addResourceComponent(ContainerOpComp)).init(node.params.id);
});
export default {
order: 1.5,
resourceType: ResourceTypeEnum.Container.value,
rootNodeType: NodeTypeContainerTag,
manager: {
componentConf: {
component: ContainerConfList,
icon: Icon,
name: 'tag.container',
},
permCode: 'container',
countKey: 'container',
},
} as ResourceConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
:destroy-on-close="true" :destroy-on-close="true"
:show-close="true" :show-close="true"
:before-close="handleClose" :before-close="handleClose"
width="55%" width="60%"
> >
<page-table <page-table
ref="pageTableRef" ref="pageTableRef"

View File

@@ -43,7 +43,7 @@ const NodeTypeRedis = new NodeType(2).withLoadNodesFunc(async (parentNode: TagTr
const redisInfo = parentNode.params; const redisInfo = parentNode.params;
let dbs: TagTreeNode[] = redisInfo.db.split(',').map((x: string) => { let dbs: TagTreeNode[] = redisInfo.db.split(',').map((x: string) => {
return TagTreeNode.new(parentNode, x, `db${x}`, NodeTypeDb) return TagTreeNode.new(parentNode, `${parentNode.key}.${x}`, `db${x}`, NodeTypeDb)
.withIsLeaf(true) .withIsLeaf(true)
.withParams({ .withParams({
id: redisInfo.id, id: redisInfo.id,

View File

@@ -41,7 +41,7 @@
</el-dropdown> </el-dropdown>
</span> </span>
<span v-else class="ml-auto pr-1.5 text-[10px] text-gray-400"> <span v-else class="ml-auto pr-2 text-[10px] text-gray-400">
<slot :node="node" :data="data" name="suffix"></slot> <slot :node="node" :data="data" name="suffix"></slot>
</span> </span>
</div> </div>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="h-full"> <div class="h-full">
<ResourceOpPanel @resize="onResizeOpPanel"> <el-splitter @resize="onResizeOpPanel">
<template #left> <el-splitter-panel size="24%" max="40%">
<el-card class="h-full flex tag-tree-card" body-class="!p-0 flex flex-col w-full"> <el-card class="h-full flex tag-tree-card" body-class="!p-0 flex flex-col w-full">
<div class="tag-tree-header flex flex-row justify-between items-center"> <div class="tag-tree-header flex flex-row justify-between items-center">
<el-input v-model="filterText" :placeholder="$t('tag.tagFilterPlaceholder')" clearable size="small" class="tag-tree-search w-full"> <el-input v-model="filterText" :placeholder="$t('tag.tagFilterPlaceholder')" clearable size="small" class="tag-tree-search w-full">
@@ -19,7 +19,7 @@
<el-dropdown-item <el-dropdown-item
:command="{ name }" :command="{ name }"
v-for="(compConf, name) in resourceComponents" v-for="(compConf, name) in resourceComponents"
:disabled="name == activeResourceComp" :disabled="name == activeResourceCompName"
> >
<SvgIcon v-if="compConf.icon" :name="compConf.icon.name" :color="compConf.icon.color" /> <SvgIcon v-if="compConf.icon" :name="compConf.icon.name" :color="compConf.icon.color" />
<div class="ml-1">{{ $t(name) }}</div> <div class="ml-1">{{ $t(name) }}</div>
@@ -53,18 +53,18 @@
</el-tree> </el-tree>
</el-scrollbar> </el-scrollbar>
</el-card> </el-card>
</template> </el-splitter-panel>
<template #right> <el-splitter-panel>
<el-card class="h-full" body-class=" h-full !p-1 flex flex-col flex-1"> <el-card class="h-full" body-class=" h-full !p-1 flex flex-col flex-1">
<transition name="slide-x" mode="out-in"> <transition name="slide-x" mode="out-in">
<keep-alive> <keep-alive>
<component :is="resourceComponents[activeResourceComp]?.component" :key="activeResourceComp" @init="initResourceComp" /> <component :is="resourceComponents[activeResourceCompName]?.component" :key="activeResourceCompName" @init="initResourceComp" />
</keep-alive> </keep-alive>
</transition> </transition>
</el-card> </el-card>
</template> </el-splitter-panel>
</ResourceOpPanel> </el-splitter>
</div> </div>
</template> </template>
@@ -78,7 +78,6 @@ import EnumValue from '@/common/Enum';
import { getResourceNodeType, getResourceTypes, ResourceOpCtxKey } from './resource'; import { getResourceNodeType, getResourceTypes, ResourceOpCtxKey } from './resource';
import BaseTreeNode from './BaseTreeNode.vue'; import BaseTreeNode from './BaseTreeNode.vue';
import { tagApi } from '@/views/ops/tag/api'; import { tagApi } from '@/views/ops/tag/api';
import ResourceOpPanel from '@/views/ops/component/ResourceOpPanel.vue';
import { TagTreeNode, ResourceComponentConfig, ResourceOpCtx } from '@/views/ops/component/tag'; import { TagTreeNode, ResourceComponentConfig, ResourceOpCtx } from '@/views/ops/component/tag';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAutoOpenResource } from '@/store/autoOpenResource'; import { useAutoOpenResource } from '@/store/autoOpenResource';
@@ -110,10 +109,14 @@ const emit = defineEmits(['nodeClick', 'currentContextmenuClick']);
const treeRef: any = useTemplateRef('treeRef'); const treeRef: any = useTemplateRef('treeRef');
// 存储所有注册的资源组件引用 // 存储所有注册的资源组件引用key -> 组件名称
const resourceComponents = ref<Record<string, ResourceComponentConfig>>({}); const resourceComponents = ref<Record<string, ResourceComponentConfig>>({});
// 当前激活的资源组件
const activeResourceComp = ref<string>(''); // 存储当前组件对应的最后操作的节点key用户切换资源操作组件时定位到相应的树节点
const resourceComponentsNodeKey = ref<Record<string, string>>({});
// 当前激活(正在操作)的资源组件
const activeResourceCompName = ref<string>('');
const resourceComponentRefs = ref<Record<string, any>>({}); const resourceComponentRefs = ref<Record<string, any>>({});
@@ -214,20 +217,26 @@ let lastNodeClickTime = 0;
const treeNodeClick = async (data: any, node: any) => { const treeNodeClick = async (data: any, node: any) => {
const currentClickNodeTime = Date.now(); const currentClickNodeTime = Date.now();
// 双击节点
if (currentClickNodeTime - lastNodeClickTime < 300) { if (currentClickNodeTime - lastNodeClickTime < 300) {
treeNodeDblclick(data, node); await treeNodeDblclick(data, node);
return; } else {
lastNodeClickTime = currentClickNodeTime;
if (!data.disabled && !data.type.nodeDblclickFunc && data.type.nodeClickFunc) {
emit('nodeClick', data);
await data.type.nodeClickFunc(data);
}
} }
lastNodeClickTime = currentClickNodeTime;
if (!data.disabled && !data.type.nodeDblclickFunc && data.type.nodeClickFunc) { setTimeout(() => {
emit('nodeClick', data); if (activeResourceCompName.value) {
await data.type.nodeClickFunc(data); resourceComponentsNodeKey.value[activeResourceCompName.value] = data.key;
} }
}, 500);
}; };
// 树节点双击事件 // 树节点双击事件
const treeNodeDblclick = (data: any, node: any) => { const treeNodeDblclick = async (data: any, node: any) => {
if (node.expanded) { if (node.expanded) {
node.collapse(); node.collapse();
} else { } else {
@@ -235,7 +244,7 @@ const treeNodeDblclick = (data: any, node: any) => {
} }
if (!data.disabled && data.type.nodeDblclickFunc) { if (!data.disabled && data.type.nodeDblclickFunc) {
data.type.nodeDblclickFunc(data); await data.type.nodeDblclickFunc(data);
} }
}; };
@@ -248,7 +257,6 @@ const initResourceComp = (val: any) => {
}; };
const addResourceComponent = async (componentConf: ResourceComponentConfig) => { const addResourceComponent = async (componentConf: ResourceComponentConfig) => {
console.log(componentConf);
const compName = componentConf.name; const compName = componentConf.name;
if (!resourceComponents.value[compName]) { if (!resourceComponents.value[compName]) {
@@ -259,7 +267,7 @@ const addResourceComponent = async (componentConf: ResourceComponentConfig) => {
}; };
} }
activeResourceComp.value = compName; activeResourceCompName.value = compName;
// 使用一个 Promise 来确保组件引用已经被设置 // 使用一个 Promise 来确保组件引用已经被设置
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -279,7 +287,11 @@ const addResourceComponent = async (componentConf: ResourceComponentConfig) => {
}; };
const changeResourceOp = (data: any) => { const changeResourceOp = (data: any) => {
activeResourceComp.value = data.name; const compName = data.name;
activeResourceCompName.value = compName;
if (resourceComponentsNodeKey.value[compName]) {
setCurrentKey(resourceComponentsNodeKey.value[compName]);
}
}; };
const reloadNode = (nodeKey: any) => { const reloadNode = (nodeKey: any) => {

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="tag-tree-list card !p-2 h-full flex"> <div class="tag-tree-list card !p-2 h-full flex">
<el-splitter> <el-splitter>
<el-splitter-panel size="25%" max="35%" class="flex flex-col flex-1"> <el-splitter-panel size="24%" max="35%" class="flex flex-col flex-1">
<div class="card !p-1 !mr-1 flex flex-row items-center justify-between overflow-hidden"> <div class="card !p-1 !mr-1 flex flex-row items-center justify-between overflow-hidden">
<el-input v-model="filterTag" clearable :placeholder="$t('tag.nameFilterPlaceholder')" class="mr-2" /> <el-input v-model="filterTag" clearable :placeholder="$t('tag.nameFilterPlaceholder')" class="mr-2" />
<el-button <el-button
@@ -502,7 +502,7 @@ const removeDeafultExpandId = (id: any) => {
} }
}; };
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
.tag-tree-list { .tag-tree-list {
.tag-tree-data { .tag-tree-data {
// .el-tree-node__content { // .el-tree-node__content {

View File

@@ -33,7 +33,7 @@ require (
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/tidwall/gjson v1.18.0 github.com/tidwall/gjson v1.18.0
github.com/veops/go-ansiterm v0.0.5 github.com/veops/go-ansiterm v0.0.5
go.mongodb.org/mongo-driver/v2 v2.2.2 // mongo go.mongodb.org/mongo-driver/v2 v2.3.0 // mongo
golang.org/x/crypto v0.41.0 // ssh golang.org/x/crypto v0.41.0 // ssh
golang.org/x/oauth2 v0.30.0 golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.16.0 golang.org/x/sync v0.16.0
@@ -41,7 +41,7 @@ require (
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
// gorm // gorm
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.30.2 gorm.io/gorm v1.30.3
) )
require ( require (

View File

@@ -3,6 +3,7 @@ package api
import "mayfly-go/pkg/ioc" import "mayfly-go/pkg/ioc"
func InitIoc() { func InitIoc() {
ioc.Register(new(ContainerConf))
ioc.Register(new(Docker)) ioc.Register(new(Docker))
ioc.Register(new(Container)) ioc.Register(new(Container))
ioc.Register(new(Image)) ioc.Register(new(Image))

View File

@@ -6,7 +6,6 @@ import (
"io" "io"
"mayfly-go/internal/docker/api/form" "mayfly-go/internal/docker/api/form"
"mayfly-go/internal/docker/api/vo" "mayfly-go/internal/docker/api/vo"
"mayfly-go/internal/docker/dkm"
"mayfly-go/internal/docker/imsg" "mayfly-go/internal/docker/imsg"
"mayfly-go/pkg/biz" "mayfly-go/pkg/biz"
"mayfly-go/pkg/errorx" "mayfly-go/pkg/errorx"
@@ -52,12 +51,11 @@ func (d *Container) ReqConfs() *req.Confs {
req.NewGet("/logs", d.ContainerLogs).NoRes(), req.NewGet("/logs", d.ContainerLogs).NoRes(),
} }
return req.NewConfs("docker/containers", reqs[:]...) return req.NewConfs("docker/:id/containers", reqs[:]...)
} }
func (d *Container) GetContainers(rc *req.Ctx) { func (d *Container) GetContainers(rc *req.Ctx) {
cli, err := dkm.GetCli(rc.Query("host")) cli := GetCli(rc)
biz.ErrIsNil(err)
cs, err := cli.ContainerList() cs, err := cli.ContainerList()
biz.ErrIsNil(err) biz.ErrIsNil(err)
@@ -89,8 +87,7 @@ func (d *Container) GetContainers(rc *req.Ctx) {
} }
func (d *Container) GetContainersStats(rc *req.Ctx) { func (d *Container) GetContainersStats(rc *req.Ctx) {
cli, err := dkm.GetCli(rc.Query("host")) cli := GetCli(rc)
biz.ErrIsNil(err)
cs, err := cli.ContainerList() cs, err := cli.ContainerList()
biz.ErrIsNil(err) biz.ErrIsNil(err)
@@ -141,8 +138,7 @@ func (d *Container) ContainerCreate(rc *req.Ctx) {
rc.ReqParam = containerCreate rc.ReqParam = containerCreate
cli, err := dkm.GetCli(containerCreate.Host) cli := GetCli(rc)
biz.ErrIsNil(err)
config, hostConfig, networkConfig, err := loadConfigInfo(true, containerCreate, nil) config, hostConfig, networkConfig, err := loadConfigInfo(true, containerCreate, nil)
biz.ErrIsNil(err) biz.ErrIsNil(err)
@@ -167,36 +163,30 @@ func (d *Container) ContainerStop(rc *req.Ctx) {
containerOp := &form.ContainerOp{} containerOp := &form.ContainerOp{}
biz.ErrIsNil(rc.BindJSON(containerOp)) biz.ErrIsNil(rc.BindJSON(containerOp))
rc.ReqParam = collx.Kvs("host", containerOp.Host, "containerId", containerOp.ContainerId) cli := GetCli(rc)
rc.ReqParam = collx.Kvs("addr", cli.Server.Addr, "containerId", containerOp.ContainerId)
cli, err := dkm.GetCli(containerOp.Host) biz.ErrIsNil(cli.ContainerStop(containerOp.ContainerId))
biz.ErrIsNil(err)
err = cli.ContainerStop(containerOp.ContainerId)
biz.ErrIsNil(err)
} }
func (d *Container) ContainerRemove(rc *req.Ctx) { func (d *Container) ContainerRemove(rc *req.Ctx) {
containerOp := &form.ContainerOp{} containerOp := &form.ContainerOp{}
biz.ErrIsNil(rc.BindJSON(containerOp)) biz.ErrIsNil(rc.BindJSON(containerOp))
rc.ReqParam = collx.Kvs("host", containerOp.Host, "containerId", containerOp.ContainerId) cli := GetCli(rc)
rc.ReqParam = collx.Kvs("addr", cli.Server.Addr, "containerId", containerOp.ContainerId)
cli, err := dkm.GetCli(containerOp.Host) biz.ErrIsNil(cli.ContainerRemove(containerOp.ContainerId))
biz.ErrIsNil(err)
err = cli.ContainerRemove(containerOp.ContainerId)
biz.ErrIsNil(err)
} }
func (d *Container) ContainerRestart(rc *req.Ctx) { func (d *Container) ContainerRestart(rc *req.Ctx) {
containerOp := &form.ContainerOp{} containerOp := &form.ContainerOp{}
biz.ErrIsNil(rc.BindJSON(containerOp)) biz.ErrIsNil(rc.BindJSON(containerOp))
rc.ReqParam = collx.Kvs("host", containerOp.Host, "containerId", containerOp.ContainerId) cli := GetCli(rc)
rc.ReqParam = collx.Kvs("addr", cli.Server.Addr, "containerId", containerOp.ContainerId)
cli, err := dkm.GetCli(containerOp.Host) biz.ErrIsNil(cli.ContainerRestart(containerOp.ContainerId))
biz.ErrIsNil(err)
err = cli.ContainerRestart(containerOp.ContainerId)
biz.ErrIsNil(err)
} }
func (d *Container) ContainerLogs(rc *req.Ctx) { func (d *Container) ContainerLogs(rc *req.Ctx) {
@@ -211,9 +201,7 @@ func (d *Container) ContainerLogs(rc *req.Ctx) {
}() }()
biz.ErrIsNilAppendErr(err, "Upgrade websocket fail: %s") biz.ErrIsNilAppendErr(err, "Upgrade websocket fail: %s")
cli, err := dkm.GetCli(rc.Query("host")) cli := GetCli(rc)
biz.ErrIsNil(err)
ctx, cancel := context.WithCancel(rc.MetaCtx) ctx, cancel := context.WithCancel(rc.MetaCtx)
defer cancel() defer cancel()
@@ -288,9 +276,7 @@ func (d *Container) ContainerExecAttach(rc *req.Ctx) {
biz.ErrIsNilAppendErr(err, "Upgrade websocket fail: %s") biz.ErrIsNilAppendErr(err, "Upgrade websocket fail: %s")
wsConn.WriteMessage(websocket.TextMessage, []byte("Connecting to container...")) wsConn.WriteMessage(websocket.TextMessage, []byte("Connecting to container..."))
cli, err := dkm.GetCli(rc.Query("host")) cli := GetCli(rc)
biz.ErrIsNil(err)
cols := rc.QueryIntDefault("cols", 80) cols := rc.QueryIntDefault("cols", 80)
rows := rc.QueryIntDefault("rows", 32) rows := rc.QueryIntDefault("rows", 32)
@@ -311,9 +297,7 @@ func (d *Container) ContainerProxy(rc *req.Ctx) {
containerID := pathParts[2] containerID := pathParts[2]
remainingPath := strings.Join(pathParts[3:], "/") remainingPath := strings.Join(pathParts[3:], "/")
cli, err := dkm.GetCli(rc.Query("host")) cli := GetCli(rc)
biz.ErrIsNil(err)
ctx := rc.MetaCtx ctx := rc.MetaCtx
containerJSON, err := cli.DockerClient.ContainerInspect(ctx, containerID) containerJSON, err := cli.DockerClient.ContainerInspect(ctx, containerID)
biz.ErrIsNil(err) biz.ErrIsNil(err)

View File

@@ -0,0 +1,96 @@
package api
import (
"mayfly-go/internal/docker/api/form"
"mayfly-go/internal/docker/api/vo"
"mayfly-go/internal/docker/application"
"mayfly-go/internal/docker/application/dto"
"mayfly-go/internal/docker/dkm"
"mayfly-go/internal/docker/domain/entity"
"mayfly-go/internal/pkg/consts"
tagapp "mayfly-go/internal/tag/application"
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/model"
"mayfly-go/pkg/req"
"mayfly-go/pkg/utils/collx"
"strings"
"github.com/spf13/cast"
)
type ContainerConf struct {
containerApp application.Container `inject:"T"`
tagTreeApp tagapp.TagTree `inject:"T"`
}
func (cc *ContainerConf) ReqConfs() *req.Confs {
reqs := [...]*req.Conf{
req.NewGet("/page", cc.GetContainerPage),
req.NewPost("/save", cc.Save),
req.NewDelete("/del/:ids", cc.Delete),
}
return req.NewConfs("docker/container-conf", reqs[:]...)
}
func (cc *ContainerConf) GetContainerPage(rc *req.Ctx) {
condition := req.BindQuery[*entity.ContainerQuery](rc)
tags := cc.tagTreeApp.GetAccountTags(rc.GetLoginAccount().Id, &tagentity.TagTreeQuery{
TypePaths: collx.AsArray(tagentity.NewTypePaths(tagentity.TagTypeContainer)),
CodePathLikes: collx.AsArray(condition.TagPath),
})
// 不存在,即没有可操作数据
if len(tags) == 0 {
rc.ResData = model.NewEmptyPageResult[any]()
return
}
tagCodePaths := tags.GetCodePaths()
containerCodes := tagentity.GetCodesByCodePaths(tagentity.TagTypeContainer, tagCodePaths...)
condition.Codes = collx.ArrayDeduplicate(containerCodes)
res, err := cc.containerApp.GetContainerPage(condition)
biz.ErrIsNil(err)
if res.Total == 0 {
rc.ResData = res
return
}
resVo := model.PageResultConv[*entity.Container, *vo.ContainerConf](res)
containerVos := resVo.List
cc.tagTreeApp.FillTagInfo(tagentity.TagType(consts.ResourceTypeContainer), collx.ArrayMap(containerVos, func(cvo *vo.ContainerConf) tagentity.ITagResource {
return cvo
})...)
rc.ResData = resVo
}
func (c *ContainerConf) Save(rc *req.Ctx) {
machineForm, container := req.BindJsonAndCopyTo[*form.ContainerSave, *entity.Container](rc)
rc.ReqParam = machineForm
biz.ErrIsNil(c.containerApp.SaveContainer(rc.MetaCtx, &dto.SaveContainer{
Container: container,
TagCodePaths: machineForm.TagCodePaths,
}))
}
func (c *ContainerConf) Delete(rc *req.Ctx) {
idsStr := rc.PathParam("ids")
rc.ReqParam = idsStr
ids := strings.Split(idsStr, ",")
for _, v := range ids {
biz.ErrIsNil(c.containerApp.DeleteContainer(rc.MetaCtx, cast.ToUint64(v)))
}
}
func GetCli(rc *req.Ctx) *dkm.Client {
id := rc.PathParamInt("id")
biz.IsTrue(id > 0, "id error")
cli, err := application.GetContainerApp().GetContainerCli(rc.MetaCtx, uint64(id))
biz.ErrIsNil(err)
return cli
}

View File

@@ -1,7 +1,6 @@
package api package api
import ( import (
"mayfly-go/internal/docker/dkm"
"mayfly-go/pkg/biz" "mayfly-go/pkg/biz"
"mayfly-go/pkg/req" "mayfly-go/pkg/req"
) )
@@ -14,13 +13,11 @@ func (d *Docker) ReqConfs() *req.Confs {
req.NewGet("/info", d.GetDockerInfo), req.NewGet("/info", d.GetDockerInfo),
} }
return req.NewConfs("docker", reqs[:]...) return req.NewConfs("docker/:id", reqs[:]...)
} }
func (d *Docker) GetDockerInfo(rc *req.Ctx) { func (d *Docker) GetDockerInfo(rc *req.Ctx) {
host := rc.Query("host") cli := GetCli(rc)
cli, err := dkm.GetCli(host)
biz.ErrIsNil(err)
info, err := cli.DockerClient.Info(rc.MetaCtx) info, err := cli.DockerClient.Info(rc.MetaCtx)
biz.ErrIsNil(err) biz.ErrIsNil(err)
rc.ResData = info rc.ResData = info

View File

@@ -1,12 +1,22 @@
package form package form
import "mayfly-go/pkg/model"
type ContainerSave struct {
model.ExtraData
Id uint64 `json:"id"`
Addr string `json:"addr" binding:"required"`
Name string `json:"name" binding:"required"`
Remark string `json:"remark"`
TagCodePaths []string `json:"tagCodePaths" binding:"required"`
}
type ContainerOp struct { type ContainerOp struct {
Host string `json:"host"`
ContainerId string `json:"containerId" binding:"required"` ContainerId string `json:"containerId" binding:"required"`
} }
type ContainerCreate struct { type ContainerCreate struct {
Host string `json:"host" binding:"required"`
ContainerID string `json:"containerId"` ContainerID string `json:"containerId"`
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Image string `json:"image" validate:"required"` Image string `json:"image" validate:"required"`

View File

@@ -1,6 +1,5 @@
package form package form
type ImageOp struct { type ImageOp struct {
Host string `json:"host" binding:"required"`
ImageId string `json:"imageId" binding:"required"` ImageId string `json:"imageId" binding:"required"`
} }

View File

@@ -5,7 +5,6 @@ import (
"io" "io"
"mayfly-go/internal/docker/api/form" "mayfly-go/internal/docker/api/form"
"mayfly-go/internal/docker/api/vo" "mayfly-go/internal/docker/api/vo"
"mayfly-go/internal/docker/dkm"
"mayfly-go/internal/docker/imsg" "mayfly-go/internal/docker/imsg"
"mayfly-go/pkg/biz" "mayfly-go/pkg/biz"
"mayfly-go/pkg/req" "mayfly-go/pkg/req"
@@ -32,12 +31,11 @@ func (d *Image) ReqConfs() *req.Confs {
req.NewPost("/load", d.ImageLoad).Log(req.NewLogSaveI(imsg.LogDockerImageLoad)), req.NewPost("/load", d.ImageLoad).Log(req.NewLogSaveI(imsg.LogDockerImageLoad)),
} }
return req.NewConfs("docker/images", reqs[:]...) return req.NewConfs("docker/:id/images", reqs[:]...)
} }
func (d *Image) GetImages(rc *req.Ctx) { func (d *Image) GetImages(rc *req.Ctx) {
cli, err := dkm.GetCli(rc.Query("host")) cli := GetCli(rc)
biz.ErrIsNil(err)
is, err := cli.ImageList() is, err := cli.ImageList()
biz.ErrIsNil(err) biz.ErrIsNil(err)
@@ -62,18 +60,12 @@ func (d *Image) ImageRemove(rc *req.Ctx) {
imageOp := &form.ImageOp{} imageOp := &form.ImageOp{}
biz.ErrIsNil(rc.BindJSON(imageOp)) biz.ErrIsNil(rc.BindJSON(imageOp))
rc.ReqParam = collx.Kvs("host", imageOp.Host, "imageId", imageOp.ImageId) rc.ReqParam = collx.Kvs("imageId", imageOp.ImageId)
cli, err := dkm.GetCli(imageOp.Host) cli := GetCli(rc)
biz.ErrIsNil(err) biz.ErrIsNil(cli.ImageRemove(imageOp.ImageId))
err = cli.ImageRemove(imageOp.ImageId)
biz.ErrIsNil(err)
} }
func (d *Image) ImageLoad(rc *req.Ctx) { func (d *Image) ImageLoad(rc *req.Ctx) {
host := rc.PostForm("host")
biz.NotEmpty(host, "host cannot be empty")
rc.ReqParam = host
fileheader, err := rc.FormFile("file") fileheader, err := rc.FormFile("file")
biz.ErrIsNilAppendErr(err, "read form file error: %s") biz.ErrIsNilAppendErr(err, "read form file error: %s")
@@ -81,8 +73,9 @@ func (d *Image) ImageLoad(rc *req.Ctx) {
biz.ErrIsNil(err) biz.ErrIsNil(err)
defer file.Close() defer file.Close()
cli, err := dkm.GetCli(host) cli := GetCli(rc)
biz.ErrIsNil(err) rc.ReqParam = cli.Server
resp, err := cli.DockerClient.ImageLoad(rc.MetaCtx, file) resp, err := cli.DockerClient.ImageLoad(rc.MetaCtx, file)
biz.ErrIsNil(err) biz.ErrIsNil(err)
defer resp.Body.Close() defer resp.Body.Close()
@@ -94,14 +87,10 @@ func (d *Image) ImageLoad(rc *req.Ctx) {
} }
func (d *Image) ImageExport(rc *req.Ctx) { func (d *Image) ImageExport(rc *req.Ctx) {
host := rc.Query("host")
biz.NotEmpty(host, "host cannot be empty")
tag := rc.Query("tag") tag := rc.Query("tag")
biz.NotEmpty(tag, "tag cannot be empty") biz.NotEmpty(tag, "tag cannot be empty")
cli, err := dkm.GetCli(host) cli := GetCli(rc)
biz.ErrIsNil(err)
reader, err := cli.DockerClient.ImageSave(rc.MetaCtx, []string{tag}, client.ImageSaveWithPlatforms()) reader, err := cli.DockerClient.ImageSave(rc.MetaCtx, []string{tag}, client.ImageSaveWithPlatforms())
biz.ErrIsNil(err) biz.ErrIsNil(err)
defer reader.Close() defer reader.Close()

View File

@@ -0,0 +1,21 @@
package vo
import (
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/model"
)
type ContainerConf struct {
model.Model
model.ExtraData
tagentity.ResourceTags // 标签信息
Addr string `json:"addr"`
Code string `json:"code"`
Name string `json:"name"`
Remark string `json:"remark"`
}
func (c *ContainerConf) GetCode() string {
return c.Code
}

View File

@@ -0,0 +1,13 @@
package application
import (
"mayfly-go/pkg/ioc"
)
func InitIoc() {
ioc.Register(new(containerAppImpl), ioc.WithComponentName("ContainerApp"))
}
func GetContainerApp() Container {
return ioc.Get[Container]("ContainerApp")
}

View File

@@ -0,0 +1,144 @@
package application
import (
"context"
"mayfly-go/internal/docker/application/dto"
"mayfly-go/internal/docker/dkm"
"mayfly-go/internal/docker/domain/entity"
"mayfly-go/internal/docker/domain/repository"
"mayfly-go/internal/docker/imsg"
tagapp "mayfly-go/internal/tag/application"
tagdto "mayfly-go/internal/tag/application/dto"
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/base"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils/stringx"
)
type Container interface {
base.App[*entity.Container]
GetContainerPage(condition *entity.ContainerQuery, orderBy ...string) (*model.PageResult[*entity.Container], error)
// SaveContainer 保存容器配置信息
SaveContainer(context.Context, *dto.SaveContainer) error
// DeleteContainer 删除容器配置信息
DeleteContainer(context.Context, uint64) error
// GetContaienrCli 获取容器客户端
GetContainerCli(context.Context, uint64) (*dkm.Client, error)
}
type containerAppImpl struct {
base.AppImpl[*entity.Container, repository.Container]
tagApp tagapp.TagTree `inject:"T"`
}
var _ (Container) = (*containerAppImpl)(nil)
func (c *containerAppImpl) GetContainerPage(condition *entity.ContainerQuery, orderBy ...string) (*model.PageResult[*entity.Container], error) {
return c.Repo.GetContainerPage(condition, orderBy...)
}
func (c *containerAppImpl) SaveContainer(ctx context.Context, saveContainer *dto.SaveContainer) error {
container := saveContainer.Container
tagCodePaths := saveContainer.TagCodePaths
resourceType := tagentity.TagTypeContainer
oldContainer := &entity.Container{
Addr: container.Addr,
}
err := c.GetByCond(oldContainer)
if container.Id == 0 {
if err == nil {
return errorx.NewBizI(ctx, imsg.ErrContainerConfExist)
}
// 生成随机编号
container.Code = stringx.Rand(10)
return c.Tx(ctx, func(ctx context.Context) error {
if err := c.Insert(ctx, container); err != nil {
return err
}
return c.tagApp.SaveResourceTag(ctx, &tagdto.SaveResourceTag{
ResourceTag: &tagdto.ResourceTag{
Code: container.Code,
Name: container.Name,
Type: resourceType,
},
ParentTagCodePaths: tagCodePaths,
})
})
}
if err == nil && container.Id != oldContainer.Id {
return errorx.NewBizI(ctx, imsg.ErrContainerConfExist)
}
if oldContainer.Code == "" {
oldContainer, _ = c.GetById(container.Id)
}
dkm.CloseCli(oldContainer.Id)
return c.Tx(ctx, func(ctx context.Context) error {
if err := c.UpdateById(ctx, container); err != nil {
return err
}
if oldContainer.Name != container.Name {
if err := c.tagApp.UpdateTagName(ctx, tagentity.TagTypeMachine, oldContainer.Code, container.Name); err != nil {
return err
}
}
return c.tagApp.SaveResourceTag(ctx, &tagdto.SaveResourceTag{
ResourceTag: &tagdto.ResourceTag{
Code: oldContainer.Code,
Name: container.Name,
Type: resourceType,
},
ParentTagCodePaths: tagCodePaths,
})
})
}
func (c *containerAppImpl) DeleteContainer(ctx context.Context, id uint64) error {
container, err := c.GetById(id)
if err != nil {
return err
}
dkm.CloseCli(id)
return c.Tx(ctx, func(ctx context.Context) error {
if err := c.DeleteById(ctx, id); err != nil {
return err
}
return c.tagApp.SaveResourceTag(ctx, &tagdto.SaveResourceTag{
ResourceTag: &tagdto.ResourceTag{
Code: container.Code,
Type: tagentity.TagTypeContainer,
},
})
})
}
func (c *containerAppImpl) GetContainerCli(ctx context.Context, id uint64) (*dkm.Client, error) {
return dkm.GetCli(id, func(u uint64) (*dkm.ContainerServer, error) {
containerConf, err := c.GetById(u)
if err != nil {
return nil, err
}
return &dkm.ContainerServer{
Id: id,
Addr: containerConf.Addr,
}, nil
})
}

View File

@@ -0,0 +1,8 @@
package dto
import "mayfly-go/internal/docker/domain/entity"
type SaveContainer struct {
Container *entity.Container
TagCodePaths []string
}

View File

@@ -3,6 +3,7 @@ package dkm
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"mayfly-go/internal/machine/mcm" "mayfly-go/internal/machine/mcm"
"mayfly-go/pkg/logx" "mayfly-go/pkg/logx"
@@ -26,13 +27,13 @@ const (
DefaultServer = "unix:///var/run/docker.sock" DefaultServer = "unix:///var/run/docker.sock"
) )
type DockerServer struct { type ContainerServer struct {
Host string Id uint64
Addr string
Client *client.Client
} }
type Client struct { type Client struct {
Server *ContainerServer
DockerClient *client.Client DockerClient *client.Client
} }
@@ -46,12 +47,13 @@ func (c *Client) Ping() error {
} }
// GetCli get docker cli // GetCli get docker cli
func GetCli(host string) (*Client, error) { func GetCli(id uint64, getContainer func(uint64) (*ContainerServer, error)) (*Client, error) {
if host == "" { pool, err := poolGroup.GetCachePool(fmt.Sprintf("%d", id), func() (*Client, error) {
host = DefaultServer containerServer, err := getContainer(id)
} if err != nil {
pool, err := poolGroup.GetCachePool(host, func() (*Client, error) { return nil, err
return NewClient(&DockerServer{Host: host}) }
return NewClient(containerServer)
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -59,18 +61,23 @@ func GetCli(host string) (*Client, error) {
return pool.Get(context.Background()) return pool.Get(context.Background())
} }
func CloseCli(id uint64) error {
return poolGroup.Close(fmt.Sprintf("%d", id))
}
// NewClient new docker client // NewClient new docker client
func NewClient(server *DockerServer) (*Client, error) { func NewClient(server *ContainerServer) (*Client, error) {
if server.Host == "" { if server.Addr == "" {
server.Host = DefaultServer server.Addr = DefaultServer
} }
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithHost(server.Host), client.WithAPIVersionNegotiation()) cli, err := client.NewClientWithOpts(client.FromEnv, client.WithHost(server.Addr), client.WithAPIVersionNegotiation())
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Client{ return &Client{
DockerClient: cli, DockerClient: cli,
Server: server,
}, nil }, nil
} }

View File

@@ -0,0 +1,14 @@
package entity
import "mayfly-go/pkg/model"
// 容器配置
type Container struct {
model.Model
model.ExtraData
Code string `json:"code" gorm:"size:32;comment:code"` // code
Name string `json:"name" gorm:"size:32"` // 名称
Addr string `json:"addr" gorm:"size:64;not null;comment:地址"` // 地址
Remark string `json:"remark" gorm:"comment:备注"` // 备注
}

View File

@@ -0,0 +1,16 @@
package entity
import "mayfly-go/pkg/model"
type ContainerQuery struct {
model.PageParam
Id uint64 `json:"id" form:"id"`
Code string `json:"code" form:"code"`
Name string `json:"name" form:"name"`
Addr string `json:"addr" form:"addr"`
TagPath string `json:"tagPath" form:"tagPath"`
Keyword string `json:"keyword" form:"keyword"`
Codes []string
}

View File

@@ -0,0 +1,14 @@
package repository
import (
"mayfly-go/internal/docker/domain/entity"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
)
type Container interface {
base.Repo[*entity.Container]
// 分页获取容器配置列表
GetContainerPage(condition *entity.ContainerQuery, orderBy ...string) (*model.PageResult[*entity.Container], error)
}

View File

@@ -5,4 +5,11 @@ import "mayfly-go/pkg/i18n"
var En = map[i18n.MsgId]string{ var En = map[i18n.MsgId]string{
LogDockerContainerStop: "Container - Stop", LogDockerContainerStop: "Container - Stop",
LogDockerContainerRestart: "Container - Restart", LogDockerContainerRestart: "Container - Restart",
LogDockerContainerRemove: "Container - Remove",
LogDockerContainerCreate: "Container - Create",
LogDockerImageRemove: "Image - Remove",
LogDockerImageLoad: "Image - Load",
ErrContainerConfExist: "Container conf already exists",
} }

View File

@@ -18,4 +18,6 @@ const (
LogDockerImageRemove LogDockerImageRemove
LogDockerImageLoad LogDockerImageLoad
ErrContainerConfExist
) )

View File

@@ -10,4 +10,6 @@ var Zh_CN = map[i18n.MsgId]string{
LogDockerImageRemove: "镜像-删除", LogDockerImageRemove: "镜像-删除",
LogDockerImageLoad: "镜像-导入", LogDockerImageLoad: "镜像-导入",
ErrContainerConfExist: "容器配置已存在",
} }

View File

@@ -0,0 +1,33 @@
package persistence
import (
"mayfly-go/internal/docker/domain/entity"
"mayfly-go/internal/docker/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
)
type containerRepoImpl struct {
base.RepoImpl[*entity.Container]
}
func newContainerRepo() repository.Container {
return &containerRepoImpl{}
}
func (m *containerRepoImpl) GetContainerPage(condition *entity.ContainerQuery, orderBy ...string) (*model.PageResult[*entity.Container], error) {
qd := model.NewCond().
Eq("id", condition.Id).
Like("addr", condition.Addr).
Like("name", condition.Name).
In("code", condition.Codes).
Eq("code", condition.Code)
keyword := condition.Keyword
if keyword != "" {
keyword = "%" + keyword + "%"
qd.And("addr like ? or name like ? or code like ?", keyword, keyword, keyword)
}
return m.PageByCond(qd, condition.PageParam)
}

View File

@@ -0,0 +1,9 @@
package persistence
import (
"mayfly-go/pkg/ioc"
)
func InitIoc() {
ioc.Register(newContainerRepo(), ioc.WithComponentName("ContainerRepo"))
}

View File

@@ -1,7 +1,16 @@
package init package init
import "mayfly-go/internal/docker/api" import (
"mayfly-go/initialize"
"mayfly-go/internal/docker/api"
"mayfly-go/internal/docker/application"
"mayfly-go/internal/docker/infra/persistence"
)
func init() { func init() {
api.InitIoc() initialize.AddInitIocFunc(func() {
persistence.InitIoc()
application.InitIoc()
api.InitIoc()
})
} }

View File

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

View File

@@ -9,6 +9,7 @@ const (
ResourceTypeMongo int8 = 4 ResourceTypeMongo int8 = 4
ResourceTypeAuthCert int8 = 5 ResourceTypeAuthCert int8 = 5
ResourceTypeEsInstance int8 = 6 ResourceTypeEsInstance int8 = 6
ResourceTypeContainer int8 = 7
// imsg起始编号 // imsg起始编号
ImsgNumSys = 10000 ImsgNumSys = 10000

View File

@@ -183,6 +183,10 @@ func (p *TagTree) CountTagResource(rc *req.Ctx) {
Types: collx.AsArray(entity.TagTypeMongo), Types: collx.AsArray(entity.TagTypeMongo),
CodePathLikes: collx.AsArray(tagPath), CodePathLikes: collx.AsArray(tagPath),
}).GetCodes()), }).GetCodes()),
"container": len(p.tagTreeApp.GetAccountTags(accountId, &entity.TagTreeQuery{
Types: collx.AsArray(entity.TagTypeContainer),
CodePathLikes: collx.AsArray(tagPath),
}).GetCodes()),
} }
} }

View File

@@ -36,6 +36,7 @@ const (
TagTypeRedis TagType = TagType(consts.ResourceTypeRedis) TagTypeRedis TagType = TagType(consts.ResourceTypeRedis)
TagTypeMongo TagType = TagType(consts.ResourceTypeMongo) TagTypeMongo TagType = TagType(consts.ResourceTypeMongo)
TagTypeAuthCert TagType = TagType(consts.ResourceTypeAuthCert) // 授权凭证类型 TagTypeAuthCert TagType = TagType(consts.ResourceTypeAuthCert) // 授权凭证类型
TagTypeContainer TagType = TagType(consts.ResourceTypeContainer)
TagTypeDb TagType = 22 // 数据库名 TagTypeDb TagType = 22 // 数据库名
) )

View File

@@ -1,6 +1,7 @@
package migrations package migrations
import ( import (
dockerentity "mayfly-go/internal/docker/domain/entity"
esentity "mayfly-go/internal/es/domain/entity" esentity "mayfly-go/internal/es/domain/entity"
flowentity "mayfly-go/internal/flow/domain/entity" flowentity "mayfly-go/internal/flow/domain/entity"
machineentity "mayfly-go/internal/machine/domain/entity" machineentity "mayfly-go/internal/machine/domain/entity"
@@ -18,6 +19,7 @@ func V1_10() []*gormigrate.Migration {
migrations = append(migrations, V1_10_0()...) migrations = append(migrations, V1_10_0()...)
migrations = append(migrations, V1_10_1()...) migrations = append(migrations, V1_10_1()...)
migrations = append(migrations, V1_10_2()...) migrations = append(migrations, V1_10_2()...)
migrations = append(migrations, V1_10_3()...)
return migrations return migrations
} }
@@ -273,3 +275,54 @@ func V1_10_2() []*gormigrate.Migration {
}, },
} }
} }
func V1_10_3() []*gormigrate.Migration {
return []*gormigrate.Migration{
{
ID: "20250904-v1.10.3",
Migrate: func(tx *gorm.DB) error {
tx.AutoMigrate(&dockerentity.Container{})
// 删除容器菜单
tx.Exec("update t_sys_resource set is_deleted = 1 where code = '/container'")
// 新增容器管理基本权限
tx.Exec("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 (1757145306, 94, 'Tag3fhad/glxajg23/Bbrte5UH/', 2, 1, 'menu.containerManageBase', 'container', 1757145306, 'null', 1, 'admin', 1, 'admin', '2025-09-06 15:55:06', '2025-09-06 15:56:10', 0, NULL)")
// 机器列表相关菜单权限
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/Alw1Xkq3/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/Alw1Xkq3/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/Lsew24Kx/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/Lsew24Kx/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/Keiqkx4L/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/Keiqkx4L/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/Keal2Xke/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/Keal2Xke/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/Ihfs2xaw/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/Ihfs2xaw/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/3ldkxJDx/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/3ldkxJDx/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/Ljewix43/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/Ljewix43/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/L12wix43/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/L12wix43/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/Ljewisd3/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/Ljewisd3/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/Ljeew43/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/Ljeew43/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/ODewix43/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/ODewix43/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/LIEwix43/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/LIEwix43/'")
// redis
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/IUlxia23/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/IUlxia23/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/Gxlagheg/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/Gxlagheg/'")
// db
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/TGFPA3Ez/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/TGFPA3Ez/'")
// es
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/SQNFhhhn/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/SQNFhhhn/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/XAgy5Uvp/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/XAgy5Uvp/'")
// mongo
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/xvpKk36u/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/xvpKk36u/'")
tx.Exec("Update t_sys_resource set ui_path='ocdrUNaa/3sblw1Wb/', pid=1756122788 where ui_path = 'Tag3fhad/glxajg23/3sblw1Wb/'")
return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
},
}
}