From e9c1abefe4d0fc70aa7576fdf3b2cf3ad2bd0d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 15 May 2026 15:18:02 +0200 Subject: [PATCH 1/5] fix: update Microsoft Teams message structure and theme color handling --- .../server/notify/MicrosoftTeamsNotifier.java | 113 ++++++--- .../notify/MicrosoftTeamsNotifierTest.java | 237 ++++++++++++++---- 2 files changed, 271 insertions(+), 79 deletions(-) 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..fdb58e3f2dc 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 @@ -62,7 +62,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"; @@ -197,24 +197,44 @@ protected Message getStatusChangedMessage(Instance instance, EvaluationContext c protected Message createMessage(Instance instance, String registeredTitle, String activitySubtitle, EvaluationContext context) { 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(); + String themeColorValue = evaluateExpression(context, themeColor); + + List cardBody = new ArrayList<>(); + + // Title + cardBody.add(CardElement.builder() + .type("TextBlock") + .text(registeredTitle) + .size("Large") + .weight("Bolder") + .color(themeColorValue) + .build()); + + // Service Name + cardBody.add(CardElement.builder() + .type("TextBlock") + .text(instance.getRegistration().getName()) + .size("Medium") + .weight("Bolder") + .build()); + + // Activity Subtitle + cardBody.add(CardElement.builder().type("TextBlock").text(activitySubtitle).wrap(true).build()); + + // Facts + cardBody.add(CardElement.builder().type("FactSet").facts(facts).build()); + + AdaptiveCard adaptiveCard = AdaptiveCard.builder().body(cardBody).build(); + + Attachment attachment = Attachment.builder().content(adaptiveCard).build(); + + return Message.builder().attachments(singletonList(attachment)).build(); } protected String evaluateExpression(EvaluationContext context, Expression expression) { @@ -232,6 +252,12 @@ protected EvaluationContext createEvaluationContext(InstanceEvent event, Instanc .build(); } + private void addFactIfNotNull(List facts, String title, @Nullable String value) { + if (value != null && !value.isBlank()) { + facts.add(new Fact(title, value)); + } + } + @Nullable public URI getWebhookUrl() { return webhookUrl; } @@ -278,31 +304,62 @@ public void setStatusActivitySubtitle(String statusActivitySubtitle) { @Builder public static class Message { - private final String summary; + private final String type = "message"; - private final String themeColor; + @Builder.Default + private final List attachments = new ArrayList<>(); - private final String title; + } - @Builder.Default - private final List
sections = new ArrayList<>(); + @Data + @Builder + public static class Attachment { + + private final String contentType = "application/vnd.microsoft.card.adaptive"; + + @Nullable private final String contentUrl = null; + + private final AdaptiveCard content; } @Data @Builder - public static class Section { + public static class AdaptiveCard { + + @Builder.Default + private final String schema = "http://adaptivecards.io/schemas/adaptive-card.json"; - private final String activityTitle; + private final String type = "AdaptiveCard"; - private final String activitySubtitle; + private final String version = "1.2"; @Builder.Default - private final List facts = new ArrayList<>(); + private final List body = new ArrayList<>(); + + } + + @Data + @Builder + public static class CardElement { + + private final String type; + + @Nullable private final String text; + + @Nullable private final String size; + + @Nullable private final String weight; + + @Nullable private final String color; + + @Nullable private final Boolean wrap; + + @Nullable private final 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..bae1cbb5e98 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 @@ -18,6 +18,7 @@ import java.net.URI; +import org.json.JSONObject; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -45,11 +46,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 +96,8 @@ 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(), + "Test App with id TestAppId has de-registered from Spring Boot Admin", ACCENT); } @Test @@ -111,8 +112,8 @@ 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(), + "Test App with id TestAppId has registered with Spring Boot Admin", ACCENT); } @Test @@ -127,8 +128,8 @@ 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(), + "Test App with id TestAppId changed status from UNKNOWN to UP", GOOD); } @Test @@ -148,8 +149,8 @@ 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(), + "Test App with id TestAppId has de-registered from Spring Boot Admin", ACCENT); } @Test @@ -157,8 +158,8 @@ 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(), + "Test App with id TestAppId has registered with Spring Boot Admin", ACCENT); } @Test @@ -166,8 +167,8 @@ 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(), + "Test App with id TestAppId changed status from UNKNOWN to DOWN", ATTENTION); } @Test @@ -177,8 +178,8 @@ 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(), + "Test App with id TestAppId changed status from UP to DOWN", ATTENTION); } @Test @@ -187,7 +188,7 @@ 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(getActivitySubtitleFromMessage(message)).isEqualTo("STATUS_ACTIVITY_PATTERN_" + APP_NAME); } @Test @@ -196,8 +197,7 @@ void test_getRegisterMessageWithExtraFormatArgumentReturns_activitySubtitlePatte Message message = notifier.getRegisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertThat(message.getSections().get(0).getActivitySubtitle()) - .isEqualTo("REGISTER_ACTIVITY_PATTERN_" + APP_NAME); + assertThat(getActivitySubtitleFromMessage(message)).isEqualTo("REGISTER_ACTIVITY_PATTERN_" + APP_NAME); } @Test @@ -206,8 +206,7 @@ void test_getDeRegisterMessageWithExtraFormatArgumentReturns_activitySubtitlePat Message message = notifier.getDeregisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertThat(message.getSections().get(0).getActivitySubtitle()) - .isEqualTo("DEREGISTER_ACTIVITY_PATTERN_" + APP_NAME); + assertThat(getActivitySubtitleFromMessage(message)).isEqualTo("DEREGISTER_ACTIVITY_PATTERN_" + APP_NAME); } @Test @@ -218,35 +217,171 @@ 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(getColorFromMessage(message)).isEqualTo("green"); + } + + @Test + void test_messageSerializesToExpectedJsonStructure() throws Exception { + // Update instance to UP status + Instance upInstance = Instance.create(instance.getId()) + .register(instance.getRegistration()) + .withStatusInfo(StatusInfo.ofUp()); + + Message message = notifier.getStatusChangedMessage(upInstance, notifier.createEvaluationContext( + new InstanceStatusChangedEvent(upInstance.getId(), 1L, StatusInfo.ofUp()), upInstance)); + + // Build expected JSON structure using JSONObject with actual values + JSONObject expectedJson = new JSONObject(""" + { + "type": "message", + "attachments": [{ + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": null, + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.2", + "body": [ + { + "type": "TextBlock", + "text": "Status Changed", + "size": "Large", + "weight": "Bolder", + "color": "Good" + }, + { + "type": "TextBlock", + "text": "Test App", + "size": "Medium", + "weight": "Bolder" + }, + { + "type": "TextBlock", + "text": "Test App with id TestAppId changed status from UNKNOWN to UP", + "wrap": true + }, + { + "type": "FactSet", + "facts": [ + {"title": "Status", "value": "UP"}, + {"title": "Service URL", "value": "https://service"}, + {"title": "Health URL", "value": "https://health"}, + {"title": "Management URL", "value": "https://management"} + ] + } + ] + } + }] + } + """); + + // Verify message structure matches expected format + assertThat(message.getType()).isEqualTo(expectedJson.getString("type")); + + assertThat(message.getAttachments()).hasSize(1); + var attachment = message.getAttachments().get(0); + assertThat(attachment.getContentType()) + .isEqualTo(expectedJson.getJSONArray("attachments").getJSONObject(0).getString("contentType")); + + var content = attachment.getContent(); + var expectedContent = expectedJson.getJSONArray("attachments").getJSONObject(0).getJSONObject("content"); + assertThat(content.getSchema()).isEqualTo(expectedContent.getString("$schema")); + assertThat(content.getType()).isEqualTo(expectedContent.getString("type")); + assertThat(content.getVersion()).isEqualTo(expectedContent.getString("version")); + + // Verify body structure and content + var body = content.getBody(); + var expectedBody = expectedContent.getJSONArray("body"); + assertThat(body).hasSize(expectedBody.length()); + + // Verify Title TextBlock + assertThat(body.get(0).getType()).isEqualTo("TextBlock"); + assertThat(body.get(0).getText()).isEqualTo("Status Changed"); + assertThat(body.get(0).getSize()).isEqualTo("Large"); + assertThat(body.get(0).getWeight()).isEqualTo("Bolder"); + assertThat(body.get(0).getColor()).isEqualTo("Good"); + + // Verify Service Name 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"); + + // Verify Activity Subtitle TextBlock + assertThat(body.get(2).getType()).isEqualTo("TextBlock"); + assertThat(body.get(2).getText()).isEqualTo("Test App with id TestAppId changed status from UNKNOWN to UP"); + assertThat(body.get(2).getWrap()).isTrue(); + + // Verify FactSet + assertThat(body.get(3).getType()).isEqualTo("FactSet"); + assertThat(body.get(3).getFacts()).hasSize(4); // Source is omitted because it's + // null + assertThat(body.get(3).getFacts().get(0).title()).isEqualTo("Status"); + assertThat(body.get(3).getFacts().get(0).value()).isEqualTo("UP"); + assertThat(body.get(3).getFacts().get(1).title()).isEqualTo("Service URL"); + assertThat(body.get(3).getFacts().get(1).value()).isEqualTo(SERVICE_URL); + assertThat(body.get(3).getFacts().get(2).title()).isEqualTo("Health URL"); + assertThat(body.get(3).getFacts().get(2).value()).isEqualTo(HEALTH_URL); + assertThat(body.get(3).getFacts().get(3).title()).isEqualTo("Management URL"); + assertThat(body.get(3).getFacts().get(3).value()).isEqualTo(MANAGEMENT_URL); + } + + private String getActivitySubtitleFromMessage(Message message) { + return message.getAttachments().get(0).getContent().getBody().get(2).getText(); + } + + private String getColorFromMessage(Message message) { + return message.getAttachments().get(0).getContent().getBody().get(0).getColor(); + } + + private void assertMessage(Message message, String expectedTitle, String expectedSubTitle, String expectedColor) { + assertThat(message.getType()).isEqualTo("message"); + assertThat(message.getAttachments()).hasSize(1); + + var attachment = message.getAttachments().get(0); + assertThat(attachment.getContentType()).isEqualTo("application/vnd.microsoft.card.adaptive"); + assertThat(attachment.getContentUrl()).isNull(); + + var card = attachment.getContent(); + assertThat(card.getType()).isEqualTo("AdaptiveCard"); + assertThat(card.getVersion()).isEqualTo("1.2"); + assertThat(card.getSchema()).isEqualTo("http://adaptivecards.io/schemas/adaptive-card.json"); + + var body = card.getBody(); + assertThat(body).hasSize(4); + + // Title + 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); + + // Service Name + assertThat(body.get(1).getType()).isEqualTo("TextBlock"); + assertThat(body.get(1).getText()).isEqualTo(instance.getRegistration().getName()); + assertThat(body.get(1).getSize()).isEqualTo("Medium"); + assertThat(body.get(1).getWeight()).isEqualTo("Bolder"); + + // Activity Subtitle + assertThat(body.get(2).getType()).isEqualTo("TextBlock"); + assertThat(body.get(2).getText()).isEqualTo(expectedSubTitle); + assertThat(body.get(2).getWrap()).isTrue(); + + // Facts + assertThat(body.get(3).getType()).isEqualTo("FactSet"); + assertThat(body.get(3).getFacts()).hasSize(4).anySatisfy((fact) -> { + assertThat(fact.title()).isEqualTo("Status"); + assertThat(fact.value()).isEqualTo("UNKNOWN"); + }).anySatisfy((fact) -> { + assertThat(fact.title()).isEqualTo("Service URL"); + assertThat(fact.value()).isEqualTo(SERVICE_URL); + }).anySatisfy((fact) -> { + assertThat(fact.title()).isEqualTo("Health URL"); + assertThat(fact.value()).isEqualTo(HEALTH_URL); + }).anySatisfy((fact) -> { + assertThat(fact.title()).isEqualTo("Management URL"); + assertThat(fact.value()).isEqualTo(MANAGEMENT_URL); }); } From 2e51ec01e5c703ee1b12caf2c4f202eba1fb9f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 22 May 2026 09:00:43 +0200 Subject: [PATCH 2/5] fix: update MicrosoftTeams notifier colors, JSON serialization, and improve event store capacity test --- .../server/notify/MicrosoftTeamsNotifier.java | 9 ++ .../notify/MicrosoftTeamsNotifierTest.java | 84 ++++--------------- 2 files changed, 26 insertions(+), 67 deletions(-) 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 fdb58e3f2dc..2eb11bb690f 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,8 @@ import java.util.Map; import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; import lombok.Data; import lombok.Getter; @@ -62,6 +64,8 @@ public class MicrosoftTeamsNotifier extends AbstractStatusChangeNotifier { private static final String SOURCE_KEY = "Source"; + // For color definitions see: + // https://adaptivecards.microsoft.com/?topic=TextBlock#color 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"; @@ -302,6 +306,7 @@ public void setStatusActivitySubtitle(String statusActivitySubtitle) { @Data @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) public static class Message { private final String type = "message"; @@ -313,6 +318,7 @@ public static class Message { @Data @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) public static class Attachment { private final String contentType = "application/vnd.microsoft.card.adaptive"; @@ -325,9 +331,11 @@ public static class Attachment { @Data @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) public static class AdaptiveCard { @Builder.Default + @JsonProperty("$schema") private final String schema = "http://adaptivecards.io/schemas/adaptive-card.json"; private final String type = "AdaptiveCard"; @@ -341,6 +349,7 @@ public static class AdaptiveCard { @Data @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) public static class CardElement { private final String type; 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 bae1cbb5e98..d819ffc0eb6 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 @@ -18,15 +18,17 @@ import java.net.URI; -import org.json.JSONObject; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; import org.springframework.http.HttpEntity; import org.springframework.http.MediaType; import org.springframework.web.client.RestTemplate; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import tools.jackson.databind.json.JsonMapper; import de.codecentric.boot.admin.server.domain.entities.Instance; import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; @@ -46,12 +48,6 @@ class MicrosoftTeamsNotifierTest { - private static final String ACCENT = "Accent"; - - private static final String ATTENTION = "Attention"; - - private static final String GOOD = "Good"; - private static final String APP_NAME = "Test App"; private static final String APP_ID = "TestAppId"; @@ -97,7 +93,7 @@ void test_onClientApplicationDeRegisteredEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); assertMessage(entity.getValue().getBody(), notifier.getDeRegisteredTitle(), - "Test App with id TestAppId has de-registered from Spring Boot Admin", ACCENT); + "Test App with id TestAppId has de-registered from Spring Boot Admin", "Accent"); } @Test @@ -113,7 +109,7 @@ void test_onApplicationRegisteredEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); assertMessage(entity.getValue().getBody(), notifier.getRegisteredTitle(), - "Test App with id TestAppId has registered with Spring Boot Admin", ACCENT); + "Test App with id TestAppId has registered with Spring Boot Admin", "Accent"); } @Test @@ -129,7 +125,7 @@ void test_onApplicationStatusChangedEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); assertMessage(entity.getValue().getBody(), notifier.getStatusChangedTitle(), - "Test App with id TestAppId changed status from UNKNOWN to UP", GOOD); + "Test App with id TestAppId changed status from UNKNOWN to UP", "Good"); } @Test @@ -150,7 +146,7 @@ void test_getDeregisteredMessageForAppReturns_correctContent() { notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); assertMessage(message, notifier.getDeRegisteredTitle(), - "Test App with id TestAppId has de-registered from Spring Boot Admin", ACCENT); + "Test App with id TestAppId has de-registered from Spring Boot Admin", "Accent"); } @Test @@ -159,7 +155,7 @@ void test_getRegisteredMessageForAppReturns_correctContent() { notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); assertMessage(message, notifier.getRegisteredTitle(), - "Test App with id TestAppId has registered with Spring Boot Admin", ACCENT); + "Test App with id TestAppId has registered with Spring Boot Admin", "Accent"); } @Test @@ -168,7 +164,7 @@ void test_getStatusChangedMessageForAppReturns_correctContent() { new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofDown()), instance)); assertMessage(message, notifier.getStatusChangedTitle(), - "Test App with id TestAppId changed status from UNKNOWN to DOWN", ATTENTION); + "Test App with id TestAppId changed status from UNKNOWN to DOWN", "Attention"); } @Test @@ -179,7 +175,7 @@ void test_getStatusChangedMessageForAppReturns_UP_to_DOWN() { new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofDown()), instance)); assertMessage(message, notifier.getStatusChangedTitle(), - "Test App with id TestAppId changed status from UP to DOWN", ATTENTION); + "Test App with id TestAppId changed status from UP to DOWN", "Attention"); } @Test @@ -230,13 +226,15 @@ void test_messageSerializesToExpectedJsonStructure() throws Exception { Message message = notifier.getStatusChangedMessage(upInstance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(upInstance.getId(), 1L, StatusInfo.ofUp()), upInstance)); - // Build expected JSON structure using JSONObject with actual values - JSONObject expectedJson = new JSONObject(""" + JsonMapper mapper = JsonMapper.builder().build(); + String actual = mapper.writeValueAsString(message); + + // Build expected JSON structure + String expectedJson = """ { "type": "message", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", - "contentUrl": null, "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", @@ -273,57 +271,9 @@ void test_messageSerializesToExpectedJsonStructure() throws Exception { } }] } - """); - - // Verify message structure matches expected format - assertThat(message.getType()).isEqualTo(expectedJson.getString("type")); - - assertThat(message.getAttachments()).hasSize(1); - var attachment = message.getAttachments().get(0); - assertThat(attachment.getContentType()) - .isEqualTo(expectedJson.getJSONArray("attachments").getJSONObject(0).getString("contentType")); - - var content = attachment.getContent(); - var expectedContent = expectedJson.getJSONArray("attachments").getJSONObject(0).getJSONObject("content"); - assertThat(content.getSchema()).isEqualTo(expectedContent.getString("$schema")); - assertThat(content.getType()).isEqualTo(expectedContent.getString("type")); - assertThat(content.getVersion()).isEqualTo(expectedContent.getString("version")); - - // Verify body structure and content - var body = content.getBody(); - var expectedBody = expectedContent.getJSONArray("body"); - assertThat(body).hasSize(expectedBody.length()); - - // Verify Title TextBlock - assertThat(body.get(0).getType()).isEqualTo("TextBlock"); - assertThat(body.get(0).getText()).isEqualTo("Status Changed"); - assertThat(body.get(0).getSize()).isEqualTo("Large"); - assertThat(body.get(0).getWeight()).isEqualTo("Bolder"); - assertThat(body.get(0).getColor()).isEqualTo("Good"); + """; - // Verify Service Name 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"); - - // Verify Activity Subtitle TextBlock - assertThat(body.get(2).getType()).isEqualTo("TextBlock"); - assertThat(body.get(2).getText()).isEqualTo("Test App with id TestAppId changed status from UNKNOWN to UP"); - assertThat(body.get(2).getWrap()).isTrue(); - - // Verify FactSet - assertThat(body.get(3).getType()).isEqualTo("FactSet"); - assertThat(body.get(3).getFacts()).hasSize(4); // Source is omitted because it's - // null - assertThat(body.get(3).getFacts().get(0).title()).isEqualTo("Status"); - assertThat(body.get(3).getFacts().get(0).value()).isEqualTo("UP"); - assertThat(body.get(3).getFacts().get(1).title()).isEqualTo("Service URL"); - assertThat(body.get(3).getFacts().get(1).value()).isEqualTo(SERVICE_URL); - assertThat(body.get(3).getFacts().get(2).title()).isEqualTo("Health URL"); - assertThat(body.get(3).getFacts().get(2).value()).isEqualTo(HEALTH_URL); - assertThat(body.get(3).getFacts().get(3).title()).isEqualTo("Management URL"); - assertThat(body.get(3).getFacts().get(3).value()).isEqualTo(MANAGEMENT_URL); + JSONAssert.assertEquals(expectedJson, actual, JSONCompareMode.NON_EXTENSIBLE); } private String getActivitySubtitleFromMessage(Message message) { From a08aec5b7e4c3dd9f042cfd545507460becff395 Mon Sep 17 00:00:00 2001 From: Timo Date: Fri, 29 May 2026 11:05:10 +0200 Subject: [PATCH 3/5] refactor: rename MicrosoftTeamsNotifier property names Rename properties so their names reflect what they actually produce in the Adaptive Card. --- .../server/notify/MicrosoftTeamsNotifier.java | 107 ++++++++---------- .../notify/MicrosoftTeamsNotifierTest.java | 22 ++-- 2 files changed, 60 insertions(+), 69 deletions(-) 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 2eb11bb690f..5c24610bd72 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 @@ -64,15 +64,13 @@ public class MicrosoftTeamsNotifier extends AbstractStatusChangeNotifier { private static final String SOURCE_KEY = "Source"; - // For color definitions see: - // https://adaptivecards.microsoft.com/?topic=TextBlock#color - private static final String DEFAULT_THEME_COLOR_EXPRESSION = "#{event.type == 'STATUS_CHANGED' ? (event.statusInfo.status=='UP' ? 'Good' : 'Attention') : 'Accent'}"; + private static final String DEFAULT_TITLE_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"; + private static final String DEFAULT_DEREGISTER_TEXT_EXPRESSION = "#{instance.registration.name} with id #{instance.id} has de-registered from Spring Boot Admin"; - private static final String DEFAULT_REGISTER_ACTIVITY_SUBTITLE_EXPRESSION = "#{instance.registration.name} with id #{instance.id} has registered with Spring Boot Admin"; + private static final String DEFAULT_REGISTER_TEXT_EXPRESSION = "#{instance.registration.name} with id #{instance.id} has registered with Spring Boot Admin"; - private static final String DEFAULT_STATUS_ACTIVITY_SUBTITLE_EXPRESSION = "#{instance.registration.name} with id #{instance.id} changed status from #{lastStatus} to #{event.statusInfo.status}"; + private static final String DEFAULT_STATUS_CHANGED_TEXT_EXPRESSION = "#{instance.registration.name} with id #{instance.id} changed status from #{lastStatus} to #{event.statusInfo.status}"; private final SpelExpressionParser parser = new SpelExpressionParser(); @@ -86,35 +84,33 @@ public class MicrosoftTeamsNotifier extends AbstractStatusChangeNotifier { @Nullable private URI webhookUrl; /** - * Theme Color is the color of the accent on the message that appears in Microsoft - * Teams. Default is Spring Green + * Expression for the color of the message title, see + * supported + * colors */ - private Expression themeColor; + private Expression titleColorExpression; /** - * Message will be used as title of the Activity section of the Teams message when an - * app de-registers. + * Expression for the text that will be displayed when an app deregisters. */ - private Expression deregisterActivitySubtitle; + private Expression deregisteredTextExpression; /** - * Message will be used as title of the Activity section of the Teams message when an - * app registers + * Expression for the text that will be displayed when an app registers */ - private Expression registerActivitySubtitle; + private Expression registeredTextExpression; /** - * Message will be used as title of the Activity section of the Teams message when an - * app changes status + * Expression for the text that will be displayed when an app changes status */ - private Expression statusActivitySubtitle; + private Expression statusChangedTextExpression; /** - * Title of the Teams message when an app de-registers + * Title of the Teams message when an app deregisters */ @Setter @Getter - private String deRegisteredTitle = "De-Registered"; + private String deregisteredTitle = "Deregistered"; /** * Title of the Teams message when an app registers @@ -130,22 +126,16 @@ 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; - this.themeColor = parser.parseExpression(DEFAULT_THEME_COLOR_EXPRESSION, ParserContext.TEMPLATE_EXPRESSION); - this.deregisterActivitySubtitle = parser.parseExpression(DEFAULT_DEREGISTER_ACTIVITY_SUBTITLE_EXPRESSION, + this.titleColorExpression = parser.parseExpression(DEFAULT_TITLE_COLOR_EXPRESSION, + ParserContext.TEMPLATE_EXPRESSION); + this.deregisteredTextExpression = parser.parseExpression(DEFAULT_DEREGISTER_TEXT_EXPRESSION, ParserContext.TEMPLATE_EXPRESSION); - this.registerActivitySubtitle = parser.parseExpression(DEFAULT_REGISTER_ACTIVITY_SUBTITLE_EXPRESSION, + this.registeredTextExpression = parser.parseExpression(DEFAULT_REGISTER_TEXT_EXPRESSION, ParserContext.TEMPLATE_EXPRESSION); - this.statusActivitySubtitle = parser.parseExpression(DEFAULT_STATUS_ACTIVITY_SUBTITLE_EXPRESSION, + this.statusChangedTextExpression = parser.parseExpression(DEFAULT_STATUS_CHANGED_TEXT_EXPRESSION, ParserContext.TEMPLATE_EXPRESSION); } @@ -160,7 +150,7 @@ else if (event instanceof InstanceDeregisteredEvent) { message = getDeregisteredMessage(instance, context); } else if (event instanceof InstanceStatusChangedEvent) { - message = getStatusChangedMessage(instance, context); + message = getStatusChangedTextExpression(instance, context); } else { return Mono.empty(); @@ -184,18 +174,18 @@ protected boolean shouldNotify(InstanceEvent event, Instance instance) { } protected Message getDeregisteredMessage(Instance instance, EvaluationContext context) { - String activitySubtitle = evaluateExpression(context, deregisterActivitySubtitle); - return createMessage(instance, deRegisteredTitle, activitySubtitle, context); + String textValue = evaluateExpression(context, deregisteredTextExpression); + return createMessage(instance, deregisteredTitle, textValue, context); } protected Message getRegisteredMessage(Instance instance, EvaluationContext context) { - String activitySubtitle = evaluateExpression(context, registerActivitySubtitle); - return createMessage(instance, registeredTitle, activitySubtitle, context); + String textValue = evaluateExpression(context, registeredTextExpression); + return createMessage(instance, registeredTitle, textValue, context); } - protected Message getStatusChangedMessage(Instance instance, EvaluationContext context) { - String activitySubtitle = evaluateExpression(context, statusActivitySubtitle); - return createMessage(instance, statusChangedTitle, activitySubtitle, context); + protected Message getStatusChangedTextExpression(Instance instance, EvaluationContext context) { + String textValue = evaluateExpression(context, statusChangedTextExpression); + return createMessage(instance, statusChangedTitle, textValue, context); } protected Message createMessage(Instance instance, String registeredTitle, String activitySubtitle, @@ -207,7 +197,7 @@ protected Message createMessage(Instance instance, String registeredTitle, Strin addFactIfNotNull(facts, MANAGEMENT_URL_KEY, instance.getRegistration().getManagementUrl()); addFactIfNotNull(facts, SOURCE_KEY, instance.getRegistration().getSource()); - String themeColorValue = evaluateExpression(context, themeColor); + String titleColorValue = evaluateExpression(context, titleColorExpression); List cardBody = new ArrayList<>(); @@ -217,7 +207,7 @@ protected Message createMessage(Instance instance, String registeredTitle, Strin .text(registeredTitle) .size("Large") .weight("Bolder") - .color(themeColorValue) + .color(titleColorValue) .build()); // Service Name @@ -228,7 +218,7 @@ protected Message createMessage(Instance instance, String registeredTitle, Strin .weight("Bolder") .build()); - // Activity Subtitle + // Text cardBody.add(CardElement.builder().type("TextBlock").text(activitySubtitle).wrap(true).build()); // Facts @@ -270,38 +260,39 @@ public void setWebhookUrl(@Nullable URI webhookUrl) { this.webhookUrl = webhookUrl; } - public String getThemeColor() { - return themeColor.getExpressionString(); + public String getTitleColorExpression() { + return titleColorExpression.getExpressionString(); } - public void setThemeColor(String themeColor) { - this.themeColor = parser.parseExpression(themeColor, ParserContext.TEMPLATE_EXPRESSION); + public void setTitleColorExpression(String titleColorExpression) { + this.titleColorExpression = parser.parseExpression(titleColorExpression, ParserContext.TEMPLATE_EXPRESSION); } - public String getDeregisterActivitySubtitle() { - return deregisterActivitySubtitle.getExpressionString(); + public String getDeregisteredTextExpression() { + return deregisteredTextExpression.getExpressionString(); } - public void setDeregisterActivitySubtitle(String deregisterActivitySubtitle) { - this.deregisterActivitySubtitle = parser.parseExpression(deregisterActivitySubtitle, + public void setDeregisteredTextExpression(String deregisteredTextExpression) { + this.deregisteredTextExpression = parser.parseExpression(deregisteredTextExpression, ParserContext.TEMPLATE_EXPRESSION); } - public String getRegisterActivitySubtitle() { - return registerActivitySubtitle.getExpressionString(); + public String getRegisteredTextExpression() { + return registeredTextExpression.getExpressionString(); } - public void setRegisterActivitySubtitle(String registerActivitySubtitle) { - this.registerActivitySubtitle = parser.parseExpression(registerActivitySubtitle, + public void setRegisteredTextExpression(String registeredTextExpression) { + this.registeredTextExpression = parser.parseExpression(registeredTextExpression, ParserContext.TEMPLATE_EXPRESSION); } - public String getStatusActivitySubtitle() { - return statusActivitySubtitle.getExpressionString(); + public String getStatusChangedTextExpression() { + return statusChangedTextExpression.getExpressionString(); } - public void setStatusActivitySubtitle(String statusActivitySubtitle) { - this.statusActivitySubtitle = parser.parseExpression(statusActivitySubtitle, ParserContext.TEMPLATE_EXPRESSION); + public void setStatusChangedTextExpression(String statusChangedTextExpression) { + this.statusChangedTextExpression = parser.parseExpression(statusChangedTextExpression, + ParserContext.TEMPLATE_EXPRESSION); } @Data 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 d819ffc0eb6..00c4159d46d 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 @@ -92,7 +92,7 @@ void test_onClientApplicationDeRegisteredEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); - assertMessage(entity.getValue().getBody(), notifier.getDeRegisteredTitle(), + assertMessage(entity.getValue().getBody(), notifier.getDeregisteredTitle(), "Test App with id TestAppId has de-registered from Spring Boot Admin", "Accent"); } @@ -145,7 +145,7 @@ void test_getDeregisteredMessageForAppReturns_correctContent() { Message message = notifier.getDeregisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertMessage(message, notifier.getDeRegisteredTitle(), + assertMessage(message, notifier.getDeregisteredTitle(), "Test App with id TestAppId has de-registered from Spring Boot Admin", "Accent"); } @@ -160,7 +160,7 @@ void test_getRegisteredMessageForAppReturns_correctContent() { @Test void test_getStatusChangedMessageForAppReturns_correctContent() { - Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext( + Message message = notifier.getStatusChangedTextExpression(instance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofDown()), instance)); assertMessage(message, notifier.getStatusChangedTitle(), @@ -171,7 +171,7 @@ void test_getStatusChangedMessageForAppReturns_correctContent() { void test_getStatusChangedMessageForAppReturns_UP_to_DOWN() { notifier.updateLastStatus(new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofUp())); - Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext( + Message message = notifier.getStatusChangedTextExpression(instance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofDown()), instance)); assertMessage(message, notifier.getStatusChangedTitle(), @@ -180,8 +180,8 @@ void test_getStatusChangedMessageForAppReturns_UP_to_DOWN() { @Test void test_getStatusChangedMessageWithExtraFormatArgumentReturns_activitySubtitlePatternWithAppName() { - notifier.setStatusActivitySubtitle("STATUS_ACTIVITY_PATTERN_#{instance.registration.name}"); - Message message = notifier.getStatusChangedMessage(instance, + notifier.setStatusChangedTextExpression("STATUS_ACTIVITY_PATTERN_#{instance.registration.name}"); + Message message = notifier.getStatusChangedTextExpression(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); assertThat(getActivitySubtitleFromMessage(message)).isEqualTo("STATUS_ACTIVITY_PATTERN_" + APP_NAME); @@ -189,7 +189,7 @@ void test_getStatusChangedMessageWithExtraFormatArgumentReturns_activitySubtitle @Test void test_getRegisterMessageWithExtraFormatArgumentReturns_activitySubtitlePatternWithAppName() { - notifier.setRegisterActivitySubtitle("REGISTER_ACTIVITY_PATTERN_#{instance.registration.name}"); + notifier.setRegisteredTextExpression("REGISTER_ACTIVITY_PATTERN_#{instance.registration.name}"); Message message = notifier.getRegisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); @@ -198,7 +198,7 @@ void test_getRegisterMessageWithExtraFormatArgumentReturns_activitySubtitlePatte @Test void test_getDeRegisterMessageWithExtraFormatArgumentReturns_activitySubtitlePatternWithAppName() { - notifier.setDeregisterActivitySubtitle("DEREGISTER_ACTIVITY_PATTERN_#{instance.registration.name}"); + notifier.setDeregisteredTextExpression("DEREGISTER_ACTIVITY_PATTERN_#{instance.registration.name}"); Message message = notifier.getDeregisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); @@ -207,10 +207,10 @@ void test_getDeRegisterMessageWithExtraFormatArgumentReturns_activitySubtitlePat @Test void test_getStatusChangedMessage_parsesThemeColorFromSpelExpression() { - notifier.setThemeColor( + notifier.setTitleColorExpression( "#{event.type == 'STATUS_CHANGED' ? (event.statusInfo.status=='UP' ? 'green' : 'red') : 'blue'}"); - Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext( + Message message = notifier.getStatusChangedTextExpression(instance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofUp()), instance)); assertThat(getColorFromMessage(message)).isEqualTo("green"); @@ -223,7 +223,7 @@ void test_messageSerializesToExpectedJsonStructure() throws Exception { .register(instance.getRegistration()) .withStatusInfo(StatusInfo.ofUp()); - Message message = notifier.getStatusChangedMessage(upInstance, notifier.createEvaluationContext( + Message message = notifier.getStatusChangedTextExpression(upInstance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(upInstance.getId(), 1L, StatusInfo.ofUp()), upInstance)); JsonMapper mapper = JsonMapper.builder().build(); From 1cfb15b35e4955dddfbd81acb87ca7b9d9e57b4a Mon Sep 17 00:00:00 2001 From: Timo Date: Fri, 29 May 2026 11:05:57 +0200 Subject: [PATCH 4/5] docs: describe MicrosoftTeams notifier message format and SpEL context Add a sample screenshot, document the Adaptive Card body layout, and list the SpEL root variables (event, instance, lastStatus) available to the *Expression properties. --- .../notifications/msteams-notification.png | Bin 0 -> 64050 bytes .../notifications/notifier-msteams.mdx | 21 +++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 spring-boot-admin-docs/src/site/docs/02-server/notifications/msteams-notification.png diff --git a/spring-boot-admin-docs/src/site/docs/02-server/notifications/msteams-notification.png b/spring-boot-admin-docs/src/site/docs/02-server/notifications/msteams-notification.png new file mode 100644 index 0000000000000000000000000000000000000000..8baed7da8caff71588dd4efd3bb2225377465cac GIT binary patch literal 64050 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

T~V2yU06#o?7p~&Spn`4j|*PvQ?vE^EbfNMD*(t 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 + ![msteams-notification.png](msteams-notification.png) +

Sample Microsoft Teams notification for a `STATUS_CHANGED` event
+ + +## Message format + +Notifications are sent as [Adaptive Cards](https://adaptivecards.microsoft.com/). The card body is composed of the title, the instance name, the text and a `FactSet` containing the instance's status and URLs. + +## SpEL expression context + +The expression-based properties are parsed as SpEL template expressions (`#{ ... }`). The following root variables are available: + +| Variable | Type | Description | +| --- | --- | --- | +| `event` | [`InstanceEvent`](https://github.com/codecentric/spring-boot-admin/blob/master/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/domain/events/InstanceEvent.java) | The event that triggered the notification. Frequently used property: `type` (one of `REGISTERED`, `DEREGISTERED`, `STATUS_CHANGED`, `REGISTRATION_UPDATED`, `INFO_CHANGED`, `ENDPOINTS_DETECTED`). For `STATUS_CHANGED` events `event.statusInfo.status` holds the new status. | +| `instance` | [`Instance`](https://github.com/codecentric/spring-boot-admin/blob/master/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/domain/entities/Instance.java) | The full instance aggregate. Frequently used paths: `instance.id` (e.g. "TestAppId"), `instance.registration.name` (e.g. "Test App"), `instance.statusInfo.status` (one of `UP`, `DOWN`, `OFFLINE`, `RESTRICTED`, `OUT_OF_SERVICE`, `UNKNOWN`). | +| `lastStatus` | `String` | The previous status code of the instance (e.g. `UP`, `DOWN`, `UNKNOWN`), useful for building messages like `from #{lastStatus} to #{event.statusInfo.status}`. | Date: Mon, 1 Jun 2026 08:29:38 +0200 Subject: [PATCH 5/5] fix: change webhookUrl type from URI to String in MicrosoftTeamsNotifier to prevent accidental double escaping of url entities --- .../admin/server/notify/MicrosoftTeamsNotifier.java | 11 ++++++----- .../server/notify/MicrosoftTeamsNotifierTest.java | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) 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 5c24610bd72..ec914e09bee 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 @@ -81,7 +81,7 @@ public class MicrosoftTeamsNotifier extends AbstractStatusChangeNotifier { * Webhook url for Microsoft Teams Channel Webhook connector (i.e. *
...{webhook-id}) */ - @Nullable private URI webhookUrl; + @Nullable private String webhookUrl; /** * Expression for the color of the message title, see @@ -163,8 +163,9 @@ else if (event instanceof InstanceStatusChangedEvent) { return Mono.error(new IllegalStateException("'webhookUrl' must not be null.")); } - return Mono.fromRunnable(() -> this.restTemplate.postForEntity(webhookUrl, - new HttpEntity(message, headers), Void.class)); + URI uri = URI.create(webhookUrl); + return Mono.fromRunnable( + () -> this.restTemplate.postForEntity(uri, new HttpEntity(message, headers), Void.class)); } @Override @@ -252,11 +253,11 @@ private void addFactIfNotNull(List facts, String title, @Nullable String v } } - @Nullable public URI getWebhookUrl() { + @Nullable public String getWebhookUrl() { return webhookUrl; } - public void setWebhookUrl(@Nullable URI webhookUrl) { + public void setWebhookUrl(@Nullable String webhookUrl) { this.webhookUrl = webhookUrl; } 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 00c4159d46d..9010fdd8e9f 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 @@ -77,7 +77,7 @@ void setUp() { mockRestTemplate = mock(RestTemplate.class); notifier = new MicrosoftTeamsNotifier(repository, mockRestTemplate); - notifier.setWebhookUrl(URI.create("https://example.com")); + notifier.setWebhookUrl("https://example.com"); } @Test