diff --git a/application/src/main/data/json/system/scada_symbols/vertical-ball-valve.svg b/application/src/main/data/json/system/scada_symbols/vertical-ball-valve.svg index 3aee266486..f284c7f86d 100644 --- a/application/src/main/data/json/system/scada_symbols/vertical-ball-valve.svg +++ b/application/src/main/data/json/system/scada_symbols/vertical-ball-valve.svg @@ -93,7 +93,7 @@ }, "valueToData": { "type": "CONSTANT", - "constantValue": false, + "constantValue": true, "valueToDataFunction": "/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;" } }, @@ -127,7 +127,7 @@ }, "valueToData": { "type": "CONSTANT", - "constantValue": true, + "constantValue": false, "valueToDataFunction": "/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;" } }, diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java index d3113787e5..358e2d1f6d 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java @@ -15,16 +15,17 @@ */ package org.thingsboard.server.service.notification.channels; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.base.Strings; -import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.Setter; import org.apache.commons.codec.binary.Base64; import org.apache.commons.lang3.StringUtils; import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import org.thingsboard.common.util.JacksonUtil; @@ -37,6 +38,8 @@ import org.thingsboard.server.common.data.notification.template.MicrosoftTeamsDe import org.thingsboard.server.service.notification.NotificationProcessingContext; import org.thingsboard.server.service.security.system.SystemSecurityService; +import java.net.URI; +import java.net.URISyntaxException; import java.time.Duration; import java.time.temporal.ChronoUnit; import java.util.List; @@ -56,17 +59,93 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel request = new HttpEntity<>(JacksonUtil.toString(teamsAdaptiveCard), headers); + restTemplate.postForEntity(new URI(targetConfig.getWebhookUrl()), request, String.class); + } + + private void sendTeamsMessageCard(MicrosoftTeamsNotificationTargetConfig targetConfig, MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws JsonProcessingException, URISyntaxException { + TeamsMessageCard teamsMessageCard = new TeamsMessageCard(); + teamsMessageCard.setThemeColor(Strings.emptyToNull(processedTemplate.getThemeColor())); + if (StringUtils.isEmpty(processedTemplate.getSubject())) { + teamsMessageCard.setText(processedTemplate.getBody()); + } else { + teamsMessageCard.setSummary(processedTemplate.getSubject()); + TeamsMessageCard.Section section = new TeamsMessageCard.Section(); section.setActivityTitle(processedTemplate.getSubject()); section.setActivitySubtitle(processedTemplate.getBody()); - message.setSections(List.of(section)); + teamsMessageCard.setSections(List.of(section)); } + var button = processedTemplate.getButton(); + String uri = getButtonUri(processedTemplate, ctx); + + if (StringUtils.isNotBlank(uri) && button.getText() != null) { + TeamsMessageCard.ActionCard actionCard = new TeamsMessageCard.ActionCard(); + actionCard.setType("OpenUri"); + actionCard.setName(button.getText()); + var target = new TeamsMessageCard.ActionCard.Target("default", uri); + actionCard.setTargets(List.of(target)); + teamsMessageCard.setPotentialAction(List.of(actionCard)); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(JacksonUtil.toString(teamsMessageCard), headers); + restTemplate.postForEntity(new URI(targetConfig.getWebhookUrl()), request, String.class); + } + + private String getButtonUri(MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws JsonProcessingException { var button = processedTemplate.getButton(); if (button != null && button.isEnabled()) { String uri; @@ -99,17 +178,9 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel sections; - private List potentialAction; - - @Data - public static class Section { - private String activityTitle; - private String activitySubtitle; - private String activityImage; - private List facts; - private boolean markdown; - - @Data - public static class Fact { - private final String name; - private final String value; - } - } - - @Data - @JsonInclude(JsonInclude.Include.NON_NULL) - public static class ActionCard { - @JsonProperty("@type") - private String type; // ActionCard, OpenUri - private String name; - private List inputs; // for ActionCard - private List actions; // for ActionCard - private List targets; - - @Data - public static class Input { - @JsonProperty("@type") - private String type; // TextInput, DateInput, MultichoiceInput - private String id; - private boolean isMultiple; - private String title; - private boolean isMultiSelect; - - @Data - public static class Choice { - private final String display; - private final String value; - } - } - - @Data - public static class Action { - @JsonProperty("@type") - private final String type; // HttpPOST - private final String name; - private final String target; // url - } - - @Data - public static class Target { - private final String os; - private final String uri; - } - } - - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/TeamsAdaptiveCard.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/TeamsAdaptiveCard.java new file mode 100644 index 0000000000..b5d434bac0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/TeamsAdaptiveCard.java @@ -0,0 +1,93 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.notification.channels; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.dao.util.ImageUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * @link AdaptiveCard Designer + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TeamsAdaptiveCard { + private String type = "message"; + private List attachments; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Attachment { + private String contentType = "application/vnd.microsoft.card.adaptive"; + private AdaptiveCard content; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class AdaptiveCard { + @JsonProperty("$schema") + private final String schema = "http://adaptivecards.io/schemas/adaptive-card.json"; + private final String type = "AdaptiveCard"; + private BackgroundImage backgroundImage; + @JsonProperty("body") + private List textBlocks = new ArrayList<>(); + private List actions = new ArrayList<>(); + } + + @Data + @NoArgsConstructor + public static class BackgroundImage { + private String url; + private final String fillMode = "repeat"; + + public BackgroundImage(String color) { + // This is the only one way how to specify color the custom color for the card + url = ImageUtils.getEmbeddedBase64EncodedImg(color); + } + + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class TextBlock { + private final String type = "TextBlock"; + private String text; + private String weight = "Normal"; + private String size = "Medium"; + private String spacing = "None"; + private String color = "#FFFFFF"; + private final boolean wrap = true; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ActionOpenUrl { + private final String type = "Action.OpenUrl"; + private String title; + private String url; + } + +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/TeamsMessageCard.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/TeamsMessageCard.java new file mode 100644 index 0000000000..65cdfec765 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/TeamsMessageCard.java @@ -0,0 +1,92 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.notification.channels; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class TeamsMessageCard { + @JsonProperty("@type") + private final String type = "MessageCard"; + @JsonProperty("@context") + private final String context = "http://schema.org/extensions"; + private String themeColor; + private String summary; + private String text; + private List
sections; + private List potentialAction; + + @Data + public static class Section { + private String activityTitle; + private String activitySubtitle; + private String activityImage; + private List facts; + private boolean markdown; + + @Data + public static class Fact { + private final String name; + private final String value; + } + } + + @Data + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ActionCard { + @JsonProperty("@type") + private String type; // ActionCard, OpenUri + private String name; + private List inputs; // for ActionCard + private List actions; // for ActionCard + private List targets; + + @Data + public static class Input { + @JsonProperty("@type") + private String type; // TextInput, DateInput, MultichoiceInput + private String id; + private boolean isMultiple; + private String title; + private boolean isMultiSelect; + + @Data + public static class Choice { + private final String display; + private final String value; + } + } + + @Data + public static class Action { + @JsonProperty("@type") + private final String type; // HttpPOST + private final String name; + private final String target; // url + } + + @Data + public static class Target { + private final String os; + private final String uri; + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index b7c43391be..51d37e291a 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -417,7 +417,8 @@ public class DefaultTransportApiService implements TransportApiService { requestMsg.getCredentialsDataProto().getValidateDeviceX509CertRequestMsg().getHash()), new ProvisionDeviceProfileCredentials( requestMsg.getProvisionDeviceCredentialsMsg().getProvisionDeviceKey(), - requestMsg.getProvisionDeviceCredentialsMsg().getProvisionDeviceSecret()))); + requestMsg.getProvisionDeviceCredentialsMsg().getProvisionDeviceSecret()), + requestMsg.getGateway())); } catch (ProvisionFailedException e) { return getTransportApiResponseMsg(new DeviceCredentials(), TransportProtos.ResponseStatus.valueOf(e.getMessage())); } @@ -665,7 +666,7 @@ public class DefaultTransportApiService implements TransportApiService { private ProvisionRequest createProvisionRequest(String certificateValue) { return new ProvisionRequest(null, DeviceCredentialsType.X509_CERTIFICATE, new ProvisionDeviceCredentialsData(null, null, null, null, certificateValue), - null); + null, null); } } diff --git a/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java index 8d5d66f6d2..75b84f50be 100644 --- a/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java @@ -236,7 +236,7 @@ public class DeviceProvisionServiceTest { private ProvisionRequest createProvisionRequest(String certificateValue) { return new ProvisionRequest(null, DeviceCredentialsType.X509_CERTIFICATE, new ProvisionDeviceCredentialsData(null, null, null, null, certificateValue), - null); + null, null); } public static String certTrimNewLinesForChainInDeviceProfile(String input) { diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java index 53ae5f36ae..8bae1feca9 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java @@ -25,6 +25,7 @@ import org.junit.Test; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpEntity; import org.springframework.test.web.servlet.ResultActions; import org.springframework.web.client.RestTemplate; import org.thingsboard.common.util.JacksonUtil; @@ -85,8 +86,12 @@ import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.notification.DefaultNotifications; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.service.notification.channels.MicrosoftTeamsNotificationChannel; +import org.thingsboard.server.service.notification.channels.TeamsAdaptiveCard; +import org.thingsboard.server.service.notification.channels.TeamsMessageCard; import org.thingsboard.server.service.ws.notification.cmd.UnreadNotificationsUpdate; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -752,7 +757,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest { } @Test - public void testMicrosoftTeamsNotifications() throws Exception { + public void testMicrosoftTeamsNotificationsWithOfficeConnector() throws URISyntaxException { RestTemplate restTemplate = mock(RestTemplate.class); microsoftTeamsNotificationChannel.setRestTemplate(restTemplate); @@ -760,6 +765,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest { var targetConfig = new MicrosoftTeamsNotificationTargetConfig(); targetConfig.setWebhookUrl(webhookUrl); targetConfig.setChannelName("My channel"); + targetConfig.setUseOldApi(true); NotificationTarget target = new NotificationTarget(); target.setName("Microsoft Teams channel"); target.setConfiguration(targetConfig); @@ -770,7 +776,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest { String templateParams = "${recipientTitle} - ${entityType}"; template.setSubject("Subject: " + templateParams); template.setBody("Body: " + templateParams); - template.setThemeColor("ff0000"); + template.setThemeColor("#ff0000"); var button = new MicrosoftTeamsDeliveryMethodNotificationTemplate.Button(); button.setEnabled(true); button.setText("Button: " + templateParams); @@ -803,11 +809,13 @@ public class NotificationApiTest extends AbstractNotificationApiTest { assertThat(preview.getRecipientsCountByTarget().get(target.getName())).isEqualTo(1); assertThat(preview.getRecipientsPreview()).containsOnly(targetConfig.getChannelName()); - var messageCaptor = ArgumentCaptor.forClass(MicrosoftTeamsNotificationChannel.Message.class); + ArgumentCaptor> messageCaptor = ArgumentCaptor.forClass(HttpEntity.class); notificationCenter.processNotificationRequest(tenantId, notificationRequest, null); - verify(restTemplate, timeout(20000)).postForEntity(eq(webhookUrl), messageCaptor.capture(), any()); + verify(restTemplate, timeout(20000)).postForEntity(eq(new URI(webhookUrl)), messageCaptor.capture(), any()); + + HttpEntity value = messageCaptor.getValue(); + TeamsMessageCard message = JacksonUtil.fromString(value.getBody(), TeamsMessageCard.class); - var message = messageCaptor.getValue(); String expectedParams = "My channel - Device"; assertThat(message.getThemeColor()).isEqualTo(template.getThemeColor()); assertThat(message.getSections().get(0).getActivityTitle()).isEqualTo("Subject: " + expectedParams); @@ -816,6 +824,74 @@ public class NotificationApiTest extends AbstractNotificationApiTest { assertThat(message.getPotentialAction().get(0).getTargets().get(0).getUri()).isEqualTo("https://" + expectedParams); } + @Test + public void testMicrosoftTeamsNotificationsWithWorkflow() throws Exception { + RestTemplate restTemplate = mock(RestTemplate.class); + microsoftTeamsNotificationChannel.setRestTemplate(restTemplate); + + String webhookUrl = "https://webhook.com/webhookb2/9628fa60-d873-11ed-913c-a196b1f9b445"; + var targetConfig = new MicrosoftTeamsNotificationTargetConfig(); + targetConfig.setWebhookUrl(webhookUrl); + targetConfig.setChannelName("My channel"); + targetConfig.setUseOldApi(false); + NotificationTarget target = new NotificationTarget(); + target.setName("Microsoft Teams channel"); + target.setConfiguration(targetConfig); + target = saveNotificationTarget(target); + + var template = new MicrosoftTeamsDeliveryMethodNotificationTemplate(); + template.setEnabled(true); + String templateParams = "${recipientTitle} - ${entityType}"; + template.setSubject("Subject: " + templateParams); + template.setBody("Body: " + templateParams); + template.setThemeColor("#ff0000"); + var button = new MicrosoftTeamsDeliveryMethodNotificationTemplate.Button(); + button.setEnabled(true); + button.setText("Button: " + templateParams); + button.setLinkType(LinkType.LINK); + button.setLink("https://" + templateParams); + template.setButton(button); + NotificationTemplate notificationTemplate = new NotificationTemplate(); + notificationTemplate.setName("Notification to Teams"); + notificationTemplate.setNotificationType(NotificationType.GENERAL); + NotificationTemplateConfig templateConfig = new NotificationTemplateConfig(); + templateConfig.setDeliveryMethodsTemplates(Map.of( + NotificationDeliveryMethod.MICROSOFT_TEAMS, template + )); + notificationTemplate.setConfiguration(templateConfig); + notificationTemplate = saveNotificationTemplate(notificationTemplate); + + NotificationRequest notificationRequest = NotificationRequest.builder() + .tenantId(tenantId) + .originatorEntityId(tenantAdminUserId) + .templateId(notificationTemplate.getId()) + .targets(List.of(target.getUuidId())) + .info(EntityActionNotificationInfo.builder() + .entityId(new DeviceId(UUID.randomUUID())) + .actionType(ActionType.ADDED) + .userId(tenantAdminUserId.getId()) + .build()) + .build(); + + NotificationRequestPreview preview = doPost("/api/notification/request/preview", notificationRequest, NotificationRequestPreview.class); + assertThat(preview.getRecipientsCountByTarget().get(target.getName())).isEqualTo(1); + assertThat(preview.getRecipientsPreview()).containsOnly(targetConfig.getChannelName()); + + ArgumentCaptor> messageCaptor = ArgumentCaptor.forClass(HttpEntity.class); + notificationCenter.processNotificationRequest(tenantId, notificationRequest, null); + verify(restTemplate, timeout(20000)).postForEntity(eq(new URI(webhookUrl)), messageCaptor.capture(), any()); + + HttpEntity value = messageCaptor.getValue(); + TeamsAdaptiveCard message = JacksonUtil.fromString(value.getBody(), TeamsAdaptiveCard.class); + String expectedParams = "My channel - Device"; + assertThat(message).isNotNull(); + assertThat(message.getAttachments().get(0).getContent().getBackgroundImage().getUrl()).isNotEmpty(); + assertThat(message.getAttachments().get(0).getContent().getTextBlocks().get(0).getText()).isEqualTo("Subject: " + expectedParams); + assertThat(message.getAttachments().get(0).getContent().getTextBlocks().get(1).getText()).isEqualTo("Body: " + expectedParams); + assertThat(message.getAttachments().get(0).getContent().getActions().get(0).getTitle()).isEqualTo("Button: " + expectedParams); + assertThat(message.getAttachments().get(0).getContent().getActions().get(0).getUrl()).isEqualTo("https://" + expectedParams); + } + @Test public void testMobileAppNotifications() throws Exception { loginCustomerUser(); diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionJsonDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionJsonDeviceTest.java index 22d1e9e349..b2187ba7b4 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionJsonDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionJsonDeviceTest.java @@ -23,6 +23,7 @@ import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.CoapDeviceType; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.TransportPayloadType; @@ -67,6 +68,11 @@ public class CoapProvisionJsonDeviceTest extends AbstractCoapIntegrationTest { processTestProvisioningCreateNewDeviceWithoutCredentials(); } + @Test + public void testProvisioningCreateNewGatewayDevice() throws Exception { + processTestProvisioningCreateNewGatewayDevice(); + } + @Test public void testProvisioningCreateNewDeviceWithAccessToken() throws Exception { processTestProvisioningCreateNewDeviceWithAccessToken(); @@ -123,6 +129,37 @@ public class CoapProvisionJsonDeviceTest extends AbstractCoapIntegrationTest { } + private void processTestProvisioningCreateNewGatewayDevice() throws Exception { + CoapTestConfigProperties configProperties = CoapTestConfigProperties.builder() + .deviceName("Test Provision device3") + .coapDeviceType(CoapDeviceType.DEFAULT) + .transportPayloadType(TransportPayloadType.JSON) + .provisionType(DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES) + .provisionKey("testProvisionKey") + .provisionSecret("testProvisionSecret") + .build(); + processBeforeTest(configProperties); + JsonNode response = JacksonUtil.fromBytes(createCoapClientAndPublish(true)); + Assert.assertTrue(response.hasNonNull("credentialsType")); + Assert.assertTrue(response.hasNonNull("status")); + + Device createdDevice = deviceService.findDeviceByTenantIdAndName(tenantId, "Test Provision device"); + + Assert.assertNotNull(createdDevice); + + JsonNode additionalInfo = createdDevice.getAdditionalInfo(); + Assert.assertNotNull(additionalInfo); + Assert.assertTrue(additionalInfo.has(DataConstants.GATEWAY_PARAMETER) + && additionalInfo.get(DataConstants.GATEWAY_PARAMETER).isBoolean()); + Assert.assertTrue(additionalInfo.get(DataConstants.GATEWAY_PARAMETER).asBoolean()); + + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId, createdDevice.getId()); + + Assert.assertEquals(deviceCredentials.getCredentialsType().name(), response.get("credentialsType").asText()); + Assert.assertEquals(ProvisionResponseStatus.SUCCESS.name(), response.get("status").asText()); + } + + private void processTestProvisioningCreateNewDeviceWithAccessToken() throws Exception { CoapTestConfigProperties configProperties = CoapTestConfigProperties.builder() .deviceName("Test Provision device3") @@ -222,16 +259,30 @@ public class CoapProvisionJsonDeviceTest extends AbstractCoapIntegrationTest { } private byte[] createCoapClientAndPublish() throws Exception { - return createCoapClientAndPublish(""); + return createCoapClientAndPublish(false); + } + + private byte[] createCoapClientAndPublish(boolean isGateway) throws Exception { + return createCoapClientAndPublish("", isGateway); } private byte[] createCoapClientAndPublish(String deviceCredentials) throws Exception { - String provisionRequestMsg = createTestProvisionMessage(deviceCredentials); + return createCoapClientAndPublish(deviceCredentials, false); + } + + private byte[] createCoapClientAndPublish(String deviceCredentials, boolean isGateway) throws Exception { + String provisionRequestMsg = createTestProvisionMessage(deviceCredentials, isGateway); client = new CoapTestClient(accessToken, FeatureType.PROVISION); return client.postMethod(provisionRequestMsg.getBytes()).getPayload(); } - private String createTestProvisionMessage(String deviceCredentials) { - return "{\"deviceName\":\"Test Provision device\",\"provisionDeviceKey\":\"testProvisionKey\", \"provisionDeviceSecret\":\"testProvisionSecret\"" + deviceCredentials + "}"; + protected String createTestProvisionMessage(String deviceCredentials, boolean isGateway) { + String request = "{\"deviceName\":\"Test Provision device\",\"provisionDeviceKey\":\"testProvisionKey\", \"provisionDeviceSecret\":\"testProvisionSecret\"" + + deviceCredentials; + if (isGateway) { + request += ",\"gateway\":true"; + } + request += "}"; + return request; } } diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionProtoDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionProtoDeviceTest.java index 5a7c880d44..4b1f806f74 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionProtoDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionProtoDeviceTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.coap.provision; +import com.fasterxml.jackson.databind.JsonNode; import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.core.CoapResponse; import org.junit.After; @@ -22,6 +23,7 @@ import org.junit.Assert; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.CoapDeviceType; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.TransportPayloadType; @@ -74,6 +76,11 @@ public class CoapProvisionProtoDeviceTest extends AbstractCoapIntegrationTest { processTestProvisioningCreateNewDeviceWithoutCredentials(); } + @Test + public void testProvisioningCreateNewGatewayDevice() throws Exception { + processTestProvisioningCreateNewGatewayDevice(); + } + @Test public void testProvisioningCreateNewDeviceWithAccessToken() throws Exception { processTestProvisioningCreateNewDeviceWithAccessToken(); @@ -124,6 +131,35 @@ public class CoapProvisionProtoDeviceTest extends AbstractCoapIntegrationTest { Assert.assertEquals(ProvisionResponseStatus.SUCCESS.name(), response.getStatus().name()); } + private void processTestProvisioningCreateNewGatewayDevice() throws Exception { + CoapTestConfigProperties configProperties = CoapTestConfigProperties.builder() + .deviceName("Test Provision device3") + .coapDeviceType(CoapDeviceType.DEFAULT) + .transportPayloadType(TransportPayloadType.PROTOBUF) + .provisionType(DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES) + .provisionKey("testProvisionKey") + .provisionSecret("testProvisionSecret") + .build(); + processBeforeTest(configProperties); + byte[] testsProvisionMessage = createTestsProvisionMessage(null, null, true); + ProvisionDeviceResponseMsg response = ProvisionDeviceResponseMsg.parseFrom(createCoapClientAndPublish(testsProvisionMessage)); + + Device createdDevice = deviceService.findDeviceByTenantIdAndName(tenantId, "Test Provision device"); + + Assert.assertNotNull(createdDevice); + + JsonNode additionalInfo = createdDevice.getAdditionalInfo(); + Assert.assertNotNull(additionalInfo); + Assert.assertTrue(additionalInfo.has(DataConstants.GATEWAY_PARAMETER) + && additionalInfo.get(DataConstants.GATEWAY_PARAMETER).isBoolean()); + Assert.assertTrue(additionalInfo.get(DataConstants.GATEWAY_PARAMETER).asBoolean()); + + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId, createdDevice.getId()); + + Assert.assertEquals(deviceCredentials.getCredentialsType().name(), response.getCredentialsType().name()); + Assert.assertEquals(ProvisionResponseStatus.SUCCESS.name(), response.getStatus().name()); + } + private void processTestProvisioningCreateNewDeviceWithAccessToken() throws Exception { CoapTestConfigProperties configProperties = CoapTestConfigProperties.builder() .deviceName("Test Provision device3") @@ -233,6 +269,10 @@ public class CoapProvisionProtoDeviceTest extends AbstractCoapIntegrationTest { } private byte[] createTestsProvisionMessage(CredentialsType credentialsType, CredentialsDataProto credentialsData) throws Exception { + return createTestsProvisionMessage(credentialsType, credentialsData, false); + } + + private byte[] createTestsProvisionMessage(CredentialsType credentialsType, CredentialsDataProto credentialsData, boolean isGateway) throws Exception { return ProvisionDeviceRequestMsg.newBuilder() .setDeviceName("Test Provision device") .setCredentialsType(credentialsType != null ? credentialsType : CredentialsType.ACCESS_TOKEN) @@ -241,7 +281,9 @@ public class CoapProvisionProtoDeviceTest extends AbstractCoapIntegrationTest { ProvisionDeviceCredentialsMsg.newBuilder() .setProvisionDeviceKey("testProvisionKey") .setProvisionDeviceSecret("testProvisionSecret") - ).build() + ) + .setGateway(isGateway) + .build() .toByteArray(); } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionJsonDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionJsonDeviceTest.java index e82612963f..05080a9b30 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionJsonDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionJsonDeviceTest.java @@ -22,6 +22,7 @@ import org.junit.Assert; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.TransportPayloadType; @@ -68,6 +69,11 @@ public class MqttProvisionJsonDeviceTest extends AbstractMqttIntegrationTest { processTestProvisioningCreateNewDeviceWithoutCredentials(); } + @Test + public void testProvisioningCreateNewGatewayDevice() throws Exception { + processTestProvisioningCreateNewGatewayDevice(); + } + @Test public void testProvisioningCreateNewDeviceWithAccessToken() throws Exception { processTestProvisioningCreateNewDeviceWithAccessToken(); @@ -130,6 +136,37 @@ public class MqttProvisionJsonDeviceTest extends AbstractMqttIntegrationTest { } + protected void processTestProvisioningCreateNewGatewayDevice() throws Exception { + MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() + .deviceName("Test Provision device3") + .transportPayloadType(TransportPayloadType.JSON) + .provisionType(DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES) + .provisionKey("testProvisionKey") + .provisionSecret("testProvisionSecret") + .build(); + super.processBeforeTest(configProperties); + byte[] result = createMqttClientAndPublish(true); + JsonNode response = JacksonUtil.fromBytes(result); + Assert.assertTrue(response.hasNonNull("credentialsType")); + Assert.assertTrue(response.hasNonNull("status")); + + Device createdDevice = deviceService.findDeviceByTenantIdAndName(tenantId, "Test Provision device"); + + Assert.assertNotNull(createdDevice); + + JsonNode additionalInfo = createdDevice.getAdditionalInfo(); + Assert.assertNotNull(additionalInfo); + Assert.assertTrue(additionalInfo.has(DataConstants.GATEWAY_PARAMETER) + && additionalInfo.get(DataConstants.GATEWAY_PARAMETER).isBoolean()); + Assert.assertTrue(additionalInfo.get(DataConstants.GATEWAY_PARAMETER).asBoolean()); + + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId, createdDevice.getId()); + + Assert.assertEquals(deviceCredentials.getCredentialsType().name(), response.get("credentialsType").asText()); + Assert.assertEquals(ProvisionResponseStatus.SUCCESS.name(), response.get("status").asText()); + } + + protected void processTestProvisioningCreateNewDeviceWithAccessToken() throws Exception { MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() .deviceName("Test Provision device3") @@ -264,11 +301,19 @@ public class MqttProvisionJsonDeviceTest extends AbstractMqttIntegrationTest { } protected byte[] createMqttClientAndPublish() throws Exception { - return createMqttClientAndPublish(""); + return createMqttClientAndPublish(false); + } + + protected byte[] createMqttClientAndPublish(boolean isGateway) throws Exception { + return createMqttClientAndPublish("", isGateway); } protected byte[] createMqttClientAndPublish(String deviceCredentials) throws Exception { - String provisionRequestMsg = createTestProvisionMessage(deviceCredentials); + return createMqttClientAndPublish(deviceCredentials, false); + } + + protected byte[] createMqttClientAndPublish(String deviceCredentials, boolean isGateway) throws Exception { + String provisionRequestMsg = createTestProvisionMessage(deviceCredentials, isGateway); MqttTestClient client = new MqttTestClient(); client.connectAndWait("provision"); MqttTestCallback onProvisionCallback = new MqttTestSubscribeOnTopicCallback(DEVICE_PROVISION_RESPONSE_TOPIC); @@ -280,7 +325,12 @@ public class MqttProvisionJsonDeviceTest extends AbstractMqttIntegrationTest { return onProvisionCallback.getPayloadBytes(); } - protected String createTestProvisionMessage(String deviceCredentials) { - return "{\"deviceName\":\"Test Provision device\",\"provisionDeviceKey\":\"testProvisionKey\", \"provisionDeviceSecret\":\"testProvisionSecret\"" + deviceCredentials + "}"; + protected String createTestProvisionMessage(String deviceCredentials, boolean isGateway) { + String request = "{\"deviceName\":\"Test Provision device\",\"provisionDeviceKey\":\"testProvisionKey\", \"provisionDeviceSecret\":\"testProvisionSecret\"" + deviceCredentials; + if (isGateway) { + request += ",\"gateway\":true"; + } + request += "}"; + return request; } } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionProtoDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionProtoDeviceTest.java index 2411d39004..3c1adfb6b4 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionProtoDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionProtoDeviceTest.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.transport.mqtt.mqttv3.provision; +import com.fasterxml.jackson.databind.JsonNode; import io.netty.handler.codec.mqtt.MqttQoS; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.TransportPayloadType; @@ -76,6 +78,11 @@ public class MqttProvisionProtoDeviceTest extends AbstractMqttIntegrationTest { processTestProvisioningCreateNewDeviceWithoutCredentials(); } + @Test + public void testProvisioningCreateNewGatewayDevice() throws Exception { + processTestProvisioningCreateNewGatewayDevice(); + } + @Test public void testProvisioningCreateNewDeviceWithAccessToken() throws Exception { processTestProvisioningCreateNewDeviceWithAccessToken(); @@ -130,6 +137,36 @@ public class MqttProvisionProtoDeviceTest extends AbstractMqttIntegrationTest { Assert.assertEquals(ProvisionResponseStatus.SUCCESS.name(), response.getStatus().name()); } + protected void processTestProvisioningCreateNewGatewayDevice() throws Exception { + MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() + .deviceName("Test Provision device3") + .transportPayloadType(TransportPayloadType.PROTOBUF) + .provisionType(DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES) + .provisionKey("testProvisionKey") + .provisionSecret("testProvisionSecret") + .build(); + processBeforeTest(configProperties); + + byte[] provisionRequestMsg = createTestsProvisionMessage(null, null, true); + byte[] responseBytesMsg = createMqttClientAndPublish(provisionRequestMsg); + ProvisionDeviceResponseMsg response = ProvisionDeviceResponseMsg.parseFrom(responseBytesMsg); + + Device createdDevice = deviceService.findDeviceByTenantIdAndName(tenantId, "Test Provision device"); + + Assert.assertNotNull(createdDevice); + + JsonNode additionalInfo = createdDevice.getAdditionalInfo(); + Assert.assertNotNull(additionalInfo); + Assert.assertTrue(additionalInfo.has(DataConstants.GATEWAY_PARAMETER) + && additionalInfo.get(DataConstants.GATEWAY_PARAMETER).isBoolean()); + Assert.assertTrue(additionalInfo.get(DataConstants.GATEWAY_PARAMETER).asBoolean()); + + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId, createdDevice.getId()); + + Assert.assertEquals(deviceCredentials.getCredentialsType().name(), response.getCredentialsType().name()); + Assert.assertEquals(ProvisionResponseStatus.SUCCESS.name(), response.getStatus().name()); + } + protected void processTestProvisioningCreateNewDeviceWithAccessToken() throws Exception { MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() .deviceName("Test Provision device3") @@ -280,6 +317,10 @@ public class MqttProvisionProtoDeviceTest extends AbstractMqttIntegrationTest { } protected byte[] createTestsProvisionMessage(CredentialsType credentialsType, CredentialsDataProto credentialsData) throws Exception { + return createTestsProvisionMessage(credentialsType, credentialsData, false); + } + + protected byte[] createTestsProvisionMessage(CredentialsType credentialsType, CredentialsDataProto credentialsData, boolean isGateway) throws Exception { return ProvisionDeviceRequestMsg.newBuilder() .setDeviceName("Test Provision device") .setCredentialsType(credentialsType != null ? credentialsType : CredentialsType.ACCESS_TOKEN) @@ -288,7 +329,9 @@ public class MqttProvisionProtoDeviceTest extends AbstractMqttIntegrationTest { ProvisionDeviceCredentialsMsg.newBuilder() .setProvisionDeviceKey("testProvisionKey") .setProvisionDeviceSecret("testProvisionSecret") - ).build() + ) + .setGateway(isGateway) + .build() .toByteArray(); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionRequest.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionRequest.java index d01d7be3c1..c0643f96da 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionRequest.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionRequest.java @@ -28,4 +28,5 @@ public class ProvisionRequest { private DeviceCredentialsType credentialsType; private ProvisionDeviceCredentialsData credentialsData; private ProvisionDeviceProfileCredentials credentials; + private Boolean gateway; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java index e07abe7c00..dbd09698f0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java @@ -28,6 +28,7 @@ public class MicrosoftTeamsNotificationTargetConfig extends NotificationTargetCo private String webhookUrl; @NotEmpty private String channelName; + private Boolean useOldApi = Boolean.TRUE; @Override public NotificationTargetType getType() { diff --git a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java index f5c7bbb901..d1283d3ba0 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java @@ -635,6 +635,7 @@ public class JsonConverter { .setProvisionDeviceCredentialsMsg(buildProvisionDeviceCredentialsMsg( getStrValue(jo, DataConstants.PROVISION_KEY, true), getStrValue(jo, DataConstants.PROVISION_SECRET, true))) + .setGateway(jo.has(DataConstants.GATEWAY_PARAMETER) && jo.get(DataConstants.GATEWAY_PARAMETER).getAsBoolean()) .build(); } diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index dd32ee1c67..f84c7f7c9b 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -697,6 +697,7 @@ message ProvisionDeviceRequestMsg { CredentialsType credentialsType = 2; ProvisionDeviceCredentialsMsg provisionDeviceCredentialsMsg = 3; CredentialsDataProto credentialsDataProto = 4; + bool gateway = 5; } message ProvisionDeviceCredentialsMsg { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index afca584a80..3cd25cc390 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.device; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -27,6 +28,7 @@ import org.springframework.util.CollectionUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cache.device.DeviceCacheEvictEvent; import org.thingsboard.server.cache.device.DeviceCacheKey; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceIdInfo; import org.thingsboard.server.common.data.DeviceInfo; @@ -554,6 +556,11 @@ public class DeviceServiceImpl extends CachedVersionedEntityService 3 ? Float.parseFloat(hslaValues[3]) : 1.0f; + return hslaToColor(h, s, l, a); + } + + private static Color hslaToColor(float h, float s, float l, float alpha) { + float c = (1 - Math.abs(2 * l - 1)) * s; + float x = c * (1 - Math.abs((h / 60) % 2 - 1)); + float m = l - c / 2; + + float r = 0, g = 0, b = 0; + if (h < 60) { + r = c; + g = x; + } else if (h < 120) { + r = x; + g = c; + } else if (h < 180) { + g = c; + b = x; + } else if (h < 240) { + g = x; + b = c; + } else if (h < 300) { + r = x; + b = c; + } else { + r = c; + b = x; + } + + r += m; + g += m; + b += m; + + return new Color(clamp(r), clamp(g), clamp(b), clamp(alpha)); + } + + private static float clamp(float value) { + return Math.max(0, Math.min(1, value)); + } + @Data @AllArgsConstructor @NoArgsConstructor diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java index 5ec80ca8be..9c33a04e64 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java @@ -21,6 +21,7 @@ import io.restassured.path.json.JsonPath; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileProvisionType; @@ -156,6 +157,46 @@ public class HttpClientTest extends AbstractContainerTest { updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED); } + @Test + public void provisionRequestForGatewayDeviceWithAllowToCreateNewDevicesStrategy() throws Exception { + + String testDeviceName = "test_provision_device"; + + DeviceProfile deviceProfile = testRestClient.getDeviceProfileById(device.getDeviceProfileId()); + + deviceProfile = updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); + + JsonObject provisionRequest = new JsonObject(); + provisionRequest.addProperty("provisionDeviceKey", TEST_PROVISION_DEVICE_KEY); + provisionRequest.addProperty("provisionDeviceSecret", TEST_PROVISION_DEVICE_SECRET); + provisionRequest.addProperty("deviceName", testDeviceName); + provisionRequest.addProperty("gateway", true); + + JsonPath provisionResponse = testRestClient.postProvisionRequest(provisionRequest.toString()); + + String credentialsType = provisionResponse.get("credentialsType"); + String credentialsValue = provisionResponse.get("credentialsValue"); + String status = provisionResponse.get("status"); + + testRestClient.deleteDeviceIfExists(device.getId()); + device = testRestClient.getDeviceByName(testDeviceName); + + JsonNode additionalInfo = device.getAdditionalInfo(); + + assertThat(additionalInfo).isNotNull(); + assertThat(additionalInfo.has(DataConstants.GATEWAY_PARAMETER) + && additionalInfo.get(DataConstants.GATEWAY_PARAMETER).isBoolean()).isTrue(); + assertThat(additionalInfo.get(DataConstants.GATEWAY_PARAMETER).asBoolean()).isTrue(); + + DeviceCredentials expectedDeviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + assertThat(credentialsType).isEqualTo(expectedDeviceCredentials.getCredentialsType().name()); + assertThat(credentialsValue).isEqualTo(expectedDeviceCredentials.getCredentialsId()); + assertThat(status).isEqualTo("SUCCESS"); + + updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED); + } + @Test public void provisionRequestForDeviceWithDisabledProvisioningStrategy() throws Exception { diff --git a/msa/web-ui/server.ts b/msa/web-ui/server.ts index fc35e4cef3..f5d8e7f7e3 100644 --- a/msa/web-ui/server.ts +++ b/msa/web-ui/server.ts @@ -23,6 +23,7 @@ import httpProxy from 'http-proxy'; import compression from 'compression'; import historyApiFallback from 'connect-history-api-fallback'; import { Socket } from 'net'; +import { RequestHandler } from 'serve-static'; const logger = _logger('main'); @@ -99,7 +100,19 @@ let connections: Socket[] = []; const root = path.join(webDir, 'public'); - app.use(express.static(root)); + const staticServe = express.static(root); + + const indexHtmlFallback: RequestHandler = (req, res, next) => { + res.sendFile(path.join(root, 'index.html')); + }; + + app.use(staticServe); + + const indexHtmlFallbackPaths = [ + /^\/resources\/scada-symbols\/(?:system|tenant)\/[^/]+$/ + ]; + + app.use(indexHtmlFallbackPaths, indexHtmlFallback); server.listen(bindPort, bindAddress, () => { logger.info('==> 🌎 Listening on port %s.', bindPort); diff --git a/packaging/js/scripts/init/template b/packaging/js/scripts/init/template index 4a8104ae05..a713a9bfcc 100644 --- a/packaging/js/scripts/init/template +++ b/packaging/js/scripts/init/template @@ -183,7 +183,7 @@ restart() { stop && start } -orce_reload() { +force_reload() { working_dir=$(dirname "$mainfile") pushd "$working_dir" > /dev/null [[ -f $pid_file ]] || { echoRed "Not running (pidfile not found)"; return 7; } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java index b60a46ee0a..267d3fae73 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java @@ -15,8 +15,9 @@ */ package org.thingsboard.rule.engine.delay; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.math.NumberUtils; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -26,18 +27,27 @@ import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.exception.DataValidationException; -import java.util.HashMap; -import java.util.Map; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; @Slf4j @RuleNode( type = ComponentType.ACTION, name = "delay (deprecated)", + version = 1, configClazz = TbMsgDelayNodeConfiguration.class, nodeDescription = "Delays incoming message (deprecated)", nodeDetails = "Delays messages for a configurable period. " + @@ -50,13 +60,22 @@ import java.util.concurrent.TimeUnit; ) public class TbMsgDelayNode implements TbNode { + private static final Set supportedTimeUnits = EnumSet.of(TimeUnit.SECONDS, TimeUnit.MINUTES, TimeUnit.HOURS); + private static final String supportedTimeUnitsStr = supportedTimeUnits.stream().map(TimeUnit::name).collect(Collectors.joining(", ")); + private TbMsgDelayNodeConfiguration config; - private Map pendingMsgs; + private ConcurrentMap pendingMsgs; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { this.config = TbNodeUtils.convert(configuration, TbMsgDelayNodeConfiguration.class); - this.pendingMsgs = new HashMap<>(); + String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: "; + try { + validateFields(config, errorPrefix); + } catch (DataValidationException e) { + throw new TbNodeException(e, true); + } + this.pendingMsgs = new ConcurrentHashMap<>(); } @Override @@ -67,7 +86,7 @@ public class TbMsgDelayNode implements TbNode { ctx.enqueueForTellNext( TbMsg.newMsg( pendingMsg.getQueueName(), - pendingMsg.getType(), + pendingMsg.getInternalType(), pendingMsg.getOriginator(), pendingMsg.getCustomerId(), pendingMsg.getMetaData(), @@ -89,25 +108,69 @@ public class TbMsgDelayNode implements TbNode { } private long getDelay(TbMsg msg) { - int periodInSeconds; - if (config.isUseMetadataPeriodInSecondsPatterns()) { - if (isParsable(msg, config.getPeriodInSecondsPattern())) { - periodInSeconds = Integer.parseInt(TbNodeUtils.processPattern(config.getPeriodInSecondsPattern(), msg)); - } else { - throw new RuntimeException("Can't parse period in seconds from metadata using pattern: " + config.getPeriodInSecondsPattern()); + String timeUnitPattern = TbNodeUtils.processPattern(config.getTimeUnit(), msg); + String periodPattern = TbNodeUtils.processPattern(config.getPeriod(), msg); + try { + TimeUnit timeUnit = TimeUnit.valueOf(timeUnitPattern.toUpperCase()); + if (!supportedTimeUnits.contains(timeUnit)) { + throw new RuntimeException("Time unit '" + timeUnit + "' is not supported! " + + "Only " + supportedTimeUnitsStr + " are supported."); } - } else { - periodInSeconds = config.getPeriodInSeconds(); + int period = Integer.parseInt(periodPattern); + return timeUnit.toMillis(period); + } catch (NumberFormatException e) { + throw new NumberFormatException("Can't parse period value : " + periodPattern); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid value for period time unit : " + timeUnitPattern); } - return TimeUnit.SECONDS.toMillis(periodInSeconds); - } - - private boolean isParsable(TbMsg msg, String pattern) { - return NumberUtils.isParsable(TbNodeUtils.processPattern(pattern, msg)); } @Override public void destroy() { pendingMsgs.clear(); } + + @Override + public TbPair upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException { + boolean hasChanges = false; + switch (fromVersion) { + case 0: + var periodInSeconds = "periodInSeconds"; + var periodInSecondsPattern = "periodInSecondsPattern"; + var useMetadataPeriodInSecondsPatterns = "useMetadataPeriodInSecondsPatterns"; + var period = "period"; + if (oldConfiguration.has(useMetadataPeriodInSecondsPatterns)) { + var isUsedPattern = oldConfiguration.get(useMetadataPeriodInSecondsPatterns).booleanValue(); + if (isUsedPattern) { + if (!oldConfiguration.has(periodInSecondsPattern)) { + throw new TbNodeException("Property to update: '" + periodInSecondsPattern + "' does not exist in configuration."); + } + ((ObjectNode) oldConfiguration).set(period, oldConfiguration.get(periodInSecondsPattern)); + } else { + if (!oldConfiguration.has(periodInSeconds)) { + throw new TbNodeException("Property to update: '" + periodInSeconds + "' does not exist in configuration."); + } + ((ObjectNode) oldConfiguration).put(period, oldConfiguration.get(periodInSeconds).asText()); + } + hasChanges = true; + } else if (oldConfiguration.has(periodInSeconds)) { + ((ObjectNode) oldConfiguration).put(period, oldConfiguration.get(periodInSeconds).asText()); + hasChanges = true; + } + if (!oldConfiguration.has(period)) { + ((ObjectNode) oldConfiguration).put(period, "60"); + hasChanges = true; + } + var timeUnit = "timeUnit"; + if (!oldConfiguration.has(timeUnit)) { + ((ObjectNode) oldConfiguration).put(timeUnit, TimeUnit.SECONDS.name()); + hasChanges = true; + } + ((ObjectNode) oldConfiguration).remove(List.of(periodInSeconds, periodInSecondsPattern, useMetadataPeriodInSecondsPatterns)); + break; + default: + break; + } + return new TbPair<>(hasChanges, oldConfiguration); + } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeConfiguration.java index f35552de18..56df2c8aea 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeConfiguration.java @@ -15,23 +15,31 @@ */ package org.thingsboard.rule.engine.delay; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.thingsboard.rule.engine.api.NodeConfiguration; +import java.util.concurrent.TimeUnit; + @Data public class TbMsgDelayNodeConfiguration implements NodeConfiguration { - private int periodInSeconds; + @NotNull + private String period; + @NotNull + private String timeUnit; + @Min(1) + @Max(100000) private int maxPendingMsgs; - private String periodInSecondsPattern; - private boolean useMetadataPeriodInSecondsPatterns; @Override public TbMsgDelayNodeConfiguration defaultConfiguration() { TbMsgDelayNodeConfiguration configuration = new TbMsgDelayNodeConfiguration(); - configuration.setPeriodInSeconds(60); + configuration.setPeriod("60"); + configuration.setTimeUnit(TimeUnit.SECONDS.name()); configuration.setMaxPendingMsgs(1000); - configuration.setUseMetadataPeriodInSecondsPatterns(false); return configuration; } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java index d7476bf08e..566430bd8d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java @@ -42,6 +42,7 @@ import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; import reactor.netty.transport.ProxyProvider; import javax.net.ssl.SSLException; @@ -95,7 +96,12 @@ public class TbHttpClient { semaphore = new Semaphore(config.getMaxParallelRequestsCount()); } - HttpClient httpClient = HttpClient.create() + ConnectionProvider connectionProvider = ConnectionProvider + .builder("rule-engine-http-client") + .maxConnections(getPoolMaxConnections()) + .build(); + + HttpClient httpClient = HttpClient.create(connectionProvider) .runOn(getSharedOrCreateEventLoopGroup(eventLoopGroupShared)) .doOnConnected(c -> c.addHandlerLast(new ReadTimeoutHandler(config.getReadTimeoutMs(), TimeUnit.MILLISECONDS))); @@ -143,6 +149,18 @@ public class TbHttpClient { } } + private int getPoolMaxConnections() { + String poolMaxConnectionsEnv = System.getenv("TB_RE_HTTP_CLIENT_POOL_MAX_CONNECTIONS"); + + int poolMaxConnections; + if (poolMaxConnectionsEnv != null) { + poolMaxConnections = Integer.parseInt(poolMaxConnectionsEnv); + } else { + poolMaxConnections = ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS; + } + return poolMaxConnections; + } + private void validateMaxInMemoryBufferSize(TbRestApiCallNodeConfiguration config) throws TbNodeException { int systemMaxInMemoryBufferSizeInKb = 25000; try { diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeTest.java new file mode 100644 index 0000000000..23fb69d8b7 --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeTest.java @@ -0,0 +1,343 @@ +/** + * 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.delay; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; +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.msg.TbMsgType; +import org.thingsboard.server.common.data.msg.TbNodeConnectionType; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; + +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.spy; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willAnswer; + +@ExtendWith(MockitoExtension.class) +public class TbMsgDelayNodeTest extends AbstractRuleNodeUpgradeTest { + + private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("20107cf0-1c5e-4ac4-8131-7c466c955a7c")); + private final RuleNodeId RULE_NODE_ID = new RuleNodeId(UUID.fromString("1be24225-b669-4b26-ab7e-083aaa82d0a0")); + + private final Set supportedTimeUnits = EnumSet.of(TimeUnit.SECONDS, TimeUnit.MINUTES, TimeUnit.HOURS); + private final String supportedTimeUnitsStr = supportedTimeUnits.stream().map(TimeUnit::name).collect(Collectors.joining(", ")); + + private TbMsgDelayNode node; + private TbMsgDelayNodeConfiguration config; + + @Mock + private TbContext ctxMock; + @Mock + private RuleNode ruleNodeMock; + + @BeforeEach + public void setUp() { + node = spy(new TbMsgDelayNode()); + config = new TbMsgDelayNodeConfiguration().defaultConfiguration(); + } + + @Test + public void verifyDefaultConfig() { + assertThat(config.getPeriod()).isEqualTo("60"); + assertThat(config.getMaxPendingMsgs()).isEqualTo(1000); + assertThat(config.getTimeUnit()).isEqualTo(TimeUnit.SECONDS.name()); + } + + @Test + public void givenDefaultConfig_whenInit_thenOk() { + given(ctxMock.getSelf()).willReturn(ruleNodeMock); + assertThatNoException().isThrownBy(() -> node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 0, 5000000}) + public void givenInvalidMaxPendingMsgsValue_whenInit_thenThrowsException(int maxPendingMsgs) { + config.setMaxPendingMsgs(maxPendingMsgs); + verifyValidationExceptionOnInit(); + } + + @Test + public void givenPeriodIsNull_whenInit_thenThrowsException() { + config.setPeriod(null); + verifyValidationExceptionOnInit(); + } + + @Test + public void givenTimeUnitIsNull_whenInit_thenThrowsException() { + config.setTimeUnit(null); + verifyValidationExceptionOnInit(); + } + + @ParameterizedTest + @MethodSource + public void givenPeriodValueAndPeriodTimeUnitPatterns_whenOnMsg_thenTellSelfTickMsgAndEnqueueForTellNext( + String periodPattern, String timeUnitPattern, TbMsgMetaData metaData, String data, long expectedDelay) throws TbNodeException { + config.setPeriod(periodPattern); + config.setTimeUnit(timeUnitPattern); + given(ctxMock.getSelf()).willReturn(ruleNodeMock); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metaData, data); + var tickMsg = TbMsg.newMsg(TbMsgType.DELAY_TIMEOUT_SELF_MSG, RULE_NODE_ID, TbMsgMetaData.EMPTY, msg.getId().toString()); + + given(ctxMock.newMsg(any(), any(TbMsgType.class), any(), any(), any(), any())).willReturn(tickMsg); + given(ctxMock.getSelfId()).willReturn(RULE_NODE_ID); + willAnswer(invocation -> { + node.onMsg(ctxMock, invocation.getArgument(0)); + return null; + }).given(ctxMock).tellSelf(any(TbMsg.class), any(Long.class)); + + node.onMsg(ctxMock, msg); + + then(ctxMock).should().tellSelf(tickMsg, expectedDelay); + then(ctxMock).should().ack(msg); + ArgumentCaptor actualMsg = ArgumentCaptor.forClass(TbMsg.class); + then(ctxMock).should().enqueueForTellNext(actualMsg.capture(), eq(TbNodeConnectionType.SUCCESS)); + assertThat(actualMsg.getValue()).usingRecursiveComparison().ignoringFields("id", "ts").isEqualTo(msg); + } + + private static Stream givenPeriodValueAndPeriodTimeUnitPatterns_whenOnMsg_thenTellSelfTickMsgAndEnqueueForTellNext() { + return Stream.of( + Arguments.of("1", "HOURS", TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT, TimeUnit.HOURS.toMillis(1L)), + Arguments.of("${md-period}", "${md-time-unit}", + new TbMsgMetaData(Map.of( + "md-period", "5", + "md-time-unit", "MINUTES" + )), TbMsg.EMPTY_JSON_OBJECT, TimeUnit.MINUTES.toMillis(5L)), + Arguments.of("$[msg-period]", "$[msg-time-unit]", TbMsgMetaData.EMPTY, + "{\"msg-period\":10,\"msg-time-unit\":\"SECONDS\"}", TimeUnit.SECONDS.toMillis(10L)) + ); + } + + @ParameterizedTest + @EnumSource(TimeUnit.class) + public void givenTimeUnit_whenOnMsg_thenVerify(TimeUnit timeUnit) throws TbNodeException { + config.setTimeUnit(timeUnit.name()); + given(ctxMock.getSelf()).willReturn(ruleNodeMock); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + var msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + if (supportedTimeUnits.contains(timeUnit)) { + assertThatNoException().isThrownBy(() -> node.onMsg(ctxMock, msg)); + } else { + assertThatThrownBy(() -> node.onMsg(ctxMock, msg)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Time unit '" + timeUnit + "' is not supported! Only " + supportedTimeUnitsStr + " are supported."); + } + } + + @Test + public void givenPeriodIsUnparsable_whenOnMsg_thenThrowsException() throws TbNodeException { + config.setPeriod("five"); + given(ctxMock.getSelf()).willReturn(ruleNodeMock); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + var msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + assertThatThrownBy(() -> node.onMsg(ctxMock, msg)) + .isInstanceOf(NumberFormatException.class) + .hasMessage("Can't parse period value : five"); + } + + @Test + public void givenInvalidTimeUnit_whenOnMsg_thenThrowsException() throws TbNodeException { + config.setTimeUnit("sec"); + given(ctxMock.getSelf()).willReturn(ruleNodeMock); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + var msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + assertThatThrownBy(() -> node.onMsg(ctxMock, msg)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid value for period time unit : sec"); + } + + @Test + public void givenMaxLimitOfPendingMsgsReached_whenOnMsg_thenTellFailure() throws TbNodeException { + config.setMaxPendingMsgs(1); + given(ctxMock.getSelf()).willReturn(ruleNodeMock); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + var msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + for (int i = 0; i < 2; i++) { + node.onMsg(ctxMock, msg); + } + + ArgumentCaptor throwable = ArgumentCaptor.forClass(Throwable.class); + then(ctxMock).should().tellFailure(eq(msg), throwable.capture()); + assertThat(throwable.getValue()).isInstanceOf(RuntimeException.class).hasMessage("Max limit of pending messages reached!"); + } + + @Test + public void verifyDestroyMethod() { + var msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + var pendingMsgs = new ConcurrentHashMap<>(); + pendingMsgs.put(UUID.fromString("321f0301-9bed-4e7d-b92f-a978f53ec5d6"), msg); + ReflectionTestUtils.setField(node, "pendingMsgs", pendingMsgs); + var actualPendingMsgs = (Map) ReflectionTestUtils.getField(node, "pendingMsgs"); + assertThat(actualPendingMsgs).isEqualTo(pendingMsgs); + + node.destroy(); + + assertThat(actualPendingMsgs).isEmpty(); + } + + private void verifyValidationExceptionOnInit() { + RuleNode ruleNode = new RuleNode(); + ruleNode.setName("test"); + given(ctxMock.getSelf()).willReturn(ruleNode); + String errorPrefix = "'test' node configuration is invalid: "; + assertThatThrownBy(() -> node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessageContaining(errorPrefix) + .extracting(e -> ((TbNodeException) e).isUnrecoverable()) + .isEqualTo(true); + } + + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { + return Stream.of( + // config for version 1 with upgrade from version 0 (useMetadataPeriodInSecondsPatterns does not exist and periodInSeconds exists) + Arguments.of(0, + """ + { + "periodInSeconds": 13, + "maxPendingMsgs": 1000, + "periodInSecondsPattern": "17" + } + """, + true, + """ + { + "period": "13", + "timeUnit": "SECONDS", + "maxPendingMsgs": 1000 + } + """ + ), + // config for version 1 with upgrade from version 0 (useMetadataPeriodInSecondsPatterns and periodInSeconds do not exist) + Arguments.of(0, + """ + { + "maxPendingMsgs": 1000, + "periodInSecondsPattern": "17" + } + """, + true, + """ + { + "period": "60", + "timeUnit": "SECONDS", + "maxPendingMsgs": 1000 + } + """ + ), + // config for version 1 with upgrade from version 0 (useMetadataPeriodInSecondsPatterns is false) + Arguments.of(0, + """ + { + "periodInSeconds": 60, + "maxPendingMsgs": 1000, + "periodInSecondsPattern": null, + "useMetadataPeriodInSecondsPatterns": false + } + """, + true, + """ + { + "period": "60", + "timeUnit": "SECONDS", + "maxPendingMsgs": 1000 + } + """ + ), + // config for version 1 with upgrade from version 0 (useMetadataPeriodInSecondsPattern is true) + Arguments.of(0, + """ + { + "periodInSeconds": 60, + "maxPendingMsgs": 1000, + "periodInSecondsPattern": "${period-pattern}", + "useMetadataPeriodInSecondsPatterns": true + } + """, + true, + """ + { + "period": "${period-pattern}", + "timeUnit": "SECONDS", + "maxPendingMsgs": 1000 + } + """ + ), + // config for version 1 with upgrade from version 0 (hasChanges is false) + Arguments.of(0, + """ + { + "period": "${period-pattern}", + "timeUnit": "SECONDS", + "maxPendingMsgs": 1000 + } + """, + false, + """ + { + "period": "${period-pattern}", + "timeUnit": "SECONDS", + "maxPendingMsgs": 1000 + } + """ + ) + ); + } + + @Override + protected TbNode getTestNode() { + return node; + } +} diff --git a/ui-ngx/src/app/core/http/ota-package.service.ts b/ui-ngx/src/app/core/http/ota-package.service.ts index 9952dd4916..de062a94ee 100644 --- a/ui-ngx/src/app/core/http/ota-package.service.ts +++ b/ui-ngx/src/app/core/http/ota-package.service.ts @@ -59,7 +59,7 @@ export class OtaPackageService { } public getOtaPackage(otaPackageId: string, config?: RequestConfig): Observable { - return this.http.get(`/api/otaPackages/${otaPackageId}`, defaultHttpOptionsFromConfig(config)); + return this.http.get(`/api/otaPackage/${otaPackageId}`, defaultHttpOptionsFromConfig(config)); } public getOtaPackageInfo(otaPackageId: string, config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/core/services/dashboard-utils.service.ts b/ui-ngx/src/app/core/services/dashboard-utils.service.ts index b31c603814..9f1c94a83e 100644 --- a/ui-ngx/src/app/core/services/dashboard-utils.service.ts +++ b/ui-ngx/src/app/core/services/dashboard-utils.service.ts @@ -349,7 +349,7 @@ export class DashboardUtilsService { const config = widget.config; config.showTitle = false; config.dropShadow = false; - config.resizable = !isScada; + config.resizable = true; config.preserveAspectRatio = isScada; config.padding = '0'; config.margin = '0'; diff --git a/ui-ngx/src/app/core/services/time.service.ts b/ui-ngx/src/app/core/services/time.service.ts index 62d5a3507c..c60ee89222 100644 --- a/ui-ngx/src/app/core/services/time.service.ts +++ b/ui-ngx/src/app/core/services/time.service.ts @@ -19,13 +19,18 @@ import { AggregationType, DAY, defaultTimeIntervals, - defaultTimewindow, Interval, IntervalMath, + defaultTimewindow, + getDefaultTimezoneInfo, + Interval, + IntervalMath, SECOND, TimeInterval, - Timewindow + Timewindow, + TimezoneInfo } from '@shared/models/time/time.models'; import { HttpClient } from '@angular/common/http'; -import { isDefined } from '@core/utils'; +import { deepClone, isDefined } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; const MIN_INTERVAL = SECOND; const MAX_INTERVAL = 365 * 20 * DAY; @@ -41,8 +46,11 @@ export class TimeService { private maxDatapointsLimit = MAX_DATAPOINTS_LIMIT; + private localBrowserTimezoneInfoPlaceholder: TimezoneInfo; + constructor( - private http: HttpClient + private http: HttpClient, + private translate: TranslateService ) {} public setMaxDatapointsLimit(limit: number) { @@ -161,4 +169,13 @@ export class TimeService { return defValue; } } + + public getLocalBrowserTimezoneInfoPlaceholder(): TimezoneInfo { + if (!this.localBrowserTimezoneInfoPlaceholder) { + this.localBrowserTimezoneInfoPlaceholder = deepClone(getDefaultTimezoneInfo()); + this.localBrowserTimezoneInfoPlaceholder.id = null; + this.localBrowserTimezoneInfoPlaceholder.name = this.translate.instant('timezone.browser-time'); + } + return this.localBrowserTimezoneInfoPlaceholder; + } } diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index c06863c6e8..512719c513 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -335,6 +335,9 @@ import * as AssetProfileAutocompleteComponent from '@home/components/profile/ass import * as RuleChainSelectComponent from '@shared/components/rule-chain/rule-chain-select.component'; import { IModulesMap } from '@modules/common/modules-map.models'; +import { TimezoneComponent } from '@shared/components/time/timezone.component'; +import { TimezonePanelComponent } from '@shared/components/time/timezone-panel.component'; +import { DatapointsLimitComponent } from '@shared/components/time/datapoints-limit.component'; declare const System; @@ -466,6 +469,9 @@ class ModulesMap implements IModulesMap { '@shared/components/time/datetime-period.component': DatetimePeriodComponent, '@shared/components/time/datetime.component': DatetimeComponent, '@shared/components/time/timezone-select.component': TimezoneSelectComponent, + '@shared/components/time/timezone.component': TimezoneComponent, + '@shared/components/time/timezone-panel.component': TimezonePanelComponent, + '@shared/components/time/datapoints-limit': DatapointsLimitComponent, '@shared/components/value-input.component': ValueInputComponent, '@shared/components/dashboard-autocomplete.component': DashboardAutocompleteComponent, '@shared/components/entity/entity-subtype-autocomplete.component': EntitySubTypeAutocompleteComponent, diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 1919bee9b9..9b5bd7d3ca 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -437,15 +437,15 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC ).pipe( map(value => this.parseBreakpointsResponse(value.breakpoints)), tap((value) => { - this.dashboardCtx.breakpoint = value.id; - this.changeMobileSize.next(this.isMobileSize(value)); + this.dashboardCtx.breakpoint = value ? value.id : 'default'; + this.changeMobileSize.next(value ? this.isMobileSize(value) : false); }), distinctUntilChanged((_, next) => { if (this.layouts.right.show || this.isEdit) { return true; } let nextBreakpointConfiguration: BreakpointId = 'default'; - if (!!this.layouts.main.layoutCtx.layoutData?.[next.id]) { + if (next && !!this.layouts.main.layoutCtx.layoutData?.[next.id]) { nextBreakpointConfiguration = next.id; } return this.layouts.main.layoutCtx.breakpoint === nextBreakpointConfiguration; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/gateway-connector-basic-config.abstract.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/gateway-connector-basic-config.abstract.ts new file mode 100644 index 0000000000..d12e647254 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/gateway-connector-basic-config.abstract.ts @@ -0,0 +1,72 @@ +/// +/// 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 { Directive, inject, Input, OnDestroy, TemplateRef } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, ValidationErrors, Validator } from '@angular/forms'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +@Directive() +export abstract class GatewayConnectorBasicConfigDirective + implements ControlValueAccessor, Validator, OnDestroy { + + @Input() generalTabContent: TemplateRef; + + basicFormGroup: FormGroup; + + protected fb = inject(FormBuilder); + protected onChange!: (value: OutputBasicConfig) => void; + protected onTouched!: () => void; + protected destroy$ = new Subject(); + + constructor() { + this.basicFormGroup = this.initBasicFormGroup(); + + this.basicFormGroup.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((value) => this.onBasicFormGroupChange(value)); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + validate(): ValidationErrors | null { + return this.basicFormGroup.valid ? null : { basicFormGroup: { valid: false } }; + } + + registerOnChange(fn: (value: OutputBasicConfig) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + writeValue(config: OutputBasicConfig): void { + this.basicFormGroup.setValue(this.mapConfigToFormValue(config), { emitEvent: false }); + } + + protected onBasicFormGroupChange(value: InputBasicConfig): void { + this.onChange(this.getMappedValue(value)); + this.onTouched(); + } + + protected abstract mapConfigToFormValue(config: OutputBasicConfig): InputBasicConfig; + protected abstract getMappedValue(config: InputBasicConfig): OutputBasicConfig; + protected abstract initBasicFormGroup(): FormGroup; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/gateway-connector-version-processor.abstract.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/gateway-connector-version-processor.abstract.ts new file mode 100644 index 0000000000..944b5ffe6c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/gateway-connector-version-processor.abstract.ts @@ -0,0 +1,61 @@ +/// +/// 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 { GatewayConnector, GatewayVersion } from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { isNumber, isString } from '@core/utils'; + +export abstract class GatewayConnectorVersionProcessor { + gatewayVersion: number; + configVersion: number; + + protected constructor(protected gatewayVersionIn: string | number, protected connector: GatewayConnector) { + this.gatewayVersion = this.parseVersion(this.gatewayVersionIn); + this.configVersion = this.parseVersion(connector.configVersion); + } + + getProcessedByVersion(): GatewayConnector { + if (this.isVersionUpdateNeeded()) { + return this.isVersionUpgradeNeeded() + ? this.getUpgradedVersion() + : this.getDowngradedVersion(); + } + + return this.connector; + } + + private isVersionUpdateNeeded(): boolean { + if (!this.gatewayVersion) { + return false; + } + + return this.configVersion !== this.gatewayVersion; + } + + private isVersionUpgradeNeeded(): boolean { + return this.gatewayVersionIn === GatewayVersion.Current && (!this.configVersion || this.configVersion < this.gatewayVersion); + } + + private parseVersion(version: string | number): number { + if (isNumber(version)) { + return version as number; + } + + return isString(version) ? parseFloat((version as string).replace(/\./g, '').slice(0, 3)) / 100 : 0; + } + + protected abstract getDowngradedVersion(): GatewayConnector; + protected abstract getUpgradedVersion(): GatewayConnector; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/modbus-version-processor.abstract.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/modbus-version-processor.abstract.ts new file mode 100644 index 0000000000..b38a07cd0f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/modbus-version-processor.abstract.ts @@ -0,0 +1,67 @@ +/// +/// 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 { + GatewayConnector, + ModbusBasicConfig, + ModbusBasicConfig_v3_5_2, + ModbusLegacyBasicConfig, + ModbusLegacySlave, + ModbusSlave, +} from '../gateway-widget.models'; +import { GatewayConnectorVersionProcessor } from './gateway-connector-version-processor.abstract'; +import { ModbusVersionMappingUtil } from '@home/components/widget/lib/gateway/utils/modbus-version-mapping.util'; + +export class ModbusVersionProcessor extends GatewayConnectorVersionProcessor { + + constructor( + protected gatewayVersionIn: string, + protected connector: GatewayConnector + ) { + super(gatewayVersionIn, connector); + } + + getUpgradedVersion(): GatewayConnector { + const configurationJson = this.connector.configurationJson; + return { + ...this.connector, + configurationJson: { + master: configurationJson.master?.slaves + ? ModbusVersionMappingUtil.mapMasterToUpgradedVersion(configurationJson.master) + : { slaves: [] }, + slave: configurationJson.slave + ? ModbusVersionMappingUtil.mapSlaveToUpgradedVersion(configurationJson.slave as ModbusLegacySlave) + : {} as ModbusSlave, + }, + configVersion: this.gatewayVersionIn + } as GatewayConnector; + } + + getDowngradedVersion(): GatewayConnector { + const configurationJson = this.connector.configurationJson; + return { + ...this.connector, + configurationJson: { + ...configurationJson, + slave: configurationJson.slave + ? ModbusVersionMappingUtil.mapSlaveToDowngradedVersion(configurationJson.slave as ModbusSlave) + : {} as ModbusLegacySlave, + master: configurationJson.master, + }, + configVersion: this.gatewayVersionIn + } as GatewayConnector; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/mqtt-version-processor.abstract.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/mqtt-version-processor.abstract.ts new file mode 100644 index 0000000000..351ad76494 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/mqtt-version-processor.abstract.ts @@ -0,0 +1,101 @@ +/// +/// 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 { isEqual } from '@core/utils'; +import { + GatewayConnector, + MQTTBasicConfig, + MQTTBasicConfig_v3_5_2, + MQTTLegacyBasicConfig, + RequestMappingData, + RequestType, +} from '../gateway-widget.models'; +import { MqttVersionMappingUtil } from '../utils/mqtt-version-mapping.util'; +import { GatewayConnectorVersionProcessor } from './gateway-connector-version-processor.abstract'; + +export class MqttVersionProcessor extends GatewayConnectorVersionProcessor { + + private readonly mqttRequestTypeKeys = Object.values(RequestType); + + constructor( + protected gatewayVersionIn: string, + protected connector: GatewayConnector + ) { + super(gatewayVersionIn, connector); + } + + getUpgradedVersion(): GatewayConnector { + const { + connectRequests, + disconnectRequests, + attributeRequests, + attributeUpdates, + serverSideRpc + } = this.connector.configurationJson as MQTTLegacyBasicConfig; + let configurationJson = { + ...this.connector.configurationJson, + requestsMapping: MqttVersionMappingUtil.mapRequestsToUpgradedVersion({ + connectRequests, + disconnectRequests, + attributeRequests, + attributeUpdates, + serverSideRpc + }), + mapping: MqttVersionMappingUtil.mapMappingToUpgradedVersion((this.connector.configurationJson as MQTTLegacyBasicConfig).mapping), + }; + + this.mqttRequestTypeKeys.forEach((key: RequestType) => { + const { [key]: removedValue, ...rest } = configurationJson as MQTTLegacyBasicConfig; + configurationJson = { ...rest } as any; + }); + + this.cleanUpConfigJson(configurationJson as MQTTBasicConfig_v3_5_2); + + return { + ...this.connector, + configurationJson, + configVersion: this.gatewayVersionIn + } as GatewayConnector; + } + + getDowngradedVersion(): GatewayConnector { + const { requestsMapping, mapping, ...restConfig } = this.connector.configurationJson as MQTTBasicConfig_v3_5_2; + + const updatedRequestsMapping = + MqttVersionMappingUtil.mapRequestsToDowngradedVersion(requestsMapping as Record); + const updatedMapping = MqttVersionMappingUtil.mapMappingToDowngradedVersion(mapping); + + return { + ...this.connector, + configurationJson: { + ...restConfig, + ...updatedRequestsMapping, + mapping: updatedMapping, + }, + configVersion: this.gatewayVersionIn + } as GatewayConnector; + } + + private cleanUpConfigJson(configurationJson: MQTTBasicConfig_v3_5_2): void { + if (isEqual(configurationJson.requestsMapping, {})) { + delete configurationJson.requestsMapping; + } + + if (isEqual(configurationJson.mapping, [])) { + delete configurationJson.mapping; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/opc-version-processor.abstract.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/opc-version-processor.abstract.ts new file mode 100644 index 0000000000..e89c48fc28 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/abstract/opc-version-processor.abstract.ts @@ -0,0 +1,56 @@ +/// +/// 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 { + GatewayConnector, LegacyServerConfig, + OPCBasicConfig, + OPCBasicConfig_v3_5_2, + OPCLegacyBasicConfig, +} from '../gateway-widget.models'; +import { GatewayConnectorVersionProcessor } from './gateway-connector-version-processor.abstract'; +import { OpcVersionMappingUtil } from '@home/components/widget/lib/gateway/utils/opc-version-mapping.util'; + +export class OpcVersionProcessor extends GatewayConnectorVersionProcessor { + + constructor( + protected gatewayVersionIn: string, + protected connector: GatewayConnector + ) { + super(gatewayVersionIn, connector); + } + + getUpgradedVersion(): GatewayConnector { + const server = this.connector.configurationJson.server as LegacyServerConfig; + return { + ...this.connector, + configurationJson: { + server: server ? OpcVersionMappingUtil.mapServerToUpgradedVersion(server) : {}, + mapping: server.mapping ? OpcVersionMappingUtil.mapMappingToUpgradedVersion(server.mapping) : [], + }, + configVersion: this.gatewayVersionIn + } as GatewayConnector; + } + + getDowngradedVersion(): GatewayConnector { + return { + ...this.connector, + configurationJson: { + server: OpcVersionMappingUtil.mapServerToDowngradedVersion(this.connector.configurationJson as OPCBasicConfig_v3_5_2) + }, + configVersion: this.gatewayVersionIn + } as GatewayConnector; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/configuration/models/gateway-configuration.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/configuration/models/gateway-configuration.models.ts index a23d8e7900..bfdd46997e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/configuration/models/gateway-configuration.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/configuration/models/gateway-configuration.models.ts @@ -16,7 +16,9 @@ import { ConfigurationModes, - GatewayConnector, LocalLogsConfigs, LogSavingPeriod, + GatewayConnector, + LocalLogsConfigs, + LogSavingPeriod, SecurityTypes, StorageTypes } from '@home/components/widget/lib/gateway/gateway-widget.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component.ts index 569f40b185..91432b7353 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table/device-info-table.component.ts @@ -42,8 +42,8 @@ import { import { DeviceInfoType, noLeadTrailSpacesRegex, - OPCUaSourceTypes, - SourceTypes, + OPCUaSourceType, + SourceType, SourceTypeTranslationsMap } from '@home/components/widget/lib/gateway/gateway-widget.models'; import { coerceBoolean } from '@shared/decorators/coercion'; @@ -81,7 +81,7 @@ export class DeviceInfoTableComponent extends PageComponent implements ControlVa required = false; @Input() - sourceTypes: Array = Object.values(SourceTypes); + sourceTypes: Array = Object.values(SourceType); deviceInfoTypeValue: any; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component.ts index 7867a7213f..de289dde2e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel/mapping-data-keys-panel.component.ts @@ -42,7 +42,7 @@ import { MappingValueType, mappingValueTypesMap, noLeadTrailSpacesRegex, - OPCUaSourceTypes, + OPCUaSourceType, RpcMethodsMapping, } from '@home/components/widget/lib/gateway/gateway-widget.models'; @@ -73,7 +73,7 @@ export class MappingDataKeysPanelComponent extends PageComponent implements OnIn keysType: MappingKeysType; @Input() - valueTypeKeys: Array = Object.values(MappingValueType); + valueTypeKeys: Array = Object.values(MappingValueType); @Input() valueTypeEnum = MappingValueType; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component.ts index 05dcfa88ba..fa8dfa6466 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component.ts @@ -40,17 +40,21 @@ import { Validator, } from '@angular/forms'; import { + AttributeUpdate, ConnectorMapping, + ConnectRequest, ConverterConnectorMapping, ConvertorTypeTranslationsMap, DeviceConnectorMapping, + DisconnectRequest, MappingInfo, MappingType, MappingTypeTranslationsMap, MappingValue, - RequestMappingData, + RequestMappingValue, RequestType, - RequestTypesTranslationsMap + RequestTypesTranslationsMap, + ServerSideRpc } from '@home/components/widget/lib/gateway/gateway-widget.models'; import { MappingDialogComponent } from '@home/components/widget/lib/gateway/dialog/mapping-dialog.component'; import { isDefinedAndNotNull, isUndefinedOrNull } from '@core/utils'; @@ -259,16 +263,17 @@ export class MappingTableComponent implements ControlValueAccessor, Validator, A }; case MappingType.REQUESTS: let details: string; - if ((value as RequestMappingData).requestType === RequestType.ATTRIBUTE_UPDATE) { - details = (value as RequestMappingData).requestValue.attributeFilter; - } else if ((value as RequestMappingData).requestType === RequestType.SERVER_SIDE_RPC) { - details = (value as RequestMappingData).requestValue.methodFilter; + const requestValue = value as RequestMappingValue; + if (requestValue.requestType === RequestType.ATTRIBUTE_UPDATE) { + details = (requestValue.requestValue as AttributeUpdate).attributeFilter; + } else if (requestValue.requestType === RequestType.SERVER_SIDE_RPC) { + details = (requestValue.requestValue as ServerSideRpc).methodFilter; } else { - details = (value as RequestMappingData).requestValue.topicFilter; + details = (requestValue.requestValue as ConnectRequest | DisconnectRequest).topicFilter; } return { - requestType: (value as RequestMappingData).requestType, - type: this.translate.instant(RequestTypesTranslationsMap.get((value as RequestMappingData).requestType)), + requestType: (value as RequestMappingValue).requestType, + type: this.translate.instant(RequestTypesTranslationsMap.get((value as RequestMappingValue).requestType)), details }; case MappingType.OPCUA: diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.abstract.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.abstract.ts new file mode 100644 index 0000000000..f3daa214dd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.abstract.ts @@ -0,0 +1,76 @@ +/// +/// 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 { Directive } from '@angular/core'; +import { FormControl, FormGroup, ValidationErrors } from '@angular/forms'; +import { takeUntil } from 'rxjs/operators'; +import { isEqual } from '@core/utils'; +import { GatewayConnectorBasicConfigDirective } from '@home/components/widget/lib/gateway/abstract/gateway-connector-basic-config.abstract'; +import { + ModbusBasicConfig, + ModbusBasicConfig_v3_5_2, +} from '@home/components/widget/lib/gateway/gateway-widget.models'; + +@Directive() +export abstract class ModbusBasicConfigDirective + extends GatewayConnectorBasicConfigDirective { + + enableSlaveControl: FormControl = new FormControl(false); + + constructor() { + super(); + + this.enableSlaveControl.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(enable => { + this.updateSlaveEnabling(enable); + this.basicFormGroup.get('slave').updateValueAndValidity({ emitEvent: !!this.onChange }); + }); + } + + override writeValue(basicConfig: BasicConfig & ModbusBasicConfig): void { + super.writeValue(basicConfig); + this.onEnableSlaveControl(basicConfig); + } + + override validate(): ValidationErrors | null { + const { master, slave } = this.basicFormGroup.value; + const isEmpty = !master?.slaves?.length && (isEqual(slave, {}) || !slave); + if (!this.basicFormGroup.valid || isEmpty) { + return { basicFormGroup: { valid: false } }; + } + return null; + } + + protected override initBasicFormGroup(): FormGroup { + return this.fb.group({ + master: [], + slave: [], + }); + } + + private updateSlaveEnabling(isEnabled: boolean): void { + if (isEnabled) { + this.basicFormGroup.get('slave').enable({ emitEvent: false }); + } else { + this.basicFormGroup.get('slave').disable({ emitEvent: false }); + } + } + + private onEnableSlaveControl(basicConfig: ModbusBasicConfig): void { + this.enableSlaveControl.setValue(!!basicConfig.slave && !isEqual(basicConfig.slave, {})); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.ts index aa0424da93..b5fab7c92d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component.ts @@ -14,28 +14,21 @@ /// limitations under the License. /// -import { ChangeDetectionStrategy, Component, forwardRef, Input, OnDestroy, TemplateRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; +import { NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms'; import { - ControlValueAccessor, - FormBuilder, - FormControl, - FormGroup, - NG_VALIDATORS, - NG_VALUE_ACCESSOR, - UntypedFormControl, - ValidationErrors, - Validator, -} from '@angular/forms'; -import { ModbusBasicConfig } from '@home/components/widget/lib/gateway/gateway-widget.models'; -import { SharedModule } from '@shared/shared.module'; + ModbusBasicConfig_v3_5_2, + ModbusMasterConfig, + ModbusSlave +} from '@home/components/widget/lib/gateway/gateway-widget.models'; import { CommonModule } from '@angular/common'; -import { takeUntil } from 'rxjs/operators'; -import { Subject } from 'rxjs'; - -import { EllipsisChipListDirective } from '@shared/directives/ellipsis-chip-list.directive'; +import { SharedModule } from '@shared/shared.module'; import { ModbusSlaveConfigComponent } from '../modbus-slave-config/modbus-slave-config.component'; import { ModbusMasterTableComponent } from '../modbus-master-table/modbus-master-table.component'; -import { isEqual } from '@core/utils'; +import { EllipsisChipListDirective } from '@shared/directives/ellipsis-chip-list.directive'; +import { + ModbusBasicConfigDirective +} from '@home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.abstract'; @Component({ selector: 'tb-modbus-basic-config', @@ -63,80 +56,19 @@ import { isEqual } from '@core/utils'; ], styleUrls: ['./modbus-basic-config.component.scss'], }) +export class ModbusBasicConfigComponent extends ModbusBasicConfigDirective { -export class ModbusBasicConfigComponent implements ControlValueAccessor, Validator, OnDestroy { - - @Input() generalTabContent: TemplateRef; - - basicFormGroup: FormGroup; - enableSlaveControl: FormControl; - - onChange: (value: ModbusBasicConfig) => void; - onTouched: () => void; - - private destroy$ = new Subject(); - - constructor(private fb: FormBuilder) { - this.basicFormGroup = this.fb.group({ - master: [], - slave: [], - }); - this.enableSlaveControl = new FormControl(false); - - this.basicFormGroup.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe(({ master, slave }) => { - this.onChange({ master, slave: slave ?? {} }); - this.onTouched(); - }); - - this.enableSlaveControl.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe(enable => { - this.updateSlaveEnabling(enable); - this.basicFormGroup.get('slave').updateValueAndValidity({emitEvent: !!this.onChange}); - }); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - registerOnChange(fn: (value: ModbusBasicConfig) => void): void { - this.onChange = fn; - } - - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - writeValue(basicConfig: ModbusBasicConfig): void { - const editedBase = { - slave: basicConfig.slave ?? {}, - master: basicConfig.master ?? {}, + protected override mapConfigToFormValue({ master, slave }: ModbusBasicConfig_v3_5_2): ModbusBasicConfig_v3_5_2 { + return { + master: master?.slaves ? master : { slaves: [] } as ModbusMasterConfig, + slave: slave ?? {} as ModbusSlave, }; - - this.basicFormGroup.setValue(editedBase, {emitEvent: false}); - this.enableSlaveControl.setValue(!!basicConfig.slave && !isEqual(basicConfig.slave, {})); - } - - validate(basicFormControl: UntypedFormControl): ValidationErrors | null { - const { master, slave } = basicFormControl.value; - const isEmpty = !master?.slaves?.length && (isEqual(slave, {}) || !slave); - if (!this.basicFormGroup.valid || isEmpty) { - return { - basicFormGroup: {valid: false} - }; - } - return null; } - private updateSlaveEnabling(isEnabled: boolean): void { - if (isEnabled) { - this.basicFormGroup.get('slave').enable({emitEvent: false}); - } else { - this.basicFormGroup.get('slave').disable({emitEvent: false}); - } + protected override getMappedValue(value: ModbusBasicConfig_v3_5_2): ModbusBasicConfig_v3_5_2 { + return { + master: value.master, + slave: value.slave, + }; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-legacy-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-legacy-basic-config.component.ts new file mode 100644 index 0000000000..5bca9b0292 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-legacy-basic-config.component.ts @@ -0,0 +1,78 @@ +/// +/// 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 { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; +import { NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { + ModbusBasicConfig_v3_5_2, + ModbusLegacyBasicConfig, ModbusLegacySlave, + ModbusMasterConfig, + ModbusSlave +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { ModbusSlaveConfigComponent } from '../modbus-slave-config/modbus-slave-config.component'; +import { ModbusMasterTableComponent } from '../modbus-master-table/modbus-master-table.component'; +import { EllipsisChipListDirective } from '@shared/directives/ellipsis-chip-list.directive'; +import { ModbusVersionMappingUtil } from '@home/components/widget/lib/gateway/utils/modbus-version-mapping.util'; +import { + ModbusBasicConfigDirective +} from '@home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.abstract'; + +@Component({ + selector: 'tb-modbus-legacy-basic-config', + templateUrl: './modbus-basic-config.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ModbusLegacyBasicConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ModbusLegacyBasicConfigComponent), + multi: true + } + ], + standalone: true, + imports: [ + CommonModule, + SharedModule, + ModbusSlaveConfigComponent, + ModbusMasterTableComponent, + EllipsisChipListDirective, + ], + styleUrls: ['./modbus-basic-config.component.scss'], +}) +export class ModbusLegacyBasicConfigComponent extends ModbusBasicConfigDirective { + + protected override mapConfigToFormValue(config: ModbusLegacyBasicConfig): ModbusBasicConfig_v3_5_2 { + return { + master: config.master?.slaves + ? ModbusVersionMappingUtil.mapMasterToUpgradedVersion(config.master) + : { slaves: [] } as ModbusMasterConfig, + slave: config.slave ? ModbusVersionMappingUtil.mapSlaveToUpgradedVersion(config.slave) : {} as ModbusSlave, + }; + } + + protected override getMappedValue(value: ModbusBasicConfig_v3_5_2): ModbusLegacyBasicConfig { + return { + master: value.master, + slave: value.slave ? ModbusVersionMappingUtil.mapSlaveToDowngradedVersion(value.slave) : {} as ModbusLegacySlave, + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.html index cfb97f674d..fc94fe49ea 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.html @@ -31,9 +31,15 @@
-
{{ 'gateway.key' | translate }}: {{ keyControl.get('tag').value }}
-
{{ 'gateway.address' | translate }}: {{ keyControl.get('address').value }}
-
{{ 'gateway.type' | translate }}: {{ keyControl.get('type').value }}
+
{{ 'gateway.key' | translate }}: + {{ keyControl.get('tag').value }} +
+
{{ 'gateway.address' | translate }}: + {{ keyControl.get('address').value }} +
+
{{ 'gateway.type' | translate }}: + {{ keyControl.get('type').value }} +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.scss index 445ac6c0ae..d242fd4e71 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-data-keys-panel/modbus-data-keys-panel.component.scss @@ -23,6 +23,10 @@ width: 180px; } + .key-label { + font-weight: 400; + } + .key-panel { height: 500px; overflow: auto; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.html index 9360ef499a..134a9baf0a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.html @@ -197,6 +197,16 @@ +
+
gateway.word-order
+
+ + + {{ order }} + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.ts index 5f5b4d293c..f4dfa57f96 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-config/modbus-slave-config.component.ts @@ -113,6 +113,7 @@ export class ModbusSlaveConfigComponent implements ControlValueAccessor, Validat pollPeriod: [5000, [Validators.required]], sendDataToThingsBoard: [false], byteOrder:[ModbusOrderType.BIG], + wordOrder: [ModbusOrderType.BIG], security: [], identity: this.fb.group({ vendorName: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], @@ -243,6 +244,7 @@ export class ModbusSlaveConfigComponent implements ControlValueAccessor, Validat pollPeriod = 5000, sendDataToThingsBoard = false, byteOrder = ModbusOrderType.BIG, + wordOrder = ModbusOrderType.BIG, security = {}, identity = { vendorName: '', @@ -266,6 +268,7 @@ export class ModbusSlaveConfigComponent implements ControlValueAccessor, Validat pollPeriod, sendDataToThingsBoard: !!sendDataToThingsBoard, byteOrder, + wordOrder, security, identity, values, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.html index 1cf95ad06e..6304cdc0cb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.html @@ -260,7 +260,7 @@
-
+
gateway.word-order
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.ts index 5568a0e34c..0167eaafb7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-slave-dialog/modbus-slave-dialog.component.ts @@ -101,7 +101,7 @@ export class ModbusSlaveDialogComponent extends DialogComponent(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.html index a6b4382638..8d1048e3cb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-values/modbus-values.component.html @@ -48,6 +48,8 @@ mat-icon-button color="primary" [disabled]="disabled" + matTooltip="{{ 'action.edit' | translate }}" + matTooltipPosition="above" #attributesButton (click)="manageKeys($event, attributesButton, ModbusValueKey.ATTRIBUTES, register)"> edit @@ -69,6 +71,8 @@ mat-icon-button color="primary" [disabled]="disabled" + matTooltip="{{ 'action.edit' | translate }}" + matTooltipPosition="above" #telemetryButton (click)="manageKeys($event, telemetryButton, ModbusValueKey.TIMESERIES, register)"> edit @@ -90,6 +94,8 @@ mat-icon-button [disabled]="disabled" color="primary" + matTooltip="{{ 'action.edit' | translate }}" + matTooltipPosition="above" #attributesUpdatesButton (click)="manageKeys($event, attributesUpdatesButton, ModbusValueKey.ATTRIBUTES_UPDATES, register)"> edit @@ -111,6 +117,8 @@ mat-icon-button color="primary" [disabled]="disabled" + matTooltip="{{ 'action.edit' | translate }}" + matTooltipPosition="above" #rpcRequestsButton (click)="manageKeys($event, rpcRequestsButton, ModbusValueKey.RPC_REQUESTS, register)"> edit diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.ts deleted file mode 100644 index 25f8671201..0000000000 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.ts +++ /dev/null @@ -1,191 +0,0 @@ -/// -/// 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 { ChangeDetectionStrategy, Component, forwardRef, Input, OnDestroy, TemplateRef } from '@angular/core'; -import { - ControlValueAccessor, - FormBuilder, - FormGroup, - NG_VALIDATORS, - NG_VALUE_ACCESSOR, - ValidationErrors, - Validator, -} from '@angular/forms'; -import { - MappingType, - MQTTBasicConfig, - RequestMappingData, - RequestType, -} from '@home/components/widget/lib/gateway/gateway-widget.models'; -import { SharedModule } from '@shared/shared.module'; -import { CommonModule } from '@angular/common'; -import { takeUntil } from 'rxjs/operators'; -import { Subject } from 'rxjs'; -import { isObject } from 'lodash'; -import { - SecurityConfigComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component'; -import { - WorkersConfigControlComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component'; -import { - BrokerConfigControlComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component'; -import { - MappingTableComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component'; -import { isDefinedAndNotNull } from '@core/utils'; - -@Component({ - selector: 'tb-mqtt-basic-config', - templateUrl: './mqtt-basic-config.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MqttBasicConfigComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MqttBasicConfigComponent), - multi: true - } - ], - standalone: true, - imports: [ - CommonModule, - SharedModule, - SecurityConfigComponent, - WorkersConfigControlComponent, - BrokerConfigControlComponent, - MappingTableComponent, - ], - styleUrls: ['./mqtt-basic-config.component.scss'] -}) - -export class MqttBasicConfigComponent implements ControlValueAccessor, Validator, OnDestroy { - - @Input() - generalTabContent: TemplateRef; - - mappingTypes = MappingType; - basicFormGroup: FormGroup; - - private onChange: (value: MQTTBasicConfig) => void; - private onTouched: () => void; - - private destroy$ = new Subject(); - - constructor(private fb: FormBuilder) { - this.basicFormGroup = this.fb.group({ - dataMapping: [], - requestsMapping: [], - broker: [], - workers: [], - }); - - this.basicFormGroup.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe(value => { - this.onChange(this.getMappedMQTTConfig(value)); - this.onTouched(); - }); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - registerOnChange(fn: (value: MQTTBasicConfig) => void): void { - this.onChange = fn; - } - - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - writeValue(basicConfig: MQTTBasicConfig): void { - const { broker, dataMapping = [], requestsMapping } = basicConfig; - const editedBase = { - workers: broker && (broker.maxNumberOfWorkers || broker.maxMessageNumberPerWorker) ? { - maxNumberOfWorkers: broker.maxNumberOfWorkers, - maxMessageNumberPerWorker: broker.maxMessageNumberPerWorker, - } : {}, - dataMapping: dataMapping || [], - broker: broker || {}, - requestsMapping: Array.isArray(requestsMapping) - ? requestsMapping - : this.getRequestDataArray(requestsMapping), - }; - - this.basicFormGroup.setValue(editedBase, {emitEvent: false}); - } - - private getMappedMQTTConfig(basicConfig: MQTTBasicConfig): MQTTBasicConfig { - let { broker, workers, dataMapping, requestsMapping } = basicConfig || {}; - - if (isDefinedAndNotNull(workers.maxNumberOfWorkers) || isDefinedAndNotNull(workers.maxMessageNumberPerWorker)) { - broker = { - ...broker, - ...workers, - }; - } - - if ((requestsMapping as RequestMappingData[])?.length) { - requestsMapping = this.getRequestDataObject(requestsMapping as RequestMappingData[]); - } - - return { broker, workers, dataMapping, requestsMapping }; - } - - validate(): ValidationErrors | null { - return this.basicFormGroup.valid ? null : { - basicFormGroup: {valid: false} - }; - } - - private getRequestDataArray(value: Record): RequestMappingData[] { - const mappingConfigs = []; - - if (isObject(value)) { - Object.keys(value).forEach((configKey: string) => { - for (const mapping of value[configKey]) { - mappingConfigs.push({ - requestType: configKey, - requestValue: mapping - }); - } - }); - } - - return mappingConfigs; - } - - private getRequestDataObject(array: RequestMappingData[]): Record { - return array.reduce((result, { requestType, requestValue }) => { - result[requestType].push(requestValue); - return result; - }, { - connectRequests: [], - disconnectRequests: [], - attributeRequests: [], - attributeUpdates: [], - serverSideRpc: [], - }); - } -} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.abstract.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.abstract.ts new file mode 100644 index 0000000000..ce87d80a75 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.abstract.ts @@ -0,0 +1,82 @@ +/// +/// 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 { Directive } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { + MappingType, + MQTTBasicConfig, MQTTBasicConfig_v3_5_2, + RequestMappingData, + RequestMappingValue, + RequestType +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { isObject } from '@core/utils'; +import { + GatewayConnectorBasicConfigDirective +} from '@home/components/widget/lib/gateway/abstract/gateway-connector-basic-config.abstract'; + +@Directive() +export abstract class MqttBasicConfigDirective + extends GatewayConnectorBasicConfigDirective { + + MappingType = MappingType; + + protected override initBasicFormGroup(): FormGroup { + return this.fb.group({ + mapping: [], + requestsMapping: [], + broker: [], + workers: [], + }); + } + + protected getRequestDataArray(value: Record): RequestMappingData[] { + const mappingConfigs = []; + + if (isObject(value)) { + Object.keys(value).forEach((configKey: string) => { + for (const mapping of value[configKey]) { + mappingConfigs.push({ + requestType: configKey, + requestValue: mapping + }); + } + }); + } + + return mappingConfigs; + } + + protected getRequestDataObject(array: RequestMappingValue[]): Record { + return array.reduce((result, { requestType, requestValue }) => { + result[requestType].push(requestValue); + return result; + }, { + connectRequests: [], + disconnectRequests: [], + attributeRequests: [], + attributeUpdates: [], + serverSideRpc: [], + }); + } + + writeValue(basicConfig: BasicConfig): void { + this.basicFormGroup.setValue(this.mapConfigToFormValue(basicConfig), { emitEvent: false }); + } + + protected abstract override mapConfigToFormValue(config: BasicConfig): MQTTBasicConfig_v3_5_2; + protected abstract override getMappedValue(config: MQTTBasicConfig): BasicConfig; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.component.html similarity index 90% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.component.html index 9849923218..b602ba20d4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.component.html @@ -24,12 +24,12 @@
- +
- +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.component.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.component.ts new file mode 100644 index 0000000000..9af627352a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.component.ts @@ -0,0 +1,103 @@ +/// +/// 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, forwardRef, ChangeDetectionStrategy } from '@angular/core'; +import { NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms'; +import { + BrokerConfig, + MQTTBasicConfig_v3_5_2, + RequestMappingData, + RequestMappingValue, + RequestType, WorkersConfig +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { + MqttBasicConfigDirective +} from '@home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.abstract'; +import { isDefinedAndNotNull } from '@core/utils'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + SecurityConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component'; +import { + WorkersConfigControlComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mqtt/workers-config-control/workers-config-control.component'; +import { + BrokerConfigControlComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mqtt/broker-config-control/broker-config-control.component'; +import { + MappingTableComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component'; + +@Component({ + selector: 'tb-mqtt-basic-config', + templateUrl: './mqtt-basic-config.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MqttBasicConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MqttBasicConfigComponent), + multi: true + } + ], + styleUrls: ['./mqtt-basic-config.component.scss'], + standalone: true, + imports: [ + CommonModule, + SharedModule, + SecurityConfigComponent, + WorkersConfigControlComponent, + BrokerConfigControlComponent, + MappingTableComponent, + ], +}) +export class MqttBasicConfigComponent extends MqttBasicConfigDirective { + + protected override mapConfigToFormValue(basicConfig: MQTTBasicConfig_v3_5_2): MQTTBasicConfig_v3_5_2 { + const { broker, mapping = [], requestsMapping } = basicConfig; + return{ + workers: broker && (broker.maxNumberOfWorkers || broker.maxMessageNumberPerWorker) ? { + maxNumberOfWorkers: broker.maxNumberOfWorkers, + maxMessageNumberPerWorker: broker.maxMessageNumberPerWorker, + } : {} as WorkersConfig, + mapping: mapping ?? [], + broker: broker ?? {} as BrokerConfig, + requestsMapping: this.getRequestDataArray(requestsMapping as Record), + }; + } + + protected override getMappedValue(basicConfig: MQTTBasicConfig_v3_5_2): MQTTBasicConfig_v3_5_2 { + let { broker, workers, mapping, requestsMapping } = basicConfig || {}; + + if (isDefinedAndNotNull(workers.maxNumberOfWorkers) || isDefinedAndNotNull(workers.maxMessageNumberPerWorker)) { + broker = { + ...broker, + ...workers, + }; + } + + if ((requestsMapping as RequestMappingData[])?.length) { + requestsMapping = this.getRequestDataObject(requestsMapping as RequestMappingValue[]); + } + + return { broker, mapping, requestsMapping }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-legacy-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-legacy-basic-config.component.ts new file mode 100644 index 0000000000..e4577c653e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-legacy-basic-config.component.ts @@ -0,0 +1,124 @@ +/// +/// 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, forwardRef, ChangeDetectionStrategy } from '@angular/core'; +import { NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms'; +import { + BrokerConfig, + MQTTBasicConfig_v3_5_2, + MQTTLegacyBasicConfig, + RequestMappingData, + RequestMappingValue, + RequestType, WorkersConfig +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { MqttVersionMappingUtil } from '@home/components/widget/lib/gateway/utils/mqtt-version-mapping.util'; +import { + MqttBasicConfigDirective +} from '@home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.abstract'; +import { isDefinedAndNotNull } from '@core/utils'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + SecurityConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component'; +import { + WorkersConfigControlComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mqtt/workers-config-control/workers-config-control.component'; +import { + BrokerConfigControlComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mqtt/broker-config-control/broker-config-control.component'; +import { + MappingTableComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component'; + +@Component({ + selector: 'tb-mqtt-legacy-basic-config', + templateUrl: './mqtt-basic-config.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MqttLegacyBasicConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MqttLegacyBasicConfigComponent), + multi: true + } + ], + styleUrls: ['./mqtt-basic-config.component.scss'], + standalone: true, + imports: [ + CommonModule, + SharedModule, + SecurityConfigComponent, + WorkersConfigControlComponent, + BrokerConfigControlComponent, + MappingTableComponent, + ], +}) +export class MqttLegacyBasicConfigComponent extends MqttBasicConfigDirective { + + protected override mapConfigToFormValue(config: MQTTLegacyBasicConfig): MQTTBasicConfig_v3_5_2 { + const { + broker, + mapping = [], + connectRequests = [], + disconnectRequests = [], + attributeRequests = [], + attributeUpdates = [], + serverSideRpc = [] + } = config as MQTTLegacyBasicConfig; + const updatedRequestMapping = MqttVersionMappingUtil.mapRequestsToUpgradedVersion({ + connectRequests, + disconnectRequests, + attributeRequests, + attributeUpdates, + serverSideRpc + }); + return { + workers: broker && (broker.maxNumberOfWorkers || broker.maxMessageNumberPerWorker) ? { + maxNumberOfWorkers: broker.maxNumberOfWorkers, + maxMessageNumberPerWorker: broker.maxMessageNumberPerWorker, + } : {} as WorkersConfig, + mapping: MqttVersionMappingUtil.mapMappingToUpgradedVersion(mapping) || [], + broker: broker || {} as BrokerConfig, + requestsMapping: this.getRequestDataArray(updatedRequestMapping), + }; + } + + protected override getMappedValue(basicConfig: MQTTBasicConfig_v3_5_2): MQTTLegacyBasicConfig { + let { broker, workers, mapping, requestsMapping } = basicConfig || {}; + + if (isDefinedAndNotNull(workers.maxNumberOfWorkers) || isDefinedAndNotNull(workers.maxMessageNumberPerWorker)) { + broker = { + ...broker, + ...workers, + }; + } + + if ((requestsMapping as RequestMappingData[])?.length) { + requestsMapping = this.getRequestDataObject(requestsMapping as RequestMappingValue[]); + } + + return { + broker, + mapping: MqttVersionMappingUtil.mapMappingToDowngradedVersion(mapping), + ...(MqttVersionMappingUtil.mapRequestsToDowngradedVersion(requestsMapping as Record)) + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/broker-config-control/broker-config-control.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/broker-config-control/broker-config-control.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/broker-config-control/broker-config-control.component.ts similarity index 97% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/broker-config-control/broker-config-control.component.ts index e44506dccc..61c62a49e1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/broker-config-control/broker-config-control.component.ts @@ -36,7 +36,7 @@ import { CommonModule } from '@angular/common'; import { generateSecret } from '@core/utils'; import { Subject } from 'rxjs'; import { GatewayPortTooltipPipe } from '@home/components/widget/lib/gateway/pipes/gateway-port-tooltip.pipe'; -import { SecurityConfigComponent } from '../security-config/security-config.component'; +import { SecurityConfigComponent } from '../../security-config/security-config.component'; @Component({ selector: 'tb-broker-config-control', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/workers-config-control/workers-config-control.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/workers-config-control/workers-config-control.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/workers-config-control/workers-config-control.component.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mqtt/workers-config-control/workers-config-control.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.ts deleted file mode 100644 index 39198c9696..0000000000 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.ts +++ /dev/null @@ -1,134 +0,0 @@ -/// -/// 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 { ChangeDetectionStrategy, Component, forwardRef, Input, OnDestroy, TemplateRef } from '@angular/core'; -import { - ControlValueAccessor, - FormBuilder, - FormGroup, - NG_VALIDATORS, - NG_VALUE_ACCESSOR, - ValidationErrors, - Validator, -} from '@angular/forms'; -import { - ConnectorType, - MappingType, - OPCBasicConfig, -} from '@home/components/widget/lib/gateway/gateway-widget.models'; -import { SharedModule } from '@shared/shared.module'; -import { CommonModule } from '@angular/common'; -import { takeUntil } from 'rxjs/operators'; -import { Subject } from 'rxjs'; -import { - SecurityConfigComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component'; -import { - WorkersConfigControlComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component'; -import { - BrokerConfigControlComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component'; -import { - MappingTableComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component'; -import { - OpcServerConfigComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component'; - -@Component({ - selector: 'tb-opc-ua-basic-config', - templateUrl: './opc-ua-basic-config.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => OpcUaBasicConfigComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => OpcUaBasicConfigComponent), - multi: true - } - ], - standalone: true, - imports: [ - CommonModule, - SharedModule, - SecurityConfigComponent, - WorkersConfigControlComponent, - BrokerConfigControlComponent, - MappingTableComponent, - OpcServerConfigComponent, - ], - styleUrls: ['./opc-ua-basic-config.component.scss'] -}) - -export class OpcUaBasicConfigComponent implements ControlValueAccessor, Validator, OnDestroy { - @Input() generalTabContent: TemplateRef; - - mappingTypes = MappingType; - basicFormGroup: FormGroup; - - onChange!: (value: string) => void; - onTouched!: () => void; - - protected readonly connectorType = ConnectorType; - private destroy$ = new Subject(); - - constructor(private fb: FormBuilder) { - this.basicFormGroup = this.fb.group({ - mapping: [], - server: [], - }); - - this.basicFormGroup.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe(value => { - this.onChange(value); - this.onTouched(); - }); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - registerOnChange(fn: (value: string) => void): void { - this.onChange = fn; - } - - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - writeValue(basicConfig: OPCBasicConfig): void { - const editedBase = { - server: basicConfig.server || {}, - mapping: basicConfig.mapping || [], - }; - - this.basicFormGroup.setValue(editedBase, {emitEvent: false}); - } - - validate(): ValidationErrors | null { - return this.basicFormGroup.valid ? null : { - basicFormGroup: {valid: false} - }; - } -} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-server-config/opc-server-config.component.html similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-server-config/opc-server-config.component.html index 584679fa2c..523705d029 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-server-config/opc-server-config.component.html @@ -84,7 +84,7 @@
-
+
{{ 'gateway.poll-period' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-server-config/opc-server-config.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-server-config/opc-server-config.component.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-server-config/opc-server-config.component.ts similarity index 90% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-server-config/opc-server-config.component.ts index c838121b76..c000139ded 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-server-config/opc-server-config.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { ChangeDetectionStrategy, Component, forwardRef, OnDestroy } from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, Component, forwardRef, Input, OnDestroy } from '@angular/core'; import { ControlValueAccessor, FormBuilder, @@ -39,6 +39,7 @@ import { SecurityConfigComponent } from '@home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component'; import { HOUR } from '@shared/models/time/time.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-opc-server-config', @@ -64,7 +65,11 @@ import { HOUR } from '@shared/models/time/time.models'; SecurityConfigComponent, ] }) -export class OpcServerConfigComponent implements ControlValueAccessor, Validator, OnDestroy { +export class OpcServerConfigComponent implements ControlValueAccessor, Validator, AfterViewInit, OnDestroy { + + @Input() + @coerceBoolean() + hideNewFields: boolean = false; securityPolicyTypes = SecurityPolicyTypes; serverConfigFormGroup: UntypedFormGroup; @@ -95,6 +100,12 @@ export class OpcServerConfigComponent implements ControlValueAccessor, Validator }); } + ngAfterViewInit(): void { + if (this.hideNewFields) { + this.serverConfigFormGroup.get('pollPeriodInMillis').disable({emitEvent: false}); + } + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-basic-config.component.html similarity index 92% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-basic-config.component.html index 5f8b25af9d..2296a472a4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-basic-config.component.html @@ -20,7 +20,7 @@ - +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-basic-config.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-basic-config.component.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-basic-config.component.ts new file mode 100644 index 0000000000..c45eafcc67 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-basic-config.component.ts @@ -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. +/// + +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; +import { FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { + MappingType, + OPCBasicConfig_v3_5_2, + ServerConfig +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { MappingTableComponent } from '@home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component'; +import { + SecurityConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component'; +import { + OpcServerConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/opc/opc-server-config/opc-server-config.component'; +import { + GatewayConnectorBasicConfigDirective +} from '@home/components/widget/lib/gateway/abstract/gateway-connector-basic-config.abstract'; + +@Component({ + selector: 'tb-opc-ua-basic-config', + templateUrl: './opc-ua-basic-config.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => OpcUaBasicConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => OpcUaBasicConfigComponent), + multi: true + } + ], + standalone: true, + imports: [ + CommonModule, + SharedModule, + SecurityConfigComponent, + MappingTableComponent, + OpcServerConfigComponent, + ], + styleUrls: ['./opc-ua-basic-config.component.scss'] +}) +export class OpcUaBasicConfigComponent extends GatewayConnectorBasicConfigDirective { + + mappingTypes = MappingType; + isLegacy = false; + + protected override initBasicFormGroup(): FormGroup { + return this.fb.group({ + mapping: [], + server: [], + }); + } + + protected override mapConfigToFormValue(config: OPCBasicConfig_v3_5_2): OPCBasicConfig_v3_5_2 { + return { + server: config.server ?? {} as ServerConfig, + mapping: config.mapping ?? [], + }; + } + + protected override getMappedValue(value: OPCBasicConfig_v3_5_2): OPCBasicConfig_v3_5_2 { + return { + server: value.server, + mapping: value.mapping, + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-legacy-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-legacy-basic-config.component.ts new file mode 100644 index 0000000000..9458858cbf --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-legacy-basic-config.component.ts @@ -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. +/// + +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; +import { FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { + MappingType, + OPCBasicConfig_v3_5_2, + OPCLegacyBasicConfig, ServerConfig, +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { MappingTableComponent } from '@home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component'; +import { + SecurityConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/security-config/security-config.component'; +import { + OpcServerConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/opc/opc-server-config/opc-server-config.component'; +import { + GatewayConnectorBasicConfigDirective +} from '@home/components/widget/lib/gateway/abstract/gateway-connector-basic-config.abstract'; +import { OpcVersionMappingUtil } from '@home/components/widget/lib/gateway/utils/opc-version-mapping.util'; + +@Component({ + selector: 'tb-opc-ua-legacy-basic-config', + templateUrl: './opc-ua-basic-config.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => OpcUaLegacyBasicConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => OpcUaLegacyBasicConfigComponent), + multi: true + } + ], + standalone: true, + imports: [ + CommonModule, + SharedModule, + SecurityConfigComponent, + MappingTableComponent, + OpcServerConfigComponent, + ], + styleUrls: ['./opc-ua-basic-config.component.scss'] +}) +export class OpcUaLegacyBasicConfigComponent extends GatewayConnectorBasicConfigDirective { + + mappingTypes = MappingType; + isLegacy = true; + + protected override initBasicFormGroup(): FormGroup { + return this.fb.group({ + mapping: [], + server: [], + }); + } + + protected override mapConfigToFormValue(config: OPCLegacyBasicConfig): OPCBasicConfig_v3_5_2 { + return { + server: config.server ? OpcVersionMappingUtil.mapServerToUpgradedVersion(config.server) : {} as ServerConfig, + mapping: config.server?.mapping ? OpcVersionMappingUtil.mapMappingToUpgradedVersion(config.server.mapping) : [], + }; + } + + protected override getMappedValue(value: OPCBasicConfig_v3_5_2): OPCLegacyBasicConfig { + return { + server: OpcVersionMappingUtil.mapServerToDowngradedVersion(value), + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts index eca1497d22..c8235cb697 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts @@ -26,12 +26,14 @@ import { AddConnectorConfigData, ConnectorType, CreatedConnectorConfigData, + GatewayConnector, GatewayConnectorDefaultTypesTranslatesMap, GatewayLogLevel, - getDefaultConfig, + GatewayVersion, + GatewayVersionedDefaultConfig, noLeadTrailSpacesRegex } from '@home/components/widget/lib/gateway/gateway-widget.models'; -import { Subject } from 'rxjs'; +import { Observable, Subject } from 'rxjs'; import { ResourcesService } from '@core/services/resources.service'; import { takeUntil, tap } from 'rxjs/operators'; import { helpBaseUrl } from '@shared/models/constants'; @@ -42,7 +44,8 @@ import { helpBaseUrl } from '@shared/models/constants'; styleUrls: ['./add-connector-dialog.component.scss'], providers: [], }) -export class AddConnectorDialogComponent extends DialogComponent> implements OnInit, OnDestroy { +export class AddConnectorDialogComponent + extends DialogComponent> implements OnInit, OnDestroy { connectorForm: UntypedFormGroup; @@ -95,8 +98,15 @@ export class AddConnectorDialogComponent extends DialogComponent { - value.configurationJson = defaultConfig; + this.getDefaultConfig(value.type).subscribe((defaultConfig: GatewayVersionedDefaultConfig) => { + const gatewayVersion = this.data.gatewayVersion; + if (gatewayVersion) { + value.configVersion = gatewayVersion; + } + value.configurationJson = (gatewayVersion === GatewayVersion.Current + ? defaultConfig[this.data.gatewayVersion] + : defaultConfig.legacy) + ?? defaultConfig; if (this.connectorForm.valid) { this.dialogRef.close(value); } @@ -130,4 +140,8 @@ export class AddConnectorDialogComponent extends DialogComponent { + return this.resourcesService.loadJsonResource(`/assets/metadata/connector-default-configs/${type}.json`); + }; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.html index 17c002115c..b56449892b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.html @@ -110,6 +110,8 @@
@@ -175,18 +176,36 @@
- - - - - - + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.scss index f151887c92..5d8c6aad5b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.scss @@ -22,6 +22,11 @@ overflow-x: auto; padding: 0; + .version-placeholder { + color: gray; + font-size: 12px + } + .connector-container { height: 100%; width: 100%; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.ts index c4cbf9d698..04dd274f81 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.ts @@ -59,12 +59,16 @@ import { GatewayConnectorDefaultTypesTranslatesMap, GatewayLogLevel, noLeadTrailSpacesRegex, + GatewayVersion, } from './gateway-widget.models'; import { MatDialog } from '@angular/material/dialog'; import { AddConnectorDialogComponent } from '@home/components/widget/lib/gateway/dialog/add-connector-dialog.component'; import { debounceTime, filter, switchMap, take, takeUntil, tap } from 'rxjs/operators'; import { ErrorStateMatcher } from '@angular/material/core'; import { PageData } from '@shared/models/page/page-data'; +import { + GatewayConnectorVersionMappingUtil +} from '@home/components/widget/lib/gateway/utils/gateway-connector-version-mapping.util'; export class ForceErrorStateMatcher implements ErrorStateMatcher { isErrorState(control: FormControl | null): boolean { @@ -98,6 +102,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie readonly displayedColumns = ['enabled', 'key', 'type', 'syncStatus', 'errors', 'actions']; readonly GatewayConnectorTypesTranslatesMap = GatewayConnectorDefaultTypesTranslatesMap; readonly ConnectorConfigurationModes = ConfigurationModes; + readonly GatewayVersion = GatewayVersion; pageLink: PageLink; dataSource: MatTableDataSource; @@ -106,6 +111,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie mode: ConfigurationModes = this.ConnectorConfigurationModes.BASIC; initialConnector: GatewayConnector; + private gatewayVersion: string; private inactiveConnectors: Array; private attributeDataSource: AttributeDatasource; private inactiveConnectorsDataSource: AttributeDatasource; @@ -239,6 +245,10 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie delete value.class; } + if (this.gatewayVersion && !value.configVersion) { + value.configVersion = this.gatewayVersion; + } + value.ts = Date.now(); return value; @@ -263,7 +273,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie }); } - isConnectorSynced(attribute: GatewayAttributeData) { + isConnectorSynced(attribute: GatewayAttributeData): boolean { const connectorData = attribute.value; if (!connectorData.ts || attribute.skipSync) { return false; @@ -340,7 +350,8 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie class: '', configuration: '', configurationJson: {}, - basicConfig: {} + basicConfig: {}, + configVersion: '' }, {emitEvent: false}); this.connectorForm.markAsPristine(); } @@ -483,7 +494,10 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie return this.dialog.open(AddConnectorDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - data: { dataSourceData: this.dataSource.data } + data: { + dataSourceData: this.dataSource.data, + gatewayVersion: this.gatewayVersion, + } }).afterClosed(); } @@ -526,7 +540,8 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie class: [''], configuration: [''], configurationJson: [{}, [Validators.required]], - basicConfig: [{}] + basicConfig: [{}], + configVersion: [''], }); this.connectorForm.disable(); } @@ -560,10 +575,12 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie forkJoin([ this.attributeService.getEntityAttributes(this.device, AttributeScope.SHARED_SCOPE, ['active_connectors']), - this.attributeService.getEntityAttributes(this.device, AttributeScope.SERVER_SCOPE, ['inactive_connectors']) + this.attributeService.getEntityAttributes(this.device, AttributeScope.SERVER_SCOPE, ['inactive_connectors']), + this.attributeService.getEntityAttributes(this.device, AttributeScope.CLIENT_SCOPE, ['Version']) ]).pipe(takeUntil(this.destroy$)).subscribe(attributes => { this.activeConnectors = this.parseConnectors(attributes[0]); this.inactiveConnectors = this.parseConnectors(attributes[1]); + this.gatewayVersion = attributes[2][0]?.value; this.updateData(true); }); @@ -721,12 +738,12 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie this.connectorForm.enable(); } - const connectorState = { + const connectorState = GatewayConnectorVersionMappingUtil.getConfig({ configuration: '', key: 'auto', configurationJson: {} as ConnectorBaseConfig, ...connector, - }; + }, this.gatewayVersion); connectorState.basicConfig = connectorState.configurationJson; this.initialConnector = connectorState; @@ -739,6 +756,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie case ConnectorType.OPCUA: case ConnectorType.MODBUS: this.connectorForm.get('mode').setValue(connector.mode || ConfigurationModes.BASIC, {emitEvent: false}); + this.connectorForm.get('configVersion').setValue(connector.configVersion, {emitEvent: false}); setTimeout(() => { this.connectorForm.patchValue(connector, {emitEvent: false}); this.connectorForm.markAsPristine(); @@ -760,7 +778,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie JSON.parse(clientConnectorData.value) : clientConnectorData.value; if (this.isConnectorSynced(clientConnectorData) && clientConnectorData.value.configurationJson) { - this.setFormValue(clientConnectorData.value); + this.setFormValue({...clientConnectorData.value, mode: this.connectorForm.get('mode').value ?? clientConnectorData.value.mode}); } } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-widget.models.ts index 74ea647b14..a2184089b1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-widget.models.ts @@ -14,8 +14,6 @@ /// limitations under the License. /// -import { ResourcesService } from '@core/services/resources.service'; -import { Observable } from 'rxjs'; import { helpBaseUrl, ValueTypeData } from '@shared/models/constants'; import { AttributeData } from '@shared/models/telemetry/telemetry.models'; @@ -115,21 +113,30 @@ export interface GatewayAttributeData extends AttributeData { skipSync?: boolean; } -export interface GatewayConnector { +export interface GatewayConnectorBase { name: string; type: ConnectorType; configuration?: string; - configurationJson: ConnectorBaseConfig; - basicConfig?: ConnectorBaseConfig; logLevel: string; key?: string; class?: string; mode?: ConfigurationModes; + configVersion?: string; +} + +export interface GatewayConnector extends GatewayConnectorBase { + configurationJson: BaseConfig; + basicConfig?: BaseConfig; +} + +export interface GatewayVersionedDefaultConfig { + legacy: GatewayConnector; + '3.5.2': GatewayConnector; } export interface DataMapping { topicFilter: string; - QoS: string; + QoS: string | number; converter: Converter; } @@ -181,11 +188,20 @@ export interface ConnectorSecurity { mode?: ModeType; } -export type ConnectorMapping = DeviceConnectorMapping | RequestMappingData | ConverterConnectorMapping; +export enum GatewayVersion { + Current = '3.5.2', + Legacy = 'legacy' +} + +export type ConnectorMapping = DeviceConnectorMapping | RequestMappingValue | ConverterConnectorMapping; export type ConnectorMappingFormValue = DeviceConnectorMapping | RequestMappingFormValue | ConverterMappingFormValue; -export type ConnectorBaseConfig = ConnectorBaseInfo | MQTTBasicConfig | OPCBasicConfig | ModbusBasicConfig; +export type ConnectorBaseConfig = ConnectorBaseConfig_v3_5_2 | ConnectorLegacyConfig; + +export type ConnectorLegacyConfig = ConnectorBaseInfo | MQTTLegacyBasicConfig | OPCLegacyBasicConfig | ModbusBasicConfig; + +export type ConnectorBaseConfig_v3_5_2 = ConnectorBaseInfo | MQTTBasicConfig_v3_5_2 | OPCBasicConfig_v3_5_2; export interface ConnectorBaseInfo { name: string; @@ -194,33 +210,64 @@ export interface ConnectorBaseInfo { logLevel: GatewayLogLevel; } -export interface MQTTBasicConfig { - dataMapping: ConverterConnectorMapping[]; - requestsMapping: Record | RequestMappingData[]; +export type MQTTBasicConfig = MQTTBasicConfig_v3_5_2 | MQTTLegacyBasicConfig; + +export interface MQTTBasicConfig_v3_5_2 { + mapping: ConverterConnectorMapping[]; + requestsMapping: Record | RequestMappingData[] | RequestMappingValue[]; + broker: BrokerConfig; + workers?: WorkersConfig; +} + +export interface MQTTLegacyBasicConfig { + mapping: LegacyConverterConnectorMapping[]; broker: BrokerConfig; workers?: WorkersConfig; + connectRequests: LegacyRequestMappingData[]; + disconnectRequests: LegacyRequestMappingData[]; + attributeRequests: LegacyRequestMappingData[]; + attributeUpdates: LegacyRequestMappingData[]; + serverSideRpc: LegacyRequestMappingData[]; } -export interface OPCBasicConfig { +export type OPCBasicConfig = OPCBasicConfig_v3_5_2 | OPCLegacyBasicConfig; + +export interface OPCBasicConfig_v3_5_2 { mapping: DeviceConnectorMapping[]; server: ServerConfig; } -export interface ModbusBasicConfig { +export interface OPCLegacyBasicConfig { + server: LegacyServerConfig; +} + +export interface LegacyServerConfig extends Omit { + mapping: LegacyDeviceConnectorMapping[]; + disableSubscriptions: boolean; +} + +export type ModbusBasicConfig = ModbusBasicConfig_v3_5_2 | ModbusLegacyBasicConfig; + +export interface ModbusBasicConfig_v3_5_2 { master: ModbusMasterConfig; slave: ModbusSlave; } +export interface ModbusLegacyBasicConfig { + master: ModbusMasterConfig; + slave: ModbusLegacySlave; +} + export interface WorkersConfig { maxNumberOfWorkers: number; maxMessageNumberPerWorker: number; } -interface DeviceInfo { +export interface ConnectorDeviceInfo { deviceNameExpression: string; - deviceNameExpressionSource: string; + deviceNameExpressionSource: SourceType | OPCUaSourceType; deviceProfileExpression: string; - deviceProfileExpressionSource: string; + deviceProfileExpressionSource: SourceType | OPCUaSourceType; } export interface Attribute { @@ -229,13 +276,23 @@ export interface Attribute { value: string; } +export interface LegacyAttribute { + key: string; + path: string; +} + export interface Timeseries { key: string; type: string; value: string; } -interface RpcArgument { +export interface LegacyTimeseries { + key: string; + path: string; +} + +export interface RpcArgument { type: string; value: number; } @@ -245,28 +302,59 @@ export interface RpcMethod { arguments: RpcArgument[]; } +export interface LegacyRpcMethod { + method: string; + arguments: unknown[]; +} + export interface AttributesUpdate { key: string; type: string; value: string; } +export interface LegacyDeviceAttributeUpdate { + attributeOnThingsBoard: string; + attributeOnDevice: string; +} + export interface Converter { type: ConvertorType; - deviceNameJsonExpression: string; - deviceTypeJsonExpression: string; + deviceInfo?: ConnectorDeviceInfo; sendDataOnlyOnChange: boolean; timeout: number; - attributes: Attribute[]; - timeseries: Timeseries[]; + attributes?: Attribute[]; + timeseries?: Timeseries[]; + extension?: string; + cached?: boolean; + extensionConfig?: Record; +} + +export interface LegacyConverter extends Converter { + deviceNameJsonExpression?: string; + deviceTypeJsonExpression?: string; + deviceNameTopicExpression?: string; + deviceTypeTopicExpression?: string; + deviceNameExpression?: string; + deviceNameExpressionSource?: string; + deviceTypeExpression?: string; + deviceProfileExpression?: string; + deviceProfileExpressionSource?: string; + ['extension-config']?: Record; } export interface ConverterConnectorMapping { topicFilter: string; - subscriptionQos?: string; + subscriptionQos?: string | number; converter: Converter; } +export interface LegacyConverterConnectorMapping { + topicFilter: string; + subscriptionQos?: string | number; + converter: LegacyConverter; +} + export type ConverterMappingFormValue = Omit & { converter: { type: ConvertorType; @@ -275,14 +363,24 @@ export type ConverterMappingFormValue = Omit; + gatewayVersion: string; } export interface CreatedConnectorConfigData { @@ -591,13 +690,13 @@ export const ConvertorTypeTranslationsMap = new Map( ] ); -export enum SourceTypes { +export enum SourceType { MSG = 'message', TOPIC = 'topic', CONST = 'constant' } -export enum OPCUaSourceTypes { +export enum OPCUaSourceType { PATH = 'path', IDENTIFIER = 'identifier', CONST = 'constant' @@ -608,33 +707,116 @@ export enum DeviceInfoType { PARTIAL = 'partial' } -export const SourceTypeTranslationsMap = new Map( +export const SourceTypeTranslationsMap = new Map( [ - [SourceTypes.MSG, 'gateway.source-type.msg'], - [SourceTypes.TOPIC, 'gateway.source-type.topic'], - [SourceTypes.CONST, 'gateway.source-type.const'], - [OPCUaSourceTypes.PATH, 'gateway.source-type.path'], - [OPCUaSourceTypes.IDENTIFIER, 'gateway.source-type.identifier'], - [OPCUaSourceTypes.CONST, 'gateway.source-type.const'] + [SourceType.MSG, 'gateway.source-type.msg'], + [SourceType.TOPIC, 'gateway.source-type.topic'], + [SourceType.CONST, 'gateway.source-type.const'], + [OPCUaSourceType.PATH, 'gateway.source-type.path'], + [OPCUaSourceType.IDENTIFIER, 'gateway.source-type.identifier'], + [OPCUaSourceType.CONST, 'gateway.source-type.const'] ] ); -export interface RequestMappingData { +export interface RequestMappingValue { requestType: RequestType; - requestValue: RequestDataItem; + requestValue: RequestMappingData; } -export type RequestMappingFormValue = Omit & { - requestValue: Record; -}; - -export interface RequestDataItem { - type: string; - details: string; +export interface RequestMappingFormValue { requestType: RequestType; - methodFilter?: string; - attributeFilter?: string; - topicFilter?: string; + requestValue: Record; +} + +export type RequestMappingData = ConnectRequest | DisconnectRequest | AttributeRequest | AttributeUpdate | ServerSideRpc; + +export type LegacyRequestMappingData = + LegacyConnectRequest + | LegacyDisconnectRequest + | LegacyAttributeRequest + | LegacyAttributeUpdate + | LegacyServerSideRpc; + +export interface ConnectRequest { + topicFilter: string; + deviceInfo: ConnectorDeviceInfo; +} + +export interface DisconnectRequest { + topicFilter: string; + deviceInfo: ConnectorDeviceInfo; +} + +export interface AttributeRequest { + retain: boolean; + topicFilter: string; + deviceInfo: ConnectorDeviceInfo; + attributeNameExpressionSource: SourceType; + attributeNameExpression: string; + topicExpression: string; + valueExpression: string; +} + +export interface AttributeUpdate { + retain: boolean; + deviceNameFilter: string; + attributeFilter: string; + topicExpression: string; + valueExpression: string; +} + +export interface ServerSideRpc { + type: ServerSideRpcType; + deviceNameFilter: string; + methodFilter: string; + requestTopicExpression: string; + responseTopicExpression?: string; + responseTopicQoS?: number; + responseTimeout?: number; + valueExpression: string; +} + +export enum ServerSideRpcType { + WithResponse = 'twoWay', + WithoutResponse = 'oneWay' +} + +export interface LegacyConnectRequest { + topicFilter: string; + deviceNameJsonExpression?: string; + deviceNameTopicExpression?: string; +} + +interface LegacyDisconnectRequest { + topicFilter: string; + deviceNameJsonExpression?: string; + deviceNameTopicExpression?: string; +} + +interface LegacyAttributeRequest { + retain: boolean; + topicFilter: string; + deviceNameJsonExpression: string; + attributeNameJsonExpression: string; + topicExpression: string; + valueExpression: string; +} + +interface LegacyAttributeUpdate { + retain: boolean; + deviceNameFilter: string; + attributeFilter: string; + topicExpression: string; + valueExpression: string; +} + +interface LegacyServerSideRpc { + deviceNameFilter: string; + methodFilter: string; + requestTopicExpression: string; + responseTopicExpression?: string; + responseTimeout?: number; + valueExpression: string; } export enum RequestType { @@ -708,9 +890,6 @@ export enum ServerSideRPCType { TWO_WAY = 'twoWay' } -export const getDefaultConfig = (resourcesService: ResourcesService, type: string): Observable => - resourcesService.loadJsonResource(`/assets/metadata/connector-default-configs/${type}.json`); - export enum MappingValueType { STRING = 'string', INTEGER = 'integer', @@ -938,7 +1117,7 @@ export interface SlaveConfig { pollPeriod: number; unitId: number; deviceName: string; - deviceType: string; + deviceType?: string; sendDataOnlyOnChange: boolean; connectAttemptTimeMs: number; connectAttemptCount: number; @@ -984,14 +1163,26 @@ export interface ModbusSlave { pollPeriod: number; sendDataToThingsBoard: boolean; byteOrder: ModbusOrderType; + wordOrder: ModbusOrderType; identity: ModbusIdentity; - values: ModbusValuesState; + values?: ModbusValuesState; port: string | number; security: ModbusSecurity; } +export interface ModbusLegacySlave extends Omit { + values?: ModbusLegacyRegisterValues; +} + export type ModbusValuesState = ModbusRegisterValues | ModbusValues; +export interface ModbusLegacyRegisterValues { + holding_registers: ModbusValues[]; + coils_initializer: ModbusValues[]; + input_registers: ModbusValues[]; + discrete_inputs: ModbusValues[]; +} + export interface ModbusRegisterValues { holding_registers: ModbusValues; coils_initializer: ModbusValues; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/pipes/gateway-help-link.pipe.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/pipes/gateway-help-link.pipe.ts index 0fec377860..06526ce08e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/pipes/gateway-help-link.pipe.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/pipes/gateway-help-link.pipe.ts @@ -17,8 +17,8 @@ import { Pipe, PipeTransform } from '@angular/core'; import { MappingValueType, - OPCUaSourceTypes, - SourceTypes + OPCUaSourceType, + SourceType } from '@home/components/widget/lib/gateway/gateway-widget.models'; @Pipe({ @@ -26,9 +26,9 @@ import { standalone: true, }) export class GatewayHelpLinkPipe implements PipeTransform { - transform(field: string, sourceType: SourceTypes | OPCUaSourceTypes, sourceTypes?: Array ): string { - if (!sourceTypes || sourceTypes?.includes(OPCUaSourceTypes.PATH)) { - if (sourceType !== OPCUaSourceTypes.CONST) { + transform(field: string, sourceType: SourceType | OPCUaSourceType, sourceTypes?: Array ): string { + if (!sourceTypes || sourceTypes?.includes(OPCUaSourceType.PATH)) { + if (sourceType !== OPCUaSourceType.CONST) { return `widget/lib/gateway/${field}-${sourceType}_fn`; } else { return; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/utils/gateway-connector-version-mapping.util.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/utils/gateway-connector-version-mapping.util.ts new file mode 100644 index 0000000000..90c89ea316 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/utils/gateway-connector-version-mapping.util.ts @@ -0,0 +1,42 @@ +/// +/// 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 { + ConnectorType, + GatewayConnector, + ModbusBasicConfig, + MQTTBasicConfig, + OPCBasicConfig, +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { MqttVersionProcessor } from '@home/components/widget/lib/gateway/abstract/mqtt-version-processor.abstract'; +import { OpcVersionProcessor } from '@home/components/widget/lib/gateway/abstract/opc-version-processor.abstract'; +import { ModbusVersionProcessor } from '@home/components/widget/lib/gateway/abstract/modbus-version-processor.abstract'; + +export abstract class GatewayConnectorVersionMappingUtil { + + static getConfig(connector: GatewayConnector, gatewayVersion: string): GatewayConnector { + switch(connector.type) { + case ConnectorType.MQTT: + return new MqttVersionProcessor(gatewayVersion, connector as GatewayConnector).getProcessedByVersion(); + case ConnectorType.OPCUA: + return new OpcVersionProcessor(gatewayVersion, connector as GatewayConnector).getProcessedByVersion(); + case ConnectorType.MODBUS: + return new ModbusVersionProcessor(gatewayVersion, connector as GatewayConnector).getProcessedByVersion(); + default: + return connector; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/utils/modbus-version-mapping.util.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/utils/modbus-version-mapping.util.ts new file mode 100644 index 0000000000..3f34d49ab2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/utils/modbus-version-mapping.util.ts @@ -0,0 +1,86 @@ +/// +/// 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 { + ModbusDataType, + ModbusLegacyRegisterValues, + ModbusLegacySlave, + ModbusMasterConfig, + ModbusRegisterValues, + ModbusSlave, + ModbusValue, + ModbusValues, + SlaveConfig +} from '@home/components/widget/lib/gateway/gateway-widget.models'; + +export class ModbusVersionMappingUtil { + + static mapMasterToUpgradedVersion(master: ModbusMasterConfig): ModbusMasterConfig { + return { + slaves: master.slaves.map((slave: SlaveConfig) => ({ + ...slave, + deviceType: slave.deviceType ?? 'default', + })) + }; + } + + static mapSlaveToDowngradedVersion(slave: ModbusSlave): ModbusLegacySlave { + if (!slave?.values) { + return slave as Omit; + } + const values = Object.keys(slave.values).reduce((acc, valueKey) => { + acc = { + ...acc, + [valueKey]: [ + slave.values[valueKey] + ] + }; + return acc; + }, {} as ModbusLegacyRegisterValues); + return { + ...slave, + values + }; + } + + static mapSlaveToUpgradedVersion(slave: ModbusLegacySlave): ModbusSlave { + if (!slave?.values) { + return slave as Omit; + } + const values = Object.keys(slave.values).reduce((acc, valueKey) => { + acc = { + ...acc, + [valueKey]: this.mapValuesToUpgradedVersion(slave.values[valueKey][0]) + }; + return acc; + }, {} as ModbusRegisterValues); + return { + ...slave, + values + }; + } + + private static mapValuesToUpgradedVersion(registerValues: ModbusValues): ModbusValues { + return Object.keys(registerValues).reduce((acc, valueKey) => { + acc = { + ...acc, + [valueKey]: registerValues[valueKey].map((value: ModbusValue) => + ({ ...value, type: (value.type as string) === 'int' ? ModbusDataType.INT16 : value.type })) + }; + return acc; + }, {} as ModbusValues); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/utils/mqtt-version-mapping.util.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/utils/mqtt-version-mapping.util.ts new file mode 100644 index 0000000000..23cc03dc4e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/utils/mqtt-version-mapping.util.ts @@ -0,0 +1,217 @@ +/// +/// 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 { deleteNullProperties } from '@core/utils'; +import { + AttributeRequest, + ConnectorDeviceInfo, + Converter, + ConverterConnectorMapping, + ConvertorType, + LegacyConverter, + LegacyConverterConnectorMapping, + LegacyRequestMappingData, + RequestMappingData, + RequestType, + ServerSideRpc, + ServerSideRpcType, + SourceType +} from '@home/components/widget/lib/gateway/gateway-widget.models'; + +export class MqttVersionMappingUtil { + + static readonly mqttRequestTypeKeys = Object.values(RequestType); + static readonly mqttRequestMappingOldFields = + ['attributeNameJsonExpression', 'deviceNameJsonExpression', 'deviceNameTopicExpression', 'extension-config']; + static readonly mqttRequestMappingNewFields = + ['attributeNameExpressionSource', 'responseTopicQoS', 'extensionConfig']; + + static mapMappingToUpgradedVersion( + mapping: LegacyConverterConnectorMapping[] + ): ConverterConnectorMapping[] { + return mapping?.map(({ converter, topicFilter, subscriptionQos = 1 }) => { + const deviceInfo = converter.deviceInfo ?? this.extractConverterDeviceInfo(converter); + + const newConverter = { + ...converter, + deviceInfo, + extensionConfig: converter.extensionConfig || converter['extension-config'] || null + }; + + this.cleanUpOldFields(newConverter); + + return { converter: newConverter, topicFilter, subscriptionQos }; + }) as ConverterConnectorMapping[]; + } + + static mapRequestsToUpgradedVersion( + requestMapping: Record + ): Record { + return this.mqttRequestTypeKeys.reduce((acc, key: RequestType) => { + if (!requestMapping[key]) { + return acc; + } + + acc[key] = requestMapping[key].map(value => { + const newValue = this.mapRequestToUpgradedVersion(value as LegacyRequestMappingData, key); + + this.cleanUpOldFields(newValue as {}); + + return newValue; + }); + + return acc; + }, {}) as Record; + } + + static mapRequestsToDowngradedVersion( + requestsMapping: Record + ): Record { + return this.mqttRequestTypeKeys.reduce((acc, key) => { + if (!requestsMapping[key]) { + return acc; + } + + acc[key] = requestsMapping[key].map((value: RequestMappingData) => { + if (key === RequestType.SERVER_SIDE_RPC) { + delete (value as ServerSideRpc).type; + } + + const { attributeNameExpression, deviceInfo, ...rest } = value as AttributeRequest; + + const newValue = { + ...rest, + attributeNameJsonExpression: attributeNameExpression || null, + deviceNameJsonExpression: deviceInfo?.deviceNameExpressionSource !== SourceType.TOPIC ? deviceInfo?.deviceNameExpression : null, + deviceNameTopicExpression: deviceInfo?.deviceNameExpressionSource === SourceType.TOPIC ? deviceInfo?.deviceNameExpression : null, + }; + + this.cleanUpNewFields(newValue); + + return newValue; + }); + + return acc; + }, {}) as Record; + } + + static mapMappingToDowngradedVersion( + mapping: ConverterConnectorMapping[] + ): LegacyConverterConnectorMapping[] { + return mapping?.map((converterMapping: ConverterConnectorMapping) => { + const converter = this.mapConverterToDowngradedVersion(converterMapping.converter); + + this.cleanUpNewFields(converter as {}); + + return { converter, topicFilter: converterMapping.topicFilter }; + }); + } + + private static mapConverterToDowngradedVersion(converter: Converter): LegacyConverter { + const { deviceInfo, ...restConverter } = converter; + + return converter.type !== ConvertorType.BYTES ? { + ...restConverter, + deviceNameJsonExpression: deviceInfo?.deviceNameExpressionSource === SourceType.MSG ? deviceInfo.deviceNameExpression : null, + deviceTypeJsonExpression: + deviceInfo?.deviceProfileExpressionSource === SourceType.MSG ? deviceInfo.deviceProfileExpression : null, + deviceNameTopicExpression: + deviceInfo?.deviceNameExpressionSource !== SourceType.MSG + ? deviceInfo?.deviceNameExpression + : null, + deviceTypeTopicExpression: deviceInfo?.deviceProfileExpressionSource !== SourceType.MSG + ? deviceInfo?.deviceProfileExpression + : null, + } : { + ...restConverter, + deviceNameExpression: deviceInfo.deviceNameExpression, + deviceTypeExpression: deviceInfo.deviceProfileExpression, + ['extension-config']: converter.extensionConfig, + }; + } + + private static cleanUpOldFields(obj: Record): void { + this.mqttRequestMappingOldFields.forEach(field => delete obj[field]); + deleteNullProperties(obj); + } + + private static cleanUpNewFields(obj: Record): void { + this.mqttRequestMappingNewFields.forEach(field => delete obj[field]); + deleteNullProperties(obj); + } + + private static getTypeSourceByValue(value: string): SourceType { + if (value.includes('${')) { + return SourceType.MSG; + } + if (value.includes(`/`)) { + return SourceType.TOPIC; + } + return SourceType.CONST; + } + + private static extractConverterDeviceInfo(converter: LegacyConverter): ConnectorDeviceInfo { + const deviceNameExpression = converter.deviceNameExpression + || converter.deviceNameJsonExpression + || converter.deviceNameTopicExpression + || null; + const deviceNameExpressionSource = converter.deviceNameExpressionSource + ? converter.deviceNameExpressionSource as SourceType + : deviceNameExpression ? this.getTypeSourceByValue(deviceNameExpression) : null; + const deviceProfileExpression = converter.deviceProfileExpression + || converter.deviceTypeTopicExpression + || converter.deviceTypeJsonExpression + || 'default'; + const deviceProfileExpressionSource = converter.deviceProfileExpressionSource + ? converter.deviceProfileExpressionSource as SourceType + : deviceProfileExpression ? this.getTypeSourceByValue(deviceProfileExpression) : null; + + return deviceNameExpression || deviceProfileExpression ? { + deviceNameExpression, + deviceNameExpressionSource, + deviceProfileExpression, + deviceProfileExpressionSource + } : null; + } + + private static mapRequestToUpgradedVersion(value, key: RequestType): RequestMappingData { + const deviceNameExpression = value.deviceNameJsonExpression || value.deviceNameTopicExpression || null; + const deviceProfileExpression = value.deviceTypeTopicExpression || value.deviceTypeJsonExpression || 'default'; + const deviceProfileExpressionSource = deviceProfileExpression ? this.getTypeSourceByValue(deviceProfileExpression) : null; + const attributeNameExpression = value.attributeNameExpressionSource || value.attributeNameJsonExpression || null; + const responseTopicQoS = key === RequestType.SERVER_SIDE_RPC ? 1 : null; + const type = key === RequestType.SERVER_SIDE_RPC + ? (value as ServerSideRpc).responseTopicExpression + ? ServerSideRpcType.WithResponse + : ServerSideRpcType.WithoutResponse + : null; + + return { + ...value, + attributeNameExpression, + attributeNameExpressionSource: attributeNameExpression ? this.getTypeSourceByValue(attributeNameExpression) : null, + deviceInfo: value.deviceInfo ? value.deviceInfo : deviceNameExpression ? { + deviceNameExpression, + deviceNameExpressionSource: this.getTypeSourceByValue(deviceNameExpression), + deviceProfileExpression, + deviceProfileExpressionSource + } : null, + responseTopicQoS, + type + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/utils/opc-version-mapping.util.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/utils/opc-version-mapping.util.ts new file mode 100644 index 0000000000..35807065e9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/utils/opc-version-mapping.util.ts @@ -0,0 +1,134 @@ +/// +/// 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 { + Attribute, + AttributesUpdate, + DeviceConnectorMapping, + LegacyAttribute, + LegacyDeviceAttributeUpdate, + LegacyDeviceConnectorMapping, + LegacyRpcMethod, + LegacyServerConfig, + LegacyTimeseries, + OPCBasicConfig_v3_5_2, + OPCUaSourceType, + RpcArgument, + RpcMethod, + ServerConfig, + Timeseries +} from '@home/components/widget/lib/gateway/gateway-widget.models'; + +export class OpcVersionMappingUtil { + + static mapServerToUpgradedVersion(server: LegacyServerConfig): ServerConfig { + const { mapping, disableSubscriptions, ...restServer } = server; + return { + ...restServer, + enableSubscriptions: !disableSubscriptions, + }; + } + + static mapServerToDowngradedVersion(config: OPCBasicConfig_v3_5_2): LegacyServerConfig { + const { mapping, server } = config; + const { enableSubscriptions, ...restServer } = server; + return { + ...restServer, + mapping: mapping ? this.mapMappingToDowngradedVersion(mapping) : [], + disableSubscriptions: !enableSubscriptions, + }; + } + + static mapMappingToUpgradedVersion(mapping: LegacyDeviceConnectorMapping[]): DeviceConnectorMapping[] { + return mapping.map((legacyMapping: LegacyDeviceConnectorMapping) => ({ + ...legacyMapping, + deviceNodeSource: this.getTypeSourceByValue(legacyMapping.deviceNodePattern), + deviceInfo: { + deviceNameExpression: legacyMapping.deviceNamePattern, + deviceNameExpressionSource: this.getTypeSourceByValue(legacyMapping.deviceNamePattern), + deviceProfileExpression: legacyMapping.deviceTypePattern ?? 'default', + deviceProfileExpressionSource: this.getTypeSourceByValue(legacyMapping.deviceTypePattern ?? 'default'), + }, + attributes: legacyMapping.attributes.map((attribute: LegacyAttribute) => ({ + key: attribute.key, + type: this.getTypeSourceByValue(attribute.path), + value: attribute.path, + })), + attributes_updates: legacyMapping.attributes_updates.map((attributeUpdate: LegacyDeviceAttributeUpdate) => ({ + key: attributeUpdate.attributeOnThingsBoard, + type: this.getTypeSourceByValue(attributeUpdate.attributeOnDevice), + value: attributeUpdate.attributeOnDevice, + })), + timeseries: legacyMapping.timeseries.map((timeseries: LegacyTimeseries) => ({ + key: timeseries.key, + type: this.getTypeSourceByValue(timeseries.path), + value: timeseries.path, + })), + rpc_methods: legacyMapping.rpc_methods.map((rpcMethod: LegacyRpcMethod) => ({ + method: rpcMethod.method, + arguments: rpcMethod.arguments.map(arg => ({ + value: arg, + type: this.getArgumentType(arg), + } as RpcArgument)) + })) + })); + } + + static mapMappingToDowngradedVersion(mapping: DeviceConnectorMapping[]): LegacyDeviceConnectorMapping[] { + return mapping.map((upgradedMapping: DeviceConnectorMapping) => ({ + ...upgradedMapping, + deviceNamePattern: upgradedMapping.deviceInfo.deviceNameExpression, + deviceTypePattern: upgradedMapping.deviceInfo.deviceProfileExpression, + attributes: upgradedMapping.attributes.map((attribute: Attribute) => ({ + key: attribute.key, + path: attribute.value, + })), + attributes_updates: upgradedMapping.attributes_updates.map((attributeUpdate: AttributesUpdate) => ({ + attributeOnThingsBoard: attributeUpdate.key, + attributeOnDevice: attributeUpdate.value, + })), + timeseries: upgradedMapping.timeseries.map((timeseries: Timeseries) => ({ + key: timeseries.key, + path: timeseries.value, + })), + rpc_methods: upgradedMapping.rpc_methods.map((rpcMethod: RpcMethod) => ({ + method: rpcMethod.method, + arguments: rpcMethod.arguments.map((arg: RpcArgument) => arg.value) + })) + })); + } + + private static getTypeSourceByValue(value: string): OPCUaSourceType { + if (value.includes('${')) { + return OPCUaSourceType.IDENTIFIER; + } + if (value.includes(`/`) || value.includes('\\')) { + return OPCUaSourceType.PATH; + } + return OPCUaSourceType.CONST; + } + + private static getArgumentType(arg: unknown): string { + switch (typeof arg) { + case 'boolean': + return 'boolean'; + case 'number': + return Number.isInteger(arg) ? 'integer' : 'float'; + default: + return 'string'; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts index 1163351fb8..8e5993902d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts @@ -933,7 +933,7 @@ export class ScadaSymbolObject { this.iconRegistry.getDefaultFontSetClass() ).filter(className => className.length > 0); fontSetClasses.forEach(className => textElement.addClass(className)); - textElement.font({size}); + textElement.font({size: `${size}px`}); textElement.attr({ 'text-anchor': 'start', 'dominant-baseline': 'hanging', diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 2acc299586..f241bc91d0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -113,22 +113,22 @@ import { GatewayHelpLinkPipe } from '@home/components/widget/lib/gateway/pipes/g import { EllipsisChipListDirective } from '@shared/directives/ellipsis-chip-list.directive'; import { BrokerConfigControlComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/broker-config-control/broker-config-control.component'; +} from '@home/components/widget/lib/gateway/connectors-configuration/mqtt/broker-config-control/broker-config-control.component'; import { WorkersConfigControlComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/workers-config-control/workers-config-control.component'; +} from '@home/components/widget/lib/gateway/connectors-configuration/mqtt/workers-config-control/workers-config-control.component'; import { OpcServerConfigComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/opc-server-config/opc-server-config.component'; +} from '@home/components/widget/lib/gateway/connectors-configuration/opc/opc-server-config/opc-server-config.component'; import { MqttBasicConfigComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/mqtt-basic-config/mqtt-basic-config.component'; +} from '@home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-basic-config.component'; import { MappingTableComponent } from '@home/components/widget/lib/gateway/connectors-configuration/mapping-table/mapping-table.component'; import { OpcUaBasicConfigComponent -} from '@home/components/widget/lib/gateway/connectors-configuration/opc-ua-basic-config/opc-ua-basic-config.component'; +} from '@home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-basic-config.component'; import { ModbusBasicConfigComponent } from '@home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-basic-config.component'; @@ -145,12 +145,21 @@ import { ModbusRpcParametersComponent } from '@home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-rpc-parameters/modbus-rpc-parameters.component'; import { ScadaSymbolWidgetComponent } from '@home/components/widget/lib/scada/scada-symbol-widget.component'; +import { + MqttLegacyBasicConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/mqtt/basic-config/mqtt-legacy-basic-config.component'; import { GatewayBasicConfigurationComponent } from '@home/components/widget/lib/gateway/configuration/basic/gateway-basic-configuration.component'; import { GatewayAdvancedConfigurationComponent } from '@home/components/widget/lib/gateway/configuration/advanced/gateway-advanced-configuration.component'; +import { + OpcUaLegacyBasicConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/opc/opc-ua-basic-config/opc-ua-legacy-basic-config.component'; +import { + ModbusLegacyBasicConfigComponent +} from '@home/components/widget/lib/gateway/connectors-configuration/modbus/modbus-basic-config/modbus-legacy-basic-config.component'; @NgModule({ declarations: [ @@ -239,8 +248,11 @@ import { ModbusBasicConfigComponent, EllipsisChipListDirective, ModbusRpcParametersComponent, + MqttLegacyBasicConfigComponent, GatewayBasicConfigurationComponent, GatewayAdvancedConfigurationComponent, + OpcUaLegacyBasicConfigComponent, + ModbusLegacyBasicConfigComponent, ], exports: [ EntitiesTableWidgetComponent, diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index ed9ca5123f..0b7b122112 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -204,8 +204,7 @@ export class DashboardWidgets implements Iterable { index = this.dashboardWidgets.findIndex((dashboardWidget) => dashboardWidget.widgetId === record.widgetId); if (index > -1) { const prevDashboardWidget = this.dashboardWidgets[index]; - if (!isEqual(prevDashboardWidget.widget, record.widget) || - !isEqual(prevDashboardWidget.widgetLayout, record.widgetLayout)) { + if (!isEqual(prevDashboardWidget.widget, record.widget)) { this.dashboardWidgets[index] = new DashboardWidget(this.dashboard, record.widget, record.widgetLayout, this.parentDashboard, this.popoverComponent); this.dashboardWidgets[index].highlighted = prevDashboardWidget.highlighted; @@ -391,7 +390,7 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { private gridsterItemComponentSubject = new Subject(); private gridsterItemComponentValue: GridsterItemComponentInterface; - private readonly aspectRatio: number; + private aspectRatio: number; private heightValue: number; private widthValue: number; @@ -419,85 +418,97 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { this.gridsterItemComponentSubject.complete(); } + private preserveAspectRatioApplied = false; + private applyPreserveAspectRatio(item: GridsterItemComponentInterface) { - this.resizableHandles.ne = false; - this.resizableHandles.sw = false; - this.resizableHandles.nw = false; - const $item = item.$item; + if (this.widgetLayout?.preserveAspectRatio) { + this.resizableHandles.ne = false; + this.resizableHandles.sw = false; + this.resizableHandles.nw = false; + } else { + this.resizableHandles.ne = true; + this.resizableHandles.sw = true; + this.resizableHandles.nw = true; + } + + if (!this.preserveAspectRatioApplied) { + const $item = item.$item; - this.rowsValue = $item.rows; - this.colsValue = $item.cols; + this.rowsValue = $item.rows; + this.colsValue = $item.cols; - Object.defineProperty($item, 'rows', { - get: () => this.rowsValue, - set: v => { - if (this.rowsValue !== v) { - if (this.preserveAspectRatio) { - this.colsValue = v * this.aspectRatio; + Object.defineProperty($item, 'rows', { + get: () => this.rowsValue, + set: v => { + if (this.rowsValue !== v) { + if (this.preserveAspectRatio) { + this.colsValue = v * this.aspectRatio; + } + this.rowsValue = v; } - this.rowsValue = v; } - } - }); + }); - Object.defineProperty($item, 'cols', { - get: () => this.colsValue, - set: v => { - if (this.colsValue !== v) { - if (this.preserveAspectRatio) { - this.rowsValue = v / this.aspectRatio; + Object.defineProperty($item, 'cols', { + get: () => this.colsValue, + set: v => { + if (this.colsValue !== v) { + if (this.preserveAspectRatio) { + this.rowsValue = v / this.aspectRatio; + } + this.colsValue = v; } - this.colsValue = v; } - } - }); + }); - const resizable = item.resize; + const resizable = item.resize; - this.heightValue = resizable.height; - this.widthValue = resizable.width; + this.heightValue = resizable.height; + this.widthValue = resizable.width; - const setItemHeight = resizable.setItemHeight.bind(resizable); - const setItemWidth = resizable.setItemWidth.bind(resizable); - resizable.setItemHeight = (height) => { - setItemHeight(height); - this.heightValue = height; - if (this.preserveAspectRatio) { - setItemWidth(height * this.aspectRatio); - } - }; - resizable.setItemWidth = (width) => { - setItemWidth(width); - this.widthValue = width; - if (this.preserveAspectRatio) { - setItemHeight(width / this.aspectRatio); - } - }; - - Object.defineProperty(resizable, 'height', { - get: () => this.heightValue, - set: v => { - if (this.heightValue !== v) { - if (this.preserveAspectRatio) { - this.widthValue = v * this.aspectRatio; + const setItemHeight = resizable.setItemHeight.bind(resizable); + const setItemWidth = resizable.setItemWidth.bind(resizable); + resizable.setItemHeight = (height) => { + setItemHeight(height); + this.heightValue = height; + if (this.preserveAspectRatio) { + setItemWidth(height * this.aspectRatio); + } + }; + resizable.setItemWidth = (width) => { + setItemWidth(width); + this.widthValue = width; + if (this.preserveAspectRatio) { + setItemHeight(width / this.aspectRatio); + } + }; + + Object.defineProperty(resizable, 'height', { + get: () => this.heightValue, + set: v => { + if (this.heightValue !== v) { + if (this.preserveAspectRatio) { + this.widthValue = v * this.aspectRatio; + } + this.heightValue = v; } - this.heightValue = v; } - } - }); + }); - Object.defineProperty(resizable, 'width', { - get: () => this.widthValue, - set: v => { - if (this.widthValue !== v) { - if (this.preserveAspectRatio) { - this.heightValue = v / this.aspectRatio; + Object.defineProperty(resizable, 'width', { + get: () => this.widthValue, + set: v => { + if (this.widthValue !== v) { + if (this.preserveAspectRatio) { + this.heightValue = v / this.aspectRatio; + } + this.widthValue = v; } - this.widthValue = v; } - } - }); + }); + this.preserveAspectRatioApplied = true; + } } get highlighted() { @@ -527,19 +538,36 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { } } + get widgetLayout(): WidgetLayout { + return this.widgetLayoutValue; + } + + set widgetLayout(value: WidgetLayout) { + this.widgetLayoutValue = value; + this._widgetLayoutUpdated(); + } + + private _widgetLayoutUpdated() { + if (isDefined(this.widgetLayout?.resizable)) { + this.resizeEnabled = this.widgetLayout.resizable; + } + if (this.widgetLayout?.preserveAspectRatio) { + this.aspectRatio = this.widgetLayout.sizeX / this.widgetLayout.sizeY; + } + if (this.gridsterItemComponentValue) { + this.applyPreserveAspectRatio(this.gridsterItemComponentValue); + } + } + constructor( private dashboard: IDashboardComponent, public widget: Widget, - public widgetLayout?: WidgetLayout, + private widgetLayoutValue?: WidgetLayout, private parentDashboard?: IDashboardComponent, private popoverComponent?: TbPopoverComponent) { - if (isDefined(widgetLayout?.resizable)) { - this.resizeEnabled = widgetLayout.resizable; - } - if (widgetLayout?.preserveAspectRatio) { - this.aspectRatio = this.widgetLayout.sizeX / this.widgetLayout.sizeY; - } + this.widgetLayout = widgetLayoutValue; + if (!widget.id) { widget.id = guid(); } diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts index 7d3e9983f8..7127d98723 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts @@ -15,14 +15,22 @@ /// import { inject, NgModule } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, ResolveFn, RouterModule, RouterStateSnapshot, Routes } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Resolve, + ResolveFn, + Router, + RouterModule, + RouterStateSnapshot, + Routes +} from '@angular/router'; import { MailServerComponent } from '@modules/home/pages/admin/mail-server.component'; import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { Authority } from '@shared/models/authority.enum'; import { GeneralSettingsComponent } from '@modules/home/pages/admin/general-settings.component'; import { SecuritySettingsComponent } from '@modules/home/pages/admin/security-settings.component'; -import { forkJoin } from 'rxjs'; +import { forkJoin, of } from 'rxjs'; import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component'; import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component'; import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; @@ -45,17 +53,24 @@ import { ScadaSymbolComponent } from '@home/pages/scada-symbol/scada-symbol.comp import { ImageService } from '@core/http/image.service'; import { ScadaSymbolData } from '@home/pages/scada-symbol/scada-symbol-editor.models'; import { MenuId } from '@core/services/menu.models'; +import { catchError } from 'rxjs/operators'; export const scadaSymbolResolver: ResolveFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot, + router = inject(Router), imageService = inject(ImageService)) => { const type: ImageResourceType = route.params.type; const key = decodeURIComponent(route.params.key); return forkJoin({ imageResource: imageService.getImageInfo(type, key), scadaSymbolContent: imageService.getImageString(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}`) - }); + }).pipe( + catchError(() => { + router.navigate(['/resources/scada-symbols']); + return of(null); + }) + ); }; export const scadaSymbolBreadcumbLabelFunction: BreadCrumbLabelFunction diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts index 1e7eef1c41..66a704996b 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts @@ -22,7 +22,7 @@ import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; import { Resolve, Router } from '@angular/router'; -import { Resource, ResourceInfo, ResourceTypeTranslationMap } from '@shared/models/resource.models'; +import { Resource, ResourceInfo, ResourceType, ResourceTypeTranslationMap } from '@shared/models/resource.models'; import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { DatePipe } from '@angular/common'; @@ -118,7 +118,7 @@ export class ResourcesLibraryTableConfigResolver implements Resolve this.isResourceEditable(resource, authUser.authority); this.config.entitySelectionEnabled = (resource) => this.isResourceEditable(resource, authUser.authority); - this.config.detailsReadonly = (resource) => !this.isResourceEditable(resource, authUser.authority); + this.config.detailsReadonly = (resource) => this.detailsReadonly(resource, authUser.authority); return this.config; } @@ -149,6 +149,13 @@ export class ResourcesLibraryTableConfigResolver implements Resolve resource.title - + {{ 'resource.title-required' | translate }} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts index f6a8befe9b..de6a62e15c 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts @@ -43,8 +43,7 @@ export class ResourcesLibraryComponent extends EntityComponent impleme readonly resourceType = ResourceType; readonly resourceTypes: ResourceType[] = Object.values(this.resourceType); readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; - - maxResourceSize = getCurrentAuthState(this.store).maxResourceSize; + readonly maxResourceSize = getCurrentAuthState(this.store).maxResourceSize; private destroy$ = new Subject(); @@ -57,20 +56,20 @@ export class ResourcesLibraryComponent extends EntityComponent impleme super(store, fb, entityValue, entitiesTableConfigValue, cd); } - ngOnInit() { + ngOnInit(): void { super.ngOnInit(); if (this.isAdd) { this.observeResourceTypeChange(); } } - ngOnDestroy() { + ngOnDestroy(): void { super.ngOnDestroy(); this.destroy$.next(); this.destroy$.complete(); } - hideDelete() { + hideDelete(): boolean { if (this.entitiesTableConfig) { return !this.entitiesTableConfig.deleteEnabled(this.entity); } else { @@ -87,19 +86,18 @@ export class ResourcesLibraryComponent extends EntityComponent impleme }); } - updateForm(entity: Resource) { - if (this.isEdit) { - this.entityForm.get('resourceType').disable({emitEvent: false}); - if (entity.resourceType !== ResourceType.JS_MODULE) { - this.entityForm.get('fileName').disable({emitEvent: false}); + updateForm(entity: Resource): void { + this.entityForm.patchValue(entity); + } + + override updateFormState(): void { + super.updateFormState(); + if (this.isEdit && this.entityForm) { + this.entityForm.get('resourceType').disable({ emitEvent: false }); + if (this.entityForm.get('resourceType').value !== ResourceType.JS_MODULE) { + this.entityForm.get('fileName').disable({ emitEvent: false }); } } - this.entityForm.patchValue({ - resourceType: entity.resourceType, - fileName: entity.fileName, - title: entity.title, - data: entity.data - }); } prepareFormValue(formValue: Resource): Resource { @@ -109,7 +107,7 @@ export class ResourcesLibraryComponent extends EntityComponent impleme return super.prepareFormValue(formValue); } - getAllowedExtensions() { + getAllowedExtensions(): string { try { return ResourceTypeExtension.get(this.entityForm.get('resourceType').value); } catch (e) { @@ -117,7 +115,7 @@ export class ResourcesLibraryComponent extends EntityComponent impleme } } - getAcceptType() { + getAcceptType(): string { try { return ResourceTypeMIMETypes.get(this.entityForm.get('resourceType').value); } catch (e) { @@ -129,7 +127,7 @@ export class ResourcesLibraryComponent extends EntityComponent impleme return window.btoa(data); } - onResourceIdCopied() { + onResourceIdCopied(): void { this.store.dispatch(new ActionNotificationShow( { message: this.translate.instant('resource.idCopiedMessage'), diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html index 40cfe51e98..311dbee6d9 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html @@ -121,6 +121,19 @@
+
+ + {{ "notification.use-old-api" | translate }} + + + open_in_new + +
notification.webhook-url diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.ts index 0a4be127ad..ad7c50d604 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.ts @@ -32,7 +32,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { NotificationService } from '@core/http/notification.service'; import { EntityType } from '@shared/models/entity-type.models'; -import { deepTrim, isDefinedAndNotNull } from '@core/utils'; +import { deepTrim, isDefinedAndNotNull, isUndefinedOrNull } from '@core/utils'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { Authority } from '@shared/models/authority.enum'; @@ -100,6 +100,7 @@ export class RecipientNotificationDialogComponent extends conversation: [{value: '', disabled: true}, Validators.required], webhookUrl: [{value: '', disabled: true}, Validators.required], channelName: [{value: '', disabled: true}, Validators.required], + useOldApi: [{value: !this.isAdd, disabled: true}], description: [null] }) }); @@ -120,6 +121,7 @@ export class RecipientNotificationDialogComponent extends case NotificationTargetType.MICROSOFT_TEAMS: this.targetNotificationForm.get('configuration.webhookUrl').enable({emitEvent: false}); this.targetNotificationForm.get('configuration.channelName').enable({emitEvent: false}); + this.targetNotificationForm.get('configuration.useOldApi').enable({emitEvent: false}); break; } this.targetNotificationForm.get('configuration.type').enable({emitEvent: false}); @@ -169,6 +171,10 @@ export class RecipientNotificationDialogComponent extends this.targetNotificationForm.get('configuration.usersFilter.filterByTenants') .patchValue(!Array.isArray(this.data.target.configuration.usersFilter.tenantProfilesIds), {onlySelf: true}); } + if (data.target.configuration.type === NotificationTargetType.MICROSOFT_TEAMS + && isUndefinedOrNull(this.data.target.configuration.useOldApi)) { + this.targetNotificationForm.get('configuration.useOldApi').patchValue(true, {emitEvent: false}); + } } } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html index abd015731e..6110f4afc0 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html @@ -30,6 +30,7 @@ headerHeightPx="64" headerTitle="{{symbolData?.imageResource?.title}}">
+
-
-
diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.scss b/ui-ngx/src/app/shared/components/time/timeinterval.component.scss index ba1c7b8d5a..c08676fe69 100644 --- a/ui-ngx/src/app/shared/components/time/timeinterval.component.scss +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.scss @@ -16,19 +16,8 @@ @import '../../../../scss/constants'; :host { - min-width: 355px; - - .advanced-switch { - margin-bottom: 16px; - } - - .advanced-label { - margin: 5px 0; - } - - .hide-label { - margin-bottom: 5px; - margin-right: 5px; + .advanced-input { + gap: 8px; } @media #{$mat-xs} { diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.ts b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts index 8ed6fb2034..96841290d5 100644 --- a/ui-ngx/src/app/shared/components/time/timeinterval.component.ts +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts @@ -14,14 +14,17 @@ /// limitations under the License. /// -import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; import { TimeService } from '@core/services/time.service'; import { coerceNumberProperty } from '@angular/cdk/coercion'; -import { SubscriptSizing } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { coerceBoolean } from '@shared/decorators/coercion'; import { Interval, IntervalMath, TimeInterval } from '@shared/models/time/time.models'; import { isDefined } from '@core/utils'; +import { IntervalType } from '@shared/models/telemetry/telemetry.models'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; @Component({ selector: 'tb-timeinterval', @@ -35,11 +38,13 @@ import { isDefined } from '@core/utils'; } ] }) -export class TimeintervalComponent implements OnInit, ControlValueAccessor { +export class TimeintervalComponent implements OnInit, ControlValueAccessor, OnDestroy { minValue: number; maxValue: number; + disabledAdvancedState = false; + @Input() set min(min: number) { const minValueData = coerceNumberProperty(min); @@ -68,33 +73,37 @@ export class TimeintervalComponent implements OnInit, ControlValueAccessor { @Input() @coerceBoolean() - hideFlag = false; - - @Input() - @coerceBoolean() - disabledAdvanced = false; + set disabledAdvanced(disabledAdvanced: boolean) { + if (this.disabledAdvancedState !== disabledAdvanced) { + this.disabledAdvancedState = disabledAdvanced; + this.updateIntervalValue(true); + } + } @Input() @coerceBoolean() useCalendarIntervals = false; - @Output() hideFlagChange = new EventEmitter(); - @Input() disabled: boolean; @Input() subscriptSizing: SubscriptSizing = 'fixed'; - days = 0; - hours = 0; - mins = 1; - secs = 0; + @Input() + appearance: MatFormFieldAppearance = 'fill'; - interval: Interval = 0; intervals: Array; advanced = false; + timeintervalFormGroup: FormGroup; + + customTimeInterval: TimeInterval = { + name: 'timeinterval.custom', + translateParams: {}, + value: IntervalType.CUSTOM + }; + private modelValue: Interval; private rendered = false; private propagateChangeValue: any; @@ -103,7 +112,43 @@ export class TimeintervalComponent implements OnInit, ControlValueAccessor { this.propagateChangeValue = value; }; - constructor(private timeService: TimeService) { + private destroy$ = new Subject(); + + constructor(private timeService: TimeService, + private fb: FormBuilder) { + this.timeintervalFormGroup = this.fb.group({ + interval: [ 1 ], + customInterval: this.fb.group({ + days: [ 0 ], + hours: [ 0 ], + mins: [ 1 ], + secs: [ 0 ] + }) + }); + + this.timeintervalFormGroup.get('interval').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.onIntervalChange()); + + this.timeintervalFormGroup.get('customInterval').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.updateView()); + + this.timeintervalFormGroup.get('customInterval.secs').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((secs) => this.onSecsChange(secs)); + + this.timeintervalFormGroup.get('customInterval.mins').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((mins) => this.onMinsChange(mins)); + + this.timeintervalFormGroup.get('customInterval.hours').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((hours) => this.onHoursChange(hours)); + + this.timeintervalFormGroup.get('customInterval.days').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((days) => this.onDaysChange(days)); } ngOnInit(): void { @@ -122,17 +167,36 @@ export class TimeintervalComponent implements OnInit, ControlValueAccessor { setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; + if (this.disabled) { + this.timeintervalFormGroup.disable({emitEvent: false}); + } else { + this.timeintervalFormGroup.enable({emitEvent: false}); + } } writeValue(interval: Interval): void { this.modelValue = interval; this.rendered = true; + this.updateIntervalValue(); + } + + private updateIntervalValue(forceBoundInterval = false) { if (typeof this.modelValue !== 'undefined') { const min = this.timeService.boundMinInterval(this.minValue); const max = this.timeService.boundMaxInterval(this.maxValue); if (IntervalMath.numberValue(this.modelValue) >= min && IntervalMath.numberValue(this.modelValue) <= max) { - this.advanced = !this.timeService.matchesExistingInterval(this.minValue, this.maxValue, this.modelValue, this.useCalendarIntervals); - this.setInterval(this.modelValue); + const advanced = !this.timeService.matchesExistingInterval(this.minValue, this.maxValue, this.modelValue, + this.useCalendarIntervals); + if (advanced && this.disabledAdvancedState) { + this.advanced = false; + this.boundInterval(); + } else { + this.advanced = advanced; + this.setInterval(this.modelValue); + if (forceBoundInterval) { + this.boundInterval(); + } + } } else { this.boundInterval(); } @@ -141,19 +205,30 @@ export class TimeintervalComponent implements OnInit, ControlValueAccessor { private setInterval(interval: Interval) { if (!this.advanced) { - this.interval = interval; + this.timeintervalFormGroup.get('interval').patchValue(interval, {emitEvent: false}); + } else { + this.timeintervalFormGroup.get('interval').patchValue(IntervalType.CUSTOM, {emitEvent: false}); + this.setCustomInterval(interval); } + } + + private setCustomInterval(interval: Interval) { const intervalSeconds = Math.floor(IntervalMath.numberValue(interval) / 1000); - this.days = Math.floor(intervalSeconds / 86400); - this.hours = Math.floor((intervalSeconds % 86400) / 3600); - this.mins = Math.floor(((intervalSeconds % 86400) % 3600) / 60); - this.secs = intervalSeconds % 60; + this.timeintervalFormGroup.get('customInterval').patchValue({ + days: Math.floor(intervalSeconds / 86400), + hours: Math.floor((intervalSeconds % 86400) / 3600), + mins: Math.floor(((intervalSeconds % 86400) % 3600) / 60), + secs: intervalSeconds % 60 + }, {emitEvent: false}); } private boundInterval(updateToPreferred = false) { const min = this.timeService.boundMinInterval(this.minValue); const max = this.timeService.boundMaxInterval(this.maxValue); this.intervals = this.timeService.getIntervals(this.minValue, this.maxValue, this.useCalendarIntervals); + if (!this.disabledAdvancedState) { + this.intervals.push(this.customTimeInterval); + } if (this.rendered) { let newInterval = this.modelValue; const newIntervalMs = IntervalMath.numberValue(newInterval); @@ -179,7 +254,7 @@ export class TimeintervalComponent implements OnInit, ControlValueAccessor { let value: Interval = null; let interval: Interval; if (!this.advanced) { - interval = this.interval; + interval = this.timeintervalFormGroup.get('interval').value; if (!interval || typeof interval === 'number' && isNaN(interval)) { interval = this.calculateIntervalMs(); } @@ -195,118 +270,90 @@ export class TimeintervalComponent implements OnInit, ControlValueAccessor { } private calculateIntervalMs(): number { - return (this.days * 86400 + - this.hours * 3600 + - this.mins * 60 + - this.secs) * 1000; + const customInterval = this.timeintervalFormGroup.get('customInterval').value; + return (customInterval.days * 86400 + + customInterval.hours * 3600 + + customInterval.mins * 60 + + customInterval.secs) * 1000; } onIntervalChange() { - this.updateView(); - } - - onAdvancedChange() { - if (!this.advanced) { - this.interval = this.calculateIntervalMs(); - } else { - let interval = this.interval; - if (!interval || typeof interval === 'number' && isNaN(interval)) { - interval = this.calculateIntervalMs(); + const customIntervalSelected = this.timeintervalFormGroup.get('interval').value === IntervalType.CUSTOM; + if (customIntervalSelected !== this.advanced) { + this.advanced = customIntervalSelected; + if (this.advanced) { + this.setCustomInterval(this.modelValue); } - this.setInterval(interval); } this.updateView(); } - onHideFlagChange() { - this.hideFlagChange.emit(this.hideFlag); - } - - onTimeInputChange(type: string) { - switch (type) { - case 'secs': - setTimeout(() => this.onSecsChange(), 0); - break; - case 'mins': - setTimeout(() => this.onMinsChange(), 0); - break; - case 'hours': - setTimeout(() => this.onHoursChange(), 0); - break; - case 'days': - setTimeout(() => this.onDaysChange(), 0); - break; - } - } - - private onSecsChange() { - if (typeof this.secs === 'undefined') { + private onSecsChange(secs: number) { + const customInterval = this.timeintervalFormGroup.get('customInterval').value; + if (typeof secs === 'undefined') { return; } - if (this.secs < 0) { - if ((this.days + this.hours + this.mins) > 0) { - this.secs = this.secs + 60; - this.mins--; - this.onMinsChange(); + if (secs < 0) { + if ((customInterval.days + customInterval.hours + customInterval.mins) > 0) { + this.timeintervalFormGroup.get('customInterval.secs').patchValue(secs + 60, {emitEvent: false}); + this.timeintervalFormGroup.get('customInterval.mins').patchValue(customInterval.mins - 1, {emitEvent: true}); } else { - this.secs = 0; + this.timeintervalFormGroup.get('customInterval.secs').patchValue(0, {emitEvent: false}); } - } else if (this.secs >= 60) { - this.secs = this.secs - 60; - this.mins++; - this.onMinsChange(); + } else if (secs >= 60) { + this.timeintervalFormGroup.get('customInterval.secs').patchValue(secs - 60, {emitEvent: false}); + this.timeintervalFormGroup.get('customInterval.mins').patchValue(customInterval.mins + 1, {emitEvent: true}); } - this.updateView(); } - private onMinsChange() { - if (typeof this.mins === 'undefined') { + private onMinsChange(mins: number) { + const customInterval = this.timeintervalFormGroup.get('customInterval').value; + if (typeof mins === 'undefined') { return; } - if (this.mins < 0) { - if ((this.days + this.hours) > 0) { - this.mins = this.mins + 60; - this.hours--; - this.onHoursChange(); + if (mins < 0) { + if ((customInterval.days + customInterval.hours) > 0) { + this.timeintervalFormGroup.get('customInterval.mins').patchValue(mins + 60, {emitEvent: false}); + this.timeintervalFormGroup.get('customInterval.hours').patchValue(customInterval.hours - 1, {emitEvent: true}); } else { - this.mins = 0; + this.timeintervalFormGroup.get('customInterval.mins').patchValue(0, {emitEvent: false}); } - } else if (this.mins >= 60) { - this.mins = this.mins - 60; - this.hours++; - this.onHoursChange(); + } else if (mins >= 60) { + this.timeintervalFormGroup.get('customInterval.mins').patchValue(mins - 60, {emitEvent: false}); + this.timeintervalFormGroup.get('customInterval.hours').patchValue(customInterval.hours + 1, {emitEvent: true}); } - this.updateView(); } - private onHoursChange() { - if (typeof this.hours === 'undefined') { + private onHoursChange(hours: number) { + const customInterval = this.timeintervalFormGroup.get('customInterval').value; + if (typeof hours === 'undefined') { return; } - if (this.hours < 0) { - if (this.days > 0) { - this.hours = this.hours + 24; - this.days--; - this.onDaysChange(); + if (hours < 0) { + if (customInterval.days > 0) { + this.timeintervalFormGroup.get('customInterval.hours').patchValue(hours + 24, {emitEvent: false}); + this.timeintervalFormGroup.get('customInterval.days').patchValue(customInterval.days - 1, {emitEvent: true}); } else { - this.hours = 0; + this.timeintervalFormGroup.get('customInterval.hours').patchValue(0, {emitEvent: false}); } - } else if (this.hours >= 24) { - this.hours = this.hours - 24; - this.days++; - this.onDaysChange(); + } else if (hours >= 24) { + this.timeintervalFormGroup.get('customInterval.hours').patchValue(hours - 24, {emitEvent: false}); + this.timeintervalFormGroup.get('customInterval.days').patchValue(customInterval.days + 1, {emitEvent: true}); } - this.updateView(); } - private onDaysChange() { - if (typeof this.days === 'undefined') { + private onDaysChange(days: number) { + if (typeof days === 'undefined') { return; } - if (this.days < 0) { - this.days = 0; + if (days < 0) { + this.timeintervalFormGroup.get('customInterval.days').patchValue(0, {emitEvent: false}); } - this.updateView(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } } diff --git a/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.html b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.html new file mode 100644 index 0000000000..333c70f24f --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.html @@ -0,0 +1,269 @@ + +
+ +

{{ 'timewindow.timewindow-settings' | translate }}

+ + + +
+
+ + +
+
+
+
{{ 'timewindow.timewindow' | translate }}
+
+ + {{ 'timewindow.hide-timewindow-section' | translate }} + +
+ + + + + +
+ + {{ 'timewindow.disable-custom-interval' | translate }} + +
+
+ + {{ 'timewindow.hide' | translate }} + + + +
+
+ +
+ + {{ 'timewindow.hide' | translate }} + + + +
+
+ +
+
{{ 'timewindow.timewindow' | translate }}
+
+ + {{ 'timewindow.hide-timewindow-section' | translate }} + +
+ + + + + +
+ + {{ 'timewindow.disable-custom-interval' | translate }} + +
+
+ + {{ 'timewindow.hide' | translate }} + + + +
+
+ +
+ + {{ 'timewindow.hide' | translate }} + + + +
+ +
+ + {{ 'timewindow.hide' | translate }} + + + +
+
+ + +
+
{{ 'aggregation.aggregation' | translate }}
+
+ + {{ 'timewindow.hide' | translate }} + + + + + + {{ aggregationTypesTranslations.get(aggregationTypes[aggregation]) | translate }} + + + + +
+
+ +
+
{{ 'aggregation.limit' | translate }}
+
+ + {{ 'timewindow.hide' | translate }} + + + + + +
+
+ +
+
{{ 'aggregation.group-interval' | translate }}
+ + +
+ + {{ 'timewindow.disable-custom-interval' | translate }} + +
+
+ + + + + + +
+
+ +
+ + {{ 'timewindow.disable-custom-interval' | translate }} + +
+
+ + + + + + +
+
+ + + + {{ 'timewindow.hide' | translate }} + + +
+
+ +
+
{{ 'timezone.timezone' | translate }}
+
+ + {{ 'timewindow.hide' | translate }} + + + +
+
+
+ +
+ + +
+
diff --git a/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.scss b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.scss new file mode 100644 index 0000000000..eb25955005 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.scss @@ -0,0 +1,37 @@ +/** + * 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. + */ + +:host { + .tb-timewindow-form { + width: 600px; + + &.mat-mdc-dialog-content { + overflow: hidden; + padding: 0; + } + + tb-timezone-select { + flex: 1; + } + } +} + +:host-context(.mat-mdc-dialog-container) { + .tb-timewindow-form { + display: grid; + grid-template-rows: min-content min-content minmax(auto, 1fr) min-content min-content; + } +} diff --git a/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts new file mode 100644 index 0000000000..77297dbe05 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts @@ -0,0 +1,395 @@ +/// +/// 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, Inject, OnDestroy, OnInit } from '@angular/core'; +import { + aggregationTranslations, + AggregationType, + DAY, + HistoryWindowType, + historyWindowTypeTranslations, + quickTimeIntervalPeriod, + RealtimeWindowType, + realtimeWindowTypeTranslations, + Timewindow, + TimewindowType +} from '@shared/models/time/time.models'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { TimeService } from '@core/services/time.service'; +import { isDefined, isDefinedAndNotNull, mergeDeep } from '@core/utils'; +import { ToggleHeaderOption } from '@shared/components/toggle-header.component'; +import { TranslateService } from '@ngx-translate/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +export interface TimewindowConfigDialogData { + quickIntervalOnly: boolean; + aggregation: boolean; + timewindow: Timewindow; +} + +@Component({ + selector: 'tb-timewindow-config-dialog', + templateUrl: './timewindow-config-dialog.component.html', + styleUrls: ['./timewindow-config-dialog.component.scss', './timewindow-form.scss'] +}) +export class TimewindowConfigDialogComponent extends PageComponent implements OnInit, OnDestroy { + + quickIntervalOnly = false; + + aggregation = false; + + timewindow: Timewindow; + + timewindowForm: FormGroup; + + historyTypes = HistoryWindowType; + + realtimeTypes = RealtimeWindowType; + + timewindowTypes = TimewindowType; + + aggregationTypes = AggregationType; + + aggregations = Object.keys(AggregationType); + + aggregationTypesTranslations = aggregationTranslations; + + result: Timewindow; + + timewindowTypeOptions: ToggleHeaderOption[] = [ + { + name: this.translate.instant('timewindow.realtime'), + value: this.timewindowTypes.REALTIME + }, + { + name: this.translate.instant('timewindow.history'), + value: this.timewindowTypes.HISTORY + } + ]; + + realtimeTimewindowOptions: ToggleHeaderOption[] = [ + { + name: this.translate.instant(realtimeWindowTypeTranslations.get(RealtimeWindowType.INTERVAL)), + value: this.realtimeTypes.INTERVAL + } + ]; + + historyTimewindowOptions: ToggleHeaderOption[] = [ + { + name: this.translate.instant(historyWindowTypeTranslations.get(HistoryWindowType.LAST_INTERVAL)), + value: this.historyTypes.LAST_INTERVAL + }, + { + name: this.translate.instant(historyWindowTypeTranslations.get(HistoryWindowType.FIXED)), + value: this.historyTypes.FIXED + }, + { + name: this.translate.instant(historyWindowTypeTranslations.get(HistoryWindowType.INTERVAL)), + value: this.historyTypes.INTERVAL + } + ]; + + realtimeTypeSelectionAvailable: boolean; + + private destroy$ = new Subject(); + + constructor(@Inject(MAT_DIALOG_DATA) public data: TimewindowConfigDialogData, + public dialogRef: MatDialogRef, + protected store: Store, + public fb: FormBuilder, + private timeService: TimeService, + private translate: TranslateService) { + super(store); + this.quickIntervalOnly = data.quickIntervalOnly; + this.aggregation = data.aggregation; + this.timewindow = data.timewindow; + + if (!this.quickIntervalOnly) { + this.realtimeTimewindowOptions.unshift({ + name: this.translate.instant(realtimeWindowTypeTranslations.get(RealtimeWindowType.LAST_INTERVAL)), + value: this.realtimeTypes.LAST_INTERVAL + }); + } + + this.realtimeTypeSelectionAvailable = this.realtimeTimewindowOptions.length > 1; + } + + ngOnInit(): void { + const realtime = this.timewindow.realtime; + const history = this.timewindow.history; + const aggregation = this.timewindow.aggregation; + + this.timewindowForm = this.fb.group({ + selectedTab: [isDefined(this.timewindow.selectedTab) ? this.timewindow.selectedTab : TimewindowType.REALTIME], + realtime: this.fb.group({ + realtimeType: [ isDefined(realtime?.realtimeType) ? this.timewindow.realtime.realtimeType : RealtimeWindowType.LAST_INTERVAL ], + timewindowMs: [ isDefined(realtime?.timewindowMs) ? this.timewindow.realtime.timewindowMs : null ], + interval: [ isDefined(realtime?.interval) ? this.timewindow.realtime.interval : null ], + quickInterval: [ isDefined(realtime?.quickInterval) ? this.timewindow.realtime.quickInterval : null ], + disableCustomInterval: [ isDefinedAndNotNull(this.timewindow.realtime?.disableCustomInterval) + ? this.timewindow.realtime?.disableCustomInterval : false ], + disableCustomGroupInterval: [ isDefinedAndNotNull(this.timewindow.realtime?.disableCustomGroupInterval) + ? this.timewindow.realtime?.disableCustomGroupInterval : false ], + hideInterval: [ isDefinedAndNotNull(this.timewindow.realtime.hideInterval) + ? this.timewindow.realtime.hideInterval : false ], + hideLastInterval: [{ + value: isDefinedAndNotNull(this.timewindow.realtime.hideLastInterval) + ? this.timewindow.realtime.hideLastInterval : false, + disabled: this.timewindow.realtime.hideInterval + }], + hideQuickInterval: [{ + value: isDefinedAndNotNull(this.timewindow.realtime.hideQuickInterval) + ? this.timewindow.realtime.hideQuickInterval : false, + disabled: this.timewindow.realtime.hideInterval + }] + }), + history: this.fb.group({ + historyType: [ isDefined(history?.historyType) ? this.timewindow.history.historyType : HistoryWindowType.LAST_INTERVAL ], + timewindowMs: [ isDefined(history?.timewindowMs) ? this.timewindow.history.timewindowMs : null ], + interval: [ isDefined(history?.interval) ? this.timewindow.history.interval : null ], + fixedTimewindow: [ isDefined(history?.fixedTimewindow) ? this.timewindow.history.fixedTimewindow : null ], + quickInterval: [ isDefined(history?.quickInterval) ? this.timewindow.history.quickInterval : null ], + disableCustomInterval: [ isDefinedAndNotNull(this.timewindow.history?.disableCustomInterval) + ? this.timewindow.history?.disableCustomInterval : false ], + disableCustomGroupInterval: [ isDefinedAndNotNull(this.timewindow.history?.disableCustomGroupInterval) + ? this.timewindow.history?.disableCustomGroupInterval : false ], + hideInterval: [ isDefinedAndNotNull(this.timewindow.history.hideInterval) + ? this.timewindow.history.hideInterval : false ], + hideLastInterval: [{ + value: isDefinedAndNotNull(this.timewindow.history.hideLastInterval) + ? this.timewindow.history.hideLastInterval : false, + disabled: this.timewindow.history.hideInterval + }], + hideQuickInterval: [{ + value: isDefinedAndNotNull(this.timewindow.history.hideQuickInterval) + ? this.timewindow.history.hideQuickInterval : false, + disabled: this.timewindow.history.hideInterval + }], + hideFixedInterval: [{ + value: isDefinedAndNotNull(this.timewindow.history.hideFixedInterval) + ? this.timewindow.history.hideFixedInterval : false, + disabled: this.timewindow.history.hideInterval + }] + }), + aggregation: this.fb.group({ + type: [ isDefined(aggregation?.type) ? this.timewindow.aggregation.type : null ], + limit: [ isDefined(aggregation?.limit) ? this.timewindow.aggregation.limit : null ] + }), + timezone: [ isDefined(this.timewindow.timezone) ? this.timewindow.timezone : null ], + hideAggregation: [ isDefinedAndNotNull(this.timewindow.hideAggregation) + ? this.timewindow.hideAggregation : false ], + hideAggInterval: [ isDefinedAndNotNull(this.timewindow.hideAggInterval) + ? this.timewindow.hideAggInterval : false ], + hideTimezone: [ isDefinedAndNotNull(this.timewindow.hideTimezone) + ? this.timewindow.hideTimezone : false ] + }); + + this.updateValidators(this.timewindowForm.get('aggregation.type').value); + this.timewindowForm.get('aggregation.type').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((aggregationType: AggregationType) => { + this.updateValidators(aggregationType); + }); + this.timewindowForm.get('selectedTab').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((selectedTab: TimewindowType) => { + this.onTimewindowTypeChange(selectedTab); + }); + this.timewindowForm.get('realtime.hideInterval').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value: boolean) => { + if (value) { + this.timewindowForm.get('realtime.hideLastInterval').disable({emitEvent: false}); + this.timewindowForm.get('realtime.hideQuickInterval').disable({emitEvent: false}); + } else { + this.timewindowForm.get('realtime.hideLastInterval').enable({emitEvent: false}); + this.timewindowForm.get('realtime.hideQuickInterval').enable({emitEvent: false}); + } + }); + this.timewindowForm.get('realtime.hideLastInterval').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((hideLastInterval: boolean) => { + if (hideLastInterval && !this.timewindowForm.get('realtime.hideQuickInterval').value) { + this.timewindowForm.get('realtime.realtimeType').setValue(RealtimeWindowType.INTERVAL); + } + }); + this.timewindowForm.get('realtime.hideQuickInterval').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((hideQuickInterval: boolean) => { + if (hideQuickInterval && !this.timewindowForm.get('realtime.hideLastInterval').value) { + this.timewindowForm.get('realtime.realtimeType').setValue(RealtimeWindowType.LAST_INTERVAL); + } + }); + + this.timewindowForm.get('history.hideInterval').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value: boolean) => { + if (value) { + this.timewindowForm.get('history.hideLastInterval').disable({emitEvent: false}); + this.timewindowForm.get('history.hideQuickInterval').disable({emitEvent: false}); + this.timewindowForm.get('history.hideFixedInterval').disable({emitEvent: false}); + } else { + this.timewindowForm.get('history.hideLastInterval').enable({emitEvent: false}); + this.timewindowForm.get('history.hideQuickInterval').enable({emitEvent: false}); + this.timewindowForm.get('history.hideFixedInterval').enable({emitEvent: false}); + } + }); + this.timewindowForm.get('history.hideLastInterval').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((hideLastInterval: boolean) => { + if (hideLastInterval) { + if (!this.timewindowForm.get('history.hideFixedInterval').value) { + this.timewindowForm.get('history.historyType').setValue(HistoryWindowType.FIXED); + } else if (!this.timewindowForm.get('history.hideQuickInterval').value) { + this.timewindowForm.get('history.historyType').setValue(HistoryWindowType.INTERVAL); + } + } + }); + this.timewindowForm.get('history.hideFixedInterval').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((hideFixedInterval: boolean) => { + if (hideFixedInterval) { + if (!this.timewindowForm.get('history.hideLastInterval').value) { + this.timewindowForm.get('history.historyType').setValue(HistoryWindowType.LAST_INTERVAL); + } else if (!this.timewindowForm.get('history.hideQuickInterval').value) { + this.timewindowForm.get('history.historyType').setValue(HistoryWindowType.INTERVAL); + } + } + }); + this.timewindowForm.get('history.hideQuickInterval').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((hideQuickInterval: boolean) => { + if (hideQuickInterval) { + if (!this.timewindowForm.get('history.hideLastInterval').value) { + this.timewindowForm.get('history.historyType').setValue(HistoryWindowType.LAST_INTERVAL); + } else if (!this.timewindowForm.get('history.hideFixedInterval').value) { + this.timewindowForm.get('history.historyType').setValue(HistoryWindowType.FIXED); + } + } + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + private updateValidators(aggType: AggregationType) { + if (aggType !== AggregationType.NONE) { + this.timewindowForm.get('aggregation.limit').clearValidators(); + } else { + this.timewindowForm.get('aggregation.limit').setValidators([Validators.required]); + } + this.timewindowForm.get('aggregation.limit').updateValueAndValidity({emitEvent: false}); + } + + private onTimewindowTypeChange(selectedTab: TimewindowType) { + const timewindowFormValue = this.timewindowForm.getRawValue(); + if (selectedTab === TimewindowType.REALTIME) { + if (timewindowFormValue.history.historyType !== HistoryWindowType.FIXED + && !(this.quickIntervalOnly && timewindowFormValue.history.historyType === HistoryWindowType.LAST_INTERVAL)) { + + this.timewindowForm.get('realtime').patchValue({ + realtimeType: Object.keys(RealtimeWindowType).includes(HistoryWindowType[timewindowFormValue.history.historyType]) ? + RealtimeWindowType[HistoryWindowType[timewindowFormValue.history.historyType]] : + timewindowFormValue.realtime.realtimeType, + timewindowMs: timewindowFormValue.history.timewindowMs, + quickInterval: timewindowFormValue.history.quickInterval.startsWith('CURRENT') ? + timewindowFormValue.history.quickInterval : timewindowFormValue.realtime.quickInterval + }); + setTimeout(() => this.timewindowForm.get('realtime.interval').patchValue(timewindowFormValue.history.interval)); + } + } else { + this.timewindowForm.get('history').patchValue({ + historyType: HistoryWindowType[RealtimeWindowType[timewindowFormValue.realtime.realtimeType]], + timewindowMs: timewindowFormValue.realtime.timewindowMs, + quickInterval: timewindowFormValue.realtime.quickInterval + }); + setTimeout(() => this.timewindowForm.get('history.interval').patchValue(timewindowFormValue.realtime.interval)); + } + this.timewindowForm.patchValue({ + aggregation: { + type: timewindowFormValue.aggregation.type, + limit: timewindowFormValue.aggregation.limit + }, + timezone: timewindowFormValue.timezone, + hideAggregation: timewindowFormValue.hideAggregation, + hideAggInterval: timewindowFormValue.hideAggInterval, + hideTimezone: timewindowFormValue.hideTimezone + }); + } + + update() { + const timewindowFormValue = this.timewindowForm.getRawValue(); + this.timewindow = mergeDeep(this.timewindow, timewindowFormValue); + if (!this.aggregation) { + delete this.timewindow.aggregation; + } + this.dialogRef.close(this.timewindow); + } + + cancel() { + this.dialogRef.close(); + } + + minRealtimeAggInterval() { + return this.timeService.minIntervalLimit(this.currentRealtimeTimewindow()); + } + + maxRealtimeAggInterval() { + return this.timeService.maxIntervalLimit(this.currentRealtimeTimewindow()); + } + + currentRealtimeTimewindow(): number { + const timeWindowFormValue = this.timewindowForm.getRawValue(); + switch (timeWindowFormValue.realtime.realtimeType) { + case RealtimeWindowType.LAST_INTERVAL: + return timeWindowFormValue.realtime.timewindowMs; + case RealtimeWindowType.INTERVAL: + return quickTimeIntervalPeriod(timeWindowFormValue.realtime.quickInterval); + default: + return DAY; + } + } + + minHistoryAggInterval() { + return this.timeService.minIntervalLimit(this.currentHistoryTimewindow()); + } + + maxHistoryAggInterval() { + return this.timeService.maxIntervalLimit(this.currentHistoryTimewindow()); + } + + currentHistoryTimewindow() { + const timewindowFormValue = this.timewindowForm.getRawValue(); + if (timewindowFormValue.history.historyType === HistoryWindowType.LAST_INTERVAL) { + return timewindowFormValue.history.timewindowMs; + } else if (timewindowFormValue.history.historyType === HistoryWindowType.INTERVAL) { + return quickTimeIntervalPeriod(timewindowFormValue.history.quickInterval); + } else if (timewindowFormValue.history.fixedTimewindow) { + return timewindowFormValue.history.fixedTimewindow.endTimeMs - + timewindowFormValue.history.fixedTimewindow.startTimeMs; + } else { + return DAY; + } + } + +} diff --git a/ui-ngx/src/app/shared/components/time/timewindow-form.scss b/ui-ngx/src/app/shared/components/time/timewindow-form.scss new file mode 100644 index 0000000000..5f436acc09 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow-form.scss @@ -0,0 +1,38 @@ +/** + * 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. + */ + +:host { + background-color: #fff; + + .tb-timewindow-form { + overflow: hidden; + + .tb-flex { + gap: 16px; + } + + &-content { + overflow-y: auto; + + tb-timeinterval, + tb-quick-time-interval, + tb-datetime-period, + tb-datapoints-limit { + flex: 1; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html index 1ad06bab3e..e631eff949 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html @@ -15,236 +15,190 @@ limitations under the License. --> -
- - -
-
- - -
-
-
- - -
-
- - -
-
- timewindow.last - -
-
-
- -
-
- - -
-
- timewindow.interval - -
-
-
-
- - + +
+ + + +
+ +
+ +
+
+
+
{{ 'timewindow.timewindow' | translate }}
+ + + +
-
+ + + +
+ +
+ + + + + +
- - - - -
-
- - -
-
-
- - -
- timewindow.for-all-time -
-
- -
- timewindow.last - -
-
- -
- timewindow.time-period - -
-
- -
- timewindow.interval - -
-
-
+ + + +
+
+
+
{{ 'timewindow.timewindow' | translate }}
+ + + +
-
+ + + +
+ +
+ + + + + + + + +
- - - - - -
-
-
- - -
-
- - aggregation.function - + + + + +
+
{{ 'aggregation.aggregation' | translate }}
+ + {{ aggregationTypesTranslations.get(aggregationTypes[aggregation]) | translate }}
-
-
-
- - -
-
-
- -
- - - - - -
-
+ +
+
{{ 'aggregation.limit' | translate }}
+ +
+ + +
+ + +
{{ 'aggregation.group-interval' | translate }}
+
+
+ + +
{{ 'aggregation.group-interval' | translate }}
+
+
-
-
- - -
-
- - -
-
-
- - -
- - -
+ + + + +
+
{{ 'timezone.timezone' | translate }}
+ + +
+
+ + + + +
+ + +
-
- - -
diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss index 25d11f510f..dcf03ed094 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss @@ -13,69 +13,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@import "../../../../scss/constants"; :host { - display: flex; - flex-direction: column; - max-height: 100%; - max-width: 100%; - background-color: #fff; + width: 450px; - .mat-content { - overflow: hidden; - } - - .mat-padding { - padding: 0 16px; - } - - .hide-label { - margin-bottom: 5px; - margin-right: 5px; - } - - tb-timeinterval[ng-reflect-fx-show="true"] { - margin-bottom: -16px; - } + .tb-timewindow-form { + display: flex; + flex-direction: column; + max-height: 100%; - .limit-slider-container { - .limit-slider-value { - margin-left: 16px; - min-width: 25px; - max-width: 100px; + &-header { + flex-direction: row; + align-items: center; } - mat-form-field input[type=number] { - text-align: center; - } - } - @media #{$mat-gt-sm} { - .history-time-input { - min-width: 364px; + &-type-options { + flex: 1; } - .limit-slider-container { - > label { - margin-right: 16px; - width: min-content; - max-width: 40%; - } - } - } -} - -:host ::ng-deep { - .mat-mdc-radio-button { - display: block; - .mdc-form-field { - align-items: start; - > label { - padding-top: 10px; - } + &-settings-btn { + color: rgba(0, 0, 0, 0.54); } } - .mat-mdc-tab-group:not(.tb-headless) { - height: 100%; - } } diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts index 857d2c7cb7..844815f51a 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts @@ -14,14 +14,16 @@ /// limitations under the License. /// -import { Component, Inject, InjectionToken, OnInit, ViewContainerRef } from '@angular/core'; +import { Component, Inject, InjectionToken, OnDestroy, OnInit, ViewContainerRef } from '@angular/core'; import { aggregationTranslations, AggregationType, DAY, HistoryWindowType, + historyWindowTypeTranslations, quickTimeIntervalPeriod, RealtimeWindowType, + realtimeWindowTypeTranslations, Timewindow, TimewindowType } from '@shared/models/time/time.models'; @@ -30,8 +32,17 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { TimeService } from '@core/services/time.service'; -import { isDefined } from '@core/utils'; +import { deepClone, isDefined } from '@core/utils'; import { OverlayRef } from '@angular/cdk/overlay'; +import { ToggleHeaderOption } from '@shared/components/toggle-header.component'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { + TimewindowConfigDialogComponent, + TimewindowConfigDialogData +} from '@shared/components/time/timewindow-config-dialog.component'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; export interface TimewindowPanelData { historyOnly: boolean; @@ -48,9 +59,9 @@ export const TIMEWINDOW_PANEL_DATA = new InjectionToken('TimewindowPanelDat @Component({ selector: 'tb-timewindow-panel', templateUrl: './timewindow-panel.component.html', - styleUrls: ['./timewindow-panel.component.scss'] + styleUrls: ['./timewindow-panel.component.scss', './timewindow-form.scss'] }) -export class TimewindowPanelComponent extends PageComponent implements OnInit { +export class TimewindowPanelComponent extends PageComponent implements OnInit, OnDestroy { historyOnly = false; @@ -82,12 +93,31 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { result: Timewindow; + timewindowTypeOptions: ToggleHeaderOption[] = [{ + name: this.translate.instant('timewindow.history'), + value: this.timewindowTypes.HISTORY + }]; + + realtimeTimewindowOptions: ToggleHeaderOption[] = []; + + historyTimewindowOptions: ToggleHeaderOption[] = []; + + realtimeTypeSelectionAvailable: boolean; + realtimeIntervalSelectionAvailable: boolean; + historyTypeSelectionAvailable: boolean; + historyIntervalSelectionAvailable: boolean; + aggregationOptionsAvailable: boolean; + + private destroy$ = new Subject(); + constructor(@Inject(TIMEWINDOW_PANEL_DATA) public data: TimewindowPanelData, public overlayRef: OverlayRef, protected store: Store, public fb: UntypedFormBuilder, private timeService: TimeService, - public viewContainerRef: ViewContainerRef) { + private translate: TranslateService, + public viewContainerRef: ViewContainerRef, + private dialog: MatDialog) { super(store); this.historyOnly = data.historyOnly; this.forAllTimeEnabled = data.forAllTimeEnabled; @@ -96,12 +126,68 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { this.aggregation = data.aggregation; this.timezone = data.timezone; this.isEdit = data.isEdit; + + if (!this.historyOnly) { + this.timewindowTypeOptions.unshift({ + name: this.translate.instant('timewindow.realtime'), + value: this.timewindowTypes.REALTIME + }); + } + + if ((this.isEdit || !this.timewindow.realtime.hideLastInterval) && !this.quickIntervalOnly) { + this.realtimeTimewindowOptions.push({ + name: this.translate.instant(realtimeWindowTypeTranslations.get(RealtimeWindowType.LAST_INTERVAL)), + value: this.realtimeTypes.LAST_INTERVAL + }); + } + + if (this.isEdit || !this.timewindow.realtime.hideQuickInterval || this.quickIntervalOnly) { + this.realtimeTimewindowOptions.push({ + name: this.translate.instant(realtimeWindowTypeTranslations.get(RealtimeWindowType.INTERVAL)), + value: this.realtimeTypes.INTERVAL + }); + } + + if (this.forAllTimeEnabled) { + this.historyTimewindowOptions.push({ + name: this.translate.instant(historyWindowTypeTranslations.get(HistoryWindowType.FOR_ALL_TIME)), + value: this.historyTypes.FOR_ALL_TIME + }); + } + + if (this.isEdit || !this.timewindow.history.hideLastInterval) { + this.historyTimewindowOptions.push({ + name: this.translate.instant(historyWindowTypeTranslations.get(HistoryWindowType.LAST_INTERVAL)), + value: this.historyTypes.LAST_INTERVAL + }); + } + + if (this.isEdit || !this.timewindow.history.hideFixedInterval) { + this.historyTimewindowOptions.push({ + name: this.translate.instant(historyWindowTypeTranslations.get(HistoryWindowType.FIXED)), + value: this.historyTypes.FIXED + }); + } + + if (this.isEdit || !this.timewindow.history.hideQuickInterval) { + this.historyTimewindowOptions.push({ + name: this.translate.instant(historyWindowTypeTranslations.get(HistoryWindowType.INTERVAL)), + value: this.historyTypes.INTERVAL + }); + } + + this.realtimeTypeSelectionAvailable = this.realtimeTimewindowOptions.length > 1; + this.historyTypeSelectionAvailable = this.historyTimewindowOptions.length > 1; + this.realtimeIntervalSelectionAvailable = this.isEdit || !(this.timewindow.realtime.hideInterval || + (this.timewindow.realtime.hideLastInterval && this.timewindow.realtime.hideQuickInterval)); + this.historyIntervalSelectionAvailable = this.isEdit || !(this.timewindow.history.hideInterval || + (this.timewindow.history.hideLastInterval && this.timewindow.history.hideQuickInterval && this.timewindow.history.hideFixedInterval)); + + this.aggregationOptionsAvailable = this.aggregation && (this.isEdit || + !(this.timewindow.hideAggregation && this.timewindow.hideAggInterval)); } ngOnInit(): void { - const hideInterval = this.timewindow.hideInterval || false; - const hideLastInterval = this.timewindow.hideLastInterval || false; - const hideQuickInterval = this.timewindow.hideQuickInterval || false; const hideAggregation = this.timewindow.hideAggregation || false; const hideAggInterval = this.timewindow.hideAggInterval || false; const hideTimezone = this.timewindow.hideTimezone || false; @@ -110,49 +196,86 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { const history = this.timewindow.history; const aggregation = this.timewindow.aggregation; + if (!this.isEdit) { + if (realtime.hideLastInterval && !realtime.hideQuickInterval) { + realtime.realtimeType = RealtimeWindowType.INTERVAL; + } + if (realtime.hideQuickInterval && !realtime.hideLastInterval) { + realtime.realtimeType = RealtimeWindowType.LAST_INTERVAL; + } + + if (history.hideLastInterval) { + if (!history.hideFixedInterval) { + history.historyType = HistoryWindowType.FIXED; + } else if (!history.hideQuickInterval) { + history.historyType = HistoryWindowType.INTERVAL; + } + } + if (history.hideFixedInterval) { + if (!history.hideLastInterval) { + history.historyType = HistoryWindowType.LAST_INTERVAL; + } else if (!history.hideQuickInterval) { + history.historyType = HistoryWindowType.INTERVAL; + } + } + if (history.hideQuickInterval) { + if (!history.hideLastInterval) { + history.historyType = HistoryWindowType.LAST_INTERVAL; + } else if (!history.hideFixedInterval) { + history.historyType = HistoryWindowType.FIXED; + } + } + } + this.timewindowForm = this.fb.group({ + selectedTab: [isDefined(this.timewindow.selectedTab) ? this.timewindow.selectedTab : TimewindowType.REALTIME], realtime: this.fb.group({ realtimeType: [{ - value: isDefined(realtime?.realtimeType) ? this.timewindow.realtime.realtimeType : RealtimeWindowType.LAST_INTERVAL, - disabled: hideInterval + value: isDefined(realtime?.realtimeType) ? realtime.realtimeType : RealtimeWindowType.LAST_INTERVAL, + disabled: realtime.hideInterval }], timewindowMs: [{ - value: isDefined(realtime?.timewindowMs) ? this.timewindow.realtime.timewindowMs : null, - disabled: hideInterval || hideLastInterval + value: isDefined(realtime?.timewindowMs) ? realtime.timewindowMs : null, + disabled: realtime.hideInterval || realtime.hideLastInterval + }], + interval: [{ + value:isDefined(realtime?.interval) ? realtime.interval : null, + disabled: hideAggInterval }], - interval: [isDefined(realtime?.interval) ? this.timewindow.realtime.interval : null], quickInterval: [{ - value: isDefined(realtime?.quickInterval) ? this.timewindow.realtime.quickInterval : null, - disabled: hideInterval || hideQuickInterval + value: isDefined(realtime?.quickInterval) ? realtime.quickInterval : null, + disabled: realtime.hideInterval || realtime.hideQuickInterval }] }), history: this.fb.group({ historyType: [{ - value: isDefined(history?.historyType) ? this.timewindow.history.historyType : HistoryWindowType.LAST_INTERVAL, - disabled: hideInterval + value: isDefined(history?.historyType) ? history.historyType : HistoryWindowType.LAST_INTERVAL, + disabled: history.hideInterval }], timewindowMs: [{ - value: isDefined(history?.timewindowMs) ? this.timewindow.history.timewindowMs : null, - disabled: hideInterval + value: isDefined(history?.timewindowMs) ? history.timewindowMs : null, + disabled: history.hideInterval || history.hideLastInterval + }], + interval: [{ + value:isDefined(history?.interval) ? history.interval : null, + disabled: hideAggInterval }], - interval: [ isDefined(history?.interval) ? this.timewindow.history.interval : null - ], fixedTimewindow: [{ - value: isDefined(history?.fixedTimewindow) ? this.timewindow.history.fixedTimewindow : null, - disabled: hideInterval + value: isDefined(history?.fixedTimewindow) ? history.fixedTimewindow : null, + disabled: history.hideInterval || history.hideFixedInterval }], quickInterval: [{ - value: isDefined(history?.quickInterval) ? this.timewindow.history.quickInterval : null, - disabled: hideInterval + value: isDefined(history?.quickInterval) ? history.quickInterval : null, + disabled: history.hideInterval || history.hideQuickInterval }] }), aggregation: this.fb.group({ type: [{ - value: isDefined(aggregation?.type) ? this.timewindow.aggregation.type : null, + value: isDefined(aggregation?.type) ? aggregation.type : null, disabled: hideAggregation }], limit: [{ - value: isDefined(aggregation?.limit) ? this.checkLimit(this.timewindow.aggregation.limit) : null, + value: isDefined(aggregation?.limit) ? aggregation.limit : null, disabled: hideAggInterval }, []] }), @@ -162,35 +285,41 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { }] }); this.updateValidators(this.timewindowForm.get('aggregation.type').value); - this.timewindowForm.get('aggregation.type').valueChanges.subscribe((aggregationType: AggregationType) => { + this.timewindowForm.get('aggregation.type').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((aggregationType: AggregationType) => { this.updateValidators(aggregationType); }); + + this.timewindowForm.get('selectedTab').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((selectedTab: TimewindowType) => { + this.onTimewindowTypeChange(selectedTab); + }); } - private checkLimit(limit?: number): number { - if (!limit || limit < this.minDatapointsLimit()) { - return this.minDatapointsLimit(); - } else if (limit > this.maxDatapointsLimit()) { - return this.maxDatapointsLimit(); - } - return limit; + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } private updateValidators(aggType: AggregationType) { if (aggType !== AggregationType.NONE) { this.timewindowForm.get('aggregation.limit').clearValidators(); } else { - this.timewindowForm.get('aggregation.limit').setValidators([Validators.min(this.minDatapointsLimit()), - Validators.max(this.maxDatapointsLimit())]); + this.timewindowForm.get('aggregation.limit').setValidators([Validators.required]); } this.timewindowForm.get('aggregation.limit').updateValueAndValidity({emitEvent: false}); } - onTimewindowTypeChange() { - this.timewindowForm.markAsDirty(); + private onTimewindowTypeChange(selectedTab: TimewindowType) { const timewindowFormValue = this.timewindowForm.getRawValue(); - if (this.timewindow.selectedTab === TimewindowType.REALTIME) { - if (timewindowFormValue.history.historyType !== HistoryWindowType.FIXED) { + if (selectedTab === TimewindowType.REALTIME) { + if (timewindowFormValue.history.historyType !== HistoryWindowType.FIXED + && !((this.quickIntervalOnly || this.timewindow.realtime.hideLastInterval) + && timewindowFormValue.history.historyType === HistoryWindowType.LAST_INTERVAL) + && !(this.timewindow.realtime.hideQuickInterval && timewindowFormValue.history.historyType === HistoryWindowType.INTERVAL)) { + this.timewindowForm.get('realtime').patchValue({ realtimeType: Object.keys(RealtimeWindowType).includes(HistoryWindowType[timewindowFormValue.history.historyType]) ? RealtimeWindowType[HistoryWindowType[timewindowFormValue.history.historyType]] : @@ -201,7 +330,9 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { }); setTimeout(() => this.timewindowForm.get('realtime.interval').patchValue(timewindowFormValue.history.interval)); } - } else { + } else if (!(this.timewindow.history.hideLastInterval && timewindowFormValue.realtime.realtimeType === RealtimeWindowType.LAST_INTERVAL) + && !(this.timewindow.history.hideQuickInterval && timewindowFormValue.realtime.realtimeType === RealtimeWindowType.INTERVAL)) { + this.timewindowForm.get('history').patchValue({ historyType: HistoryWindowType[RealtimeWindowType[timewindowFormValue.realtime.realtimeType]], timewindowMs: timewindowFormValue.realtime.timewindowMs, @@ -219,20 +350,27 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { } update() { + this.prepareTimewindowConfig(); + this.result = this.timewindow; + this.overlayRef.dispose(); + } + + private prepareTimewindowConfig() { const timewindowFormValue = this.timewindowForm.getRawValue(); - this.timewindow.realtime = { - realtimeType: timewindowFormValue.realtime.realtimeType, - timewindowMs: timewindowFormValue.realtime.timewindowMs, - quickInterval: timewindowFormValue.realtime.quickInterval, - interval: timewindowFormValue.realtime.interval - }; - this.timewindow.history = { - historyType: timewindowFormValue.history.historyType, - timewindowMs: timewindowFormValue.history.timewindowMs, - interval: timewindowFormValue.history.interval, - fixedTimewindow: timewindowFormValue.history.fixedTimewindow, - quickInterval: timewindowFormValue.history.quickInterval, - }; + this.timewindow.selectedTab = timewindowFormValue.selectedTab; + this.timewindow.realtime = {...this.timewindow.realtime, ...{ + realtimeType: timewindowFormValue.realtime.realtimeType, + timewindowMs: timewindowFormValue.realtime.timewindowMs, + quickInterval: timewindowFormValue.realtime.quickInterval, + interval: timewindowFormValue.realtime.interval + }}; + this.timewindow.history = {...this.timewindow.history, ...{ + historyType: timewindowFormValue.history.historyType, + timewindowMs: timewindowFormValue.history.timewindowMs, + interval: timewindowFormValue.history.interval, + fixedTimewindow: timewindowFormValue.history.fixedTimewindow, + quickInterval: timewindowFormValue.history.quickInterval, + }}; if (this.aggregation) { this.timewindow.aggregation = { type: timewindowFormValue.aggregation.type, @@ -242,20 +380,79 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { if (this.timezone) { this.timewindow.timezone = timewindowFormValue.timezone; } - this.result = this.timewindow; - this.overlayRef.dispose(); } - cancel() { - this.overlayRef.dispose(); - } + private updateTimewindowForm() { + this.timewindowForm.patchValue(this.timewindow); + + if (this.timewindow.realtime.hideInterval) { + this.timewindowForm.get('realtime.realtimeType').disable({emitEvent: false}); + this.timewindowForm.get('realtime.timewindowMs').disable({emitEvent: false}); + this.timewindowForm.get('realtime.quickInterval').disable({emitEvent: false}); + } else { + this.timewindowForm.get('realtime.realtimeType').enable({emitEvent: false}); + if (this.timewindow.realtime.hideLastInterval) { + this.timewindowForm.get('realtime.timewindowMs').disable({emitEvent: false}); + } else { + this.timewindowForm.get('realtime.timewindowMs').enable({emitEvent: false}); + } + if (this.timewindow.realtime.hideQuickInterval) { + this.timewindowForm.get('realtime.quickInterval').disable({emitEvent: false}); + } else { + this.timewindowForm.get('realtime.quickInterval').enable({emitEvent: false}); + } + } + + if (this.timewindow.history.hideInterval) { + this.timewindowForm.get('history.historyType').disable({emitEvent: false}); + this.timewindowForm.get('history.timewindowMs').disable({emitEvent: false}); + this.timewindowForm.get('history.fixedTimewindow').disable({emitEvent: false}); + this.timewindowForm.get('history.quickInterval').disable({emitEvent: false}); + } else { + this.timewindowForm.get('history.historyType').enable({emitEvent: false}); + if (this.timewindow.history.hideLastInterval) { + this.timewindowForm.get('history.timewindowMs').disable({emitEvent: false}); + } else { + this.timewindowForm.get('history.timewindowMs').enable({emitEvent: false}); + } + if (this.timewindow.history.hideFixedInterval) { + this.timewindowForm.get('history.fixedTimewindow').disable({emitEvent: false}); + } else { + this.timewindowForm.get('history.fixedTimewindow').enable({emitEvent: false}); + } + if (this.timewindow.history.hideQuickInterval) { + this.timewindowForm.get('history.quickInterval').disable({emitEvent: false}); + } else { + this.timewindowForm.get('history.quickInterval').enable({emitEvent: false}); + } + } - minDatapointsLimit() { - return this.timeService.getMinDatapointsLimit(); + if (this.timewindow.hideAggregation) { + this.timewindowForm.get('aggregation.type').disable({emitEvent: false}); + } else { + this.timewindowForm.get('aggregation.type').enable({emitEvent: false}); + } + if (this.timewindow.hideAggInterval) { + this.timewindowForm.get('aggregation.limit').disable({emitEvent: false}); + this.timewindowForm.get('realtime.interval').disable({emitEvent: false}); + this.timewindowForm.get('history.interval').disable({emitEvent: false}); + } else { + this.timewindowForm.get('aggregation.limit').enable({emitEvent: false}); + this.timewindowForm.get('realtime.interval').enable({emitEvent: false}); + this.timewindowForm.get('history.interval').enable({emitEvent: false}); + } + + if (this.timewindow.hideTimezone) { + this.timewindowForm.get('timezone').disable({emitEvent: false}); + } else { + this.timewindowForm.get('timezone').enable({emitEvent: false}); + } + + this.timewindowForm.markAsDirty(); } - maxDatapointsLimit() { - return this.timeService.getMaxDatapointsLimit(); + cancel() { + this.overlayRef.dispose(); } minRealtimeAggInterval() { @@ -300,84 +497,24 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { } } - onHideIntervalChanged() { - if (this.timewindow.hideInterval) { - this.timewindowForm.get('history.historyType').disable({emitEvent: false}); - this.timewindowForm.get('history.timewindowMs').disable({emitEvent: false}); - this.timewindowForm.get('history.fixedTimewindow').disable({emitEvent: false}); - this.timewindowForm.get('history.quickInterval').disable({emitEvent: false}); - this.timewindowForm.get('realtime.realtimeType').disable({emitEvent: false}); - this.timewindowForm.get('realtime.timewindowMs').disable({emitEvent: false}); - this.timewindowForm.get('realtime.quickInterval').disable({emitEvent: false}); - } else { - this.timewindowForm.get('history.historyType').enable({emitEvent: false}); - this.timewindowForm.get('history.timewindowMs').enable({emitEvent: false}); - this.timewindowForm.get('history.fixedTimewindow').enable({emitEvent: false}); - this.timewindowForm.get('history.quickInterval').enable({emitEvent: false}); - this.timewindowForm.get('realtime.realtimeType').enable({emitEvent: false}); - if (!this.timewindow.hideLastInterval) { - this.timewindowForm.get('realtime.timewindowMs').enable({emitEvent: false}); - } - if (!this.timewindow.hideQuickInterval) { - this.timewindowForm.get('realtime.quickInterval').enable({emitEvent: false}); - } - } - this.timewindowForm.markAsDirty(); - } - - onHideLastIntervalChanged() { - if (this.timewindow.hideLastInterval) { - this.timewindowForm.get('realtime.timewindowMs').disable({emitEvent: false}); - if (!this.timewindow.hideQuickInterval) { - this.timewindowForm.get('realtime.realtimeType').setValue(RealtimeWindowType.INTERVAL); - } - } else { - if (!this.timewindow.hideInterval) { - this.timewindowForm.get('realtime.timewindowMs').enable({emitEvent: false}); - } - } - this.timewindowForm.markAsDirty(); - } - - onHideQuickIntervalChanged() { - if (this.timewindow.hideQuickInterval) { - this.timewindowForm.get('realtime.quickInterval').disable({emitEvent: false}); - if (!this.timewindow.hideLastInterval) { - this.timewindowForm.get('realtime.realtimeType').setValue(RealtimeWindowType.LAST_INTERVAL); - } - } else { - if (!this.timewindow.hideInterval) { - this.timewindowForm.get('realtime.quickInterval').enable({emitEvent: false}); - } - } - this.timewindowForm.markAsDirty(); - } - - onHideAggregationChanged() { - if (this.timewindow.hideAggregation) { - this.timewindowForm.get('aggregation.type').disable({emitEvent: false}); - } else { - this.timewindowForm.get('aggregation.type').enable({emitEvent: false}); - } - this.timewindowForm.markAsDirty(); - } - - onHideAggIntervalChanged() { - if (this.timewindow.hideAggInterval) { - this.timewindowForm.get('aggregation.limit').disable({emitEvent: false}); - } else { - this.timewindowForm.get('aggregation.limit').enable({emitEvent: false}); - } - this.timewindowForm.markAsDirty(); - } - - onHideTimezoneChanged() { - if (this.timewindow.hideTimezone) { - this.timewindowForm.get('timezone').disable({emitEvent: false}); - } else { - this.timewindowForm.get('timezone').enable({emitEvent: false}); - } - this.timewindowForm.markAsDirty(); + openTimewindowConfig() { + this.prepareTimewindowConfig(); + this.dialog.open( + TimewindowConfigDialogComponent, { + autoFocus: false, + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + quickIntervalOnly: this.quickIntervalOnly, + aggregation: this.aggregation, + timewindow: deepClone(this.timewindow) + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.timewindow = res; + this.updateTimewindowForm(); + } + }); } - } diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.html b/ui-ngx/src/app/shared/components/time/timewindow.component.html index 622f5654d9..9896d65bc0 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow.component.html +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.html @@ -44,6 +44,7 @@ [class]="{'no-padding': noPadding}" matTooltip="{{ 'timewindow.edit' | translate }}" [matTooltipPosition]="tooltipPosition" + [matTooltipDisabled]="timewindowDisabled" [style]="timewindowComponentStyle" (click)="toggleTimewindow($event)"> +
+
+
{{ 'timezone.timezone' | translate }}
+ + +
+
+
+ + +
diff --git a/ui-ngx/src/app/shared/components/time/timezone-panel.component.scss b/ui-ngx/src/app/shared/components/time/timezone-panel.component.scss new file mode 100644 index 0000000000..29f76b3063 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timezone-panel.component.scss @@ -0,0 +1,33 @@ +/** + * 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. + */ + +:host { + display: flex; + flex-direction: column; + width: 380px; + max-height: 100%; + max-width: 100%; + background-color: #fff; + + tb-timezone-select { + flex: 1; + } + + .tb-timezone-panel-actions { + padding-top: 12px; + } + +} diff --git a/ui-ngx/src/app/shared/components/time/timezone-panel.component.ts b/ui-ngx/src/app/shared/components/time/timezone-panel.component.ts new file mode 100644 index 0000000000..361902f51d --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timezone-panel.component.ts @@ -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 { Component, Input, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { TbPopoverComponent } from '@shared/components/popover.component'; + +export interface TimezoneSelectionResult { + timezone: string | null; +} + +@Component({ + selector: 'tb-timezone-panel', + templateUrl: './timezone-panel.component.html', + styleUrls: ['./timezone-panel.component.scss'] +}) +export class TimezonePanelComponent extends PageComponent implements OnInit { + + @Input() + timezone: string | null; + + @Input() + userTimezoneByDefault: boolean; + + @Input() + localBrowserTimezonePlaceholderOnEmpty: boolean; + + @Input() + defaultTimezone: string; + + @Input() + onClose: (result: TimezoneSelectionResult | null) => void; + + @Input() + popoverComponent: TbPopoverComponent; + + timezoneForm: FormGroup; + + constructor(protected store: Store, + public fb: FormBuilder) { + super(store); + } + + ngOnInit(): void { + this.timezoneForm = this.fb.group({ + timezone: [this.timezone] + }); + } + + update() { + if (this.onClose) { + this.onClose({ + timezone: this.timezoneForm.get('timezone').value + }); + } + } + + cancel() { + if (this.onClose) { + this.onClose(null); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/time/timezone-select.component.html b/ui-ngx/src/app/shared/components/time/timezone-select.component.html index 1ed05d820d..8def5a4ee8 100644 --- a/ui-ngx/src/app/shared/components/time/timezone-select.component.html +++ b/ui-ngx/src/app/shared/components/time/timezone-select.component.html @@ -15,8 +15,9 @@ limitations under the License. --> - - timezone.timezone + + timezone.timezone ; @@ -104,7 +113,8 @@ export class TimezoneSelectComponent implements ControlValueAccessor, OnInit, Af constructor(private store: Store, public translate: TranslateService, private ngZone: NgZone, - private fb: UntypedFormBuilder) { + private fb: UntypedFormBuilder, + private timeService: TimeService) { this.selectTimezoneFormGroup = this.fb.group({ timezone: [null] }); @@ -165,7 +175,7 @@ export class TimezoneSelectComponent implements ControlValueAccessor, OnInit, Af } else { this.modelValue = null; if (this.localBrowserTimezonePlaceholderOnEmptyValue) { - this.selectTimezoneFormGroup.get('timezone').patchValue(this.getLocalBrowserTimezoneInfoPlaceholder(), {emitEvent: false}); + this.selectTimezoneFormGroup.get('timezone').patchValue(this.localBrowserTimezoneInfoPlaceholder, {emitEvent: false}); } else { this.selectTimezoneFormGroup.get('timezone').patchValue('', {emitEvent: false}); } @@ -194,7 +204,7 @@ export class TimezoneSelectComponent implements ControlValueAccessor, OnInit, Af } } else if (this.localBrowserTimezonePlaceholderOnEmptyValue) { this.ngZone.run(() => { - this.selectTimezoneFormGroup.get('timezone').reset(this.getLocalBrowserTimezoneInfoPlaceholder(), {emitEvent: true}); + this.selectTimezoneFormGroup.get('timezone').reset(this.localBrowserTimezoneInfoPlaceholder, {emitEvent: true}); }); } } @@ -232,19 +242,11 @@ export class TimezoneSelectComponent implements ControlValueAccessor, OnInit, Af if (!this.timezones) { this.timezones = []; if (this.localBrowserTimezonePlaceholderOnEmptyValue) { - this.timezones.push(this.getLocalBrowserTimezoneInfoPlaceholder()); + this.timezones.push(this.localBrowserTimezoneInfoPlaceholder); } this.timezones.push(...getTimezones()); } return this.timezones; } - private getLocalBrowserTimezoneInfoPlaceholder(): TimezoneInfo { - if (!this.localBrowserTimezoneInfoPlaceholder) { - this.localBrowserTimezoneInfoPlaceholder = deepClone(getDefaultTimezoneInfo()); - this.localBrowserTimezoneInfoPlaceholder.id = null; - this.localBrowserTimezoneInfoPlaceholder.name = this.translate.instant('timezone.browser-time'); - } - return this.localBrowserTimezoneInfoPlaceholder; - } } diff --git a/ui-ngx/src/app/shared/components/time/timezone.component.html b/ui-ngx/src/app/shared/components/time/timezone.component.html new file mode 100644 index 0000000000..a2a5b84899 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timezone.component.html @@ -0,0 +1,54 @@ + + + + +
+
+ {{displayValue()}} +
+
diff --git a/ui-ngx/src/app/shared/components/time/timezone.component.scss b/ui-ngx/src/app/shared/components/time/timezone.component.scss new file mode 100644 index 0000000000..92f6c7fcc7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timezone.component.scss @@ -0,0 +1,64 @@ +/** + * 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. + */ +:host { + min-width: 88px; + margin: 8px 0; + max-width: 100%; + &.no-margin { + margin: 0; + } + .mdc-button { + max-width: 100%; + } + section.tb-timezone { + padding: 0 8px; + &.no-padding { + padding: 0; + } + line-height: 32px; + pointer-events: all; + cursor: pointer; + display: flex; + flex-direction: row; + place-content: center flex-start; + align-items: center; + gap: 4px; + width: 100%; + + .tb-timezone-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .timezone-abbr { + font-weight: 500; + } + } +} + +:host ::ng-deep { + .mdc-button { + .mat-icon { + min-width: 24px; + } + .mdc-button__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} diff --git a/ui-ngx/src/app/shared/components/time/timezone.component.ts b/ui-ngx/src/app/shared/components/time/timezone.component.ts new file mode 100644 index 0000000000..e7ff6f2e7e --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timezone.component.ts @@ -0,0 +1,235 @@ +/// +/// 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 { + ChangeDetectorRef, + Component, + forwardRef, + HostBinding, + Input, + OnInit, + Renderer2, + ViewContainerRef +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { TooltipPosition } from '@angular/material/tooltip'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { TimezonePanelComponent, TimezoneSelectionResult } from '@shared/components/time/timezone-panel.component'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { getTimezoneInfo, TimezoneInfo } from '@shared/models/time/time.models'; +import { TimeService } from '@core/services/time.service'; + +// @dynamic +@Component({ + selector: 'tb-timezone', + templateUrl: './timezone.component.html', + styleUrls: ['./timezone.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimezoneComponent), + multi: true + } + ] +}) +export class TimezoneComponent implements ControlValueAccessor, OnInit { + + @HostBinding('class.no-margin') + @Input() + @coerceBoolean() + noMargin = false; + + @Input() + @coerceBoolean() + noPadding = false; + + @Input() + @coerceBoolean() + disablePanel = false; + + @Input() + @coerceBoolean() + asButton = false; + + @Input() + @coerceBoolean() + strokedButton = false; + + @Input() + @coerceBoolean() + flatButton = false; + + @Input() + @coerceBoolean() + displayTimezoneValue = true; + + @Input() + @coerceBoolean() + hideLabel = false; + + @Input() + tooltipPosition: TooltipPosition = 'above'; + + @Input() + @coerceBoolean() + disabled: boolean; + + private userTimezoneByDefaultValue: boolean; + get userTimezoneByDefault(): boolean { + return this.userTimezoneByDefaultValue; + } + @Input() + set userTimezoneByDefault(value: boolean) { + this.userTimezoneByDefaultValue = coerceBooleanProperty(value); + } + + private localBrowserTimezonePlaceholderOnEmptyValue: boolean; + get localBrowserTimezonePlaceholderOnEmpty(): boolean { + return this.localBrowserTimezonePlaceholderOnEmptyValue; + } + @Input() + set localBrowserTimezonePlaceholderOnEmpty(value: boolean) { + this.localBrowserTimezonePlaceholderOnEmptyValue = coerceBooleanProperty(value); + } + defaultTimezoneId: string = null; + + @Input() + set defaultTimezone(timezone: string) { + if (this.defaultTimezoneId !== timezone) { + this.defaultTimezoneId = timezone; + } + } + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + modelValue: string | null; + timezoneInfo: TimezoneInfo; + + private localBrowserTimezoneInfoPlaceholder: TimezoneInfo = this.timeService.getLocalBrowserTimezoneInfoPlaceholder(); + + timezoneDisabled: boolean; + + private propagateChange = (_: any) => {}; + + constructor(private translate: TranslateService, + private cd: ChangeDetectorRef, + public viewContainerRef: ViewContainerRef, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private timeService: TimeService) { + } + + ngOnInit() { + } + + toggleTimezone($event: Event) { + if ($event) { + $event.stopPropagation(); + } + if (this.disablePanel) { + return; + } + const trigger = ($event.target || $event.srcElement || $event.currentTarget) as Element; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const timezoneSelectionPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, TimezonePanelComponent, ['bottomRight', 'leftBottom'], true, null, + { + timezone: this.modelValue, + userTimezoneByDefault: this.userTimezoneByDefaultValue, + localBrowserTimezonePlaceholderOnEmpty: this.localBrowserTimezonePlaceholderOnEmptyValue, + defaultTimezone: this.defaultTimezoneId, + onClose: (result: TimezoneSelectionResult | null) => { + timezoneSelectionPopover.hide(); + if (result) { + this.modelValue = result.timezone; + this.setTimezoneInfo(); + this.timezoneDisabled = this.isTimezoneDisabled(); + this.updateDisplayValue(); + this.notifyChanged(); + } + } + }, + {}, + {}, {}, false); + timezoneSelectionPopover.tbComponentRef.instance.popoverComponent = timezoneSelectionPopover; + } + this.cd.detectChanges(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this.timezoneDisabled = this.isTimezoneDisabled(); + } + + writeValue(value: string | null): void { + this.modelValue = value; + this.setTimezoneInfo(); + this.timezoneDisabled = this.isTimezoneDisabled(); + this.updateDisplayValue(); + } + + notifyChanged() { + this.propagateChange(this.modelValue); + } + + displayValue(): string { + return this.displayTimezoneValue && this.timezoneInfo ? this.timezoneInfo.offset : this.translate.instant('timezone.timezone'); + } + + tooltipValue(): string { + return this.timezoneInfo ? `${this.timezoneInfo.name} (${this.timezoneInfo.offset})` : undefined; + } + + updateDisplayValue() { + this.cd.detectChanges(); + } + + private isTimezoneDisabled(): boolean { + return this.disabled; + } + + private setTimezoneInfo() { + const foundTimezone = getTimezoneInfo(this.modelValue, this.defaultTimezoneId, this.userTimezoneByDefaultValue); + if (foundTimezone !== null) { + this.timezoneInfo = foundTimezone; + } else { + if (this.localBrowserTimezonePlaceholderOnEmptyValue) { + this.timezoneInfo = this.localBrowserTimezoneInfoPlaceholder; + } else { + this.timezoneInfo = null; + } + } + } + +} diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index 829e49257f..36600eec72 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -161,7 +161,7 @@ export const HelpLinks = { assetProfiles: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/asset-profiles`, edges: `${helpBaseUrl}/docs/edge/getting-started-guides/what-is-edge`, assets: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/assets`, - entityViews: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/entity-views`, + entityViews: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/entity-views`, entitiesImport: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/bulk-provisioning`, rulechains: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/rule-chains`, lwm2mResourceLibrary: `${helpBaseUrl}/docs${docPlatformPrefix}/reference/lwm2m-api`, @@ -183,8 +183,10 @@ export const HelpLinks = { templateNotifications: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/notifications/#templates`, recipientNotifications: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/notifications/#recipients`, ruleNotifications: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/notifications/#rules`, - jwtSecuritySettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/jwt-security-settings/`, - gatewayInstall: `${helpBaseUrl}/docs/iot-gateway/install/docker-installation/`, + jwtSecuritySettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/jwt-security-settings`, + gatewayInstall: `${helpBaseUrl}/docs/iot-gateway/install/docker-installation`, + scada: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/scada`, + scadaSymbolDev: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/scada/symbols-dev-guide`, } }; /* eslint-enable max-len */ diff --git a/ui-ngx/src/app/shared/models/notification.models.ts b/ui-ngx/src/app/shared/models/notification.models.ts index cfb1312209..dd5550e907 100644 --- a/ui-ngx/src/app/shared/models/notification.models.ts +++ b/ui-ngx/src/app/shared/models/notification.models.ts @@ -290,6 +290,7 @@ export interface SlackNotificationTargetConfig { export interface MicrosoftTeamsNotificationTargetConfig { webhookUrl: string; channelName: string; + useOldApi?: boolean; } export enum NotificationTargetType { PLATFORM_USERS = 'PLATFORM_USERS', diff --git a/ui-ngx/src/app/shared/models/overlay.models.ts b/ui-ngx/src/app/shared/models/overlay.models.ts index c852807e76..46a2f6f5bc 100644 --- a/ui-ngx/src/app/shared/models/overlay.models.ts +++ b/ui-ngx/src/app/shared/models/overlay.models.ts @@ -41,4 +41,4 @@ export const POSITION_MAP: { [key: string]: ConnectionPositionPair } = { }; export const DEFAULT_OVERLAY_POSITIONS = [POSITION_MAP.bottomLeft, POSITION_MAP.bottomRight, POSITION_MAP.topLeft, - POSITION_MAP.topRight, POSITION_MAP.left, POSITION_MAP.right]; + POSITION_MAP.topRight, POSITION_MAP.left, POSITION_MAP.right, POSITION_MAP.bottom]; diff --git a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts index 8cf07e1128..9f0d244e9e 100644 --- a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts +++ b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts @@ -186,7 +186,8 @@ export enum IntervalType { WEEK = 'WEEK', WEEK_ISO = 'WEEK_ISO', MONTH = 'MONTH', - QUARTER = 'QUARTER' + QUARTER = 'QUARTER', + CUSTOM = 'CUSTOM' } export class TimeseriesSubscriptionCmd extends SubscriptionCmd { diff --git a/ui-ngx/src/app/shared/models/time/time.models.ts b/ui-ngx/src/app/shared/models/time/time.models.ts index 9336f47d90..d0d4153b5f 100644 --- a/ui-ngx/src/app/shared/models/time/time.models.ts +++ b/ui-ngx/src/app/shared/models/time/time.models.ts @@ -15,7 +15,7 @@ /// import { TimeService } from '@core/services/time.service'; -import { deepClone, isDefined, isNumeric, isUndefined } from '@app/core/utils'; +import { deepClone, isDefined, isDefinedAndNotNull, isNumeric, isUndefined } from '@app/core/utils'; import * as moment_ from 'moment'; import * as momentTz from 'moment-timezone'; import { IntervalType } from '@shared/models/telemetry/telemetry.models'; @@ -53,6 +53,17 @@ export enum HistoryWindowType { FOR_ALL_TIME } +export const realtimeWindowTypeTranslations = new Map([ + [RealtimeWindowType.LAST_INTERVAL, 'timewindow.last'], + [RealtimeWindowType.INTERVAL, 'timewindow.relative'] +]); +export const historyWindowTypeTranslations = new Map([ + [HistoryWindowType.LAST_INTERVAL, 'timewindow.last'], + [HistoryWindowType.FIXED, 'timewindow.range'], + [HistoryWindowType.INTERVAL, 'timewindow.relative'], + [HistoryWindowType.FOR_ALL_TIME, 'timewindow.for-all-time'] +]); + export type Interval = number | IntervalType; export class IntervalMath { @@ -77,6 +88,12 @@ export interface IntervalWindow { interval?: Interval; timewindowMs?: number; quickInterval?: QuickTimeInterval; + disableCustomInterval?: boolean; + disableCustomGroupInterval?: boolean; + hideInterval?: boolean; + hideLastInterval?: boolean; + hideQuickInterval?: boolean; + hideFixedInterval?: boolean; } export interface RealtimeWindow extends IntervalWindow{ @@ -122,9 +139,6 @@ export interface Aggregation { export interface Timewindow { displayValue?: string; displayTimezoneAbbr?: string; - hideInterval?: boolean; - hideQuickInterval?: boolean; - hideLastInterval?: boolean; hideAggregation?: boolean; hideAggInterval?: boolean; hideTimezone?: boolean; @@ -241,9 +255,6 @@ export const defaultTimewindow = (timeService: TimeService): Timewindow => { const currentTime = moment().valueOf(); return { displayValue: '', - hideInterval: false, - hideLastInterval: false, - hideQuickInterval: false, hideAggregation: false, hideAggInterval: false, hideTimezone: false, @@ -252,7 +263,10 @@ export const defaultTimewindow = (timeService: TimeService): Timewindow => { realtimeType: RealtimeWindowType.LAST_INTERVAL, interval: SECOND, timewindowMs: MINUTE, - quickInterval: QuickTimeInterval.CURRENT_DAY + quickInterval: QuickTimeInterval.CURRENT_DAY, + hideInterval: false, + hideLastInterval: false, + hideQuickInterval: false }, history: { historyType: HistoryWindowType.LAST_INTERVAL, @@ -262,7 +276,11 @@ export const defaultTimewindow = (timeService: TimeService): Timewindow => { startTimeMs: currentTime - DAY, endTimeMs: currentTime }, - quickInterval: QuickTimeInterval.CURRENT_DAY + quickInterval: QuickTimeInterval.CURRENT_DAY, + hideInterval: false, + hideLastInterval: false, + hideFixedInterval: false, + hideQuickInterval: false }, aggregation: { type: AggregationType.AVG, @@ -283,14 +301,37 @@ export const initModelFromDefaultTimewindow = (value: Timewindow, quickIntervalO historyOnly: boolean, timeService: TimeService): Timewindow => { const model = defaultTimewindow(timeService); if (value) { - model.hideInterval = value.hideInterval; - model.hideLastInterval = value.hideLastInterval; - model.hideQuickInterval = value.hideQuickInterval; model.hideAggregation = value.hideAggregation; model.hideAggInterval = value.hideAggInterval; model.hideTimezone = value.hideTimezone; model.selectedTab = getTimewindowType(value); + + // for backward compatibility + if (isDefinedAndNotNull((value as any).hideInterval)) { + model.realtime.hideInterval = (value as any).hideInterval; + model.history.hideInterval = (value as any).hideInterval; + delete (value as any).hideInterval; + } + if (isDefinedAndNotNull((value as any).hideLastInterval)) { + model.realtime.hideLastInterval = (value as any).hideLastInterval; + delete (value as any).hideLastInterval; + } + if (isDefinedAndNotNull((value as any).hideQuickInterval)) { + model.realtime.hideQuickInterval = (value as any).hideQuickInterval; + delete (value as any).hideQuickInterval; + } + if (isDefined(value.realtime)) { + if (isDefinedAndNotNull(value.realtime.hideInterval)) { + model.realtime.hideInterval = value.realtime.hideInterval; + } + if (isDefinedAndNotNull(value.realtime.hideLastInterval)) { + model.realtime.hideLastInterval = value.realtime.hideLastInterval; + } + if (isDefinedAndNotNull(value.realtime.hideQuickInterval)) { + model.realtime.hideQuickInterval = value.realtime.hideQuickInterval; + } + if (isDefined(value.realtime.interval)) { model.realtime.interval = value.realtime.interval; } @@ -311,6 +352,19 @@ export const initModelFromDefaultTimewindow = (value: Timewindow, quickIntervalO } } if (isDefined(value.history)) { + if (isDefinedAndNotNull(value.history.hideInterval)) { + model.history.hideInterval = value.history.hideInterval; + } + if (isDefinedAndNotNull(value.history.hideLastInterval)) { + model.history.hideLastInterval = value.history.hideLastInterval; + } + if (isDefinedAndNotNull(value.history.hideFixedInterval)) { + model.history.hideFixedInterval = value.history.hideFixedInterval; + } + if (isDefinedAndNotNull(value.history.hideQuickInterval)) { + model.history.hideQuickInterval = value.history.hideQuickInterval; + } + if (isDefined(value.history.interval)) { model.history.interval = value.history.interval; } @@ -376,9 +430,6 @@ export const toHistoryTimewindow = (timewindow: Timewindow, startTimeMs: number, limit = timeService.getMaxDatapointsLimit(); } return { - hideInterval: timewindow.hideInterval || false, - hideLastInterval: timewindow.hideLastInterval || false, - hideQuickInterval: timewindow.hideQuickInterval || false, hideAggregation: timewindow.hideAggregation || false, hideAggInterval: timewindow.hideAggInterval || false, hideTimezone: timewindow.hideTimezone || false, @@ -389,7 +440,10 @@ export const toHistoryTimewindow = (timewindow: Timewindow, startTimeMs: number, startTimeMs, endTimeMs }, - interval: timeService.boundIntervalToTimewindow(endTimeMs - startTimeMs, interval, AggregationType.AVG) + interval: timeService.boundIntervalToTimewindow(endTimeMs - startTimeMs, interval, AggregationType.AVG), + hideInterval: timewindow.history?.hideInterval || false, + hideLastInterval: timewindow.history?.hideLastInterval || false, + hideQuickInterval: timewindow.history?.hideQuickInterval || false, }, aggregation: { type: aggType, @@ -844,20 +898,14 @@ export const createTimewindowForComparison = (subscriptionTimewindow: Subscripti export const cloneSelectedTimewindow = (timewindow: Timewindow): Timewindow => { const cloned: Timewindow = {}; - cloned.hideInterval = timewindow.hideInterval || false; - cloned.hideLastInterval = timewindow.hideLastInterval || false; - cloned.hideQuickInterval = timewindow.hideQuickInterval || false; cloned.hideAggregation = timewindow.hideAggregation || false; cloned.hideAggInterval = timewindow.hideAggInterval || false; cloned.hideTimezone = timewindow.hideTimezone || false; if (isDefined(timewindow.selectedTab)) { cloned.selectedTab = timewindow.selectedTab; - if (timewindow.selectedTab === TimewindowType.REALTIME) { - cloned.realtime = deepClone(timewindow.realtime); - } else if (timewindow.selectedTab === TimewindowType.HISTORY) { - cloned.history = deepClone(timewindow.history); - } } + cloned.realtime = deepClone(timewindow.realtime); + cloned.history = deepClone(timewindow.history); cloned.aggregation = deepClone(timewindow.aggregation); cloned.timezone = timewindow.timezone; return cloned; diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 2bf3676b32..130e272c21 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -220,11 +220,15 @@ import { ImageGalleryDialogComponent } from '@shared/components/image/image-gall import { RuleChainSelectPanelComponent } from '@shared/components/rule-chain/rule-chain-select-panel.component'; import { WidgetButtonComponent } from '@shared/components/button/widget-button.component'; import { HexInputComponent } from '@shared/components/color-picker/hex-input.component'; +import { TimezoneComponent } from '@shared/components/time/timezone.component'; +import { TimezonePanelComponent } from '@shared/components/time/timezone-panel.component'; +import { TimewindowConfigDialogComponent } from '@shared/components/time/timewindow-config-dialog.component'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import { ScadaSymbolInputComponent } from '@shared/components/image/scada-symbol-input.component'; import { CountryAutocompleteComponent } from '@shared/components/country-autocomplete.component'; import { CountryData } from '@shared/models/country.models'; import { SvgXmlComponent } from '@shared/components/svg-xml.component'; +import { DatapointsLimitComponent } from '@shared/components/time/datapoints-limit.component'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -309,8 +313,12 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) UserMenuComponent, TimewindowComponent, TimewindowPanelComponent, + TimewindowConfigDialogComponent, TimeintervalComponent, + TimezoneComponent, + TimezonePanelComponent, QuickTimeIntervalComponent, + DatapointsLimitComponent, DashboardSelectComponent, DashboardSelectPanelComponent, DatetimePeriodComponent, @@ -518,8 +526,12 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) UserMenuComponent, TimewindowComponent, TimewindowPanelComponent, + TimewindowConfigDialogComponent, TimeintervalComponent, + TimezoneComponent, + TimezonePanelComponent, QuickTimeIntervalComponent, + DatapointsLimitComponent, DashboardSelectComponent, DatetimePeriodComponent, DatetimeComponent, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 37ea4a1a87..f5f26beeeb 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1114,7 +1114,9 @@ "date-from": "Date from", "time-from": "Time from", "date-to": "Date to", - "time-to": "Time to" + "time-to": "Time to", + "from": "From", + "to": "To" }, "dashboard": { "dashboard": "Dashboard", @@ -4295,6 +4297,8 @@ "type": "Type", "unread": "Unread", "updated": "Updated", + "use-deprecated-webhook-connectors": "Use deprecated Webhook connectors", + "use-old-api": "Use old API", "use-template": "Use template", "view-all": "View all", "warning": "Warning", @@ -5088,6 +5092,7 @@ "minutes": "Minutes", "seconds": "Seconds", "advanced": "Advanced", + "custom": "Custom", "predefined": { "yesterday": "Yesterday", "day-before-yesterday": "Day before yesterday", @@ -5130,6 +5135,7 @@ }, "timewindow": { "timewindow": "Timewindow", + "timewindow-settings": "Timewindow settings", "years": "{ years, plural, =1 { year } other {# years } }", "years-short": "{{ years }}y", "months": "{ months, plural, =1 { month } other {# months } }", @@ -5175,7 +5181,11 @@ "font": "Font", "color": "Color", "displayTypePrefix": "Display Realtime/History prefix", - "preview": "Preview" + "preview": "Preview", + "relative": "Relative", + "range": "Range", + "hide-timewindow-section": "Hide timewindow section from end-users", + "disable-custom-interval": "Disable custom interval selection" }, "tooltip": { "trigger": "Trigger", diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/modbus.json b/ui-ngx/src/assets/metadata/connector-default-configs/modbus.json index 441f63ab5f..38bb44e036 100644 --- a/ui-ngx/src/assets/metadata/connector-default-configs/modbus.json +++ b/ui-ngx/src/assets/metadata/connector-default-configs/modbus.json @@ -1,246 +1,474 @@ { - "master": { - "slaves": [ - { - "name": "Slave 1", - "host": "127.0.0.1", - "port": 5021, - "type": "tcp", - "method": "socket", - "timeout": 35, - "byteOrder": "LITTLE", - "wordOrder": "LITTLE", - "retries": true, - "retryOnEmpty": true, - "retryOnInvalid": true, - "pollPeriod": 5000, - "unitId": 1, - "deviceName": "Temp Sensor", - "deviceType": "default", - "sendDataOnlyOnChange": true, - "connectAttemptTimeMs": 5000, - "connectAttemptCount": 5, - "waitAfterFailedAttemptsMs": 300000, - "attributes": [ - { - "tag": "string_read", - "type": "string", - "functionCode": 4, - "objectsCount": 4, - "address": 1 - }, - { - "tag": "bits_read", - "type": "bits", - "functionCode": 4, - "objectsCount": 1, - "address": 5 - }, - { - "tag": "8int_read", - "type": "8int", - "functionCode": 4, - "objectsCount": 1, - "address": 6 - }, - { - "tag": "16int_read", - "type": "16int", - "functionCode": 4, - "objectsCount": 1, - "address": 7 - }, - { - "tag": "32int_read_divider", - "type": "32int", - "functionCode": 4, - "objectsCount": 2, - "address": 8, - "divider": 10 - }, - { - "tag": "8int_read_multiplier", - "type": "8int", - "functionCode": 4, - "objectsCount": 1, - "address": 10, - "multiplier": 10 - }, - { - "tag": "32int_read", - "type": "32int", - "functionCode": 4, - "objectsCount": 2, - "address": 11 - }, - { - "tag": "64int_read", - "type": "64int", - "functionCode": 4, - "objectsCount": 4, - "address": 13 - } - ], - "timeseries": [ - { - "tag": "8uint_read", - "type": "8uint", - "functionCode": 4, - "objectsCount": 1, - "address": 17 - }, - { - "tag": "16uint_read", - "type": "16uint", - "functionCode": 4, - "objectsCount": 2, - "address": 18 - }, - { - "tag": "32uint_read", - "type": "32uint", - "functionCode": 4, - "objectsCount": 4, - "address": 20 - }, - { - "tag": "64uint_read", - "type": "64uint", - "functionCode": 4, - "objectsCount": 1, - "address": 24 - }, - { - "tag": "16float_read", - "type": "16float", - "functionCode": 4, - "objectsCount": 1, - "address": 25 - }, - { - "tag": "32float_read", - "type": "32float", - "functionCode": 4, - "objectsCount": 2, - "address": 26 - }, - { - "tag": "64float_read", - "type": "64float", - "functionCode": 4, - "objectsCount": 4, - "address": 28 - } - ], - "attributeUpdates": [ - { - "tag": "shared_attribute_write", - "type": "32int", - "functionCode": 6, - "objectsCount": 2, - "address": 29 - } - ], - "rpc": [ - { - "tag": "setValue", - "type": "bits", - "functionCode": 5, - "objectsCount": 1, - "address": 31 - }, - { - "tag": "getValue", - "type": "bits", - "functionCode": 1, - "objectsCount": 1, - "address": 31 - }, - { - "tag": "setCPUFanSpeed", - "type": "32int", - "functionCode": 16, - "objectsCount": 2, - "address": 33 - }, - { - "tag": "getCPULoad", - "type": "32int", - "functionCode": 4, - "objectsCount": 2, - "address": 35 - } - ] - } - ] - }, - "slave": { - "type": "tcp", - "host": "127.0.0.1", - "port": 5026, - "method": "socket", - "deviceName": "Modbus Slave Example", - "deviceType": "default", - "pollPeriod": 5000, - "sendDataToThingsBoard": false, - "byteOrder": "LITTLE", - "wordOrder": "LITTLE", - "unitId": 0, - "values": { - "holding_registers": { - "attributes": [ + "3.5.2": { + "master": { + "slaves": [ { - "address": 1, - "type": "string", - "tag": "sm", - "objectsCount": 1, - "value": "ON" + "name": "Slave 1", + "host": "127.0.0.1", + "port": 5021, + "type": "tcp", + "method": "socket", + "timeout": 35, + "byteOrder": "LITTLE", + "wordOrder": "LITTLE", + "retries": true, + "retryOnEmpty": true, + "retryOnInvalid": true, + "pollPeriod": 5000, + "unitId": 1, + "deviceName": "Temp Sensor", + "deviceType": "default", + "sendDataOnlyOnChange": true, + "connectAttemptTimeMs": 5000, + "connectAttemptCount": 5, + "waitAfterFailedAttemptsMs": 300000, + "attributes": [ + { + "tag": "string_read", + "type": "string", + "functionCode": 4, + "objectsCount": 4, + "address": 1 + }, + { + "tag": "bits_read", + "type": "bits", + "functionCode": 4, + "objectsCount": 1, + "address": 5 + }, + { + "tag": "8int_read", + "type": "8int", + "functionCode": 4, + "objectsCount": 1, + "address": 6 + }, + { + "tag": "16int_read", + "type": "16int", + "functionCode": 4, + "objectsCount": 1, + "address": 7 + }, + { + "tag": "32int_read_divider", + "type": "32int", + "functionCode": 4, + "objectsCount": 2, + "address": 8, + "divider": 10 + }, + { + "tag": "8int_read_multiplier", + "type": "8int", + "functionCode": 4, + "objectsCount": 1, + "address": 10, + "multiplier": 10 + }, + { + "tag": "32int_read", + "type": "32int", + "functionCode": 4, + "objectsCount": 2, + "address": 11 + }, + { + "tag": "64int_read", + "type": "64int", + "functionCode": 4, + "objectsCount": 4, + "address": 13 + } + ], + "timeseries": [ + { + "tag": "8uint_read", + "type": "8uint", + "functionCode": 4, + "objectsCount": 1, + "address": 17 + }, + { + "tag": "16uint_read", + "type": "16uint", + "functionCode": 4, + "objectsCount": 2, + "address": 18 + }, + { + "tag": "32uint_read", + "type": "32uint", + "functionCode": 4, + "objectsCount": 4, + "address": 20 + }, + { + "tag": "64uint_read", + "type": "64uint", + "functionCode": 4, + "objectsCount": 1, + "address": 24 + }, + { + "tag": "16float_read", + "type": "16float", + "functionCode": 4, + "objectsCount": 1, + "address": 25 + }, + { + "tag": "32float_read", + "type": "32float", + "functionCode": 4, + "objectsCount": 2, + "address": 26 + }, + { + "tag": "64float_read", + "type": "64float", + "functionCode": 4, + "objectsCount": 4, + "address": 28 + } + ], + "attributeUpdates": [ + { + "tag": "shared_attribute_write", + "type": "32int", + "functionCode": 6, + "objectsCount": 2, + "address": 29 + } + ], + "rpc": [ + { + "tag": "setValue", + "type": "bits", + "functionCode": 5, + "objectsCount": 1, + "address": 31 + }, + { + "tag": "getValue", + "type": "bits", + "functionCode": 1, + "objectsCount": 1, + "address": 31 + }, + { + "tag": "setCPUFanSpeed", + "type": "32int", + "functionCode": 16, + "objectsCount": 2, + "address": 33 + }, + { + "tag": "getCPULoad", + "type": "32int", + "functionCode": 4, + "objectsCount": 2, + "address": 35 + } + ] } - ], - "timeseries": [ - { - "address": 2, - "type": "8int", - "tag": "smm", - "objectsCount": 1, - "value": "12334" - } - ], - "attributeUpdates": [ - { - "tag": "shared_attribute_write", - "type": "32int", - "functionCode": 6, - "objectsCount": 2, - "address": 29, - "value": 1243 + ] + }, + "slave": { + "type": "tcp", + "host": "127.0.0.1", + "port": 5026, + "method": "socket", + "deviceName": "Modbus Slave Example", + "deviceType": "default", + "pollPeriod": 5000, + "sendDataToThingsBoard": false, + "byteOrder": "LITTLE", + "wordOrder": "LITTLE", + "unitId": 0, + "values": { + "holding_registers": { + "attributes": [ + { + "address": 1, + "type": "string", + "tag": "sm", + "objectsCount": 1, + "value": "ON" + } + ], + "timeseries": [ + { + "address": 2, + "type": "8int", + "tag": "smm", + "objectsCount": 1, + "value": "12334" + } + ], + "attributeUpdates": [ + { + "tag": "shared_attribute_write", + "type": "32int", + "functionCode": 6, + "objectsCount": 2, + "address": 29, + "value": 1243 + } + ], + "rpc": [ + { + "tag": "setValue", + "type": "bits", + "functionCode": 5, + "objectsCount": 1, + "address": 31, + "value": 22 + } + ] + }, + "coils_initializer": { + "attributes": [ + { + "address": 5, + "type": "string", + "tag": "sm", + "objectsCount": 1, + "value": "12" + } + ], + "timeseries": [], + "attributeUpdates": [], + "rpc": [] } - ], - "rpc": [ + } + } + }, + "legacy": { + "master": { + "slaves": [ { - "tag": "setValue", - "type": "bits", - "functionCode": 5, - "objectsCount": 1, - "address": 31, - "value": 22 + "host": "127.0.0.1", + "port": 5021, + "type": "tcp", + "method": "socket", + "timeout": 35, + "byteOrder": "LITTLE", + "wordOrder": "LITTLE", + "retries": true, + "retryOnEmpty": true, + "retryOnInvalid": true, + "pollPeriod": 5000, + "unitId": 1, + "deviceName": "Temp Sensor", + "sendDataOnlyOnChange": true, + "connectAttemptTimeMs": 5000, + "connectAttemptCount": 5, + "waitAfterFailedAttemptsMs": 300000, + "attributes": [ + { + "tag": "string_read", + "type": "string", + "functionCode": 4, + "objectsCount": 4, + "address": 1 + }, + { + "tag": "bits_read", + "type": "bits", + "functionCode": 4, + "objectsCount": 1, + "address": 5 + }, + { + "tag": "16int_read", + "type": "16int", + "functionCode": 4, + "objectsCount": 1, + "address": 7 + }, + { + "tag": "32int_read_divider", + "type": "32int", + "functionCode": 4, + "objectsCount": 2, + "address": 8, + "divider": 10 + }, + { + "tag": "32int_read", + "type": "32int", + "functionCode": 4, + "objectsCount": 2, + "address": 11 + }, + { + "tag": "64int_read", + "type": "64int", + "functionCode": 4, + "objectsCount": 4, + "address": 13 + } + ], + "timeseries": [ + { + "tag": "16uint_read", + "type": "16uint", + "functionCode": 4, + "objectsCount": 2, + "address": 18 + }, + { + "tag": "32uint_read", + "type": "32uint", + "functionCode": 4, + "objectsCount": 4, + "address": 20 + }, + { + "tag": "64uint_read", + "type": "64uint", + "functionCode": 4, + "objectsCount": 1, + "address": 24 + }, + { + "tag": "16float_read", + "type": "16float", + "functionCode": 4, + "objectsCount": 1, + "address": 25 + }, + { + "tag": "32float_read", + "type": "32float", + "functionCode": 4, + "objectsCount": 2, + "address": 26 + }, + { + "tag": "64float_read", + "type": "64float", + "functionCode": 4, + "objectsCount": 4, + "address": 28 + } + ], + "attributeUpdates": [ + { + "tag": "shared_attribute_write", + "type": "32int", + "functionCode": 6, + "objectsCount": 2, + "address": 29 + } + ], + "rpc": [ + { + "tag": "setValue", + "type": "bits", + "functionCode": 5, + "objectsCount": 1, + "address": 31 + }, + { + "tag": "getValue", + "type": "bits", + "functionCode": 1, + "objectsCount": 1, + "address": 31 + }, + { + "tag": "setCPUFanSpeed", + "type": "32int", + "functionCode": 16, + "objectsCount": 2, + "address": 33 + }, + { + "tag": "getCPULoad", + "type": "32int", + "functionCode": 4, + "objectsCount": 2, + "address": 35 + } + ] } ] }, - "coils_initializer": { - "attributes": [ - { - "address": 5, - "type": "string", - "tag": "sm", - "objectsCount": 1, - "value": "12" - } - ], - "timeseries": [], - "attributeUpdates": [], - "rpc": [] + "slave": { + "type": "tcp", + "host": "127.0.0.1", + "port": 5026, + "method": "socket", + "deviceName": "Modbus Slave Example", + "deviceType": "default", + "pollPeriod": 5000, + "sendDataToThingsBoard": false, + "byteOrder": "LITTLE", + "wordOrder": "LITTLE", + "unitId": 0, + "values": { + "holding_registers": [ + { + "attributes": [ + { + "address": 1, + "type": "string", + "tag": "sm", + "objectsCount": 1, + "value": "ON" + } + ], + "timeseries": [ + { + "address": 2, + "type": "int", + "tag": "smm", + "objectsCount": 1, + "value": "12334" + } + ], + "attributeUpdates": [ + { + "tag": "shared_attribute_write", + "type": "32int", + "functionCode": 6, + "objectsCount": 2, + "address": 29, + "value": 1243 + } + ], + "rpc": [ + { + "tag": "setValue", + "type": "bits", + "functionCode": 5, + "objectsCount": 1, + "address": 31, + "value": 22 + } + ] + } + ], + "coils_initializer": [ + { + "attributes": [ + { + "address": 5, + "type": "string", + "tag": "sm", + "objectsCount": 1, + "value": "12" + } + ], + "timeseries": [], + "attributeUpdates": [], + "rpc": [] + } + ] + } } } - } } diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/mqtt.json b/ui-ngx/src/assets/metadata/connector-default-configs/mqtt.json index 3e0800c5d6..5b8b8dbd44 100644 --- a/ui-ngx/src/assets/metadata/connector-default-configs/mqtt.json +++ b/ui-ngx/src/assets/metadata/connector-default-configs/mqtt.json @@ -1,217 +1,398 @@ { - "broker": { - "host": "127.0.0.1", - "port": 1883, - "clientId": "ThingsBoard_gateway", - "version": 5, - "maxMessageNumberPerWorker": 10, - "maxNumberOfWorkers": 100, - "sendDataOnlyOnChange": false, - "security": { - "type": "anonymous" - } - }, - "dataMapping": [ - { - "topicFilter": "sensor/data", - "subscriptionQos": 1, - "converter": { - "type": "json", - "deviceInfo": { - "deviceNameExpressionSource": "message", - "deviceNameExpression": "${serialNumber}", - "deviceProfileExpressionSource": "message", - "deviceProfileExpression": "${sensorType}" + "3.5.2": { + "broker": { + "host": "127.0.0.1", + "port": 1883, + "clientId": "ThingsBoard_gateway", + "version": 5, + "maxMessageNumberPerWorker": 10, + "maxNumberOfWorkers": 100, + "sendDataOnlyOnChange": false, + "security": { + "type": "anonymous" + } }, - "sendDataOnlyOnChange": false, - "timeout": 60000, - "attributes": [ - { - "type": "string", - "key": "model", - "value": "${sensorModel}" - }, - { - "type": "string", - "key": "${sensorModel}", - "value": "on" - } + "mapping": [ + { + "topicFilter": "sensor/data", + "subscriptionQos": 1, + "converter": { + "type": "json", + "deviceInfo": { + "deviceNameExpressionSource": "message", + "deviceNameExpression": "${serialNumber}", + "deviceProfileExpressionSource": "message", + "deviceProfileExpression": "${sensorType}" + }, + "sendDataOnlyOnChange": false, + "timeout": 60000, + "attributes": [ + { + "type": "string", + "key": "model", + "value": "${sensorModel}" + }, + { + "type": "string", + "key": "${sensorModel}", + "value": "on" + } + ], + "timeseries": [ + { + "type": "string", + "key": "temperature", + "value": "${temp}" + }, + { + "type": "double", + "key": "humidity", + "value": "${hum}" + }, + { + "type": "string", + "key": "combine", + "value": "${hum}:${temp}" + } + ] + } + }, + { + "topicFilter": "sensor/+/data", + "subscriptionQos": 1, + "converter": { + "type": "json", + "deviceInfo": { + "deviceNameExpressionSource": "topic", + "deviceNameExpression": "(?<=sensor\/)(.*?)(?=\/data)", + "deviceProfileExpressionSource": "constant", + "deviceProfileExpression": "Thermometer" + }, + "sendDataOnlyOnChange": false, + "timeout": 60000, + "attributes": [ + { + "type": "string", + "key": "model", + "value": "${sensorModel}" + } + ], + "timeseries": [ + { + "type": "double", + "key": "temperature", + "value": "${temp}" + }, + { + "type": "string", + "key": "humidity", + "value": "${hum}" + } + ] + } + }, + { + "topicFilter": "sensor/raw_data", + "subscriptionQos": 1, + "converter": { + "type": "bytes", + "deviceInfo": { + "deviceNameExpressionSource": "message", + "deviceNameExpression": "[0:4]", + "deviceProfileExpressionSource": "constant", + "deviceProfileExpression": "default" + }, + "sendDataOnlyOnChange": false, + "timeout": 60000, + "attributes": [ + { + "type": "raw", + "key": "rawData", + "value": "[:]" + } + ], + "timeseries": [ + { + "type": "raw", + "key": "temp", + "value": "[4:]" + } + ] + } + }, + { + "topicFilter": "custom/sensors/+", + "subscriptionQos": 1, + "converter": { + "type": "custom", + "extension": "CustomMqttUplinkConverter", + "cached": true, + "extensionConfig": { + "temperature": 2, + "humidity": 2, + "batteryLevel": 1 + } + } + } ], - "timeseries": [ - { - "type": "string", - "key": "temperature", - "value": "${temp}" - }, - { - "type": "double", - "key": "humidity", - "value": "${hum}" - }, - { - "type": "string", - "key": "combine", - "value": "${hum}:${temp}" - } - ] - } + "requestsMapping": { + "connectRequests": [ + { + "topicFilter": "sensor/connect", + "deviceInfo": { + "deviceNameExpressionSource": "message", + "deviceNameExpression": "${serialNumber}", + "deviceProfileExpressionSource": "constant", + "deviceProfileExpression": "Thermometer" + } + }, + { + "topicFilter": "sensor/+/connect", + "deviceInfo": { + "deviceNameExpressionSource": "topic", + "deviceNameExpression": "(?<=sensor\/)(.*?)(?=\/connect)", + "deviceProfileExpressionSource": "constant", + "deviceProfileExpression": "Thermometer" + } + } + ], + "disconnectRequests": [ + { + "topicFilter": "sensor/disconnect", + "deviceInfo": { + "deviceNameExpressionSource": "message", + "deviceNameExpression": "${serialNumber}" + } + }, + { + "topicFilter": "sensor/+/disconnect", + "deviceInfo": { + "deviceNameExpressionSource": "topic", + "deviceNameExpression": "(?<=sensor\/)(.*?)(?=\/connect)" + } + } + ], + "attributeRequests": [ + { + "retain": false, + "topicFilter": "v1/devices/me/attributes/request", + "deviceInfo": { + "deviceNameExpressionSource": "message", + "deviceNameExpression": "${serialNumber}" + }, + "attributeNameExpressionSource": "message", + "attributeNameExpression": "${versionAttribute}, ${pduAttribute}", + "topicExpression": "devices/${deviceName}/attrs", + "valueExpression": "${attributeKey}: ${attributeValue}" + } + ], + "attributeUpdates": [ + { + "retain": true, + "deviceNameFilter": ".*", + "attributeFilter": "firmwareVersion", + "topicExpression": "sensor/${deviceName}/${attributeKey}", + "valueExpression": "{\"${attributeKey}\":\"${attributeValue}\"}" + } + ], + "serverSideRpc": [ + { + "type": "twoWay", + "deviceNameFilter": ".*", + "methodFilter": "echo", + "requestTopicExpression": "sensor/${deviceName}/request/${methodName}/${requestId}", + "responseTopicExpression": "sensor/${deviceName}/response/${methodName}/${requestId}", + "responseTopicQoS": 1, + "responseTimeout": 10000, + "valueExpression": "${params}" + }, + { + "type": "oneWay", + "deviceNameFilter": ".*", + "methodFilter": "no-reply", + "requestTopicExpression": "sensor/${deviceName}/request/${methodName}/${requestId}", + "valueExpression": "${params}" + } + ] + } }, - { - "topicFilter": "sensor/+/data", - "subscriptionQos": 1, - "converter": { - "type": "json", - "deviceInfo": { - "deviceNameExpressionSource": "topic", - "deviceNameExpression": "(?<=sensor\/)(.*?)(?=\/data)", - "deviceProfileExpressionSource": "constant", - "deviceProfileExpression": "Thermometer" + "legacy": { + "broker": { + "name": "Default Local Broker", + "host": "127.0.0.1", + "port": 1883, + "clientId": "ThingsBoard_gateway", + "version": 5, + "maxMessageNumberPerWorker": 10, + "maxNumberOfWorkers": 100, + "sendDataOnlyOnChange": false, + "security": { + "type": "basic", + "username": "user", + "password": "password" + } }, - "sendDataOnlyOnChange": false, - "timeout": 60000, - "attributes": [ - { - "type": "string", - "key": "model", - "value": "${sensorModel}" - } + "mapping": [ + { + "topicFilter": "sensor/data", + "converter": { + "type": "json", + "deviceNameJsonExpression": "${serialNumber}", + "deviceTypeJsonExpression": "${sensorType}", + "sendDataOnlyOnChange": false, + "timeout": 60000, + "attributes": [ + { + "type": "string", + "key": "model", + "value": "${sensorModel}" + }, + { + "type": "string", + "key": "${sensorModel}", + "value": "on" + } + ], + "timeseries": [ + { + "type": "double", + "key": "temperature", + "value": "${temp}" + }, + { + "type": "double", + "key": "humidity", + "value": "${hum}" + }, + { + "type": "string", + "key": "combine", + "value": "${hum}:${temp}" + } + ] + } + }, + { + "topicFilter": "sensor/+/data", + "converter": { + "type": "json", + "deviceNameTopicExpression": "(?<=sensor\/)(.*?)(?=\/data)", + "deviceTypeTopicExpression": "Thermometer", + "sendDataOnlyOnChange": false, + "timeout": 60000, + "attributes": [ + { + "type": "string", + "key": "model", + "value": "${sensorModel}" + } + ], + "timeseries": [ + { + "type": "double", + "key": "temperature", + "value": "${temp}" + }, + { + "type": "double", + "key": "humidity", + "value": "${hum}" + } + ] + } + }, + { + "topicFilter": "sensor/raw_data", + "converter": { + "type": "bytes", + "deviceNameExpression": "[0:4]", + "deviceTypeExpression": "default", + "sendDataOnlyOnChange": false, + "timeout": 60000, + "attributes": [ + { + "type": "raw", + "key": "rawData", + "value": "[:]" + } + ], + "timeseries": [ + { + "type": "raw", + "key": "temp", + "value": "[4:]" + } + ] + } + }, + { + "topicFilter": "custom/sensors/+", + "converter": { + "type": "custom", + "extension": "CustomMqttUplinkConverter", + "cached": true, + "extension-config": { + "temperatureBytes": 2, + "humidityBytes": 2, + "batteryLevelBytes": 1 + } + } + } ], - "timeseries": [ - { - "type": "double", - "key": "temperature", - "value": "${temp}" - }, - { - "type": "string", - "key": "humidity", - "value": "${hum}" - } - ] - } - }, - { - "topicFilter": "sensor/raw_data", - "subscriptionQos": 1, - "converter": { - "type": "bytes", - "deviceInfo": { - "deviceNameExpressionSource": "message", - "deviceNameExpression": "[0:4]", - "deviceProfileExpressionSource": "constant", - "deviceProfileExpression": "default" - }, - "sendDataOnlyOnChange": false, - "timeout": 60000, - "attributes": [ - { - "type": "raw", - "key": "rawData", - "value": "[:]" - } + "connectRequests": [ + { + "topicFilter": "sensor/connect", + "deviceNameJsonExpression": "${serialNumber}" + }, + { + "topicFilter": "sensor/+/connect", + "deviceNameTopicExpression": "(?<=sensor\/)(.*?)(?=\/connect)" + } + ], + "disconnectRequests": [ + { + "topicFilter": "sensor/disconnect", + "deviceNameJsonExpression": "${serialNumber}" + }, + { + "topicFilter": "sensor/+/disconnect", + "deviceNameTopicExpression": "(?<=sensor\/)(.*?)(?=\/disconnect)" + } + ], + "attributeRequests": [ + { + "retain": false, + "topicFilter": "v1/devices/me/attributes/request", + "deviceNameJsonExpression": "${serialNumber}", + "attributeNameJsonExpression": "${versionAttribute}, ${pduAttribute}", + "topicExpression": "devices/${deviceName}/attrs", + "valueExpression": "${attributeKey}: ${attributeValue}" + } ], - "timeseries": [ - { - "type": "raw", - "key": "temp", - "value": "[4:]" - } + "attributeUpdates": [ + { + "retain": true, + "deviceNameFilter": ".*", + "attributeFilter": "firmwareVersion", + "topicExpression": "sensor/${deviceName}/${attributeKey}", + "valueExpression": "{\"${attributeKey}\":\"${attributeValue}\"}" + } + ], + "serverSideRpc": [ + { + "deviceNameFilter": ".*", + "methodFilter": "echo", + "requestTopicExpression": "sensor/${deviceName}/request/${methodName}/${requestId}", + "responseTopicExpression": "sensor/${deviceName}/response/${methodName}/${requestId}", + "responseTimeout": 10000, + "valueExpression": "${params}" + }, + { + "deviceNameFilter": ".*", + "methodFilter": "no-reply", + "requestTopicExpression": "sensor/${deviceName}/request/${methodName}/${requestId}", + "valueExpression": "${params}" + } ] - } - }, - { - "topicFilter": "custom/sensors/+", - "subscriptionQos": 1, - "converter": { - "type": "custom", - "extension": "CustomMqttUplinkConverter", - "cached": true, - "extensionConfig": { - "temperature": 2, - "humidity": 2, - "batteryLevel": 1 - } - } - } - ], - "requestsMapping": { - "connectRequests": [ - { - "topicFilter": "sensor/connect", - "deviceInfo": { - "deviceNameExpressionSource": "message", - "deviceNameExpression": "${serialNumber}", - "deviceProfileExpressionSource": "constant", - "deviceProfileExpression": "Thermometer" - } - }, - { - "topicFilter": "sensor/+/connect", - "deviceInfo": { - "deviceNameExpressionSource": "topic", - "deviceNameExpression": "(?<=sensor\/)(.*?)(?=\/connect)", - "deviceProfileExpressionSource": "constant", - "deviceProfileExpression": "Thermometer" - } - } - ], - "disconnectRequests": [ - { - "topicFilter": "sensor/disconnect", - "deviceInfo": { - "deviceNameExpressionSource": "message", - "deviceNameExpression": "${serialNumber}" - } - }, - { - "topicFilter": "sensor/+/disconnect", - "deviceInfo": { - "deviceNameExpressionSource": "topic", - "deviceNameExpression": "(?<=sensor\/)(.*?)(?=\/connect)" - } - } - ], - "attributeRequests": [ - { - "retain": false, - "topicFilter": "v1/devices/me/attributes/request", - "deviceInfo": { - "deviceNameExpressionSource": "message", - "deviceNameExpression": "${serialNumber}" - }, - "attributeNameExpressionSource": "message", - "attributeNameExpression": "${versionAttribute}, ${pduAttribute}", - "topicExpression": "devices/${deviceName}/attrs", - "valueExpression": "${attributeKey}: ${attributeValue}" - } - ], - "attributeUpdates": [ - { - "retain": true, - "deviceNameFilter": ".*", - "attributeFilter": "firmwareVersion", - "topicExpression": "sensor/${deviceName}/${attributeKey}", - "valueExpression": "{\"${attributeKey}\":\"${attributeValue}\"}" - } - ], - "serverSideRpc": [ - { - "type": "twoWay", - "deviceNameFilter": ".*", - "methodFilter": "echo", - "requestTopicExpression": "sensor/${deviceName}/request/${methodName}/${requestId}", - "responseTopicExpression": "sensor/${deviceName}/response/${methodName}/${requestId}", - "responseTopicQoS": 1, - "responseTimeout": 10000, - "valueExpression": "${params}" - }, - { - "type": "oneWay", - "deviceNameFilter": ".*", - "methodFilter": "no-reply", - "requestTopicExpression": "sensor/${deviceName}/request/${methodName}/${requestId}", - "valueExpression": "${params}" } - ] - } } diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/opcua.json b/ui-ngx/src/assets/metadata/connector-default-configs/opcua.json index 7ff5b78433..c0ef993dd1 100644 --- a/ui-ngx/src/assets/metadata/connector-default-configs/opcua.json +++ b/ui-ngx/src/assets/metadata/connector-default-configs/opcua.json @@ -1,66 +1,120 @@ { - "server": { - "url": "localhost:4840/freeopcua/server/", - "timeoutInMillis": 5000, - "scanPeriodInMillis": 3600000, - "pollPeriodInMillis": 5000, - "enableSubscriptions": true, - "subCheckPeriodInMillis": 100, - "showMap": false, - "security": "Basic128Rsa15", - "identity": { - "type": "anonymous" - } - }, - "mapping": [{ - "deviceNodePattern": "Root\\.Objects\\.Device1", - "deviceNodeSource": "path", - "deviceInfo": { - "deviceNameExpression": "Device ${Root\\.Objects\\.Device1\\.serialNumber}", - "deviceNameExpressionSource": "path", - "deviceProfileExpression": "Device", - "deviceProfileExpressionSource": "constant" + "3.5.2": { + "server": { + "url": "localhost:4840/freeopcua/server/", + "timeoutInMillis": 5000, + "scanPeriodInMillis": 3600000, + "pollPeriodInMillis": 5000, + "enableSubscriptions": true, + "subCheckPeriodInMillis": 100, + "showMap": false, + "security": "Basic128Rsa15", + "identity": { + "type": "anonymous" + } + }, + "mapping": [{ + "deviceNodePattern": "Root\\.Objects\\.Device1", + "deviceNodeSource": "path", + "deviceInfo": { + "deviceNameExpression": "Device ${Root\\.Objects\\.Device1\\.serialNumber}", + "deviceNameExpressionSource": "path", + "deviceProfileExpression": "Device", + "deviceProfileExpressionSource": "constant" + }, + "attributes": [ + { + "key": "temperature °C", + "type": "path", + "value": "${ns=2;i=5}" + } + ], + "timeseries": [ + { + "key": "humidity", + "type": "path", + "value": "${Root\\.Objects\\.Device1\\.TemperatureAndHumiditySensor\\.Humidity}" + }, + { + "key": "batteryLevel", + "type": "path", + "value": "${Battery\\.batteryLevel}" + } + ], + "rpc_methods": [ + { + "method": "multiply", + "arguments": [ + { + "type": "integer", + "value": 2 + }, + { + "type": "integer", + "value": 4 + } + ] + } + ], + "attributes_updates": [ + { + "key": "deviceName", + "type": "path", + "value": "Root\\.Objects\\.Device1\\.serialNumber" + } + ] + }] }, - "attributes": [ - { - "key": "temperature °C", - "type": "path", - "value": "${ns=2;i=5}" - } - ], - "timeseries": [ - { - "key": "humidity", - "type": "path", - "value": "${Root\\.Objects\\.Device1\\.TemperatureAndHumiditySensor\\.Humidity}" - }, - { - "key": "batteryLevel", - "type": "path", - "value": "${Battery\\.batteryLevel}" - } - ], - "rpc_methods": [ - { - "method": "multiply", - "arguments": [ - { - "type": "integer", - "value": 2 - }, - { - "type": "integer", - "value": 4 - } - ] - } - ], - "attributes_updates": [ - { - "key": "deviceName", - "type": "path", - "value": "Root\\.Objects\\.Device1\\.serialNumber" - } - ] - }] + "legacy": { + "server": { + "name": "OPC-UA Default Server", + "url": "localhost:4840/freeopcua/server/", + "timeoutInMillis": 5000, + "scanPeriodInMillis": 5000, + "disableSubscriptions": false, + "subCheckPeriodInMillis": 100, + "showMap": false, + "security": "Basic128Rsa15", + "identity": { + "type": "anonymous" + }, + "mapping": [ + { + "deviceNodePattern": "Root\\.Objects\\.Device1", + "deviceNamePattern": "Device ${Root\\.Objects\\.Device1\\.serialNumber}", + "attributes": [ + { + "key": "temperature °C", + "path": "${ns=2;i=5}" + } + ], + "timeseries": [ + { + "key": "humidity", + "path": "${Root\\.Objects\\.Device1\\.TemperatureAndHumiditySensor\\.Humidity}" + }, + { + "key": "batteryLevel", + "path": "${Battery\\.batteryLevel}" + } + ], + "rpc_methods": [ + { + "method": "multiply", + "arguments": [ + 2, + 4 + ] + } + ], + "attributes_updates": [ + { + "attributeOnThingsBoard": "deviceName", + "attributeOnDevice": "Root\\.Objects\\.Device1\\.serialNumber" + } + ] + } + ] + } + } } diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index c7eae0fe6f..83c773cbbb 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -235,6 +235,10 @@ .fixed-title-width { min-width: 200px; + &-180 { + min-width: 180px; + } + &-230 { min-width: 230px; } @@ -260,12 +264,21 @@ } .tb-flex { - display: flex; flex: 1; - gap: 8px; + &.no-flex { flex: none; } + + &-xs { + @media #{$mat-xs} { + flex: 1; + } + } + } + [class*="tb-flex"] { + display: flex; + gap: 8px; &.row { flex-direction: row; } @@ -287,12 +300,15 @@ &.space-between { justify-content: space-between; } - &.center { - justify-content: center; - } &.align-center { align-items: center; } + &.align-start { + align-items: start; + } + &.align-end { + align-items: end; + } &.no-gap { gap: 0; } @@ -362,10 +378,19 @@ .mat-mdc-text-field-wrapper { &.mdc-text-field--outlined, &:not(.mdc-text-field--outlined) { &:not(.mdc-text-field--focused):not(.mdc-text-field--disabled):not(.mdc-text-field--invalid):not(:hover) { - .mdc-notched-outline__leading, .mdc-notched-outline__trailing { + .mdc-notched-outline__leading, .mdc-notched-outline__trailing, .mdc-notched-outline__notch { border-color: rgba(0, 0, 0, 0.12); } } + .mdc-floating-label { + top: 20px; + font-weight: 400; + font-size: 14px; + line-height: 20px; + &--float-above { + --mat-mdc-form-field-label-transform: translateY(-27px) scale(var(--mat-mdc-form-field-floating-label-scale, 0.75)); + } + } .mat-mdc-form-field-infix { padding-top: 8px; padding-bottom: 8px; @@ -377,7 +402,8 @@ line-height: 20px; } } - .mat-mdc-form-field-icon-prefix, .mat-mdc-form-field-icon-suffix { + .mat-mdc-form-field-icon-prefix, .mat-mdc-form-field-icon-suffix, + .mat-datetimepicker-toggle { height: 40px; font-size: 14px; line-height: 40px; @@ -391,6 +417,10 @@ width: 20px; height: 20px; font-size: 20px; + svg { + width: 20px; + height: 20px; + } } .mat-mdc-button-touch-target { width: 40px;