Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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();
}

}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}

}
Original file line number Diff line number Diff line change
@@ -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<EchoEntity> 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();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String, String> 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();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down