From 353972695cde4fc4605acbeb388e2de2d58c8c93 Mon Sep 17 00:00:00 2001 From: Cosimo Damiano Prete Date: Wed, 17 Jun 2026 11:00:35 +0200 Subject: [PATCH 1/5] fix(#5450): Trigger status update events not only when the status itself changes but also when the details change so that the UI displays the correct, most recent, information --- .../server/domain/entities/Instance.java | 2 +- .../server/services/StatusUpdaterTest.java | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/domain/entities/Instance.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/domain/entities/Instance.java index 3256087ead2..9e29c873a7d 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/domain/entities/Instance.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/domain/entities/Instance.java @@ -141,7 +141,7 @@ public Instance withInfo(Info info) { public Instance withStatusInfo(StatusInfo statusInfo) { Assert.notNull(statusInfo, "'statusInfo' must not be null"); - if (Objects.equals(this.statusInfo.getStatus(), statusInfo.getStatus())) { + if (Objects.equals(this.statusInfo, statusInfo)) { return this; } return this.apply(new InstanceStatusChangedEvent(this.id, this.nextVersion(), statusInfo), true); diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/StatusUpdaterTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/StatusUpdaterTest.java index 2f74fe8695d..938f3318737 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/StatusUpdaterTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/StatusUpdaterTest.java @@ -17,6 +17,7 @@ package de.codecentric.boot.admin.server.services; import java.time.Duration; +import java.util.Map; import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.core.Options; @@ -54,6 +55,7 @@ import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.timeout; import static java.util.Collections.emptyMap; import static java.util.Collections.singletonMap; +import static java.util.stream.Collectors.joining; import static org.assertj.core.api.Assertions.assertThat; class StatusUpdaterTest { @@ -265,4 +267,41 @@ void should_retry() { .verifyComplete(); } + @Test + void should_update_status_details() { + // 1st pass -> initial details + shouldUpdateStatusDetails(singletonMap("foo", "bar")); + + // 2nd pass -> details changed + shouldUpdateStatusDetails(singletonMap("foo", "baz")); + } + + private void shouldUpdateStatusDetails(Map details) { + String body = "{ \"status\" : \"UP\", \"details\" : %s }".formatted(details.entrySet() + .stream() + .map((e) -> "\"%s\" : \"%s\"".formatted(e.getKey(), e.getValue())) + .collect(joining(", ", "{ ", " }"))); + this.wireMock.stubFor( + get("/health").willReturn(okForContentType(ApiVersion.LATEST.getProducedMimeType().toString(), body) + .withHeader("Content-Length", Integer.toString(body.length())))); + + StepVerifier.create(this.eventStore) + .expectSubscription() + .then(() -> StepVerifier.create(this.updater.updateStatus(this.instance.getId())).verifyComplete()) + .assertNext((event) -> { + assertThat(event).isInstanceOf(InstanceStatusChangedEvent.class); + assertThat(event.getInstance()).isEqualTo(this.instance.getId()); + InstanceStatusChangedEvent statusChangedEvent = (InstanceStatusChangedEvent) event; + assertThat(statusChangedEvent.getStatusInfo().getStatus()).isEqualTo("UP"); + assertThat(statusChangedEvent.getStatusInfo().getDetails()).isEqualTo(details); + }) + .thenCancel() + .verify(); + + StepVerifier.create(this.repository.find(this.instance.getId())).assertNext((app) -> { + assertThat(app.getStatusInfo().getStatus()).isEqualTo("UP"); + assertThat(app.getStatusInfo().getDetails()).isEqualTo(details); + }).verifyComplete(); + } + } From 4bbf4e28ce3ab732f635db90d1f7d0721c2ad36b Mon Sep 17 00:00:00 2001 From: Cosimo Damiano Prete Date: Wed, 17 Jun 2026 11:35:17 +0200 Subject: [PATCH 2/5] fix(#5450): Add simple 'echo' health indicator and related resources to easily test changes in the health directly from the browser --- .../admin/sample/echo/EchoConfiguration.java | 39 +++++++++++ .../admin/sample/echo/EchoController.java | 64 +++++++++++++++++++ .../boot/admin/sample/echo/EchoEntity.java | 33 ++++++++++ .../sample/echo/EchoHealthIndicator.java | 49 ++++++++++++++ .../admin/sample/echo/EchoRepository.java | 42 ++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoConfiguration.java create mode 100644 spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoController.java create mode 100644 spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoEntity.java create mode 100644 spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoHealthIndicator.java create mode 100644 spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoRepository.java diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoConfiguration.java b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoConfiguration.java new file mode 100644 index 00000000000..2dc386d48db --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoConfiguration.java @@ -0,0 +1,39 @@ +/* + * Copyright 2014-2023 the original author or 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 + * + * https://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 de.codecentric.boot.admin.sample.echo; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Cosimo Damiano Prete + * @since 17/06/2026 + **/ +@Configuration(proxyBeanMethods = false) +public class EchoConfiguration { + + @Bean + public EchoRepository echoRepository() { + return new EchoRepository(); + } + + @Bean + public EchoHealthIndicator echoHealthIndicator(EchoRepository echoRepository) { + return new EchoHealthIndicator(echoRepository); + } + +} diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoController.java b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoController.java new file mode 100644 index 00000000000..189901cee52 --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoController.java @@ -0,0 +1,64 @@ +/* + * Copyright 2014-2023 the original author or 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 + * + * https://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 de.codecentric.boot.admin.sample.echo; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.util.StringUtils.hasText; + +/** + * This controller exposes REST resources under '/echo'. + * + * @author Cosimo Damiano Prete + * @since 17/06/2026 + **/ +@RestController +@RequestMapping("/echo") +public class EchoController { + + private final EchoRepository repository; + + public EchoController(EchoRepository repository) { + this.repository = repository; + } + + /** + * Allows to get the latest recorded echo value or to set a new one and return it if a + * value for {@code status} or {@code details} is provided. + *

+ * While this endpoint breaks quite some principles (SRP, invalid REST resource and so + * on), it has been designed in this way so that it can be called by just typing it in + * the browser address bar without the need of any other additional or external tools + * (e.g.: Postman, cURL and so on). + * @param status the new status to set. For example: UP, DOWN, OUT_OF_SERVICE, + * UNKNOWN. + * @param details the new details to set. For example: "Database is down", "Disk space + * is low" and so on. + * @return the latest recorded echo value or the new one if a value for {@code status} + * or {@code details} is provided. + */ + @GetMapping(produces = APPLICATION_JSON_VALUE) + public EchoEntity echo(@RequestParam(required = false) String status, + @RequestParam(required = false) String details) { + return (hasText(status) || hasText(details)) ? repository.save(status, details) : repository.get(); + } + +} diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoEntity.java b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoEntity.java new file mode 100644 index 00000000000..f658c2a6458 --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoEntity.java @@ -0,0 +1,33 @@ +/* + * Copyright 2014-2023 the original author or 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 + * + * https://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 de.codecentric.boot.admin.sample.echo; + +import org.jspecify.annotations.NonNull; + +/** + * @author Cosimo Damiano Prete + * @since 17/06/2026 + **/ +public record EchoEntity(@NonNull String status, String details) { + public EchoEntity(@NonNull String status) { + this(status, null); + } + + public EchoEntity withDetails(String details) { + return new EchoEntity(status, details); + } +} diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoHealthIndicator.java b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoHealthIndicator.java new file mode 100644 index 00000000000..be47878651f --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoHealthIndicator.java @@ -0,0 +1,49 @@ +/* + * Copyright 2014-2023 the original author or 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 + * + * https://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 de.codecentric.boot.admin.sample.echo; + +import org.jspecify.annotations.Nullable; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; + +import static org.springframework.util.StringUtils.hasText; + +/** + * @author Cosimo Damiano Prete + * @since 17/06/2026 + **/ +public class EchoHealthIndicator implements HealthIndicator { + + private final EchoRepository repository; + + public EchoHealthIndicator(EchoRepository repository) { + this.repository = repository; + } + + @Override + public @Nullable Health health() { + EchoEntity entity = repository.get(); + + Health.Builder builder = new Health.Builder().status(entity.status()); + if (hasText(entity.details())) { + builder.withDetail("details", entity.details()); + } + + return builder.build(); + } + +} diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoRepository.java b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoRepository.java new file mode 100644 index 00000000000..b7c2b9fb51c --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/echo/EchoRepository.java @@ -0,0 +1,42 @@ +/* + * Copyright 2014-2023 the original author or 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 + * + * https://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 de.codecentric.boot.admin.sample.echo; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.springframework.util.StringUtils.hasText; + +/** + * @author Cosimo Damiano Prete + * @since 17/06/2026 + **/ +public class EchoRepository { + + private static final EchoEntity DEFAULT = new EchoEntity("UP"); + + private final AtomicReference value = new AtomicReference<>(DEFAULT); + + public EchoEntity save(String status, String details) { + return value.updateAndGet((e) -> hasText(status) ? new EchoEntity(status.strip().toUpperCase(), details) + : e.withDetails(details)); + } + + public EchoEntity get() { + return value.get(); + } + +} From e7f5a3ddcf735ec8cafb56c1eff2e9f37cbef643 Mon Sep 17 00:00:00 2001 From: Cosimo Damiano Prete Date: Wed, 17 Jun 2026 18:46:04 +0200 Subject: [PATCH 3/5] fix(#5450): Make AbstractStatusChangeNotifier ignore status update events when only their details have changed but not their status --- .../boot/admin/server/notify/AbstractStatusChangeNotifier.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/AbstractStatusChangeNotifier.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/AbstractStatusChangeNotifier.java index 34ee9beb011..27ed1305e90 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/AbstractStatusChangeNotifier.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/AbstractStatusChangeNotifier.java @@ -58,7 +58,7 @@ protected boolean shouldNotify(InstanceEvent event, Instance instance) { if (event instanceof InstanceStatusChangedEvent statusChangedEvent) { String from = getLastStatus(event.getInstance()); String to = statusChangedEvent.getStatusInfo().getStatus(); - return Arrays.binarySearch(ignoreChanges, from + ":" + to) < 0 + return !from.equals(to) && Arrays.binarySearch(ignoreChanges, from + ":" + to) < 0 && Arrays.binarySearch(ignoreChanges, "*:" + to) < 0 && Arrays.binarySearch(ignoreChanges, from + ":*") < 0; } From 3ae635650ac5cdb8c4acaeb0a53f4a07c195366a Mon Sep 17 00:00:00 2001 From: Cosimo Damiano Prete Date: Wed, 17 Jun 2026 19:59:11 +0200 Subject: [PATCH 4/5] fix(#5450): Fix InstancesControllerIntegrationTest#should_return_registered_instances by correctly exposing the Actuator health endpoint for the instances --- .../admin/server/web/InstancesControllerIntegrationTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/InstancesControllerIntegrationTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/InstancesControllerIntegrationTest.java index 664c03d3b90..4c772801a40 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/InstancesControllerIntegrationTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/InstancesControllerIntegrationTest.java @@ -74,7 +74,8 @@ static void beforeAll() { void setUp() { instance = new SpringApplicationBuilder().sources(AdminReactiveApplicationTest.TestAdminApplication.class) .web(WebApplicationType.REACTIVE) - .run("--server.port=0", "--eureka.client.enabled=false"); + .run("--server.port=0", "--eureka.client.enabled=false", + "--management.endpoints.web.base-path=/application"); localPort = instance.getEnvironment().getProperty("local.server.port", Integer.class, 0); From 8f126d6a3460042c27c5c995b906cceaac063cfe Mon Sep 17 00:00:00 2001 From: Cosimo Damiano Prete Date: Thu, 18 Jun 2026 13:25:59 +0200 Subject: [PATCH 5/5] fix(#5450): Preserve existing status timestamp if only the details have changed --- .../boot/admin/server/domain/entities/Instance.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/domain/entities/Instance.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/domain/entities/Instance.java index 9e29c873a7d..501943383f6 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/domain/entities/Instance.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/domain/entities/Instance.java @@ -216,8 +216,11 @@ else if (event instanceof InstanceRegistrationUpdatedEvent updatedEvent) { } else if (event instanceof InstanceStatusChangedEvent statusChangedEvent) { StatusInfo statusInfo = statusChangedEvent.getStatusInfo(); + // Preserve the existing status timestamp if only the details have changed + Instant statusTimestamp = this.statusInfo.getStatus().equals(statusInfo.getStatus()) ? this.statusTimestamp + : event.getTimestamp(); return new Instance(this.id, event.getVersion(), this.registration, this.registered, statusInfo, - event.getTimestamp(), this.info, this.endpoints, this.buildVersion, this.tags, unsavedEvents); + statusTimestamp, this.info, this.endpoints, this.buildVersion, this.tags, unsavedEvents); } else if (event instanceof InstanceEndpointsDetectedEvent endpointsDetectedEvent) {