|
|
@@ -0,0 +1,266 @@
|
|
|
+<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="接口分组" size="small">
|
|
|
+ <el-option :label="item.name" :value="item.url" v-for="(item, index) in state.groupList" :key="index" />
|
|
|
+ </el-select>
|
|
|
+ </el-col>
|
|
|
+ <el-col :xs="24" :sm="18" :md="18" :lg="18" :xl="18" class="mb10">
|
|
|
+ <el-input v-model="state.keywords" size="small" placeholder="关键字" clearable />
|
|
|
+ </el-col>
|
|
|
+ <el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb10">
|
|
|
+ <el-button size="small" icon="ele-Search" @click="queryTreeNode()" />
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+ <el-tree
|
|
|
+ ref="treeRef"
|
|
|
+ class="filter-tree overlay-y vh68"
|
|
|
+ style="padding-bottom: 60px"
|
|
|
+ :data="state.data"
|
|
|
+ :props="{ children: 'children', label: 'summary' }"
|
|
|
+ :filter-node-method="filterNode"
|
|
|
+ node-key="id"
|
|
|
+ highlight-current
|
|
|
+ check-strictly>
|
|
|
+ <template #default="{ node }">
|
|
|
+ {{ node.label }}
|
|
|
+ <span class="node-button" v-if="!node.data.children">
|
|
|
+ <el-button size="small" icon="ele-DataLine" @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" @click="showDialog(undefined)">开始测试</el-button>
|
|
|
+ </template>
|
|
|
+ <el-descriptions title="压测参数" label-width="180px" :column="2" class="mb20" border>
|
|
|
+ <el-descriptions-item label="请求方式" label-align="left" align="left">
|
|
|
+ {{ state.ruleForm.requestMethod?.toUpperCase() }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="请求地址" label-align="left" align="left">
|
|
|
+ {{ state.ruleForm.requestUri }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="轮数" label-align="left" align="left">
|
|
|
+ {{ state.ruleForm.numberOfRounds ?? 0 }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="每轮请求数" label-align="left" align="left">
|
|
|
+ {{ state.ruleForm.numberOfRequests ?? 0 }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="最大并发量" label-align="left" align="left">
|
|
|
+ {{ state.ruleForm.maxDegreeOfParallelism ?? 0 }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+ <el-descriptions title="压测结果" label-width="180px" :column="3" border>
|
|
|
+ <el-descriptions-item label="总用时(秒)" label-align="left" align="left">
|
|
|
+ {{ (state.result.totalTimeInSeconds ?? 0).toFixed(2) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="成功请求次数" label-align="left" align="left">
|
|
|
+ {{ state.result.successfulRequests ?? 0 }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="失败请求次数" label-align="left" align="left">
|
|
|
+ {{ state.result.failedRequests ?? 0 }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="每秒查询率(QPS)" label-align="left" align="left">
|
|
|
+ {{ (state.result.queriesPerSecond ?? 0).toFixed(2) }}
|
|
|
+ </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.percentile999ResponseTime ?? 0).toFixed(2) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+ </el-card>
|
|
|
+ </pane>
|
|
|
+ </splitpanes>
|
|
|
+ </div>
|
|
|
+ <EditStressTest ref="editStressTestRef" @refreshData="refreshData" />
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts" setup name="sysStressTest">
|
|
|
+import { onMounted, reactive, ref } from 'vue';
|
|
|
+import EditStressTest from './component/editStressTest.vue';
|
|
|
+import NoticeBar from '/@/components/noticeBar/index.vue';
|
|
|
+import request, { getToken } from '/@/utils/request';
|
|
|
+import { StressTestOutput } from "/@/api-services";
|
|
|
+import { Splitpanes, Pane } from 'splitpanes';
|
|
|
+import { ElTree } from 'element-plus';
|
|
|
+import 'splitpanes/dist/splitpanes.css';
|
|
|
+import 'vue-json-pretty/lib/styles.css';
|
|
|
+
|
|
|
+const editStressTestRef = ref();
|
|
|
+const treeRef = ref<InstanceType<typeof ElTree>>();
|
|
|
+const state = reactive({
|
|
|
+ loading: false,
|
|
|
+ activeName: '',
|
|
|
+ ruleForm: {
|
|
|
+ requestUri: '',
|
|
|
+ requestMethod: 'GET',
|
|
|
+ numberOfRounds: 1,
|
|
|
+ numberOfRequests: 100,
|
|
|
+ maxDegreeOfParallelism: 200,
|
|
|
+ requestParameters: [[]],
|
|
|
+ queryParameters: [[]],
|
|
|
+ pathParameters: [[]],
|
|
|
+ headers: [[]],
|
|
|
+ },
|
|
|
+ keywords: undefined,
|
|
|
+ swaggerUrl: '/swagger/Default/swagger.json',
|
|
|
+ result: {} as StressTestOutput,
|
|
|
+ data: [] as Array<any>,
|
|
|
+ groupList: [],
|
|
|
+});
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ state.groupList = await getGroupList();
|
|
|
+ state.data = await getApiList();
|
|
|
+});
|
|
|
+
|
|
|
+// 获取分组列表
|
|
|
+const getGroupList = 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 treeNodeTest = async (node: any) => {
|
|
|
+ if (node.id == 0) return;
|
|
|
+ state.ruleForm = {
|
|
|
+ requestUri: location.origin + node.path,
|
|
|
+ requestMethod: node.method,
|
|
|
+ numberOfRounds: 1,
|
|
|
+ numberOfRequests: 100,
|
|
|
+ maxDegreeOfParallelism: 200,
|
|
|
+ requestParameters: [],
|
|
|
+ queryParameters: [],
|
|
|
+ pathParameters: [],
|
|
|
+ headers: [
|
|
|
+ ['Authorization', 'Bearer ' + getToken()]
|
|
|
+ ],
|
|
|
+ };
|
|
|
+ showDialog(state.ruleForm)
|
|
|
+};
|
|
|
+
|
|
|
+const showDialog = async (row: any) => {
|
|
|
+ const newRow = row ?? { ...state.ruleForm };
|
|
|
+ const convertToKeyValuePairs = (params) => {
|
|
|
+ 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 refreshData = (data: StressTestOutput) => {
|
|
|
+ state.result = data;
|
|
|
+}
|
|
|
+
|
|
|
+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 => u.children.length > 0);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// 查询树节点
|
|
|
+const queryTreeNode = async () => {
|
|
|
+ state.data = 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>
|