Merge pull request #12652 from dashevchenko/alarmCountQueryFilters
Entity filters for AlarmCountQuery
This commit is contained in:
		
						commit
						2363dc8edf
					
				@ -33,7 +33,7 @@ import org.springframework.stereotype.Service;
 | 
			
		||||
import org.springframework.web.socket.CloseStatus;
 | 
			
		||||
import org.thingsboard.common.util.ThingsBoardExecutors;
 | 
			
		||||
import org.thingsboard.common.util.ThingsBoardThreadFactory;
 | 
			
		||||
import org.thingsboard.server.common.data.alarm.AlarmInfo;
 | 
			
		||||
import org.thingsboard.server.common.data.id.EntityId;
 | 
			
		||||
import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
 | 
			
		||||
import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
 | 
			
		||||
import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult;
 | 
			
		||||
@ -41,7 +41,6 @@ import org.thingsboard.server.common.data.kv.TsKvEntry;
 | 
			
		||||
import org.thingsboard.server.common.data.page.PageData;
 | 
			
		||||
import org.thingsboard.server.common.data.query.AlarmDataQuery;
 | 
			
		||||
import org.thingsboard.server.common.data.query.ComparisonTsValue;
 | 
			
		||||
import org.thingsboard.server.common.data.query.OriginatorAlarmFilter;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityData;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityDataQuery;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityKey;
 | 
			
		||||
@ -55,17 +54,16 @@ import org.thingsboard.server.dao.timeseries.TimeseriesService;
 | 
			
		||||
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
 | 
			
		||||
import org.thingsboard.server.queue.util.TbCoreComponent;
 | 
			
		||||
import org.thingsboard.server.service.executors.DbCallbackExecutorService;
 | 
			
		||||
import org.thingsboard.server.service.security.model.SecurityUser;
 | 
			
		||||
import org.thingsboard.server.service.ws.WebSocketService;
 | 
			
		||||
import org.thingsboard.server.service.ws.WebSocketSessionRef;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.AggHistoryCmd;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.AggKey;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.AggTimeSeriesCmd;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmCountCmd;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmCountUpdate;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmDataCmd;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmDataUpdate;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmStatusCmd;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.CmdUpdate;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountCmd;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataCmd;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataUpdate;
 | 
			
		||||
@ -74,7 +72,6 @@ import org.thingsboard.server.service.ws.telemetry.cmd.v2.GetTsCmd;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.LatestValueCmd;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.TimeSeriesCmd;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.UnsubscribeCmd;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.sub.AlarmSubscriptionUpdate;
 | 
			
		||||
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Arrays;
 | 
			
		||||
@ -83,7 +80,6 @@ import java.util.LinkedHashSet;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Map;
 | 
			
		||||
import java.util.Set;
 | 
			
		||||
import java.util.UUID;
 | 
			
		||||
import java.util.concurrent.ConcurrentHashMap;
 | 
			
		||||
import java.util.concurrent.ConcurrentMap;
 | 
			
		||||
import java.util.concurrent.ExecutionException;
 | 
			
		||||
@ -430,13 +426,23 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc
 | 
			
		||||
            long start = System.currentTimeMillis();
 | 
			
		||||
            ctx.fetchData();
 | 
			
		||||
            long end = System.currentTimeMillis();
 | 
			
		||||
            stats.getAlarmQueryInvocationCnt().incrementAndGet();
 | 
			
		||||
            stats.getAlarmQueryTimeSpent().addAndGet(end - start);
 | 
			
		||||
            TbAlarmCountSubCtx finalCtx = ctx;
 | 
			
		||||
            ScheduledFuture<?> task = scheduler.scheduleWithFixedDelay(
 | 
			
		||||
                    () -> refreshDynamicQuery(finalCtx),
 | 
			
		||||
                    dynamicPageLinkRefreshInterval, dynamicPageLinkRefreshInterval, TimeUnit.SECONDS);
 | 
			
		||||
            finalCtx.setRefreshTask(task);
 | 
			
		||||
            stats.getRegularQueryInvocationCnt().incrementAndGet();
 | 
			
		||||
            stats.getRegularQueryTimeSpent().addAndGet(end - start);
 | 
			
		||||
            Set<EntityId> entitiesIds = ctx.getEntitiesIds();
 | 
			
		||||
            ctx.cancelTasks();
 | 
			
		||||
            ctx.clearAlarmSubscriptions();
 | 
			
		||||
            if (entitiesIds != null && entitiesIds.isEmpty()) {
 | 
			
		||||
                AlarmCountUpdate update = new AlarmCountUpdate(cmd.getCmdId(), 0);
 | 
			
		||||
                ctx.sendWsMsg(update);
 | 
			
		||||
            } else {
 | 
			
		||||
                ctx.doFetchAlarmCount();
 | 
			
		||||
                ctx.createAlarmSubscriptions();
 | 
			
		||||
                TbAlarmCountSubCtx finalCtx = ctx;
 | 
			
		||||
                ScheduledFuture<?> task = scheduler.scheduleWithFixedDelay(
 | 
			
		||||
                        () -> refreshDynamicQuery(finalCtx),
 | 
			
		||||
                        dynamicPageLinkRefreshInterval, dynamicPageLinkRefreshInterval, TimeUnit.SECONDS);
 | 
			
		||||
                finalCtx.setRefreshTask(task);
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            log.debug("[{}][{}] Received duplicate command: {}", session.getSessionId(), cmd.getCmdId(), cmd);
 | 
			
		||||
        }
 | 
			
		||||
@ -555,7 +561,7 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc
 | 
			
		||||
    private TbAlarmCountSubCtx createSubCtx(WebSocketSessionRef sessionRef, AlarmCountCmd cmd) {
 | 
			
		||||
        Map<Integer, TbAbstractSubCtx> sessionSubs = subscriptionsBySessionId.computeIfAbsent(sessionRef.getSessionId(), k -> new ConcurrentHashMap<>());
 | 
			
		||||
        TbAlarmCountSubCtx ctx = new TbAlarmCountSubCtx(serviceId, wsService, entityService, localSubscriptionService,
 | 
			
		||||
                attributesService, stats, alarmService, sessionRef, cmd.getCmdId());
 | 
			
		||||
                attributesService, stats, alarmService, sessionRef, cmd.getCmdId(), maxEntitiesPerAlarmSubscription, maxAlarmQueriesPerRefreshInterval);
 | 
			
		||||
        if (cmd.getQuery() != null) {
 | 
			
		||||
            ctx.setAndResolveQuery(cmd.getQuery());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -19,49 +19,152 @@ import lombok.Getter;
 | 
			
		||||
import lombok.Setter;
 | 
			
		||||
import lombok.ToString;
 | 
			
		||||
import lombok.extern.slf4j.Slf4j;
 | 
			
		||||
import org.thingsboard.server.common.data.id.EntityId;
 | 
			
		||||
import org.thingsboard.server.common.data.page.PageData;
 | 
			
		||||
import org.thingsboard.server.common.data.query.AlarmCountQuery;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityData;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityDataPageLink;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityDataQuery;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityDataSortOrder;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityKey;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityKeyType;
 | 
			
		||||
import org.thingsboard.server.dao.alarm.AlarmService;
 | 
			
		||||
import org.thingsboard.server.dao.attributes.AttributesService;
 | 
			
		||||
import org.thingsboard.server.dao.entity.EntityService;
 | 
			
		||||
import org.thingsboard.server.dao.model.ModelConstants;
 | 
			
		||||
import org.thingsboard.server.service.ws.WebSocketService;
 | 
			
		||||
import org.thingsboard.server.service.ws.WebSocketSessionRef;
 | 
			
		||||
import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmCountUpdate;
 | 
			
		||||
 | 
			
		||||
import java.util.LinkedHashSet;
 | 
			
		||||
import java.util.Map;
 | 
			
		||||
import java.util.concurrent.ConcurrentHashMap;
 | 
			
		||||
 | 
			
		||||
@Slf4j
 | 
			
		||||
@ToString(callSuper = true)
 | 
			
		||||
public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx<AlarmCountQuery> {
 | 
			
		||||
 | 
			
		||||
    private final AlarmService alarmService;
 | 
			
		||||
 | 
			
		||||
    protected final Map<Integer, EntityId> subToEntityIdMap;
 | 
			
		||||
 | 
			
		||||
    @Getter
 | 
			
		||||
    private LinkedHashSet<EntityId> entitiesIds;
 | 
			
		||||
 | 
			
		||||
    private final int maxEntitiesPerAlarmSubscription;
 | 
			
		||||
 | 
			
		||||
    private final int maxAlarmQueriesPerRefreshInterval;
 | 
			
		||||
 | 
			
		||||
    @Getter
 | 
			
		||||
    @Setter
 | 
			
		||||
    private volatile int result;
 | 
			
		||||
 | 
			
		||||
    @Getter
 | 
			
		||||
    @Setter
 | 
			
		||||
    private boolean tooManyEntities;
 | 
			
		||||
 | 
			
		||||
    private int alarmCountInvocationAttempts;
 | 
			
		||||
 | 
			
		||||
    public TbAlarmCountSubCtx(String serviceId, WebSocketService wsService,
 | 
			
		||||
                              EntityService entityService, TbLocalSubscriptionService localSubscriptionService,
 | 
			
		||||
                              AttributesService attributesService, SubscriptionServiceStatistics stats, AlarmService alarmService,
 | 
			
		||||
                              WebSocketSessionRef sessionRef, int cmdId) {
 | 
			
		||||
                              WebSocketSessionRef sessionRef, int cmdId, int maxEntitiesPerAlarmSubscription, int maxAlarmQueriesPerRefreshInterval) {
 | 
			
		||||
        super(serviceId, wsService, entityService, localSubscriptionService, attributesService, stats, sessionRef, cmdId);
 | 
			
		||||
        this.alarmService = alarmService;
 | 
			
		||||
        this.subToEntityIdMap = new ConcurrentHashMap<>();
 | 
			
		||||
        this.maxEntitiesPerAlarmSubscription = maxEntitiesPerAlarmSubscription;
 | 
			
		||||
        this.maxAlarmQueriesPerRefreshInterval = maxAlarmQueriesPerRefreshInterval;
 | 
			
		||||
        this.entitiesIds = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void clearSubscriptions() {
 | 
			
		||||
        clearAlarmSubscriptions();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void fetchData() {
 | 
			
		||||
        result = (int) alarmService.countAlarmsByQuery(getTenantId(), getCustomerId(), query);
 | 
			
		||||
        sendWsMsg(new AlarmCountUpdate(cmdId, result));
 | 
			
		||||
        resetInvocationCounter();
 | 
			
		||||
        if (query.getEntityFilter() != null) {
 | 
			
		||||
            entitiesIds = new LinkedHashSet<>();
 | 
			
		||||
            log.trace("[{}] Fetching data: {}", cmdId, alarmCountInvocationAttempts);
 | 
			
		||||
            PageData<EntityData> data = entityService.findEntityDataByQuery(getTenantId(), getCustomerId(), buildEntityDataQuery());
 | 
			
		||||
            entitiesIds.clear();
 | 
			
		||||
            tooManyEntities = data.hasNext();
 | 
			
		||||
            for (EntityData entityData : data.getData()) {
 | 
			
		||||
                entitiesIds.add(entityData.getEntityId());
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    protected void update() {
 | 
			
		||||
        int newCount = (int) alarmService.countAlarmsByQuery(getTenantId(), getCustomerId(), query);
 | 
			
		||||
        if (newCount != result) {
 | 
			
		||||
            result = newCount;
 | 
			
		||||
            sendWsMsg(new AlarmCountUpdate(cmdId, result));
 | 
			
		||||
        }
 | 
			
		||||
        resetInvocationCounter();
 | 
			
		||||
        fetchAlarmCount();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public boolean isDynamic() {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void fetchAlarmCount() {
 | 
			
		||||
        alarmCountInvocationAttempts++;
 | 
			
		||||
        log.trace("[{}] Fetching alarms: {}", cmdId, alarmCountInvocationAttempts);
 | 
			
		||||
        if (alarmCountInvocationAttempts <= maxAlarmQueriesPerRefreshInterval) {
 | 
			
		||||
            int newCount = (int) alarmService.countAlarmsByQuery(getTenantId(), getCustomerId(), query, entitiesIds);
 | 
			
		||||
            if (newCount != result) {
 | 
			
		||||
                result = newCount;
 | 
			
		||||
                sendWsMsg(new AlarmCountUpdate(cmdId, result));
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            log.trace("[{}] Ignore alarm count fetch due to rate limit: [{}] of maximum [{}]", cmdId, alarmCountInvocationAttempts, maxAlarmQueriesPerRefreshInterval);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void doFetchAlarmCount() {
 | 
			
		||||
        result = (int) alarmService.countAlarmsByQuery(getTenantId(), getCustomerId(), query, entitiesIds);
 | 
			
		||||
        sendWsMsg(new AlarmCountUpdate(cmdId, result));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private EntityDataQuery buildEntityDataQuery() {
 | 
			
		||||
        EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null,
 | 
			
		||||
                new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY)));
 | 
			
		||||
        return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void resetInvocationCounter() {
 | 
			
		||||
        alarmCountInvocationAttempts = 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void createAlarmSubscriptions() {
 | 
			
		||||
        for (EntityId entityId : entitiesIds) {
 | 
			
		||||
            createAlarmSubscriptionForEntity(entityId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void createAlarmSubscriptionForEntity(EntityId entityId) {
 | 
			
		||||
        int subIdx = sessionRef.getSessionSubIdSeq().incrementAndGet();
 | 
			
		||||
        subToEntityIdMap.put(subIdx, entityId);
 | 
			
		||||
        log.trace("[{}][{}][{}] Creating alarms subscription for [{}] ", serviceId, cmdId, subIdx, entityId);
 | 
			
		||||
        TbAlarmsSubscription subscription = TbAlarmsSubscription.builder()
 | 
			
		||||
                .serviceId(serviceId)
 | 
			
		||||
                .sessionId(sessionRef.getSessionId())
 | 
			
		||||
                .subscriptionId(subIdx)
 | 
			
		||||
                .tenantId(sessionRef.getSecurityCtx().getTenantId())
 | 
			
		||||
                .entityId(entityId)
 | 
			
		||||
                .updateProcessor((sub, update) -> fetchAlarmCount())
 | 
			
		||||
                .build();
 | 
			
		||||
        localSubscriptionService.addSubscription(subscription, sessionRef);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void clearAlarmSubscriptions() {
 | 
			
		||||
        if (subToEntityIdMap != null) {
 | 
			
		||||
            for (Integer subId : subToEntityIdMap.keySet()) {
 | 
			
		||||
                localSubscriptionService.cancelSubscription(getTenantId(), getSessionId(), subId);
 | 
			
		||||
            }
 | 
			
		||||
            subToEntityIdMap.clear();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -324,6 +324,83 @@ public class WebsocketApiTest extends AbstractControllerTest {
 | 
			
		||||
        Assert.assertEquals(1, update.getCount());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    public void testAlarmCountWsCmdWithSingleEntityFilter() throws Exception {
 | 
			
		||||
        loginTenantAdmin();
 | 
			
		||||
 | 
			
		||||
        SingleEntityFilter singleEntityFilter = new SingleEntityFilter();
 | 
			
		||||
        singleEntityFilter.setSingleEntity(tenantId);
 | 
			
		||||
        AlarmCountQuery alarmCountQuery = new AlarmCountQuery(singleEntityFilter);
 | 
			
		||||
        AlarmCountCmd cmd1 = new AlarmCountCmd(1, alarmCountQuery);
 | 
			
		||||
 | 
			
		||||
        getWsClient().send(cmd1);
 | 
			
		||||
 | 
			
		||||
        AlarmCountUpdate update = getWsClient().parseAlarmCountReply(getWsClient().waitForReply());
 | 
			
		||||
        Assert.assertEquals(1, update.getCmdId());
 | 
			
		||||
        Assert.assertEquals(0, update.getCount());
 | 
			
		||||
 | 
			
		||||
        //create alarm, check count = 1
 | 
			
		||||
        getWsClient().registerWaitForUpdate();
 | 
			
		||||
 | 
			
		||||
        Alarm alarm = new Alarm();
 | 
			
		||||
        alarm.setOriginator(tenantId);
 | 
			
		||||
        alarm.setType("TEST ALARM");
 | 
			
		||||
        alarm.setSeverity(AlarmSeverity.WARNING);
 | 
			
		||||
        alarm = doPost("/api/alarm", alarm, Alarm.class);
 | 
			
		||||
 | 
			
		||||
        update = getWsClient().parseAlarmCountReply(getWsClient().waitForUpdate());
 | 
			
		||||
        Assert.assertEquals(1, update.getCmdId());
 | 
			
		||||
        Assert.assertEquals(1, update.getCount());
 | 
			
		||||
 | 
			
		||||
        // set wrong entity id in filter, check count = 0
 | 
			
		||||
        singleEntityFilter.setSingleEntity(tenantAdminUserId);
 | 
			
		||||
        AlarmCountCmd cmd3 = new AlarmCountCmd(2, alarmCountQuery);
 | 
			
		||||
 | 
			
		||||
        getWsClient().send(cmd3);
 | 
			
		||||
 | 
			
		||||
        update = getWsClient().parseAlarmCountReply(getWsClient().waitForReply());
 | 
			
		||||
        Assert.assertEquals(2, update.getCmdId());
 | 
			
		||||
        Assert.assertEquals(0, update.getCount());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    public void testAlarmCountWsCmdWithDeviceType() throws Exception {
 | 
			
		||||
        loginTenantAdmin();
 | 
			
		||||
 | 
			
		||||
        DeviceTypeFilter deviceTypeFilter = new DeviceTypeFilter();
 | 
			
		||||
        deviceTypeFilter.setDeviceTypes(List.of("default"));
 | 
			
		||||
        AlarmCountQuery alarmCountQuery = new AlarmCountQuery(deviceTypeFilter);
 | 
			
		||||
        AlarmCountCmd cmd1 = new AlarmCountCmd(1, alarmCountQuery);
 | 
			
		||||
 | 
			
		||||
        getWsClient().send(cmd1);
 | 
			
		||||
 | 
			
		||||
        AlarmCountUpdate update = getWsClient().parseAlarmCountReply(getWsClient().waitForReply());
 | 
			
		||||
        Assert.assertEquals(1, update.getCmdId());
 | 
			
		||||
        Assert.assertEquals(0, update.getCount());
 | 
			
		||||
 | 
			
		||||
        getWsClient().registerWaitForUpdate();
 | 
			
		||||
 | 
			
		||||
        Alarm alarm = new Alarm();
 | 
			
		||||
        alarm.setOriginator(device.getId());
 | 
			
		||||
        alarm.setType("TEST ALARM");
 | 
			
		||||
        alarm.setSeverity(AlarmSeverity.WARNING);
 | 
			
		||||
 | 
			
		||||
        alarm = doPost("/api/alarm", alarm, Alarm.class);
 | 
			
		||||
 | 
			
		||||
        update = getWsClient().parseAlarmCountReply(getWsClient().waitForUpdate());
 | 
			
		||||
        Assert.assertEquals(1, update.getCmdId());
 | 
			
		||||
        Assert.assertEquals(1, update.getCount());
 | 
			
		||||
 | 
			
		||||
        deviceTypeFilter.setDeviceTypes(List.of("non-existing"));
 | 
			
		||||
        AlarmCountCmd cmd3 = new AlarmCountCmd(3, alarmCountQuery);
 | 
			
		||||
 | 
			
		||||
        getWsClient().send(cmd3);
 | 
			
		||||
 | 
			
		||||
        update = getWsClient().parseAlarmCountReply(getWsClient().waitForReply());
 | 
			
		||||
        Assert.assertEquals(3, update.getCmdId());
 | 
			
		||||
        Assert.assertEquals(0, update.getCount());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    public void testAlarmStatusWsCmd() throws Exception {
 | 
			
		||||
        loginTenantAdmin();
 | 
			
		||||
@ -372,17 +449,18 @@ public class WebsocketApiTest extends AbstractControllerTest {
 | 
			
		||||
 | 
			
		||||
        doPost("/api/alarm", alarm2, Alarm.class);
 | 
			
		||||
 | 
			
		||||
        AlarmStatusUpdate alarmStatusUpdate3 = JacksonUtil.fromString(getWsClient().waitForReply(), AlarmStatusUpdate.class);
 | 
			
		||||
        AlarmStatusUpdate alarmStatusUpdate3 = JacksonUtil.fromString(getWsClient().waitForUpdate(), AlarmStatusUpdate.class);
 | 
			
		||||
        Assert.assertEquals(1, alarmStatusUpdate3.getCmdId());
 | 
			
		||||
        Assert.assertTrue(alarmStatusUpdate3.isActive());
 | 
			
		||||
 | 
			
		||||
        //change severity
 | 
			
		||||
        getWsClient().registerWaitForUpdate();
 | 
			
		||||
        alarm2.setSeverity(AlarmSeverity.MAJOR);
 | 
			
		||||
        Alarm updatedAlarm = doPost("/api/alarm", alarm2, Alarm.class);
 | 
			
		||||
        Assert.assertNotNull(updatedAlarm);
 | 
			
		||||
        Assert.assertEquals(AlarmSeverity.MAJOR, updatedAlarm.getSeverity());
 | 
			
		||||
 | 
			
		||||
        AlarmStatusUpdate alarmStatusUpdate4 = JacksonUtil.fromString(getWsClient().waitForReply(), AlarmStatusUpdate.class);
 | 
			
		||||
        AlarmStatusUpdate alarmStatusUpdate4 = JacksonUtil.fromString(getWsClient().waitForUpdate(), AlarmStatusUpdate.class);
 | 
			
		||||
        Assert.assertEquals(1, alarmStatusUpdate4.getCmdId());
 | 
			
		||||
        Assert.assertFalse(alarmStatusUpdate4.isActive());
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -118,6 +118,8 @@ public interface AlarmService extends EntityDaoService {
 | 
			
		||||
 | 
			
		||||
    long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query);
 | 
			
		||||
 | 
			
		||||
    long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query, Collection<EntityId> orderedEntityIds);
 | 
			
		||||
 | 
			
		||||
    PageData<EntitySubtype> findAlarmTypesByTenantId(TenantId tenantId, PageLink pageLink);
 | 
			
		||||
 | 
			
		||||
    List<UUID> findActiveOriginatorAlarms(TenantId tenantId, OriginatorAlarmFilter originatorAlarmFilter, int limit);
 | 
			
		||||
 | 
			
		||||
@ -17,7 +17,7 @@ package org.thingsboard.server.common.data.query;
 | 
			
		||||
 | 
			
		||||
import lombok.AllArgsConstructor;
 | 
			
		||||
import lombok.Builder;
 | 
			
		||||
import lombok.Getter;
 | 
			
		||||
import lombok.Data;
 | 
			
		||||
import lombok.NoArgsConstructor;
 | 
			
		||||
import lombok.ToString;
 | 
			
		||||
import org.thingsboard.server.common.data.alarm.AlarmSearchStatus;
 | 
			
		||||
@ -29,7 +29,7 @@ import java.util.List;
 | 
			
		||||
@Builder
 | 
			
		||||
@NoArgsConstructor
 | 
			
		||||
@AllArgsConstructor
 | 
			
		||||
@Getter
 | 
			
		||||
@Data
 | 
			
		||||
@ToString
 | 
			
		||||
public class AlarmCountQuery extends EntityCountQuery {
 | 
			
		||||
    private long startTs;
 | 
			
		||||
@ -40,4 +40,9 @@ public class AlarmCountQuery extends EntityCountQuery {
 | 
			
		||||
    private List<AlarmSeverity> severityList;
 | 
			
		||||
    private boolean searchPropagatedAlarms;
 | 
			
		||||
    private UserId assigneeId;
 | 
			
		||||
 | 
			
		||||
    public AlarmCountQuery(EntityFilter entityFilter) {
 | 
			
		||||
        super(entityFilter);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -106,7 +106,7 @@ public interface AlarmDao extends Dao<Alarm> {
 | 
			
		||||
 | 
			
		||||
    AlarmApiCallResult unassignAlarm(TenantId tenantId, AlarmId alarmId, long unassignTime);
 | 
			
		||||
 | 
			
		||||
    long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query);
 | 
			
		||||
    long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query, Collection<EntityId> orderedEntityIds);
 | 
			
		||||
 | 
			
		||||
    PageData<EntitySubtype> findTenantAlarmTypes(UUID tenantId, PageLink pageLink);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -351,8 +351,13 @@ public class BaseAlarmService extends AbstractCachedEntityService<TenantId, Page
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query) {
 | 
			
		||||
        return countAlarmsByQuery(tenantId, customerId, query, null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query, Collection<EntityId> orderedEntityIds) {
 | 
			
		||||
        validateId(tenantId, id -> INCORRECT_TENANT_ID + id);
 | 
			
		||||
        return alarmDao.countAlarmsByQuery(tenantId, customerId, query);
 | 
			
		||||
        return alarmDao.countAlarmsByQuery(tenantId, customerId, query, orderedEntityIds);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
 | 
			
		||||
@ -415,8 +415,8 @@ public class JpaAlarmDao extends JpaAbstractDao<AlarmEntity, Alarm> implements A
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query) {
 | 
			
		||||
        return alarmQueryRepository.countAlarmsByQuery(tenantId, customerId, query);
 | 
			
		||||
    public long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query, Collection<EntityId> orderedEntityIds) {
 | 
			
		||||
        return alarmQueryRepository.countAlarmsByQuery(tenantId, customerId, query, orderedEntityIds);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
 | 
			
		||||
@ -30,6 +30,6 @@ public interface AlarmQueryRepository {
 | 
			
		||||
    PageData<AlarmData> findAlarmDataByQueryForEntities(TenantId tenantId,
 | 
			
		||||
                                                        AlarmDataQuery query, Collection<EntityId> orderedEntityIds);
 | 
			
		||||
 | 
			
		||||
    long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query);
 | 
			
		||||
    long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query, Collection<EntityId> orderedEntityIds);
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -44,6 +44,7 @@ import java.util.HashMap;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Map;
 | 
			
		||||
import java.util.Objects;
 | 
			
		||||
import java.util.UUID;
 | 
			
		||||
import java.util.stream.Collectors;
 | 
			
		||||
 | 
			
		||||
@Repository
 | 
			
		||||
@ -314,25 +315,41 @@ public class DefaultAlarmQueryRepository implements AlarmQueryRepository {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query) {
 | 
			
		||||
    public long countAlarmsByQuery(TenantId tenantId, CustomerId customerId, AlarmCountQuery query, Collection<EntityId> orderedEntityIds) {
 | 
			
		||||
        QueryContext ctx = new QueryContext(new QuerySecurityContext(tenantId, null, EntityType.ALARM));
 | 
			
		||||
 | 
			
		||||
        if (query.isSearchPropagatedAlarms()) {
 | 
			
		||||
            ctx.append("select count(distinct(a.id)) from alarm_info a ");
 | 
			
		||||
            ctx.append(JOIN_ENTITY_ALARMS);
 | 
			
		||||
            ctx.append("where a.tenant_id = :tenantId and ea.tenant_id = :tenantId");
 | 
			
		||||
            ctx.addUuidParameter("tenantId", tenantId.getId());
 | 
			
		||||
            if (customerId != null && !customerId.isNullUid()) {
 | 
			
		||||
                ctx.append(" and a.customer_id = :customerId and ea.customer_id = :customerId");
 | 
			
		||||
                ctx.addUuidParameter("customerId", customerId.getId());
 | 
			
		||||
            if (orderedEntityIds != null) {
 | 
			
		||||
                if (orderedEntityIds.isEmpty()) {
 | 
			
		||||
                    return 0;
 | 
			
		||||
                }
 | 
			
		||||
                ctx.addUuidListParameter("entity_filter_entity_ids", orderedEntityIds.stream().map(EntityId::getId).collect(Collectors.toList()));
 | 
			
		||||
                ctx.append("where ea.entity_id in (:entity_filter_entity_ids)");
 | 
			
		||||
            } else {
 | 
			
		||||
                ctx.append("where a.tenant_id = :tenantId and ea.tenant_id = :tenantId");
 | 
			
		||||
                ctx.addUuidParameter("tenantId", tenantId.getId());
 | 
			
		||||
                if (customerId != null && !customerId.isNullUid()) {
 | 
			
		||||
                    ctx.append(" and a.customer_id = :customerId and ea.customer_id = :customerId");
 | 
			
		||||
                    ctx.addUuidParameter("customerId", customerId.getId());
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            ctx.append("select count(id) from alarm_info a ");
 | 
			
		||||
            ctx.append("where a.tenant_id = :tenantId");
 | 
			
		||||
            ctx.addUuidParameter("tenantId", tenantId.getId());
 | 
			
		||||
            if (customerId != null && !customerId.isNullUid()) {
 | 
			
		||||
                ctx.append(" and a.customer_id = :customerId");
 | 
			
		||||
                ctx.addUuidParameter("customerId", customerId.getId());
 | 
			
		||||
            if (orderedEntityIds != null) {
 | 
			
		||||
                if (orderedEntityIds.isEmpty()) {
 | 
			
		||||
                    return 0;
 | 
			
		||||
                }
 | 
			
		||||
                ctx.addUuidListParameter("entity_filter_entity_ids", orderedEntityIds.stream().map(EntityId::getId).collect(Collectors.toList()));
 | 
			
		||||
                ctx.append("where a.originator_id in (:entity_filter_entity_ids)");
 | 
			
		||||
            } else {
 | 
			
		||||
                ctx.append("where a.tenant_id = :tenantId");
 | 
			
		||||
                ctx.addUuidParameter("tenantId", tenantId.getId());
 | 
			
		||||
                if (customerId != null && !customerId.isNullUid()) {
 | 
			
		||||
                    ctx.append(" and a.customer_id = :customerId");
 | 
			
		||||
                    ctx.addUuidParameter("customerId", customerId.getId());
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -22,6 +22,7 @@ import org.springframework.beans.factory.annotation.Autowired;
 | 
			
		||||
import org.thingsboard.common.util.JacksonUtil;
 | 
			
		||||
import org.thingsboard.server.common.data.Customer;
 | 
			
		||||
import org.thingsboard.server.common.data.Device;
 | 
			
		||||
import org.thingsboard.server.common.data.EntityType;
 | 
			
		||||
import org.thingsboard.server.common.data.User;
 | 
			
		||||
import org.thingsboard.server.common.data.alarm.Alarm;
 | 
			
		||||
import org.thingsboard.server.common.data.alarm.AlarmApiCallResult;
 | 
			
		||||
@ -48,6 +49,7 @@ import org.thingsboard.server.common.data.query.DeviceTypeFilter;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityDataSortOrder;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityKey;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityKeyType;
 | 
			
		||||
import org.thingsboard.server.common.data.query.EntityListFilter;
 | 
			
		||||
import org.thingsboard.server.common.data.relation.EntityRelation;
 | 
			
		||||
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
 | 
			
		||||
import org.thingsboard.server.common.data.security.Authority;
 | 
			
		||||
@ -936,4 +938,53 @@ public class AlarmServiceTest extends AbstractServiceTest {
 | 
			
		||||
        Assert.assertEquals(0, alarms.getData().size());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    public void testCountAlarmsForEntities() throws ExecutionException, InterruptedException {
 | 
			
		||||
        AssetId parentId = new AssetId(Uuids.timeBased());
 | 
			
		||||
        AssetId childId = new AssetId(Uuids.timeBased());
 | 
			
		||||
 | 
			
		||||
        EntityRelation relation = new EntityRelation(parentId, childId, EntityRelation.CONTAINS_TYPE);
 | 
			
		||||
 | 
			
		||||
        Assert.assertTrue(relationService.saveRelationAsync(tenantId, relation).get());
 | 
			
		||||
 | 
			
		||||
        long ts = System.currentTimeMillis();
 | 
			
		||||
        AlarmApiCallResult result = alarmService.createAlarm(AlarmCreateOrUpdateActiveRequest.builder()
 | 
			
		||||
                .tenantId(tenantId)
 | 
			
		||||
                .originator(childId)
 | 
			
		||||
                .type(TEST_ALARM)
 | 
			
		||||
                .severity(AlarmSeverity.CRITICAL)
 | 
			
		||||
                .startTs(ts).build());
 | 
			
		||||
        AlarmInfo created = result.getAlarm();
 | 
			
		||||
        created.setPropagate(true);
 | 
			
		||||
        result = alarmService.updateAlarm(AlarmUpdateRequest.fromAlarm(created));
 | 
			
		||||
        created = result.getAlarm();
 | 
			
		||||
 | 
			
		||||
        EntityListFilter entityListFilter = new EntityListFilter();
 | 
			
		||||
        entityListFilter.setEntityList(List.of(childId.getId().toString(), parentId.getId().toString()));
 | 
			
		||||
        entityListFilter.setEntityType(EntityType.ASSET);
 | 
			
		||||
        AlarmCountQuery countQuery = new AlarmCountQuery(entityListFilter);
 | 
			
		||||
        countQuery.setStartTs(0L);
 | 
			
		||||
        countQuery.setEndTs(System.currentTimeMillis());
 | 
			
		||||
 | 
			
		||||
        long alarmsCount = alarmService.countAlarmsByQuery(tenantId, null, countQuery, List.of(childId));
 | 
			
		||||
        Assert.assertEquals(1, alarmsCount);
 | 
			
		||||
 | 
			
		||||
        countQuery.setSearchPropagatedAlarms(true);
 | 
			
		||||
 | 
			
		||||
        alarmsCount = alarmService.countAlarmsByQuery(tenantId, null, countQuery, List.of(parentId));
 | 
			
		||||
        Assert.assertEquals(1, alarmsCount);
 | 
			
		||||
 | 
			
		||||
        created = alarmService.acknowledgeAlarm(tenantId, created.getId(), System.currentTimeMillis()).getAlarm();
 | 
			
		||||
 | 
			
		||||
        countQuery.setStatusList(List.of(AlarmSearchStatus.UNACK));
 | 
			
		||||
        alarmsCount = alarmService.countAlarmsByQuery(tenantId, null, countQuery, List.of(childId));
 | 
			
		||||
        Assert.assertEquals(0, alarmsCount);
 | 
			
		||||
 | 
			
		||||
        alarmService.clearAlarm(tenantId, created.getId(), System.currentTimeMillis(), null);
 | 
			
		||||
 | 
			
		||||
        countQuery.setStatusList(List.of(AlarmSearchStatus.CLEARED));
 | 
			
		||||
        alarmsCount = alarmService.countAlarmsByQuery(tenantId, null, countQuery, List.of(childId));
 | 
			
		||||
        Assert.assertEquals(1, alarmsCount);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -16,6 +16,15 @@
 | 
			
		||||
 | 
			
		||||
-->
 | 
			
		||||
<ng-container [formGroup]="alarmCountWidgetConfigForm">
 | 
			
		||||
  <tb-datasources
 | 
			
		||||
    [configMode]="basicMode"
 | 
			
		||||
    hideDatasourcesMode
 | 
			
		||||
    hideDatasourceLabel
 | 
			
		||||
    hideDataKeys
 | 
			
		||||
    hideAlarmFilter
 | 
			
		||||
    displayDatasourceFilterForBasicMode
 | 
			
		||||
    formControlName="datasources">
 | 
			
		||||
  </tb-datasources>
 | 
			
		||||
  <div class="tb-form-panel">
 | 
			
		||||
    <div class="flex flex-row items-center justify-between">
 | 
			
		||||
      <div class="tb-form-panel-title" translate>alarm.filter</div>
 | 
			
		||||
 | 
			
		||||
@ -69,6 +69,7 @@ export class AlarmCountBasicConfigComponent extends BasicWidgetConfigComponent {
 | 
			
		||||
    const settings: CountWidgetSettings = {...countDefaultSettings(true), ...(configData.config.settings || {})};
 | 
			
		||||
    this.alarmCountWidgetConfigForm = this.fb.group({
 | 
			
		||||
      alarmFilterConfig: [getAlarmFilterConfig(configData.config.datasources), []],
 | 
			
		||||
      datasources: [configData.config.datasources, []],
 | 
			
		||||
 | 
			
		||||
      settings: [settings, []],
 | 
			
		||||
 | 
			
		||||
@ -81,6 +82,7 @@ export class AlarmCountBasicConfigComponent extends BasicWidgetConfigComponent {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected prepareOutputConfig(config: any): WidgetConfigComponentData {
 | 
			
		||||
    this.widgetConfig.config.datasources = config.datasources;
 | 
			
		||||
    setAlarmFilterConfig(config.alarmFilterConfig, this.widgetConfig.config.datasources);
 | 
			
		||||
 | 
			
		||||
    this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings};
 | 
			
		||||
 | 
			
		||||
@ -36,7 +36,7 @@
 | 
			
		||||
                                 datasourceFormGroup.get('type').value === datasourceType.entity ||
 | 
			
		||||
                                 datasourceFormGroup.get('type').value === datasourceType.entityCount ||
 | 
			
		||||
                                 datasourceFormGroup.get('type').value === datasourceType.alarmCount ? datasourceFormGroup.get('type').value : ''">
 | 
			
		||||
      <tb-alarm-filter-config *ngIf="datasourceFormGroup.get('type').value === datasourceType.alarmCount"
 | 
			
		||||
      <tb-alarm-filter-config *ngIf="datasourceFormGroup.get('type').value === datasourceType.alarmCount && !hideAlarmFilter"
 | 
			
		||||
                              propagatedFilter="false"
 | 
			
		||||
                              [initialAlarmFilterConfig]="{ statusList: [alarmSearchStatus.ACTIVE] }"
 | 
			
		||||
                              style="height: 56px; margin-bottom: 22px;"
 | 
			
		||||
@ -47,9 +47,9 @@
 | 
			
		||||
                              formControlName="deviceId">
 | 
			
		||||
      </tb-entity-autocomplete>
 | 
			
		||||
      <tb-entity-alias-select
 | 
			
		||||
        *ngIf="datasourceFormGroup.get('type').value !== datasourceType.device && datasourceFormGroup.get('type').value !== datasourceType.alarmCount"
 | 
			
		||||
        *ngIf="datasourceFormGroup.get('type').value !== datasourceType.device"
 | 
			
		||||
        [showLabel]="true"
 | 
			
		||||
        [tbRequired]="!datasourcesOptional"
 | 
			
		||||
        [tbRequired]="!entityAliasOptional"
 | 
			
		||||
        [aliasController]="aliasController"
 | 
			
		||||
        formControlName="entityAliasId"
 | 
			
		||||
        [callbacks]="entityAliasSelectCallbacks">
 | 
			
		||||
@ -98,7 +98,7 @@
 | 
			
		||||
    </tb-data-keys>
 | 
			
		||||
  </section>
 | 
			
		||||
  <tb-filter-select
 | 
			
		||||
    *ngIf="(!basicMode || displayDatasourceFilterForBasicMode) && ![datasourceType.function, datasourceType.alarmCount].includes(datasourceFormGroup.get('type').value)"
 | 
			
		||||
    *ngIf="(!basicMode || displayDatasourceFilterForBasicMode) && ![datasourceType.function].includes(datasourceFormGroup.get('type').value)"
 | 
			
		||||
    [showLabel]="true"
 | 
			
		||||
    [aliasController]="aliasController"
 | 
			
		||||
    formControlName="filterId"
 | 
			
		||||
 | 
			
		||||
@ -106,6 +106,11 @@ export class DatasourceComponent implements ControlValueAccessor, OnInit, Valida
 | 
			
		||||
    return this.widgetConfigComponent.modelValue?.typeParameters?.datasourcesOptional;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public get entityAliasOptional(): boolean {
 | 
			
		||||
    const type: DatasourceType = this.datasourceFormGroup.get('type').value;
 | 
			
		||||
    return this.datasourcesOptional || type === DatasourceType.alarmCount
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public get maxDataKeys(): number {
 | 
			
		||||
    return this.widgetConfigComponent.modelValue?.typeParameters?.maxDataKeys;
 | 
			
		||||
  }
 | 
			
		||||
@ -170,6 +175,10 @@ export class DatasourceComponent implements ControlValueAccessor, OnInit, Valida
 | 
			
		||||
    return this.datasourcesComponent?.hideLatestDataKeys;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public get hideAlarmFilter(): boolean {
 | 
			
		||||
    return this.datasourcesComponent?.hideAlarmFilter;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  disabled: boolean;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -131,6 +131,10 @@ export class DatasourcesComponent implements ControlValueAccessor, OnInit, Valid
 | 
			
		||||
  @coerceBoolean()
 | 
			
		||||
  hideLatestDataKeys = false;
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  @coerceBoolean()
 | 
			
		||||
  hideAlarmFilter = false;
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  @coerceBoolean()
 | 
			
		||||
  forceSingleDatasource = false;
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user