committed by
GitHub
109 changed files with 3769 additions and 261 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,65 @@ |
|||
/** |
|||
* Copyright © 2016-2024 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.edge.instructions; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.Setter; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.thingsboard.server.service.install.InstallScripts; |
|||
|
|||
import java.io.IOException; |
|||
import java.nio.file.Files; |
|||
import java.nio.file.Path; |
|||
import java.nio.file.Paths; |
|||
|
|||
@Slf4j |
|||
@RequiredArgsConstructor |
|||
public abstract class BaseEdgeInstallUpgradeInstructionsService { |
|||
|
|||
private static final String EDGE_DIR = "edge"; |
|||
private static final String INSTRUCTIONS_DIR = "instructions"; |
|||
|
|||
private final InstallScripts installScripts; |
|||
|
|||
@Value("${app.version:unknown}") |
|||
@Setter |
|||
protected String appVersion; |
|||
|
|||
protected String readFile(Path file) { |
|||
try { |
|||
return Files.readString(file); |
|||
} catch (IOException e) { |
|||
log.warn("Failed to read file: {}", file, e); |
|||
throw new RuntimeException(e); |
|||
} |
|||
} |
|||
|
|||
protected String getTagVersion(String version) { |
|||
return version.endsWith(".0") ? version.substring(0, version.length() - 2) : version; |
|||
} |
|||
|
|||
protected Path resolveFile(String subDir, String... subDirs) { |
|||
return getEdgeInstructionsDir().resolve(Paths.get(subDir, subDirs)); |
|||
} |
|||
|
|||
protected Path getEdgeInstructionsDir() { |
|||
return Paths.get(installScripts.getDataDir(), InstallScripts.JSON_DIR, EDGE_DIR, INSTRUCTIONS_DIR, getBaseDirName()); |
|||
} |
|||
|
|||
protected abstract String getBaseDirName(); |
|||
|
|||
} |
|||
@ -0,0 +1,273 @@ |
|||
/** |
|||
* Copyright © 2016-2024 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.action; |
|||
|
|||
import com.google.common.util.concurrent.FutureCallback; |
|||
import com.google.common.util.concurrent.Futures; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.EnumSource; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; |
|||
import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; |
|||
import org.thingsboard.rule.engine.api.TbContext; |
|||
import org.thingsboard.rule.engine.api.TbNodeConfiguration; |
|||
import org.thingsboard.rule.engine.api.TbNodeException; |
|||
import org.thingsboard.server.common.data.AttributeScope; |
|||
import org.thingsboard.server.common.data.DataConstants; |
|||
import org.thingsboard.server.common.data.EntityView; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.EntityViewId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.kv.AttributeKvEntry; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.data.msg.TbNodeConnectionType; |
|||
import org.thingsboard.server.common.data.objects.AttributesEntityView; |
|||
import org.thingsboard.server.common.data.objects.TelemetryEntityView; |
|||
import org.thingsboard.server.common.msg.TbMsg; |
|||
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|||
import org.thingsboard.server.dao.entityview.EntityViewService; |
|||
|
|||
import java.time.Instant; |
|||
import java.time.temporal.ChronoUnit; |
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
|
|||
import static org.assertj.core.api.AssertionsForClassTypes.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyList; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.Mockito.doAnswer; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.verifyNoMoreInteractions; |
|||
import static org.mockito.Mockito.when; |
|||
import static org.thingsboard.server.common.data.msg.TbMsgType.ACTIVITY_EVENT; |
|||
import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_DELETED; |
|||
import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_UPDATED; |
|||
import static org.thingsboard.server.common.data.msg.TbMsgType.INACTIVITY_EVENT; |
|||
import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class TbCopyAttributesToEntityViewNodeTest { |
|||
|
|||
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("9fdb1f05-dc66-4960-9263-ae195f1b4533")); |
|||
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("1d453dc9-9333-476a-a51f-093cf2176e59")); |
|||
private final EntityViewId ENTITY_VIEW_ID = new EntityViewId(UUID.fromString("65636806-453d-4bb4-b513-92b833970753")); |
|||
|
|||
private final AttributesEntityView CLIENT_ATTRIBUTES = new AttributesEntityView(List.of("clientAttribute1"), Collections.emptyList(), Collections.emptyList()); |
|||
private final AttributesEntityView SERVER_ATTRIBUTES = new AttributesEntityView(Collections.emptyList(), List.of("serverAttribute1"), Collections.emptyList()); |
|||
private final AttributesEntityView SHARED_ATTRIBUTES = new AttributesEntityView(Collections.emptyList(), Collections.emptyList(), List.of("sharedAttribute1")); |
|||
|
|||
private final TelemetryEntityView CLIENT_TELEMETRY_ENTITY_VIEW = new TelemetryEntityView(Collections.emptyList(), CLIENT_ATTRIBUTES); |
|||
private final TelemetryEntityView SERVER_TELEMETRY_ENTITY_VIEW = new TelemetryEntityView(Collections.emptyList(), SERVER_ATTRIBUTES); |
|||
private final TelemetryEntityView SHARED_TELEMETRY_ENTITY_VIEW = new TelemetryEntityView(Collections.emptyList(), SHARED_ATTRIBUTES); |
|||
|
|||
private final long ENTITY_VIEW_START_TS = Instant.now().minus(1, ChronoUnit.DAYS).toEpochMilli(); |
|||
private final long ENTITY_VIEW_END_TS = Instant.now().plus(1, ChronoUnit.DAYS).toEpochMilli(); |
|||
|
|||
private TbCopyAttributesToEntityViewNode node; |
|||
|
|||
@Mock |
|||
private TbContext ctxMock; |
|||
@Mock |
|||
private EntityViewService entityViewServiceMock; |
|||
@Mock |
|||
private RuleEngineTelemetryService telemetryServiceMock; |
|||
|
|||
@BeforeEach |
|||
void setUp() throws TbNodeException { |
|||
node = new TbCopyAttributesToEntityViewNode(); |
|||
var config = new EmptyNodeConfiguration().defaultConfiguration(); |
|||
var configuration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
|||
node.init(ctxMock, configuration); |
|||
} |
|||
|
|||
@Test |
|||
public void givenExistingClientAttributes_whenOnMsg_thenCopyAttributesToView() { |
|||
EntityView entityView = getEntityView(CLIENT_TELEMETRY_ENTITY_VIEW); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_ATTRIBUTES_REQUEST, DEVICE_ID, |
|||
new TbMsgMetaData(Map.of(DataConstants.SCOPE, AttributeScope.SERVER_SCOPE.name())), |
|||
"{\"clientAttribute1\": 100, \"clientAttribute2\": \"value2\"}"); |
|||
|
|||
mockEntityViewLookup(entityView); |
|||
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock); |
|||
doAnswer(invocation -> { |
|||
FutureCallback<Void> callback = invocation.getArgument(4); |
|||
callback.onSuccess(null); |
|||
return null; |
|||
}).when(telemetryServiceMock).saveAndNotify(any(), any(), any(AttributeScope.class), anyList(), any(FutureCallback.class)); |
|||
TbMsg newMsg = TbMsg.newMsg(msg, msg.getQueueName(), msg.getRuleChainId(), msg.getRuleNodeId()); |
|||
// TODO: use newMsg() with any(TbMsgType.class), replace in other tests as well.
|
|||
doAnswer(invocation -> newMsg).when(ctxMock).newMsg(any(), any(String.class), any(), any(), any(), any()); |
|||
|
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
verify(entityViewServiceMock).findEntityViewsByTenantIdAndEntityIdAsync(eq(TENANT_ID), eq(DEVICE_ID)); |
|||
ArgumentCaptor<List<AttributeKvEntry>> filteredAttributesCaptor = ArgumentCaptor.forClass(List.class); |
|||
verify(telemetryServiceMock).saveAndNotify(eq(TENANT_ID), eq(ENTITY_VIEW_ID), eq(AttributeScope.CLIENT_SCOPE), |
|||
filteredAttributesCaptor.capture(), any(FutureCallback.class)); |
|||
List<AttributeKvEntry> filteredAttributesCaptorValue = filteredAttributesCaptor.getValue(); |
|||
assertThat(filteredAttributesCaptorValue.size()).isEqualTo(1); |
|||
assertThat(filteredAttributesCaptorValue.get(0).getKey()).isEqualTo("clientAttribute1"); |
|||
assertThat(filteredAttributesCaptorValue.get(0).getValue()).isEqualTo(100L); |
|||
verify(ctxMock).ack(eq(msg)); |
|||
verify(ctxMock).enqueueForTellNext(eq(newMsg), eq(TbNodeConnectionType.SUCCESS)); |
|||
verifyNoMoreInteractions(ctxMock, entityViewServiceMock, telemetryServiceMock); |
|||
} |
|||
|
|||
@Test |
|||
public void givenExistingServerAttributesAndMsgTypeAttributesDeleted_whenOnMsg_thenDeleteAttributesFromView() { |
|||
EntityView entityView = getEntityView(SERVER_TELEMETRY_ENTITY_VIEW); |
|||
|
|||
TbMsg msg = TbMsg.newMsg( |
|||
ATTRIBUTES_DELETED, DEVICE_ID, new TbMsgMetaData(Map.of(DataConstants.SCOPE, AttributeScope.SERVER_SCOPE.name())), |
|||
"{\"attributes\": [\"serverAttribute1\"]}"); |
|||
|
|||
mockEntityViewLookup(entityView); |
|||
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock); |
|||
doAnswer(invocation -> { |
|||
FutureCallback<Void> callback = invocation.getArgument(4); |
|||
callback.onSuccess(null); |
|||
return null; |
|||
}).when(telemetryServiceMock).deleteAndNotify(any(), any(), any(AttributeScope.class), anyList(), any(FutureCallback.class)); |
|||
TbMsg newMsg = TbMsg.newMsg(msg, msg.getQueueName(), msg.getRuleChainId(), msg.getRuleNodeId()); |
|||
doAnswer(invocation -> newMsg).when(ctxMock).newMsg(any(), any(String.class), any(), any(), any(), any()); |
|||
|
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
verify(entityViewServiceMock).findEntityViewsByTenantIdAndEntityIdAsync(eq(TENANT_ID), eq(DEVICE_ID)); |
|||
ArgumentCaptor<List<String>> filteredAttributesCaptor = ArgumentCaptor.forClass(List.class); |
|||
verify(telemetryServiceMock).deleteAndNotify(eq(TENANT_ID), eq(ENTITY_VIEW_ID), eq(AttributeScope.SERVER_SCOPE), filteredAttributesCaptor.capture(), any(FutureCallback.class)); |
|||
List<String> filteredAttributesCaptorValue = filteredAttributesCaptor.getValue(); |
|||
assertThat(filteredAttributesCaptorValue.size()).isEqualTo(1); |
|||
assertThat(filteredAttributesCaptorValue.get(0)).isEqualTo("serverAttribute1"); |
|||
verify(ctxMock).ack(eq(msg)); |
|||
verify(ctxMock).enqueueForTellNext(eq(newMsg), eq(TbNodeConnectionType.SUCCESS)); |
|||
verifyNoMoreInteractions(ctxMock, entityViewServiceMock, telemetryServiceMock); |
|||
} |
|||
|
|||
@Test |
|||
public void givenNonMatchedSharedAttributesAndMsgTypeIsAttributesDeleted_whenOnMsg_thenNoAttributesDeleteFromView() { |
|||
EntityView entityView = getEntityView(SHARED_TELEMETRY_ENTITY_VIEW); |
|||
|
|||
TbMsg msg = TbMsg.newMsg( |
|||
TbMsgType.ATTRIBUTES_DELETED, DEVICE_ID, new TbMsgMetaData(Map.of(DataConstants.SCOPE, AttributeScope.SHARED_SCOPE.name())), |
|||
"{\"attributes\": [\"anotherAttribute\"]}"); |
|||
|
|||
mockEntityViewLookup(entityView); |
|||
|
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
verify(entityViewServiceMock).findEntityViewsByTenantIdAndEntityIdAsync(eq(TENANT_ID), eq(DEVICE_ID)); |
|||
verify(ctxMock).ack(eq(msg)); |
|||
verifyNoMoreInteractions(ctxMock, entityViewServiceMock); |
|||
} |
|||
|
|||
@Test |
|||
public void givenNonMatchedAttributesAndMsgTypeIsPostAttributesRequest_whenOnMsg_thenCopyNoAttributesToView() { |
|||
EntityView entityView = getEntityView(CLIENT_TELEMETRY_ENTITY_VIEW); |
|||
|
|||
TbMsg msg = TbMsg.newMsg( |
|||
TbMsgType.POST_ATTRIBUTES_REQUEST, DEVICE_ID, new TbMsgMetaData(Map.of(DataConstants.SCOPE, AttributeScope.SERVER_SCOPE.name())), |
|||
"{\"clientAttribute2\": \"value2\"}"); |
|||
|
|||
mockEntityViewLookup(entityView); |
|||
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock); |
|||
doAnswer(invocation -> { |
|||
FutureCallback<Void> callback = invocation.getArgument(4); |
|||
callback.onSuccess(null); |
|||
return null; |
|||
}).when(telemetryServiceMock).saveAndNotify(any(), any(), any(AttributeScope.class), anyList(), any(FutureCallback.class)); |
|||
TbMsg newMsg = TbMsg.newMsg(msg, msg.getQueueName(), msg.getRuleChainId(), msg.getRuleNodeId()); |
|||
doAnswer(invocation -> newMsg).when(ctxMock).newMsg(any(), any(String.class), any(), any(), any(), any()); |
|||
|
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
verify(entityViewServiceMock).findEntityViewsByTenantIdAndEntityIdAsync(eq(TENANT_ID), eq(DEVICE_ID)); |
|||
verify(telemetryServiceMock).saveAndNotify(eq(TENANT_ID), eq(ENTITY_VIEW_ID), eq(AttributeScope.CLIENT_SCOPE), eq(Collections.emptyList()), any(FutureCallback.class)); |
|||
verify(ctxMock).ack(eq(msg)); |
|||
verify(ctxMock).enqueueForTellNext(eq(newMsg), eq(TbNodeConnectionType.SUCCESS)); |
|||
verifyNoMoreInteractions(ctxMock, entityViewServiceMock, telemetryServiceMock); |
|||
} |
|||
|
|||
@Test |
|||
public void givenAttributesValidityPeriodOutOfStartDateAndEndDate_whenOnMsg_thenDoNothing() { |
|||
EntityView entityView = getEntityView( |
|||
SERVER_TELEMETRY_ENTITY_VIEW, |
|||
Instant.now().minus(2, ChronoUnit.DAYS).toEpochMilli(), |
|||
Instant.now().minus(1, ChronoUnit.DAYS).toEpochMilli() |
|||
); |
|||
mockEntityViewLookup(entityView); |
|||
|
|||
TbMsg msg = TbMsg.newMsg( |
|||
ATTRIBUTES_DELETED, DEVICE_ID, new TbMsgMetaData(Map.of(DataConstants.SCOPE, AttributeScope.SERVER_SCOPE.name())), |
|||
"{\"attributes\": [\"serverAttribute1\"]}"); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
verify(entityViewServiceMock).findEntityViewsByTenantIdAndEntityIdAsync(eq(TENANT_ID), eq(DEVICE_ID)); |
|||
verify(ctxMock).ack(eq(msg)); |
|||
verifyNoMoreInteractions(ctxMock, entityViewServiceMock); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@EnumSource(TbMsgType.class) |
|||
public void givenMsgTypeAndEmptyMetadata_whenOnMsg_thenVerifyFailureMsg(TbMsgType msgType) { |
|||
TbMsg msg = TbMsg.newMsg(msgType, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
|||
|
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class); |
|||
verify(ctxMock).tellFailure(eq(msg), throwableCaptor.capture()); |
|||
|
|||
if (msg.isTypeOneOf(ATTRIBUTES_UPDATED, ATTRIBUTES_DELETED, |
|||
ACTIVITY_EVENT, INACTIVITY_EVENT, POST_ATTRIBUTES_REQUEST)) { |
|||
assertThat(throwableCaptor.getValue()).isInstanceOf(IllegalArgumentException.class) |
|||
.hasMessage("Message metadata is empty"); |
|||
return; |
|||
} |
|||
assertThat(throwableCaptor.getValue()).isInstanceOf(IllegalArgumentException.class) |
|||
.hasMessage("Unsupported msg type [" + msgType + "]"); |
|||
|
|||
verifyNoMoreInteractions(ctxMock); |
|||
} |
|||
|
|||
private EntityView getEntityView(TelemetryEntityView attributesEntityView, long startTimeMs, long endTimeMs) { |
|||
EntityView entityView = new EntityView(ENTITY_VIEW_ID); |
|||
entityView.setStartTimeMs(startTimeMs); |
|||
entityView.setEndTimeMs(endTimeMs); |
|||
entityView.setKeys(attributesEntityView); |
|||
return entityView; |
|||
} |
|||
|
|||
private EntityView getEntityView(TelemetryEntityView attributesEntityView) { |
|||
return getEntityView(attributesEntityView, ENTITY_VIEW_START_TS, ENTITY_VIEW_END_TS); |
|||
} |
|||
|
|||
private void mockEntityViewLookup(EntityView entityView) { |
|||
when(ctxMock.getEntityViewService()).thenReturn(entityViewServiceMock); |
|||
when(ctxMock.getTenantId()).thenReturn(TENANT_ID); |
|||
when(entityViewServiceMock.findEntityViewsByTenantIdAndEntityIdAsync(any(), any())) |
|||
.thenReturn(Futures.immediateFuture(List.of(entityView))); |
|||
} |
|||
} |
|||
@ -0,0 +1,155 @@ |
|||
/** |
|||
* Copyright © 2016-2024 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.action; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.mockito.stubbing.Answer; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.common.util.ThingsBoardThreadFactory; |
|||
import org.thingsboard.rule.engine.api.TbContext; |
|||
import org.thingsboard.rule.engine.api.TbNodeConfiguration; |
|||
import org.thingsboard.rule.engine.api.TbNodeException; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.RuleNodeId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.data.msg.TbNodeConnectionType; |
|||
import org.thingsboard.server.common.msg.TbMsg; |
|||
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.ScheduledExecutorService; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.concurrent.atomic.AtomicBoolean; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.BDDMockito.given; |
|||
import static org.mockito.BDDMockito.then; |
|||
import static org.mockito.BDDMockito.willAnswer; |
|||
import static org.mockito.Mockito.times; |
|||
|
|||
@Slf4j |
|||
@ExtendWith(MockitoExtension.class) |
|||
public class TbMsgCountNodeTest { |
|||
|
|||
private final RuleNodeId RULE_NODE_ID = new RuleNodeId(UUID.fromString("ee682a85-7f5a-4182-91bc-46e555138fe2")); |
|||
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("1b21c7cc-0c9e-4ab1-b867-99451599e146")); |
|||
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("04dfbd38-10e5-47b7-925f-11e795db89e1")); |
|||
|
|||
private final ThingsBoardThreadFactory factory = ThingsBoardThreadFactory.forName("msg-count-node-test"); |
|||
private final TbMsg tickMsg = TbMsg.newMsg(TbMsgType.MSG_COUNT_SELF_MSG, RULE_NODE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_STRING); |
|||
|
|||
private ScheduledExecutorService executorService; |
|||
private TbMsgCountNode node; |
|||
private TbMsgCountNodeConfiguration config; |
|||
|
|||
@Mock |
|||
private TbContext ctxMock; |
|||
|
|||
@BeforeEach |
|||
public void setUp() { |
|||
node = new TbMsgCountNode(); |
|||
config = new TbMsgCountNodeConfiguration().defaultConfiguration(); |
|||
executorService = Executors.newSingleThreadScheduledExecutor(factory); |
|||
} |
|||
|
|||
@AfterEach |
|||
public void tearDown() { |
|||
if (executorService != null) { |
|||
executorService.shutdownNow(); |
|||
} |
|||
node.destroy(); |
|||
} |
|||
|
|||
@Test |
|||
public void verifyDefaultConfig() { |
|||
assertThat(config.getInterval()).isEqualTo(1); |
|||
assertThat(config.getTelemetryPrefix()).isEqualTo("messageCount"); |
|||
} |
|||
|
|||
@Test |
|||
public void givenIncomingMsgs_whenOnMsg_thenSendsMsgWithMsgCount() throws TbNodeException, InterruptedException { |
|||
// GIVEN
|
|||
int msgCount = 100; |
|||
var awaitTellSelfLatch = new CountDownLatch(1); |
|||
var currentMsgNumber = new AtomicInteger(0); |
|||
var msgWithCounterSent = new AtomicBoolean(false); |
|||
|
|||
willAnswer((Answer<Void>) invocationOnMock -> { |
|||
executorService.schedule(() -> { |
|||
TbMsg tickMsg = invocationOnMock.getArgument(0); |
|||
msgWithCounterSent.set(true); |
|||
node.onMsg(ctxMock, tickMsg); |
|||
awaitTellSelfLatch.countDown(); |
|||
}, config.getInterval(), TimeUnit.SECONDS); |
|||
return null; |
|||
}).given(ctxMock).tellSelf(any(TbMsg.class), anyLong()); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
given(ctxMock.getServiceId()).willReturn("tb-rule-engine"); |
|||
given(ctxMock.getSelfId()).willReturn(RULE_NODE_ID); |
|||
given(ctxMock.newMsg(null, TbMsgType.MSG_COUNT_SELF_MSG, RULE_NODE_ID, null, TbMsgMetaData.EMPTY, TbMsg.EMPTY_STRING)).willReturn(tickMsg); |
|||
|
|||
// WHEN
|
|||
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); |
|||
|
|||
var expectedProcessedMsgs = new ArrayList<TbMsg>(); |
|||
for (int i = 0; i < msgCount; i++) { |
|||
var msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_STRING); |
|||
if (msgWithCounterSent.get()) { |
|||
break; |
|||
} |
|||
node.onMsg(ctxMock, msg); |
|||
expectedProcessedMsgs.add(msg); |
|||
currentMsgNumber.getAndIncrement(); |
|||
} |
|||
|
|||
awaitTellSelfLatch.await(); |
|||
|
|||
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
|||
then(ctxMock).should(times(currentMsgNumber.get())).ack(msgCaptor.capture()); |
|||
var actualProcessedMsgs = msgCaptor.getAllValues(); |
|||
assertThat(actualProcessedMsgs).hasSize(expectedProcessedMsgs.size()); |
|||
assertThat(actualProcessedMsgs).isNotEmpty(); |
|||
assertThat(actualProcessedMsgs).containsExactlyInAnyOrderElementsOf(expectedProcessedMsgs); |
|||
|
|||
ArgumentCaptor<TbMsg> msgWithCounterCaptor = ArgumentCaptor.forClass(TbMsg.class); |
|||
then(ctxMock).should().enqueueForTellNext(msgWithCounterCaptor.capture(), eq(TbNodeConnectionType.SUCCESS)); |
|||
TbMsg resultedMsg = msgWithCounterCaptor.getValue(); |
|||
String expectedData = "{\"messageCount_tb-rule-engine\":" + currentMsgNumber + "}"; |
|||
TbMsg expectedMsg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, TENANT_ID, TbMsgMetaData.EMPTY, expectedData); |
|||
assertThat(resultedMsg).usingRecursiveComparison() |
|||
.ignoringFields("id", "ts", "ctx", "metaData") |
|||
.isEqualTo(expectedMsg); |
|||
Map<String, String> actualMetadata = resultedMsg.getMetaData().getData(); |
|||
assertThat(actualMetadata).hasFieldOrProperty("delta"); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,176 @@ |
|||
/** |
|||
* Copyright © 2016-2024 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.aws.sns; |
|||
|
|||
import com.amazonaws.ResponseMetadata; |
|||
import com.amazonaws.services.sns.AmazonSNS; |
|||
import com.amazonaws.services.sns.model.PublishRequest; |
|||
import com.amazonaws.services.sns.model.PublishResult; |
|||
import com.google.common.util.concurrent.Futures; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.Arguments; |
|||
import org.junit.jupiter.params.provider.MethodSource; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.common.util.ListeningExecutor; |
|||
import org.thingsboard.rule.engine.TestDbCallbackExecutor; |
|||
import org.thingsboard.rule.engine.api.TbContext; |
|||
import org.thingsboard.rule.engine.api.util.TbNodeUtils; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.data.msg.TbNodeConnectionType; |
|||
import org.thingsboard.server.common.msg.TbMsg; |
|||
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|||
|
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
import java.util.concurrent.Callable; |
|||
import java.util.stream.Stream; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.BDDMockito.given; |
|||
import static org.mockito.BDDMockito.then; |
|||
import static org.mockito.BDDMockito.mock; |
|||
import static org.mockito.BDDMockito.never; |
|||
import static org.mockito.BDDMockito.verifyNoMoreInteractions; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
class TbSnsNodeTest { |
|||
|
|||
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("fccfdf2e-6a88-4a94-81dd-5cbb557019cf")); |
|||
private final ListeningExecutor executor = new TestDbCallbackExecutor(); |
|||
|
|||
private TbSnsNode node; |
|||
private TbSnsNodeConfiguration config; |
|||
|
|||
@Mock |
|||
private TbContext ctxMock; |
|||
@Mock |
|||
private AmazonSNS snsClientMock; |
|||
@Mock |
|||
private PublishResult publishResultMock; |
|||
@Mock |
|||
private ResponseMetadata responseMetadataMock; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
node = new TbSnsNode(); |
|||
config = new TbSnsNodeConfiguration().defaultConfiguration(); |
|||
ReflectionTestUtils.setField(node, "snsClient", snsClientMock); |
|||
ReflectionTestUtils.setField(node, "config", config); |
|||
} |
|||
|
|||
@Test |
|||
void verifyDefaultConfig() { |
|||
assertThat(config.getTopicArnPattern()).isEqualTo("arn:aws:sns:us-east-1:123456789012:MyNewTopic"); |
|||
assertThat(config.getAccessKeyId()).isNull(); |
|||
assertThat(config.getSecretAccessKey()).isNull(); |
|||
assertThat(config.getRegion()).isEqualTo("us-east-1"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@MethodSource |
|||
void givenForceAckIsTrueAndTopicNamePattern_whenOnMsg_thenEnqueueForTellNext(String topicName, TbMsgMetaData metaData, String data) { |
|||
ReflectionTestUtils.setField(node, "forceAck", true); |
|||
config.setAccessKeyId("accessKeyId"); |
|||
config.setSecretAccessKey("secretAccessKey"); |
|||
config.setTopicArnPattern(topicName); |
|||
String messageId = "msgId-1d186a16-80c7-44b3-a245-a1fc835f20c7"; |
|||
String requestId = "reqId-bef0799b-dde9-4aa0-855b-86bbafaeaf31"; |
|||
|
|||
given(ctxMock.getExternalCallExecutor()).willReturn(executor); |
|||
given(snsClientMock.publish(any(PublishRequest.class))).willReturn(publishResultMock); |
|||
given(publishResultMock.getMessageId()).willReturn(messageId); |
|||
given(publishResultMock.getSdkResponseMetadata()).willReturn(responseMetadataMock); |
|||
given(responseMetadataMock.getRequestId()).willReturn(requestId); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metaData, data); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
then(ctxMock).should().ack(msg); |
|||
PublishRequest publishRequest = new PublishRequest() |
|||
.withTopicArn(TbNodeUtils.processPattern(topicName, msg)) |
|||
.withMessage(data); |
|||
then(snsClientMock).should().publish(publishRequest); |
|||
ArgumentCaptor<TbMsg> actualMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
|||
then(ctxMock).should().enqueueForTellNext(actualMsgCaptor.capture(), eq(TbNodeConnectionType.SUCCESS)); |
|||
TbMsg actualMsg = actualMsgCaptor.getValue(); |
|||
assertThat(actualMsg) |
|||
.usingRecursiveComparison() |
|||
.ignoringFields("metaData", "ctx") |
|||
.isEqualTo(msg); |
|||
assertThat(actualMsg.getMetaData().getData()) |
|||
.hasFieldOrPropertyWithValue("messageId", messageId) |
|||
.hasFieldOrPropertyWithValue("requestId", requestId); |
|||
verifyNoMoreInteractions(ctxMock, snsClientMock, publishResultMock, responseMetadataMock); |
|||
} |
|||
|
|||
private static Stream<Arguments> givenForceAckIsTrueAndTopicNamePattern_whenOnMsg_thenEnqueueForTellNext() { |
|||
return Stream.of( |
|||
Arguments.of("arn:aws:sns:us-east-1:123456789012:NewTopic", TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT), |
|||
Arguments.of("arn:aws:sns:us-east-1:123456789012:$[msgTopicName]", TbMsgMetaData.EMPTY, "{\"msgTopicName\":\"msg-topic-name\"}"), |
|||
Arguments.of("arn:aws:sns:us-east-1:123456789012:${mdTopicName}", new TbMsgMetaData(Map.of("mdTopicName", "md-topic-name")), TbMsg.EMPTY_JSON_OBJECT) |
|||
); |
|||
} |
|||
|
|||
@Test |
|||
void givenForceAckIsFalseAndErrorOccursDuringProcessingRequest_whenOnMsg_thenTellFailure() { |
|||
ReflectionTestUtils.setField(node, "forceAck", false); |
|||
ListeningExecutor listeningExecutor = mock(ListeningExecutor.class); |
|||
given(ctxMock.getExternalCallExecutor()).willReturn(listeningExecutor); |
|||
String errorMsg = "Something went wrong"; |
|||
ListenableFuture<TbMsg> failedFuture = Futures.immediateFailedFuture(new RuntimeException(errorMsg)); |
|||
given(listeningExecutor.executeAsync(any(Callable.class))).willReturn(failedFuture); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
then(ctxMock).should(never()).enqueueForTellNext(any(), any(String.class)); |
|||
ArgumentCaptor<TbMsg> actualMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
|||
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class); |
|||
then(ctxMock).should().tellFailure(actualMsgCaptor.capture(), throwableCaptor.capture()); |
|||
TbMsg actualMsg = actualMsgCaptor.getValue(); |
|||
assertThat(actualMsg) |
|||
.usingRecursiveComparison() |
|||
.ignoringFields("metaData", "ctx") |
|||
.isEqualTo(msg); |
|||
assertThat(actualMsg.getMetaData().getData()) |
|||
.hasFieldOrPropertyWithValue("error", RuntimeException.class + ": " + errorMsg); |
|||
assertThat(throwableCaptor.getValue()).isInstanceOf(RuntimeException.class).hasMessage(errorMsg); |
|||
verifyNoMoreInteractions(ctxMock, snsClientMock); |
|||
} |
|||
|
|||
@Test |
|||
void givenSnsClientIsNotNull_whenDestroy_thenShutdown() { |
|||
node.destroy(); |
|||
then(snsClientMock).should().shutdown(); |
|||
} |
|||
|
|||
@Test |
|||
void givenSnsClientIsNull_whenDestroy_thenVerifyNoInteractions() { |
|||
ReflectionTestUtils.setField(node, "snsClient", null); |
|||
node.destroy(); |
|||
then(snsClientMock).shouldHaveNoInteractions(); |
|||
} |
|||
} |
|||
@ -0,0 +1,263 @@ |
|||
/** |
|||
* Copyright © 2016-2024 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.aws.sqs; |
|||
|
|||
import com.amazonaws.ResponseMetadata; |
|||
import com.amazonaws.services.sqs.AmazonSQS; |
|||
import com.amazonaws.services.sqs.model.MessageAttributeValue; |
|||
import com.amazonaws.services.sqs.model.SendMessageRequest; |
|||
import com.amazonaws.services.sqs.model.SendMessageResult; |
|||
import com.google.common.util.concurrent.Futures; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.Arguments; |
|||
import org.junit.jupiter.params.provider.MethodSource; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.common.util.ListeningExecutor; |
|||
import org.thingsboard.rule.engine.TestDbCallbackExecutor; |
|||
import org.thingsboard.rule.engine.api.TbContext; |
|||
import org.thingsboard.rule.engine.api.util.TbNodeUtils; |
|||
import org.thingsboard.rule.engine.aws.sqs.TbSqsNodeConfiguration.QueueType; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.data.msg.TbNodeConnectionType; |
|||
import org.thingsboard.server.common.msg.TbMsg; |
|||
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|||
|
|||
import java.util.Collections; |
|||
import java.util.HashMap; |
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
import java.util.concurrent.Callable; |
|||
import java.util.stream.Stream; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.BDDMockito.given; |
|||
import static org.mockito.BDDMockito.then; |
|||
import static org.mockito.BDDMockito.mock; |
|||
import static org.mockito.BDDMockito.never; |
|||
import static org.mockito.BDDMockito.verifyNoMoreInteractions; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
class TbSqsNodeTest { |
|||
|
|||
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("764de824-929f-4114-95ea-0ea0401ffa3d")); |
|||
private final ListeningExecutor executor = new TestDbCallbackExecutor(); |
|||
|
|||
private final String messageId = "msgId-1d186a16-80c7-44b3-a245-a1fc835f20c7"; |
|||
private final String requestId = "reqId-bef0799b-dde9-4aa0-855b-86bbafaeaf31"; |
|||
|
|||
private TbSqsNode node; |
|||
private TbSqsNodeConfiguration config; |
|||
|
|||
@Mock |
|||
private TbContext ctxMock; |
|||
@Mock |
|||
private AmazonSQS sqsClientMock; |
|||
@Mock |
|||
private SendMessageResult sendMessageResultMock; |
|||
@Mock |
|||
private ResponseMetadata responseMetadataMock; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
node = new TbSqsNode(); |
|||
config = new TbSqsNodeConfiguration().defaultConfiguration(); |
|||
ReflectionTestUtils.setField(node, "sqsClient", sqsClientMock); |
|||
ReflectionTestUtils.setField(node, "config", config); |
|||
} |
|||
|
|||
@Test |
|||
void verifyDefaultConfig() { |
|||
assertThat(config.getQueueType()).isEqualTo(QueueType.STANDARD); |
|||
assertThat(config.getQueueUrlPattern()).isEqualTo("https://sqs.us-east-1.amazonaws.com/123456789012/my-queue-name"); |
|||
assertThat(config.getDelaySeconds()).isEqualTo(0); |
|||
assertThat(config.getMessageAttributes()).isEqualTo(Collections.emptyMap()); |
|||
assertThat(config.getAccessKeyId()).isNull(); |
|||
assertThat(config.getSecretAccessKey()).isNull(); |
|||
assertThat(config.getRegion()).isEqualTo("us-east-1"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@MethodSource |
|||
void givenQueueUrlPatternsAndQueueTypeIsFifo_whenOnMsg_thenVerifyRequest(String queueUrl, TbMsgMetaData metaData, String data) { |
|||
config.setQueueType(QueueType.FIFO); |
|||
config.setQueueUrlPattern(queueUrl); |
|||
|
|||
mockSendingMsgRequest(); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metaData, data); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
SendMessageRequest sendMsgRequest = new SendMessageRequest() |
|||
.withQueueUrl(TbNodeUtils.processPattern(queueUrl, msg)) |
|||
.withMessageBody(data) |
|||
.withMessageDeduplicationId(msg.getId().toString()) |
|||
.withMessageGroupId(DEVICE_ID.toString()); |
|||
then(sqsClientMock).should().sendMessage(sendMsgRequest); |
|||
} |
|||
|
|||
private static Stream<Arguments> givenQueueUrlPatternsAndQueueTypeIsFifo_whenOnMsg_thenVerifyRequest() { |
|||
return Stream.of( |
|||
Arguments.of( |
|||
"https://sqs.us-east-1.amazonaws.com/123456789012/new-queue-name", |
|||
TbMsgMetaData.EMPTY, |
|||
TbMsg.EMPTY_JSON_OBJECT), |
|||
Arguments.of( |
|||
"https://sqs.us-east-1.amazonaws.com/123456789012/$[msgQueueName]", |
|||
TbMsgMetaData.EMPTY, |
|||
"{\"msgQueueName\":\"msg-queue-name\"}"), |
|||
Arguments.of( |
|||
"https://sqs.us-east-1.amazonaws.com/123456789012/${mdQueueName}", |
|||
new TbMsgMetaData(Map.of("mdQueueName", "md-queue-name")), |
|||
TbMsg.EMPTY_JSON_OBJECT) |
|||
); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@MethodSource |
|||
void givenMsgAttributesPatternsAndQueueTypeIsStandard_whenOnMsg_thenVerifyRequest(TbMsgMetaData metaData, String data, |
|||
Map<String, String> attributes) { |
|||
config.setMessageAttributes(attributes); |
|||
|
|||
mockSendingMsgRequest(); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metaData, data); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
Map<String, MessageAttributeValue> messageAttributes = new HashMap<>(); |
|||
this.config.getMessageAttributes().forEach((k, v) -> { |
|||
String name = TbNodeUtils.processPattern(k, msg); |
|||
String val = TbNodeUtils.processPattern(v, msg); |
|||
messageAttributes.put(name, new MessageAttributeValue().withDataType("String").withStringValue(val)); |
|||
}); |
|||
SendMessageRequest sendMsgRequest = new SendMessageRequest() |
|||
.withQueueUrl(config.getQueueUrlPattern()) |
|||
.withMessageBody(data) |
|||
.withMessageAttributes(messageAttributes) |
|||
.withDelaySeconds(config.getDelaySeconds()); |
|||
then(sqsClientMock).should().sendMessage(sendMsgRequest); |
|||
} |
|||
|
|||
private static Stream<Arguments> givenMsgAttributesPatternsAndQueueTypeIsStandard_whenOnMsg_thenVerifyRequest() { |
|||
return Stream.of( |
|||
Arguments.of(TbMsgMetaData.EMPTY, |
|||
TbMsg.EMPTY_JSON_OBJECT, |
|||
Map.of("attributeName", "attributeValue")), |
|||
Arguments.of(TbMsgMetaData.EMPTY, |
|||
"{\"msgAttrNamePattern\":\"msgAttrName\",\"msgAttrValuePattern\":\"msgAttrValue\"}", |
|||
Map.of("$[msgAttrNamePattern]", "$[msgAttrValuePattern]")), |
|||
Arguments.of(new TbMsgMetaData(Map.of("mdAttrNamePattern", "mdAttrName", "mdAttrValuePattern", "mdAttrValue")), |
|||
TbMsg.EMPTY_JSON_OBJECT, |
|||
Map.of("${mdAttrNamePattern}", "${mdAttrValuePattern}")) |
|||
); |
|||
} |
|||
|
|||
@Test |
|||
void givenForceAckIsTrueAndMsgResultContainsBodyAndAttributesAndNumber_whenOnMsg_thenEnqueueForTellNext() { |
|||
ReflectionTestUtils.setField(node, "forceAck", true); |
|||
String messageBodyMd5 = "msgBodyMd5-55fb8ba2-2b71-4673-a82a-969756764761"; |
|||
String messageAttributesMd5 = "msgAttrMd5-e3ba3eef-52ae-436a-bec1-0c2c2252d1f1"; |
|||
String sequenceNumber = "seqNum-bb5ddce0-cf4e-4295-b015-524bdb6a332f"; |
|||
|
|||
mockSendingMsgRequest(); |
|||
given(sendMessageResultMock.getMD5OfMessageBody()).willReturn(messageBodyMd5); |
|||
given(sendMessageResultMock.getMD5OfMessageAttributes()).willReturn(messageAttributesMd5); |
|||
given(sendMessageResultMock.getSequenceNumber()).willReturn(sequenceNumber); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
then(ctxMock).should().ack(msg); |
|||
SendMessageRequest sendMsgRequest = new SendMessageRequest() |
|||
.withQueueUrl(TbNodeUtils.processPattern(config.getQueueUrlPattern(), msg)) |
|||
.withMessageBody(msg.getData()) |
|||
.withDelaySeconds(config.getDelaySeconds()); |
|||
then(sqsClientMock).should().sendMessage(sendMsgRequest); |
|||
ArgumentCaptor<TbMsg> actualMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
|||
then(ctxMock).should().enqueueForTellNext(actualMsgCaptor.capture(), eq(TbNodeConnectionType.SUCCESS)); |
|||
TbMsg actualMsg = actualMsgCaptor.getValue(); |
|||
assertThat(actualMsg) |
|||
.usingRecursiveComparison() |
|||
.ignoringFields("metaData", "ctx") |
|||
.isEqualTo(msg); |
|||
assertThat(actualMsg.getMetaData().getData()) |
|||
.hasFieldOrPropertyWithValue("messageId", messageId) |
|||
.hasFieldOrPropertyWithValue("requestId", requestId) |
|||
.hasFieldOrPropertyWithValue("messageBodyMd5", messageBodyMd5) |
|||
.hasFieldOrPropertyWithValue("messageAttributesMd5", messageAttributesMd5) |
|||
.hasFieldOrPropertyWithValue("sequenceNumber", sequenceNumber); |
|||
verifyNoMoreInteractions(ctxMock, sqsClientMock, sendMessageResultMock, responseMetadataMock); |
|||
} |
|||
|
|||
@Test |
|||
void givenForceAckIsFalseAndErrorOccursDuringProcessingRequest_whenOnMsg_thenTellFailure() { |
|||
ReflectionTestUtils.setField(node, "forceAck", false); |
|||
ListeningExecutor listeningExecutor = mock(ListeningExecutor.class); |
|||
given(ctxMock.getExternalCallExecutor()).willReturn(listeningExecutor); |
|||
String errorMsg = "Something went wrong"; |
|||
|
|||
ListenableFuture<TbMsg> failedFuture = Futures.immediateFailedFuture(new RuntimeException(errorMsg)); |
|||
given(listeningExecutor.executeAsync(any(Callable.class))).willReturn(failedFuture); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
then(ctxMock).should(never()).enqueueForTellNext(any(), any(String.class)); |
|||
ArgumentCaptor<TbMsg> actualMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
|||
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class); |
|||
then(ctxMock).should().tellFailure(actualMsgCaptor.capture(), throwableCaptor.capture()); |
|||
TbMsg actualMsg = actualMsgCaptor.getValue(); |
|||
assertThat(actualMsg) |
|||
.usingRecursiveComparison() |
|||
.ignoringFields("metaData", "ctx") |
|||
.isEqualTo(msg); |
|||
assertThat(actualMsg.getMetaData().getData()) |
|||
.hasFieldOrPropertyWithValue("error", RuntimeException.class + ": " + errorMsg); |
|||
assertThat(throwableCaptor.getValue()).isInstanceOf(RuntimeException.class).hasMessage(errorMsg); |
|||
verifyNoMoreInteractions(ctxMock, sqsClientMock); |
|||
} |
|||
|
|||
@Test |
|||
void givenSqsClientIsNotNull_whenDestroy_thenShutdown() { |
|||
node.destroy(); |
|||
then(sqsClientMock).should().shutdown(); |
|||
} |
|||
|
|||
@Test |
|||
void givenSqsClientIsNull_whenDestroy_thenVerifyNoInteractions() { |
|||
ReflectionTestUtils.setField(node, "sqsClient", null); |
|||
node.destroy(); |
|||
then(sqsClientMock).shouldHaveNoInteractions(); |
|||
} |
|||
|
|||
private void mockSendingMsgRequest() { |
|||
given(ctxMock.getExternalCallExecutor()).willReturn(executor); |
|||
given(sqsClientMock.sendMessage(any(SendMessageRequest.class))).willReturn(sendMessageResultMock); |
|||
given(sendMessageResultMock.getMessageId()).willReturn(messageId); |
|||
given(sendMessageResultMock.getSdkResponseMetadata()).willReturn(responseMetadataMock); |
|||
given(responseMetadataMock.getRequestId()).willReturn(requestId); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,421 @@ |
|||
/** |
|||
* Copyright © 2016-2024 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.rpc; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.Arguments; |
|||
import org.junit.jupiter.params.provider.EnumSource; |
|||
import org.junit.jupiter.params.provider.MethodSource; |
|||
import org.junit.jupiter.params.provider.NullAndEmptySource; |
|||
import org.junit.jupiter.params.provider.ValueSource; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.rule.engine.api.RuleEngineDeviceRpcRequest; |
|||
import org.thingsboard.rule.engine.api.RuleEngineDeviceRpcResponse; |
|||
import org.thingsboard.rule.engine.api.RuleEngineRpcService; |
|||
import org.thingsboard.rule.engine.api.TbContext; |
|||
import org.thingsboard.rule.engine.api.TbNodeConfiguration; |
|||
import org.thingsboard.rule.engine.api.TbNodeException; |
|||
import org.thingsboard.server.common.data.DataConstants; |
|||
import org.thingsboard.server.common.data.EntityType; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.EntityIdFactory; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.data.msg.TbNodeConnectionType; |
|||
import org.thingsboard.server.common.data.rpc.RpcError; |
|||
import org.thingsboard.server.common.msg.TbMsg; |
|||
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|||
|
|||
import java.util.Optional; |
|||
import java.util.Random; |
|||
import java.util.UUID; |
|||
import java.util.function.Consumer; |
|||
import java.util.stream.Stream; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.BDDMockito.given; |
|||
import static org.mockito.BDDMockito.then; |
|||
import static org.mockito.BDDMockito.willAnswer; |
|||
import static org.mockito.Mockito.mock; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class TbSendRPCRequestNodeTest { |
|||
|
|||
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("d3a47f8b-d863-4c1f-b6f0-2c946b43f21c")); |
|||
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("b052ae59-b9b4-47e8-ac71-39e7124bbd66")); |
|||
|
|||
private final String MSG_DATA = """ |
|||
{ |
|||
"method": "setGpio", |
|||
"params": { |
|||
"pin": "23", |
|||
"value": 1 |
|||
}, |
|||
"additionalInfo": "information" |
|||
} |
|||
"""; |
|||
|
|||
private TbSendRPCRequestNode node; |
|||
private TbSendRpcRequestNodeConfiguration config; |
|||
|
|||
@Mock |
|||
private TbContext ctxMock; |
|||
@Mock |
|||
private RuleEngineRpcService rpcServiceMock; |
|||
|
|||
@BeforeEach |
|||
public void setUp() throws TbNodeException { |
|||
node = new TbSendRPCRequestNode(); |
|||
config = new TbSendRpcRequestNodeConfiguration().defaultConfiguration(); |
|||
var configuration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
|||
node.init(ctxMock, configuration); |
|||
} |
|||
|
|||
@Test |
|||
public void verifyDefaultConfig() { |
|||
assertThat(config.getTimeoutInSeconds()).isEqualTo(60); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@MethodSource |
|||
public void givenOneway_whenOnMsg_thenVerifyRequest(String mdKeyValue, boolean expectedResult) { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
TbMsgMetaData msgMetadata = new TbMsgMetaData(); |
|||
msgMetadata.putValue("oneway", mdKeyValue); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, msgMetadata, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
var ruleEngineDeviceRpcRequestCaptor = captureRequest(); |
|||
assertThat(ruleEngineDeviceRpcRequestCaptor.getValue().isOneway()).isEqualTo(expectedResult); |
|||
} |
|||
|
|||
private static Stream<Arguments> givenOneway_whenOnMsg_thenVerifyRequest() { |
|||
return Stream.of( |
|||
Arguments.of("true", true), |
|||
Arguments.of("false", false), |
|||
Arguments.of(null, false), |
|||
Arguments.of("", false) |
|||
); |
|||
} |
|||
|
|||
@Test |
|||
public void givenMsgBody_whenOnMsg_thenVerifyRequest() { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, TbMsgMetaData.EMPTY, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = ArgumentCaptor.forClass(RuleEngineDeviceRpcRequest.class); |
|||
then(rpcServiceMock).should().sendRpcRequestToDevice(requestCaptor.capture(), any(Consumer.class)); |
|||
assertThat(requestCaptor.getValue()) |
|||
.hasFieldOrPropertyWithValue("method", "setGpio") |
|||
.hasFieldOrPropertyWithValue("body", "{\"pin\":\"23\",\"value\":1}") |
|||
.hasFieldOrPropertyWithValue("deviceId", DEVICE_ID) |
|||
.hasFieldOrPropertyWithValue("tenantId", TENANT_ID) |
|||
.hasFieldOrPropertyWithValue("additionalInfo", "information"); |
|||
} |
|||
|
|||
@Test |
|||
public void givenRequestIdIsNotSet_whenOnMsg_thenVerifyRequest() { |
|||
Random randomMock = mock(Random.class); |
|||
given(randomMock.nextInt()).willReturn(123); |
|||
ReflectionTestUtils.setField(node, "random", randomMock); |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.TO_SERVER_RPC_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = captureRequest(); |
|||
assertThat(requestCaptor.getValue().getRequestId()).isEqualTo(123); |
|||
} |
|||
|
|||
@Test |
|||
public void givenRequestId_whenOnMsg_thenVerifyRequest() { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
String data = """ |
|||
{ |
|||
"method": "setGpio", |
|||
"params": { |
|||
"pin": "23", |
|||
"value": 1 |
|||
}, |
|||
"requestId": 12345 |
|||
} |
|||
"""; |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.TO_SERVER_RPC_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, data); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = captureRequest(); |
|||
assertThat(requestCaptor.getValue().getRequestId()).isEqualTo(12345); |
|||
} |
|||
|
|||
@Test |
|||
public void givenRequestUUID_whenOnMsg_thenVerifyRequest() { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
String requestUUID = "b795a241-5a30-48fb-92d5-46b864d47130"; |
|||
TbMsgMetaData metadata = new TbMsgMetaData(); |
|||
metadata.putValue("requestUUID", requestUUID); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, metadata, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = captureRequest(); |
|||
assertThat(requestCaptor.getValue().getRequestUUID()).isEqualTo(UUID.fromString(requestUUID)); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@NullAndEmptySource |
|||
public void givenInvalidRequestUUID_whenOnMsg_thenVerifyRequest(String requestUUID) { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
TbMsgMetaData metadata = new TbMsgMetaData(); |
|||
metadata.putValue("requestUUID", requestUUID); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, metadata, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = captureRequest(); |
|||
assertThat(requestCaptor.getValue().getRequestUUID()).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenOriginServiceId_whenOnMsg_thenVerifyRequest() { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
String originServiceId = "service-id-123"; |
|||
TbMsgMetaData metadata = new TbMsgMetaData(); |
|||
metadata.putValue("originServiceId", originServiceId); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, metadata, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = captureRequest(); |
|||
assertThat(requestCaptor.getValue().getOriginServiceId()).isEqualTo(originServiceId); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@NullAndEmptySource |
|||
public void givenInvalidOriginServiceId_whenOnMsg_thenVerifyRequest(String originServiceId) { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
TbMsgMetaData metadata = new TbMsgMetaData(); |
|||
metadata.putValue("originServiceId", originServiceId); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, metadata, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = captureRequest(); |
|||
assertThat(requestCaptor.getValue().getOriginServiceId()).isNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenExpirationTime_whenOnMsg_thenVerifyRequest() { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
String expirationTime = "2000000000000"; |
|||
TbMsgMetaData metadata = new TbMsgMetaData(); |
|||
metadata.putValue(DataConstants.EXPIRATION_TIME, expirationTime); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, metadata, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = captureRequest(); |
|||
assertThat(requestCaptor.getValue().getExpirationTime()).isEqualTo(Long.parseLong(expirationTime)); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@NullAndEmptySource |
|||
public void givenInvalidExpirationTime_whenOnMsg_thenVerifyRequest(String expirationTime) { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
TbMsgMetaData metadata = new TbMsgMetaData(); |
|||
metadata.putValue(DataConstants.EXPIRATION_TIME, expirationTime); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, metadata, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = captureRequest(); |
|||
assertThat(requestCaptor.getValue().getExpirationTime()).isGreaterThan(System.currentTimeMillis()); |
|||
} |
|||
|
|||
@Test |
|||
public void givenRetries_whenOnMsg_thenVerifyRequest() { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
Integer retries = 3; |
|||
TbMsgMetaData metadata = new TbMsgMetaData(); |
|||
metadata.putValue(DataConstants.RETRIES, String.valueOf(retries)); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, metadata, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = captureRequest(); |
|||
assertThat(requestCaptor.getValue().getRetries()).isEqualTo(retries); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@NullAndEmptySource |
|||
public void givenInvalidRetriesValue_whenOnMsg_thenVerifyRequest(String retries) { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
TbMsgMetaData metadata = new TbMsgMetaData(); |
|||
metadata.putValue(DataConstants.RETRIES, retries); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, metadata, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = captureRequest(); |
|||
assertThat(requestCaptor.getValue().getRetries()).isNull(); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@EnumSource(TbMsgType.class) |
|||
public void givenTbMsgType_whenOnMsg_thenVerifyRequest(TbMsgType msgType) { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(msgType, DEVICE_ID, TbMsgMetaData.EMPTY, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = captureRequest(); |
|||
if (msgType == TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE) { |
|||
assertThat(requestCaptor.getValue().isRestApiCall()).isTrue(); |
|||
return; |
|||
} |
|||
assertThat(requestCaptor.getValue().isRestApiCall()).isFalse(); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@MethodSource |
|||
public void givenPersistent_whenOnMsg_thenVerifyRequest(String isPersisted, boolean expectedPersistence) { |
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
|
|||
TbMsgMetaData metadata = new TbMsgMetaData(); |
|||
metadata.putValue(DataConstants.PERSISTENT, isPersisted); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, metadata, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = captureRequest(); |
|||
assertThat(requestCaptor.getValue().isPersisted()).isEqualTo(expectedPersistence); |
|||
} |
|||
|
|||
private static Stream<Arguments> givenPersistent_whenOnMsg_thenVerifyRequest() { |
|||
return Stream.of( |
|||
Arguments.of("true", true), |
|||
Arguments.of("false", false), |
|||
Arguments.of(null, false), |
|||
Arguments.of("", false) |
|||
); |
|||
} |
|||
|
|||
private ArgumentCaptor<RuleEngineDeviceRpcRequest> captureRequest() { |
|||
ArgumentCaptor<RuleEngineDeviceRpcRequest> requestCaptor = ArgumentCaptor.forClass(RuleEngineDeviceRpcRequest.class); |
|||
then(rpcServiceMock).should().sendRpcRequestToDevice(requestCaptor.capture(), any(Consumer.class)); |
|||
return requestCaptor; |
|||
} |
|||
|
|||
@Test |
|||
public void givenRpcResponseWithoutError_whenOnMsg_thenSendsRpcRequest() { |
|||
TbMsg outMsg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
|||
|
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
// TODO: replace deprecated method newMsg()
|
|||
given(ctxMock.newMsg(any(), any(String.class), any(), any(), any(), any())).willReturn(outMsg); |
|||
willAnswer(invocation -> { |
|||
Consumer<RuleEngineDeviceRpcResponse> consumer = invocation.getArgument(1); |
|||
RuleEngineDeviceRpcResponse rpcResponseMock = mock(RuleEngineDeviceRpcResponse.class); |
|||
given(rpcResponseMock.getError()).willReturn(Optional.empty()); |
|||
given(rpcResponseMock.getResponse()).willReturn(Optional.of(TbMsg.EMPTY_JSON_OBJECT)); |
|||
consumer.accept(rpcResponseMock); |
|||
return null; |
|||
}).given(rpcServiceMock).sendRpcRequestToDevice(any(RuleEngineDeviceRpcRequest.class), any(Consumer.class)); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, TbMsgMetaData.EMPTY, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
then(ctxMock).should().enqueueForTellNext(outMsg, TbNodeConnectionType.SUCCESS); |
|||
then(ctxMock).should().ack(msg); |
|||
} |
|||
|
|||
@Test |
|||
public void givenRpcResponseWithError_whenOnMsg_thenTellFailure() { |
|||
TbMsg outMsg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
|||
|
|||
given(ctxMock.getRpcService()).willReturn(rpcServiceMock); |
|||
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|||
// TODO: replace deprecated method newMsg()
|
|||
given(ctxMock.newMsg(any(), any(String.class), any(), any(), any(), any())).willReturn(outMsg); |
|||
willAnswer(invocation -> { |
|||
Consumer<RuleEngineDeviceRpcResponse> consumer = invocation.getArgument(1); |
|||
RuleEngineDeviceRpcResponse rpcResponseMock = mock(RuleEngineDeviceRpcResponse.class); |
|||
given(rpcResponseMock.getError()).willReturn(Optional.of(RpcError.NO_ACTIVE_CONNECTION)); |
|||
consumer.accept(rpcResponseMock); |
|||
return null; |
|||
}).given(rpcServiceMock).sendRpcRequestToDevice(any(RuleEngineDeviceRpcRequest.class), any(Consumer.class)); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.RPC_CALL_FROM_SERVER_TO_DEVICE, DEVICE_ID, TbMsgMetaData.EMPTY, MSG_DATA); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
then(ctxMock).should().enqueueForTellFailure(outMsg, RpcError.NO_ACTIVE_CONNECTION.name()); |
|||
then(ctxMock).should().ack(msg); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@EnumSource(EntityType.class) |
|||
public void givenOriginatorIsNotDevice_whenOnMsg_thenThrowsException(EntityType entityType) { |
|||
EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, "ac21a1bb-eabf-4463-8313-24bea1f498d9"); |
|||
|
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, entityId, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class); |
|||
then(ctxMock).should().tellFailure(eq(msg), throwableCaptor.capture()); |
|||
assertThat(throwableCaptor.getValue()).isInstanceOf(RuntimeException.class) |
|||
.hasMessage(EntityType.DEVICE != entityType ? "Message originator is not a device entity!" |
|||
: "Method is not present in the message!"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = {"method", "params"}) |
|||
public void givenMethodOrParamsAreNotPresent_whenOnMsg_thenThrowsException(String key) { |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, "{\"" + key + "\": \"value\"}"); |
|||
|
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class); |
|||
then(ctxMock).should().tellFailure(eq(msg), throwableCaptor.capture()); |
|||
assertThat(throwableCaptor.getValue()).isInstanceOf(RuntimeException.class) |
|||
.hasMessage(key.equals("method") ? "Params are not present in the message!" : "Method is not present in the message!"); |
|||
} |
|||
} |
|||
@ -0,0 +1,249 @@ |
|||
/** |
|||
* Copyright © 2016-2024 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.telemetry; |
|||
|
|||
import com.google.gson.JsonParser; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.Arguments; |
|||
import org.junit.jupiter.params.provider.EnumSource; |
|||
import org.junit.jupiter.params.provider.MethodSource; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; |
|||
import org.thingsboard.rule.engine.api.TbContext; |
|||
import org.thingsboard.rule.engine.api.TbNodeConfiguration; |
|||
import org.thingsboard.rule.engine.api.TbNodeException; |
|||
import org.thingsboard.server.common.adaptor.JsonConverter; |
|||
import org.thingsboard.server.common.data.TenantProfile; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.id.TenantProfileId; |
|||
import org.thingsboard.server.common.data.kv.BasicTsKvEntry; |
|||
import org.thingsboard.server.common.data.kv.KvEntry; |
|||
import org.thingsboard.server.common.data.kv.TsKvEntry; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; |
|||
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; |
|||
import org.thingsboard.server.common.msg.TbMsg; |
|||
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.stream.Stream; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyList; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.ArgumentMatchers.isNull; |
|||
import static org.mockito.Mockito.doAnswer; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.verifyNoMoreInteractions; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class TbMsgTimeseriesNodeTest { |
|||
|
|||
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("c8f34868-603a-4433-876a-7d356e5cf377")); |
|||
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("e5095e9a-04f4-44c9-b443-1cf1b97d3384")); |
|||
private final TenantProfileId TENANT_PROFILE_ID = new TenantProfileId(UUID.fromString("ab78dd78-83d0-43fa-869f-d42ec9ed1744")); |
|||
|
|||
private TbMsgTimeseriesNode node; |
|||
private TbMsgTimeseriesNodeConfiguration config; |
|||
private long tenantProfileDefaultStorageTtl; |
|||
|
|||
@Mock |
|||
private TbContext ctxMock; |
|||
@Mock |
|||
private RuleEngineTelemetryService telemetryServiceMock; |
|||
|
|||
@BeforeEach |
|||
public void setUp() throws TbNodeException { |
|||
node = new TbMsgTimeseriesNode(); |
|||
config = new TbMsgTimeseriesNodeConfiguration().defaultConfiguration(); |
|||
} |
|||
|
|||
@Test |
|||
public void verifyDefaultConfig() { |
|||
assertThat(config.getDefaultTTL()).isEqualTo(0L); |
|||
assertThat(config.isSkipLatestPersistence()).isFalse(); |
|||
assertThat(config.isUseServerTs()).isFalse(); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@EnumSource(TbMsgType.class) |
|||
public void givenMsgTypeAndEmptyMsgData_whenOnMsg_thenVerifyFailureMsg(TbMsgType msgType) throws TbNodeException { |
|||
init(); |
|||
TbMsg msg = TbMsg.newMsg(msgType, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_ARRAY); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class); |
|||
verify(ctxMock).tellFailure(eq(msg), throwableCaptor.capture()); |
|||
|
|||
if (TbMsgType.POST_TELEMETRY_REQUEST.equals(msgType)) { |
|||
assertThat(throwableCaptor.getValue()).isInstanceOf(IllegalArgumentException.class).hasMessage("Msg body is empty: " + msg.getData()); |
|||
verifyNoMoreInteractions(ctxMock); |
|||
return; |
|||
} |
|||
assertThat(throwableCaptor.getValue()).isInstanceOf(IllegalArgumentException.class).hasMessage("Unsupported msg type: " + msgType); |
|||
verifyNoMoreInteractions(ctxMock); |
|||
} |
|||
|
|||
@Test |
|||
public void givenTtlFromConfigIsZeroAndUseServiceTsIsTrue_whenOnMsg_thenSaveTimeseriesUsingTenantProfileDefaultTtl() throws TbNodeException { |
|||
config.setUseServerTs(true); |
|||
init(); |
|||
|
|||
String data = """ |
|||
{ |
|||
"temp": 45, |
|||
"humidity": 77 |
|||
} |
|||
"""; |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, data); |
|||
|
|||
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock); |
|||
when(ctxMock.getTenantId()).thenReturn(TENANT_ID); |
|||
doAnswer(invocation -> { |
|||
TelemetryNodeCallback callback = invocation.getArgument(5); |
|||
callback.onSuccess(null); |
|||
return null; |
|||
}).when(telemetryServiceMock).saveAndNotify(any(), any(), any(), anyList(), anyLong(), any()); |
|||
|
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
List<TsKvEntry> expectedList = getTsKvEntriesListWithTs(data, System.currentTimeMillis()); |
|||
ArgumentCaptor<List<TsKvEntry>> entryListCaptor = ArgumentCaptor.forClass(List.class); |
|||
verify(telemetryServiceMock).saveAndNotify(eq(TENANT_ID), isNull(), eq(DEVICE_ID), entryListCaptor.capture(), |
|||
eq(tenantProfileDefaultStorageTtl), any(TelemetryNodeCallback.class)); |
|||
assertThat(entryListCaptor.getValue()).usingRecursiveFieldByFieldElementComparatorIgnoringFields("ts") |
|||
.containsExactlyElementsOf(expectedList); |
|||
verify(ctxMock).tellSuccess(msg); |
|||
verifyNoMoreInteractions(ctxMock, telemetryServiceMock); |
|||
} |
|||
|
|||
@Test |
|||
public void givenSkipLatestPersistenceIsTrueAndTtlFromConfig_whenOnMsg_thenSaveTimeseriesUsingTtlFromConfig() throws TbNodeException { |
|||
long ttlFromConfig = 5L; |
|||
config.setDefaultTTL(ttlFromConfig); |
|||
config.setSkipLatestPersistence(true); |
|||
init(); |
|||
|
|||
String data = """ |
|||
{ |
|||
"temp": 45, |
|||
"humidity": 77 |
|||
} |
|||
"""; |
|||
long ts = System.currentTimeMillis(); |
|||
var metadata = Map.of("ts", String.valueOf(ts)); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, new TbMsgMetaData(metadata), data); |
|||
|
|||
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock); |
|||
when(ctxMock.getTenantId()).thenReturn(TENANT_ID); |
|||
doAnswer(invocation -> { |
|||
TelemetryNodeCallback callback = invocation.getArgument(5); |
|||
callback.onSuccess(null); |
|||
return null; |
|||
}).when(telemetryServiceMock).saveWithoutLatestAndNotify(any(), any(), any(), anyList(), anyLong(), any()); |
|||
|
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
List<TsKvEntry> expectedList = getTsKvEntriesListWithTs(data, ts); |
|||
ArgumentCaptor<List<TsKvEntry>> entryListCaptor = ArgumentCaptor.forClass(List.class); |
|||
verify(telemetryServiceMock).saveWithoutLatestAndNotify( |
|||
eq(TENANT_ID), isNull(), eq(DEVICE_ID), entryListCaptor.capture(), eq(ttlFromConfig), any(TelemetryNodeCallback.class)); |
|||
assertThat(entryListCaptor.getValue()).containsExactlyElementsOf(expectedList); |
|||
verify(ctxMock).tellSuccess(msg); |
|||
verifyNoMoreInteractions(ctxMock, telemetryServiceMock); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@MethodSource |
|||
public void givenTtlFromConfigAndTtlFromMd_whenOnMsg_thenVerifyTtl(String ttlFromMd, long ttlFromConfig, long expectedTtl) throws TbNodeException { |
|||
config.setDefaultTTL(ttlFromConfig); |
|||
init(); |
|||
|
|||
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock); |
|||
when(ctxMock.getTenantId()).thenReturn(TENANT_ID); |
|||
|
|||
String data = """ |
|||
{ |
|||
"temp": 45, |
|||
"humidity": 77 |
|||
} |
|||
"""; |
|||
var metadata = new TbMsgMetaData(); |
|||
metadata.putValue("TTL", ttlFromMd); |
|||
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metadata, data); |
|||
node.onMsg(ctxMock, msg); |
|||
|
|||
verify(telemetryServiceMock).saveAndNotify(eq(TENANT_ID), isNull(), eq(DEVICE_ID), anyList(), eq(expectedTtl), any(TelemetryNodeCallback.class)); |
|||
} |
|||
|
|||
private static Stream<Arguments> givenTtlFromConfigAndTtlFromMd_whenOnMsg_thenVerifyTtl() { |
|||
return Stream.of( |
|||
// when ttl is present in metadata and it is not zero then ttl = ttl from metadata
|
|||
Arguments.of("1", 2L, 1L), |
|||
// when ttl is absent in metadata and present in config and it is not zero then ttl = ttl from config
|
|||
Arguments.of("", 3L, 3L), |
|||
Arguments.of(null, 4L, 4L), |
|||
// when ttl is present in metadata or config but it is zero then ttl = default ttl from tenant profile
|
|||
Arguments.of("0", 0L, TimeUnit.DAYS.toSeconds(5L)) |
|||
); |
|||
} |
|||
|
|||
private void init() throws TbNodeException { |
|||
var configuration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
|||
var tenantProfile = getTenantProfile(); |
|||
when(ctxMock.getTenantProfile()).thenReturn(tenantProfile); |
|||
tenantProfile.getProfileConfiguration().ifPresent(profileConfiguration -> |
|||
tenantProfileDefaultStorageTtl = TimeUnit.DAYS.toSeconds(profileConfiguration.getDefaultStorageTtlDays())); |
|||
node.init(ctxMock, configuration); |
|||
verify(ctxMock).addTenantProfileListener(any()); |
|||
} |
|||
|
|||
private TenantProfile getTenantProfile() { |
|||
var tenantProfile = new TenantProfile(TENANT_PROFILE_ID); |
|||
var tenantProfileData = new TenantProfileData(); |
|||
var tenantProfileConfiguration = new DefaultTenantProfileConfiguration(); |
|||
tenantProfileConfiguration.setDefaultStorageTtlDays(5); |
|||
tenantProfileData.setConfiguration(tenantProfileConfiguration); |
|||
tenantProfile.setProfileData(tenantProfileData); |
|||
return tenantProfile; |
|||
} |
|||
|
|||
private static List<TsKvEntry> getTsKvEntriesListWithTs(String data, long ts) { |
|||
Map<Long, List<KvEntry>> tsKvMap = JsonConverter.convertToTelemetry(JsonParser.parseString(data), ts); |
|||
List<TsKvEntry> expectedList = new ArrayList<>(); |
|||
for (Map.Entry<Long, List<KvEntry>> tsKvEntry : tsKvMap.entrySet()) { |
|||
for (KvEntry kvEntry : tsKvEntry.getValue()) { |
|||
expectedList.add(new BasicTsKvEntry(tsKvEntry.getKey(), kvEntry)); |
|||
} |
|||
} |
|||
return expectedList; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2024 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. |
|||
|
|||
--> |
|||
<ng-container [formGroup]="labelCardWidgetConfigForm"> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" translate>widget-config.appearance</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="autoScale"> |
|||
{{ 'widgets.label-card.auto-scale' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width" translate>widgets.label-card.label</div> |
|||
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
<tb-font-settings formControlName="labelFont" |
|||
[previewText]="labelCardWidgetConfigForm.get('label').value"> |
|||
</tb-font-settings> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="labelColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon"> |
|||
{{ 'widgets.label-card.icon' | translate }} |
|||
</mat-slide-toggle> |
|||
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic"> |
|||
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
<tb-css-unit-select fxFlex formControlName="iconSizeUnit"></tb-css-unit-select> |
|||
<tb-material-icon-select asBoxInput |
|||
iconClearButton |
|||
[color]="labelCardWidgetConfigForm.get('iconColor').value" |
|||
formControlName="icon"> |
|||
</tb-material-icon-select> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="iconColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.background.background' | translate }}</div> |
|||
<tb-background-settings formControlName="background"> |
|||
</tb-background-settings> |
|||
</div> |
|||
<div class="tb-form-row space-between column-lt-md"> |
|||
<div translate>widget-config.show-card-buttons</div> |
|||
<mat-chip-listbox multiple formControlName="cardButtons"> |
|||
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option> |
|||
</mat-chip-listbox> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widget-config.card-border-radius' | translate }}</div> |
|||
<mat-form-field appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widget-config.card-padding' | translate }}</div> |
|||
<mat-form-field appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<tb-widget-actions-panel |
|||
formControlName="actions"> |
|||
</tb-widget-actions-panel> |
|||
</ng-container> |
|||
@ -0,0 +1,130 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 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.
|
|||
///
|
|||
|
|||
import { Component } from '@angular/core'; |
|||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; |
|||
import { WidgetConfigComponentData } from '@home/models/widget-component.models'; |
|||
import { WidgetConfig, } from '@shared/models/widget.models'; |
|||
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; |
|||
import { isUndefined } from '@core/utils'; |
|||
import { |
|||
labelCardWidgetDefaultSettings, |
|||
LabelCardWidgetSettings |
|||
} from '@home/components/widget/lib/cards/label-card-widget.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-label-card-basic-config', |
|||
templateUrl: './label-card-basic-config.component.html', |
|||
styleUrls: ['../basic-config.scss'] |
|||
}) |
|||
export class LabelCardBasicConfigComponent extends BasicWidgetConfigComponent { |
|||
|
|||
labelCardWidgetConfigForm: UntypedFormGroup; |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
protected widgetConfigComponent: WidgetConfigComponent, |
|||
private fb: UntypedFormBuilder) { |
|||
super(store, widgetConfigComponent); |
|||
} |
|||
|
|||
protected configForm(): UntypedFormGroup { |
|||
return this.labelCardWidgetConfigForm; |
|||
} |
|||
|
|||
protected onConfigSet(configData: WidgetConfigComponentData) { |
|||
const settings: LabelCardWidgetSettings = {...labelCardWidgetDefaultSettings, ...(configData.config.settings || {})}; |
|||
this.labelCardWidgetConfigForm = this.fb.group({ |
|||
autoScale: [settings.autoScale, []], |
|||
|
|||
label: [settings.label, []], |
|||
labelFont: [settings.labelFont, []], |
|||
labelColor: [settings.labelColor, []], |
|||
|
|||
showIcon: [settings.showIcon, []], |
|||
iconSize: [settings.iconSize, [Validators.min(0)]], |
|||
iconSizeUnit: [settings.iconSizeUnit, []], |
|||
icon: [settings.icon, []], |
|||
iconColor: [settings.iconColor, []], |
|||
|
|||
background: [settings.background, []], |
|||
|
|||
cardButtons: [this.getCardButtons(configData.config), []], |
|||
borderRadius: [configData.config.borderRadius, []], |
|||
padding: [settings.padding, []], |
|||
|
|||
actions: [configData.config.actions || {}, []] |
|||
}); |
|||
} |
|||
|
|||
protected prepareOutputConfig(config: any): WidgetConfigComponentData { |
|||
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; |
|||
|
|||
this.widgetConfig.config.settings.autoScale = config.autoScale; |
|||
|
|||
this.widgetConfig.config.settings.label = config.label; |
|||
this.widgetConfig.config.settings.labelFont = config.labelFont; |
|||
this.widgetConfig.config.settings.labelColor = config.labelColor; |
|||
|
|||
this.widgetConfig.config.settings.showIcon = config.showIcon; |
|||
this.widgetConfig.config.settings.iconSize = config.iconSize; |
|||
this.widgetConfig.config.settings.iconSizeUnit = config.iconSizeUnit; |
|||
this.widgetConfig.config.settings.icon = config.icon; |
|||
this.widgetConfig.config.settings.iconColor = config.iconColor; |
|||
|
|||
this.widgetConfig.config.settings.background = config.background; |
|||
|
|||
this.setCardButtons(config.cardButtons, this.widgetConfig.config); |
|||
this.widgetConfig.config.borderRadius = config.borderRadius; |
|||
this.widgetConfig.config.settings.padding = config.padding; |
|||
|
|||
this.widgetConfig.config.actions = config.actions; |
|||
return this.widgetConfig; |
|||
} |
|||
|
|||
protected validatorTriggers(): string[] { |
|||
return ['showIcon']; |
|||
} |
|||
|
|||
protected updateValidators(emitEvent: boolean, trigger?: string) { |
|||
const showIcon: boolean = this.labelCardWidgetConfigForm.get('showIcon').value; |
|||
if (showIcon) { |
|||
this.labelCardWidgetConfigForm.get('iconSize').enable(); |
|||
this.labelCardWidgetConfigForm.get('iconSizeUnit').enable(); |
|||
this.labelCardWidgetConfigForm.get('icon').enable(); |
|||
this.labelCardWidgetConfigForm.get('iconColor').enable(); |
|||
} else { |
|||
this.labelCardWidgetConfigForm.get('iconSize').disable(); |
|||
this.labelCardWidgetConfigForm.get('iconSizeUnit').disable(); |
|||
this.labelCardWidgetConfigForm.get('icon').disable(); |
|||
this.labelCardWidgetConfigForm.get('iconColor').disable(); |
|||
} |
|||
} |
|||
|
|||
private getCardButtons(config: WidgetConfig): string[] { |
|||
const buttons: string[] = []; |
|||
if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { |
|||
buttons.push('fullscreen'); |
|||
} |
|||
return buttons; |
|||
} |
|||
|
|||
private setCardButtons(buttons: string[], config: WidgetConfig) { |
|||
config.enableFullscreen = buttons.includes('fullscreen'); |
|||
} |
|||
} |
|||
@ -0,0 +1,114 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2024 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. |
|||
|
|||
--> |
|||
<ng-container [formGroup]="labelValueCardWidgetConfigForm"> |
|||
<tb-timewindow-config-panel *ngIf="displayTimewindowConfig" |
|||
[onlyHistoryTimewindow]="onlyHistoryTimewindow()" |
|||
formControlName="timewindowConfig"> |
|||
</tb-timewindow-config-panel> |
|||
<tb-datasources |
|||
[configMode]="basicMode" |
|||
hideDatasourceLabel |
|||
hideDataKeyLabel |
|||
hideDataKeyColor |
|||
hideDataKeyUnits |
|||
hideDataKeyDecimals |
|||
formControlName="datasources"> |
|||
</tb-datasources> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" translate>widget-config.appearance</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="autoScale"> |
|||
{{ 'widgets.label-card.auto-scale' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showLabel"> |
|||
{{ 'widgets.label-card.label' | translate }} |
|||
</mat-slide-toggle> |
|||
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
<tb-font-settings formControlName="labelFont" |
|||
[previewText]="labelValueCardWidgetConfigForm.get('label').value"> |
|||
</tb-font-settings> |
|||
<tb-color-settings formControlName="labelColor" settingsKey="{{'widgets.label-card.label' | translate }}"> |
|||
</tb-color-settings> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon"> |
|||
{{ 'widgets.label-card.icon' | translate }} |
|||
</mat-slide-toggle> |
|||
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic"> |
|||
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
<tb-css-unit-select fxFlex formControlName="iconSizeUnit"></tb-css-unit-select> |
|||
<tb-material-icon-select asBoxInput |
|||
iconClearButton |
|||
[color]="labelValueCardWidgetConfigForm.get('iconColor').value?.color" |
|||
formControlName="icon"> |
|||
</tb-material-icon-select> |
|||
<tb-color-settings formControlName="iconColor" settingsKey="{{'widgets.label-card.icon' | translate }}"> |
|||
</tb-color-settings> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width" translate>widgets.label-value-card.value</div> |
|||
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<tb-unit-input class="flex" formControlName="units"></tb-unit-input> |
|||
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
<div matSuffix fxHide.lt-md translate>widget-config.decimals-suffix</div> |
|||
</mat-form-field> |
|||
<tb-font-settings formControlName="valueFont" |
|||
[previewText]="valuePreviewFn"> |
|||
</tb-font-settings> |
|||
<tb-color-settings formControlName="valueColor" settingsKey="{{'widgets.label-value-card.value' | translate }}"> |
|||
</tb-color-settings> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.background.background' | translate }}</div> |
|||
<tb-background-settings formControlName="background"> |
|||
</tb-background-settings> |
|||
</div> |
|||
<div class="tb-form-row space-between column-lt-md"> |
|||
<div translate>widget-config.show-card-buttons</div> |
|||
<mat-chip-listbox multiple formControlName="cardButtons"> |
|||
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option> |
|||
</mat-chip-listbox> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widget-config.card-border-radius' | translate }}</div> |
|||
<mat-form-field appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widget-config.card-padding' | translate }}</div> |
|||
<mat-form-field appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<tb-widget-actions-panel |
|||
formControlName="actions"> |
|||
</tb-widget-actions-panel> |
|||
</ng-container> |
|||
@ -0,0 +1,191 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 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.
|
|||
///
|
|||
|
|||
import { Component } from '@angular/core'; |
|||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; |
|||
import { WidgetConfigComponentData } from '@home/models/widget-component.models'; |
|||
import { |
|||
DataKey, |
|||
datasourcesHasAggregation, |
|||
datasourcesHasOnlyComparisonAggregation, |
|||
WidgetConfig, |
|||
} from '@shared/models/widget.models'; |
|||
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; |
|||
import { formatValue, isUndefined } from '@core/utils'; |
|||
import { |
|||
labelValueCardWidgetDefaultSettings, |
|||
LabelValueCardWidgetSettings |
|||
} from '@home/components/widget/lib/cards/label-value-card-widget.models'; |
|||
import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; |
|||
import { |
|||
getTimewindowConfig, |
|||
setTimewindowConfig |
|||
} from '@home/components/widget/config/timewindow-config-panel.component'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-label-value-card-basic-config', |
|||
templateUrl: './label-value-card-basic-config.component.html', |
|||
styleUrls: ['../basic-config.scss'] |
|||
}) |
|||
export class LabelValueCardBasicConfigComponent extends BasicWidgetConfigComponent { |
|||
|
|||
public get displayTimewindowConfig(): boolean { |
|||
const datasources = this.labelValueCardWidgetConfigForm.get('datasources').value; |
|||
return datasourcesHasAggregation(datasources); |
|||
} |
|||
|
|||
public onlyHistoryTimewindow(): boolean { |
|||
const datasources = this.labelValueCardWidgetConfigForm.get('datasources').value; |
|||
return datasourcesHasOnlyComparisonAggregation(datasources); |
|||
} |
|||
|
|||
labelValueCardWidgetConfigForm: UntypedFormGroup; |
|||
|
|||
valuePreviewFn = this._valuePreviewFn.bind(this); |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
protected widgetConfigComponent: WidgetConfigComponent, |
|||
private fb: UntypedFormBuilder) { |
|||
super(store, widgetConfigComponent); |
|||
} |
|||
|
|||
protected configForm(): UntypedFormGroup { |
|||
return this.labelValueCardWidgetConfigForm; |
|||
} |
|||
|
|||
protected defaultDataKeys(configData: WidgetConfigComponentData): DataKey[] { |
|||
return [{ name: 'temperature', label: 'Temperature', type: DataKeyType.timeseries }]; |
|||
} |
|||
|
|||
protected onConfigSet(configData: WidgetConfigComponentData) { |
|||
const settings: LabelValueCardWidgetSettings = {...labelValueCardWidgetDefaultSettings, ...(configData.config.settings || {})}; |
|||
this.labelValueCardWidgetConfigForm = this.fb.group({ |
|||
timewindowConfig: [getTimewindowConfig(configData.config), []], |
|||
datasources: [configData.config.datasources, []], |
|||
autoScale: [settings.autoScale, []], |
|||
|
|||
showLabel: [settings.showLabel, []], |
|||
label: [settings.label, []], |
|||
labelFont: [settings.labelFont, []], |
|||
labelColor: [settings.labelColor, []], |
|||
|
|||
showIcon: [settings.showIcon, []], |
|||
iconSize: [settings.iconSize, [Validators.min(0)]], |
|||
iconSizeUnit: [settings.iconSizeUnit, []], |
|||
icon: [settings.icon, []], |
|||
iconColor: [settings.iconColor, []], |
|||
|
|||
units: [configData.config.units, []], |
|||
decimals: [configData.config.decimals, []], |
|||
valueFont: [settings.valueFont, []], |
|||
valueColor: [settings.valueColor, []], |
|||
|
|||
background: [settings.background, []], |
|||
|
|||
cardButtons: [this.getCardButtons(configData.config), []], |
|||
borderRadius: [configData.config.borderRadius, []], |
|||
padding: [settings.padding, []], |
|||
|
|||
actions: [configData.config.actions || {}, []] |
|||
}); |
|||
} |
|||
|
|||
protected prepareOutputConfig(config: any): WidgetConfigComponentData { |
|||
setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig); |
|||
this.widgetConfig.config.datasources = config.datasources; |
|||
|
|||
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; |
|||
|
|||
this.widgetConfig.config.settings.autoScale = config.autoScale; |
|||
|
|||
this.widgetConfig.config.settings.showLabel = config.showLabel; |
|||
this.widgetConfig.config.settings.label = config.label; |
|||
this.widgetConfig.config.settings.labelFont = config.labelFont; |
|||
this.widgetConfig.config.settings.labelColor = config.labelColor; |
|||
|
|||
this.widgetConfig.config.settings.showIcon = config.showIcon; |
|||
this.widgetConfig.config.settings.iconSize = config.iconSize; |
|||
this.widgetConfig.config.settings.iconSizeUnit = config.iconSizeUnit; |
|||
this.widgetConfig.config.settings.icon = config.icon; |
|||
this.widgetConfig.config.settings.iconColor = config.iconColor; |
|||
|
|||
this.widgetConfig.config.units = config.units; |
|||
this.widgetConfig.config.decimals = config.decimals; |
|||
this.widgetConfig.config.settings.valueFont = config.valueFont; |
|||
this.widgetConfig.config.settings.valueColor = config.valueColor; |
|||
|
|||
this.widgetConfig.config.settings.background = config.background; |
|||
|
|||
this.setCardButtons(config.cardButtons, this.widgetConfig.config); |
|||
this.widgetConfig.config.borderRadius = config.borderRadius; |
|||
this.widgetConfig.config.settings.padding = config.padding; |
|||
|
|||
this.widgetConfig.config.actions = config.actions; |
|||
return this.widgetConfig; |
|||
} |
|||
|
|||
protected validatorTriggers(): string[] { |
|||
return ['showLabel', 'showIcon']; |
|||
} |
|||
|
|||
protected updateValidators(emitEvent: boolean, trigger?: string) { |
|||
const showLabel: boolean = this.labelValueCardWidgetConfigForm.get('showLabel').value; |
|||
const showIcon: boolean = this.labelValueCardWidgetConfigForm.get('showIcon').value; |
|||
|
|||
if (showLabel) { |
|||
this.labelValueCardWidgetConfigForm.get('label').enable(); |
|||
this.labelValueCardWidgetConfigForm.get('labelFont').enable(); |
|||
this.labelValueCardWidgetConfigForm.get('labelColor').enable(); |
|||
} else { |
|||
this.labelValueCardWidgetConfigForm.get('label').disable(); |
|||
this.labelValueCardWidgetConfigForm.get('labelFont').disable(); |
|||
this.labelValueCardWidgetConfigForm.get('labelColor').disable(); |
|||
} |
|||
|
|||
if (showIcon) { |
|||
this.labelValueCardWidgetConfigForm.get('iconSize').enable(); |
|||
this.labelValueCardWidgetConfigForm.get('iconSizeUnit').enable(); |
|||
this.labelValueCardWidgetConfigForm.get('icon').enable(); |
|||
this.labelValueCardWidgetConfigForm.get('iconColor').enable(); |
|||
} else { |
|||
this.labelValueCardWidgetConfigForm.get('iconSize').disable(); |
|||
this.labelValueCardWidgetConfigForm.get('iconSizeUnit').disable(); |
|||
this.labelValueCardWidgetConfigForm.get('icon').disable(); |
|||
this.labelValueCardWidgetConfigForm.get('iconColor').disable(); |
|||
} |
|||
} |
|||
|
|||
private getCardButtons(config: WidgetConfig): string[] { |
|||
const buttons: string[] = []; |
|||
if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { |
|||
buttons.push('fullscreen'); |
|||
} |
|||
return buttons; |
|||
} |
|||
|
|||
private setCardButtons(buttons: string[], config: WidgetConfig) { |
|||
config.enableFullscreen = buttons.includes('fullscreen'); |
|||
} |
|||
|
|||
private _valuePreviewFn(): string { |
|||
const units: string = this.labelValueCardWidgetConfigForm.get('units').value; |
|||
const decimals: number = this.labelValueCardWidgetConfigForm.get('decimals').value; |
|||
return formatValue(22, decimals, units, true); |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2024 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. |
|||
|
|||
--> |
|||
<div #labelCardPanel class="tb-label-card-panel" [style.padding]="padding" [style]="backgroundStyle$ | async" |
|||
[class.tb-label-card-pointer]="hasCardClickAction" (click)="cardClick($event)"> |
|||
<div class="tb-label-card-overlay" [style]="overlayStyle"></div> |
|||
<div class="tb-label-card-title-panel"> |
|||
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> |
|||
</div> |
|||
<div #labelCardContent class="tb-label-card-content"> |
|||
<div #labelCardRow class="tb-label-card-row"> |
|||
<tb-icon *ngIf="showIcon" [style]="iconStyle">{{ icon }}</tb-icon> |
|||
<div [class.tb-label-card-label]="!settings.autoScale" [style]="labelStyle" [innerHTML]="label | safe:'html'"></div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,70 @@ |
|||
/** |
|||
* Copyright © 2016-2024 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. |
|||
*/ |
|||
|
|||
.tb-label-card-panel { |
|||
width: 100%; |
|||
height: 100%; |
|||
position: relative; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
padding: 20px 24px; |
|||
&.tb-label-card-pointer { |
|||
cursor: pointer; |
|||
} |
|||
> div:not(.tb-label-card-overlay), > tb-icon { |
|||
z-index: 1; |
|||
} |
|||
.tb-label-card-overlay { |
|||
position: absolute; |
|||
top: 12px; |
|||
left: 12px; |
|||
bottom: 12px; |
|||
right: 12px; |
|||
} |
|||
> div.tb-label-card-title-panel { |
|||
position: absolute; |
|||
top: 12px; |
|||
left: 12px; |
|||
right: 12px; |
|||
z-index: 2; |
|||
} |
|||
div.tb-widget-title { |
|||
padding: 0; |
|||
} |
|||
.tb-label-card-content { |
|||
width: 100%; |
|||
height: 100%; |
|||
position: relative; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
.tb-label-card-row { |
|||
display: flex; |
|||
flex-direction: row; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 8px; |
|||
} |
|||
.tb-label-card-label { |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
-webkit-line-clamp: 2; |
|||
display: -webkit-box; |
|||
-webkit-box-orient: vertical; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,170 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 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.
|
|||
///
|
|||
|
|||
import { |
|||
AfterViewInit, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
ElementRef, |
|||
Input, |
|||
OnDestroy, |
|||
OnInit, |
|||
Renderer2, |
|||
TemplateRef, |
|||
ViewChild, |
|||
ViewEncapsulation |
|||
} from '@angular/core'; |
|||
import { |
|||
labelCardWidgetDefaultSettings, |
|||
LabelCardWidgetSettings |
|||
} from '@home/components/widget/lib/cards/label-card-widget.models'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { Observable } from 'rxjs'; |
|||
import { |
|||
backgroundStyle, |
|||
ComponentStyle, |
|||
iconStyle, |
|||
overlayStyle, |
|||
resolveCssSize, |
|||
textStyle |
|||
} from '@shared/models/widget-settings.models'; |
|||
import { ResizeObserver } from '@juggle/resize-observer'; |
|||
import { ImagePipe } from '@shared/pipe/image.pipe'; |
|||
import { DomSanitizer } from '@angular/platform-browser'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-label-card-widget', |
|||
templateUrl: './label-card-widget.component.html', |
|||
styleUrls: ['./label-card-widget.component.scss'], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class LabelCardWidgetComponent implements OnInit, AfterViewInit, OnDestroy { |
|||
|
|||
@ViewChild('labelCardPanel', {static: false}) |
|||
labelCardPanel: ElementRef<HTMLElement>; |
|||
|
|||
@ViewChild('labelCardContent', {static: false}) |
|||
labelCardContent: ElementRef<HTMLElement>; |
|||
|
|||
@ViewChild('labelCardRow', {static: false}) |
|||
labelCardRow: ElementRef<HTMLElement>; |
|||
|
|||
settings: LabelCardWidgetSettings; |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
@Input() |
|||
widgetTitlePanel: TemplateRef<any>; |
|||
|
|||
backgroundStyle$: Observable<ComponentStyle>; |
|||
overlayStyle: ComponentStyle = {}; |
|||
padding: string; |
|||
|
|||
label: string; |
|||
labelStyle: ComponentStyle = {}; |
|||
|
|||
showIcon = true; |
|||
icon = ''; |
|||
iconStyle: ComponentStyle = {}; |
|||
|
|||
hasCardClickAction = false; |
|||
|
|||
private panelResize$: ResizeObserver; |
|||
|
|||
constructor(private imagePipe: ImagePipe, |
|||
private sanitizer: DomSanitizer, |
|||
private renderer: Renderer2, |
|||
private cd: ChangeDetectorRef) { |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.ctx.$scope.labelCardWidget = this; |
|||
this.settings = {...labelCardWidgetDefaultSettings, ...this.ctx.settings}; |
|||
|
|||
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); |
|||
this.overlayStyle = overlayStyle(this.settings.background.overlay); |
|||
this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; |
|||
|
|||
this.showIcon = this.settings.showIcon; |
|||
this.icon = this.settings.icon; |
|||
this.iconStyle = iconStyle(this.settings.iconSize, this.settings.iconSizeUnit); |
|||
this.iconStyle.color = this.settings.iconColor; |
|||
|
|||
this.label = this.settings.label; |
|||
this.labelStyle = textStyle(this.settings.labelFont); |
|||
this.labelStyle.color = this.settings.labelColor; |
|||
|
|||
this.hasCardClickAction = this.ctx.actionsApi.getActionDescriptors('cardClick').length > 0; |
|||
} |
|||
|
|||
public ngAfterViewInit() { |
|||
if (this.settings.autoScale) { |
|||
this.renderer.setStyle(this.labelCardContent.nativeElement, 'overflow', 'visible'); |
|||
this.renderer.setStyle(this.labelCardContent.nativeElement, 'position', 'absolute'); |
|||
this.panelResize$ = new ResizeObserver(() => { |
|||
this.onResize(); |
|||
}); |
|||
this.panelResize$.observe(this.labelCardPanel.nativeElement); |
|||
this.onResize(); |
|||
} |
|||
} |
|||
|
|||
ngOnDestroy() { |
|||
if (this.panelResize$) { |
|||
this.panelResize$.disconnect(); |
|||
} |
|||
} |
|||
|
|||
public onInit() { |
|||
const borderRadius = this.ctx.$widgetElement.css('borderRadius'); |
|||
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; |
|||
this.cd.detectChanges(); |
|||
} |
|||
|
|||
public cardClick($event: Event) { |
|||
this.ctx.actionsApi.cardClick($event); |
|||
} |
|||
|
|||
private onResize() { |
|||
const paddingLeft = getComputedStyle(this.labelCardPanel.nativeElement).paddingLeft; |
|||
const paddingRight = getComputedStyle(this.labelCardPanel.nativeElement).paddingRight; |
|||
const paddingTop = getComputedStyle(this.labelCardPanel.nativeElement).paddingTop; |
|||
const paddingBottom = getComputedStyle(this.labelCardPanel.nativeElement).paddingBottom; |
|||
const pLeft = resolveCssSize(paddingLeft)[0]; |
|||
const pRight = resolveCssSize(paddingRight)[0]; |
|||
const pTop = resolveCssSize(paddingTop)[0]; |
|||
const pBottom = resolveCssSize(paddingBottom)[0]; |
|||
const panelWidth = this.labelCardPanel.nativeElement.getBoundingClientRect().width - (pLeft + pRight); |
|||
const panelHeight = this.labelCardPanel.nativeElement.getBoundingClientRect().height - (pTop + pBottom); |
|||
this.renderer.setStyle(this.labelCardContent.nativeElement, 'width', 'auto'); |
|||
this.renderer.setStyle(this.labelCardContent.nativeElement, 'transform', `none`); |
|||
const contentWidth = this.labelCardRow.nativeElement.getBoundingClientRect().width; |
|||
const contentHeight = this.labelCardRow.nativeElement.getBoundingClientRect().height; |
|||
const panelAspect = panelWidth / panelHeight; |
|||
const contentAspect = contentWidth / contentHeight; |
|||
let scale: number; |
|||
if (contentAspect > panelAspect) { |
|||
scale = panelWidth / contentWidth; |
|||
} else { |
|||
scale = panelHeight / contentHeight; |
|||
} |
|||
const width = panelWidth / scale; |
|||
this.renderer.setStyle(this.labelCardContent.nativeElement, 'width', width + 'px'); |
|||
this.renderer.setStyle(this.labelCardContent.nativeElement, 'transform', `scale(${scale})`); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,60 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 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.
|
|||
///
|
|||
|
|||
import { BackgroundSettings, BackgroundType, cssUnit, Font } from '@shared/models/widget-settings.models'; |
|||
|
|||
export interface LabelCardWidgetSettings { |
|||
autoScale: boolean; |
|||
label: string; |
|||
labelFont: Font; |
|||
labelColor: string; |
|||
showIcon: boolean; |
|||
icon: string; |
|||
iconSize: number; |
|||
iconSizeUnit: cssUnit; |
|||
iconColor: string; |
|||
background: BackgroundSettings; |
|||
padding: string; |
|||
} |
|||
|
|||
export const labelCardWidgetDefaultSettings: LabelCardWidgetSettings = { |
|||
autoScale: true, |
|||
label: 'Thermostat', |
|||
labelFont: { |
|||
family: 'Roboto', |
|||
size: 20, |
|||
sizeUnit: 'px', |
|||
style: 'normal', |
|||
weight: '400', |
|||
lineHeight: '24px' |
|||
}, |
|||
labelColor: 'rgba(0, 0, 0, 0.87)', |
|||
showIcon: true, |
|||
icon: 'thermostat', |
|||
iconSize: 24, |
|||
iconSizeUnit: 'px', |
|||
iconColor: '#5469FF', |
|||
background: { |
|||
type: BackgroundType.color, |
|||
color: '#fff', |
|||
overlay: { |
|||
enabled: false, |
|||
color: 'rgba(255,255,255,0.72)', |
|||
blur: 3 |
|||
} |
|||
}, |
|||
padding: '12px' |
|||
}; |
|||
@ -0,0 +1,34 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2024 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. |
|||
|
|||
--> |
|||
<div #labelCardPanel class="tb-label-value-card-panel" [style.padding]="padding" [style]="backgroundStyle$ | async" |
|||
[class.tb-label-value-card-pointer]="hasCardClickAction" (click)="cardClick($event)"> |
|||
<div class="tb-label-value-card-overlay" [style]="overlayStyle"></div> |
|||
<div class="tb-label-value-card-title-panel"> |
|||
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> |
|||
</div> |
|||
<div #labelCardContent class="tb-label-value-card-content"> |
|||
<div #labelCardRow class="tb-label-value-card-row"> |
|||
<div *ngIf="showIcon || showLabel" class="tb-label-value-card-icon-label"> |
|||
<tb-icon *ngIf="showIcon" [style]="iconStyle" [style.color]="iconColor.color">{{ icon }}</tb-icon> |
|||
<div *ngIf="showLabel" [class.tb-label-value-card-label]="!settings.autoScale" [style]="labelStyle" [style.color]="labelColor.color" |
|||
[innerHTML]="label$ | async | safe:'html'"></div> |
|||
</div> |
|||
<div class="tb-label-value-card-value" [style]="valueStyle" [style.color]="valueColor.color">{{ valueText }}</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,80 @@ |
|||
/** |
|||
* Copyright © 2016-2024 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. |
|||
*/ |
|||
|
|||
.tb-label-value-card-panel { |
|||
width: 100%; |
|||
height: 100%; |
|||
position: relative; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
padding: 20px 24px; |
|||
&.tb-label-value-card-pointer { |
|||
cursor: pointer; |
|||
} |
|||
> div:not(.tb-label-value-card-overlay), > tb-icon { |
|||
z-index: 1; |
|||
} |
|||
.tb-label-value-card-overlay { |
|||
position: absolute; |
|||
top: 12px; |
|||
left: 12px; |
|||
bottom: 12px; |
|||
right: 12px; |
|||
} |
|||
> div.tb-label-value-card-title-panel { |
|||
position: absolute; |
|||
top: 12px; |
|||
left: 12px; |
|||
right: 12px; |
|||
z-index: 2; |
|||
} |
|||
div.tb-widget-title { |
|||
padding: 0; |
|||
} |
|||
.tb-label-value-card-content { |
|||
width: 100%; |
|||
height: 100%; |
|||
position: relative; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
.tb-label-value-card-row { |
|||
display: flex; |
|||
flex-direction: row; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 16px; |
|||
} |
|||
.tb-label-value-card-icon-label { |
|||
display: flex; |
|||
flex-direction: row; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 8px; |
|||
} |
|||
.tb-label-value-card-label { |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
-webkit-line-clamp: 2; |
|||
display: -webkit-box; |
|||
-webkit-box-orient: vertical; |
|||
} |
|||
.tb-label-value-card-value { |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,218 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 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.
|
|||
///
|
|||
|
|||
import { |
|||
AfterViewInit, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
ElementRef, |
|||
Input, |
|||
OnDestroy, |
|||
OnInit, |
|||
Renderer2, |
|||
TemplateRef, |
|||
ViewChild, |
|||
ViewEncapsulation |
|||
} from '@angular/core'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { Observable } from 'rxjs'; |
|||
import { |
|||
backgroundStyle, |
|||
ColorProcessor, |
|||
ComponentStyle, |
|||
getDataKey, |
|||
getSingleTsValue, |
|||
iconStyle, |
|||
overlayStyle, |
|||
resolveCssSize, |
|||
textStyle |
|||
} from '@shared/models/widget-settings.models'; |
|||
import { ResizeObserver } from '@juggle/resize-observer'; |
|||
import { ImagePipe } from '@shared/pipe/image.pipe'; |
|||
import { DomSanitizer } from '@angular/platform-browser'; |
|||
import { |
|||
labelValueCardWidgetDefaultSettings, |
|||
LabelValueCardWidgetSettings |
|||
} from '@home/components/widget/lib/cards/label-value-card-widget.models'; |
|||
import { formatValue, isDefinedAndNotNull } from '@core/utils'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-label-value-card-widget', |
|||
templateUrl: './label-value-card-widget.component.html', |
|||
styleUrls: ['./label-value-card-widget.component.scss'], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class LabelValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestroy { |
|||
|
|||
@ViewChild('labelCardPanel', {static: false}) |
|||
labelCardPanel: ElementRef<HTMLElement>; |
|||
|
|||
@ViewChild('labelCardContent', {static: false}) |
|||
labelCardContent: ElementRef<HTMLElement>; |
|||
|
|||
@ViewChild('labelCardRow', {static: false}) |
|||
labelCardRow: ElementRef<HTMLElement>; |
|||
|
|||
settings: LabelValueCardWidgetSettings; |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
@Input() |
|||
widgetTitlePanel: TemplateRef<any>; |
|||
|
|||
backgroundStyle$: Observable<ComponentStyle>; |
|||
overlayStyle: ComponentStyle = {}; |
|||
padding: string; |
|||
|
|||
showLabel = true; |
|||
label$: Observable<string>; |
|||
labelStyle: ComponentStyle = {}; |
|||
labelColor: ColorProcessor; |
|||
|
|||
valueText = 'N/A'; |
|||
valueStyle: ComponentStyle = {}; |
|||
valueColor: ColorProcessor; |
|||
|
|||
showIcon = true; |
|||
icon = ''; |
|||
iconStyle: ComponentStyle = {}; |
|||
iconColor: ColorProcessor; |
|||
|
|||
hasCardClickAction = false; |
|||
|
|||
private panelResize$: ResizeObserver; |
|||
|
|||
private decimals = 0; |
|||
private units = ''; |
|||
|
|||
constructor(private imagePipe: ImagePipe, |
|||
private sanitizer: DomSanitizer, |
|||
private renderer: Renderer2, |
|||
private cd: ChangeDetectorRef) { |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.ctx.$scope.labelValueCardWidget = this; |
|||
this.settings = {...labelValueCardWidgetDefaultSettings, ...this.ctx.settings}; |
|||
|
|||
this.decimals = this.ctx.decimals; |
|||
this.units = this.ctx.units; |
|||
const dataKey = getDataKey(this.ctx.datasources); |
|||
if (isDefinedAndNotNull(dataKey?.decimals)) { |
|||
this.decimals = dataKey.decimals; |
|||
} |
|||
if (dataKey?.units) { |
|||
this.units = dataKey.units; |
|||
} |
|||
|
|||
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); |
|||
this.overlayStyle = overlayStyle(this.settings.background.overlay); |
|||
this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; |
|||
|
|||
this.showIcon = this.settings.showIcon; |
|||
this.icon = this.settings.icon; |
|||
this.iconStyle = iconStyle(this.settings.iconSize, this.settings.iconSizeUnit); |
|||
this.iconColor = ColorProcessor.fromSettings(this.settings.iconColor); |
|||
|
|||
this.showLabel = this.settings.showLabel; |
|||
const label = this.settings.label; |
|||
this.label$ = this.ctx.registerLabelPattern(label, this.label$); |
|||
this.labelStyle = textStyle(this.settings.labelFont); |
|||
this.labelColor = ColorProcessor.fromSettings(this.settings.labelColor); |
|||
this.valueStyle = textStyle(this.settings.valueFont); |
|||
this.valueColor = ColorProcessor.fromSettings(this.settings.valueColor); |
|||
|
|||
this.hasCardClickAction = this.ctx.actionsApi.getActionDescriptors('cardClick').length > 0; |
|||
} |
|||
|
|||
public ngAfterViewInit() { |
|||
if (this.settings.autoScale) { |
|||
this.renderer.setStyle(this.labelCardContent.nativeElement, 'overflow', 'visible'); |
|||
this.renderer.setStyle(this.labelCardContent.nativeElement, 'position', 'absolute'); |
|||
this.panelResize$ = new ResizeObserver(() => { |
|||
this.onResize(); |
|||
}); |
|||
this.panelResize$.observe(this.labelCardPanel.nativeElement); |
|||
this.onResize(); |
|||
} |
|||
} |
|||
|
|||
ngOnDestroy() { |
|||
if (this.panelResize$) { |
|||
this.panelResize$.disconnect(); |
|||
} |
|||
} |
|||
|
|||
public onInit() { |
|||
const borderRadius = this.ctx.$widgetElement.css('borderRadius'); |
|||
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; |
|||
this.cd.detectChanges(); |
|||
} |
|||
|
|||
public onDataUpdated() { |
|||
const tsValue = getSingleTsValue(this.ctx.data); |
|||
let value; |
|||
if (tsValue && isDefinedAndNotNull(tsValue[1]) && tsValue[0] !== 0) { |
|||
value = tsValue[1]; |
|||
this.valueText = formatValue(value, this.decimals, this.units, false); |
|||
} else { |
|||
this.valueText = 'N/A'; |
|||
} |
|||
this.iconColor.update(value); |
|||
this.labelColor.update(value); |
|||
this.valueColor.update(value); |
|||
this.cd.detectChanges(); |
|||
if (this.settings.autoScale) { |
|||
setTimeout(() => { |
|||
this.onResize(); |
|||
}, 0); |
|||
} |
|||
} |
|||
|
|||
public cardClick($event: Event) { |
|||
this.ctx.actionsApi.cardClick($event); |
|||
} |
|||
|
|||
private onResize() { |
|||
const paddingLeft = getComputedStyle(this.labelCardPanel.nativeElement).paddingLeft; |
|||
const paddingRight = getComputedStyle(this.labelCardPanel.nativeElement).paddingRight; |
|||
const paddingTop = getComputedStyle(this.labelCardPanel.nativeElement).paddingTop; |
|||
const paddingBottom = getComputedStyle(this.labelCardPanel.nativeElement).paddingBottom; |
|||
const pLeft = resolveCssSize(paddingLeft)[0]; |
|||
const pRight = resolveCssSize(paddingRight)[0]; |
|||
const pTop = resolveCssSize(paddingTop)[0]; |
|||
const pBottom = resolveCssSize(paddingBottom)[0]; |
|||
const panelWidth = this.labelCardPanel.nativeElement.getBoundingClientRect().width - (pLeft + pRight); |
|||
const panelHeight = this.labelCardPanel.nativeElement.getBoundingClientRect().height - (pTop + pBottom); |
|||
this.renderer.setStyle(this.labelCardContent.nativeElement, 'width', 'auto'); |
|||
this.renderer.setStyle(this.labelCardContent.nativeElement, 'transform', `none`); |
|||
const contentWidth = this.labelCardRow.nativeElement.getBoundingClientRect().width; |
|||
const contentHeight = this.labelCardRow.nativeElement.getBoundingClientRect().height; |
|||
const panelAspect = panelWidth / panelHeight; |
|||
const contentAspect = contentWidth / contentHeight; |
|||
let scale: number; |
|||
if (contentAspect > panelAspect) { |
|||
scale = panelWidth / contentWidth; |
|||
} else { |
|||
scale = panelHeight / contentHeight; |
|||
} |
|||
const width = panelWidth / scale; |
|||
this.renderer.setStyle(this.labelCardContent.nativeElement, 'width', width + 'px'); |
|||
this.renderer.setStyle(this.labelCardContent.nativeElement, 'transform', `scale(${scale})`); |
|||
} |
|||
|
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue