😎增加接口压测菜单和页面

This commit is contained in:
zuohuaijun 2024-12-28 18:21:59 +08:00
parent 3e19165557
commit d99bd99c0d
4 changed files with 489 additions and 0 deletions

View File

@ -208,6 +208,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
new SysMenu{ Id=1310000000621, Pid=1310000000601, Title="代码生成", Path="/develop/codeGen", Name="sysCodeGen", Component="/system/codeGen/index", Icon="ele-Crop", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=110 },
new SysMenu{ Id=1310000000631, Pid=1310000000601, Title="表单设计", Path="/develop/formDes", Name="sysFormDes", Component="/system/formDes/index", Icon="ele-Edit", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=120 },
new SysMenu{ Id=1310000000641, Pid=1310000000601, Title="系统接口", Path="/develop/api", Name="sysApi", Component="layout/routerView/iframe", IsIframe=true, OutLink="http://localhost:5005", Icon="ele-Help", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=130 },
new SysMenu{ Id=1310000000651, Pid=1310000000601, Title="接口压测", Path="/develop/stressTest", Name="sysStressTest", Component="/system/stressTest/index", Icon="ele-Compass", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2024-12-28 00:00:00"), OrderNo=140 },
new SysMenu{ Id=1310000000701, Pid=0, Title="帮助文档", Path="/doc", Name="doc", Component="Layout", Icon="ele-Notebook", Type=MenuTypeEnum.Dir, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=14000 },
new SysMenu{ Id=1310000000711, Pid=1310000000701, Title="框架教程", Path="/doc/admin", Name="sysAdmin", Component="layout/routerView/link", IsIframe=false, IsKeepAlive=false, OutLink="http://101.43.53.74:5050/", Icon="ele-Sunny", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=100 },

View File

@ -9,6 +9,7 @@ namespace Admin.NET.Plugin.ApprovalFlow;
/// <summary>
/// 流程类型枚举
/// </summary>
[Description("流程类型枚举")]
public enum FlowTypeEnum
{
}

View File

@ -0,0 +1,203 @@
<template>
<div class="sys-stress-test">
<el-dialog v-model="state.isShowDialog" draggable :close-on-click-modal="false" width="40vw">
<template #header>
<div style="color: #fff">
<el-icon size="16" style="margin-right: 3px; display: inline; vertical-align: middle"> <ele-Odometer /> </el-icon>
<span> 接口压测参数 </span>
</div>
</template>
<el-form :model="state.ruleForm" ref="ruleFormRef" label-width="auto" v-loading="state.loading">
<el-row :gutter="35">
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="请求地址" :rules="[{ required: true, message: '请求地址不能为空', trigger: 'blur' }]">
<el-input v-model="state.ruleForm.requestUri" placeholder="请求地址" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="请求方式" :rules="[{ required: true, message: '请求方式不能为空', trigger: 'blur' }]">
<el-select v-model="state.ruleForm.requestMethod" placeholder="请求方式">
<el-option :value="'GET'">GET</el-option>
<el-option :value="'PUT'">PUT</el-option>
<el-option :value="'POST'">POST</el-option>
<el-option :value="'DELETE'">DELETE</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="压测轮数" :rules="[{ required: true, message: '压测轮数不能为空', trigger: 'blur' }]">
<el-input-number v-model="state.ruleForm.numberOfRounds" placeholder="压测轮数" class="w100" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="每轮请求数" :rules="[{ required: true, message: '每轮请求数不能为空', trigger: 'blur' }]">
<el-input-number v-model="state.ruleForm.numberOfRequests" :step="100" placeholder="每轮请求数" class="w100" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="最大并发量">
<el-input-number v-model="state.ruleForm.maxDegreeOfParallelism" :step="5" placeholder="最大并发量" class="w100" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-tabs v-model="state.activeName" addable @tab-add="addParams()">
<el-tab-pane label="请求头(Headers)" name="1">
<el-row v-for="(item, index) in state.ruleForm.headers" :key="index" :gutter="25" class="w100">
<el-col :xs="24" :sm="2" :md="2" :lg="2" :xl="2" class="mb10">
<el-button type="danger" size="small" icon="ele-Delete" text @click="() => state.ruleForm.headers.splice(index, 1)" />
</el-col>
<el-col :xs="24" :sm="4" :md="4" :lg="4" :xl="4" class="mb10">
<el-input v-model="item[0]" placeholder="参数名" clearable />
</el-col>
<el-col :xs="24" :sm="18" :md="18" :lg="18" :xl="18" class="mb10">
<el-input v-model="item[1]" placeholder="参数值" clearable />
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="请求体(Body)" name="2">
<el-row v-for="(item, index) in state.ruleForm.requestParameters" :key="index" :gutter="25" class="w100">
<el-col :xs="24" :sm="2" :md="2" :lg="2" :xl="2" class="mb10">
<el-button type="danger" size="small" icon="ele-Delete" text @click="() => state.ruleForm.requestParameters.splice(index, 1)" />
</el-col>
<el-col :xs="24" :sm="4" :md="4" :lg="4" :xl="4" class="mb10">
<el-input v-model="item[0]" placeholder="参数名" clearable />
</el-col>
<el-col :xs="24" :sm="18" :md="18" :lg="18" :xl="18" class="mb10">
<el-input v-model="item[1]" placeholder="参数值" clearable />
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="路径参数(Path)" name="3">
<el-row v-for="(item, index) in state.ruleForm.pathParameters" :key="index" :gutter="25" class="w100">
<el-col :xs="24" :sm="2" :md="2" :lg="2" :xl="2" class="mb10">
<el-button type="danger" size="small" icon="ele-Delete" text @click="() => state.ruleForm.pathParameters.splice(index, 1)" />
</el-col>
<el-col :xs="24" :sm="4" :md="4" :lg="4" :xl="4" class="mb10">
<el-input v-model="item[0]" placeholder="参数名" clearable />
</el-col>
<el-col :xs="24" :sm="18" :md="18" :lg="18" :xl="18" class="mb10">
<el-input v-model="item[1]" placeholder="参数值" clearable />
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="查询参数(Query)" name="4">
<el-row v-for="(item, index) in state.ruleForm.queryParameters" :key="index" :gutter="25" class="w100">
<el-col :xs="24" :sm="2" :md="2" :lg="2" :xl="2" class="mb10">
<el-button type="danger" size="small" icon="ele-Delete" text @click="() => state.ruleForm.queryParameters.splice(index, 1)" />
</el-col>
<el-col :xs="24" :sm="4" :md="4" :lg="4" :xl="4" class="mb10">
<el-input v-model="item[0]" placeholder="参数名" clearable />
</el-col>
<el-col :xs="24" :sm="18" :md="18" :lg="18" :xl="18" class="mb10">
<el-input v-model="item[1]" placeholder="参数值" clearable />
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer" v-loading="state.loading">
<el-button @click="() => (state.isShowDialog = false)"> </el-button>
<el-button type="primary" @click="submit" v-reclick="1000"> </el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup name="sysStressTest">
import { reactive, ref } from 'vue';
import { SysCommonApi } from '/@/api-services';
import { getAPI } from '/@/utils/axios-utils';
const emits = defineEmits(['refreshData']);
const ruleFormRef = ref();
const state = reactive({
isShowDialog: false,
activeName: '1',
loading: false,
ruleForm: {
requestUri: '',
requestMethod: 'GET',
numberOfRounds: 1,
numberOfRequests: 100,
maxDegreeOfParallelism: 500,
requestParameters: [[]] as [[]] | {},
queryParameters: [[]] as [[]] | {},
pathParameters: [[]] as [[]] | {},
headers: [[]] as [[]] | {},
} as any,
});
//
const formatParameter = (params: any[] | {}) => {
if (Array.isArray(params)) {
return Object.fromEntries(params.filter((e) => e.length === 2));
} else if (typeof params === 'object' && params !== null) {
return Object.entries(params);
}
return {};
};
//
const openDialog = (row: any) => {
const newRow = { ...state.ruleForm, ...row }; //
state.ruleForm = {
...newRow,
requestMethod: row.requestMethod?.toUpperCase() ?? 'GET',
};
state.isShowDialog = true;
ruleFormRef.value?.resetFields();
};
//
const submit = () => {
ruleFormRef.value.validate(async (valid: boolean) => {
if (!valid) return;
try {
state.loading = true;
//
const formattedRuleForm = {
...state.ruleForm,
headers: formatParameter(state.ruleForm.headers),
pathParameters: formatParameter(state.ruleForm.pathParameters),
queryParameters: formatParameter(state.ruleForm.queryParameters),
requestParameters: formatParameter(state.ruleForm.requestParameters),
} as any;
// undefined
['headers', 'pathParameters', 'queryParameters', 'requestParameters'].forEach((paramKey) => {
if (Object.keys(formattedRuleForm[paramKey] || {}).length === 0) {
formattedRuleForm[paramKey] = undefined;
}
});
emits(
'refreshData',
await getAPI(SysCommonApi)
.apiSysCommonStressTestPost(formattedRuleForm, { timeout: 0 })
.then((res) => res.data.result)
);
state.isShowDialog = false;
} finally {
state.loading = false;
}
});
};
//
const addParams = () => {
const paramType = ['headers', 'requestParameters', 'pathParameters', 'queryParameters'][+state.activeName - 1];
if (Array.isArray(state.ruleForm[paramType])) {
state.ruleForm[paramType].push([null, null]);
} else if (typeof state.ruleForm[paramType] === 'object') {
state.ruleForm[paramType] = [[null, null]];
}
};
//
defineExpose({ openDialog });
</script>

View File

@ -0,0 +1,284 @@
<template>
<div class="sys-stress-test h100 overlay-none">
<div>
<NoticeBar text="接口压测会占用服务器大量的系统资源(内存、带宽),请慎重操作!!!" style="margin: 4px" />
</div>
<splitpanes class="default-theme overlay-hidden">
<pane size="20" class="vh100">
<el-card class="vh80" shadow="hover" header="" v-loading="state.loading">
<el-row :gutter="35">
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb10">
<el-select v-model="state.swaggerUrl" placeholder="接口分组">
<el-option v-for="(item, index) in state.apiGroupList" :key="index" :label="item.name" :value="item.url" />
</el-select>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb10">
<el-input v-model="state.keywords" placeholder="接口名称" clearable>
<template #append>
<el-button icon="ele-Search" v-reclick="100" @click="queryTreeNode()" />
</template>
</el-input>
</el-col>
</el-row>
<el-tree
ref="treeRef"
class="filter-tree overlay-y vh68"
style="padding-bottom: 60px"
:data="state.apiList"
:props="{ children: 'children', label: 'summary' }"
:filter-node-method="filterNode"
node-key="id"
highlight-current
check-strictly
>
<template #default="{ node }">
<el-icon v-if="node.level == 1" size="16" style="margin-right: 3px; display: inline; vertical-align: middle"><ele-Menu /></el-icon>
<el-icon v-else size="16" style="margin-right: 3px; display: inline; vertical-align: middle"><ele-Link /></el-icon>
{{ node.label }}
<span class="node-button" v-if="!node.data.children">
<el-button type="warning" plain icon="ele-Position" @click="treeNodeTest(node.data)" />
</span>
</template>
</el-tree>
</el-card>
</pane>
<pane size="80" class="vh100">
<el-card class="main-container vh80" shadow="hover" header="" v-loading="state.loading" body-style="height:100vh; overflow:auto">
<template #header>
<el-button type="primary" icon="ele-Odometer" @click="showDialog(undefined)">接口压测</el-button>
</template>
<el-descriptions title="⚡压测参数" label-width="180px" :column="1" class="mb20" border>
<el-descriptions-item label="请求地址" label-align="left" align="left">
{{ state.ruleForm.requestUri }}
</el-descriptions-item>
<el-descriptions-item label="请求方式" label-align="left" align="left">
<el-tag>{{ state.ruleForm.requestMethod?.toUpperCase() }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="压测轮数" label-align="left" align="left">
{{ state.ruleForm.numberOfRounds ?? 1 }}
</el-descriptions-item>
<el-descriptions-item label="每轮请求数" label-align="left" align="left">
{{ state.ruleForm.numberOfRequests ?? 1 }}
</el-descriptions-item>
<el-descriptions-item label="最大并发量" label-align="left" align="left">
{{ state.ruleForm.maxDegreeOfParallelism ?? 1 }}
</el-descriptions-item>
</el-descriptions>
<el-descriptions title="⚡压测结果" label-width="180px" :column="4" border>
<el-descriptions-item label="总用时(秒)" label-align="left" align="left">
<el-tag>{{ (state.result.totalTimeInSeconds ?? 0).toFixed(2) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="成功请求次数" label-align="left" align="left">
<el-tag type="success">{{ state.result.successfulRequests ?? 0 }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="失败请求次数" label-align="left" align="left">
<el-tag type="danger">{{ state.result.failedRequests ?? 0 }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="每秒查询率QPS" label-align="left" align="left">
<el-tag>{{ (state.result.queriesPerSecond ?? 0).toFixed(2) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最小响应时间(毫秒)" label-align="left" align="left">
{{ (state.result.minResponseTime ?? 0).toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="最大响应时间(毫秒)" label-align="left" align="left">
{{ (state.result.maxResponseTime ?? 0).toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="平均响应时间(毫秒)" span="3" label-align="left" align="left">
{{ (state.result.averageResponseTime ?? 0).toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="P10 响应时间(毫秒)" label-align="left" align="left">
{{ (state.result.percentile10ResponseTime ?? 0).toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="P25 响应时间(毫秒)" label-align="left" align="left">
{{ (state.result.percentile25ResponseTime ?? 0).toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="P50 响应时间(毫秒)" label-align="left" align="left">
{{ (state.result.percentile50ResponseTime ?? 0).toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="P75 响应时间(毫秒)" label-align="left" align="left">
{{ (state.result.percentile75ResponseTime ?? 0).toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="P90 响应时间(毫秒)" label-align="left" align="left">
{{ (state.result.percentile90ResponseTime ?? 0).toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="P99 响应时间(毫秒)" label-align="left" align="left">
{{ (state.result.percentile99ResponseTime ?? 0).toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="P999 响应时间(毫秒)" label-align="left" align="left">
{{ (state.result.percentile9999ResponseTime ?? 0).toFixed(2) }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</pane>
</splitpanes>
<EditStressTest ref="editStressTestRef" @refreshData="refreshData" />
</div>
</template>
<script lang="ts" setup name="sysStressTest">
import { onMounted, reactive, ref } from 'vue';
import { ElTree } from 'element-plus';
import { Splitpanes, Pane } from 'splitpanes';
import 'splitpanes/dist/splitpanes.css';
import 'vue-json-pretty/lib/styles.css';
import EditStressTest from './component/editStressTest.vue';
import NoticeBar from '/@/components/noticeBar/index.vue';
import request, { getToken } from '/@/utils/request';
import { StressTestHarnessResult } from '/@/api-services';
const editStressTestRef = ref();
const treeRef = ref<InstanceType<typeof ElTree>>();
const state = reactive({
loading: false,
activeName: '',
ruleForm: {
requestUri: '',
requestMethod: 'GET',
numberOfRounds: 1,
numberOfRequests: 100,
maxDegreeOfParallelism: 500,
requestParameters: [[]],
queryParameters: [[]],
pathParameters: [[]],
headers: [[]],
},
keywords: undefined,
swaggerUrl: '/swagger/Default/swagger.json',
result: {} as StressTestHarnessResult,
apiList: [] as Array<any>,
apiGroupList: [] as any,
});
//
onMounted(async () => {
state.apiGroupList = await getApiGroupList();
state.apiList = await getApiList('');
});
//
const getApiGroupList = async () => {
try {
const html = await request(`/index.html`, { method: 'get' }).then(({ data }) => data);
const prefixText = "var configObject = JSON.parse('";
const jsonStr = html
.substring(html.indexOf(prefixText) + prefixText.length, html.indexOf('var oauthConfigObject = JSON.parse('))
?.trim()
.replace("');", '');
return JSON.parse(jsonStr).urls;
} catch {
return [];
}
};
//
const getApiList = (keywords: string | undefined) => {
const emojiPattern =
/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu;
return request(state.swaggerUrl, { method: 'get' }).then(({ data }) => {
const pathMap = data.paths;
const result = data.tags.map((e: any) => ({ path: e.name, summary: e.description.replaceAll(emojiPattern, ''), children: [] }));
Object.keys(pathMap).map((path) => {
const method = Object.keys(pathMap[path])[0];
const apiInfo = pathMap[path][method];
if (keywords && apiInfo.summary?.indexOf(keywords) === -1) return;
result
.find((u: any) => u.path === apiInfo.tags[0])
.children.push({
path: path,
method: method,
summary: apiInfo.summary?.replaceAll(emojiPattern, '') ?? path,
parameters: apiInfo.parameters,
requestBody: apiInfo.requestBody,
data: apiInfo,
});
});
return result.filter((u: any) => u.children.length > 0);
});
};
//
const refreshData = (data: StressTestHarnessResult) => {
state.result = data;
};
//
const showDialog = async (row: any) => {
const newRow = row ?? { ...state.ruleForm };
const convertToKeyValuePairs = (params: any) => {
if (Array.isArray(params) && params.every((item) => Array.isArray(item) && item.length === 2)) {
return params;
} else if (typeof params === 'object' && params !== null) {
return Object.entries(params);
}
return [];
};
state.ruleForm = {
...newRow,
requestParameters: convertToKeyValuePairs(newRow.requestParameters),
queryParameters: convertToKeyValuePairs(newRow.queryParameters),
pathParameters: convertToKeyValuePairs(newRow.pathParameters),
headers: convertToKeyValuePairs(newRow.headers),
};
editStressTestRef.value.openDialog(state.ruleForm);
};
//
const treeNodeTest = async (node: any) => {
if (node.id == 0) return;
state.ruleForm = {
requestUri: location.origin + node.path,
requestMethod: node.method,
numberOfRounds: 1,
numberOfRequests: 100,
maxDegreeOfParallelism: 500,
requestParameters: [],
queryParameters: [],
pathParameters: [],
headers: [['Authorization', 'Bearer ' + getToken()]] as any,
};
showDialog(state.ruleForm);
};
//
const queryTreeNode = async () => {
state.apiList = await getApiList(state.keywords);
};
const filterNode = (value: string, data: any) => {
if (!value) return true;
return data.name.includes(value);
};
</script>
<style lang="scss" scoped>
.card-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
:deep(.el-collapse-item) {
.el-collapse-item__arrow {
float: right;
}
}
:deep(.main-container) {
.el-card__header {
padding: 8px;
}
}
.node-button {
position: absolute;
scale: 0.7;
right: 0;
}
</style>