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/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/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/dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java b/dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java index e92adba01d..19649655a3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java @@ -46,9 +46,11 @@ import org.thingsboard.server.common.data.StringUtils; import org.w3c.dom.Document; import javax.imageio.ImageIO; +import java.awt.Color; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.util.Base64; import java.util.Map; @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -265,6 +267,94 @@ public class ImageUtils { return new int[]{thumbnailWidth, thumbnailHeight}; } + public static String getEmbeddedBase64EncodedImg(String colorStr) { + try { + Color color = parseColor(colorStr); // Support for hex, rgb, hsla + BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + image.setRGB(0, 0, color.getRGB()); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ImageIO.write(image, "png", outputStream); + byte[] imageBytes = outputStream.toByteArray(); + String base64String = Base64.getEncoder().encodeToString(imageBytes); + + return "data:image/png;base64," + base64String; + } catch (Exception e) { + log.warn("Failed to generate embedded image for color: {}", colorStr, e); + return null; + } + } + + private static Color parseColor(String colorStr) { + if (colorStr.startsWith("#")) { + return Color.decode(colorStr); + } + + if (colorStr.startsWith("rgb")) { + return parseRgbColor(colorStr); + } + + if (colorStr.startsWith("hsl")) { + return parseHslaColor(colorStr); + } + + throw new IllegalArgumentException("Unsupported color format: " + colorStr); + } + + private static Color parseRgbColor(String rgb) { + String[] rgbValues = rgb.replaceAll("[^0-9,]", "").split(","); + int r = Integer.parseInt(rgbValues[0]); + int g = Integer.parseInt(rgbValues[1]); + int b = Integer.parseInt(rgbValues[2]); + return new Color(r, g, b); + } + + private static Color parseHslaColor(String hsla) { + String[] hslaValues = hsla.replaceAll("[^0-9.,]", "").split(","); + float h = Float.parseFloat(hslaValues[0]); + float s = Float.parseFloat(hslaValues[1]) / 100; + float l = Float.parseFloat(hslaValues[2]) / 100; + float a = hslaValues.length > 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/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/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/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/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 37ea4a1a87..1a2089823c 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4295,6 +4295,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",