committed by
Andrew Shvayka
33 changed files with 747 additions and 200 deletions
@ -0,0 +1,59 @@ |
|||
/** |
|||
* Copyright © 2016-2020 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.subscription; |
|||
|
|||
import lombok.Builder; |
|||
import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; |
|||
import org.thingsboard.server.common.data.alarm.AlarmSeverity; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.service.telemetry.sub.AlarmSubscriptionUpdate; |
|||
import org.thingsboard.server.service.telemetry.sub.TsSubscriptionUpdate; |
|||
|
|||
import java.util.List; |
|||
import java.util.function.BiConsumer; |
|||
|
|||
public class TbAlarmsSubscription extends TbSubscription<AlarmSubscriptionUpdate> { |
|||
|
|||
private final long ts; |
|||
private final List<String> typeList; |
|||
private final List<AlarmSearchStatus> statusList; |
|||
private final List<AlarmSeverity> severityList; |
|||
private final boolean searchPropagatedAlarms; |
|||
|
|||
@Builder |
|||
public TbAlarmsSubscription(String serviceId, String sessionId, int subscriptionId, TenantId tenantId, EntityId entityId, |
|||
TbSubscriptionType type, BiConsumer<String, AlarmSubscriptionUpdate> updateConsumer, |
|||
long ts, List<String> typeList, List<AlarmSearchStatus> statusList, |
|||
List<AlarmSeverity> severityList, boolean searchPropagatedAlarms) { |
|||
super(serviceId, sessionId, subscriptionId, tenantId, entityId, type, updateConsumer); |
|||
this.ts = ts; |
|||
this.typeList = typeList; |
|||
this.statusList = statusList; |
|||
this.severityList = severityList; |
|||
this.searchPropagatedAlarms = searchPropagatedAlarms; |
|||
} |
|||
|
|||
@Override |
|||
public boolean equals(Object o) { |
|||
return super.equals(o); |
|||
} |
|||
|
|||
@Override |
|||
public int hashCode() { |
|||
return super.hashCode(); |
|||
} |
|||
} |
|||
@ -0,0 +1,120 @@ |
|||
/** |
|||
* Copyright © 2016-2020 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.telemetry; |
|||
|
|||
import com.google.common.util.concurrent.FutureCallback; |
|||
import com.google.common.util.concurrent.Futures; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.context.ApplicationListener; |
|||
import org.springframework.context.event.EventListener; |
|||
import org.springframework.stereotype.Service; |
|||
import org.thingsboard.common.util.ThingsBoardThreadFactory; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.kv.AttributeKvEntry; |
|||
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; |
|||
import org.thingsboard.server.common.data.kv.BooleanDataEntry; |
|||
import org.thingsboard.server.common.data.kv.DoubleDataEntry; |
|||
import org.thingsboard.server.common.data.kv.LongDataEntry; |
|||
import org.thingsboard.server.common.data.kv.StringDataEntry; |
|||
import org.thingsboard.server.common.data.kv.TsKvEntry; |
|||
import org.thingsboard.server.common.msg.queue.ServiceType; |
|||
import org.thingsboard.server.common.msg.queue.TbCallback; |
|||
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; |
|||
import org.thingsboard.server.dao.attributes.AttributesService; |
|||
import org.thingsboard.server.dao.timeseries.TimeseriesService; |
|||
import org.thingsboard.server.gen.transport.TransportProtos; |
|||
import org.thingsboard.server.queue.discovery.PartitionChangeEvent; |
|||
import org.thingsboard.server.queue.discovery.PartitionService; |
|||
import org.thingsboard.server.service.queue.TbClusterService; |
|||
import org.thingsboard.server.service.subscription.SubscriptionManagerService; |
|||
import org.thingsboard.server.service.subscription.TbSubscriptionUtils; |
|||
|
|||
import javax.annotation.Nullable; |
|||
import javax.annotation.PostConstruct; |
|||
import javax.annotation.PreDestroy; |
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
import java.util.concurrent.ConcurrentHashMap; |
|||
import java.util.concurrent.ExecutorService; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.function.Consumer; |
|||
|
|||
/** |
|||
* Created by ashvayka on 27.03.18. |
|||
*/ |
|||
@Slf4j |
|||
public abstract class AbstractSubscriptionService implements ApplicationListener<PartitionChangeEvent> { |
|||
|
|||
protected final Set<TopicPartitionInfo> currentPartitions = ConcurrentHashMap.newKeySet(); |
|||
|
|||
protected final TbClusterService clusterService; |
|||
protected final PartitionService partitionService; |
|||
protected Optional<SubscriptionManagerService> subscriptionManagerService; |
|||
|
|||
protected ExecutorService wsCallBackExecutor; |
|||
|
|||
public AbstractSubscriptionService(TbClusterService clusterService, |
|||
PartitionService partitionService) { |
|||
this.clusterService = clusterService; |
|||
this.partitionService = partitionService; |
|||
} |
|||
|
|||
@Autowired(required = false) |
|||
public void setSubscriptionManagerService(Optional<SubscriptionManagerService> subscriptionManagerService) { |
|||
this.subscriptionManagerService = subscriptionManagerService; |
|||
} |
|||
|
|||
abstract String getExecutorPrefix(); |
|||
|
|||
@PostConstruct |
|||
public void initExecutor() { |
|||
wsCallBackExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName(getExecutorPrefix() + "-service-ws-callback")); |
|||
} |
|||
|
|||
@PreDestroy |
|||
public void shutdownExecutor() { |
|||
if (wsCallBackExecutor != null) { |
|||
wsCallBackExecutor.shutdownNow(); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
@EventListener(PartitionChangeEvent.class) |
|||
public void onApplicationEvent(PartitionChangeEvent partitionChangeEvent) { |
|||
if (ServiceType.TB_CORE.equals(partitionChangeEvent.getServiceType())) { |
|||
currentPartitions.clear(); |
|||
currentPartitions.addAll(partitionChangeEvent.getPartitions()); |
|||
} |
|||
} |
|||
|
|||
protected void addWsCallback(ListenableFuture<List<Void>> saveFuture, Consumer<Void> callback) { |
|||
Futures.addCallback(saveFuture, new FutureCallback<List<Void>>() { |
|||
@Override |
|||
public void onSuccess(@Nullable List<Void> result) { |
|||
callback.accept(null); |
|||
} |
|||
|
|||
@Override |
|||
public void onFailure(Throwable t) { |
|||
} |
|||
}, wsCallBackExecutor); |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
/** |
|||
* Copyright © 2016-2020 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.telemetry; |
|||
|
|||
import org.springframework.context.ApplicationListener; |
|||
import org.thingsboard.rule.engine.api.RuleEngineAlarmService; |
|||
import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; |
|||
import org.thingsboard.server.queue.discovery.PartitionChangeEvent; |
|||
|
|||
/** |
|||
* Created by ashvayka on 27.03.18. |
|||
*/ |
|||
public interface AlarmSubscriptionService extends RuleEngineAlarmService, ApplicationListener<PartitionChangeEvent> { |
|||
|
|||
} |
|||
@ -0,0 +1,155 @@ |
|||
/** |
|||
* Copyright © 2016-2020 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.telemetry; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.google.common.util.concurrent.FutureCallback; |
|||
import com.google.common.util.concurrent.Futures; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.context.event.EventListener; |
|||
import org.springframework.stereotype.Service; |
|||
import org.thingsboard.common.util.ThingsBoardThreadFactory; |
|||
import org.thingsboard.server.common.data.alarm.Alarm; |
|||
import org.thingsboard.server.common.data.alarm.AlarmInfo; |
|||
import org.thingsboard.server.common.data.alarm.AlarmQuery; |
|||
import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; |
|||
import org.thingsboard.server.common.data.alarm.AlarmSeverity; |
|||
import org.thingsboard.server.common.data.alarm.AlarmStatus; |
|||
import org.thingsboard.server.common.data.id.AlarmId; |
|||
import org.thingsboard.server.common.data.id.CustomerId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.kv.AttributeKvEntry; |
|||
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; |
|||
import org.thingsboard.server.common.data.kv.BooleanDataEntry; |
|||
import org.thingsboard.server.common.data.kv.DoubleDataEntry; |
|||
import org.thingsboard.server.common.data.kv.LongDataEntry; |
|||
import org.thingsboard.server.common.data.kv.StringDataEntry; |
|||
import org.thingsboard.server.common.data.kv.TsKvEntry; |
|||
import org.thingsboard.server.common.data.page.PageData; |
|||
import org.thingsboard.server.common.data.query.AlarmData; |
|||
import org.thingsboard.server.common.data.query.AlarmDataPageLink; |
|||
import org.thingsboard.server.common.msg.queue.ServiceType; |
|||
import org.thingsboard.server.common.msg.queue.TbCallback; |
|||
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; |
|||
import org.thingsboard.server.dao.alarm.AlarmService; |
|||
import org.thingsboard.server.dao.attributes.AttributesService; |
|||
import org.thingsboard.server.dao.timeseries.TimeseriesService; |
|||
import org.thingsboard.server.gen.transport.TransportProtos; |
|||
import org.thingsboard.server.queue.discovery.PartitionChangeEvent; |
|||
import org.thingsboard.server.queue.discovery.PartitionService; |
|||
import org.thingsboard.server.service.queue.TbClusterService; |
|||
import org.thingsboard.server.service.subscription.SubscriptionManagerService; |
|||
import org.thingsboard.server.service.subscription.TbSubscriptionUtils; |
|||
import org.thingsboard.server.service.telemetry.sub.AlarmSubscriptionUpdate; |
|||
|
|||
import javax.annotation.Nullable; |
|||
import javax.annotation.PostConstruct; |
|||
import javax.annotation.PreDestroy; |
|||
import java.util.Collection; |
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
import java.util.concurrent.ConcurrentHashMap; |
|||
import java.util.concurrent.ExecutorService; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.function.Consumer; |
|||
|
|||
/** |
|||
* Created by ashvayka on 27.03.18. |
|||
*/ |
|||
@Service |
|||
@Slf4j |
|||
public class DefaultAlarmSubscriptionService extends AbstractSubscriptionService implements AlarmSubscriptionService { |
|||
|
|||
private final AlarmService alarmService; |
|||
|
|||
public DefaultAlarmSubscriptionService(TbClusterService clusterService, |
|||
PartitionService partitionService, |
|||
AlarmService alarmService) { |
|||
super(clusterService, partitionService); |
|||
this.alarmService = alarmService; |
|||
} |
|||
|
|||
@Autowired(required = false) |
|||
public void setSubscriptionManagerService(Optional<SubscriptionManagerService> subscriptionManagerService) { |
|||
this.subscriptionManagerService = subscriptionManagerService; |
|||
} |
|||
|
|||
@Override |
|||
String getExecutorPrefix() { |
|||
return "alarm"; |
|||
} |
|||
|
|||
@Override |
|||
public Alarm createOrUpdateAlarm(Alarm alarm) { |
|||
//TODO 3.1: we also need a list of related entities if this is propagated alarm;
|
|||
Alarm result = alarmService.createOrUpdateAlarm(alarm); |
|||
List<EntityId> relatedEntities = Collections.singletonList(result.getOriginator()); |
|||
pushAlarmToSubService(result, relatedEntities); |
|||
return result; |
|||
} |
|||
|
|||
private void pushAlarmToSubService(Alarm result, List<EntityId> entityIds) { |
|||
wsCallBackExecutor.submit(() -> { |
|||
TenantId tenantId = result.getTenantId(); |
|||
for (EntityId entityId : entityIds) { |
|||
TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId); |
|||
if (currentPartitions.contains(tpi)) { |
|||
if (subscriptionManagerService.isPresent()) { |
|||
subscriptionManagerService.get().onAlarmUpdate(tenantId, entityId, result); |
|||
} else { |
|||
log.warn("Possible misconfiguration because subscriptionManagerService is null!"); |
|||
} |
|||
} else { |
|||
//TODO 3.1: cluster mode notification
|
|||
// TransportProtos.ToCoreMsg toCoreMsg = TbSubscriptionUtils.toTimeseriesUpdateProto(tenantId, entityId, ts);
|
|||
// clusterService.pushMsgToCore(tpi, entityId.getId(), toCoreMsg, null);
|
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
@Override |
|||
public Boolean deleteAlarm(TenantId tenantId, AlarmId alarmId) { |
|||
return alarmService.deleteAlarm(tenantId, alarmId); |
|||
} |
|||
|
|||
@Override |
|||
public ListenableFuture<Boolean> ackAlarm(TenantId tenantId, AlarmId alarmId, long ackTs) { |
|||
return alarmService.ackAlarm(tenantId, alarmId, ackTs); |
|||
} |
|||
|
|||
@Override |
|||
public ListenableFuture<Boolean> clearAlarm(TenantId tenantId, AlarmId alarmId, JsonNode details, long clearTs) { |
|||
return alarmService.clearAlarm(tenantId, alarmId, details, clearTs); |
|||
} |
|||
|
|||
@Override |
|||
public ListenableFuture<Alarm> findAlarmByIdAsync(TenantId tenantId, AlarmId alarmId) { |
|||
return alarmService.findAlarmByIdAsync(tenantId, alarmId); |
|||
} |
|||
|
|||
@Override |
|||
public ListenableFuture<Alarm> findLatestByOriginatorAndType(TenantId tenantId, EntityId originator, String type) { |
|||
return alarmService.findLatestByOriginatorAndType(tenantId, originator, type); |
|||
} |
|||
|
|||
|
|||
} |
|||
@ -0,0 +1,71 @@ |
|||
/** |
|||
* Copyright © 2016-2020 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.telemetry.sub; |
|||
|
|||
import org.thingsboard.server.common.data.alarm.Alarm; |
|||
import org.thingsboard.server.common.data.kv.TsKvEntry; |
|||
import org.thingsboard.server.common.data.query.AlarmData; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.TreeMap; |
|||
import java.util.stream.Collectors; |
|||
|
|||
public class AlarmSubscriptionUpdate { |
|||
|
|||
private int subscriptionId; |
|||
private int errorCode; |
|||
private String errorMsg; |
|||
private Alarm alarm; |
|||
|
|||
public AlarmSubscriptionUpdate(int subscriptionId, Alarm alarm) { |
|||
super(); |
|||
this.subscriptionId = subscriptionId; |
|||
this.alarm = alarm; |
|||
} |
|||
|
|||
public AlarmSubscriptionUpdate(int subscriptionId, SubscriptionErrorCode errorCode) { |
|||
this(subscriptionId, errorCode, null); |
|||
} |
|||
|
|||
public AlarmSubscriptionUpdate(int subscriptionId, SubscriptionErrorCode errorCode, String errorMsg) { |
|||
super(); |
|||
this.subscriptionId = subscriptionId; |
|||
this.errorCode = errorCode.getCode(); |
|||
this.errorMsg = errorMsg != null ? errorMsg : errorCode.getDefaultMsg(); |
|||
} |
|||
|
|||
public int getSubscriptionId() { |
|||
return subscriptionId; |
|||
} |
|||
|
|||
|
|||
public int getErrorCode() { |
|||
return errorCode; |
|||
} |
|||
|
|||
public String getErrorMsg() { |
|||
return errorMsg; |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return "AlarmUpdate [subscriptionId=" + subscriptionId + ", errorCode=" + errorCode + ", errorMsg=" + errorMsg + ", alarm=" |
|||
+ alarm + "]"; |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
package org.thingsboard.server.dao.alarm; |
|||
|
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.alarm.Alarm; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Data |
|||
public class AlarmOperationResult { |
|||
private final Alarm alarm; |
|||
private final boolean successful; |
|||
private final List<EntityId> propagatedEntitiesList; |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
/** |
|||
* Copyright © 2016-2020 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.rule.engine.api; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.google.common.util.concurrent.FutureCallback; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import org.thingsboard.server.common.data.alarm.Alarm; |
|||
import org.thingsboard.server.common.data.alarm.AlarmInfo; |
|||
import org.thingsboard.server.common.data.alarm.AlarmQuery; |
|||
import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; |
|||
import org.thingsboard.server.common.data.alarm.AlarmSeverity; |
|||
import org.thingsboard.server.common.data.alarm.AlarmStatus; |
|||
import org.thingsboard.server.common.data.id.AlarmId; |
|||
import org.thingsboard.server.common.data.id.CustomerId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.kv.AttributeKvEntry; |
|||
import org.thingsboard.server.common.data.kv.TsKvEntry; |
|||
import org.thingsboard.server.common.data.page.PageData; |
|||
import org.thingsboard.server.common.data.query.AlarmData; |
|||
import org.thingsboard.server.common.data.query.AlarmDataPageLink; |
|||
|
|||
import java.util.Collection; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* Created by ashvayka on 02.04.18. |
|||
*/ |
|||
public interface RuleEngineAlarmService { |
|||
|
|||
Alarm createOrUpdateAlarm(Alarm alarm); |
|||
|
|||
Boolean deleteAlarm(TenantId tenantId, AlarmId alarmId); |
|||
|
|||
ListenableFuture<Boolean> ackAlarm(TenantId tenantId, AlarmId alarmId, long ackTs); |
|||
|
|||
ListenableFuture<Boolean> clearAlarm(TenantId tenantId, AlarmId alarmId, JsonNode details, long clearTs); |
|||
|
|||
ListenableFuture<Alarm> findAlarmByIdAsync(TenantId tenantId, AlarmId alarmId); |
|||
|
|||
ListenableFuture<Alarm> findLatestByOriginatorAndType(TenantId tenantId, EntityId originator, String type); |
|||
|
|||
} |
|||
Loading…
Reference in new issue