fix(性能测试): 压力配置上可以选择场景是否顺序执行
This commit is contained in:
parent
6e1545b619
commit
512d2c3170
|
@ -41,6 +41,7 @@ public class JmeterDocumentParser implements DocumentParser {
|
||||||
private final static String RESPONSE_ASSERTION = "ResponseAssertion";
|
private final static String RESPONSE_ASSERTION = "ResponseAssertion";
|
||||||
private final static String CSV_DATA_SET = "CSVDataSet";
|
private final static String CSV_DATA_SET = "CSVDataSet";
|
||||||
private EngineContext context;
|
private EngineContext context;
|
||||||
|
private boolean containsIterationThread = false;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String parse(EngineContext context, Document document) throws Exception {
|
public String parse(EngineContext context, Document document) throws Exception {
|
||||||
|
@ -96,10 +97,10 @@ public class JmeterDocumentParser implements DocumentParser {
|
||||||
processCheckoutArguments(ele);
|
processCheckoutArguments(ele);
|
||||||
processCheckoutResponseAssertion(ele);
|
processCheckoutResponseAssertion(ele);
|
||||||
processCheckoutSerializeThreadgroups(ele);
|
processCheckoutSerializeThreadgroups(ele);
|
||||||
|
processCheckoutBackendListener(ele);
|
||||||
} else if (nodeNameEquals(ele, CONCURRENCY_THREAD_GROUP)) {
|
} else if (nodeNameEquals(ele, CONCURRENCY_THREAD_GROUP)) {
|
||||||
processThreadGroupName(ele);
|
processThreadGroupName(ele);
|
||||||
processCheckoutTimer(ele);
|
processCheckoutTimer(ele);
|
||||||
processCheckoutBackendListener(ele);
|
|
||||||
} else if (nodeNameEquals(ele, VARIABLE_THROUGHPUT_TIMER)) {
|
} else if (nodeNameEquals(ele, VARIABLE_THROUGHPUT_TIMER)) {
|
||||||
processVariableThroughputTimer(ele);
|
processVariableThroughputTimer(ele);
|
||||||
} else if (nodeNameEquals(ele, THREAD_GROUP)) {
|
} else if (nodeNameEquals(ele, THREAD_GROUP)) {
|
||||||
|
@ -112,6 +113,7 @@ public class JmeterDocumentParser implements DocumentParser {
|
||||||
}
|
}
|
||||||
if ("ITERATION".equals(o)) {
|
if ("ITERATION".equals(o)) {
|
||||||
processIterationThreadGroup(ele);
|
processIterationThreadGroup(ele);
|
||||||
|
this.containsIterationThread = true; // 包括按照迭代次数的线程组
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
processThreadGroup(ele);
|
processThreadGroup(ele);
|
||||||
|
@ -119,7 +121,6 @@ public class JmeterDocumentParser implements DocumentParser {
|
||||||
|
|
||||||
processThreadGroupName(ele);
|
processThreadGroupName(ele);
|
||||||
processCheckoutTimer(ele);
|
processCheckoutTimer(ele);
|
||||||
processCheckoutBackendListener(ele);
|
|
||||||
} else if (nodeNameEquals(ele, BACKEND_LISTENER)) {
|
} else if (nodeNameEquals(ele, BACKEND_LISTENER)) {
|
||||||
processBackendListener(ele);
|
processBackendListener(ele);
|
||||||
} else if (nodeNameEquals(ele, CONFIG_TEST_ELEMENT)) {
|
} else if (nodeNameEquals(ele, CONFIG_TEST_ELEMENT)) {
|
||||||
|
@ -144,14 +145,19 @@ public class JmeterDocumentParser implements DocumentParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void processCheckoutSerializeThreadgroups(Element element) {
|
private void processCheckoutSerializeThreadgroups(Element element) {
|
||||||
|
Object serializeThreadGroups = context.getProperty("serializeThreadGroups");
|
||||||
|
String serializeThreadGroup = "false";
|
||||||
|
if (serializeThreadGroups instanceof List) {
|
||||||
|
Object o = ((List<?>) serializeThreadGroups).get(0);
|
||||||
|
serializeThreadGroup = o.toString();
|
||||||
|
}
|
||||||
NodeList childNodes = element.getChildNodes();
|
NodeList childNodes = element.getChildNodes();
|
||||||
for (int i = 0; i < childNodes.getLength(); i++) {
|
for (int i = 0; i < childNodes.getLength(); i++) {
|
||||||
Node item = childNodes.item(i);
|
Node item = childNodes.item(i);
|
||||||
if (nodeNameEquals(item, BOOL_PROP)) {
|
if (nodeNameEquals(item, BOOL_PROP)) {
|
||||||
String serializeName = ((Element) item).getAttribute("name");
|
String serializeName = ((Element) item).getAttribute("name");
|
||||||
if (StringUtils.equals(serializeName, "TestPlan.serialize_threadgroups")) {
|
if (StringUtils.equals(serializeName, "TestPlan.serialize_threadgroups")) {
|
||||||
// 保存线程组是否是顺序执行
|
item.setTextContent(serializeThreadGroup);
|
||||||
context.addProperty("serialize_threadgroups", item.getTextContent());
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -542,6 +548,15 @@ public class JmeterDocumentParser implements DocumentParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void processBackendListener(Element backendListener) {
|
private void processBackendListener(Element backendListener) {
|
||||||
|
String duration = "0";
|
||||||
|
Object expectedDurations = context.getProperty("expectedDuration");
|
||||||
|
if (expectedDurations instanceof List) {
|
||||||
|
Object o = ((List<?>) expectedDurations).get(0);// 预计执行时间已经计算好
|
||||||
|
duration = o.toString() + "000"; // 转成 ms
|
||||||
|
}
|
||||||
|
if (this.containsIterationThread) {
|
||||||
|
duration = Integer.MAX_VALUE + ""; // 如果包含了按照迭代次数的线程组,预计执行时间很长
|
||||||
|
}
|
||||||
KafkaProperties kafkaProperties = CommonBeanFactory.getBean(KafkaProperties.class);
|
KafkaProperties kafkaProperties = CommonBeanFactory.getBean(KafkaProperties.class);
|
||||||
Document document = backendListener.getOwnerDocument();
|
Document document = backendListener.getOwnerDocument();
|
||||||
// 清空child
|
// 清空child
|
||||||
|
@ -586,7 +601,7 @@ public class JmeterDocumentParser implements DocumentParser {
|
||||||
collectionProp.appendChild(createKafkaProp(document, "test.name", context.getTestName()));
|
collectionProp.appendChild(createKafkaProp(document, "test.name", context.getTestName()));
|
||||||
collectionProp.appendChild(createKafkaProp(document, "test.startTime", context.getStartTime().toString()));
|
collectionProp.appendChild(createKafkaProp(document, "test.startTime", context.getStartTime().toString()));
|
||||||
collectionProp.appendChild(createKafkaProp(document, "test.reportId", context.getReportId()));
|
collectionProp.appendChild(createKafkaProp(document, "test.reportId", context.getReportId()));
|
||||||
collectionProp.appendChild(createKafkaProp(document, "test.expectedDuration", (String) context.getProperty("expectedDuration")));
|
collectionProp.appendChild(createKafkaProp(document, "test.expectedDuration", duration));// ms
|
||||||
collectionProp.appendChild(createKafkaProp(document, "test.expectedDelayEndTime", kafkaProperties.getExpectedDelayEndTime())); // 30s
|
collectionProp.appendChild(createKafkaProp(document, "test.expectedDelayEndTime", kafkaProperties.getExpectedDelayEndTime())); // 30s
|
||||||
|
|
||||||
elementProp.appendChild(collectionProp);
|
elementProp.appendChild(collectionProp);
|
||||||
|
@ -612,15 +627,6 @@ public class JmeterDocumentParser implements DocumentParser {
|
||||||
listenerParent = listenerParent.getNextSibling();
|
listenerParent = listenerParent.getNextSibling();
|
||||||
}
|
}
|
||||||
|
|
||||||
NodeList childNodes = listenerParent.getChildNodes();
|
|
||||||
for (int i = 0, l = childNodes.getLength(); i < l; i++) {
|
|
||||||
Node item = childNodes.item(i);
|
|
||||||
if (nodeNameEquals(item, BACKEND_LISTENER)) {
|
|
||||||
// 如果已经存在,不再添加
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add class name
|
// add class name
|
||||||
Element backendListener = document.createElement(BACKEND_LISTENER);
|
Element backendListener = document.createElement(BACKEND_LISTENER);
|
||||||
backendListener.setAttribute("guiclass", "BackendListenerGui");
|
backendListener.setAttribute("guiclass", "BackendListenerGui");
|
||||||
|
@ -729,8 +735,6 @@ public class JmeterDocumentParser implements DocumentParser {
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// 处理预计结束时间
|
|
||||||
processExpectedDuration(duration);
|
|
||||||
|
|
||||||
threadGroup.setAttribute("enabled", enabled);
|
threadGroup.setAttribute("enabled", enabled);
|
||||||
if (BooleanUtils.toBoolean(deleted)) {
|
if (BooleanUtils.toBoolean(deleted)) {
|
||||||
|
@ -824,30 +828,18 @@ public class JmeterDocumentParser implements DocumentParser {
|
||||||
enabled = o.toString();
|
enabled = o.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
Object durations = context.getProperty("duration");
|
|
||||||
String duration = "2";
|
|
||||||
if (durations instanceof List) {
|
|
||||||
Object o = ((List<?>) durations).get(0);
|
|
||||||
((List<?>) durations).remove(0);
|
|
||||||
duration = o.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case "M":
|
case "M":
|
||||||
duration = String.valueOf(Long.parseLong(duration) * 60);
|
|
||||||
hold = String.valueOf(Long.parseLong(hold) * 60);
|
hold = String.valueOf(Long.parseLong(hold) * 60);
|
||||||
rampUp = String.valueOf(Long.parseLong(rampUp) * 60);
|
rampUp = String.valueOf(Long.parseLong(rampUp) * 60);
|
||||||
break;
|
break;
|
||||||
case "H":
|
case "H":
|
||||||
duration = String.valueOf(Long.parseLong(duration) * 60 * 60);
|
|
||||||
hold = String.valueOf(Long.parseLong(hold) * 60 * 60);
|
hold = String.valueOf(Long.parseLong(hold) * 60 * 60);
|
||||||
rampUp = String.valueOf(Long.parseLong(rampUp) * 60 * 60);
|
rampUp = String.valueOf(Long.parseLong(rampUp) * 60 * 60);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// 处理预计结束时间
|
|
||||||
processExpectedDuration(duration);
|
|
||||||
|
|
||||||
threadGroup.setAttribute("enabled", enabled);
|
threadGroup.setAttribute("enabled", enabled);
|
||||||
if (BooleanUtils.toBoolean(deleted)) {
|
if (BooleanUtils.toBoolean(deleted)) {
|
||||||
|
@ -868,26 +860,6 @@ public class JmeterDocumentParser implements DocumentParser {
|
||||||
threadGroup.appendChild(createStringProp(document, "Unit", "S"));
|
threadGroup.appendChild(createStringProp(document, "Unit", "S"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void processExpectedDuration(String duration) {
|
|
||||||
Long d = Long.parseLong(duration);
|
|
||||||
Object serialize = context.getProperty("serialize_threadgroups");
|
|
||||||
String expectedDuration = (String) context.getProperty("expectedDuration");
|
|
||||||
if (StringUtils.isBlank(expectedDuration)) {
|
|
||||||
expectedDuration = "0";
|
|
||||||
}
|
|
||||||
long durationTime = Long.parseLong(expectedDuration);
|
|
||||||
|
|
||||||
if (BooleanUtils.toBoolean((String) serialize)) {
|
|
||||||
// 顺序执行线程组
|
|
||||||
context.addProperty("expectedDuration", String.valueOf(durationTime + d * 1000));
|
|
||||||
} else {
|
|
||||||
// 同时执行线程组
|
|
||||||
if (durationTime < d * 1000) {
|
|
||||||
context.addProperty("expectedDuration", String.valueOf(d * 1000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void processIterationThreadGroup(Element threadGroup) {
|
private void processIterationThreadGroup(Element threadGroup) {
|
||||||
// 检查 threadgroup 后面的hashtree是否为空
|
// 检查 threadgroup 后面的hashtree是否为空
|
||||||
Node hashTree = threadGroup.getNextSibling();
|
Node hashTree = threadGroup.getNextSibling();
|
||||||
|
@ -956,10 +928,6 @@ public class JmeterDocumentParser implements DocumentParser {
|
||||||
threadGroup.appendChild(createStringProp(document, "ThreadGroup.duration", "10"));
|
threadGroup.appendChild(createStringProp(document, "ThreadGroup.duration", "10"));
|
||||||
threadGroup.appendChild(createStringProp(document, "ThreadGroup.delay", ""));
|
threadGroup.appendChild(createStringProp(document, "ThreadGroup.delay", ""));
|
||||||
threadGroup.appendChild(createBoolProp(document, "ThreadGroup.same_user_on_next_iteration", true));
|
threadGroup.appendChild(createBoolProp(document, "ThreadGroup.same_user_on_next_iteration", true));
|
||||||
|
|
||||||
// 处理预计结束时间, (按照迭代次数 * 线程数)s
|
|
||||||
String duration = String.valueOf(Long.parseLong(loops) * Long.parseLong(threads));
|
|
||||||
processExpectedDuration(duration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void processCheckoutTimer(Element element) {
|
private void processCheckoutTimer(Element element) {
|
||||||
|
|
|
@ -229,30 +229,6 @@ export default {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
stringToByte(str) {
|
|
||||||
var bytes = new Array();
|
|
||||||
var len, c;
|
|
||||||
len = str.length;
|
|
||||||
for (var i = 0; i < len; i++) {
|
|
||||||
c = str.charCodeAt(i);
|
|
||||||
if (c >= 0x010000 && c <= 0x10FFFF) {
|
|
||||||
bytes.push(((c >> 18) & 0x07) | 0xF0);
|
|
||||||
bytes.push(((c >> 12) & 0x3F) | 0x80);
|
|
||||||
bytes.push(((c >> 6) & 0x3F) | 0x80);
|
|
||||||
bytes.push((c & 0x3F) | 0x80);
|
|
||||||
} else if (c >= 0x000800 && c <= 0x00FFFF) {
|
|
||||||
bytes.push(((c >> 12) & 0x0F) | 0xE0);
|
|
||||||
bytes.push(((c >> 6) & 0x3F) | 0x80);
|
|
||||||
bytes.push((c & 0x3F) | 0x80);
|
|
||||||
} else if (c >= 0x000080 && c <= 0x0007FF) {
|
|
||||||
bytes.push(((c >> 6) & 0x1F) | 0xC0);
|
|
||||||
bytes.push((c & 0x3F) | 0x80);
|
|
||||||
} else {
|
|
||||||
bytes.push(c & 0xFF);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
},
|
|
||||||
cancel() {
|
cancel() {
|
||||||
this.$router.push({path: '/performance/test/all'})
|
this.$router.push({path: '/performance/test/all'})
|
||||||
},
|
},
|
||||||
|
|
|
@ -13,6 +13,9 @@
|
||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item :label="$t('load_test.serialize_threadgroups')">
|
||||||
|
<el-switch v-model="serializeThreadGroups"/>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
@ -177,6 +180,8 @@ import {findThreadGroup} from "@/business/components/performance/test/model/Thre
|
||||||
|
|
||||||
const HANDLER = "handler";
|
const HANDLER = "handler";
|
||||||
const THREAD_GROUP_TYPE = "tgType";
|
const THREAD_GROUP_TYPE = "tgType";
|
||||||
|
const EXPECTED_DURATION = "expectedDuration";
|
||||||
|
const SERIALIZE_THREAD_GROUPS = "serializeThreadGroups";
|
||||||
const TARGET_LEVEL = "TargetLevel";
|
const TARGET_LEVEL = "TargetLevel";
|
||||||
const RAMP_UP = "RampUp";
|
const RAMP_UP = "RampUp";
|
||||||
const ITERATE_RAMP_UP = "iterateRampUpTime";
|
const ITERATE_RAMP_UP = "iterateRampUpTime";
|
||||||
|
@ -231,6 +236,7 @@ export default {
|
||||||
threadGroups: [],
|
threadGroups: [],
|
||||||
resourcePoolResourceLength: 1,
|
resourcePoolResourceLength: 1,
|
||||||
maxThreadNumbers: 5000,
|
maxThreadNumbers: 5000,
|
||||||
|
serializeThreadGroups: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -317,6 +323,9 @@ export default {
|
||||||
case THREAD_GROUP_TYPE:
|
case THREAD_GROUP_TYPE:
|
||||||
this.threadGroups[i].tgType = item.value;
|
this.threadGroups[i].tgType = item.value;
|
||||||
break;
|
break;
|
||||||
|
case SERIALIZE_THREAD_GROUPS:
|
||||||
|
this.serializeThreadGroups = item.value;// 所有的线程组值一样
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -639,9 +648,52 @@ export default {
|
||||||
}
|
}
|
||||||
return this.$t('schedule.cron.seconds');
|
return this.$t('schedule.cron.seconds');
|
||||||
},
|
},
|
||||||
|
calculateDuration() {
|
||||||
|
let expectedDuration = 0;
|
||||||
|
for (let i = 0; i < this.threadGroups.length; i++) {
|
||||||
|
if (this.serializeThreadGroups) {
|
||||||
|
switch (this.threadGroups[i].unit) {
|
||||||
|
case "S":
|
||||||
|
expectedDuration += this.threadGroups[i].duration;
|
||||||
|
break;
|
||||||
|
case "M":
|
||||||
|
expectedDuration += this.threadGroups[i].duration * 60;
|
||||||
|
break;
|
||||||
|
case "H":
|
||||||
|
expectedDuration += this.threadGroups[i].duration * 60 * 60;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let tmp = 0;
|
||||||
|
switch (this.threadGroups[i].unit) {
|
||||||
|
case "S":
|
||||||
|
tmp = this.threadGroups[i].duration;
|
||||||
|
break;
|
||||||
|
case "M":
|
||||||
|
tmp = this.threadGroups[i].duration * 60;
|
||||||
|
break;
|
||||||
|
case "H":
|
||||||
|
tmp = this.threadGroups[i].duration * 60 * 60;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (expectedDuration < tmp) {
|
||||||
|
expectedDuration = tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expectedDuration;
|
||||||
|
},
|
||||||
convertProperty() {
|
convertProperty() {
|
||||||
/// todo:下面4个属性是jmeter ConcurrencyThreadGroup plugin的属性,这种硬编码不太好吧,在哪能转换这种属性?
|
/// todo:下面4个属性是jmeter ConcurrencyThreadGroup plugin的属性,这种硬编码不太好吧,在哪能转换这种属性?
|
||||||
let result = [];
|
let result = [];
|
||||||
|
// 先计算执行时间
|
||||||
|
let expectedDuration = this.calculateDuration();
|
||||||
|
|
||||||
|
// 再组织数据
|
||||||
for (let i = 0; i < this.threadGroups.length; i++) {
|
for (let i = 0; i < this.threadGroups.length; i++) {
|
||||||
result.push([
|
result.push([
|
||||||
{key: HANDLER, value: this.threadGroups[i].handler},
|
{key: HANDLER, value: this.threadGroups[i].handler},
|
||||||
|
@ -659,8 +711,11 @@ export default {
|
||||||
{key: ENABLED, value: this.threadGroups[i].enabled},
|
{key: ENABLED, value: this.threadGroups[i].enabled},
|
||||||
{key: DELETED, value: this.threadGroups[i].deleted},
|
{key: DELETED, value: this.threadGroups[i].deleted},
|
||||||
{key: THREAD_GROUP_TYPE, value: this.threadGroups[i].tgType},
|
{key: THREAD_GROUP_TYPE, value: this.threadGroups[i].tgType},
|
||||||
|
{key: EXPECTED_DURATION, value: expectedDuration},
|
||||||
|
{key: SERIALIZE_THREAD_GROUPS, value: this.serializeThreadGroups},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -523,6 +523,7 @@ export default {
|
||||||
response_timeout: 'Timeout to response',
|
response_timeout: 'Timeout to response',
|
||||||
custom_http_code: 'Custom HTTP response success status code',
|
custom_http_code: 'Custom HTTP response success status code',
|
||||||
separated_by_commas: 'Separated by commas',
|
separated_by_commas: 'Separated by commas',
|
||||||
|
serialize_threadgroups:'Whether the scene is executed sequentially',
|
||||||
create: 'Create Test',
|
create: 'Create Test',
|
||||||
select_resource_pool: 'Please Select Resource Pool',
|
select_resource_pool: 'Please Select Resource Pool',
|
||||||
resource_pool_is_null: 'Resource Pool is empty',
|
resource_pool_is_null: 'Resource Pool is empty',
|
||||||
|
|
|
@ -523,6 +523,7 @@ export default {
|
||||||
create: '创建测试',
|
create: '创建测试',
|
||||||
run: '一键运行',
|
run: '一键运行',
|
||||||
select_resource_pool: '请选择资源池',
|
select_resource_pool: '请选择资源池',
|
||||||
|
serialize_threadgroups: '场景是否顺序执行',
|
||||||
resource_pool_is_null: '资源池为空',
|
resource_pool_is_null: '资源池为空',
|
||||||
download_log_file: '下载完整日志文件',
|
download_log_file: '下载完整日志文件',
|
||||||
pressure_prediction_chart: '压力预估图',
|
pressure_prediction_chart: '压力预估图',
|
||||||
|
|
|
@ -523,6 +523,7 @@ export default {
|
||||||
create: '創建測試',
|
create: '創建測試',
|
||||||
run: '壹鍵運行',
|
run: '壹鍵運行',
|
||||||
select_resource_pool: '請選擇資源池',
|
select_resource_pool: '請選擇資源池',
|
||||||
|
serialize_threadgroups: '場景是否順序執行',
|
||||||
resource_pool_is_null: '資源池為空',
|
resource_pool_is_null: '資源池為空',
|
||||||
download_log_file: '下載完整日誌文件',
|
download_log_file: '下載完整日誌文件',
|
||||||
pressure_prediction_chart: '壓力預估圖',
|
pressure_prediction_chart: '壓力預估圖',
|
||||||
|
|
Loading…
Reference in New Issue