feat(性能测试): 测试报告支持自定义图表
This commit is contained in:
parent
cc24a21e48
commit
051552c0da
|
@ -1,6 +1,24 @@
|
||||||
package io.metersphere.commons.constants;
|
package io.metersphere.commons.constants;
|
||||||
|
|
||||||
public enum ReportKeys {
|
public enum ReportKeys {
|
||||||
LoadChart, ResponseTimeChart, ResponseCodeChart, Errors, ErrorsTop5, RequestStatistics, Overview, TimeInfo, ResultStatus, ErrorsChart
|
LoadChart,
|
||||||
|
ResponseTimeChart,
|
||||||
|
ResponseTimePercentilesChart,
|
||||||
|
ConnectTimeChart,
|
||||||
|
BytesThroughputChart,
|
||||||
|
DistributedActiveThreads,
|
||||||
|
HitsChart,
|
||||||
|
LatencyChart,
|
||||||
|
ResponseCodeChart,
|
||||||
|
TransactionsChart,
|
||||||
|
TotalTransactionsChart,
|
||||||
|
ResponseTimeVsRequestChart,
|
||||||
|
LatencyVsRequestChart,
|
||||||
|
ErrorsChart,
|
||||||
|
Errors,
|
||||||
|
ErrorsTop5,
|
||||||
|
RequestStatistics,
|
||||||
|
Overview,
|
||||||
|
TimeInfo,
|
||||||
|
ResultStatus,
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,6 +68,11 @@ public class PerformanceReportController {
|
||||||
return performanceReportService.getReportErrors(reportId);
|
return performanceReportService.getReportErrors(reportId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/content/{reportKey}/{reportId}")
|
||||||
|
public List<ChartsData> getReportChart(@PathVariable String reportKey, @PathVariable String reportId) {
|
||||||
|
return performanceReportService.getReportChart(reportKey, reportId);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/content/errors_top5/{reportId}")
|
@GetMapping("/content/errors_top5/{reportId}")
|
||||||
public List<ErrorsTop5> getReportErrorsTop5(@PathVariable String reportId) {
|
public List<ErrorsTop5> getReportErrorsTop5(@PathVariable String reportId) {
|
||||||
return performanceReportService.getReportErrorsTOP5(reportId);
|
return performanceReportService.getReportErrorsTOP5(reportId);
|
||||||
|
|
|
@ -392,4 +392,14 @@ public class PerformanceReportService {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<ChartsData> getReportChart(String reportKey, String reportId) {
|
||||||
|
checkReportStatus(reportId);
|
||||||
|
try {
|
||||||
|
String content = getContent(reportId, ReportKeys.valueOf(reportKey));
|
||||||
|
return JSON.parseArray(content, ChartsData.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,8 @@
|
||||||
@click="rerun(testId)">
|
@click="rerun(testId)">
|
||||||
{{ $t('report.test_execute_again') }}
|
{{ $t('report.test_execute_again') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :disabled="isReadOnly" type="info" plain size="mini" @click="handleExport(reportName)" v-permission="['PROJECT_PERFORMANCE_REPORT:READ+EXPORT']">
|
<el-button :disabled="isReadOnly" type="info" plain size="mini" @click="handleExport(reportName)"
|
||||||
|
v-permission="['PROJECT_PERFORMANCE_REPORT:READ+EXPORT']">
|
||||||
{{ $t('test_track.plan_view.export_report') }}
|
{{ $t('test_track.plan_view.export_report') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :disabled="report.status !== 'Completed'" type="default" plain
|
<el-button :disabled="report.status !== 'Completed'" type="default" plain
|
||||||
|
@ -84,6 +85,9 @@
|
||||||
<el-tab-pane :label="$t('report.test_overview')">
|
<el-tab-pane :label="$t('report.test_overview')">
|
||||||
<ms-report-test-overview :report="report" ref="testOverview"/>
|
<ms-report-test-overview :report="report" ref="testOverview"/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
<el-tab-pane :label="$t('report.test_details')">
|
||||||
|
<ms-report-test-details :report="report" ref="testDetails"/>
|
||||||
|
</el-tab-pane>
|
||||||
<el-tab-pane :label="$t('report.test_request_statistics')">
|
<el-tab-pane :label="$t('report.test_request_statistics')">
|
||||||
<ms-report-request-statistics :report="report" ref="requestStatistics"/>
|
<ms-report-request-statistics :report="report" ref="requestStatistics"/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
@ -122,6 +126,7 @@
|
||||||
import MsReportErrorLog from './components/ErrorLog';
|
import MsReportErrorLog from './components/ErrorLog';
|
||||||
import MsReportLogDetails from './components/LogDetails';
|
import MsReportLogDetails from './components/LogDetails';
|
||||||
import MsReportRequestStatistics from './components/RequestStatistics';
|
import MsReportRequestStatistics from './components/RequestStatistics';
|
||||||
|
import MsReportTestDetails from './components/TestDetails';
|
||||||
import MsReportTestOverview from './components/TestOverview';
|
import MsReportTestOverview from './components/TestOverview';
|
||||||
import MsPerformancePressureConfig from "./components/PerformancePressureConfig";
|
import MsPerformancePressureConfig from "./components/PerformancePressureConfig";
|
||||||
import MsContainer from "../../common/components/MsContainer";
|
import MsContainer from "../../common/components/MsContainer";
|
||||||
|
@ -147,6 +152,7 @@ export default {
|
||||||
MsReportTestOverview,
|
MsReportTestOverview,
|
||||||
MsContainer,
|
MsContainer,
|
||||||
MsMainContainer,
|
MsMainContainer,
|
||||||
|
MsReportTestDetails,
|
||||||
MsPerformancePressureConfig
|
MsPerformancePressureConfig
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|
|
@ -0,0 +1,299 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-row>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-collapse v-model="activeNames" class="test-detail">
|
||||||
|
<el-collapse-item :title="$t('load_test.report.ActiveThreadsChart')" name="users">
|
||||||
|
<el-checkbox-group v-model="checkList['ActiveThreadsChart']"
|
||||||
|
@change="handleChecked('ActiveThreadsChart')">
|
||||||
|
<el-checkbox v-for="name in checkOptions['ActiveThreadsChart']" :key="name" :label="name"/>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-collapse-item>
|
||||||
|
<el-collapse-item :title="$t('load_test.report.TransactionsChart')" name="transactions">
|
||||||
|
<el-checkbox-group v-model="checkList['TransactionsChart']" @change="handleChecked('TransactionsChart')">
|
||||||
|
<el-checkbox v-for="name in checkOptions['TransactionsChart']" :key="name" :label="name"/>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-collapse-item>
|
||||||
|
<el-collapse-item :title="$t('load_test.report.ResponseTimeChart')" name="responseTime">
|
||||||
|
<el-checkbox-group v-model="checkList['ResponseTimeChart']" @change="handleChecked('ResponseTimeChart')">
|
||||||
|
<el-checkbox v-for="name in checkOptions['ResponseTimeChart']" :key="name" :label="name"/>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-collapse-item>
|
||||||
|
<el-collapse-item :title="$t('load_test.report.ResponseTimePercentilesChart')" name="responseTimePercentiles">
|
||||||
|
<el-checkbox-group v-model="checkList['ResponseTimePercentilesChart']"
|
||||||
|
@change="handleChecked('ResponseTimePercentilesChart')">
|
||||||
|
<el-checkbox v-for="name in checkOptions['ResponseTimePercentilesChart']" :key="name" :label="name"/>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-collapse-item>
|
||||||
|
<el-collapse-item :title="$t('load_test.report.ResponseCodeChart')" name="responseCode">
|
||||||
|
<el-checkbox-group v-model="checkList['ResponseCodeChart']" @change="handleChecked('ResponseCodeChart')">
|
||||||
|
<el-checkbox v-for="code in checkOptions['ResponseCodeChart']" :key="code" :label="code"/>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-collapse-item>
|
||||||
|
<el-collapse-item :title="$t('load_test.report.LatencyChart')" name="latency">
|
||||||
|
<el-checkbox-group v-model="checkList['LatencyChart']" @change="handleChecked('LatencyChart')">
|
||||||
|
<el-checkbox v-for="name in checkOptions['LatencyChart']" :key="name" :label="name"/>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-collapse-item>
|
||||||
|
<el-collapse-item :title="$t('load_test.report.BytesThroughputChart')" name="bytes">
|
||||||
|
<el-checkbox-group v-model="checkList['BytesThroughputChart']"
|
||||||
|
@change="handleChecked('BytesThroughputChart')">
|
||||||
|
<el-checkbox v-for="code in checkOptions['BytesThroughputChart']" :key="code" :label="code"/>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-collapse-item>
|
||||||
|
<el-collapse-item :title="$t('load_test.report.ErrorsChart')" name="errors">
|
||||||
|
<el-checkbox-group v-model="checkList['ErrorsChart']" @change="handleChecked('ErrorsChart')">
|
||||||
|
<el-checkbox v-for="name in checkOptions['ErrorsChart']" :key="name" :label="name"/>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="18">
|
||||||
|
<ms-chart ref="chart2" :options="totalOption" class="chart-config" :autoresize="true"></ms-chart>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import MsChart from "@/business/components/common/chart/MsChart";
|
||||||
|
|
||||||
|
|
||||||
|
const CHART_MAP = [
|
||||||
|
'ActiveThreadsChart',
|
||||||
|
'TransactionsChart',
|
||||||
|
'ResponseTimePercentilesChart',
|
||||||
|
'ResponseTimeChart',
|
||||||
|
'ResponseCodeChart',
|
||||||
|
'ErrorsChart',
|
||||||
|
'LatencyChart',
|
||||||
|
'BytesThroughputChart',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "TestDetails",
|
||||||
|
components: {MsChart},
|
||||||
|
props: ['report', 'export'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
activeNames: 'users',
|
||||||
|
loadOption: {},
|
||||||
|
resOption: {},
|
||||||
|
totalOption: {},
|
||||||
|
responseCodes: [],
|
||||||
|
checkList: CHART_MAP.reduce((result, curr) => {
|
||||||
|
result[curr] = [];
|
||||||
|
return result;
|
||||||
|
}, {}),
|
||||||
|
checkOptions: {},
|
||||||
|
defaultProps: {
|
||||||
|
children: 'children',
|
||||||
|
label: 'label'
|
||||||
|
},
|
||||||
|
init: false,
|
||||||
|
baseOption: {
|
||||||
|
title: {
|
||||||
|
text: 'Test Details',
|
||||||
|
left: 'center',
|
||||||
|
top: 20,
|
||||||
|
textStyle: {
|
||||||
|
color: '#99743C'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
show: true,
|
||||||
|
trigger: 'axis',
|
||||||
|
// extraCssText: 'z-index: 999;',
|
||||||
|
confine: true,
|
||||||
|
formatter: function (params, ticket, callback) {
|
||||||
|
let result = "";
|
||||||
|
let name = params[0].name;
|
||||||
|
result += name + "<br/>";
|
||||||
|
for (let i = 0; i < params.length; i++) {
|
||||||
|
let seriesName = params[i].seriesName;
|
||||||
|
if (seriesName.length > 100) {
|
||||||
|
seriesName = seriesName.substring(0, 100);
|
||||||
|
}
|
||||||
|
let value = params[i].value;
|
||||||
|
let marker = params[i].marker;
|
||||||
|
result += marker + seriesName + ": " + value[1] + "<br/>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {},
|
||||||
|
xAxis: {},
|
||||||
|
|
||||||
|
series: []
|
||||||
|
},
|
||||||
|
chartData: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleChecked(name) {
|
||||||
|
this.getTotalChart();
|
||||||
|
},
|
||||||
|
initTableData() {
|
||||||
|
this.init = true;
|
||||||
|
|
||||||
|
for (const name of CHART_MAP) {
|
||||||
|
this.getCheckOptions(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.checkList['ActiveThreadsChart'] = ['ALL'];
|
||||||
|
this.checkList['TransactionsChart'] = ['ALL'];
|
||||||
|
this.checkList['ResponseTimeChart'] = ['ALL'];
|
||||||
|
|
||||||
|
this.getTotalChart();
|
||||||
|
},
|
||||||
|
getCheckOptions(reportKey) {
|
||||||
|
this.$get("/performance/report/content/" + reportKey + "/" + this.id)
|
||||||
|
.then(res => {
|
||||||
|
let data = res.data.data;
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let yAxisIndex0List = data.filter(m => m.yAxis2 === -1).map(m => m.groupName);
|
||||||
|
yAxisIndex0List = this._unique(yAxisIndex0List);
|
||||||
|
this.checkOptions[reportKey] = ['ALL'].concat(yAxisIndex0List);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getTotalChart() {
|
||||||
|
this.totalOption = {};
|
||||||
|
this.chartData = [];
|
||||||
|
for (let name in this.checkList) {
|
||||||
|
if (this.checkList[name].length > 0) {
|
||||||
|
this.getChart(name, this.checkList[name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getChart(reportKey, checkList) {
|
||||||
|
this.$get("/performance/report/content/" + reportKey + "/" + this.id)
|
||||||
|
.then(res => {
|
||||||
|
let data = res.data.data;
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
this.init = false;
|
||||||
|
}
|
||||||
|
if (checkList) {
|
||||||
|
data = data.filter(item => {
|
||||||
|
if (checkList.indexOf('ALL') > -1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (checkList.indexOf(item.groupName) > -1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefix
|
||||||
|
data.forEach(item => {
|
||||||
|
item.groupName = this.$t('load_test.report.' + reportKey) + ': ' + item.groupName;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.chartData = this.chartData.concat(data);
|
||||||
|
|
||||||
|
let yAxisList = data.filter(m => m.yAxis2 === -1).map(m => m.yAxis);
|
||||||
|
let yAxisListMax = this._getChartMax(yAxisList);
|
||||||
|
|
||||||
|
this.baseOption.yAxis = [
|
||||||
|
{
|
||||||
|
name: 'Value',
|
||||||
|
type: 'value',
|
||||||
|
min: 0,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
this.totalOption = this.generateOption(this.baseOption, this.chartData);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.totalOption = {};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
generateOption(option, data) {
|
||||||
|
let chartData = data;
|
||||||
|
let legend = [], series = {}, xAxis = [], seriesData = [];
|
||||||
|
chartData.forEach(item => {
|
||||||
|
if (!xAxis.includes(item.xAxis)) {
|
||||||
|
xAxis.push(item.xAxis);
|
||||||
|
}
|
||||||
|
xAxis.sort();
|
||||||
|
let name = item.groupName;
|
||||||
|
if (!legend.includes(name)) {
|
||||||
|
legend.push(name);
|
||||||
|
series[name] = [];
|
||||||
|
}
|
||||||
|
series[name].splice(xAxis.indexOf(item.xAxis), 0, [item.xAxis, item.yAxis.toFixed(2)]);
|
||||||
|
});
|
||||||
|
this.$set(option.legend, "data", legend);
|
||||||
|
this.$set(option.legend, "type", "scroll");
|
||||||
|
this.$set(option.legend, "bottom", "10px");
|
||||||
|
this.$set(option.xAxis, "data", xAxis);
|
||||||
|
for (let name in series) {
|
||||||
|
let d = series[name];
|
||||||
|
d.sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
let items = {
|
||||||
|
name: name,
|
||||||
|
type: 'line',
|
||||||
|
data: d,
|
||||||
|
smooth: true,
|
||||||
|
sampling: 'lttb',
|
||||||
|
animation: !this.export,
|
||||||
|
};
|
||||||
|
seriesData.push(items);
|
||||||
|
}
|
||||||
|
this.$set(option, "series", seriesData);
|
||||||
|
return option;
|
||||||
|
},
|
||||||
|
_getChartMax(arr) {
|
||||||
|
const max = Math.max(...arr);
|
||||||
|
return Math.ceil(max / 4.5) * 5;
|
||||||
|
},
|
||||||
|
_unique(arr) {
|
||||||
|
return Array.from(new Set(arr));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.id = this.$route.path.split('/')[4];
|
||||||
|
this.initTableData();
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route'(to) {
|
||||||
|
if (to.name === "perReportView") {
|
||||||
|
this.id = to.path.split('/')[4];
|
||||||
|
this.init = false;
|
||||||
|
this.initTableData();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
report: {
|
||||||
|
handler(val) {
|
||||||
|
if (!val.status || !val.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let status = val.status;
|
||||||
|
this.id = val.id;
|
||||||
|
if (this.init) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === "Completed" || status === "Running") {
|
||||||
|
this.initTableData();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-config {
|
||||||
|
width: 100%;
|
||||||
|
height: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-detail {
|
||||||
|
height: calc(100vh - 320px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -646,7 +646,15 @@ export default {
|
||||||
project_file_exist: "The file already exists in the project, please import it directly",
|
project_file_exist: "The file already exists in the project, please import it directly",
|
||||||
project_file_update_type_error: 'Updated file types must be consistent',
|
project_file_update_type_error: 'Updated file types must be consistent',
|
||||||
report: {
|
report: {
|
||||||
diff: "Compare"
|
diff: "Compare",
|
||||||
|
ActiveThreadsChart: 'Users',
|
||||||
|
TransactionsChart: 'Requests/Transactions',
|
||||||
|
ErrorsChart: 'Error',
|
||||||
|
ResponseTimeChart: 'Response Time',
|
||||||
|
ResponseTimePercentilesChart: 'Response time percentage',
|
||||||
|
ResponseCodeChart: 'Response Code',
|
||||||
|
LatencyChart: 'Latency',
|
||||||
|
BytesThroughputChart: 'Bytes',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
api_test: {
|
api_test: {
|
||||||
|
@ -847,7 +855,7 @@ export default {
|
||||||
wait_controller: "Wait controller",
|
wait_controller: "Wait controller",
|
||||||
if_controller: "If controller",
|
if_controller: "If controller",
|
||||||
loop_controller: "Loop Controller",
|
loop_controller: "Loop Controller",
|
||||||
transcation_controller:"Transcation controller",
|
transcation_controller: "Transcation controller",
|
||||||
scenario_import: "Scenario import",
|
scenario_import: "Scenario import",
|
||||||
customize_script: "Customize script",
|
customize_script: "Customize script",
|
||||||
customize_req: "Customize req",
|
customize_req: "Customize req",
|
||||||
|
|
|
@ -644,7 +644,15 @@ export default {
|
||||||
load_api_automation_jmx: '引用接口自动化场景',
|
load_api_automation_jmx: '引用接口自动化场景',
|
||||||
project_file_exist: "项目中已存在该文件,请直接引用",
|
project_file_exist: "项目中已存在该文件,请直接引用",
|
||||||
report: {
|
report: {
|
||||||
diff: "对比"
|
diff: "对比",
|
||||||
|
ActiveThreadsChart: '用户数',
|
||||||
|
TransactionsChart: '请求/事务数',
|
||||||
|
ErrorsChart: '错误',
|
||||||
|
ResponseTimeChart: '响应时间',
|
||||||
|
ResponseTimePercentilesChart: '响应时间百分比',
|
||||||
|
ResponseCodeChart: '响应码',
|
||||||
|
LatencyChart: '延迟时间',
|
||||||
|
BytesThroughputChart: '字节数',
|
||||||
},
|
},
|
||||||
project_file_update_type_error: '更新的文件类型必须一致',
|
project_file_update_type_error: '更新的文件类型必须一致',
|
||||||
},
|
},
|
||||||
|
|
|
@ -644,7 +644,15 @@ export default {
|
||||||
load_api_automation_jmx: '引用接口自動化場景',
|
load_api_automation_jmx: '引用接口自動化場景',
|
||||||
project_file_exist: "項目中已存在該文件,請直接引用",
|
project_file_exist: "項目中已存在該文件,請直接引用",
|
||||||
report: {
|
report: {
|
||||||
diff: "對比"
|
diff: "對比",
|
||||||
|
ActiveThreadsChart: '用戶數',
|
||||||
|
TransactionsChart: '請求/事務數',
|
||||||
|
ErrorsChart: '錯誤',
|
||||||
|
ResponseTimeChart: '響應時間',
|
||||||
|
ResponseTimePercentilesChart: '響應時間百分比',
|
||||||
|
ResponseCodeChart: '響應碼',
|
||||||
|
LatencyChart: '延遲時間',
|
||||||
|
BytesThroughputChart: '字節數',
|
||||||
},
|
},
|
||||||
project_file_update_type_error: '更新的文件類型必須一致',
|
project_file_update_type_error: '更新的文件類型必須一致',
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in New Issue