!134 feat: 新增支持es和连接池

* feat: 各连接,支持连接池
* feat:支持es
This commit is contained in:
zongyangleo
2025-05-21 04:42:30 +00:00
committed by Coder慌
parent f676ec9e7b
commit 142bbd265d
89 changed files with 5734 additions and 575 deletions

View File

@@ -11,7 +11,7 @@ module.exports = {
parser: '@typescript-eslint/parser',
sourceType: 'module',
},
extends: ['plugin:vue/vue3-essential', 'plugin:vue/essential', 'eslint:recommended'],
extends: ['plugin:vue/essential', 'eslint:recommended'],
plugins: ['vue', '@typescript-eslint'],
overrides: [
{
@@ -35,9 +35,8 @@ module.exports = {
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-redeclare': 'error',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'@typescript-eslint/no-unused-vars': [2],
'@typescript-eslint/no-unused-vars': 'off',
'vue/custom-event-name-casing': 'off',
'vue/attributes-order': 'off',
'vue/one-component-per-file': 'off',
@@ -53,6 +52,7 @@ module.exports = {
'vue/no-arrow-functions-in-watch': 'off',
'vue/no-template-key': 'off',
'vue/no-v-html': 'off',
'vue/no-unused-vars': 'off',
'vue/comment-directive': 'off',
'vue/no-parsing-error': 'off',
'vue/no-deprecated-v-on-native-modifier': 'off',
@@ -67,7 +67,7 @@ module.exports = {
'generator-star-spacing': 'off',
'no-unreachable': 'off',
'no-multiple-template-root': 'off',
'no-unused-vars': 'error',
'no-unused-vars': 'off',
'no-v-model-argument': 'off',
'no-case-declarations': 'off',
// 'no-console': 'error',

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M96.426667 649.173333H712.96a137.173333 137.173333 0 0 0 0-274.346666H96.426667c-12.8 43.52-19.626667 89.514667-19.626667 137.173333s6.826667 93.696 19.626667 137.173333z" fill="#07A5DE" p-id="6101"></path><path d="M563.2 25.6A486.4 486.4 0 0 0 125.354667 299.946667H837.546667c52.096 0 97.450667-29.013333 120.661333-71.808A485.76 485.76 0 0 0 563.2 25.6z" fill="#EFBF19" p-id="6102"></path><path d="M942.421333 816.64a137.258667 137.258667 0 0 0-129.749333-92.586667H125.312A486.4 486.4 0 0 0 563.2 998.4c153.344 0 290.090667-70.954667 379.221333-181.76z" fill="#3EBEB1" p-id="6103"></path><path d="M506.197333 649.173333c12.8-43.52 19.626667-89.514667 19.626667-137.173333s-6.826667-93.696-19.626667-137.173333H96.469333c-12.8 43.52-19.626667 89.514667-19.626666 137.173333s6.826667 93.696 19.626666 137.173333h409.728z" fill="#231F20" p-id="6104"></path><path d="M477.269333 724.053333H125.354667a488.533333 488.533333 0 0 0 175.957333 197.888 488.533333 488.533333 0 0 0 175.957333-197.930666z" fill="#019B8F" p-id="6105"></path><path d="M301.312 102.058667a488.533333 488.533333 0 0 1 175.957333 197.930666H125.354667a488.533333 488.533333 0 0 1 175.957333-197.930666z" fill="#D8A22A" p-id="6106"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M465.664 679.168c105.301333 0.597333 172.970667 1.066667 202.922667 1.450667 20.48 0.256 36.181333 0.426667 47.274666 0.469333h3.84c45.824 0 84.096 8.533333 114.901334 25.258667 31.189333 16.938667 54.826667 42.368 70.826666 76.245333l1.152 2.517333a24.106667 24.106667 0 0 1-1.621333 3.413334c-46.336 67.84-101.034667 116.565333-164.096 146.346666-63.146667 29.824-134.613333 40.704-214.485333 32.469334-159.232-16.384-283.477333-106.24-372.352-269.994667a5.973333 5.973333 0 0 1 3.584-8.618667c13.653333-3.968 27.733333-6.528 41.941333-7.594666 91.306667-1.365333 170.538667-1.877333 238.165333-1.962667h27.946667z m44.885333 63.829333l-0.64 1.152c-3.754667 6.485333-9.386667 15.36-16.128 25.6l-2.645333 3.925334-1.578667 2.346666c-21.205333 31.445333-51.072 72.234667-70.784 94.464 64.853333 34.304 133.162667 45.44 227.157334 27.52 95.146667-18.090667 145.450667-52.565333 175.829333-114.090666-5.034667-10.581333-14.592-19.285333-31.488-27.733334-12.8-6.4-32.426667-11.050667-58.752-14.250666l-221.013333 1.066666z m-257.578666-5.546666l1.237333 1.536c21.504 26.112 67.712 72.277333 96.896 95.786666 15.146667-14.08 29.098667-29.397333 41.642667-45.824 13.952-18.261333 24.149333-32.64 35.370666-52.821333l-175.146666 1.322667z m471.296-360.874667c38.229333 5.077333 67.626667 18.944 88.448 41.301333 20.736 22.229333 33.024 52.992 36.565333 92.373334 3.626667 39.722667-5.76 71.808-27.733333 96.426666-20.906667 23.381333-53.461333 40.106667-97.877334 49.706667l-2.645333 0.597333-2.816 0.554667H144.725333a8.021333 8.021333 0 0 1-7.893333-6.485333 1545.173333 1545.173333 0 0 1-0.298667-1.578667c-12.373333-62.378667-18.517333-106.666667-18.517333-132.906667 0-38.570667 5.888-81.962667 17.706667-130.261333l1.066666-4.394667a7.082667 7.082667 0 0 1 6.826667-5.333333h580.650667zM197.546667 442.88l-0.853334 2.688c-7.509333 24.064-12.544 44.330667-12.117333 70.954667 0 30.293333 5.418667 54.272 13.653333 81.664h283.050667l0.341333-2.218667 0.469334-3.2c3.541333-24.448 4.010667-47.701333 4.010666-76.544 0-30.805333-1.066667-51.541333-6.4-75.264l-282.154666 1.92z m493.397333-3.029333l-131.797333 1.024 0.512 2.474666c4.48 22.357333 6.741333 43.861333 6.741333 73.216 0 30.421333-2.432 53.76-7.552 79.189334l134.826667-0.170667 1.962666-0.213333c28.16-2.901333 49.194667-7.210667 62.421334-23.04 11.52-13.866667 17.152-32.469333 17.152-55.765334 0-24.746667-6.272-42.624-19.456-54.826666-13.653333-12.714667-34.474667-19.2-61.994667-21.674667l-2.816-0.213333z m49.877333-342.784c63.104 29.824 117.845333 78.592 164.181334 146.346666l1.536 2.304a23.466667 23.466667 0 0 1-1.066667 3.669334c-16 33.92-39.594667 59.306667-70.784 76.245333-30.805333 16.768-69.12 25.258667-114.986667 25.258667-11.178667 0-28.16 0.213333-50.944 0.469333-45.098667 0.597333-112.597333 1.408-202.965333 1.493333h-14.122667c-70.613333 0-154.453333-0.512-251.733333-1.962666a207.061333 207.061333 0 0 1-42.24-7.594667 5.973333 5.973333 0 0 1-3.626667-8.618667C242.986667 170.922667 367.146667 81.066667 526.378667 64.682667c79.829333-8.277333 151.296 2.56 214.4 32.426666z m-102.101333 28.501333c-85.205333-15.36-143.957333-4.010667-213.717333 27.221333 11.648 13.312 26.410667 33.621333 40.874666 55.04l1.578667 2.389334 2.56 3.754666 3.498667 5.376 2.346666 3.584 1.237334 1.877334c18.688 28.757333 35.157333 56.746667 41.728 70.613333h213.674666l2.474667-0.298667 2.56-0.341333c21.290667-2.986667 38.144-10.794667 55.978667-19.754667 17.408-8.576 30.122667-18.304 40.106666-29.866666-49.493333-63.018667-108.586667-104.106667-194.901333-119.594667zM367.744 186.453333c-12.458667 10.069333-34.304 29.44-56.192 50.048l-1.706667 1.621334-3.328 3.157333-3.498666 3.328-1.877334 1.792-2.048 2.005333c-17.322667 16.64-33.578667 33.109333-44.501333 45.909334l179.797333-1.536-1.109333-1.877334a3067.264 3067.264 0 0 1-12.672-21.418666l-11.776-20.053334-2.474667-4.053333-2.56-4.266667-1.152-2.005333-1.237333-2.005333c-12.458667-20.693333-24.917333-40.405333-33.706667-50.645334z" fill="#2c2c2c" p-id="5739"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,5 +1,5 @@
import request from './request';
import { useApiFetch } from '@/hooks/useRequest';
import { RequestOptions, useApiFetch } from '@/hooks/useRequest';
/**
* 可用于各模块定义各自api请求
@@ -49,7 +49,7 @@ class Api {
* @param reqOptions 其他可选值
* @returns
*/
useApi<T>(params: any = null, reqOptions: RequestInit = {}) {
useApi<T>(params: any = null, reqOptions?: RequestOptions) {
return useApiFetch<T>(this, params, reqOptions);
}
@@ -59,8 +59,8 @@ class Api {
*/
async request(param: any = null, options: any = {}): Promise<any> {
const { execute, data } = this.useApi(param, options);
await execute();
return data.value;
const res = await execute();
return data.value || res;
}
/**

View File

@@ -15,6 +15,8 @@ export const ResourceTypeEnum = {
Db: EnumValue.of(2, '数据库实例').setExtra({ icon: 'Coin', iconColor: 'var(--el-color-warning)' }).tagTypeWarning(),
Redis: EnumValue.of(3, 'redis').setExtra({ icon: 'icon redis/redis', iconColor: 'var(--el-color-danger)' }).tagTypeInfo(),
Mongo: EnumValue.of(4, 'mongo').setExtra({ icon: 'icon mongo/mongo', iconColor: 'var(--el-color-success)' }).tagTypeDanger(),
AuthCert: EnumValue.of(5, '授权凭证').setExtra({ icon: 'Ticket', iconColor: 'var(--el-color-success)' }),
Es: EnumValue.of(6, 'ES实例').setExtra({ icon: 'Coin', iconColor: 'var(--el-color-warning)' }).tagTypeWarning(),
};
// 标签关联的资源类型
@@ -24,9 +26,10 @@ export const TagResourceTypeEnum = {
Machine: ResourceTypeEnum.Machine,
DbInstance: ResourceTypeEnum.Db,
EsInstance: ResourceTypeEnum.Es,
Redis: ResourceTypeEnum.Redis,
Mongo: ResourceTypeEnum.Mongo,
AuthCert: EnumValue.of(5, '授权凭证').setExtra({ icon: 'Ticket', iconColor: 'var(--el-color-success)' }),
AuthCert: ResourceTypeEnum.AuthCert,
Db: EnumValue.of(22, '数据库').setExtra({ icon: 'Coin' }),
};
@@ -37,4 +40,5 @@ export const TagResourceTypePath = {
DbInstanceAuthCert: `${TagResourceTypeEnum.DbInstance.value}/${TagResourceTypeEnum.AuthCert.value}`,
Db: `${TagResourceTypeEnum.DbInstance.value}/${TagResourceTypeEnum.AuthCert.value}/${TagResourceTypeEnum.Db.value}`,
Es: `${TagResourceTypeEnum.EsInstance.value}/${TagResourceTypeEnum.AuthCert.value}`,
};

View File

@@ -30,6 +30,18 @@ export function formatByteSize(size: number, fixed = 2) {
return parseFloat((size / Math.pow(base, exponent)).toFixed(fixed)) + units[exponent];
}
export function formatDocSize(size: number, fixed = 2) {
if (size === 0) {
return '0';
}
const units = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
const base = 1000;
const exponent = Math.floor(Math.log(size) / Math.log(base));
return parseFloat((size / Math.pow(base, exponent)).toFixed(fixed)) + units[exponent];
}
/**
* 容量转为对应的字节大小,如 1KB转为 1024
* @param sizeString 1kb 1gb等
@@ -86,8 +98,8 @@ export function formatTime(time: number, unit: string = 's') {
let result = '';
const timeUnits = Object.entries(units).map(([unit, duration]) => {
const value = Math.floor(seconds / duration);
seconds %= duration;
const value = Math.floor(seconds / (duration as any));
seconds %= duration as any;
return { value, unit };
});

View File

@@ -8,6 +8,7 @@
:style="`top: ${state.dropdown.y + 5}px;left: ${state.dropdown.x}px;`"
:key="Math.random()"
v-show="state.isShow && !allHide"
@contextmenu="headerContextmenuClick"
>
<ul class="el-dropdown-menu">
<template v-for="(v, k) in state.dropdownList">
@@ -125,6 +126,10 @@ const onCurrentContextmenuClick = (ci: ContextmenuItem) => {
emit('currentContextmenuClick', { id: ci.clickId, item: state.item });
};
const headerContextmenuClick = (event: any, data: any) => {
event.preventDefault(); // 阻止默认的右击菜单行为
};
// 打开右键菜单:判断是否固定,固定则不显示关闭按钮
const openContextmenu = (item: any) => {
state.item = item;

View File

@@ -1,15 +1,15 @@
<template>
<div class="monaco-editor-custom relative h-full" style="border: 1px solid var(--el-border-color-light, #ebeef5)">
<div class="monaco-editor-custom relative h-full">
<div class="monaco-editor-content" ref="monacoTextareaRef" :style="{ height: height }"></div>
<el-select v-if="canChangeMode" class="code-mode-select" v-model="languageMode" @change="changeLanguage" filterable>
<el-option v-for="mode in languageArr" :key="mode.value" :label="mode.label" :value="mode.value"> </el-option>
<el-option v-for="mode in languageArr" :key="mode.value" :label="mode.label" :value="mode.value" />
</el-select>
</div>
</template>
<script lang="ts" setup>
import { watch, toRefs, reactive, onMounted, onBeforeUnmount, useTemplateRef, Ref } from 'vue';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import * as monaco from 'monaco-editor';
// 相关语言
import 'monaco-editor/esm/vs/basic-languages/shell/shell.contribution.js';
import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution.js';
@@ -31,7 +31,6 @@ import 'monaco-editor/esm/vs/editor/contrib/format//browser/formatActions.js';
// 提示
import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js';
import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestInlineCompletions.js';
import { editor, languages } from 'monaco-editor';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker';
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
@@ -134,6 +133,7 @@ const defaultOptions = {
theme: 'SolarizedLight',
automaticLayout: true, //自适应宽高布局
foldingStrategy: 'indentation', //代码可分小段折叠
folding: true,
roundedSelection: false, // 禁用选择文本背景的圆角
matchBrackets: 'near',
linkedEditing: true,
@@ -149,7 +149,13 @@ const defaultOptions = {
minimap: {
enabled: false, // 不要小地图
},
};
renderLineHighlight: 'all',
selectOnLineNumbers: false,
readOnly: false,
scrollBeyondLastLine: false,
lineNumbers: 'on',
lineNumbersMinChars: 3,
} as editor.IStandaloneEditorConstructionOptions;
const monacoTextareaRef: Ref<any> = useTemplateRef('monacoTextareaRef');
@@ -221,7 +227,8 @@ const initMonacoEditorIns = () => {
monaco.editor.defineTheme('SolarizedLight', SolarizedLight);
defaultOptions.language = state.languageMode;
defaultOptions.theme = themeConfig.value.editorTheme;
monacoEditorIns = monaco.editor.create(monacoTextareaRef.value, Object.assign(defaultOptions, props.options as any));
let options = Object.assign(defaultOptions, props.options as any);
monacoEditorIns = monaco.editor.create(monacoTextareaRef.value, options);
// 监听内容改变,双向绑定
monacoEditorIns.onDidChangeModelContent(() => {
@@ -317,5 +324,8 @@ defineExpose({ getEditor, format, focus });
top: 10px;
max-width: 130px;
}
border: 1px solid var(--el-border-color-light, #ebeef5);
width: 100%;
}
</style>

View File

@@ -38,14 +38,19 @@ const useCustomFetch = createFetch({
return { options };
},
async afterFetch(ctx) {
const result: Result = await ctx.response.json();
ctx.data = result;
ctx.data = await ctx.response.json();
return ctx;
},
},
});
export function useApiFetch<T>(api: Api, params: any = null, reqOptions: RequestInit = {}) {
interface EsReq {
esProxyReq: boolean;
}
export interface RequestOptions extends RequestInit, EsReq {}
export function useApiFetch<T>(api: Api, params: any = null, reqOptions?: RequestOptions) {
const uaf = useCustomFetch<T>(api.url, {
async beforeFetch({ url, options }) {
options.method = api.method;
@@ -90,13 +95,20 @@ export function useApiFetch<T>(api: Api, params: any = null, reqOptions: Request
},
};
},
});
onFetchError: (ctx) => {
if (reqOptions?.esProxyReq) {
uaf.data = { value: JSON.parse(ctx.data) };
return Promise.resolve(uaf.data);
}
return ctx;
},
}) as any;
// 统一处理后的返回结果如果直接使用uaf.data则数据会出现由{code: x, data: {}} -> data 的变化导致某些结果绑定报错
const data = ref<T | null>(null);
return {
execute: async function () {
await execCustomFetch(uaf);
await execCustomFetch(uaf, reqOptions);
data.value = uaf.data.value;
},
isFetching: uaf.isFetching,
@@ -108,37 +120,44 @@ export function useApiFetch<T>(api: Api, params: any = null, reqOptions: Request
let refreshingToken = false;
let queue: any[] = [];
async function execCustomFetch(uaf: UseFetchReturn<any>) {
async function execCustomFetch(uaf: UseFetchReturn<any>, reqOptions?: RequestOptions) {
try {
await uaf.execute(true);
} catch (e: any) {
const rejectPromise = Promise.reject(e);
if (!reqOptions?.esProxyReq) {
const rejectPromise = Promise.reject(e);
if (e?.name == 'AbortError') {
console.log('请求已取消');
if (e?.name == 'AbortError') {
console.log('请求已取消');
return rejectPromise;
}
const respStatus = uaf.response.value?.status;
if (respStatus == 404) {
ElMessage.error('url not found');
return rejectPromise;
}
if (respStatus == 500) {
ElMessage.error('server error');
return rejectPromise;
}
console.error(e);
ElMessage.error('network error');
return rejectPromise;
}
const respStatus = uaf.response.value?.status;
if (respStatus == 404) {
ElMessage.error('url not found');
return rejectPromise;
}
if (respStatus == 500) {
ElMessage.error('server error');
return rejectPromise;
}
console.error(e);
ElMessage.error('network error');
return rejectPromise;
}
const result: Result = uaf.data.value as any;
const result: Result & { error: any; status: number } = uaf.data.value as any;
if (!result) {
ElMessage.error('network request failed');
return Promise.reject(result);
}
// es代理请求
if (reqOptions?.esProxyReq) {
uaf.data.value = result;
return Promise.resolve(result);
}
const resultCode = result.code;
@@ -154,7 +173,7 @@ async function execCustomFetch(uaf: UseFetchReturn<any>) {
// 请求加入队列等待, 防止并发多次请求refreshToken
return new Promise((resolve) => {
queue.push(() => {
resolve(execCustomFetch(uaf));
resolve(execCustomFetch(uaf, reqOptions));
});
});
}
@@ -178,13 +197,13 @@ async function execCustomFetch(uaf: UseFetchReturn<any>) {
queue = [];
}
await execCustomFetch(uaf);
await execCustomFetch(uaf, reqOptions);
return;
}
// 如果提示没有权限,则跳转至无权限页面
if (resultCode === ResultEnum.NO_PERMISSION) {
router.push({
await router.push({
path: URL_401,
});
return Promise.reject(result);

View File

@@ -7,12 +7,16 @@ export default {
detail: 'Details',
add: 'Add',
save: 'Save',
close: 'Close',
download: 'Download',
upload: 'Upload',
remove: 'Remove',
confirm: 'Confirm',
cancel: 'Cancel',
submit: 'Submit',
operation: 'Operations',
name: 'Name',
version: 'Version',
code: 'Code',
remark: 'Remark',
status: 'Status',
@@ -48,9 +52,11 @@ export default {
previousStep: 'Previous Step',
nextStep: 'Next Step',
copy: 'Copy',
copyCell: 'Copy Cell',
search: 'Search',
pleaseInput: 'Please enter {label}',
pleaseSelect: 'Please select {label}',
pleaseSelectOne: 'Please select Only One Data',
formValidationError: 'Please check the form',
createTitle: 'Create {name}',
editTitle: 'Edit {name}',
@@ -61,6 +67,8 @@ export default {
deleteSuccess: 'delete successfully',
operateSuccess: 'operate successfully',
fieldNotEmpty: '{field} cannot be empty',
selectAll: 'Select all',
MultiPlaceholder: 'Multiple are separated by commas',
},
layout: {
user: {

View File

@@ -219,4 +219,11 @@ export default {
running: 'Running',
waitRun: 'Wait Run',
},
es: {
keywordPlaceholder: 'host / name / code',
port: 'Port',
acName: 'Credential',
dbInst: 'Es Instance',
connSuccess: 'be connected successfully',
},
};

122
frontend/src/i18n/en/es.ts Normal file
View File

@@ -0,0 +1,122 @@
export default {
es: {
keywordPlaceholder: 'host / name / code',
port: 'Port',
size: 'size',
docs: 'docs',
health: 'health',
aliases: 'Aliases',
addAlias: 'Add Alias',
specifyIdAdd: 'Specify the ID added, if id exists, then update',
addIndex: 'Add Index',
editIndex: 'Edit Index',
status: 'status',
acName: 'Credential',
emptyTable: 'data not fund',
connSuccess: 'be connected successfully',
shouldTestConn: 'please test connection first',
instance: 'ES Instance',
instanceSave: 'Save Instance',
instanceDel: 'Delete Instance',
operation: 'Data Operation',
dataSave: 'Data Save',
dataDel: 'Data Del',
indexName: 'Index Name',
requireIndexName: 'Index Name Is Required',
indexDetail: 'Index Detail',
indexMapping: 'Mappings',
indexStats: 'Stats',
opViewColumns: 'Option View Columns',
opIndex: 'Index Management',
opSearch: 'Search',
searchParamsPreview: 'Search Params Preview',
opBasicSearch: 'Basic Search',
opSeniorSearch: 'Senior Search',
sampleMappings: 'Sample Mappings',
copyMappings: 'Copy Mappings',
readonlyMsg: 'The content is readOnly',
opDashboard: 'Dashboard',
opSettings: 'Settings',
templates: 'Templates',
availableSettingFields: 'Available Setting Fields',
Reindex: 'Reindex',
ReindexTargetIdx: 'Target Index',
ReindexIsSync: 'Sync Able',
ReindexDescription:
"If a field in Mapping has been defined, you can't modify the type of the field, and you can't change the number of shards, you can use the Reindex API to solve this problem.",
ReindexSyncDescription: 'If the amount of index data is large, we recommend that you enable asynchronous data to avoid request timeouts.',
ReindexToOtherInst: 'To other Instance',
ReindexSyncTask: 'Sync Task',
makeSearchParam: 'Make Search Params',
filterColumn: 'Filter Columns',
searchParams: 'Search',
searchParamsDesc: 'If no field is selected or no condition value is set, it will not take effect',
standardSearch: 'Standard Search',
AggregationSearch: 'Aggregation Search',
SqlSearch: 'Sql Search',
searchError: 'Search Error',
execError: 'Exec Error',
docJsonError: 'Document JSON Format Error',
sortParams: 'Sort',
otherParams: 'Other',
previewParams: 'Preview',
closeIndexConfirm: 'This operation will close index [{name}]. Do you want to continue?',
openIndexConfirm: 'This operation will open index [{name}]. Do you want to continue?',
clearCacheConfirm: 'This operation will clear index [{name}] cache. Do you want to continue?',
page: {
home: 'First Page',
prev: 'Prev Page',
next: 'Next Page',
total: 'Total Count',
changeSize: 'Change Page Size',
},
temp: {
addTemp: 'Add template',
view: 'Template Detail',
name: 'name',
priority: 'priority',
index_patterns: 'patterns',
content: 'content',
showHide: 'show system templates',
description: 'description',
filter: 'filter name / description',
versionAlert: 'Versions prior to 7.8 are not supported',
note: `1、When creating a new index, if the index name matches the wildcard of the index template, the index template's settings (_setting, _mapping, etc.) are used。
2、Templates take effect only when an index is created, and modifying a template does not affect existing indexes。
3、You can specify the value of "priority", which was "order" before version 7.8, and if the new index name matches multiple templates, the one with the lowest priority will be used first.`,
},
dashboard: {
instInfo: 'Instance Info',
clusterHealth: 'Cluster Health',
nodes: 'Nodes Info',
sysMem: 'System Mem',
jvmMem: 'JVM Mem',
fileSystem: 'File System',
analyze: 'Analyze',
idxName: 'Index Name',
field: 'Field',
text: 'Text',
startAnalyze: 'Start Analyze',
},
contextmenu: {
index: {
addIndex: 'Add Index',
showSys: 'Show System Index',
copyName: 'Copy Name',
refresh: 'Refresh Index',
flush: 'Flush Index',
clearCache: 'Clear Index Cache',
addAlias: 'Add Alias',
Close: 'Close',
Open: 'Open',
Delete: 'Delete Index',
edit: 'Edit Index',
DeleteSelectLine: 'Copy Selected Line Json',
BaseSearch: 'Base Search',
SeniorSearch: 'Senior Search',
copyLineJson: 'Copy Line Json',
copySelectLineJson: 'Copy Selected Line Json',
},
},
},
};

View File

@@ -7,12 +7,16 @@ export default {
detail: '详情',
add: '添加',
save: '保存',
close: '关闭',
download: '下载',
upload: '上传',
remove: '移除',
confirm: '确定',
cancel: '取消',
submit: '提交',
operation: '操作',
name: '名称',
version: '版本',
code: '编号',
remark: '备注',
status: '状态',
@@ -48,9 +52,11 @@ export default {
previousStep: '上一步',
nextStep: '下一步',
copy: '复制',
copyCell: '复制单元格',
search: '搜索',
pleaseInput: '请输入{label}',
pleaseSelect: '请选择{label}',
pleaseSelectOne: '请选择一条数据',
formValidationError: '信息填写有误,请检查',
createTitle: '创建{name}',
editTitle: '编辑{name}',
@@ -61,6 +67,8 @@ export default {
deleteSuccess: '删除成功',
operateSuccess: '操作成功',
fieldNotEmpty: '{field}不能为空',
selectAll: '全选',
MultiPlaceholder: '多个用逗号隔开',
},
layout: {
user: {

View File

@@ -0,0 +1,121 @@
export default {
es: {
keywordPlaceholder: 'host / 名称 / 编号',
port: '端口',
size: '存储大小',
docs: '文档数',
health: '健康',
aliases: '别名',
addAlias: '添加别名',
specifyIdAdd: '可指定_id添加如果_id已存在则修改',
addIndex: '添加索引',
editIndex: '编辑索引',
status: '状态',
acName: '授权凭证',
emptyTable: '无数据',
connSuccess: '连接成功',
shouldTestConn: '请先测试连接可用性',
instance: 'ES实例',
instanceSave: '实例保存',
instanceDel: '实例删除',
operation: '数据操作',
dataSave: '数据保存',
dataDel: '数据删除',
indexName: '索引名',
requireIndexName: '请填写索引名',
indexDetail: '索引详情',
indexMapping: '映射',
indexStats: '统计信息',
opViewColumns: '设置显示字段',
opIndex: '索引管理',
opSearch: '搜索',
searchParamsPreview: '搜索条件预览',
opBasicSearch: '基础搜索',
opSeniorSearch: '高级搜索',
sampleMappings: 'Mapping示例',
copyMappings: '拷贝Mapping',
readonlyMsg: '该内容不可修改',
opDashboard: '仪表盘',
opSettings: '设置',
templates: '模板管理',
availableSettingFields: '支持修改的字段',
Reindex: '索引迁移',
ReindexTargetIdx: '目标索引',
ReindexIsSync: '是否异步',
ReindexDescription: '如果 Mapping 中字段已经定义就不能修改其字段的类型等属性了,同时也不能改变分片的数量, 可以使用 Reindex API 来解决这个问题。',
ReindexSyncDescription: '如果索引数据量较大,建议开启异步,以免造成请求超时。',
ReindexToOtherInst: '迁移到其他实例',
ReindexSyncTask: '异步任务',
makeSearchParam: '组装搜索条件',
filterColumn: '过滤列名',
searchParams: '查询',
searchParamsDesc: '未选择字段,或未设置条件值,则不生效',
standardSearch: '标准查询',
AggregationSearch: '聚合查询',
SqlSearch: 'Sql查询',
searchError: '查询错误',
execError: '执行错误',
docJsonError: '文档JSON格式错误',
sortParams: '排序',
otherParams: '其他',
previewParams: '预览',
closeIndexConfirm: '将会关闭索引:[{name}]。 确认继续吗?',
openIndexConfirm: '将会打开索引:[{name}]。 确认继续吗?',
clearCacheConfirm: '将会清除索引:[{name}]缓存。 确认继续吗?',
page: {
home: '首页',
prev: '上一页',
next: '下一页',
total: '点击切换总条数',
changeSize: '修改每页条数',
},
temp: {
addTemp: '添加模板',
view: '模板详情',
name: '模板名',
priority: '优先级',
index_patterns: '匹配模式',
content: '模板内容',
showHide: '显示隐藏模板',
description: '描述信息',
filter: '模糊过滤名字和描述',
versionAlert: '暂不支持 7.8 以前的版本',
note: `1、在新建索引时如果索引名与索引模板的通配符匹配那么就使用索引模板的设置_setting、_mapping等
2、模板仅在索引创建时才会生效而且修改模板不会影响现有的索引。
3、可以指定"priority"的数值7.8版本前是"order"如果新建的索引名匹配到了多个模板则优先使用priority最小的那个。`,
},
dashboard: {
instInfo: '实例信息',
clusterHealth: '集群健康',
nodes: '节点信息',
sysMem: '系统内存',
jvmMem: 'JVM内存',
fileSystem: '文件系统',
analyze: '字段分析',
idxName: '索引名',
field: '字段名',
text: '文本',
startAnalyze: '开始分析',
},
contextmenu: {
index: {
addIndex: '添加索引',
showSys: '显示系统索引',
copyName: '复制名字',
refresh: '刷新索引',
flush: 'flush索引',
clearCache: '清除索引缓存',
addAlias: '添加别名',
Close: '关闭索引',
Open: '打开索引',
Delete: '删除索引',
edit: '编辑索引',
DeleteSelectLine: '删除选中行',
BaseSearch: '基本搜索',
SeniorSearch: '高级搜索',
copyLineJson: '复制整行JSON',
copySelectLineJson: '复制选中行JSON',
},
},
},
};

View File

@@ -9,6 +9,7 @@ export default {
tagTips3: '3. 拥有父标签的团队成员可访问操作其自身或子标签关联的资源',
machine: '机器',
db: '数据库',
es: 'ES',
code: '编号',
createSubTag: '创建子标签',
createSubTagTitle: '创建【{codePath}】的子标签',

View File

@@ -16,8 +16,7 @@
}
"
>
<SvgIcon name="icon layout/tag-view-active" class="layout-navbars-tagsview-ul-li-iconfont !text-[14px]" v-if="isActive(v)" />
<SvgIcon :name="v.icon" class="layout-navbars-tagsview-ul-li-iconfont" v-if="!isActive(v) && themeConfig.isTagsviewIcon" />
<SvgIcon :name="v.icon" class="layout-navbars-tagsview-ul-li-iconfont" v-if="themeConfig.isTagsviewIcon" />
<span>{{ $t(v.title) }}</span>
<template v-if="isActive(v)">
<SvgIcon

View File

@@ -112,7 +112,7 @@ export const useThemeConfig = defineStore('themeConfig', {
/* 布局切换
------------------------------- */
// 默认布局,可选 1、默认 defaults 2、经典 classic 3、横向 transverse 4、分栏 columns
layout: 'classic',
layout: 'transverse',
terminalTheme: 'light',
// ssh终端字体颜色
@@ -161,7 +161,7 @@ export const useThemeConfig = defineStore('themeConfig', {
} else {
getServerConf().then((res) => {
this.themeConfig.globalI18n = res.i18n;
})
});
}
// 根据后台系统配置初始化

View File

@@ -218,6 +218,9 @@ $menuHeight: 46px !important;
}
}
/* Dialog 对话框
------------------------------- */
/* Card 卡片
------------------------------- */
.el-card__header {

View File

@@ -45,7 +45,6 @@ import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
import { SearchItem } from '@/components/SearchForm';
import ProcdefEdit from './ProcdefEdit.vue';
import ProcdefTasks from './components/ProcdefTasks.vue';
import { ProcdefStatus } from './enums';
import TagCodePath from '../ops/component/TagCodePath.vue';
import { useI18nCreateTitle, useI18nDeleteConfirm, useI18nDeleteSuccessMsg, useI18nEditTitle, useI18nSaveSuccessMsg } from '@/hooks/useI18n';

View File

@@ -19,8 +19,6 @@ const emit = defineEmits(['resize']);
const { width } = useWindowSize();
console.log(width);
const leftPaneSize = computed(() => (width.value >= 1600 ? 20 : 24));
// 处理 resize 事件

View File

@@ -8,6 +8,7 @@
@clear="clear"
placeholder="SSH tunnel machine"
clearable
filterable
>
<el-option v-for="item in sshTunnelMachineList" :key="item.id" :label="`${item.ip}:${item.port} [${item.name}]`" :value="item.id"> </el-option>
</el-select>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
<template>
<div>
<el-drawer :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>
<el-form :model="form" ref="dbForm" :rules="rules" label-width="auto">
<el-divider content-position="left">{{ t('common.basic') }}</el-divider>
<el-form-item ref="tagSelectRef" prop="tagCodePaths" :label="t('tag.relateTag')">
<tag-tree-select
multiple
@change-tag="
(paths: any) => {
form.tagCodePaths = paths;
tagSelectRef.validate();
}
"
:select-tags="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="version" :label="t('common.version')">
<el-input v-model.trim="form.version" auto-complete="off" disabled></el-input>
</el-form-item>
<el-form-item prop="host" label="Host" required>
<el-col :span="18">
<el-input v-model.trim="form.host" auto-complete="off"></el-input>
</el-col>
<el-col style="text-align: center" :span="1">:</el-col>
<el-col :span="5">
<el-input type="number" v-model.number="form.port" :placeholder="t('es.port')"></el-input>
</el-col>
</el-form-item>
<el-form-item prop="remark" :label="t('common.remark')">
<el-input v-model="form.remark" auto-complete="off" type="textarea"></el-input>
</el-form-item>
<el-divider content-position="left">{{ t('common.account') }}</el-divider>
<div>
<ResourceAuthCertTableEdit
v-model="form.authCerts"
:resource-code="form.code"
:resource-type="TagResourceTypeEnum.EsInstance.value"
:test-conn-btn-loading="testConnBtnLoading"
@test-conn="testConn"
:disable-ciphertext-type="[AuthCertCiphertextTypeEnum.PrivateKey.value]"
/>
</div>
<el-divider content-position="left">{{ t('common.other') }}</el-divider>
<el-form-item prop="sshTunnelMachineId" :label="t('machine.sshTunnel')">
<ssh-tunnel-select v-model="form.sshTunnelMachineId" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="testConn(null)" type="success" v-if="form.authCerts?.length <= 0">{{ t('ac.testConn') }}</el-button>
<el-button @click="cancel()">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk">{{ t('common.confirm') }}</el-button>
</template>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, toRefs, watchEffect } from 'vue';
import { esApi } from './api';
import { ElMessage } from 'element-plus';
import SshTunnelSelect from '../component/SshTunnelSelect.vue';
import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import ResourceAuthCertTableEdit from '../component/ResourceAuthCertTableEdit.vue';
import { AuthCertCiphertextTypeEnum } from '../tag/enums';
import TagTreeSelect from '../component/TagTreeSelect.vue';
import { useI18nFormValidate, useI18nSaveSuccessMsg } from '@/hooks/useI18n';
import { useI18n } from 'vue-i18n';
import { Rules } from '@/common/rule';
const { t } = useI18n();
const props = defineProps({
visible: {
type: Boolean,
},
data: {
type: [Boolean, Object],
},
title: {
type: String,
},
});
//定义事件
const emit = defineEmits(['update:visible', 'cancel', 'val-change']);
const rules = {
tagCodePaths: [Rules.requiredSelect('tag.relateTag')],
name: [Rules.requiredInput('common.name')],
type: [Rules.requiredSelect('common.type')],
host: [Rules.requiredInput('Host:Port')],
};
const dbForm: any = ref(null);
const tagSelectRef: any = ref(null);
const DefaultForm = {
id: null,
code: '',
name: null,
host: '',
version: '',
port: 9200,
remark: '',
sshTunnelMachineId: null as any,
authCerts: [],
tagCodePaths: [],
};
const state = reactive({
dialogVisible: false,
form: DefaultForm,
submitForm: {} as any,
});
const { dialogVisible, form, submitForm } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveInstanceExec, data: saveInstanceRes } = esApi.saveInstance.useApi(submitForm);
const { isFetching: testConnBtnLoading, execute: testConnExec, data: testConnRes } = esApi.testConn.useApi<any>(submitForm);
watchEffect(() => {
state.dialogVisible = props.visible;
if (!state.dialogVisible) {
return;
}
const dbInst: any = props.data;
if (dbInst) {
state.form = { ...dbInst };
state.form.tagCodePaths = dbInst.tags.map((t: any) => t.codePath) || [];
} else {
state.form = { ...DefaultForm };
state.form.authCerts = [];
}
});
const getReqForm = async () => {
const reqForm: any = { ...state.form };
reqForm.selectAuthCert = null;
reqForm.tags = null;
if (!state.form.sshTunnelMachineId) {
reqForm.sshTunnelMachineId = -1;
}
return reqForm;
};
const testConn = async (authCert: any) => {
await useI18nFormValidate(dbForm);
state.submitForm = await getReqForm();
if (authCert) {
state.submitForm.authCerts = [authCert];
}
await testConnExec();
state.form.version = testConnRes.value.version.number;
ElMessage.success(t('es.connSuccess'));
};
const btnOk = async () => {
if (!state.form.version) {
ElMessage.warning(t('es.shouldTestConn'));
return;
}
await useI18nFormValidate(dbForm);
state.submitForm = await getReqForm();
await saveInstanceExec();
useI18nSaveSuccessMsg();
state.form.id = saveInstanceRes as any;
emit('val-change', state.form);
cancel();
};
const cancel = () => {
emit('update:visible', false);
emit('cancel');
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,195 @@
<template>
<div class="es-list">
<page-table
ref="pageTableRef"
:page-api="esApi.instances"
:data-handler-fn="handleData"
:searchItems="searchItems"
v-model:query-form="query"
:show-selection="true"
v-model:selection-data="state.selectionData"
:columns="columns"
lazy
>
<template #tableHeader>
<el-button v-auth="perms.saveInstance" type="primary" icon="plus" @click="editInstance(false)">{{ $t('common.create') }}</el-button>
<el-button v-auth="perms.delInstance" :disabled="selectionData.length < 1" @click="deleteInstance()" type="danger" icon="delete">
{{ $t('common.delete') }}
</el-button>
</template>
<template #tagPath="{ data }">
<ResourceTags :tags="data.tags" />
</template>
<template #authCert="{ data }">
<ResourceAuthCert v-model:select-auth-cert="data.selectAuthCert" :auth-certs="data.authCerts" />
</template>
<template #action="{ data }">
<el-button @click="showInfo(data)" link>{{ $t('common.detail') }}</el-button>
<el-button v-if="actionBtns[perms.saveInstance]" @click="editInstance(data)" type="primary" link>{{ $t('common.edit') }}</el-button>
</template>
</page-table>
<el-dialog v-model="infoDialog.visible" :title="$t('common.detail')">
<el-descriptions :column="3" border>
<el-descriptions-item :span="2" :label="$t('common.name')">{{ infoDialog.data.name }}</el-descriptions-item>
<el-descriptions-item :span="1" label="ID">{{ infoDialog.data.id }}</el-descriptions-item>
<el-descriptions-item :span="2" label="Host">{{ infoDialog.data.host }}</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('es.port')">{{ infoDialog.data.port }}</el-descriptions-item>
<el-descriptions-item :span="3" :label="$t('common.remark')">{{ infoDialog.data.remark }}</el-descriptions-item>
<el-descriptions-item :span="3" :label="$t('machine.sshTunnel')">
{{ infoDialog.data.sshTunnelMachineId > 0 ? $t('common.yes') : $t('common.no') }}
</el-descriptions-item>
<el-descriptions-item :span="2" :label="$t('common.createTime')">{{ formatDate(infoDialog.data.createTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('common.creator')">{{ infoDialog.data.creator }}</el-descriptions-item>
<el-descriptions-item :span="2" :label="$t('common.updateTime')">{{ formatDate(infoDialog.data.updateTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('common.modifier')">{{ infoDialog.data.modifier }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<instance-edit
@val-change="search()"
:title="instanceEditDialog.title"
v-model:visible="instanceEditDialog.visible"
v-model:data="instanceEditDialog.data"
/>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { esApi } from './api';
import { formatDate } from '@/common/utils/format';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
import { SearchItem } from '@/components/SearchForm';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
import ResourceTags from '../component/ResourceTags.vue';
import { getTagPathSearchItem } from '../component/tag';
import { TagResourceTypePath } from '@/common/commonEnum';
import { useI18nCreateTitle, useI18nDeleteConfirm, useI18nDeleteSuccessMsg, useI18nEditTitle } from '@/hooks/useI18n';
const InstanceEdit = defineAsyncComponent(() => import('./InstanceEdit.vue'));
const props = defineProps({
lazy: {
type: [Boolean],
default: false,
},
});
const perms = {
saveInstance: 'es:instance:save',
delInstance: 'es:instance:del',
};
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('es.keywordPlaceholder'), getTagPathSearchItem(TagResourceTypePath.Es)];
const columns = ref([
TableColumn.new('tags[0].tagPath', 'tag.relateTag').isSlot('tagPath').setAddWidth(20),
TableColumn.new('name', 'common.name'),
TableColumn.new('host', 'host:port').setFormatFunc((data: any) => `${data.host}:${data.port}`),
TableColumn.new('authCerts[0].username', 'es.acName').isSlot('authCert').setAddWidth(10),
TableColumn.new('remark', 'common.remark'),
TableColumn.new('code', 'common.code'),
]);
// 该用户拥有的的操作列按钮权限
const actionBtns: any = hasPerms(Object.values(perms));
const actionColumn = TableColumn.new('action', 'common.operation').isSlot().setMinWidth(180).fixedRight().noShowOverflowTooltip().alignCenter();
const pageTableRef: Ref<any> = ref(null);
const state = reactive({
row: {},
dbId: 0,
db: '',
/**
* 选中的数据
*/
selectionData: [],
/**
* 查询条件
*/
query: {
name: null,
tagPath: '',
pageNum: 1,
pageSize: 0,
},
infoDialog: {
visible: false,
data: null as any,
},
instanceEditDialog: {
visible: false,
data: null as any,
title: '',
},
});
const { selectionData, query, infoDialog, instanceEditDialog } = toRefs(state);
onMounted(async () => {
if (Object.keys(actionBtns).length > 0) {
columns.value.push(actionColumn);
}
if (!props.lazy) {
search();
}
});
const search = (tagPath: string = '') => {
if (tagPath) {
state.query.tagPath = tagPath;
}
pageTableRef.value.search();
};
const handleData = (res: any) => {
const dataList = res.list;
// 赋值授权凭证
for (let x of dataList) {
if (x.authCerts && x.authCerts.length > 0) {
x.selectAuthCert = x.authCerts[0];
}
}
return res;
};
const showInfo = (info: any) => {
state.infoDialog.data = info;
state.infoDialog.visible = true;
};
const editInstance = async (data: any) => {
if (!data) {
state.instanceEditDialog.data = null;
state.instanceEditDialog.title = useI18nCreateTitle('es.instance');
} else {
state.instanceEditDialog.data = data;
state.instanceEditDialog.title = useI18nEditTitle('es.instance');
}
state.instanceEditDialog.visible = true;
};
const deleteInstance = async () => {
try {
await useI18nDeleteConfirm(state.selectionData.map((x: any) => x.name).join('、'));
await esApi.deleteInstance.request({ id: state.selectionData.map((x: any) => x.id).join(',') });
useI18nDeleteSuccessMsg();
search();
} catch (err) {
//
}
};
defineExpose({ search });
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,55 @@
import Api from '@/common/Api';
import MonacoEditorBox from '@/components/monaco/MonacoEditorBox';
import { i18n } from '@/i18n';
export const esApi = {
instances: Api.newGet('/es/instance'),
deleteInstance: Api.newDelete('/es/instance/{id}'),
saveInstance: Api.newPost('/es/instance'),
testConn: Api.newPost('/es/instance/test-conn'),
// proxyGet: Api.newGet('/es/instance/proxy/{id}/{path}'),
// proxyPost: Api.newPost('/es/instance/proxy/{id}/{path}'),
// proxyPut: Api.newPut('/es/instance/proxy/{id}/{path}'),
// proxyDelete: Api.newDelete('/es/instance/proxy/{id}/{path}'),
proxyReq: async function (method: string, id: any, path: string, param?: any) {
if (path.startsWith('/')) {
path = path.substring(1);
}
let res = {} as any;
const t = i18n.global.t;
switch (method) {
case 'get':
res = await Api.newGet(`/es/instance/proxy/${id}/${path}`).request(param, { esProxyReq: true });
break;
case 'post':
res = await Api.newPost(`/es/instance/proxy/${id}/${path}`).request(param, { esProxyReq: true });
break;
case 'put':
res = await Api.newPut(`/es/instance/proxy/${id}/${path}`).request(param, { esProxyReq: true });
break;
case 'delete':
res = await Api.newDelete(`/es/instance/proxy/${id}/${path}`).request(param, { esProxyReq: true });
break;
}
let error = res.error || (res.failures && res.failures.length > 0 && res.failures[0]) || res.msg;
if (error) {
return await esApi.alertError(error, t('es.execError'));
}
return res;
},
alertError: async (errData: any, title: string) => {
MonacoEditorBox({
content: JSON.stringify(errData, null, 2),
title,
language: 'json',
width: '600px',
canChangeLang: false,
options: { wordWrap: 'on', tabSize: 2, readOnly: true }, // 自动换行
});
return await Promise.reject(errData);
},
};

View File

@@ -0,0 +1,164 @@
<!-- es 编辑索引 -->
<template>
<el-drawer
:title="t('es.addIndex')"
v-model="visible"
size="50%"
:destroy-on-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
class="es-edit-index h-full"
>
<el-auto-resizer>
<template #default="{ height, width }">
<el-form :model="formData" ref="formRef">
<el-form-item :label="t('es.indexName')" required prop="idxName">
<el-input v-model.trim="formData.idxName" maxlength="200" show-word-limit />
</el-form-item>
<el-space>
<el-form-item>
<el-select v-model="formData.copyIdxName">
<el-option v-for="idx in idxNames" :key="idx" :value="idx" :label="idx" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="onCopyMappings" link type="primary">{{ t('es.copyMappings') }}</el-button>
</el-form-item>
<el-form-item>
<el-button @click="onSampleMappings" link type="warning">{{ t('es.sampleMappings') }}</el-button>
</el-form-item>
</el-space>
<el-form-item required prop="mappings" label="mappings" label-position="top">
<monaco-editor v-model="formData.mappings" language="json" :height="height - 130 + 'px'" width="100%" :options="{ tabSize: 2 }" />
</el-form-item>
</el-form>
</template>
</el-auto-resizer>
<template #footer>
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="confirm" :loading="loading">{{ t('common.confirm') }}</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { ref, watch } from 'vue';
import { esApi } from '@/views/ops/es/api';
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import { ElMessage } from 'element-plus';
const { t } = useI18n();
const defaultSettings = {
number_of_shards: 5,
number_of_replicas: 1,
blocks: {
read_only: 'false',
},
max_result_window: '1000000',
refresh_interval: '30s',
};
const emptyMappings = {
mappings: {
properties: {},
},
settings: defaultSettings,
};
// 点击加载mapping示例
const sampleMappings = {
mappings: {
properties: {
title: {
type: 'text',
analyzer: 'ik_max_word',
search_analyzer: 'ik_smart',
fields: {
standard: {
type: 'text',
analyzer: 'standard',
},
keyword: {
type: 'keyword',
ignore_above: 250,
},
},
},
mediaName: {
type: 'text',
fields: {
keyword: {
type: 'keyword',
ignore_above: 256,
},
},
},
},
},
settings: defaultSettings,
};
const formData = ref({
idxName: '',
copyIdxName: '',
mappings: '',
});
interface Props {
instId: any;
idxNames: string[];
}
const props = defineProps<Props>();
const loading = ref(false);
const formRef = ref();
const visible = defineModel<boolean>('visible');
watch(visible, async (x: any) => {
if (x) {
formData.value.idxName = '';
formData.value.copyIdxName = '';
formData.value.mappings = JSON.stringify(emptyMappings, null, 2);
loading.value = false;
}
});
const emit = defineEmits(['success']);
const confirm = async () => {
await formRef.value.validate();
loading.value = true;
if (!formData.value.idxName) {
ElMessage.warning(t('es.requireIndexName'));
return;
}
await esApi.proxyReq('put', props.instId, `/${formData.value.idxName}`, JSON.parse(formData.value.mappings));
ElMessage.success(t('common.saveSuccess'));
emit('success');
loading.value = false;
visible.value = false;
};
const onSampleMappings = () => {
formData.value.mappings = JSON.stringify(sampleMappings, null, 2);
};
const onCopyMappings = async () => {
let mp = await esApi.proxyReq('get', props.instId, `/${formData.value.copyIdxName}/_mappings`);
let properties = mp[formData.value.copyIdxName].mappings.properties;
formData.value.mappings = JSON.stringify(
{
mappings: {
properties,
},
settings: defaultSettings,
},
null,
2
);
};
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,385 @@
<template>
<el-tabs v-model="state.tabName" type="card">
<el-tab-pane name="nodesStats" v-loading="state.nodesStatsLoading" style="height: calc(100vh - 200px); overflow-y: auto">
<template #label>
{{ t('es.dashboard.nodes') }}
<el-button v-if="state.tabName === 'nodesStats'" icon="refresh" @click="fetchNodesStats" link type="primary" />
</template>
<el-descriptions class="nodes-num" column="3" border>
<el-descriptions-item label="total">
{{ state.nodesStats._nodes?.total }}
</el-descriptions-item>
<el-descriptions-item label="successful">
{{ state.nodesStats._nodes?.successful }}
</el-descriptions-item>
<el-descriptions-item label="failed">
{{ state.nodesStats._nodes?.failed }}
</el-descriptions-item>
</el-descriptions>
<el-tabs>
<el-tab-pane :label="node.name" v-for="node in state.nodesStats.nodes" :key="node.key">
<el-card class="mt-1">
<el-form label-width="100">
<el-form-item label="ID">
<el-tag size="small" type="primary">{{ node.key }}</el-tag>
</el-form-item>
<el-form-item label="IP">
<el-tag size="small" type="primary">{{ node.ip }}</el-tag>
</el-form-item>
<el-form-item label="TIME">
<el-tag size="small" type="primary">{{ dayjs(node.timestamp).format('YYYY-MM-DD HH:mm:ss') }}</el-tag>
</el-form-item>
<el-form-item label="Roles">
<el-space wrap>
<el-tag v-for="r in node.roles" :key="r" type="success">{{ r }}</el-tag>
</el-space>
</el-form-item>
<el-form-item label="Docs">
<el-space>
<el-tag type="warning">count: {{ node.indices.docs.count }}</el-tag>
<el-tag type="info">deleted: {{ node.indices.docs.deleted }}</el-tag>
<el-tag type="primary">{{ formatByteSize(node.indices.store.size_in_bytes) }}</el-tag>
</el-space>
</el-form-item>
<el-form-item :label="t('es.dashboard.sysMem')">
{{ formatByteSize(node.os.mem.used_in_bytes) }} / {{ formatByteSize(node.os.mem.total_in_bytes) }}
<el-progress
striped
striped-flow
duration="50"
style="width: 100%"
:percentage="node.os.mem.used_percent"
:color="getPercentColor(node.os.mem.used_percent)"
/>
</el-form-item>
<el-form-item :label="t('es.dashboard.jvmMem')">
{{ formatByteSize(node.jvm.mem.heap_used_in_bytes) }} / {{ formatByteSize(node.jvm.mem.heap_max_in_bytes) }}
<el-progress
striped
striped-flow
duration="50"
style="width: 100%"
:percentage="node.jvm.mem.heap_used_percent"
:color="getPercentColor(node.jvm.mem.heap_used_percent)"
/>
</el-form-item>
<el-form-item label="CPU">
<el-progress
striped
striped-flow
duration="50"
style="width: 100%"
:percentage="node.os.cpu.percent"
:color="getPercentColor(node.os.cpu.percent)"
/>
</el-form-item>
<el-form-item :label="t('es.dashboard.fileSystem')">
{{ formatByteSize(node.fs.total.total_in_bytes - node.fs.total.free_in_bytes) }} /
{{ formatByteSize(node.fs.total.total_in_bytes) }}
<el-progress
striped
striped-flow
duration="50"
style="width: 100%"
:percentage="
Math.round(((node.fs.total.total_in_bytes - node.fs.total.free_in_bytes) * 100) / node.fs.total.total_in_bytes)
"
:color="
getPercentColor(((node.fs.total.total_in_bytes - node.fs.total.free_in_bytes) * 100) / node.fs.total.total_in_bytes)
"
/>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
<el-tab-pane
name="instInfo"
v-loading="state.instInfoLoading"
:label="t('es.dashboard.instInfo')"
style="height: calc(100vh - 200px); overflow-y: auto"
>
<el-card shadow="hover">
<el-descriptions :column="1" border>
<el-descriptions-item label-align="left" align="right" :label="item.name" v-for="item in state.instInfo" :key="item.name">
{{ item.value }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-tab-pane>
<el-tab-pane
name="clusterHealth"
v-loading="state.clusterHealthLoading"
:label="t('es.dashboard.clusterHealth')"
style="height: calc(100vh - 200px); overflow-y: auto"
>
<el-card shadow="always">
<el-descriptions :column="1" border>
<el-descriptions-item label-align="left" align="right" :label="item.name" v-for="item in state.clusterHealth" :key="item.name">
{{ item.value }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-tab-pane>
<el-tab-pane
name="analyze"
v-loading="state.clusterStateLoading"
:label="t('es.dashboard.analyze')"
style="height: calc(100vh - 200px); overflow-y: auto"
>
<el-card class="h-full">
<el-form :model="state.analyze" ref="analyzeFormRef" label-position="right" label-width="100">
<el-form-item :label="t('es.dashboard.idxName')" required prop="idxName">
<el-select v-model="state.analyze.idxName" filterable clearable @change="onSelectIdxField">
<el-option v-for="idx in state.idxFields" :key="idx.name" :value="idx.name" :label="idx.name" />
</el-select>
</el-form-item>
<el-form-item :label="t('es.dashboard.field')" required prop="field">
<el-select v-model="state.analyze.field" filterable clearable>
<el-option v-for="field in state.analyze.fields" :key="field" :value="field" :label="field" />
</el-select>
</el-form-item>
<el-form-item :label="t('es.dashboard.text')" required prop="text">
<el-input type="textarea" rows="5" v-model="state.analyze.text" />
</el-form-item>
</el-form>
<el-button @click="onAnalyze" :loading="state.analyze.loading">{{ t('es.dashboard.startAnalyze') }}</el-button>
<el-table :data="state.analyze.tokens" style="height: calc(100vh - 500px)" stripe size="small" :v-loading="true">
<el-table-column label="token" prop="token" />
<el-table-column label="position" prop="position" />
<el-table-column label="start_offset" prop="start_offset" />
<el-table-column label="end_offset" prop="end_offset" />
<el-table-column label="type" prop="type" />
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
import { esApi } from '@/views/ops/es/api';
import { formatByteSize } from '@/common/utils/format';
import dayjs from 'dayjs';
const { t } = useI18n();
interface Props {
instId: any;
}
const props = defineProps<Props>();
const analyzeFormRef = ref();
const state = reactive({
tabName: 'nodesStats',
instInfo: [] as any[],
clusterHealth: [] as any[],
nodesStats: { _nodes: {} as any, nodes: [] as any[] } as any,
idxFields: [] as any[],
nodesStatsLoading: false,
instInfoLoading: false,
clusterHealthLoading: false,
clusterStateLoading: false,
analyze: {
loading: false,
idxName: '',
fields: [],
field: '',
text: '',
tokens: [],
},
});
onMounted(async () => {
await nextTick(async () => {
await fetchNodesStats();
});
});
watch(
() => state.tabName,
async (val) => {
switch (val) {
case 'instInfo':
return await fetchInstInfo();
case 'clusterHealth':
return await fetchClusterHealth();
case 'nodesStats':
return await fetchNodesStats();
case 'analyze':
await fetchClusterState();
return;
}
}
);
const fetchInstInfo = async () => {
state.instInfoLoading = true;
state.instInfo = [];
let res = await esApi.proxyReq('get', props.instId, '/');
let fo = flattenObject(res);
for (const it in fo) {
state.instInfo.push({
name: it,
value: fo[it],
});
}
state.instInfoLoading = false;
// key 排序
state.instInfo = state.instInfo.sort((a, b) => a.name.localeCompare(b.name));
};
function flattenObject(obj: Record<string, any>, parentKey = '', result: Record<string, any> = {}): Record<string, any> {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const newKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
flattenObject(obj[key], newKey, result);
} else {
result[newKey] = obj[key];
}
}
}
return result;
}
const fetchClusterHealth = async () => {
state.clusterHealthLoading = true;
state.clusterHealth = [];
let res = await esApi.proxyReq('get', props.instId, '/_cluster/health');
let fo = flattenObject(res);
for (const it in fo) {
state.clusterHealth.push({
name: it,
value: fo[it],
});
}
state.clusterHealthLoading = false;
// key 排序
state.clusterHealth = state.clusterHealth.sort((a, b) => a.name.localeCompare(b.name));
};
const fetchNodesStats = async () => {
state.nodesStatsLoading = true;
let res = await esApi.proxyReq('get', props.instId, '/_nodes/stats/os,jvm,indices,transport,fs');
state.nodesStats._nodes = res._nodes;
let nodes = [] as any[];
for (let k in res.nodes) {
let node = res.nodes[k];
node.key = k;
nodes.push(node);
}
state.nodesStats.nodes = nodes.sort((a, b) => a.name.localeCompare(b.name));
// 以node名排序
state.nodesStatsLoading = false;
// id
// ip
// name
// roles
// 系统内存 饼图 os.mem.total_in_bytes os.mem.used_in_bytes os.mem.used_percent
// 系统cpu使用率 饼图 os.cpu.percent
// jvm内存 饼图 jvm.mem.heap_max_in_bytes jvm.mem.heap_used_in_bytes jvm.mem.heap_used_percent
// 存储空间占用信息 饼图 fs.total.total_in_bytes fs.total.free_in_bytes
// 索引文档数 indices.docs.count
// 索引占用 indices.store.size_in_bytes
// 总分片数量 indices.shard_stats.total_count
// 网络流量 transport.rx_size_in_bytes transport.tx_size_in_bytes
};
const fetchClusterState = async () => {
state.clusterStateLoading = true;
const res = await esApi.proxyReq('get', props.instId, '/_cluster/state');
const idxFields = [];
for (let k in res.metadata.indices) {
// 过滤系统索引
if (k.indexOf('.') >= 0) {
continue;
}
let properties = res.metadata.indices[k]?.mappings?._doc?.properties || {};
let fields = [];
for (let k in properties) {
let f = properties[k];
// long字段类型不支持分析
if (f.type === 'long' || f.type === 'date') {
continue;
}
// 添加字段
fields.push(k);
// 如果有子字段,则添加子字段
if (f.fields) {
for (let fk in f.fields) {
fields.push(`${k}.${fk}`);
}
}
}
idxFields.push({
name: k,
fields: fields.sort(),
});
}
// 索引字段信息
state.idxFields = idxFields.sort((a, b) => a.name.localeCompare(b.name));
state.clusterStateLoading = false;
};
const getPercentColor = (percent: number) => {
if (percent < 60) {
return '#67c23a';
} else if (percent < 80) {
return '#e6a23c';
} else {
return '#f56c6c';
}
};
const onSelectIdxField = () => {
state.analyze.fields = state.idxFields.find((item: any) => item.name === state.analyze.idxName)?.fields || [];
state.analyze.field = '';
};
const onAnalyze = async () => {
await analyzeFormRef.value.validate();
state.analyze.loading = true;
setTimeout(() => {
state.analyze.loading = false;
}, 2000);
let res = await esApi.proxyReq('post', props.instId, `/${state.analyze.idxName}/_analyze`, {
field: state.analyze.field,
text: state.analyze.text,
});
state.analyze.tokens = res.tokens;
state.analyze.loading = false;
};
</script>
<style scoped lang="scss">
.nodes-num {
font-size: 20px;
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<el-drawer
:title="`${model.isAdd ? t('common.add') : t('common.edit')} ${model.idxName}`"
v-model="visible"
:destroy-on-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
size="50%"
>
<el-auto-resizer>
<template #default="{ height, width }">
<el-form>
<el-form-item label="_id">
<el-input v-model.trim="_id" :disabled="model._id != ''" :placeholder="t('es.specifyIdAdd')" />
</el-form-item>
<monaco-editor v-model="model.doc" language="json" :height="height - 40 + 'px'" :options="{ wordWrap: 'on', tabSize: 2 }" />
</el-form>
</template>
</el-auto-resizer>
<template #footer>
<el-button size="small" @click="visible = false">{{ t('common.cancel') }}</el-button>
<el-button size="small" v-auth="perms.saveData" @click="onSaveDoc" :loading="loading" type="primary">{{ t('common.confirm') }}</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { defineAsyncComponent, ref, watch } from 'vue';
import { esApi } from '@/views/ops/es/api';
import { ElMessage } from 'element-plus';
const MonacoEditor = defineAsyncComponent(() => import('@/components/monaco/MonacoEditor.vue'));
const { t } = useI18n();
const perms = {
saveData: 'es:data:save',
};
const visible = defineModel<boolean>('visible');
const loading = ref(false);
const _id = ref('');
interface Params {
isAdd: boolean;
instId: string;
doc: string;
idxName: string;
_id: string;
}
const model = defineModel<Params>({ required: true });
const emit = defineEmits(['success']);
const getZeroValueByProperties = async () => {
// 根据mapping字段赋值
let mp = await esApi.proxyReq('get', model.value.instId, `/${model.value.idxName}/_mappings`);
let properties = mp[model.value.idxName].mappings.properties;
let data = {} as any;
for (let key in properties) {
let item = properties[key];
switch (item.type) {
case 'object':
case 'nested':
case 'flattened':
data[key] = {};
break;
case 'long':
case 'short':
case 'byte':
case 'double':
case 'float':
case 'half_float':
case 'scaled_float':
data[key] = 0;
break;
case 'boolean':
data[key] = false;
break;
default:
data[key] = '';
break;
}
}
return data;
};
watch(visible, async (newValue) => {
if (!newValue) {
model.value._id = '';
model.value.doc = '';
_id.value = '';
loading.value = false;
} else {
if (model.value._id) {
_id.value = model.value._id;
}
if (!model.value.doc) {
model.value.doc = JSON.stringify(await getZeroValueByProperties(), null, 2);
}
}
});
const onSaveDoc = async () => {
loading.value = true;
let doc = model.value.doc;
let data;
try {
data = JSON.parse(doc);
} catch (error) {
ElMessage.error(t('es.docJsonError'));
loading.value = false;
return;
}
// 如果数据中带有_id则删除_id
if (data._id) {
delete data._id;
}
// 2 秒后关闭loading避免接口报错后不关闭loading
setTimeout(async () => {
loading.value = false;
}, 2000);
await esApi.proxyReq('post', model.value.instId, `/${model.value.idxName}/_doc/${_id.value}`, data);
ElMessage.success(t('common.saveSuccess'));
setTimeout(() => {
visible.value = false;
emit('success');
}, 500);
};
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,213 @@
<template>
<el-drawer
:title="t('es.indexDetail') + ' - ' + state.idxName"
v-model="visible"
size="50%"
:destroy-on-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
class="es-index-detail h-full"
@close="onClose"
>
<el-auto-resizer>
<template #default="{ height, width }">
<el-tabs v-model="activeName">
<el-tab-pane name="settings">
<template #label>
<el-tooltip>
<template #content> {{ t('es.availableSettingFields') }}: {{ allowedKeys }}</template>
<el-space>{{ t('es.opSettings') }}<SvgIcon name="QuestionFilled" /></el-space>
</el-tooltip>
</template>
<monaco-editor v-model="state.settings" language="json" :height="height - 40 + 'px'" :options="{ tabSize: 2 }" />
</el-tab-pane>
<el-tab-pane :label="t('es.indexMapping')" name="mappings">
<monaco-editor v-model="state.mappings" language="json" :height="height - 40 + 'px'" :options="state.editorOptions" />
</el-tab-pane>
<el-tab-pane :label="t('es.indexStats')" name="stats">
<monaco-editor v-model="state.stats" language="json" :height="height - 40 + 'px'" :options="state.editorOptions" />
</el-tab-pane>
<el-tab-pane :label="t('es.aliases')" name="aliases">
<el-button type="primary" @click="onAddAlias" icon="plus" size="small">{{ t('es.addAlias') }}</el-button>
<div :style="{ paddingTop: '20px' }">
<el-space direction="vertical" alignment="start">
<el-tag v-for="tag in state.aliases" :key="tag" closable type="primary" @close="onRemoveAlias(tag)">
{{ tag }}
</el-tag>
</el-space>
</div>
</el-tab-pane>
</el-tabs>
</template>
</el-auto-resizer>
<template #footer>
<el-button size="small" @click="visible = false">{{ t('common.close') }}</el-button>
<el-button size="small" @click="onOk" type="primary" v-if="activeName == 'settings'" :loading="state.loading">{{ t('common.confirm') }}</el-button>
</template>
</el-drawer>
<el-dialog v-model="dialogFormVisible" :title="t('es.addAlias')" width="400">
<el-form :model="state.aliasesForm">
<el-form-item :label="t('es.aliases')">
<el-input v-model="state.aliasesForm.name" autocomplete="off" />
</el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="dialogFormVisible = false">{{ t('common.cancel') }}</el-button>
<el-button size="small" @click="onSubmitAddAlias" :loading="aliasLoading" type="primary">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { defineAsyncComponent, reactive, ref, watch } from 'vue';
import { esApi } from '@/views/ops/es/api';
import { ElMessage } from 'element-plus';
import { useI18nDeleteConfirm } from '@/hooks/useI18n';
const MonacoEditor = defineAsyncComponent(() => import('@/components/monaco/MonacoEditor.vue'));
const { t } = useI18n();
const visible = ref(false);
const aliasLoading = ref(false);
const activeName = ref('settings');
const defaultData = {
instId: 0,
loading: false,
aliases: [] as string[],
idxName: '',
health: {},
mappings: '',
stats: '',
settings: '',
editorOptions: { tabSize: 2, readOnly: true, readOnlyMessage: { value: t('es.readonlyMsg') } },
aliasesForm: { name: '' },
};
let state = reactive(defaultData);
const dialogFormVisible = ref(false);
let allowedKeys = ['number_of_replicas', 'refresh_interval', 'blocks.read_only', 'blocks.read', 'blocks.write', 'max_result_window', 'blocks'];
const onOk = async () => {
if (activeName.value === 'settings') {
/**
* 常见可修改设置:
* 设置项 描述
* number_of_replicas 副本分片数,可随时修改
* refresh_interval 刷新间隔,控制索引频率
* blocks.read_only 设置索引为只读或可写
* blocks.read / blocks.write 控制是否允许读/写操作
* max_result_window 控制最大返回结果数量默认为10000
*/
let settings = JSON.parse(state.settings).index;
// 只允许传可设置的字段
for (let key in settings) {
if (allowedKeys.indexOf(key) == -1) {
delete settings[key];
}
}
await esApi.proxyReq('put', state.instId, `/${state.idxName}/_settings`, { index: settings });
ElMessage.success(t('common.saveSuccess'));
}
};
watch(activeName, async (val) => {
state.mappings = '';
state.stats = '';
state.aliases = [];
// 如果没有值就请求接口获取值
if (val === 'mappings') {
await refreshMappings();
} else if (val === 'stats') {
await refreshStats();
} else if (val === 'aliases') {
await refreshAlias();
}
});
const refreshMappings = async () => {
let res = await esApi.proxyReq('get', state.instId, `/${state.idxName}/_mappings`);
state.mappings = JSON.stringify(res[state.idxName].mappings, null, 2);
};
const refreshStats = async () => {
let stats = await esApi.proxyReq('get', state.instId, `/${state.idxName}/_stats`);
state.stats = JSON.stringify(stats.indices[state.idxName], null, 2);
};
const refreshAlias = async () => {
let aliases = await esApi.proxyReq('get', state.instId, `/${state.idxName}/_alias`);
state.aliases = Object.keys(aliases[state.idxName].aliases);
};
const refreshSettings = async () => {
let res = await esApi.proxyReq('get', state.instId, `/${state.idxName}/_settings`);
let st = res[state.idxName].settings;
state.settings = JSON.stringify(st, null, 2);
};
const initBasic = async () => {
state.health = await esApi.proxyReq('get', state.instId, `/_cluster/health/${state.idxName}`);
await refreshSettings();
};
const onAddAlias = async () => {
dialogFormVisible.value = true;
state.aliasesForm.name = '';
aliasLoading.value = false;
};
const onRemoveAlias = async (name: string) => {
await useI18nDeleteConfirm(`${t('es.aliases')}: ${name}`);
await esApi.proxyReq('delete', state.instId, `/${state.idxName}/_alias/${name}`);
ElMessage.success(t('common.deleteSuccess'));
await refreshAlias();
};
const onSubmitAddAlias = async () => {
aliasLoading.value = true;
await esApi.proxyReq('put', state.instId, `/${state.idxName}/_alias/${state.aliasesForm.name}`);
ElMessage.success(t('common.saveSuccess'));
await refreshAlias();
dialogFormVisible.value = false;
aliasLoading.value = false;
};
const onClose = () => {
state = reactive(defaultData);
};
const open = (data: any) => {
visible.value = true;
activeName.value = 'settings';
state = reactive(defaultData);
state.instId = data.instId;
state.idxName = data.idxName;
initBasic();
};
const close = () => {
visible.value = false;
onClose();
};
defineExpose({
open,
close,
});
</script>
<style lang="scss">
.es-index-detail {
.el-drawer__body {
padding-top: 0;
}
}
</style>

View File

@@ -0,0 +1,284 @@
<template>
<el-drawer
size="50%"
:destroy-on-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
class="es-op-temp h-full"
v-model="visible"
:title="t('es.templates')"
>
<el-auto-resizer>
<template #default="{ height, width }">
<el-space class="mb-3">
<el-input :placeholder="t('es.temp.filter')" v-model.trim="state.filterTableName" @input="onFilterTemplates" />
<el-button type="primary" @click="onAddTemplate" icon="plus">{{ t('common.add') }}</el-button>
<SvgIcon name="refresh" @click="fetchTemplates" :size="20" />
<el-dropdown :hide-on-click="false">
<SvgIcon name="setting" :size="20" />
<el-button link icon="setting" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<el-checkbox v-model="state.showHideTemps" @change="onSwitchShowHide">{{ t('es.temp.showHide') }}</el-checkbox>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-tooltip>
<template #content>
<pre>{{ t('es.temp.note') }}</pre>
</template>
<SvgIcon name="Warning" :size="20" />
</el-tooltip>
<el-text type="warning" size="small">{{ t('es.temp.versionAlert') }}</el-text>
</el-space>
<el-table :data="state.templates" :max-height="height - 40" stripe size="small">
<el-table-column prop="name" :label="t('es.temp.name')" />
<el-table-column prop="index_patterns" :label="t('es.temp.index_patterns')" />
<el-table-column prop="description" :label="t('es.temp.description')" />
<el-table-column :label="t('common.operation')" width="100px" align="center">
<template #default="scope">
<el-space>
<el-button link type="primary" size="small" @click="onViewTemplate(scope.row)">{{ t('common.detail') }}</el-button>
<el-button link type="danger" size="small" @click="onDelTemplate(scope.row.name)">{{ t('common.delete') }}</el-button>
</el-space>
</template>
</el-table-column>
</el-table>
</template>
</el-auto-resizer>
</el-drawer>
<el-drawer
size="50%"
:destroy-on-close="true"
:close-on-click-modal="false"
:close-on-press-escape="false"
class="es-add-temp h-full"
v-model="state.addVisible"
:title="state.formReadonly ? t('es.temp.view') : t('es.temp.addTemp')"
>
<el-auto-resizer>
<template #default="{ height, width }">
<el-form :model="form" ref="formRef" label-position="right" label-width="80">
<el-form-item :label="t('es.temp.name')" required prop="name">
<el-input v-model.trim="form.name" :disabled="state.formReadonly" />
</el-form-item>
<el-form-item :label="t('es.temp.priority')" required prop="priority">
<el-input-number v-model="form.priority" :disabled="state.formReadonly" />
</el-form-item>
<el-form-item :label="t('es.temp.index_patterns')" prop="index_patterns">
<el-select allow-create filterable multiple clearable v-model="form.index_patterns" :disabled="state.formReadonly"></el-select>
</el-form-item>
<el-form-item :label="t('es.temp.description')" required prop="description">
<el-input v-model.trim="form.description" :disabled="state.formReadonly" />
</el-form-item>
<el-form-item :label="t('es.temp.content')" required prop="template">
<monaco-editor
v-model="form.template"
language="json"
:height="height - 200 + 'px'"
:options="{ tabSize: 2, readOnly: state.formReadonly }"
/>
</el-form-item>
</el-form>
</template>
</el-auto-resizer>
<template #footer>
<el-button @click="state.addVisible = false">{{ t('common.close') }}</el-button>
<el-button v-if="!state.formReadonly" type="primary" @click="doAddTemplate">{{ t('common.confirm') }}</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { esApi } from '@/views/ops/es/api';
import { nextTick, reactive, ref, unref, watch } from 'vue';
import { useI18nConfirm, useI18nDeleteConfirm, useI18nDeleteSuccessMsg, useI18nOperateSuccessMsg } from '@/hooks/useI18n';
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import SvgIcon from '@/components/svgIcon/index.vue';
const { t } = useI18n();
const visible = defineModel<boolean>();
interface Props {
instId: any;
version: string;
}
const props = defineProps<Props>();
const formRef = ref();
const state = reactive({
originTemplates: [] as any,
templates: [] as any,
showHideTemps: false,
filterTableName: '',
addVisible: false,
formReadonly: false,
form: {
name: '',
priority: 100,
index_patterns: [],
template: '',
description: '',
},
// es 版本是否小于 7.8.0
// 7.8之前的版本模板接口为_template优先级字段为order,
// 7.8之后的版本模板接口为_index_template优先级字段为priority
v: {
oldVersion: false,
api: '_index_template',
priority: 'priority',
},
});
const { form } = unref(state);
const getDefaultTemplate = () => {
return {
settings: {
number_of_shards: 5,
number_of_replicas: 1,
blocks: {
read_only: 'false',
},
max_result_window: '1000000',
refresh_interval: '30s',
},
mappings: { properties: {} },
};
};
watch(visible, async (x: any) => {
if (x) {
// 初始化状态
state.filterTableName = '';
state.originTemplates = [];
state.templates = [];
state.showHideTemps = false;
state.v.oldVersion = isVersionBefore7_8_0(props.version);
if (state.v.oldVersion) {
state.v.api = '_template';
state.v.priority = 'order';
} else {
state.v.api = '_index_template';
state.v.priority = 'priority';
}
await nextTick(fetchTemplates);
}
});
const isVersionBefore7_8_0 = (version: string) => {
if (!version) {
return false;
}
const v1 = version.split('.').map(Number);
const v2 = [7, 8, 0]; // 比较目标版本
for (let i = 0; i < 3; i++) {
if (v1[i] < v2[i]) return true;
if (v1[i] > v2[i]) return false;
}
return false; // 等于 7.8.0 时返回 false
};
const fetchTemplates = async () => {
const data = await esApi.proxyReq('get', props.instId, `/${state.v.api}`);
state.originTemplates = data.index_templates
.map((a: any) => {
return {
name: a.name,
priority: a.index_template.priority || 'NULL',
index_patterns: JSON.stringify(a.index_template.index_patterns || '[]'),
template: JSON.stringify(a.index_template.template || {}, null, 2),
description: a.index_template._meta?.description || '',
};
})
.sort((a: any, b: any) => a.name.localeCompare(b.name));
onSwitchShowHide();
};
const onSwitchShowHide = () => {
if (state.showHideTemps) {
state.templates = state.originTemplates;
} else {
state.templates = state.originTemplates.filter((item: any) => item.name.indexOf('.') < 0);
}
};
const onFilterTemplates = () => {
onSwitchShowHide();
let regx = createPattern(state.filterTableName);
state.templates = state.templates.filter((item: any) => regx.test(item.name) || regx.test(item.description));
};
function createPattern(str: string): RegExp {
const escaped = str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // 转义特殊字符
const pattern = [...escaped].join('.*');
return new RegExp(`.*${pattern}.*`);
}
const onViewTemplate = async (data: any) => {
state.addVisible = true;
state.formReadonly = true;
state.form.name = data.name;
state.form.priority = data.priority;
state.form.index_patterns = JSON.parse(data.index_patterns);
state.form.template = data.template;
state.form.description = data.description;
};
const onAddTemplate = () => {
state.addVisible = true;
state.formReadonly = false;
state.form.name = '';
state.form.priority = 100;
state.form.index_patterns = [];
state.form.template = JSON.stringify(getDefaultTemplate(), null, 2);
state.form.description = '';
};
const doAddTemplate = async () => {
await formRef.value.validate();
let data = {
index_patterns: state.form.index_patterns,
[state.v.priority]: state.form.priority,
template: JSON.parse(state.form.template),
_meta: {
description: state.form.description,
},
};
await esApi.proxyReq('put', props.instId, `/${state.v.api}/${state.form.name}`, data);
useI18nOperateSuccessMsg();
setTimeout(async () => {
state.addVisible = false;
await fetchTemplates();
}, 500);
};
const onDelTemplate = async (name: any) => {
await useI18nDeleteConfirm(name);
await useI18nConfirm('es.deleteTemplateConfirm', { name: name });
await esApi.proxyReq('delete', props.instId, `/${state.v.api}/${name}`);
useI18nDeleteSuccessMsg();
setTimeout(async () => {
await fetchTemplates();
}, 500);
};
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,85 @@
<template>
<el-drawer
:title="t('es.Reindex')"
v-model="visible"
size="40%"
:destroy-on-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
class="es-reindex h-full"
>
<el-tabs v-model="tabActiveName">
<el-tab-pane name="basic" label="basic">
<el-form :model="formData" ref="formRef">
<el-form-item :label="t('es.ReindexTargetIdx')" required prop="targetIdxName">
<el-select clearable filterable v-model="formData.targetIdxName" :style="{ width: '100%' }">
<el-option v-for="idx in idxNames" :key="idx" :value="idx" :label="idx" />
</el-select>
</el-form-item>
<el-form-item :label="t('es.ReindexIsSync')">
<el-space>
<el-switch v-model="formData.sync" />
<el-text type="info" size="small">{{ t('es.ReindexSyncDescription') }}</el-text>
</el-space>
</el-form-item>
<el-form-item>
<el-text type="info" size="small">{{ t('es.ReindexDescription') }}</el-text>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane name="otherInst" :label="t('es.ReindexToOtherInst')"> developing... </el-tab-pane>
<el-tab-pane name="task" :label="t('es.ReindexSyncTask')"> developing... </el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="confirm">{{ t('common.confirm') }}</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { esApi } from '@/views/ops/es/api';
import { ElMessage } from 'element-plus';
const { t } = useI18n();
const visible = defineModel<boolean>('visible');
const formRef = ref();
interface Props {
instId: any;
idxName: string;
idxNames: string[];
}
const props = defineProps<Props>();
const tabActiveName = ref('basic');
const formData = ref({
targetIdxName: '',
sync: false,
});
const confirm = async () => {
if (tabActiveName.value === 'basic') {
await doBasicReindex();
}
};
const doBasicReindex = async () => {
await formRef.value.validate();
let wfc = '';
if (!formData.value.sync) {
wfc = '?wait_for_completion=false';
}
let data = { source: { index: props.idxName }, dest: { index: formData.value.targetIdxName } };
let res = await esApi.proxyReq('POST', props.instId, `/_reindex${wfc}`, data);
// FIXME 如果是异步返回异步任务id添加到任务列表中可以在任务列表中查看状态
ElMessage.success(t('common.operateSuccess'));
};
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,621 @@
<template>
<el-dialog :title="t('es.makeSearchParam')" v-model="visible" :width="state.searchBoxWidth" class="es-search-form-inline">
<el-tabs v-model="state.activeName">
<el-tab-pane :label="t('es.standardSearch')" name="standard">
<el-card>
<template #header>
<el-space>
<span>{{ t('es.searchParams') }}</span>
<el-text type="info" size="small">{{ t('es.searchParamsDesc') }}</el-text>
</el-space>
</template>
<el-button v-if="state.queryParams.length == 0" size="small" @click="onAddParam" type="primary" icon="plus">{{
t('common.add')
}}</el-button>
<div v-for="item in state.queryParams" :key="item.uuid">
<el-form :inline="true" :model="item">
<el-form-item>
<el-switch v-model="item.enable" active-text="on" inactive-text="off" inline-prompt />
</el-form-item>
<el-form-item>
<el-select v-model="item.type">
<el-option v-for="p in paramTypes" :key="p" :label="p" :value="p" />
</el-select>
</el-form-item>
<el-form-item>
<el-select filterable v-model="item.field" class="field-select">
<el-option v-for="f in fields" :key="f" :label="f" :value="f" />
</el-select>
</el-form-item>
<el-form-item>
<el-select filterable v-model="item.matchType">
<el-option v-for="d in matchTypes" :key="d" :label="d" :value="d" />
</el-select>
</el-form-item>
<el-form-item v-if="item.matchType !== 'range'">
<el-input
v-model.trim="item.value"
:placeholder="item.matchType === 'terms' || item.type === 'should' ? t('common.MultiPlaceholder') : ''"
/>
</el-form-item>
<el-form-item>
<el-button link @click="onAddParam" type="primary" icon="plus" />
</el-form-item>
<el-form-item>
<el-button link @click="onCopyParam(item)" type="primary" icon="CopyDocument" />
</el-form-item>
<el-form-item>
<el-button link @click="onDelParam(item.uuid)" type="danger" icon="delete" />
</el-form-item>
<div v-if="item.matchType === 'range'">
<el-form-item>
<el-select v-model="item.gtType" class="es-range-select">
<el-option value="gt">gt ></el-option>
<el-option value="gte">gte >=</el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-input class="es-range-input" v-model.trim="item.gtValue" placeholder="> or >=" />
</el-form-item>
<el-form-item>
<el-select v-model="item.ltType" class="es-range-select">
<el-option value="lt">lt <</el-option>
<el-option value="lte">lte <=</el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-input class="es-range-input" v-model.trim="item.ltValue" placeholder="< or <=" />
</el-form-item>
</div>
</el-form>
</div>
</el-card>
<el-card :header="t('es.sortParams')">
<el-button v-if="state.sortParams.length == 0" size="small" @click="onAddSort" type="primary" icon="plus">{{ t('common.add') }}</el-button>
<div v-for="item in state.sortParams" :key="item.uuid">
<el-form :inline="true" :model="item">
<el-form-item>
<el-switch v-model="item.enable" active-text="on" inactive-text="off" inline-prompt></el-switch>
</el-form-item>
<el-form-item>
<el-select filterable v-model="item.field" class="field-select">
<el-option v-for="f in fields" :key="f" :label="f" :value="f" />
</el-select>
</el-form-item>
<el-form-item>
<el-select filterable v-model="item.order">
<el-option v-for="t in orderTypes" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
<el-form-item>
<el-button link @click="onAddSort" type="primary" icon="plus" />
</el-form-item>
<el-form-item>
<el-button link @click="onDelSort(item.uuid)" type="danger" icon="delete" />
</el-form-item>
</el-form>
</div>
</el-card>
<el-card :header="t('es.otherParams')">
<el-form label-width="200px" label-position="left">
<el-form-item label="track_total_hits">
<el-checkbox v-model="state.track_total_hits" />
</el-form-item>
<el-form-item label="minimum_should_match">
<el-input-number size="small" v-model="state.minimum_should_match" :min="1" :max="10" />
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
<el-tab-pane :label="t('es.AggregationSearch')" name="aggs"> developing... </el-tab-pane>
<el-tab-pane :label="t('es.SqlSearch')" name="sql"> developing... </el-tab-pane>
</el-tabs>
<template #footer>
<div>
<el-button size="small" @click="onPreviewParam" icon="view">{{ t('es.previewParams') }}</el-button>
<el-button size="small" @click="onClearParam" icon="refresh">{{ t('common.reset') }}</el-button>
<!-- <el-button size="small" @click="onSaveParam" type="primary" icon="check">{{ t('common.save') }}</el-button>-->
<el-button size="small" @click="visible = false" icon="close">{{ t('common.close') }}</el-button>
<el-button size="small" @click="onSearch" type="primary" icon="search">{{ t('common.search') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { reactive, ref, watch } from 'vue';
import { randomUuid } from '@/common/utils/string';
import MonacoEditorBox from '@/components/monaco/MonacoEditorBox';
import { esApi } from '@/views/ops/es/api';
const { t } = useI18n();
/**
*
* 类型 是否参与评分 必须满足 说明
* must ✅ 是 ✅ 是 所有条件都必须满足,类似 SQL 的 AND
* should ✅ 是(默认) ❌ 否 至少满足一个,类似 SQL 的 OR可通过 minimum_should_match 控制
* must_not ❌ 否 ✅ 是 所有条件都不满足,不参与评分
*
*
* 匹配方式 适用类型 特点 示例
* match text 对文本字段进行分词后匹配。 "match": { "content": "elasticsearch search" } 匹配包含 "elasticsearch" 或 "search" 的文档。
* match_phrase text 短语匹配,要求关键词按顺序连续出现。 "match_phrase": { "content": "今天天气不错" } 只有当该短语作为一个整体出现在内容中时才匹配。
* term keyword、boolean、number 等不分词字段 对字段进行精确匹配,不进行分词。 "term": { "status": "published" 匹配 status 字段等于 "published" 的文档。
* terms 用于匹配多个值中的任意一个(类似 SQL 中的 IN "terms": { "category": ["tech", "science", "ai"] }
* exists 判断某个字段是否存在。
* wildcard 支持通配符匹配(* 匹配任意字符序列,? 匹配单个字符)。
* range 用于匹配数值或日期范围 "range": { "age": { "gte": 18, "lte": 30 } }
*
* 使用建议
* 对于需要全文检索的字段,使用 text 类型 + match。
* 对于精确匹配(如 ID、状态码、标签等使用 keyword 类型 + term。
* 如果要提高性能,可以将不关心相关度的条件放在 bool.filter 中。
* 尽量避免在大数据集中频繁使用 wildcard它会显著影响性能。
*
* 查询示例
* {
* "query": {
* "bool": {
* "must": [
* { "match": { "title": "搜索测试" }}
* ],
* "should": [
* { "term": { "category": "技术" }},
* { "match_phrase": { "content": "性能优化" }}
* ],
* "must_not": [
* { "term": { "status": "草稿" }}
* ],
* "minimum_should_match": 1
* }
* },
* "sort": { "etlTime": { "order": "desc" } },
* "aggs": {}
* "from": 0,
* "size": 25
* }
*
* 聚合查询Aggregations
* 是一种强大的数据分析功能,用于对数据进行分类、统计、分析和分组。它类似于 SQL 中的 GROUP BY 和 COUNT()、SUM() 等操作,但更加强大灵活。
* 一、聚合的基本结构
*
* {
* "size": 0,
* "aggs": {
* "自定义聚合名称": {
* "聚合类型": {
* // 聚合参数
* }
* }
* }
* }
* - "size": 0表示不返回具体的文档内容只返回聚合结果。
* - "aggs":是聚合的入口,你可以在这里定义多个聚合项。
*
* 二、常见的聚合类型
* 1. 指标聚合Metric Aggregations
* 用于计算字段的统计指标
*
* 聚合类型 描述
* avg 计算某个字段的平均值
* sum 计算字段总和
* min / max 获取最小值或最大值
* value_count 统计非空值的数量
* cardinality 去重统计(类似 SQL 的 COUNT(DISTINCT)
*
* 示例:
*
* "aggs": {
* "avg_salary": { "avg": { "field": "salary" } },
* "unique_users": { "cardinality": { "field": "user_id.keyword" } }
* }
*
* 2. 桶聚合Bucket Aggregations
* 用于将文档分组(类似 SQL 的 GROUP BY每个桶是一个分组。
* (1) terms按字段值分组统计
* "aggs": {
* "group_by_status": {
* "terms": { "field": "status.keyword" }
* }
* }
* 按 status 字段的不同值进行分组,并统计每组数量。
* (2) date_histogram按时间间隔分组
* "aggs": {
* "articles_over_time": {
* "date_histogram": {
* "field": "publish_date",
* "calendar_interval": "day"
* }
* }
* }
* 按天统计文章发布数量。
*
* (3) range / date_range按数值/日期范围分组
*
* "aggs": {
* "age_distribution": {
* "range": {
* "field": "age",
* "ranges": [
* { "from": 0, "to": 18 },
* { "from": 18, "to": 35 },
* { "from": 35, "to": 60 }
* ]
* }
* }
* }
* 按年龄段区间统计人数。
* (4) histogram按固定数值步长分组
* "aggs": {
* "price_distribution": {
* "histogram": {
* "field": "price",
* "interval": 100
* }
* }
* }
* 每 100 元为一个价格区间进行分组统计。
*
* 3. 嵌套聚合(组合使用)
* 你可以在一个聚合中嵌套其他聚合,实现多维分析。
* 示例:先按状态分组,再按平均工资排序
* "aggs": {
* "group_by_status": {
* "terms": {
* "field": "status.keyword",
* "order": { "avg_salary": "desc" }
* },
* "aggs": {
* "avg_salary": { "avg": { "field": "salary" } }
* }
* }
* }
*
*
*
*
*
* es 数据类型
*
* 一、基础数据类型
* 类型 描述
* text 用于全文本搜索,会被分析器分词处理。适用于长文本内容。
* keyword 不分词,作为完整字符串存储和匹配,适用于精确查询、聚合、排序等。
* long 64位整数。
* integer 32位整数。
* short 16位整数。
* byte 8位整数。
* double 双精度浮点数。
* float 单精度浮点数。
* half_float 半精度浮点数(占用更少空间)。
* scaled_float 以长整型形式存储浮点数1.99 存为 199缩放因子为 100
* date 日期类型,可接受格式化字符串或时间戳。
* boolean 布尔值true 或 false。
* binary Base64 编码的二进制数据(不存储,仅用于传输)。
*
* 二、复杂数据类型
* 类型 描述
* object 默认嵌套 JSON 对象类型,适合嵌套结构但不支持嵌套查询。
* nested 特殊的 object 类型,支持嵌套查询(需使用 nested query
* flattened 将整个对象视为单个字段,适用于动态结构但只支持精确匹配。
* join 用于父子文档关系Parent-Child实现文档间逻辑关联。
* percolator 用于预注册查询,然后对新文档进行匹配测试。
*
* 三、地理空间数据类型
* 类型 描述
* geo_point 表示经纬度坐标,可用于距离查询、地理围栏等。
* geo_shape 支持复杂的地理形状(如多边形、线段等),用于高级地理查询。
*
* 四、特殊用途数据类型
* 类型 描述
* ip 用于 IPv4/IPv6 地址,支持范围查询。
* token_count 统计某个 text 字段分词后的词项数量。
* murmur3 自动计算字段的哈希值(需显式开启)。
* attachment 用于解析 Base64 编码的文件(如 PDF、Word 等)。
* search_as_you_type 优化自动补全搜索体验的字段类型。
* rank_feature / rank_features 用于基于机器学习的相关性评分优化。
*
* 五、数组类型
* 在 ES 中,没有单独的数组类型。任何字段都可以包含多个值,只要它们的类型一致。
*
* 六、字段映射示例
*
* {
* "mappings": {
* "properties": {
* "title": { "type": "text" },
* "status": { "type": "keyword" },
* "views": { "type": "integer" },
* "created_at": { "type": "date" },
* "location": { "type": "geo_point" },
* "tags": { "type": "keyword" },
* "user": {
* "type": "nested",
* "properties": {
* "name": { "type": "text" },
* "email": { "type": "keyword" }
* }
* }
* }
* }
* }
*
*/
const defaultSearch = {
sort: {} as any, // etlTime: { order: 'desc' }
query: { bool: { must: [], should: [], must_not: [] } },
aggs: {},
} as any;
interface Props {
instId: string;
idxName: string;
}
const fields = ref<string[]>();
const props = defineProps<Props>();
const emit = defineEmits(['search']);
const visible = defineModel<boolean>('visible');
watch(visible, async (v) => {
if (v) {
// 通过mapping获取所有字段信息
if (fields.value?.length) {
return;
}
let mp = await esApi.proxyReq('get', props.instId, `/${props.idxName}/_mappings`);
let properties = mp[props.idxName].mappings.properties;
let data = ['_id'];
for (let key in properties) {
data.push(key);
let item = properties[key];
if (item.fields) {
for (let f in item.fields) {
data.push(`${key}.${f}`);
}
}
}
fields.value = data;
}
});
const paramTypes = ['must', 'should', 'must_not'] as const;
const matchTypes = ['match', 'match_phrase', 'term', 'terms', 'exists', 'wildcard', 'range'] as const;
const orderTypes = ['asc', 'desc'] as const;
const gtTypes = ['gt', 'gte'] as const;
const ltTypes = ['lt', 'lte'] as const;
type searchParam = {
uuid: string; // 唯一id
enable: boolean; // 是否启用,启用后才应用到搜索
type: (typeof paramTypes)[number];
field: string;
matchType: (typeof matchTypes)[number];
value: any;
gtType: (typeof gtTypes)[number];
gtValue: string;
ltType: (typeof ltTypes)[number];
ltValue: string;
};
type sortParam = {
uuid: string; // 唯一id
enable: boolean; // 是否启用,启用后才应用到搜索
field: string;
order: (typeof orderTypes)[number];
};
const state = reactive({
searchBoxWidth: '720px',
queryParams: [] as searchParam[],
sortParams: [] as sortParam[],
search: defaultSearch,
minimum_should_match: 1,
track_total_hits: false,
activeName: 'standard',
});
const onAddParam = () => {
state.queryParams.push({
uuid: randomUuid(),
enable: true,
type: 'must',
field: '',
matchType: 'term',
value: '',
gtType: 'gt',
gtValue: '',
ltType: 'lt',
ltValue: '',
});
};
const onCopyParam = (item: searchParam) => {
let newItem = JSON.parse(JSON.stringify(item));
newItem.uuid = randomUuid();
state.queryParams.push(newItem);
};
const onDelParam = (uuid: string) => {
state.queryParams = state.queryParams.filter((item) => item.uuid !== uuid);
};
const onAddSort = () => {
state.sortParams.push({
uuid: randomUuid(),
enable: true,
field: '',
order: 'asc',
});
};
const onDelSort = (uuid: string) => {
state.sortParams = state.sortParams.filter((item) => item.uuid !== uuid);
};
const onClearParam = () => {
// 清空查询条件
state.queryParams = [];
state.sortParams = [];
};
const onSaveParam = () => {
// 保存查询条件
};
const onPreviewParam = () => {
parseParams();
MonacoEditorBox({
content: JSON.stringify(state.search, null, 2),
title: t('es.searchParamsPreview'),
language: 'json',
width: state.searchBoxWidth,
canChangeLang: false,
options: { wordWrap: 'on', tabSize: 2, readOnly: true }, // 自动换行
});
};
const parseParams = () => {
// 组装查询条件并emit search事件
let must = [] as any;
let should = [] as any;
let must_not = [] as any;
let sort = {} as any;
for (let item of state.queryParams) {
if (!item.enable || !item.field || (!item.value.trim() && !item.gtValue.trim() && !item.ltValue.trim())) {
continue;
}
// wildcard 自动添加通配符
if (item.matchType === 'wildcard' && !item.value.includes('*') && !item.value.includes('?')) {
item.value = `*${item.value}*`;
}
let value = item.value;
if (item.matchType === 'terms') {
value = item.value.split(',').map((item: string) => item.trim());
}
let match = {
[item.matchType]: {
[item.field]: value,
},
} as any;
// 处理range
if (item.matchType == 'range') {
let gtType = item.gtType;
let ltType = item.ltType;
let gtValue = item.gtValue.trim();
let ltValue = item.ltValue.trim();
if (!gtValue && !ltValue) {
continue;
}
let range = {} as any;
if (gtValue) {
range[gtType] = gtValue;
}
if (ltValue) {
range[ltType] = ltValue;
}
match = {
range: {
[item.field]: range,
},
};
}
switch (item.type) {
case 'must':
must.push(match);
break;
case 'should':
should.push(match);
break;
case 'must_not':
must_not.push(match);
break;
}
}
state.search.query = { bool: { must, should, must_not } };
// 排序
state.sortParams.forEach((item) => {
if (item.enable && item.field) {
sort[item.field] = { order: item.order };
}
});
state.search.sort = sort;
// track_total_hits
if (state.track_total_hits) {
state.search['track_total_hits'] = true;
} else {
delete state.search['track_total_hits'];
}
// minimum_should_match 需要结合should使用默认为1表示至少一个should条件满足
if (should.length > 0) {
state.search['minimum_should_match'] = Math.max(1, state.minimum_should_match);
} else {
delete state.search['minimum_should_match'];
}
};
const onSearch = () => {
parseParams();
emit('search', state.search);
};
</script>
<style lang="scss" scoped>
.es-search-form-inline {
* {
border-radius: 3px;
}
.el-card {
margin-bottom: 10px;
.el-card__header {
padding: 10px;
}
}
.el-input {
--el-input-width: 150px;
}
.es-range-input {
--el-input-width: 240px;
}
.el-select {
--el-select-width: 100px;
font-size: var(--font-size);
}
.es-range-select {
--el-select-width: 70px;
}
.field-select {
--el-select-width: 150px;
}
.el-form {
margin-bottom: 10px;
}
.el-form-item {
margin-right: 5px;
margin-bottom: 5px;
}
.el-input-number {
width: 80px;
}
}
</style>

View File

@@ -6,66 +6,51 @@
</template>
<el-form :model="form" ref="redisForm" :rules="rules" label-width="auto">
<el-tabs v-model="tabActiveName">
<el-tab-pane :label="$t('common.basic')" name="basic">
<el-form-item ref="tagSelectRef" prop="tagCodePaths" :label="$t('tag.relateTag')" required>
<tag-tree-select
@change-tag="
(tagCodePaths) => {
form.tagCodePaths = tagCodePaths;
tagSelectRef.validate();
}
"
multiple
:select-tags="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="mode" label="mode" required>
<el-select v-model="form.mode">
<el-option label="standalone" value="standalone"> </el-option>
<el-option label="cluster" value="cluster"> </el-option>
<el-option label="sentinel" value="sentinel"> </el-option>
</el-select>
</el-form-item>
<el-form-item prop="host" label="host" required>
<el-input v-model.trim="form.host" :placeholder="$t('redis.hostTips')" auto-complete="off" type="textarea"></el-input>
</el-form-item>
<el-form-item prop="username" :label="$t('common.username')">
<el-input v-model.trim="form.username"></el-input>
</el-form-item>
<el-form-item prop="password" :label="$t('common.password')">
<el-input type="password" show-password v-model.trim="form.password" autocomplete="new-password"> </el-input>
</el-form-item>
<el-form-item v-if="form.mode == 'sentinel'" prop="redisNodePassword" :label="$t('redis.nodePassword')">
<el-input type="password" show-password v-model.trim="form.redisNodePassword" autocomplete="new-password"> </el-input>
</el-form-item>
<el-form-item prop="db" label="DB" required>
<el-select
@change="changeDb"
:disabled="form.mode == 'cluster'"
v-model="dbList"
multiple
allow-create
filterable
style="width: 100%"
>
<el-option v-for="db in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]" :key="db" :label="db" :value="db" />
</el-select>
</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-tab-pane>
<el-tab-pane :label="$t('common.other')" name="other">
<el-form-item prop="sshTunnelMachineId" :label="$t('machine.sshTunnel')">
<ssh-tunnel-select v-model="form.sshTunnelMachineId" />
</el-form-item>
</el-tab-pane>
</el-tabs>
<el-form-item ref="tagSelectRef" prop="tagCodePaths" :label="$t('tag.relateTag')" required>
<tag-tree-select
@change-tag="
(tagCodePaths) => {
form.tagCodePaths = tagCodePaths;
tagSelectRef.validate();
}
"
multiple
:select-tags="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="mode" label="mode" required>
<el-select v-model="form.mode">
<el-option label="standalone" value="standalone"> </el-option>
<el-option label="cluster" value="cluster"> </el-option>
<el-option label="sentinel" value="sentinel"> </el-option>
</el-select>
</el-form-item>
<el-form-item prop="host" label="host" required>
<el-input v-model.trim="form.host" :placeholder="$t('redis.hostTips')" auto-complete="off" type="textarea"></el-input>
</el-form-item>
<el-form-item prop="username" :label="$t('common.username')">
<el-input v-model.trim="form.username"></el-input>
</el-form-item>
<el-form-item prop="password" :label="$t('common.password')">
<el-input type="password" show-password v-model.trim="form.password" autocomplete="new-password"> </el-input>
</el-form-item>
<el-form-item v-if="form.mode == 'sentinel'" prop="redisNodePassword" :label="$t('redis.nodePassword')">
<el-input type="password" show-password v-model.trim="form.redisNodePassword" autocomplete="new-password"> </el-input>
</el-form-item>
<el-form-item prop="db" label="DB" required>
<el-select @change="changeDb" :disabled="form.mode == 'cluster'" v-model="dbList" multiple allow-create filterable style="width: 100%">
<el-option v-for="db in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]" :key="db" :label="db" :value="db" />
</el-select>
</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-item prop="sshTunnelMachineId" :label="$t('machine.sshTunnel')">
<ssh-tunnel-select v-model="form.sshTunnelMachineId" />
</el-form-item>
</el-form>
<template #footer>

View File

@@ -1,28 +0,0 @@
import { h, render } from 'vue';
import CmdExecDialog from './CmdExecDialog.vue';
export type CmdExecProps = {
id: number;
db: number | string;
cmd: any[];
flowProcdef?: any;
visible?: boolean;
runSuccessFn?: Function;
cancelFn?: Function;
};
const showCmdExecBox = (props: CmdExecProps): void => {
const propsCancelFn = props.cancelFn;
props.cancelFn = () => {
propsCancelFn && propsCancelFn();
setTimeout(() => {
// 销毁组件
render(null, document.body);
}, 500);
};
const vnode = h(CmdExecDialog, { ...props, visible: true });
render(vnode, document.body);
};
export default showCmdExecBox;

View File

@@ -1,95 +0,0 @@
<template>
<div>
<el-dialog title="待执行cmd" v-model="dialogVisible" :show-close="false" width="600px" @close="cancel">
<el-input type="textarea" disabled v-model="state.cmdStr" class="mt-1" :rows="5" />
<el-input @keyup.enter="runCmd" ref="remarkInputRef" v-model="remark" placeholder="请输入执行备注" class="mt-1" />
<div v-if="props.flowProcdef">
<el-divider content-position="left">审批节点</el-divider>
<procdef-tasks :procdef="props.flowProcdef" />
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="cancel"> </el-button>
<el-button @click="runCmd" type="primary" :loading="btnLoading"> </el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { toRefs, ref, reactive, onMounted } from 'vue';
import { ElDialog, ElButton, ElInput, ElMessage, InputInstance, ElDivider } from 'element-plus';
import ProcdefTasks from '@/views/flow/components/ProcdefTasks.vue';
import { redisApi } from '../api';
import { CmdExecProps } from './CmdExecBox';
const props = withDefaults(defineProps<CmdExecProps>(), {});
const remarkInputRef = ref<InputInstance>();
const state = reactive({
dialogVisible: false,
flowProcdef: null as any,
cmdStr: '',
remark: '',
btnLoading: false,
});
const { dialogVisible, remark, btnLoading } = toRefs(state);
onMounted(() => {
show(props);
});
const show = (props: CmdExecProps) => {
const cmdArr = props.cmd.map((item: any, index: number) => {
if (index === 0) {
return item; // 第一个元素直接返回原值
}
if (typeof item === 'string') {
return `'${item}'`; // 字符串加单引号
}
return item; // 其他类型直接返回
});
state.cmdStr = cmdArr.join(' ');
state.dialogVisible = props.visible || true;
setTimeout(() => {
remarkInputRef.value?.focus();
}, 200);
};
/**
* 执行cmd
*/
const runCmd = async () => {
if (!state.remark) {
ElMessage.error('请输入执行备注信息');
return;
}
try {
state.btnLoading = true;
await redisApi.runCmd.request({
id: props.id,
db: props.db,
cmd: props.cmd,
remark: state.remark,
});
props.runSuccessFn && props.runSuccessFn();
ElMessage.success('工单提交成功');
} finally {
state.btnLoading = false;
cancel();
}
};
const cancel = () => {
state.dialogVisible = false;
props.cancelFn && props.cancelFn();
};
</script>
<style lang="scss"></style>

View File

@@ -109,6 +109,14 @@
<el-tab-pane
class="h-full"
:disabled="currentTag.type != TagResourceTypeEnum.Tag.value"
:label="`${$t('tag.es')} (${resourceCount.es || 0})`"
:name="EsTag"
>
<EsInstanceList lazy ref="esInstanceListRef" />
</el-tab-pane>
<el-tab-pane
:disabled="currentTag.type != TagResourceTypeEnum.Tag.value"
:label="`Redis (${resourceCount.redis || 0})`"
:name="RedisTag"
@@ -178,6 +186,7 @@ import { Rules } from '@/common/rule';
const MachineList = defineAsyncComponent(() => import('../machine/MachineList.vue'));
const InstanceList = defineAsyncComponent(() => import('../db/InstanceList.vue'));
const EsInstanceList = defineAsyncComponent(() => import('../es/InstanceList.vue'));
const RedisList = defineAsyncComponent(() => import('../redis/RedisList.vue'));
const MongoList = defineAsyncComponent(() => import('../mongo/MongoList.vue'));
@@ -196,12 +205,14 @@ const filterTag = ref('');
const contextmenuRef = ref();
const machineListRef: Ref<any> = ref(null);
const dbInstanceListRef: Ref<any> = ref(null);
const esInstanceListRef: Ref<any> = ref(null);
const redisListRef: Ref<any> = ref(null);
const mongoListRef: Ref<any> = ref(null);
const TagDetail = 'tagDetail';
const MachineTag = 'machineTag';
const DbTag = 'dbTag';
const EsTag = 'EsTag';
const RedisTag = 'redisTag';
const MongoTag = 'mongoTag';
@@ -380,6 +391,9 @@ const setNowTabData = () => {
case DbTag:
dbInstanceListRef.value.search(tagPath);
break;
case EsTag:
esInstanceListRef.value.search(tagPath);
break;
case RedisTag:
redisListRef.value.search(tagPath);
break;

3
server/.gitignore vendored
View File

@@ -9,3 +9,6 @@ mayfly_rsa.pub
/db/mysql/
# mariadb 程序目录
/db/mariadb/
*.sqlite
file

View File

@@ -8,6 +8,7 @@ import (
"mayfly-go/internal/db/application"
"mayfly-go/internal/db/application/dto"
"mayfly-go/internal/db/config"
"mayfly-go/internal/db/dbm"
"mayfly-go/internal/db/dbm/dbi"
"mayfly-go/internal/db/domain/entity"
"mayfly-go/internal/db/imsg"
@@ -142,6 +143,8 @@ func (d *Db) ExecSql(rc *req.Ctx) {
dbId := getDbId(rc)
dbConn, err := d.dbApp.GetDbConn(dbId, form.Db)
biz.ErrIsNil(err)
defer dbm.PutDbConn(dbConn)
biz.ErrIsNilAppendErr(d.tagApp.CanAccess(rc.GetLoginAccount().Id, dbConn.Info.CodePath...), "%s")
global.EventBus.Publish(rc.MetaCtx, event.EventTopicResourceOp, dbConn.Info.CodePath[0])
@@ -193,6 +196,7 @@ func (d *Db) ExecSqlFile(rc *req.Ctx) {
dbConn, err := d.dbApp.GetDbConn(dbId, dbName)
biz.ErrIsNil(err)
defer dbm.PutDbConn(dbConn)
biz.ErrIsNilAppendErr(d.tagApp.CanAccess(rc.GetLoginAccount().Id, dbConn.Info.CodePath...), "%s")
rc.ReqParam = fmt.Sprintf("filename: %s -> %s", filename, dbConn.Info.GetLogDesc())
@@ -226,6 +230,8 @@ func (d *Db) DumpSql(rc *req.Ctx) {
la := rc.GetLoginAccount()
dbConn, err := d.dbApp.GetDbConn(dbId, dbName)
biz.ErrIsNil(err)
defer dbm.PutDbConn(dbConn)
biz.ErrIsNilAppendErr(d.tagApp.CanAccess(la.Id, dbConn.Info.CodePath...), "%s")
now := time.Now()
@@ -354,6 +360,7 @@ func (d *Db) CopyTable(rc *req.Ctx) {
conn, err := d.dbApp.GetDbConn(form.Id, form.Db)
biz.ErrIsNilAppendErr(err, "copy table error: %s")
defer dbm.PutDbConn(conn)
err = conn.GetDialect().CopyTable(copy)
if err != nil {
@@ -377,5 +384,6 @@ func getDbName(rc *req.Ctx) string {
func (d *Db) getDbConn(rc *req.Ctx) *dbi.DbConn {
dc, err := d.dbApp.GetDbConn(getDbId(rc), getDbName(rc))
biz.ErrIsNil(err)
defer dbm.PutDbConn(dc)
return dc
}

View File

@@ -6,6 +6,7 @@ import (
"mayfly-go/internal/db/api/vo"
"mayfly-go/internal/db/application"
"mayfly-go/internal/db/application/dto"
"mayfly-go/internal/db/dbm"
"mayfly-go/internal/db/domain/entity"
"mayfly-go/internal/db/imsg"
fileapp "mayfly-go/internal/file/application"
@@ -151,6 +152,8 @@ func (d *DbTransferTask) FileRun(rc *req.Ctx) {
targetDbConn, err := d.dbApp.GetDbConn(fm.TargetDbId, fm.TargetDbName)
biz.ErrIsNilAppendErr(err, "failed to connect to the target database: %s")
defer dbm.PutDbConn(targetDbConn)
biz.ErrIsNilAppendErr(d.tagApp.CanAccess(rc.GetLoginAccount().Id, targetDbConn.Info.CodePath...), "%s")
filename, reader, err := d.fileApp.GetReader(context.TODO(), tFile.FileKey)

View File

@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"mayfly-go/internal/db/dbm"
"mayfly-go/internal/db/dbm/dbi"
"mayfly-go/internal/db/domain/entity"
"mayfly-go/internal/db/domain/repository"
@@ -150,6 +151,7 @@ func (app *dataSyncAppImpl) RunCronJob(ctx context.Context, id uint64) error {
return
}
srcConn, err := app.dbApp.GetDbConn(uint64(task.SrcDbId), task.SrcDbName)
defer dbm.PutDbConn(srcConn)
if err != nil {
logx.ErrorfContext(ctx, "failed to connect to the source database: %s", err.Error())
return
@@ -204,12 +206,15 @@ func (app *dataSyncAppImpl) doDataSync(ctx context.Context, sql string, task *en
// 获取源数据库连接
srcConn, err := app.dbApp.GetDbConn(uint64(task.SrcDbId), task.SrcDbName)
defer dbm.PutDbConn(srcConn)
if err != nil {
return syncLog, errorx.NewBiz("failed to connect to the source database: %s", err.Error())
}
// 获取目标数据库连接
targetConn, err := app.dbApp.GetDbConn(uint64(task.TargetDbId), task.TargetDbName)
defer dbm.PutDbConn(targetConn)
if err != nil {
return syncLog, errorx.NewBiz("failed to connect to the target database: %s", err.Error())
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"mayfly-go/internal/db/application/dto"
"mayfly-go/internal/db/config"
"mayfly-go/internal/db/dbm"
"mayfly-go/internal/db/dbm/dbi"
"mayfly-go/internal/db/dbm/sqlparser"
"mayfly-go/internal/db/dbm/sqlparser/sqlstmt"
@@ -283,6 +284,7 @@ func (d *dbSqlExecAppImpl) FlowBizHandle(ctx context.Context, bizHandleParam *fl
}
dbConn, err := d.dbApp.GetDbConn(execSqlBizForm.DbId, execSqlBizForm.DbName)
defer dbm.PutDbConn(dbConn)
if err != nil {
return nil, err
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"mayfly-go/internal/db/application/dto"
"mayfly-go/internal/db/dbm"
"mayfly-go/internal/db/dbm/dbi"
"mayfly-go/internal/db/dbm/sqlparser"
"mayfly-go/internal/db/domain/entity"
@@ -209,6 +210,7 @@ func (app *dbTransferAppImpl) Run(ctx context.Context, taskId uint64, logId uint
// 获取源库连接、目标库连接判断连接可用性否则记录日志xx连接不可用
// 获取源库表信息
srcConn, err := app.dbApp.GetDbConn(uint64(task.SrcDbId), task.SrcDbName)
defer dbm.PutDbConn(srcConn)
if err != nil {
app.EndTransfer(ctx, logId, taskId, "failed to obtain source db connection", err, nil)
return
@@ -247,6 +249,7 @@ func (app *dbTransferAppImpl) Run(ctx context.Context, taskId uint64, logId uint
func (app *dbTransferAppImpl) transfer2Db(ctx context.Context, taskId uint64, logId uint64, task *entity.DbTransferTask, srcConn *dbi.DbConn, start time.Time, tables []dbi.Table) {
// 获取目标库表信息
targetConn, err := app.dbApp.GetDbConn(uint64(task.TargetDbId), task.TargetDbName)
defer dbm.PutDbConn(targetConn)
if err != nil {
app.EndTransfer(ctx, logId, taskId, "failed to get target db connection", err, nil)
return

View File

@@ -3,9 +3,9 @@ package dbi
import (
"context"
"database/sql"
"errors"
"fmt"
"mayfly-go/internal/machine/mcm"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/logx"
)
@@ -173,14 +173,18 @@ func (d *DbConn) Close() {
if err := d.db.Close(); err != nil {
logx.Errorf("关闭数据库实例[%s]连接失败: %s", d.Id, err.Error())
}
// 如果是使用了自己实现的ssh隧道转发则需要手动将其关闭
if d.Info.useSshTunnel {
mcm.CloseSshTunnelMachine(d.Info.SshTunnelMachineId, fmt.Sprintf("db:%d", d.Info.Id))
}
// TODO 关闭实例隧道会影响其他正在使用的连接,所以暂时不关闭
//if d.Info.useSshTunnel {
// mcm.CloseSshTunnelMachine(d.Info.SshTunnelMachineId, fmt.Sprintf("db:%d", d.Info.Id))
//}
d.db = nil
}
}
func (d *DbConn) Ping() error {
return d.db.Ping()
}
// 游标方式遍历查询rows, walkFn error不为nil, 则跳出遍历
func (d *DbConn) walkQueryRows(ctx context.Context, selectSql string, walkFn WalkQueryRowsFunc, args ...any) ([]*QueryColumn, error) {
cancelCtx, cancelFunc := context.WithCancel(ctx)
@@ -238,10 +242,10 @@ func (d *DbConn) walkQueryRows(ctx context.Context, selectSql string, walkFn Wal
// 包装sql执行相关错误
func wrapSqlError(err error) error {
if err == context.Canceled {
if errors.Is(err, context.Canceled) {
return errorx.NewBiz("execution cancel")
}
if err == context.DeadlineExceeded {
if errors.Is(err, context.DeadlineExceeded) {
return errorx.NewBiz("execution timeout")
}
return err

View File

@@ -9,69 +9,83 @@ import (
_ "mayfly-go/internal/db/dbm/oracle"
_ "mayfly-go/internal/db/dbm/postgres"
_ "mayfly-go/internal/db/dbm/sqlite"
"mayfly-go/internal/machine/mcm"
"mayfly-go/internal/pkg/consts"
"mayfly-go/pkg/cache"
"mayfly-go/pkg/logx"
"sync"
"mayfly-go/pkg/pool"
"time"
)
// 客户端连接缓存,指定时间内没有访问则会被关闭, key为数据库连接id
var connCache = cache.NewTimedCache(consts.DbConnExpireTime, 5*time.Second).
WithUpdateAccessTime(true).
OnEvicted(func(key any, value any) {
logx.Info(fmt.Sprintf("delete db conn cache, id = %s", key))
value.(*dbi.DbConn).Close()
})
var connPool = make(map[string]pool.Pool)
var instPool = make(map[uint64]pool.Pool)
func init() {
mcm.AddCheckSshTunnelMachineUseFunc(func(machineId int) bool {
// 遍历所有db连接实例若存在db实例使用该ssh隧道机器则返回true表示还在使用中...
items := connCache.Items()
for _, v := range items {
if v.Value.(*dbi.DbConn).Info.SshTunnelMachineId == machineId {
return true
}
}
return false
})
}
var mutex sync.Mutex
// PutDbConn 释放连接
func PutDbConn(c *dbi.DbConn) {
if nil == c {
return
}
connId := dbi.GetDbConnId(c.Info.Id, c.Info.Database)
if p, ok := connPool[connId]; ok {
p.Put(c)
}
}
// 从缓存中获取数据库连接信息若缓存中不存在则会使用回调函数获取dbInfo进行连接并缓存
func GetDbConn(dbId uint64, database string, getDbInfo func() (*dbi.DbInfo, error)) (*dbi.DbConn, error) {
func getPool(dbId uint64, database string, getDbInfo func() (*dbi.DbInfo, error)) (pool.Pool, error) {
connId := dbi.GetDbConnId(dbId, database)
// connId不为空则为需要缓存
needCache := connId != ""
if needCache {
load, ok := connCache.Get(connId)
if ok {
return load.(*dbi.DbConn), nil
// 获取连接池,如果没有,则创建一个
if p, ok := connPool[connId]; !ok {
var err error
p, err = pool.NewChannelPool(&pool.Config{
InitialCap: 1, //资源池初始连接数
MaxCap: 10, //最大空闲连接数
MaxIdle: 10, //最大并发连接数
IdleTimeout: 10 * time.Minute, // 连接最大空闲时间,过期则失效
Factory: func() (interface{}, error) {
// 若缓存中不存在则从回调函数中获取DbInfo
dbInfo, err := getDbInfo()
if err != nil {
return nil, err
}
// 连接数据库
return Conn(dbInfo)
},
Close: func(v interface{}) error {
v.(*dbi.DbConn).Close()
return nil
},
Ping: func(v interface{}) error {
return v.(*dbi.DbConn).Ping()
},
})
if err != nil {
return nil, err
}
connPool[connId] = p
instPool[dbId] = p
return p, nil
} else {
return p, nil
}
}
mutex.Lock()
defer mutex.Unlock()
// GetDbConn 从连接池中获取连接信息,记的用完连接后必须调用 PutDbConn 还回池
func GetDbConn(dbId uint64, database string, getDbInfo func() (*dbi.DbInfo, error)) (*dbi.DbConn, error) {
// 若缓存中不存在,则从回调函数中获取DbInfo
dbInfo, err := getDbInfo()
p, err := getPool(dbId, database, getDbInfo)
if err != nil {
return nil, err
}
// 连接数据库
dbConn, err := Conn(dbInfo)
// 从连接池中获取一个可用的连接
c, err := p.Get()
if err != nil {
return nil, err
}
ec := c.(*dbi.DbConn)
return ec, nil
if needCache {
connCache.Put(connId, dbConn)
}
return dbConn, nil
}
// 使用指定dbInfo信息进行连接
@@ -81,16 +95,19 @@ func Conn(di *dbi.DbInfo) (*dbi.DbConn, error) {
// 根据实例id获取连接
func GetDbConnByInstanceId(instanceId uint64) *dbi.DbConn {
for _, connItem := range connCache.Items() {
conn := connItem.Value.(*dbi.DbConn)
if conn.Info.InstanceId == instanceId {
return conn
if p, ok := instPool[instanceId]; ok {
c, err := p.Get()
if err != nil {
logx.Error(fmt.Sprintf("实例id[%d]连接获取失败:%s", instanceId, err))
return nil
}
return c.(*dbi.DbConn)
}
return nil
}
// 删除db缓存并关闭该数据库所有连接
func CloseDb(dbId uint64, db string) {
connCache.Delete(dbi.GetDbConnId(dbId, db))
delete(connPool, dbi.GetDbConnId(dbId, db))
delete(instPool, dbId)
}

View File

@@ -0,0 +1,7 @@
package api
import "mayfly-go/pkg/ioc"
func InitIoc() {
ioc.Register(new(Instance))
}

View File

@@ -0,0 +1,170 @@
package api
import (
"github.com/may-fly/cast"
"mayfly-go/internal/es/api/form"
"mayfly-go/internal/es/api/vo"
"mayfly-go/internal/es/application"
"mayfly-go/internal/es/application/dto"
"mayfly-go/internal/es/domain/entity"
"mayfly-go/internal/es/esm/esi"
"mayfly-go/internal/es/imsg"
"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"
"net/http"
"net/url"
"strings"
)
type Instance struct {
inst application.Instance `inject:"T"`
tagApp tagapp.TagTree `inject:"T"`
resourceAuthCertApp tagapp.ResourceAuthCert `inject:"T"`
}
func (d *Instance) ReqConfs() *req.Confs {
reqs := [...]*req.Conf{
// /es/instance 获取实例列表
req.NewGet("", d.Instances),
// /es/instance/test-conn 测试连接
req.NewPost("/test-conn", d.TestConn),
// /es/instance 添加实例
req.NewPost("", d.SaveInstance).Log(req.NewLogSaveI(imsg.LogEsInstSave)),
// /es/instance/:id 删除实例
req.NewDelete(":instanceId", d.DeleteInstance).Log(req.NewLogSaveI(imsg.LogEsInstDelete)),
// /es/instance/proxy 反向代理es接口请求
req.NewAny("/proxy/:instanceId/*path", d.Proxy),
}
return req.NewConfs("/es/instance", reqs[:]...)
}
func (d *Instance) Instances(rc *req.Ctx) {
queryCond := req.BindQuery(rc, new(entity.InstanceQuery))
// 只查询实例,兼容没有录入密码的实例
instTags := d.tagApp.GetAccountTags(rc.GetLoginAccount().Id, &tagentity.TagTreeQuery{
TypePaths: collx.AsArray(tagentity.NewTypePaths(tagentity.TagTypeEsInstance)),
CodePathLikes: collx.AsArray(queryCond.TagPath),
})
// 不存在可操作的数据库,即没有可操作数据
if len(instTags) == 0 {
rc.ResData = model.NewEmptyPageResult[any]()
return
}
dbInstCodes := tagentity.GetCodesByCodePaths(tagentity.TagTypeEsInstance, instTags.GetCodePaths()...)
queryCond.Codes = dbInstCodes
res, err := d.inst.GetPageList(queryCond)
biz.ErrIsNil(err)
resVo := model.PageResultConv[*entity.EsInstance, *vo.InstanceListVO](res)
instvos := resVo.List
// 只查询标签
certTags := d.tagApp.GetAccountTags(rc.GetLoginAccount().Id, &tagentity.TagTreeQuery{
TypePaths: collx.AsArray(tagentity.NewTypePaths(tagentity.TagTypeEsInstance, tagentity.TagTypeAuthCert)),
CodePathLikes: collx.AsArray(queryCond.TagPath),
})
// 填充授权凭证信息
d.resourceAuthCertApp.FillAuthCertByAcNames(tagentity.GetCodesByCodePaths(tagentity.TagTypeAuthCert, certTags.GetCodePaths()...), collx.ArrayMap(instvos, func(vos *vo.InstanceListVO) tagentity.IAuthCert {
return vos
})...)
// 填充标签信息
d.tagApp.FillTagInfo(tagentity.TagType(consts.ResourceTypeEsInstance), collx.ArrayMap(instvos, func(insvo *vo.InstanceListVO) tagentity.ITagResource {
return insvo
})...)
rc.ResData = res
}
func (d *Instance) TestConn(rc *req.Ctx) {
fm := &form.InstanceForm{}
instance := req.BindJsonAndCopyTo[*entity.EsInstance](rc, fm, new(entity.EsInstance))
var ac *tagentity.ResourceAuthCert
if len(fm.AuthCerts) > 0 {
ac = fm.AuthCerts[0]
}
res, err := d.inst.TestConn(instance, ac)
biz.ErrIsNil(err)
rc.ResData = res
}
func (d *Instance) SaveInstance(rc *req.Ctx) {
fm := &form.InstanceForm{}
instance := req.BindJsonAndCopyTo[*entity.EsInstance](rc, fm, new(entity.EsInstance))
rc.ReqParam = fm
id, err := d.inst.SaveInst(rc.MetaCtx, &dto.SaveEsInstance{
EsInstance: instance,
AuthCerts: fm.AuthCerts,
TagCodePaths: fm.TagCodePaths,
})
biz.ErrIsNil(err)
rc.ResData = id
}
func (d *Instance) DeleteInstance(rc *req.Ctx) {
idsStr := rc.PathParam("instanceId")
rc.ReqParam = idsStr
ids := strings.Split(idsStr, ",")
for _, v := range ids {
biz.ErrIsNilAppendErr(d.inst.Delete(rc.MetaCtx, cast.ToUint64(v)), "delete db instance failed: %s")
}
}
func (d *Instance) Proxy(rc *req.Ctx) {
path := rc.PathParam("path")
instanceId := getInstanceId(rc)
// 去掉request中的 id 和 path参数否则es会报错
r := rc.GetRequest()
_ = RemoveQueryParam(r, "id", "path")
err := d.inst.DoConn(instanceId, func(conn *esi.EsConn) error {
conn.Proxy(rc.GetWriter(), r, path)
return nil
})
biz.ErrIsNil(err)
}
func RemoveQueryParam(req *http.Request, paramNames ...string) error {
parsedURL, err := url.ParseRequestURI(req.RequestURI)
if err != nil {
return err
}
// Get the query parameters
queryParams, err := url.ParseQuery(parsedURL.RawQuery)
if err != nil {
return err
}
// Remove the specified query parameter
for i := range paramNames {
delete(queryParams, paramNames[i])
}
// Reconstruct the query string
parsedURL.RawQuery = queryParams.Encode()
// Update the request URL
req.URL = parsedURL
req.RequestURI = parsedURL.String()
return nil
}
func getInstanceId(rc *req.Ctx) uint64 {
instanceId := rc.PathParamInt("instanceId")
biz.IsTrue(instanceId > 0, "instanceId error")
return uint64(instanceId)
}

View File

@@ -0,0 +1,18 @@
package form
import (
tagentity "mayfly-go/internal/tag/domain/entity"
)
type InstanceForm struct {
Id uint64 `json:"id"`
Name string `binding:"required" json:"name"`
Host string `binding:"required" json:"host"`
Port int `binding:"required" json:"port"`
Version string `json:"version"`
Remark string `json:"remark"`
SshTunnelMachineId int `json:"sshTunnelMachineId"`
AuthCerts []*tagentity.ResourceAuthCert `json:"authCerts"` // 资产授权凭证信息列表
TagCodePaths []string `binding:"required" json:"tagCodePaths"`
}

View File

@@ -0,0 +1,31 @@
package vo
import (
tagentity "mayfly-go/internal/tag/domain/entity"
"time"
)
type InstanceListVO struct {
tagentity.AuthCerts // 授权凭证信息
tagentity.ResourceTags
Id *int64 `json:"id"`
Code string `json:"code"`
Name *string `json:"name"`
Host *string `json:"host"`
Port *int `json:"port"`
Version *string `json:"version"`
CreateTime *time.Time `json:"createTime"`
Creator *string `json:"creator"`
CreatorId *int64 `json:"creatorId"`
UpdateTime *time.Time `json:"updateTime"`
Modifier *string `json:"modifier"`
ModifierId *int64 `json:"modifierId"`
SshTunnelMachineId int `json:"sshTunnelMachineId"`
}
func (i *InstanceListVO) GetCode() string {
return i.Code
}

View File

@@ -0,0 +1,15 @@
package application
import (
"mayfly-go/pkg/ioc"
"sync"
)
func InitIoc() {
ioc.Register(new(instanceAppImpl), ioc.WithComponentName("EsInstanceApp"))
}
func Init() {
sync.OnceFunc(func() {
})()
}

View File

@@ -0,0 +1,12 @@
package dto
import (
"mayfly-go/internal/es/domain/entity"
tagentity "mayfly-go/internal/tag/domain/entity"
)
type SaveEsInstance struct {
EsInstance *entity.EsInstance
AuthCerts []*tagentity.ResourceAuthCert
TagCodePaths []string
}

View File

@@ -0,0 +1,284 @@
package application
import (
"context"
"fmt"
"mayfly-go/internal/es/application/dto"
"mayfly-go/internal/es/domain/entity"
"mayfly-go/internal/es/domain/repository"
"mayfly-go/internal/es/esm/esi"
"mayfly-go/internal/es/imsg"
"mayfly-go/internal/pkg/consts"
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/pool"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"mayfly-go/pkg/utils/structx"
"time"
)
type Instance interface {
base.App[*entity.EsInstance]
// GetPageList 分页获取数据库实例
GetPageList(condition *entity.InstanceQuery, orderBy ...string) (*model.PageResult[*entity.EsInstance], error)
// DoConn 获取连接并执行函数
DoConn(instanceId uint64, fn func(*esi.EsConn) error) error
TestConn(instance *entity.EsInstance, ac *tagentity.ResourceAuthCert) (map[string]any, error)
SaveInst(ctx context.Context, d *dto.SaveEsInstance) (uint64, error)
Delete(ctx context.Context, instanceId uint64) error
}
var _ Instance = &instanceAppImpl{}
var connPool = make(map[uint64]pool.Pool)
type instanceAppImpl struct {
base.AppImpl[*entity.EsInstance, repository.EsInstance]
tagApp tagapp.TagTree `inject:"T"`
resourceAuthCertApp tagapp.ResourceAuthCert `inject:"T"`
}
// GetPageList 分页获取数据库实例
func (app *instanceAppImpl) GetPageList(condition *entity.InstanceQuery, orderBy ...string) (*model.PageResult[*entity.EsInstance], error) {
return app.GetRepo().GetInstanceList(condition, orderBy...)
}
func (app *instanceAppImpl) DoConn(instanceId uint64, fn func(*esi.EsConn) error) error {
// 通过实例id获取实例连接信息
p, err := app.getPool(instanceId)
if err != nil {
return err
}
// 从连接池中获取一个可用的连接
c, err := p.Get()
if err != nil {
return err
}
ec := c.(*esi.EsConn)
// 用完后放回连接池
defer p.Put(c)
return fn(ec)
}
func (app *instanceAppImpl) getPool(instanceId uint64) (pool.Pool, error) {
// 获取连接池,如果没有,则创建一个
if p, ok := connPool[instanceId]; !ok {
var err error
p, err = pool.NewChannelPool(&pool.Config{
InitialCap: 1, //资源池初始连接数
MaxCap: 10, //最大空闲连接数
MaxIdle: 10, //最大并发连接数
IdleTimeout: 10 * time.Minute, // 连接最大空闲时间,过期则失效
Factory: func() (interface{}, error) {
return app.createConn(instanceId)
},
Close: func(v interface{}) error {
return v.(*esi.EsConn).Close()
},
Ping: func(v interface{}) error {
return v.(*esi.EsConn).Ping()
},
})
if err != nil {
return nil, err
}
connPool[instanceId] = p
return p, nil
} else {
return p, nil
}
}
func (app *instanceAppImpl) createConn(instanceId uint64) (*esi.EsConn, error) {
// 缓存不存在,则重新连接
instance, err := app.GetById(instanceId)
if err != nil {
return nil, errorx.NewBiz("es instance not found")
}
ei, err := app.ToEsInfo(instance, nil)
if err != nil {
return nil, err
}
ei.CodePath = app.tagApp.ListTagPathByTypeAndCode(int8(tagentity.TagTypeEsInstance), instance.Code)
conn, _, err := ei.Conn()
if err != nil {
return nil, err
}
// 缓存连接信息
return conn, nil
}
func (app *instanceAppImpl) ToEsInfo(instance *entity.EsInstance, ac *tagentity.ResourceAuthCert) (*esi.EsInfo, error) {
ei := new(esi.EsInfo)
ei.InstanceId = instance.Id
structx.Copy(ei, instance)
ei.OriginUrl = fmt.Sprintf("http://%s:%d", instance.Host, instance.Port)
if ac != nil {
if ac.Ciphertext == "" && ac.Name != "" {
ac1, err := app.resourceAuthCertApp.GetAuthCert(ac.Name)
if err == nil {
ac = ac1
}
}
} else {
if instance.Code != "" {
ac2, err := app.resourceAuthCertApp.GetResourceAuthCert(tagentity.TagTypeEsInstance, instance.Code)
if err == nil {
ac = ac2
}
}
}
if ac != nil && ac.Ciphertext != "" {
ei.Username = ac.Username
ei.Password = ac.Ciphertext
}
return ei, nil
}
func (app *instanceAppImpl) TestConn(instance *entity.EsInstance, ac *tagentity.ResourceAuthCert) (map[string]any, error) {
instance.Network = instance.GetNetwork()
ei, err := app.ToEsInfo(instance, ac)
if err != nil {
return nil, err
}
_, res, err := ei.Conn()
if err != nil {
return nil, err
}
return res, nil
}
func (app *instanceAppImpl) SaveInst(ctx context.Context, instance *dto.SaveEsInstance) (uint64, error) {
instanceEntity := instance.EsInstance
// 默认tcp连接
instanceEntity.Network = instanceEntity.GetNetwork()
resourceType := consts.ResourceTypeEsInstance
authCerts := instance.AuthCerts
tagCodePaths := instance.TagCodePaths
// 查找是否存在该库
oldInstance := &entity.EsInstance{
Host: instanceEntity.Host,
Port: instanceEntity.Port,
SshTunnelMachineId: instanceEntity.SshTunnelMachineId,
}
err := app.GetByCond(oldInstance)
if instanceEntity.Id == 0 {
if err == nil {
return 0, errorx.NewBizI(ctx, imsg.ErrEsInstExist)
}
instanceEntity.Code = stringx.Rand(10)
return instanceEntity.Id, app.Tx(ctx, func(ctx context.Context) error {
return app.Insert(ctx, instanceEntity)
}, func(ctx context.Context) error {
return app.resourceAuthCertApp.RelateAuthCert(ctx, &tagdto.RelateAuthCert{
ResourceCode: instanceEntity.Code,
ResourceType: tagentity.TagType(resourceType),
AuthCerts: authCerts,
})
}, func(ctx context.Context) error {
return app.tagApp.SaveResourceTag(ctx, &tagdto.SaveResourceTag{
ResourceTag: app.genEsInstanceResourceTag(instanceEntity, authCerts),
ParentTagCodePaths: tagCodePaths,
})
})
}
// 如果存在该库,则校验修改的库是否为该库
if err == nil {
if oldInstance.Id != instanceEntity.Id {
return 0, errorx.NewBizI(ctx, imsg.ErrEsInstExist)
}
} else {
// 根据host等未查到旧数据则需要根据id重新获取因为后续需要使用到code
oldInstance, err = app.GetById(instanceEntity.Id)
if err != nil {
return 0, errorx.NewBiz("db instance not found")
}
}
return oldInstance.Id, app.Tx(ctx, func(ctx context.Context) error {
return app.UpdateById(ctx, instanceEntity)
}, func(ctx context.Context) error {
return app.resourceAuthCertApp.RelateAuthCert(ctx, &tagdto.RelateAuthCert{
ResourceCode: oldInstance.Code,
ResourceType: tagentity.TagType(resourceType),
AuthCerts: authCerts,
})
}, func(ctx context.Context) error {
if instanceEntity.Name != oldInstance.Name {
if err := app.tagApp.UpdateTagName(ctx, tagentity.TagTypeDbInstance, oldInstance.Code, instanceEntity.Name); err != nil {
return err
}
}
return app.tagApp.SaveResourceTag(ctx, &tagdto.SaveResourceTag{
ResourceTag: app.genEsInstanceResourceTag(oldInstance, authCerts),
ParentTagCodePaths: tagCodePaths,
})
})
}
func (app *instanceAppImpl) genEsInstanceResourceTag(ei *entity.EsInstance, authCerts []*tagentity.ResourceAuthCert) *tagdto.ResourceTag {
// 授权证书对应的tag
authCertTags := collx.ArrayMap[*tagentity.ResourceAuthCert, *tagdto.ResourceTag](authCerts, func(val *tagentity.ResourceAuthCert) *tagdto.ResourceTag {
return &tagdto.ResourceTag{
Code: val.Name,
Name: val.Username,
Type: tagentity.TagTypeAuthCert,
}
})
// es实例
return &tagdto.ResourceTag{
Code: ei.Code,
Name: ei.Name,
Type: tagentity.TagTypeEsInstance,
Children: authCertTags,
}
}
func (app *instanceAppImpl) Delete(ctx context.Context, instanceId uint64) error {
instance, err := app.GetById(instanceId)
if err != nil {
return errorx.NewBiz("db instnace not found")
}
return app.Tx(ctx, func(ctx context.Context) error {
// 删除该实例
return app.DeleteById(ctx, instanceId)
}, func(ctx context.Context) error {
// 删除该实例关联的授权凭证信息
return app.resourceAuthCertApp.RelateAuthCert(ctx, &tagdto.RelateAuthCert{
ResourceCode: instance.Code,
ResourceType: tagentity.TagType(consts.ResourceTypeEsInstance),
})
}, func(ctx context.Context) error {
// 删除该实例关联的tag信息
return app.tagApp.DeleteTagByParam(ctx, &tagdto.DelResourceTag{
ResourceCode: instance.Code,
ResourceType: tagentity.TagType(consts.ResourceTypeEsInstance),
})
})
}

View File

@@ -0,0 +1,36 @@
package entity
import (
"fmt"
"mayfly-go/pkg/model"
)
type EsInstance struct {
model.Model
Code string `json:"code" gorm:"size:32;not null;"`
Name string `json:"name" gorm:"size:32;not null;"`
Host string `json:"host" gorm:"size:255;not null;"`
Port int `json:"port"`
Network string `json:"network" gorm:"size:20;"`
Version string `json:"version" gorm:"size:50;"`
AuthCertName string `json:"authCertName" gorm:"size:255;"`
SshTunnelMachineId int `json:"sshTunnelMachineId"` // ssh隧道机器id
}
func (d *EsInstance) TableName() string {
return "t_es_instance"
}
// 获取es连接网络, 若没有使用ssh隧道则直接返回。否则返回拼接的网络需要注册至指定dial
func (d *EsInstance) GetNetwork() string {
network := d.Network
if d.SshTunnelMachineId <= 0 {
if network == "" {
return "tcp"
} else {
return network
}
}
return fmt.Sprintf("es+ssh:%d", d.SshTunnelMachineId)
}

View File

@@ -0,0 +1,16 @@
package entity
import "mayfly-go/pkg/model"
// InstanceQuery 数据库实例查询
type InstanceQuery struct {
model.PageParam
Id uint64 `json:"id" form:"id"`
Name string `json:"name" form:"name"`
Code string `json:"code" form:"code"`
Host string `json:"host" form:"host"`
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/es/domain/entity"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
)
type EsInstance interface {
base.Repo[*entity.EsInstance]
// 分页获取数据库实例信息列表
GetInstanceList(condition *entity.InstanceQuery, orderBy ...string) (*model.PageResult[*entity.EsInstance], error)
}

View File

@@ -0,0 +1,29 @@
package esi
import (
"net/http/httputil"
"sync"
)
type BufferPool struct {
pool *sync.Pool
}
// 需要实现 httputil.BufferPool
var _ httputil.BufferPool = (*BufferPool)(nil)
func NewBufferPool() *BufferPool {
return &BufferPool{&sync.Pool{
New: func() interface{} {
return make([]byte, 32*1024)
},
}}
}
func (b *BufferPool) Get() []byte {
return b.pool.Get().([]byte)
}
func (b *BufferPool) Put(buf []byte) {
b.pool.Put(buf)
}

View File

@@ -0,0 +1,55 @@
package esi
import (
"fmt"
"mayfly-go/internal/machine/mcm"
"mayfly-go/pkg/logx"
"net/http"
"net/http/httputil"
"net/url"
)
type EsConn struct {
Id uint64
Info *EsInfo
proxy *httputil.ReverseProxy
}
// StartProxy 开始代理
func (d *EsConn) StartProxy() error {
// 目标 URL
targetURL, err := url.Parse(d.Info.baseUrl)
if err != nil {
logx.Errorf("Error parsing URL: %v", err)
return err
}
// 创建反向代理
d.proxy = httputil.NewSingleHostReverseProxy(targetURL)
// 设置 proxy buffer pool
d.proxy.BufferPool = NewBufferPool()
return nil
}
func (d *EsConn) Proxy(w http.ResponseWriter, r *http.Request, path string) {
r.URL.Path = path
if d.Info.authorization != "" {
r.Header.Set("Authorization", d.Info.authorization)
}
r.Header.Set("connection", "keep-alive")
r.Header.Set("Accept", "application/json")
d.proxy.ServeHTTP(w, r)
}
func (d *EsConn) Close() error {
// 如果是使用了ssh隧道转发则需要手动将其关闭
if d.Info.useSshTunnel {
mcm.CloseSshTunnelMachine(uint64(d.Info.SshTunnelMachineId), fmt.Sprintf("es:%d", d.Id))
}
return nil
}
func (d *EsConn) Ping() error {
_, err := d.Info.Ping()
return err
}

View File

@@ -0,0 +1,142 @@
package esi
import (
"encoding/base64"
"fmt"
machineapp "mayfly-go/internal/machine/application"
"mayfly-go/internal/machine/mcm"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/httpx"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils/structx"
"net/http"
"strings"
)
type EsVersion string
type EsInfo struct {
model.ExtraData // 连接需要的其他额外参数json字符串如oracle数据库需要指定sid等
InstanceId uint64 // 实例id
Name string
Host string
Port int
Network string
Username string
Password string
Version EsVersion // 数据库版本信息,用于语法兼容
DefaultVersion bool // 经过查询数据库版本信息后,是否仍然使用默认版本
CodePath []string
SshTunnelMachineId int
useSshTunnel bool // 是否使用系统自己实现的ssh隧道连接,而非库自带的
OriginUrl string // 原始url
baseUrl string // 发起http请求的基本url
authorization string // 发起http请求携带的认证信息
}
// 获取记录日志的描述
func (di *EsInfo) GetLogDesc() string {
return fmt.Sprintf("ES[id=%d, tag=%s, name=%s, ip=%s:%d]", di.InstanceId, di.CodePath, di.Name, di.Host, di.Port)
}
// 连接数据库
func (di *EsInfo) Conn() (*EsConn, map[string]any, error) {
// 使用basic加密用户名和密码
if di.Username != "" && di.Password != "" {
encodeString := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", di.Username, di.Password)))
di.authorization = fmt.Sprintf("Basic %s", encodeString)
}
// 使用ssh隧道
err := di.IfUseSshTunnelChangeIpPort()
if err != nil {
logx.Errorf("es ssh failed: %s, err:%s", di.baseUrl, err.Error())
return nil, nil, errorx.NewBiz("es ssh failed: %s", err.Error())
}
// 尝试获取es版本信息调用接口get /
res, err := di.Ping()
if err != nil {
logx.Errorf("es ping failed: %s, err:%s", di.baseUrl, err.Error())
return nil, nil, errorx.NewBiz("es ping failed: %s", err.Error())
}
esc := &EsConn{Id: di.InstanceId, Info: di}
err = esc.StartProxy()
if err != nil {
logx.Errorf("es porxy failed: %s, err:%s", di.baseUrl, err.Error())
return nil, nil, err
}
if di.OriginUrl != di.baseUrl {
logx.Infof("es porxy success: %s => %s", di.baseUrl, di.OriginUrl)
} else {
logx.Infof("es porxy success: %s", di.baseUrl)
}
return esc, res, nil
}
func (di *EsInfo) Ping() (map[string]any, error) {
return di.ExecApi("get", "", nil)
}
// ExecApi 执行api
func (di *EsInfo) ExecApi(method, path string, data any, timeoutSecond ...int) (map[string]any, error) {
request := httpx.NewReq(di.baseUrl + path)
if di.authorization != "" {
request.Header("Authorization", di.authorization)
}
if len(timeoutSecond) > 0 { // 设置超时时间
request.Timeout(timeoutSecond[0])
}
switch strings.ToUpper(method) {
case http.MethodGet:
if data != nil {
return request.GetByQuery(structx.ToMap(data)).BodyToMap()
}
return request.Get().BodyToMap()
case http.MethodPost:
return request.PostObj(data).BodyToMap()
case http.MethodPut:
return request.PutObj(data).BodyToMap()
}
return nil, errorx.NewBiz("不支持的请求方法: %s", method)
}
// 如果使用了ssh隧道将其host port改变其本地映射host port
func (di *EsInfo) IfUseSshTunnelChangeIpPort() error {
// 开启ssh隧道
if di.SshTunnelMachineId > 0 {
stm, err := GetSshTunnel(di.SshTunnelMachineId)
if err != nil {
return err
}
exposedIp, exposedPort, err := stm.OpenSshTunnel(fmt.Sprintf("es:%d", di.InstanceId), di.Host, di.Port)
if err != nil {
return err
}
di.Host = exposedIp
di.Port = exposedPort
di.useSshTunnel = true
di.baseUrl = fmt.Sprintf("http://%s:%d", exposedIp, exposedPort)
} else {
di.baseUrl = fmt.Sprintf("http://%s:%d", di.Host, di.Port)
}
return nil
}
// 根据ssh tunnel机器id返回ssh tunnel
func GetSshTunnel(sshTunnelMachineId int) (*mcm.SshTunnelMachine, error) {
return machineapp.GetMachineApp().GetSshTunnelMachine(sshTunnelMachineId)
}

View File

@@ -0,0 +1,9 @@
package imsg
import "mayfly-go/pkg/i18n"
var En = map[i18n.MsgId]string{
LogEsInstSave: "Es - Save Instance",
LogEsInstDelete: "Es - Delete Instance",
ErrEsInstExist: "The es instance already exists",
}

View File

@@ -0,0 +1,19 @@
package imsg
import (
"mayfly-go/internal/pkg/consts"
"mayfly-go/pkg/i18n"
)
func init() {
i18n.AppendLangMsg(i18n.Zh_CN, Zh_CN)
i18n.AppendLangMsg(i18n.En, En)
}
const (
// es inst
LogEsInstDelete = iota + consts.ImsgNumEs
LogEsInstSave
ErrEsInstExist
)

View File

@@ -0,0 +1,9 @@
package imsg
import "mayfly-go/pkg/i18n"
var Zh_CN = map[i18n.MsgId]string{
LogEsInstSave: "ES-保存实例",
LogEsInstDelete: "ES-删除实例",
ErrEsInstExist: "ES实例已存在",
}

View File

@@ -0,0 +1,34 @@
package persistence
import (
"mayfly-go/internal/es/domain/entity"
"mayfly-go/internal/es/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
)
type instanceRepoImpl struct {
base.RepoImpl[*entity.EsInstance]
}
func NewInstanceRepo() repository.EsInstance {
return &instanceRepoImpl{}
}
// 分页获取数据库信息列表
func (d *instanceRepoImpl) GetInstanceList(condition *entity.InstanceQuery, orderBy ...string) (*model.PageResult[*entity.EsInstance], error) {
qd := model.NewCond().
Eq("id", condition.Id).
Eq("host", condition.Host).
Like("name", condition.Name).
Like("code", condition.Code).
In("code", condition.Codes)
keyword := condition.Keyword
if keyword != "" {
keyword = "%" + keyword + "%"
qd.And("host like ? or name like ? or code like ?", keyword, keyword, keyword)
}
return d.PageByCond(qd, condition.PageParam)
}

View File

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

View File

@@ -0,0 +1,18 @@
package init
import (
"mayfly-go/initialize"
"mayfly-go/internal/es/api"
"mayfly-go/internal/es/application"
"mayfly-go/internal/es/infrastructure/persistence"
)
func init() {
initialize.AddInitIocFunc(func() {
persistence.InitIoc()
application.InitIoc()
api.InitIoc()
})
initialize.AddInitFunc(application.Init)
}

View File

@@ -0,0 +1,41 @@
# es 模块开发步骤
## 1、模块设计
### es实例
- 支持录入es实例所属标签、ip、端口、账号、密码、ssh跳板机、
### es操作
- 参照db操作右侧标签树实例列表实例下子菜单
- 索引管理:支持右键菜单:刷新、添加索引、显示系统索引(以.开头的索引名)
- 索引设置:过滤索引名^\..*
- 索引列表:展开索引名列表,以索引名排序,支持右键菜单:复制名字、添加别名、索引迁移、关闭/打开索引、删除索引
- 索引详情:
- 索引增删改查
- 索引迁移:
- 如果 Mapping 中字段已经定义就不能修改其字段的类型等属性了,同时也不能改变分片的数量, 可以使用 Reindex API 来解决这个问题。
- 支持迁移到其他实例的指定索引,默认选中当前实例
- 数据浏览:
- 跳转到:基础搜索、高级搜索
- 基础搜索:
- 保存es查询条件指定查询名关联实例id、索引名
- 可视化组装查询条件
- 加载保存的查询条件列表、删除、修改、应用
- 高级搜索自己拼接查询json返回并展示查询结果json
- 仪表盘:一些指标数据:基本信息、节点信息、插件信息、集群状态、集群健康值
- 设置:一些公共设置
## 开发路线
1、后端封装所需接口
参考 src/components/es/api/ClusterApi.ts
- 实例管理接口设计:/es/instance/:实例id/:index/具体接口
- 实例代理接口设计:/es/instance/proxy/:实例id/:官方api接口
2、前端参考es-client相关页面逻辑
参照: https://gitee.com/liuzongyang/es-client

View File

@@ -137,6 +137,8 @@ func (m *Machine) SimpleMachieInfo(rc *req.Ctx) {
func (m *Machine) MachineStats(rc *req.Ctx) {
cli, err := m.machineApp.GetCli(GetMachineId(rc))
biz.ErrIsNilAppendErr(err, "connection error: %s")
defer mcm.PutMachineCli(cli)
rc.ResData = cli.GetAllStats()
}
@@ -198,6 +200,8 @@ func (m *Machine) GetProcess(rc *req.Ctx) {
cli, err := m.machineApp.GetCli(GetMachineId(rc))
biz.ErrIsNilAppendErr(err, "connection error: %s")
defer mcm.PutMachineCli(cli)
biz.ErrIsNilAppendErr(m.tagTreeApp.CanAccess(rc.GetLoginAccount().Id, cli.Info.CodePath...), "%s")
res, err := cli.Run(cmd)
@@ -212,6 +216,8 @@ func (m *Machine) KillProcess(rc *req.Ctx) {
cli, err := m.machineApp.GetCli(GetMachineId(rc))
biz.ErrIsNilAppendErr(err, "connection error: %s")
defer mcm.PutMachineCli(cli)
biz.ErrIsNilAppendErr(m.tagTreeApp.CanAccess(rc.GetLoginAccount().Id, cli.Info.CodePath...), "%s")
res, err := cli.Run("sudo kill -9 " + pid)
@@ -221,6 +227,8 @@ func (m *Machine) KillProcess(rc *req.Ctx) {
func (m *Machine) GetUsers(rc *req.Ctx) {
cli, err := m.machineApp.GetCli(GetMachineId(rc))
biz.ErrIsNilAppendErr(err, "connection error: %s")
defer mcm.PutMachineCli(cli)
res, err := cli.GetUsers()
biz.ErrIsNil(err)
rc.ResData = res
@@ -229,6 +237,8 @@ func (m *Machine) GetUsers(rc *req.Ctx) {
func (m *Machine) GetGroups(rc *req.Ctx) {
cli, err := m.machineApp.GetCli(GetMachineId(rc))
biz.ErrIsNilAppendErr(err, "connection error: %s")
defer mcm.PutMachineCli(cli)
res, err := cli.GetGroups()
biz.ErrIsNil(err)
rc.ResData = res
@@ -252,9 +262,12 @@ func (m *Machine) WsSSH(rc *req.Ctx) {
err = req.PermissionHandler(rc)
biz.ErrIsNil(err, mcm.GetErrorContentRn("You do not have permission to operate the machine terminal, please log in again and try again ~"))
cli, err := m.machineApp.NewCli(GetMachineAc(rc))
cli, err := m.machineApp.GetCliByAc(GetMachineAc(rc))
biz.ErrIsNilAppendErr(err, mcm.GetErrorContentRn("connection error: %s"))
defer cli.Close()
defer func() {
cli.Close()
mcm.PutMachineCli(cli)
}()
biz.ErrIsNilAppendErr(m.tagTreeApp.CanAccess(rc.GetLoginAccount().Id, cli.Info.CodePath...), mcm.GetErrorContentRn("%s"))
global.EventBus.Publish(rc.MetaCtx, event.EventTopicResourceOp, cli.Info.CodePath[0])

View File

@@ -328,6 +328,8 @@ func (m *MachineFile) UploadFolder(rc *req.Ctx) {
folderName := filepath.Dir(paths[0])
mcli, err := m.machineFileApp.GetMachineCli(authCertName)
biz.ErrIsNil(err)
defer mcm.PutMachineCli(mcli)
mi := mcli.Info
sftpCli, err := mcli.GetSftpCli()

View File

@@ -5,6 +5,7 @@ import (
"mayfly-go/internal/machine/api/vo"
"mayfly-go/internal/machine/application"
"mayfly-go/internal/machine/domain/entity"
"mayfly-go/internal/machine/mcm"
tagapp "mayfly-go/internal/tag/application"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/model"
@@ -79,6 +80,8 @@ func (m *MachineScript) RunMachineScript(rc *req.Ctx) {
}
cli, err := m.machineApp.GetCliByAc(ac)
biz.ErrIsNilAppendErr(err, "connection error: %s")
defer mcm.PutMachineCli(cli)
biz.ErrIsNilAppendErr(m.tagApp.CanAccess(rc.GetLoginAccount().Id, cli.Info.CodePath...), "%s")
res, err := cli.Run(script)

View File

@@ -239,11 +239,6 @@ func (m *machineAppImpl) GetCliByAc(authCertName string) (*mcm.Cli, error) {
}
func (m *machineAppImpl) GetCli(machineId uint64) (*mcm.Cli, error) {
cli, err := mcm.GetMachineCliById(machineId)
if err == nil {
return cli, nil
}
_, authCert, err := m.getMachineAndAuthCert(machineId)
if err != nil {
return nil, err

View File

@@ -5,6 +5,7 @@ import (
"mayfly-go/internal/machine/application/dto"
"mayfly-go/internal/machine/domain/entity"
"mayfly-go/internal/machine/domain/repository"
"mayfly-go/internal/machine/mcm"
tagapp "mayfly-go/internal/tag/application"
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/base"
@@ -178,12 +179,14 @@ func (m *machineCronJobAppImpl) runCronJob0(mid uint64, cronJob *entity.MachineC
ExecTime: time.Now(),
}
machineCli, err := m.machineApp.GetCli(uint64(mid))
machineCli, err := m.machineApp.GetCli(mid)
res := ""
if err != nil {
machine, _ := m.machineApp.GetById(mid)
execRes.MachineCode = machine.Code
} else {
defer mcm.PutMachineCli(machineCli)
execRes.MachineCode = machineCli.Info.Code
res, err = machineCli.Run(cronJob.Script)
if err != nil {

View File

@@ -170,6 +170,8 @@ func (m *machineFileAppImpl) GetDirSize(ctx context.Context, opParam *dto.Machin
if err != nil {
return "", err
}
defer mcm.PutMachineCli(mcli)
res, err := mcli.Run(fmt.Sprintf("du -sh %s", path))
if err != nil {
// 若存在目录为空,则可能会返回如下内容。最后一行即为真正目录内容所占磁盘空间大小
@@ -202,6 +204,8 @@ func (m *machineFileAppImpl) FileStat(ctx context.Context, opParam *dto.MachineF
if err != nil {
return "", err
}
defer mcm.PutMachineCli(mcli)
return mcli.Run(fmt.Sprintf("stat -L %s", path))
}
@@ -379,6 +383,8 @@ func (m *machineFileAppImpl) RemoveFile(ctx context.Context, opParam *dto.Machin
if err != nil {
return nil, err
}
defer mcm.PutMachineCli(mcli)
minfo := mcli.Info
// 优先使用命令删除速度快sftp需要递归遍历删除子文件等
@@ -429,6 +435,7 @@ func (m *machineFileAppImpl) Copy(ctx context.Context, opParam *dto.MachineFileO
if err != nil {
return nil, err
}
defer mcm.PutMachineCli(mcli)
mi := mcli.Info
res, err := mcli.Run(fmt.Sprintf("cp -r %s %s", strings.Join(path, " "), toPath))
@@ -458,6 +465,7 @@ func (m *machineFileAppImpl) Mv(ctx context.Context, opParam *dto.MachineFileOp,
if err != nil {
return nil, err
}
defer mcm.PutMachineCli(mcli)
mi := mcli.Info
res, err := mcli.Run(fmt.Sprintf("mv %s %s", strings.Join(path, " "), toPath))
@@ -493,6 +501,7 @@ func (m *machineFileAppImpl) GetMachineSftpCli(opParam *dto.MachineFileOp) (*mcm
if err != nil {
return nil, nil, err
}
defer mcm.PutMachineCli(mcli)
sftpCli, err := mcli.GetSftpCli()
if err != nil {

View File

@@ -18,6 +18,11 @@ type Cli struct {
sftpClient *sftp.Client // sftp客户端
}
func (c *Cli) Ping() error {
_, _, err := c.sshClient.Conn.SendRequest("ping", true, nil)
return err
}
// GetSftpCli 获取sftp client
func (c *Cli) GetSftpCli() (*sftp.Client, error) {
if c.sshClient == nil {
@@ -89,7 +94,7 @@ func (c *Cli) Close() {
}
if sshTunnelMachineId != 0 {
logx.Debugf("close machine ssh tunnel -> machineId=%d, sshTunnelMachineId=%d", m.Id, sshTunnelMachineId)
CloseSshTunnelMachine(int(sshTunnelMachineId), m.GetTunnelId())
CloseSshTunnelMachine(sshTunnelMachineId, m.GetTunnelId())
}
}

View File

@@ -1,122 +1,77 @@
package mcm
import (
"errors"
"mayfly-go/internal/pkg/consts"
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/cache"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/pool"
"time"
)
// 机器客户端连接缓存,指定时间内没有访问则会被关闭
var cliCache = cache.NewTimedCache(consts.MachineConnExpireTime, 5*time.Second).
WithUpdateAccessTime(true).
OnEvicted(func(_, value any) {
value.(*Cli).Close()
})
var mcConnPool = make(map[string]pool.Pool)
var mcIdPool = make(map[uint64]pool.Pool)
func init() {
AddCheckSshTunnelMachineUseFunc(func(machineId int) bool {
// 遍历所有机器连接实例若存在机器连接实例使用该ssh隧道机器则返回true表示还在使用中...
items := cliCache.Items()
for _, v := range items {
sshTunnelMachine := v.Value.(*Cli).Info.SshTunnelMachine
if sshTunnelMachine != nil && int(sshTunnelMachine.Id) == machineId {
return true
}
}
func getMcPool(authCertName string, getMachine func(string) (*MachineInfo, error)) (pool.Pool, error) {
// 获取连接池,如果没有,则创建一个
if p, ok := mcConnPool[authCertName]; !ok {
var err error
p, err = pool.NewChannelPool(&pool.Config{
InitialCap: 1, //资源池初始连接数
MaxCap: 10, //最大空闲连接数
MaxIdle: 10, //最大并发连接数
IdleTimeout: 10 * time.Minute, // 连接最大空闲时间,过期则失效
Factory: func() (interface{}, error) {
mi, err := getMachine(authCertName)
if err != nil {
return nil, err
}
mi.Key = authCertName
return mi.Conn()
},
Close: func(v interface{}) error {
v.(*Cli).Close()
return nil
},
Ping: func(v interface{}) error {
return v.(*Cli).Ping()
},
})
if err != nil {
return nil, err
}
return false
})
go checkClientAvailability(3 * time.Minute)
mcConnPool[authCertName] = p
return p, nil
} else {
return p, nil
}
}
func PutMachineCli(c *Cli) {
if nil == c {
return
}
if p, ok := mcConnPool[c.Info.AuthCertName]; ok {
p.Put(c)
}
}
// 从缓存中获取客户端信息,不存在则回调获取机器信息函数,并新建。
// @param 机器的授权凭证名
func GetMachineCli(authCertName string, getMachine func(string) (*MachineInfo, error)) (*Cli, error) {
if load, ok := cliCache.Get(authCertName); ok {
return load.(*Cli), nil
}
mi, err := getMachine(authCertName)
p, err := getMcPool(authCertName, getMachine)
if err != nil {
return nil, err
}
mi.Key = authCertName
c, err := mi.Conn()
// 从连接池中获取一个可用的连接
c, err := p.Get()
if err != nil {
return nil, err
}
cliCache.Put(authCertName, c)
return c, nil
}
// 根据机器id从已连接的机器客户端中获取特权账号连接, 若不存在特权账号,则随机返回一个
func GetMachineCliById(machineId uint64) (*Cli, error) {
// 遍历所有机器连接实例删除指定机器id关联的连接...
items := cliCache.Items()
var machineCli *Cli
for _, v := range items {
cli := v.Value.(*Cli)
mi := cli.Info
if mi.Id != machineId {
continue
}
machineCli = cli
// 如果是特权账号,则跳出
if mi.AuthCertType == tagentity.AuthCertTypePrivileged {
break
}
}
if machineCli != nil {
return machineCli, nil
}
return nil, errors.New("no connection exists for this machine id")
return c.(*Cli), nil
}
// 删除指定机器缓存客户端,并关闭客户端连接
func DeleteCli(id uint64) {
// 遍历所有机器连接实例删除指定机器id关联的连接...
items := cliCache.Items()
for _, v := range items {
mi := v.Value.(*Cli).Info
if mi.Id == id {
cliCache.Delete(mi.Key)
}
}
}
// 检查缓存中的客户端是否可用,不可用则关闭客户端连接
func checkClientAvailability(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
// 遍历所有机器连接实例若存在机器连接实例使用该ssh隧道机器则返回true表示还在使用中...
items := cliCache.Items()
for _, v := range items {
if v == nil {
continue
}
cli := v.Value.(*Cli)
if cli.Info == nil {
continue
}
if cli.sshClient == nil {
continue
}
if cli.sshClient.Conn == nil {
continue
}
if _, _, err := cli.sshClient.Conn.SendRequest("ping", true, nil); err != nil {
logx.Errorf("machine[%s] cache client is not available: %s", cli.Info.Name, err.Error())
DeleteCli(cli.Info.Id)
}
logx.Debugf("machine[%s] cache client is available", cli.Info.Name)
}
}
delete(mcIdPool, id)
}

View File

@@ -62,7 +62,7 @@ func (mi *MachineInfo) Conn() (*Cli, error) {
sshClient, err := GetSshClient(mi, nil)
if err != nil {
if mi.UseSshTunnel() {
CloseSshTunnelMachine(int(mi.TempSshMachineId), mi.GetTunnelId())
CloseSshTunnelMachine(mi.TempSshMachineId, mi.GetTunnelId())
}
return nil, err
}

View File

@@ -1,59 +1,31 @@
package mcm
import (
"errors"
"fmt"
"io"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/scheduler"
"mayfly-go/pkg/pool"
"mayfly-go/pkg/utils/netx"
"net"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
var (
sshTunnelMachines map[int]*SshTunnelMachine = make(map[int]*SshTunnelMachine)
mutex sync.Mutex
// 所有检测ssh隧道机器是否被使用的函数
checkSshTunnelMachineHasUseFuncs []CheckSshTunnelMachineHasUseFunc
// 是否开启检查ssh隧道机器是否被使用只有使用到了隧道机器才启用
startCheckSshTunnelHasUse bool = false
tunnelPool = make(map[int]pool.Pool)
)
// 检查ssh隧道机器是否有被使用
type CheckSshTunnelMachineHasUseFunc func(int) bool
func startCheckUse() {
logx.Info("start periodically checking if the ssh tunnel machine is still in use")
// 每十分钟检查一次隧道机器是否还有被使用
scheduler.AddFun("@every 10m", func() {
if !mutex.TryLock() {
return
}
defer mutex.Unlock()
// 遍历隧道机器,都未被使用将会被关闭
for mid, sshTunnelMachine := range sshTunnelMachines {
logx.Debugf("periodically check if the ssh tunnel machine [%d] is still in use...", mid)
hasUse := false
for _, checkUseFunc := range checkSshTunnelMachineHasUseFuncs {
// 如果一个在使用则返回不关闭,不继续后续检查
if checkUseFunc(mid) {
hasUse = true
break
}
}
if !hasUse {
// 都未被使用,则关闭
sshTunnelMachine.Close()
}
}
})
}
// 添加ssh隧道机器检测是否使用函数
func AddCheckSshTunnelMachineUseFunc(checkFunc CheckSshTunnelMachineHasUseFunc) {
if checkSshTunnelMachineHasUseFuncs == nil {
@@ -64,12 +36,18 @@ func AddCheckSshTunnelMachineUseFunc(checkFunc CheckSshTunnelMachineHasUseFunc)
// ssh隧道机器
type SshTunnelMachine struct {
mi *MachineInfo
machineId int // 隧道机器id
SshClient *ssh.Client
mutex sync.Mutex
tunnels map[string]*Tunnel // 隧道id -> 隧道
}
func (stm *SshTunnelMachine) Ping() error {
_, _, err := stm.SshClient.Conn.SendRequest("ping", true, nil)
return err
}
func (stm *SshTunnelMachine) OpenSshTunnel(id string, ip string, port int) (exposedIp string, exposedPort int, err error) {
stm.mutex.Lock()
defer stm.mutex.Unlock()
@@ -77,6 +55,7 @@ func (stm *SshTunnelMachine) OpenSshTunnel(id string, ip string, port int) (expo
tunnel := stm.tunnels[id]
// 已存在该id隧道则直接返回
if tunnel != nil {
// FIXME 后期改成池化连接定时60秒检查连接可用性
return tunnel.localHost, tunnel.localPort, nil
}
@@ -85,7 +64,7 @@ func (stm *SshTunnelMachine) OpenSshTunnel(id string, ip string, port int) (expo
return "", 0, err
}
localHost := "0.0.0.0"
localHost := "127.0.0.1"
localAddr := fmt.Sprintf("%s:%d", localHost, localPort)
listener, err := net.Listen("tcp", localAddr)
if err != nil {
@@ -104,13 +83,13 @@ func (stm *SshTunnelMachine) OpenSshTunnel(id string, ip string, port int) (expo
go tunnel.Open(stm.SshClient)
stm.tunnels[tunnel.id] = tunnel
return tunnel.localHost, tunnel.localPort, nil
return localHost, localPort, nil
}
func (st *SshTunnelMachine) GetDialConn(network string, addr string) (net.Conn, error) {
st.mutex.Lock()
defer st.mutex.Unlock()
return st.SshClient.Dial(network, addr)
func (stm *SshTunnelMachine) GetDialConn(network string, addr string) (net.Conn, error) {
stm.mutex.Lock()
defer stm.mutex.Unlock()
return stm.SshClient.Dial(network, addr)
}
func (stm *SshTunnelMachine) Close() {
@@ -131,55 +110,82 @@ func (stm *SshTunnelMachine) Close() {
logx.Errorf("error in closing ssh tunnel machine [%d]: %s", stm.machineId, err.Error())
}
}
delete(sshTunnelMachines, stm.machineId)
delete(tunnelPool, stm.machineId)
}
func getTunnelPool(machineId int, getMachine func(uint64) (*MachineInfo, error)) (pool.Pool, error) {
// 获取连接池,如果没有,则创建一个
if p, ok := tunnelPool[machineId]; !ok {
var err error
p, err = pool.NewChannelPool(&pool.Config{
InitialCap: 1, //资源池初始连接数
MaxCap: 10, //最大空闲连接数
MaxIdle: 10, //最大并发连接数
IdleTimeout: 10 * time.Minute, // 连接最大空闲时间,过期则失效
Factory: func() (interface{}, error) {
mi, err := getMachine(uint64(machineId))
if err != nil {
return nil, err
}
if mi == nil {
return nil, errors.New("error get machine info")
}
sshClient, err := GetSshClient(mi, nil)
if err != nil {
return nil, err
}
stm := &SshTunnelMachine{SshClient: sshClient, machineId: machineId, tunnels: map[string]*Tunnel{}, mi: mi}
logx.Infof("connect to the ssh tunnel machine for the first time[%d][%s:%d]", machineId, mi.Ip, mi.Port)
return stm, err
},
Close: func(v interface{}) error {
v.(*SshTunnelMachine).Close()
return nil
},
Ping: func(v interface{}) error {
return v.(*SshTunnelMachine).Ping()
},
})
if err != nil {
return nil, err
}
tunnelPool[machineId] = p
return p, nil
} else {
return p, nil
}
}
// 获取ssh隧道机器方便统一管理充当ssh隧道的机器避免创建多个ssh client
func GetSshTunnelMachine(machineId int, getMachine func(uint64) (*MachineInfo, error)) (*SshTunnelMachine, error) {
mutex.Lock()
defer mutex.Unlock()
sshTunnelMachine := sshTunnelMachines[machineId]
if sshTunnelMachine != nil {
return sshTunnelMachine, nil
p, err := getTunnelPool(machineId, getMachine)
if err != nil {
return nil, err
}
me, err := getMachine(uint64(machineId))
// 从连接池中获取一个可用的连接
c, err := p.Get()
if err != nil {
return nil, err
}
sshClient, err := GetSshClient(me, nil)
if err != nil {
return nil, err
}
sshTunnelMachine = &SshTunnelMachine{SshClient: sshClient, machineId: machineId, tunnels: map[string]*Tunnel{}}
logx.Infof("connect to the ssh tunnel machine for the first time[%d][%s:%d]", machineId, me.Ip, me.Port)
sshTunnelMachines[machineId] = sshTunnelMachine
// 如果实用了隧道机器且还没开始定时检查是否还被实用,则执行定时任务检测隧道是否还被使用
if !startCheckSshTunnelHasUse {
startCheckUse()
startCheckSshTunnelHasUse = true
}
return sshTunnelMachine, nil
return c.(*SshTunnelMachine), nil
}
// 关闭ssh隧道机器的指定隧道
func CloseSshTunnelMachine(machineId int, tunnelId string) {
sshTunnelMachine := sshTunnelMachines[machineId]
if sshTunnelMachine == nil {
return
}
sshTunnelMachine.mutex.Lock()
defer sshTunnelMachine.mutex.Unlock()
t := sshTunnelMachine.tunnels[tunnelId]
if t != nil {
t.Close()
delete(sshTunnelMachine.tunnels, tunnelId)
}
func CloseSshTunnelMachine(machineId uint64, tunnelId string) {
//sshTunnelMachine := mcIdPool[machineId]
//if sshTunnelMachine == nil {
// return
//}
//
//sshTunnelMachine.mutex.Lock()
//defer sshTunnelMachine.mutex.Unlock()
//t := sshTunnelMachine.tunnels[tunnelId]
//if t != nil {
// t.Close()
// delete(sshTunnelMachine.tunnels, tunnelId)
//}
}
type Tunnel struct {

View File

@@ -8,6 +8,7 @@ import (
"mayfly-go/internal/mongo/application"
"mayfly-go/internal/mongo/domain/entity"
"mayfly-go/internal/mongo/imsg"
"mayfly-go/internal/mongo/mgm"
"mayfly-go/internal/pkg/consts"
tagapp "mayfly-go/internal/tag/application"
tagentity "mayfly-go/internal/tag/domain/entity"
@@ -127,6 +128,8 @@ func (m *Mongo) DeleteMongo(rc *req.Ctx) {
func (m *Mongo) Databases(rc *req.Ctx) {
conn, err := m.mongoApp.GetMongoConn(m.GetMongoId(rc))
biz.ErrIsNil(err)
defer mgm.PutMongoConn(conn)
res, err := conn.Cli.ListDatabases(context.TODO(), bson.D{})
biz.ErrIsNilAppendErr(err, "get mongo dbs error: %s")
rc.ResData = res
@@ -135,6 +138,7 @@ func (m *Mongo) Databases(rc *req.Ctx) {
func (m *Mongo) Collections(rc *req.Ctx) {
conn, err := m.mongoApp.GetMongoConn(m.GetMongoId(rc))
biz.ErrIsNil(err)
defer mgm.PutMongoConn(conn)
global.EventBus.Publish(rc.MetaCtx, event.EventTopicResourceOp, conn.Info.CodePath[0])
@@ -152,6 +156,8 @@ func (m *Mongo) RunCommand(rc *req.Ctx) {
conn, err := m.mongoApp.GetMongoConn(m.GetMongoId(rc))
biz.ErrIsNil(err)
defer mgm.PutMongoConn(conn)
rc.ReqParam = collx.Kvs("mongo", conn.Info, "cmd", commandForm)
// 顺序执行
@@ -181,6 +187,8 @@ func (m *Mongo) FindCommand(rc *req.Ctx) {
conn, err := m.mongoApp.GetMongoConn(m.GetMongoId(rc))
biz.ErrIsNil(err)
defer mgm.PutMongoConn(conn)
cli := conn.Cli
limit := commandForm.Limit
@@ -215,6 +223,8 @@ func (m *Mongo) UpdateByIdCommand(rc *req.Ctx) {
conn, err := m.mongoApp.GetMongoConn(m.GetMongoId(rc))
biz.ErrIsNil(err)
defer mgm.PutMongoConn(conn)
rc.ReqParam = collx.Kvs("mongo", conn.Info, "cmd", commandForm)
// 解析docId文档id如果为string类型则使用ObjectId解析解析失败则为普通字符串
@@ -238,6 +248,8 @@ func (m *Mongo) DeleteByIdCommand(rc *req.Ctx) {
conn, err := m.mongoApp.GetMongoConn(m.GetMongoId(rc))
biz.ErrIsNil(err)
defer mgm.PutMongoConn(conn)
rc.ReqParam = collx.Kvs("mongo", conn.Info, "cmd", commandForm)
// 解析docId文档id如果为string类型则使用ObjectId解析解析失败则为普通字符串
@@ -260,6 +272,8 @@ func (m *Mongo) InsertOneCommand(rc *req.Ctx) {
conn, err := m.mongoApp.GetMongoConn(m.GetMongoId(rc))
biz.ErrIsNil(err)
defer mgm.PutMongoConn(conn)
rc.ReqParam = collx.Kvs("mongo", conn.Info, "cmd", commandForm)
res, err := conn.Cli.Database(commandForm.Database).Collection(commandForm.Collection).InsertOne(context.TODO(), commandForm.Doc)

View File

@@ -1,72 +1,80 @@
package mgm
import (
"mayfly-go/internal/machine/mcm"
"mayfly-go/internal/pkg/consts"
"mayfly-go/pkg/cache"
"mayfly-go/pkg/logx"
"sync"
"context"
"mayfly-go/pkg/pool"
"time"
)
// mongo客户端连接缓存指定时间内没有访问则会被关闭
var connCache = cache.NewTimedCache(consts.MongoConnExpireTime, 5*time.Second).
WithUpdateAccessTime(true).
OnEvicted(func(key any, value any) {
logx.Infof("删除mongo连接缓存: id = %v", key)
value.(*MongoConn).Close()
})
var connPool = make(map[string]pool.Pool)
func init() {
mcm.AddCheckSshTunnelMachineUseFunc(func(machineId int) bool {
// 遍历所有mongo连接实例若存在redis实例使用该ssh隧道机器则返回true表示还在使用中...
items := connCache.Items()
for _, v := range items {
if v.Value.(*MongoConn).Info.SshTunnelMachineId == machineId {
return true
}
}
return false
})
}
var mutex sync.Mutex
func getPool(mongoId uint64, getMongoInfo func() (*MongoInfo, error)) (pool.Pool, error) {
connId := getConnId(mongoId)
// 获取连接池,如果没有,则创建一个
if p, ok := connPool[connId]; !ok {
var err error
p, err = pool.NewChannelPool(&pool.Config{
InitialCap: 1, //资源池初始连接数
MaxCap: 10, //最大空闲连接数
MaxIdle: 10, //最大并发连接数
IdleTimeout: 10 * time.Minute, // 连接最大空闲时间,过期则失效
Factory: func() (interface{}, error) {
// 若缓存中不存在则从回调函数中获取MongoInfo
mi, err := getMongoInfo()
if err != nil {
return nil, err
}
// 连接mongo
return mi.Conn()
},
Close: func(v interface{}) error {
v.(*MongoConn).Close()
return nil
},
Ping: func(v interface{}) error {
return v.(*MongoConn).Cli.Ping(context.Background(), nil)
},
})
if err != nil {
return nil, err
}
connPool[connId] = p
return p, nil
} else {
return p, nil
}
}
func PutMongoConn(c *MongoConn) {
if nil == c {
return
}
if p, ok := connPool[getConnId(c.Info.Id)]; ok {
p.Put(c)
}
}
// 从缓存中获取mongo连接信息, 若缓存中不存在则会使用回调函数获取mongoInfo进行连接并缓存
func GetMongoConn(mongoId uint64, getMongoInfo func() (*MongoInfo, error)) (*MongoConn, error) {
connId := getConnId(mongoId)
// connId不为空则为需要缓存
needCache := connId != ""
if needCache {
load, ok := connCache.Get(connId)
if ok {
return load.(*MongoConn), nil
}
}
mutex.Lock()
defer mutex.Unlock()
// 若缓存中不存在则从回调函数中获取MongoInfo
mi, err := getMongoInfo()
p, err := getPool(mongoId, getMongoInfo)
if err != nil {
return nil, err
}
// 连接mongo
mc, err := mi.Conn()
// 从连接池中获取一个可用的连接
c, err := p.Get()
if err != nil {
return nil, err
}
if needCache {
connCache.Put(connId, mc)
}
return mc, nil
return c.(*MongoConn), nil
}
// 关闭连接,并移除缓存连接
func CloseConn(mongoId uint64) {
connCache.Delete(mongoId)
connId := getConnId(mongoId)
delete(connPool, connId)
}

View File

@@ -75,5 +75,5 @@ func getConnId(id uint64) string {
if id == 0 {
return ""
}
return fmt.Sprintf("%d", id)
return fmt.Sprintf("mongo:%d", id)
}

View File

@@ -9,6 +9,7 @@ const (
DbConnExpireTime = 120 * time.Minute
RedisConnExpireTime = 30 * time.Minute
MongoConnExpireTime = 30 * time.Minute
EsConnExpireTime = 30 * time.Minute
/**** 开发测试使用 ****/
// MachineConnExpireTime = 4 * time.Minute
@@ -20,6 +21,8 @@ const (
ResourceTypeDbInstance int8 = 2
ResourceTypeRedis int8 = 3
ResourceTypeMongo int8 = 4
ResourceTypeAuthCert int8 = 5
ResourceTypeEsInstance int8 = 6
// imsg起始编号
ImsgNumSys = 10000
@@ -31,4 +34,5 @@ const (
ImsgNumRedis = 70000
ImsgNumMongo = 80000
ImsgNumMsg = 90000
ImsgNumEs = 100000
)

View File

@@ -46,6 +46,11 @@ func initMysql(m config.Mysql) *gorm.DB {
sqlDB, _ := db.DB()
sqlDB.SetMaxIdleConns(m.MaxIdleConns)
sqlDB.SetMaxOpenConns(m.MaxOpenConns)
// 如果是开发环境时打印sql语句
if logx.GetConfig().IsDebug() {
db = db.Debug()
}
return db
}
}

View File

@@ -152,6 +152,7 @@ func (r *Redis) DeleteRedis(rc *req.Ctx) {
func (r *Redis) RedisInfo(rc *req.Ctx) {
ri, err := r.redisApp.GetRedisConn(uint64(rc.PathParamInt("id")), 0)
biz.ErrIsNil(err)
defer rdm.PutRedisConn(ri)
section := rc.Query("section")
mode := ri.Info.Mode
@@ -229,6 +230,8 @@ func (r *Redis) RedisInfo(rc *req.Ctx) {
func (r *Redis) ClusterInfo(rc *req.Ctx) {
ri, err := r.redisApp.GetRedisConn(uint64(rc.PathParamInt("id")), 0)
biz.ErrIsNil(err)
defer rdm.PutRedisConn(ri)
biz.IsEquals(ri.Info.Mode, rdm.ClusterMode, "non-cluster mode")
info, _ := ri.ClusterCli.ClusterInfo(context.Background()).Result()
nodesStr, _ := ri.ClusterCli.ClusterNodes(context.Background()).Result()
@@ -280,6 +283,8 @@ func (r *Redis) checkKeyAndGetRedisConn(rc *req.Ctx) (*rdm.RedisConn, string) {
func (r *Redis) getRedisConn(rc *req.Ctx) *rdm.RedisConn {
ri, err := r.redisApp.GetRedisConn(getIdAndDbNum(rc))
biz.ErrIsNil(err)
defer rdm.PutRedisConn(ri)
biz.ErrIsNilAppendErr(r.tagApp.CanAccess(rc.GetLoginAccount().Id, ri.Info.CodePath...), "%s")
return ri
}

View File

@@ -1,73 +1,81 @@
package rdm
import (
"fmt"
"mayfly-go/internal/machine/mcm"
"mayfly-go/internal/pkg/consts"
"mayfly-go/pkg/cache"
"mayfly-go/pkg/logx"
"sync"
"context"
"mayfly-go/pkg/pool"
"time"
)
// redis客户端连接缓存指定时间内没有访问则会被关闭
var connCache = cache.NewTimedCache(consts.RedisConnExpireTime, 5*time.Second).
WithUpdateAccessTime(true).
OnEvicted(func(key any, value any) {
logx.Info(fmt.Sprintf("remove the redis connection cache id = %s", key))
value.(*RedisConn).Close()
})
func init() {
mcm.AddCheckSshTunnelMachineUseFunc(func(machineId int) bool {
// 遍历所有redis连接实例若存在redis实例使用该ssh隧道机器则返回true表示还在使用中...
items := connCache.Items()
for _, v := range items {
if v.Value.(*RedisConn).Info.SshTunnelMachineId == machineId {
return true
}
}
return false
})
}
var mutex sync.Mutex
var connPool = make(map[string]pool.Pool)
func getPool(redisId uint64, db int, getRedisInfo func() (*RedisInfo, error)) (pool.Pool, error) {
connId := getConnId(redisId, db)
// 获取连接池,如果没有,则创建一个
if p, ok := connPool[connId]; !ok {
var err error
p, err = pool.NewChannelPool(&pool.Config{
InitialCap: 1, //资源池初始连接数
MaxCap: 10, //最大空闲连接数
MaxIdle: 10, //最大并发连接数
IdleTimeout: 10 * time.Minute, // 连接最大空闲时间,过期则失效
Factory: func() (interface{}, error) {
// 若缓存中不存在则从回调函数中获取RedisInfo
ri, err := getRedisInfo()
if err != nil {
return nil, err
}
// 连接数据库
return ri.Conn()
},
Close: func(v interface{}) error {
v.(*RedisConn).Close()
return nil
},
Ping: func(v interface{}) error {
_, err := v.(*RedisConn).Cli.Ping(context.Background()).Result()
return err
},
})
if err != nil {
return nil, err
}
connPool[connId] = p
return p, nil
} else {
return p, nil
}
}
func PutRedisConn(c *RedisConn) {
if nil == c {
return
}
if p, ok := connPool[getConnId(c.Info.Id, c.Info.Db)]; ok {
p.Put(c)
}
}
// 从缓存中获取redis连接信息, 若缓存中不存在则会使用回调函数获取redisInfo进行连接并缓存
func GetRedisConn(redisId uint64, db int, getRedisInfo func() (*RedisInfo, error)) (*RedisConn, error) {
connId := getConnId(redisId, db)
// connId不为空则为需要缓存
needCache := connId != ""
if needCache {
load, ok := connCache.Get(connId)
if ok {
return load.(*RedisConn), nil
}
}
mutex.Lock()
defer mutex.Unlock()
// 若缓存中不存在则从回调函数中获取RedisInfo
ri, err := getRedisInfo()
p, err := getPool(redisId, db, getRedisInfo)
if err != nil {
return nil, err
}
// 连接数据库
rc, err := ri.Conn()
// 连接池中获取一个可用的连接
c, err := p.Get()
if err != nil {
return nil, err
}
if needCache {
connCache.Put(connId, rc)
}
return rc, nil
// 用完后记的放回连接池
return c.(*RedisConn), nil
}
// 移除redis连接缓存并关闭redis连接
func CloseConn(id uint64, db int) {
connCache.Delete(getConnId(id, db))
delete(connPool, getConnId(id, db))
}

View File

@@ -168,9 +168,15 @@ func (p *TagTree) CountTagResource(rc *req.Ctx) {
CodePathLikes: collx.AsArray(tagPath),
}).GetCodePaths()...)
esCodes := entity.GetCodesByCodePaths(entity.TagTypeEsInstance, p.tagTreeApp.GetAccountTags(accountId, &entity.TagTreeQuery{
Types: collx.AsArray(entity.TagTypeEsInstance),
CodePathLikes: collx.AsArray(tagPath),
}).GetCodePaths()...)
rc.ResData = collx.M{
"machine": len(machineCodes),
"db": len(dbCodes),
"es": len(esCodes),
"redis": len(p.tagTreeApp.GetAccountTags(accountId, &entity.TagTreeQuery{
Types: collx.AsArray(entity.TagTypeRedis),
CodePathLikes: collx.AsArray(tagPath),

View File

@@ -32,9 +32,10 @@ const (
TagTypeTag TagType = -1
TagTypeMachine TagType = TagType(consts.ResourceTypeMachine)
TagTypeDbInstance TagType = TagType(consts.ResourceTypeDbInstance) // 数据库实例
TagTypeEsInstance TagType = TagType(consts.ResourceTypeEsInstance) // es实例
TagTypeRedis TagType = TagType(consts.ResourceTypeRedis)
TagTypeMongo TagType = TagType(consts.ResourceTypeMongo)
TagTypeAuthCert TagType = 5 // 授权凭证类型
TagTypeAuthCert TagType = TagType(consts.ResourceTypeAuthCert) // 授权凭证类型
TagTypeDb TagType = 22 // 数据库名
)

View File

@@ -4,6 +4,7 @@ import (
_ "mayfly-go/internal/auth/init"
_ "mayfly-go/internal/common/init"
_ "mayfly-go/internal/db/init"
_ "mayfly-go/internal/es/init"
_ "mayfly-go/internal/file/init"
_ "mayfly-go/internal/flow/init"
_ "mayfly-go/internal/machine/init"

View File

@@ -1,15 +1,19 @@
package migrations
import (
flowentity "mayfly-go/internal/flow/domain/entity"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
esentity "mayfly-go/internal/es/domain/entity"
flowentity "mayfly-go/internal/flow/domain/entity"
sysentity "mayfly-go/internal/sys/domain/entity"
"mayfly-go/pkg/model"
"time"
)
func V1_10() []*gormigrate.Migration {
var migrations []*gormigrate.Migration
migrations = append(migrations, V1_10_0()...)
migrations = append(migrations, V1_10_1()...)
return migrations
}
@@ -22,7 +26,7 @@ func V1_10_0() []*gormigrate.Migration {
&flowentity.Procinst{},
&flowentity.Execution{},
&flowentity.ProcinstTask{},
flowentity.ProcinstTaskCandidate{},
&flowentity.ProcinstTaskCandidate{},
&flowentity.HisProcinstOp{})
if err != nil {
return err
@@ -36,3 +40,108 @@ func V1_10_0() []*gormigrate.Migration {
},
}
}
func V1_10_1() []*gormigrate.Migration {
return []*gormigrate.Migration{
{
ID: "20250422-v1.10.1-es",
Migrate: func(tx *gorm.DB) error {
// 添加实例表
entities := [...]any{
new(esentity.EsInstance),
}
for _, e := range entities {
if err := tx.AutoMigrate(e); err != nil {
return err
}
}
// 添加菜单资源
resources := []*sysentity.Resource{
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1745292787}}}},
Pid: 0,
UiPath: "lbOU73qg/",
Name: "Elasticsearch",
Code: "/es",
Type: 1,
Meta: `{"icon":"icon es/es-color","isKeepAlive":true,"routeName":"ES"}`,
Weight: 7,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1745319348}}}},
Pid: 1745292787,
UiPath: "lbOU73qg/gZ2MHF0b/",
Name: "es.instance",
Code: "EsInstance ",
Type: 1,
Meta: `{"component":"ops/es/InstanceList","icon":"icon es/es-color","isKeepAlive":true,"routeName":"EsInstanceList"}`,
Weight: 1745319348,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1745319410}}}},
Pid: 1745319348,
UiPath: "lbOU73qg/gZ2MHF0b/rcKBdxB5/",
Name: "es.instanceSave",
Code: "es:instance:save",
Type: 2,
Weight: 1745319410,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1745319424}}}},
Pid: 1745319348,
UiPath: "lbOU73qg/gZ2MHF0b/IMGhLSJK/",
Name: "es.instanceDel",
Code: "es:instance:del",
Type: 2,
Weight: 1745319424,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1745494931}}}},
Pid: 1745292787,
UiPath: "lbOU73qg/2sDi4isw/",
Name: "es.operation",
Code: "EsOperation",
Type: 1,
Meta: `{"component":"ops/es/EsOperation","icon":"icon es/es-color","isKeepAlive":true,"routeName":"EsOperation"}`,
Weight: 1745319347,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1745659240}}}},
Pid: 1745494931,
UiPath: "lbOU73qg/2sDi4isw/SQNFhhhn/",
Name: "es.dataSave",
Code: "es:data:save",
Type: 2,
Weight: 1745659240,
},
{
Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1745659315}}}},
Pid: 1745494931,
UiPath: "lbOU73qg/2sDi4isw/XAgy5Uvp/",
Name: "es.dataDel",
Code: "es:data:del",
Type: 2,
Weight: 1745659315,
},
}
now := time.Now()
for _, res := range resources {
res.Status = 1
res.CreateTime = &now
res.CreatorId = 1
res.Creator = "admin"
res.UpdateTime = &now
res.ModifierId = 1
res.Modifier = "admin"
tx.Create(res)
}
// 给超管授权
return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
},
}
}

View File

@@ -54,8 +54,8 @@ func (r *Req) Header(name, value string) *Req {
return r
}
func (r *Req) Timeout(timeout int) *Req {
r.timeout = timeout
func (r *Req) Timeout(second int) *Req {
r.timeout = second
return r
}
@@ -107,6 +107,25 @@ func (r *Req) PostForm(params string) *Resp {
return sendRequest(r)
}
func (r *Req) PutJson(body string) *Resp {
buf := bytes.NewBufferString(body)
r.method = "PUT"
r.body = buf
if r.header == nil {
r.header = make(map[string]string)
}
r.header["Content-type"] = "application/json"
return sendRequest(r)
}
func (r *Req) PutObj(body any) *Resp {
marshal, err := json.Marshal(body)
if err != nil {
return &Resp{err: errors.New("解析json obj错误")}
}
return r.PutJson(string(marshal))
}
func (r *Req) PostMulipart(files []MultipartFile, reqParams collx.M) *Resp {
buf := &bytes.Buffer{}
// 文件写入 buf

216
server/pkg/pool/channel.go Normal file
View File

@@ -0,0 +1,216 @@
package pool
import (
"errors"
"fmt"
"mayfly-go/pkg/logx"
"sync"
"time"
//"reflect"
)
var (
//ErrMaxActiveConnReached 连接池超限
ErrMaxActiveConnReached = errors.New("MaxActiveConnReached")
)
// Config 连接池相关配置
type Config struct {
//连接池中拥有的最小连接数
InitialCap int
//最大并发存活连接数
MaxCap int
//最大空闲连接
MaxIdle int
//生成连接的方法
Factory func() (interface{}, error)
//关闭连接的方法
Close func(interface{}) error
//检查连接是否有效的方法
Ping func(interface{}) error
//连接最大空闲时间,超过该事件则将失效
IdleTimeout time.Duration
}
// channelPool 存放连接信息
type channelPool struct {
mu sync.RWMutex
conns chan *idleConn
factory func() (interface{}, error)
close func(interface{}) error
ping func(interface{}) error
idleTimeout, waitTimeOut time.Duration
maxActive int
openingConns int
}
type idleConn struct {
conn interface{}
t time.Time
}
// NewChannelPool 初始化连接
func NewChannelPool(poolConfig *Config) (Pool, error) {
if !(poolConfig.InitialCap <= poolConfig.MaxIdle && poolConfig.MaxCap >= poolConfig.MaxIdle && poolConfig.InitialCap >= 0) {
return nil, errors.New("invalid capacity settings")
}
if poolConfig.Factory == nil {
return nil, errors.New("invalid factory func settings")
}
if poolConfig.Close == nil {
return nil, errors.New("invalid close func settings")
}
c := &channelPool{
conns: make(chan *idleConn, poolConfig.MaxIdle),
factory: poolConfig.Factory,
close: poolConfig.Close,
idleTimeout: poolConfig.IdleTimeout,
maxActive: poolConfig.MaxCap,
openingConns: poolConfig.InitialCap,
}
if poolConfig.Ping != nil {
c.ping = poolConfig.Ping
}
for i := 0; i < poolConfig.InitialCap; i++ {
conn, err := c.factory()
if err != nil {
c.Release()
return nil, fmt.Errorf("factory is not able to fill the pool: %s", err)
}
c.conns <- &idleConn{conn: conn, t: time.Now()}
}
return c, nil
}
// getConns 获取所有连接
func (c *channelPool) getConns() chan *idleConn {
c.mu.Lock()
conns := c.conns
c.mu.Unlock()
return conns
}
// Get 从pool中取一个连接
func (c *channelPool) Get() (interface{}, error) {
conns := c.getConns()
if conns == nil {
return nil, ErrClosed
}
for {
select {
case wrapConn := <-conns:
if wrapConn == nil {
return nil, ErrClosed
}
//判断是否超时,超时则丢弃
if timeout := c.idleTimeout; timeout > 0 {
if wrapConn.t.Add(timeout).Before(time.Now()) {
//丢弃并关闭该连接
c.Close(wrapConn.conn)
continue
}
}
//判断是否失效,失效则丢弃,如果用户没有设定 ping 方法,就不检查
if c.ping != nil {
if err := c.Ping(wrapConn.conn); err != nil {
c.Close(wrapConn.conn)
continue
}
}
return wrapConn.conn, nil
default:
c.mu.Lock()
logx.Debugf("openConn %v %v", c.openingConns, c.maxActive)
defer c.mu.Unlock()
if c.openingConns >= c.maxActive {
return nil, ErrMaxActiveConnReached
}
if c.factory == nil {
return nil, ErrClosed
}
conn, err := c.factory()
if err != nil {
return nil, err
}
c.openingConns++
return conn, nil
}
}
}
// Put 将连接放回pool中
func (c *channelPool) Put(conn interface{}) error {
if conn == nil {
return errors.New("connection is nil. rejecting")
}
c.mu.Lock()
if c.conns == nil {
c.mu.Unlock()
return c.Close(conn)
}
select {
case c.conns <- &idleConn{conn: conn, t: time.Now()}:
c.mu.Unlock()
return nil
default:
c.mu.Unlock()
//连接池已满,直接关闭该连接
return c.Close(conn)
}
}
// Close 关闭单条连接
func (c *channelPool) Close(conn interface{}) error {
if conn == nil {
return errors.New("connection is nil. rejecting")
}
c.mu.Lock()
defer c.mu.Unlock()
if c.close == nil {
return nil
}
c.openingConns--
return c.close(conn)
}
// Ping 检查单条连接是否有效
func (c *channelPool) Ping(conn interface{}) error {
if conn == nil {
return errors.New("connection is nil. rejecting")
}
return c.ping(conn)
}
// Release 释放连接池中所有连接
func (c *channelPool) Release() {
c.mu.Lock()
conns := c.conns
c.conns = nil
c.factory = nil
c.ping = nil
closeFun := c.close
c.close = nil
c.mu.Unlock()
if conns == nil {
return
}
close(conns)
for wrapConn := range conns {
//log.Printf("Type %v\n",reflect.TypeOf(wrapConn.conn))
_ = closeFun(wrapConn.conn)
}
}
// Len 连接池中已有的连接
func (c *channelPool) Len() int {
return len(c.getConns())
}

21
server/pkg/pool/pool.go Normal file
View File

@@ -0,0 +1,21 @@
package pool
import "errors"
var (
//ErrClosed 连接池已经关闭Error
ErrClosed = errors.New("pool is closed")
)
// Pool 基本方法
type Pool interface {
Get() (interface{}, error)
Put(interface{}) error
Close(interface{}) error
Release()
Len() int
}

View File

@@ -43,6 +43,9 @@ func NewPut(path string, handler HandlerFunc) *Conf {
func NewDelete(path string, handler HandlerFunc) *Conf {
return New("DELETE", path, handler)
}
func NewAny(path string, handler HandlerFunc) *Conf {
return New("any", path, handler)
}
func (r *Conf) ToGinHFunc() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -82,7 +85,11 @@ func (r *Conf) NoRes() *Conf {
// 注册至group
func (r *Conf) Group(gr *gin.RouterGroup) *Conf {
gr.Handle(r.method, r.path, r.ToGinHFunc())
if r.method == "any" {
gr.Any(r.path, r.ToGinHFunc())
} else {
gr.Handle(r.method, r.path, r.ToGinHFunc())
}
return r
}