diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifier.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifier.java index 597c2659dd4..43cb10bd6b9 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifier.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifier.java @@ -23,6 +23,7 @@ import java.util.Map; import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; import lombok.Data; import lombok.Getter; @@ -62,7 +63,7 @@ public class MicrosoftTeamsNotifier extends AbstractStatusChangeNotifier { private static final String SOURCE_KEY = "Source"; - private static final String DEFAULT_THEME_COLOR_EXPRESSION = "#{event.type == 'STATUS_CHANGED' ? (event.statusInfo.status=='UP' ? '6db33f' : 'b32d36') : '439fe0'}"; + private static final String DEFAULT_THEME_COLOR_EXPRESSION = "#{event.type == 'STATUS_CHANGED' ? (event.statusInfo.status=='UP' ? 'Good' : 'Attention') : 'Accent'}"; private static final String DEFAULT_DEREGISTER_ACTIVITY_SUBTITLE_EXPRESSION = "#{instance.registration.name} with id #{instance.id} has de-registered from Spring Boot Admin"; @@ -126,13 +127,6 @@ public class MicrosoftTeamsNotifier extends AbstractStatusChangeNotifier { @Getter private String statusChangedTitle = "Status Changed"; - /** - * Summary section of every Teams message originating from Spring Boot Admin - */ - @Setter - @Getter - private String messageSummary = "Spring Boot Admin Notification"; - public MicrosoftTeamsNotifier(InstanceRepository repository, RestTemplate restTemplate) { super(repository); this.restTemplate = restTemplate; @@ -194,27 +188,45 @@ protected Message getStatusChangedMessage(Instance instance, EvaluationContext c return createMessage(instance, statusChangedTitle, activitySubtitle, context); } - protected Message createMessage(Instance instance, String registeredTitle, String activitySubtitle, + protected Message createMessage(Instance instance, String title, String activitySubtitle, EvaluationContext context) { + String themeColorValue = evaluateExpression(context, themeColor); + String registrationName = instance.getRegistration().getName(); + List facts = new ArrayList<>(); - facts.add(new Fact(STATUS_KEY, instance.getStatusInfo().getStatus())); - facts.add(new Fact(SERVICE_URL_KEY, instance.getRegistration().getServiceUrl())); - facts.add(new Fact(HEALTH_URL_KEY, instance.getRegistration().getHealthUrl())); - facts.add(new Fact(MANAGEMENT_URL_KEY, instance.getRegistration().getManagementUrl())); - facts.add(new Fact(SOURCE_KEY, instance.getRegistration().getSource())); - - Section section = Section.builder() - .activityTitle(instance.getRegistration().getName()) - .activitySubtitle(activitySubtitle) - .facts(facts) - .build(); + addFactIfNotNull(facts, STATUS_KEY, instance.getStatusInfo().getStatus()); + addFactIfNotNull(facts, SERVICE_URL_KEY, instance.getRegistration().getServiceUrl()); + addFactIfNotNull(facts, HEALTH_URL_KEY, instance.getRegistration().getHealthUrl()); + addFactIfNotNull(facts, MANAGEMENT_URL_KEY, instance.getRegistration().getManagementUrl()); + addFactIfNotNull(facts, SOURCE_KEY, instance.getRegistration().getSource()); - return Message.builder() - .title(registeredTitle) - .summary(messageSummary) - .themeColor(evaluateExpression(context, themeColor)) - .sections(singletonList(section)) - .build(); + List body = new ArrayList<>(); + body.add(CardElement.builder() + .type("TextBlock") + .text(title) + .size("Large") + .weight("Bolder") + .color(themeColorValue) + .build()); + + body.add( + CardElement.builder().type("TextBlock").text(registrationName).size("Medium").weight("Bolder").build()); + + body.add(CardElement.builder().type("TextBlock").text(activitySubtitle).wrap(true).build()); + + body.add(CardElement.builder().type("FactSet").facts(facts).build()); + + AdaptiveCard adaptiveCard = AdaptiveCard.builder().body(body).build(); + + Attachment attachment = Attachment.builder().content(adaptiveCard).build(); + + return Message.builder().attachments(singletonList(attachment)).build(); + } + + private void addFactIfNotNull(List facts, String title, String value) { + if (value != null && !value.isBlank()) { + facts.add(new Fact(title, value)); + } } protected String evaluateExpression(EvaluationContext context, Expression expression) { @@ -278,31 +290,60 @@ public void setStatusActivitySubtitle(String statusActivitySubtitle) { @Builder public static class Message { - private final String summary; + private final String type = "message"; + + private List attachments; - private final String themeColor; + } + + @Data + @Builder + public static class Attachment { + + private final String contentType = "application/vnd.microsoft.card.adaptive"; + + private final String contentUrl = null; + + private AdaptiveCard content; + + } - private final String title; + @Data + @Builder + public static class AdaptiveCard { + + @JsonProperty("$schema") + private final String schema = "http://adaptivecards.io/schemas/adaptive-card.json"; + + private final String type = "AdaptiveCard"; + + private final String version = "1.2"; - @Builder.Default - private final List
sections = new ArrayList<>(); + private List body; } @Data @Builder - public static class Section { + public static class CardElement { + + private String type; + + private String text; + + private String size; + + private String weight; - private final String activityTitle; + private String color; - private final String activitySubtitle; + private Boolean wrap; - @Builder.Default - private final List facts = new ArrayList<>(); + private List facts; } - public record Fact(String name, @Nullable String value) { + public record Fact(String title, @Nullable String value) { } } diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifierTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifierTest.java index 2ce0e862c55..414c650d892 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifierTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifierTest.java @@ -17,6 +17,7 @@ package de.codecentric.boot.admin.server.notify; import java.net.URI; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -35,6 +36,9 @@ import de.codecentric.boot.admin.server.domain.values.InstanceId; import de.codecentric.boot.admin.server.domain.values.Registration; import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.notify.MicrosoftTeamsNotifier.Attachment; +import de.codecentric.boot.admin.server.notify.MicrosoftTeamsNotifier.CardElement; +import de.codecentric.boot.admin.server.notify.MicrosoftTeamsNotifier.Fact; import de.codecentric.boot.admin.server.notify.MicrosoftTeamsNotifier.Message; import static org.assertj.core.api.Assertions.assertThat; @@ -45,11 +49,11 @@ class MicrosoftTeamsNotifierTest { - private static final String BLUE = "439fe0"; + private static final String ACCENT = "Accent"; - private static final String RED = "b32d36"; + private static final String ATTENTION = "Attention"; - private static final String GREEN = "6db33f"; + private static final String GOOD = "Good"; private static final String APP_NAME = "Test App"; @@ -95,8 +99,10 @@ void test_onClientApplicationDeRegisteredEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); - assertMessage(entity.getValue().getBody(), notifier.getDeRegisteredTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId has de-registered from Spring Boot Admin", BLUE); + assertMessage(entity.getValue().getBody(), notifier.getDeRegisteredTitle(), ACCENT, + "Test App with id TestAppId has de-registered from Spring Boot Admin", + List.of(new Fact("Status", "UNKNOWN"), new Fact("Service URL", SERVICE_URL), + new Fact("Health URL", HEALTH_URL), new Fact("Management URL", MANAGEMENT_URL))); } @Test @@ -111,8 +117,10 @@ void test_onApplicationRegisteredEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); - assertMessage(entity.getValue().getBody(), notifier.getRegisteredTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId has registered with Spring Boot Admin", BLUE); + assertMessage(entity.getValue().getBody(), notifier.getRegisteredTitle(), ACCENT, + "Test App with id TestAppId has registered with Spring Boot Admin", + List.of(new Fact("Status", "UNKNOWN"), new Fact("Service URL", SERVICE_URL), + new Fact("Health URL", HEALTH_URL), new Fact("Management URL", MANAGEMENT_URL))); } @Test @@ -127,8 +135,10 @@ void test_onApplicationStatusChangedEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); - assertMessage(entity.getValue().getBody(), notifier.getStatusChangedTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId changed status from UNKNOWN to UP", GREEN); + assertMessage(entity.getValue().getBody(), notifier.getStatusChangedTitle(), GOOD, + "Test App with id TestAppId changed status from UNKNOWN to UP", + List.of(new Fact("Status", "UNKNOWN"), new Fact("Service URL", SERVICE_URL), + new Fact("Health URL", HEALTH_URL), new Fact("Management URL", MANAGEMENT_URL))); } @Test @@ -148,8 +158,10 @@ void test_getDeregisteredMessageForAppReturns_correctContent() { Message message = notifier.getDeregisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertMessage(message, notifier.getDeRegisteredTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId has de-registered from Spring Boot Admin", BLUE); + assertMessage(message, notifier.getDeRegisteredTitle(), ACCENT, + "Test App with id TestAppId has de-registered from Spring Boot Admin", + List.of(new Fact("Status", "UNKNOWN"), new Fact("Service URL", SERVICE_URL), + new Fact("Health URL", HEALTH_URL), new Fact("Management URL", MANAGEMENT_URL))); } @Test @@ -157,8 +169,10 @@ void test_getRegisteredMessageForAppReturns_correctContent() { Message message = notifier.getRegisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertMessage(message, notifier.getRegisteredTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId has registered with Spring Boot Admin", BLUE); + assertMessage(message, notifier.getRegisteredTitle(), ACCENT, + "Test App with id TestAppId has registered with Spring Boot Admin", + List.of(new Fact("Status", "UNKNOWN"), new Fact("Service URL", SERVICE_URL), + new Fact("Health URL", HEALTH_URL), new Fact("Management URL", MANAGEMENT_URL))); } @Test @@ -166,8 +180,10 @@ void test_getStatusChangedMessageForAppReturns_correctContent() { Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofDown()), instance)); - assertMessage(message, notifier.getStatusChangedTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId changed status from UNKNOWN to DOWN", RED); + assertMessage(message, notifier.getStatusChangedTitle(), ATTENTION, + "Test App with id TestAppId changed status from UNKNOWN to DOWN", + List.of(new Fact("Status", "UNKNOWN"), new Fact("Service URL", SERVICE_URL), + new Fact("Health URL", HEALTH_URL), new Fact("Management URL", MANAGEMENT_URL))); } @Test @@ -177,8 +193,10 @@ void test_getStatusChangedMessageForAppReturns_UP_to_DOWN() { Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofDown()), instance)); - assertMessage(message, notifier.getStatusChangedTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId changed status from UP to DOWN", RED); + assertMessage(message, notifier.getStatusChangedTitle(), ATTENTION, + "Test App with id TestAppId changed status from UP to DOWN", + List.of(new Fact("Status", "UNKNOWN"), new Fact("Service URL", SERVICE_URL), + new Fact("Health URL", HEALTH_URL), new Fact("Management URL", MANAGEMENT_URL))); } @Test @@ -187,7 +205,8 @@ void test_getStatusChangedMessageWithExtraFormatArgumentReturns_activitySubtitle Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertThat(message.getSections().get(0).getActivitySubtitle()).isEqualTo("STATUS_ACTIVITY_PATTERN_" + APP_NAME); + assertThat(message.getAttachments().get(0).getContent().getBody().get(2).getText()) + .isEqualTo("STATUS_ACTIVITY_PATTERN_" + APP_NAME); } @Test @@ -196,7 +215,7 @@ void test_getRegisterMessageWithExtraFormatArgumentReturns_activitySubtitlePatte Message message = notifier.getRegisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertThat(message.getSections().get(0).getActivitySubtitle()) + assertThat(message.getAttachments().get(0).getContent().getBody().get(2).getText()) .isEqualTo("REGISTER_ACTIVITY_PATTERN_" + APP_NAME); } @@ -206,7 +225,7 @@ void test_getDeRegisterMessageWithExtraFormatArgumentReturns_activitySubtitlePat Message message = notifier.getDeregisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertThat(message.getSections().get(0).getActivitySubtitle()) + assertThat(message.getAttachments().get(0).getContent().getBody().get(2).getText()) .isEqualTo("DEREGISTER_ACTIVITY_PATTERN_" + APP_NAME); } @@ -218,36 +237,68 @@ void test_getStatusChangedMessage_parsesThemeColorFromSpelExpression() { Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofUp()), instance)); - assertThat(message.getThemeColor()).isEqualTo("green"); - } - - private void assertMessage(Message message, String expectedTitle, String expectedSummary, String expectedSubTitle, - String expectedColor) { - assertThat(message.getTitle()).isEqualTo(expectedTitle); - assertThat(message.getSummary()).isEqualTo(expectedSummary); - assertThat(message.getThemeColor()).isEqualTo(expectedColor); - - assertThat(message.getSections()).hasSize(1).anySatisfy((section) -> { - assertThat(section.getActivityTitle()).isEqualTo(instance.getRegistration().getName()); - assertThat(section.getActivitySubtitle()).isEqualTo(expectedSubTitle); - - assertThat(section.getFacts()).hasSize(5).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Status"); - assertThat(fact.value()).isEqualTo("UNKNOWN"); - }).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Service URL"); - assertThat(fact.value()).isEqualTo(SERVICE_URL); - }).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Health URL"); - assertThat(fact.value()).isEqualTo(HEALTH_URL); - }).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Management URL"); - assertThat(fact.value()).isEqualTo(MANAGEMENT_URL); - }).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Source"); - assertThat(fact.value()).isNull(); - }); - }); + assertThat(message.getAttachments().get(0).getContent().getBody().get(0).getColor()).isEqualTo("green"); + } + + private Message createTestMessage() { + List facts = List.of(new Fact("Status", "UP"), new Fact("Service URL", SERVICE_URL), + new Fact("Health URL", HEALTH_URL), new Fact("Management URL", MANAGEMENT_URL)); + + List body = List.of( + CardElement.builder() + .type("TextBlock") + .text("Test Title") + .size("Large") + .weight("Bolder") + .color("Good") + .build(), + CardElement.builder().type("TextBlock").text(APP_NAME).size("Medium").weight("Bolder").build(), + CardElement.builder().type("TextBlock").text("Test subtitle").wrap(true).build(), + CardElement.builder().type("FactSet").facts(facts).build()); + + var adaptiveCard = MicrosoftTeamsNotifier.AdaptiveCard.builder().body(body).build(); + + var attachment = Attachment.builder().content(adaptiveCard).build(); + + return Message.builder().attachments(List.of(attachment)).build(); + } + + private void assertMessage(Message msg, String expectedTitle, String expectedColor, String expectedActivitySubtitle, + List expectedFacts) { + assertThat(msg.getType()).isEqualTo("message"); + assertThat(msg.getAttachments()).hasSize(1); + + Attachment attachment = msg.getAttachments().get(0); + assertThat(attachment.getContentType()).isEqualTo("application/vnd.microsoft.card.adaptive"); + + var card = attachment.getContent(); + assertThat(card.getType()).isEqualTo("AdaptiveCard"); + assertThat(card.getVersion()).isEqualTo("1.2"); + + List body = card.getBody(); + assertThat(body).hasSize(4); + + // Title TextBlock + assertThat(body.get(0).getType()).isEqualTo("TextBlock"); + assertThat(body.get(0).getText()).isEqualTo(expectedTitle); + assertThat(body.get(0).getSize()).isEqualTo("Large"); + assertThat(body.get(0).getWeight()).isEqualTo("Bolder"); + assertThat(body.get(0).getColor()).isEqualTo(expectedColor); + + // Activity Title TextBlock + assertThat(body.get(1).getType()).isEqualTo("TextBlock"); + assertThat(body.get(1).getText()).isEqualTo(APP_NAME); + assertThat(body.get(1).getSize()).isEqualTo("Medium"); + assertThat(body.get(1).getWeight()).isEqualTo("Bolder"); + + // Activity Subtitle TextBlock + assertThat(body.get(2).getType()).isEqualTo("TextBlock"); + assertThat(body.get(2).getText()).isEqualTo(expectedActivitySubtitle); + assertThat(body.get(2).getWrap()).isTrue(); + + // FactSet + assertThat(body.get(3).getType()).isEqualTo("FactSet"); + assertThat(body.get(3).getFacts()).containsExactlyElementsOf(expectedFacts); } }