29 changed files with 2837 additions and 20 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,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,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})`); |
|||
} |
|||
|
|||
} |
|||
@ -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.
|
|||
///
|
|||
|
|||
import { |
|||
BackgroundSettings, |
|||
BackgroundType, |
|||
ColorSettings, |
|||
constantColor, |
|||
cssUnit, |
|||
Font |
|||
} from '@shared/models/widget-settings.models'; |
|||
|
|||
export interface LabelValueCardWidgetSettings { |
|||
autoScale: boolean; |
|||
showLabel: boolean; |
|||
label: string; |
|||
labelFont: Font; |
|||
labelColor: ColorSettings; |
|||
showIcon: boolean; |
|||
icon: string; |
|||
iconSize: number; |
|||
iconSizeUnit: cssUnit; |
|||
iconColor: ColorSettings; |
|||
valueFont: Font; |
|||
valueColor: ColorSettings; |
|||
background: BackgroundSettings; |
|||
padding: string; |
|||
} |
|||
|
|||
export const labelValueCardWidgetDefaultSettings: LabelValueCardWidgetSettings = { |
|||
autoScale: true, |
|||
showLabel: true, |
|||
label: 'Temperature', |
|||
labelFont: { |
|||
family: 'Roboto', |
|||
size: 20, |
|||
sizeUnit: 'px', |
|||
style: 'normal', |
|||
weight: '400', |
|||
lineHeight: '24px' |
|||
}, |
|||
labelColor: constantColor('rgba(0, 0, 0, 0.87)'), |
|||
showIcon: true, |
|||
icon: 'thermostat', |
|||
iconSize: 24, |
|||
iconSizeUnit: 'px', |
|||
iconColor: constantColor('#5469FF'), |
|||
valueFont: { |
|||
family: 'Roboto', |
|||
size: 20, |
|||
sizeUnit: 'px', |
|||
style: 'normal', |
|||
weight: '400', |
|||
lineHeight: '24px' |
|||
}, |
|||
valueColor: constantColor('rgba(0, 0, 0, 0.87)'), |
|||
background: { |
|||
type: BackgroundType.color, |
|||
color: '#fff', |
|||
overlay: { |
|||
enabled: false, |
|||
color: 'rgba(255,255,255,0.72)', |
|||
blur: 3 |
|||
} |
|||
}, |
|||
padding: '12px' |
|||
}; |
|||
@ -0,0 +1,73 @@ |
|||
<!-- |
|||
|
|||
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]="labelCardWidgetSettingsForm"> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" translate>widgets.label-card.label-card-style</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]="labelCardWidgetSettingsForm.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]="labelCardWidgetSettingsForm.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"> |
|||
<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> |
|||
</ng-container> |
|||
@ -0,0 +1,83 @@ |
|||
///
|
|||
/// 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 { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; |
|||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { labelCardWidgetDefaultSettings } from '@home/components/widget/lib/cards/label-card-widget.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-label-card-widget-settings', |
|||
templateUrl: './label-card-widget-settings.component.html', |
|||
styleUrls: [] |
|||
}) |
|||
export class LabelCardWidgetSettingsComponent extends WidgetSettingsComponent { |
|||
|
|||
labelCardWidgetSettingsForm: UntypedFormGroup; |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
private fb: UntypedFormBuilder) { |
|||
super(store); |
|||
} |
|||
|
|||
protected settingsForm(): UntypedFormGroup { |
|||
return this.labelCardWidgetSettingsForm; |
|||
} |
|||
|
|||
protected defaultSettings(): WidgetSettings { |
|||
return {...labelCardWidgetDefaultSettings}; |
|||
} |
|||
|
|||
protected onSettingsSet(settings: WidgetSettings) { |
|||
this.labelCardWidgetSettingsForm = 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, []], |
|||
padding: [settings.padding, []] |
|||
}); |
|||
} |
|||
|
|||
protected validatorTriggers(): string[] { |
|||
return ['showIcon']; |
|||
} |
|||
|
|||
protected updateValidators(emitEvent: boolean) { |
|||
const showIcon: boolean = this.labelCardWidgetSettingsForm.get('showIcon').value; |
|||
if (showIcon) { |
|||
this.labelCardWidgetSettingsForm.get('iconSize').enable(); |
|||
this.labelCardWidgetSettingsForm.get('iconSizeUnit').enable(); |
|||
this.labelCardWidgetSettingsForm.get('icon').enable(); |
|||
this.labelCardWidgetSettingsForm.get('iconColor').enable(); |
|||
} else { |
|||
this.labelCardWidgetSettingsForm.get('iconSize').disable(); |
|||
this.labelCardWidgetSettingsForm.get('iconSizeUnit').disable(); |
|||
this.labelCardWidgetSettingsForm.get('icon').disable(); |
|||
this.labelCardWidgetSettingsForm.get('iconColor').disable(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,81 @@ |
|||
<!-- |
|||
|
|||
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]="labelValueCardWidgetSettingsForm"> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" translate>widgets.label-value-card.label-value-card-style</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" 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]="labelValueCardWidgetSettingsForm.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]="labelValueCardWidgetSettingsForm.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 space-between"> |
|||
<div translate>widgets.label-value-card.value</div> |
|||
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<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"> |
|||
<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> |
|||
</ng-container> |
|||
@ -0,0 +1,108 @@ |
|||
///
|
|||
/// 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 { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; |
|||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { labelValueCardWidgetDefaultSettings } from '@home/components/widget/lib/cards/label-value-card-widget.models'; |
|||
import { formatValue } from '@core/utils'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-label-value-card-widget-settings', |
|||
templateUrl: './label-value-card-widget-settings.component.html', |
|||
styleUrls: [] |
|||
}) |
|||
export class LabelValueCardWidgetSettingsComponent extends WidgetSettingsComponent { |
|||
|
|||
labelValueCardWidgetSettingsForm: UntypedFormGroup; |
|||
|
|||
valuePreviewFn = this._valuePreviewFn.bind(this); |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
private fb: UntypedFormBuilder) { |
|||
super(store); |
|||
} |
|||
|
|||
protected settingsForm(): UntypedFormGroup { |
|||
return this.labelValueCardWidgetSettingsForm; |
|||
} |
|||
|
|||
protected defaultSettings(): WidgetSettings { |
|||
return {...labelValueCardWidgetDefaultSettings}; |
|||
} |
|||
|
|||
protected onSettingsSet(settings: WidgetSettings) { |
|||
this.labelValueCardWidgetSettingsForm = this.fb.group({ |
|||
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, []], |
|||
|
|||
valueFont: [settings.valueFont, []], |
|||
valueColor: [settings.valueColor, []], |
|||
|
|||
background: [settings.background, []], |
|||
padding: [settings.padding, []] |
|||
}); |
|||
} |
|||
|
|||
protected validatorTriggers(): string[] { |
|||
return ['showLabel', 'showIcon']; |
|||
} |
|||
|
|||
protected updateValidators(emitEvent: boolean) { |
|||
const showLabel: boolean = this.labelValueCardWidgetSettingsForm.get('showLabel').value; |
|||
const showIcon: boolean = this.labelValueCardWidgetSettingsForm.get('showIcon').value; |
|||
|
|||
if (showLabel) { |
|||
this.labelValueCardWidgetSettingsForm.get('label').enable(); |
|||
this.labelValueCardWidgetSettingsForm.get('labelFont').enable(); |
|||
this.labelValueCardWidgetSettingsForm.get('labelColor').enable(); |
|||
} else { |
|||
this.labelValueCardWidgetSettingsForm.get('label').disable(); |
|||
this.labelValueCardWidgetSettingsForm.get('labelFont').disable(); |
|||
this.labelValueCardWidgetSettingsForm.get('labelColor').disable(); |
|||
} |
|||
|
|||
if (showIcon) { |
|||
this.labelValueCardWidgetSettingsForm.get('iconSize').enable(); |
|||
this.labelValueCardWidgetSettingsForm.get('iconSizeUnit').enable(); |
|||
this.labelValueCardWidgetSettingsForm.get('icon').enable(); |
|||
this.labelValueCardWidgetSettingsForm.get('iconColor').enable(); |
|||
} else { |
|||
this.labelValueCardWidgetSettingsForm.get('iconSize').disable(); |
|||
this.labelValueCardWidgetSettingsForm.get('iconSizeUnit').disable(); |
|||
this.labelValueCardWidgetSettingsForm.get('icon').disable(); |
|||
this.labelValueCardWidgetSettingsForm.get('iconColor').disable(); |
|||
} |
|||
} |
|||
|
|||
private _valuePreviewFn(): string { |
|||
const units: string = this.widgetConfig.config.units; |
|||
const decimals: number = this.widgetConfig.config.decimals; |
|||
return formatValue(22, decimals, units, true); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue