Browse Source

Merge branch 'master' of github.com:thingsboard/thingsboard

pull/11644/head
Igor Kulikov 2 years ago
parent
commit
5f0af1de15
  1. 179
      application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java
  2. 93
      application/src/main/java/org/thingsboard/server/service/notification/channels/TeamsAdaptiveCard.java
  3. 92
      application/src/main/java/org/thingsboard/server/service/notification/channels/TeamsMessageCard.java
  4. 86
      application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java
  5. 1
      common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java
  6. 90
      dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java
  7. 101
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java
  8. 18
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeConfiguration.java
  9. 20
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java
  10. 343
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeTest.java
  11. 13
      ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html
  12. 8
      ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.ts
  13. 1
      ui-ngx/src/app/shared/models/notification.models.ts
  14. 2
      ui-ngx/src/assets/locale/locale.constant-en_US.json

179
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<Mi
@Override
public void sendNotification(MicrosoftTeamsNotificationTargetConfig targetConfig, MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws Exception {
Message message = new Message();
message.setThemeColor(Strings.emptyToNull(processedTemplate.getThemeColor()));
if (targetConfig.getUseOldApi() == null || Boolean.TRUE.equals(targetConfig.getUseOldApi())) {
sendTeamsMessageCard(targetConfig, processedTemplate, ctx);
} else {
sendTeamsAdaptiveCard(targetConfig, processedTemplate, ctx);
}
}
private void sendTeamsAdaptiveCard(MicrosoftTeamsNotificationTargetConfig targetConfig, MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws URISyntaxException, JsonProcessingException {
TeamsAdaptiveCard teamsAdaptiveCard = new TeamsAdaptiveCard();
TeamsAdaptiveCard.Attachment attachment = new TeamsAdaptiveCard.Attachment();
teamsAdaptiveCard.setAttachments(List.of(attachment));
TeamsAdaptiveCard.AdaptiveCard adaptiveCard = new TeamsAdaptiveCard.AdaptiveCard();
attachment.setContent(adaptiveCard);
TeamsAdaptiveCard.BackgroundImage backgroundImage = new TeamsAdaptiveCard.BackgroundImage(processedTemplate.getThemeColor());
adaptiveCard.setBackgroundImage(backgroundImage);
if (StringUtils.isEmpty(processedTemplate.getSubject())) {
message.setText(processedTemplate.getBody());
TeamsAdaptiveCard.TextBlock textBlock = new TeamsAdaptiveCard.TextBlock();
textBlock.setText(processedTemplate.getBody());
textBlock.setWeight("Normal");
textBlock.setSize("Medium");
textBlock.setColor(processedTemplate.getThemeColor());
adaptiveCard.getTextBlocks().add(textBlock);
} else {
message.setSummary(processedTemplate.getSubject());
Message.Section section = new Message.Section();
TeamsAdaptiveCard.TextBlock subjectTextBlock = new TeamsAdaptiveCard.TextBlock();
subjectTextBlock.setText(processedTemplate.getSubject());
subjectTextBlock.setWeight("Bolder");
subjectTextBlock.setSize("Large");
subjectTextBlock.setColor(processedTemplate.getThemeColor());
adaptiveCard.getTextBlocks().add(subjectTextBlock);
TeamsAdaptiveCard.TextBlock bodyTextBlock = new TeamsAdaptiveCard.TextBlock();
bodyTextBlock.setText(processedTemplate.getBody());
bodyTextBlock.setWeight("Lighter");
bodyTextBlock.setSize("Medium");
bodyTextBlock.setColor(processedTemplate.getThemeColor());
adaptiveCard.getTextBlocks().add(bodyTextBlock);
}
String uri = getButtonUri(processedTemplate, ctx);
if (StringUtils.isNotBlank(uri) && processedTemplate.getButton().getText() != null) {
TeamsAdaptiveCard.ActionOpenUrl actionOpenUrl = new TeamsAdaptiveCard.ActionOpenUrl();
actionOpenUrl.setTitle(processedTemplate.getButton().getText());
actionOpenUrl.setUrl(uri);
adaptiveCard.getActions().add(actionOpenUrl);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> 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<String> 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<Mi
} else {
uri = button.getLink();
}
if (StringUtils.isNotBlank(uri) && button.getText() != null) {
Message.ActionCard actionCard = new Message.ActionCard();
actionCard.setType("OpenUri");
actionCard.setName(button.getText());
var target = new Message.ActionCard.Target("default", uri);
actionCard.setTargets(List.of(target));
message.setPotentialAction(List.of(actionCard));
}
return uri;
}
restTemplate.postForEntity(targetConfig.getWebhookUrl(), message, String.class);
return null;
}
@Override
@ -121,74 +192,4 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel<Mi
return NotificationDeliveryMethod.MICROSOFT_TEAMS;
}
@Data
public static class Message {
@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<Section> sections;
private List<ActionCard> potentialAction;
@Data
public static class Section {
private String activityTitle;
private String activitySubtitle;
private String activityImage;
private List<Fact> 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<Input> inputs; // for ActionCard
private List<Action> actions; // for ActionCard
private List<Target> 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;
}
}
}
}

93
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 <a href="https://adaptivecards.io/designer/">AdaptiveCard Designer</a>
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TeamsAdaptiveCard {
private String type = "message";
private List<Attachment> 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<TextBlock> textBlocks = new ArrayList<>();
private List<ActionOpenUrl> 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;
}
}

92
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<Section> sections;
private List<ActionCard> potentialAction;
@Data
public static class Section {
private String activityTitle;
private String activitySubtitle;
private String activityImage;
private List<Fact> 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<Input> inputs; // for ActionCard
private List<Action> actions; // for ActionCard
private List<Target> 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;
}
}
}

86
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<HttpEntity<String>> 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<String> 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<HttpEntity<String>> messageCaptor = ArgumentCaptor.forClass(HttpEntity.class);
notificationCenter.processNotificationRequest(tenantId, notificationRequest, null);
verify(restTemplate, timeout(20000)).postForEntity(eq(new URI(webhookUrl)), messageCaptor.capture(), any());
HttpEntity<String> 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();

1
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() {

90
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

101
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<TimeUnit> 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<UUID, TbMsg> pendingMsgs;
private ConcurrentMap<UUID, TbMsg> 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<Boolean, JsonNode> 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);
}
}

18
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<TbMsgDelayNodeConfiguration> {
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;
}
}

20
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 {

343
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<TimeUnit> 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<TbMsg> 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<Arguments> 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> 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<UUID, TbMsg>) 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<Arguments> 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;
}
}

13
ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html

@ -121,6 +121,19 @@
</tb-slack-conversation-autocomplete>
</section>
<section *ngIf="targetNotificationForm.get('configuration.type').value === notificationTargetType.MICROSOFT_TEAMS">
<section class="tb-form-row" style="margin-bottom: 16px">
<mat-slide-toggle class="mat-slide margin" formControlName="useOldApi">
{{ "notification.use-old-api" | translate }}
</mat-slide-toggle>
<a mat-icon-button
href="https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/"
target="_blank"
matTooltip="{{ 'notification.use-deprecated-webhook-connectors' | translate }}"
matTooltipPosition="above"
class="tb-mat-20">
<mat-icon>open_in_new</mat-icon>
</a>
</section>
<mat-form-field class="mat-block">
<mat-label translate>notification.webhook-url</mat-label>
<input matInput formControlName="webhookUrl">

8
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});
}
}
}

1
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',

2
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",

Loading…
Cancel
Save