From 0b7d88c9a734f5fe17f52aad0feccbebac6c8b26 Mon Sep 17 00:00:00 2001 From: ulrichschulte Date: Fri, 5 Jun 2026 16:28:44 +0200 Subject: [PATCH 1/6] #5352: Cache health groups and expose via health-groups endpoint --- .../src/main/frontend/services/instance.ts | 4 + .../instances/details/details-health.spec.ts | 84 +++++++++---------- .../instances/details/details-health.vue | 6 +- .../config/AdminServerAutoConfiguration.java | 11 ++- .../config/AdminServerWebConfiguration.java | 6 +- .../server/services/HealthGroupsCache.java | 65 ++++++++++++++ .../admin/server/services/StatusUpdater.java | 19 ++++- .../admin/server/web/InstancesController.java | 21 ++++- .../services/HealthGroupsCacheTest.java | 81 ++++++++++++++++++ .../server/services/StatusUpdaterTest.java | 34 +++++++- 10 files changed, 277 insertions(+), 54 deletions(-) create mode 100644 spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java create mode 100644 spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheTest.java diff --git a/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts b/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts index edb7159475b..273ba5b6536 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts @@ -231,6 +231,10 @@ class Instance { }); } + async fetchCachedHealthGroups() { + return this.axios.get('health-groups'); + } + async fetchHealthGroup(groupName: string) { return await this.axios.get(uri`actuator/health/${groupName}`, { validateStatus: null, diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.spec.ts index 1bd3faa6ee7..ac2c3e90a04 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.spec.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.spec.ts @@ -13,10 +13,10 @@ describe('DetailsHealth', () => { const application = new Application(applications[0]); const instance = application.instances[0]; - // Mock fetchHealth for groups (will be called once on mount) - instance.fetchHealth = vi + // Mock fetchCachedHealthGroups for groups (will be called once on mount) + instance.fetchCachedHealthGroups = vi .fn() - .mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } }); + .mockResolvedValue({ data: ['liveness'] }); render(DetailsHealth, { props: { @@ -32,9 +32,9 @@ describe('DetailsHealth', () => { const application = new Application(applications[0]); const instance = application.instances[0]; - instance.fetchHealth = vi + instance.fetchCachedHealthGroups = vi .fn() - .mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } }); + .mockResolvedValue({ data: ['liveness'] }); render(DetailsHealth, { props: { @@ -50,9 +50,9 @@ describe('DetailsHealth', () => { it('should update when instance prop changes', async () => { const application = new Application(applications[0]); const instance1 = application.instances[0]; - instance1.fetchHealth = vi + instance1.fetchCachedHealthGroups = vi .fn() - .mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } }); + .mockResolvedValue({ data: ['liveness'] }); const { rerender } = render(DetailsHealth, { props: { @@ -72,9 +72,7 @@ describe('DetailsHealth', () => { }, ], }).instances[0]; - instance2.fetchHealth = vi - .fn() - .mockResolvedValue({ data: { status: 'DOWN', groups: [] } }); + instance2.fetchCachedHealthGroups = vi.fn().mockResolvedValue({ data: [] }); await rerender({ instance: instance2 }); @@ -86,9 +84,9 @@ describe('DetailsHealth', () => { const application = new Application(applications[0]); const instance = application.instances[0]; instance.statusInfo = { status: 'UP', details: {} }; - instance.fetchHealth = vi + instance.fetchCachedHealthGroups = vi .fn() - .mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } }); + .mockResolvedValue({ data: ['liveness'] }); render(DetailsHealth, { props: { @@ -101,22 +99,21 @@ describe('DetailsHealth', () => { }); describe('SSE reactive updates', () => { - it('should call fetchHealth once on mount, not on SSE version changes', async () => { + it('should call fetchCachedHealthGroups once on mount, not on SSE version changes', async () => { const baseApp = applications[0]; const instance1 = new Application(baseApp).instances[0]; - const fetchHealthSpy1 = vi.spyOn(instance1, 'fetchHealth'); - fetchHealthSpy1.mockResolvedValue({ - data: { status: 'UP', groups: ['liveness'] }, - } as AxiosResponse); + instance1.fetchCachedHealthGroups = vi + .fn() + .mockResolvedValue({ data: ['liveness'] }); const { rerender } = render(DetailsHealth, { props: { instance: instance1 }, }); await screen.findAllByRole('status'); - expect(fetchHealthSpy1).toHaveBeenCalledTimes(1); + expect(instance1.fetchCachedHealthGroups).toHaveBeenCalledTimes(1); - // Same instance, different version (SSE update) — should NOT call fetchHealth again + // Same instance, different version (SSE update) — should NOT call fetchCachedHealthGroups again const instance2 = new Application({ ...baseApp, instances: [ @@ -127,24 +124,23 @@ describe('DetailsHealth', () => { }, ], }).instances[0]; - const fetchHealthSpy2 = vi.spyOn(instance2, 'fetchHealth'); - fetchHealthSpy2.mockResolvedValue({ - data: { status: 'DOWN', groups: [] }, - } as AxiosResponse); + instance2.fetchCachedHealthGroups = vi + .fn() + .mockResolvedValue({ data: [] }); await rerender({ instance: instance2 }); - // Original instance's spy should still be 1 (no additional calls) - expect(fetchHealthSpy1).toHaveBeenCalledTimes(1); + // Original instance's mock should still be 1 (no additional calls) + expect(instance1.fetchCachedHealthGroups).toHaveBeenCalledTimes(1); }); it('should reactively update through multiple SSE status changes without extra HTTP calls', async () => { const baseApp = applications[0]; const instance1 = new Application(baseApp).instances[0]; - instance1.fetchHealth = vi + instance1.fetchCachedHealthGroups = vi .fn() - .mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } }); + .mockResolvedValue({ data: ['liveness'] }); const { rerender } = render(DetailsHealth, { props: { instance: instance1 }, @@ -172,9 +168,9 @@ describe('DetailsHealth', () => { }, ], }).instances[0]; - instance2.fetchHealth = vi + instance2.fetchCachedHealthGroups = vi .fn() - .mockResolvedValue({ data: { status: 'DOWN', groups: [] } }); + .mockResolvedValue({ data: [] }); await rerender({ instance: instance2 }); @@ -188,8 +184,8 @@ describe('DetailsHealth', () => { it('should display health group buttons after mount', async () => { const application = new Application(applications[0]); const instance = application.instances[0]; - instance.fetchHealth = vi.fn().mockResolvedValue({ - data: { status: 'UP', groups: ['liveness', 'readiness'] }, + instance.fetchCachedHealthGroups = vi.fn().mockResolvedValue({ + data: ['liveness', 'readiness'], }); const fetchGroupSpy = vi.spyOn(instance, 'fetchHealthGroup'); @@ -211,8 +207,8 @@ describe('DetailsHealth', () => { it('should fetch group details on first click', async () => { const application = new Application(applications[0]); const instance = application.instances[0]; - instance.fetchHealth = vi.fn().mockResolvedValue({ - data: { status: 'UP', groups: ['custom-group'] }, + instance.fetchCachedHealthGroups = vi.fn().mockResolvedValue({ + data: ['custom-group'], }); const fetchGroupSpy = vi.spyOn(instance, 'fetchHealthGroup'); fetchGroupSpy.mockResolvedValue({ @@ -259,8 +255,8 @@ describe('DetailsHealth', () => { it('should toggle group visibility after data is loaded', async () => { const application = new Application(applications[0]); const instance = application.instances[0]; - instance.fetchHealth = vi.fn().mockResolvedValue({ - data: { status: 'UP', groups: ['custom-group'] }, + instance.fetchCachedHealthGroups = vi.fn().mockResolvedValue({ + data: ['custom-group'], }); const fetchGroupSpy = vi.spyOn(instance, 'fetchHealthGroup'); fetchGroupSpy.mockResolvedValue({ @@ -294,8 +290,8 @@ describe('DetailsHealth', () => { it('should not show groups when none exist', async () => { const application = new Application(applications[0]); const instance = application.instances[0]; - instance.fetchHealth = vi.fn().mockResolvedValue({ - data: { status: 'UP', groups: [] }, + instance.fetchCachedHealthGroups = vi.fn().mockResolvedValue({ + data: [], }); render(DetailsHealth, { @@ -311,16 +307,16 @@ describe('DetailsHealth', () => { it('should re-fetch groups when instance id changes', async () => { const app1 = new Application(applications[0]).instances[0]; - app1.fetchHealth = vi + app1.fetchCachedHealthGroups = vi .fn() - .mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } }); + .mockResolvedValue({ data: ['liveness'] }); const { rerender } = render(DetailsHealth, { props: { instance: app1 }, }); await waitFor(() => { - expect(app1.fetchHealth).toHaveBeenCalledTimes(1); + expect(app1.fetchCachedHealthGroups).toHaveBeenCalledTimes(1); }); const app2 = new Application({ @@ -332,18 +328,18 @@ describe('DetailsHealth', () => { }, ], }).instances[0]; - app2.fetchHealth = vi + app2.fetchCachedHealthGroups = vi .fn() - .mockResolvedValue({ data: { status: 'UP', groups: ['readiness'] } }); + .mockResolvedValue({ data: ['readiness'] }); await rerender({ instance: app2 }); await waitFor(() => { - expect(app2.fetchHealth).toHaveBeenCalledTimes(1); + expect(app2.fetchCachedHealthGroups).toHaveBeenCalledTimes(1); }); // Original instance should still have only 1 call - expect(app1.fetchHealth).toHaveBeenCalledTimes(1); + expect(app1.fetchCachedHealthGroups).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue index 676dec33073..bf00b109263 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue @@ -214,10 +214,10 @@ export default defineComponent({ this.healthGroupsError = null; try { - const res = await this.instance.fetchHealth(); + const res = await this.instance.fetchCachedHealthGroups(); - if (Array.isArray(res.data.groups)) { - this.healthGroups = res.data.groups.map((name: string) => ({ + if (Array.isArray(res.data)) { + this.healthGroups = res.data.map((name: string) => ({ name, data: null, })); diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java index 70c646678b0..78b3bc3fe72 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java @@ -42,6 +42,7 @@ import de.codecentric.boot.admin.server.services.EndpointDetectionTrigger; import de.codecentric.boot.admin.server.services.EndpointDetector; import de.codecentric.boot.admin.server.services.HashingInstanceUrlIdGenerator; +import de.codecentric.boot.admin.server.services.HealthGroupsCache; import de.codecentric.boot.admin.server.services.InfoUpdateTrigger; import de.codecentric.boot.admin.server.services.InfoUpdater; import de.codecentric.boot.admin.server.services.InstanceFilter; @@ -96,14 +97,22 @@ public InstanceIdGenerator instanceIdGenerator() { return new HashingInstanceUrlIdGenerator(); } + @Bean + @ConditionalOnMissingBean + public HealthGroupsCache healthGroupsCache() { + return new HealthGroupsCache(); + } + @Bean @ConditionalOnMissingBean public StatusUpdater statusUpdater(InstanceRepository instanceRepository, - InstanceWebClient.Builder instanceWebClientBuilder) { + InstanceWebClient.Builder instanceWebClientBuilder, HealthGroupsCache healthGroupsCache) { StatusUpdater updater = new StatusUpdater(instanceRepository, instanceWebClientBuilder.build(), new ApiMediaTypeHandler()); + updater.setHealthGroupsCache(healthGroupsCache); + AdminServerProperties.MonitorProperties monitorProperties = this.adminServerProperties.getMonitor(); Duration timeout = monitorProperties.getDefaultTimeout(); diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java index 52972b72efd..48722c560d5 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java @@ -29,6 +29,7 @@ import de.codecentric.boot.admin.server.eventstore.InstanceEventStore; import de.codecentric.boot.admin.server.services.ApplicationRegistry; +import de.codecentric.boot.admin.server.services.HealthGroupsCache; import de.codecentric.boot.admin.server.services.InstanceRegistry; import de.codecentric.boot.admin.server.utils.jackson.AdminServerModule; import de.codecentric.boot.admin.server.web.ApplicationsController; @@ -51,8 +52,9 @@ public SimpleModule adminJacksonModule() { @Bean @ConditionalOnMissingBean - public InstancesController instancesController(InstanceRegistry instanceRegistry, InstanceEventStore eventStore) { - return new InstancesController(instanceRegistry, eventStore); + public InstancesController instancesController(InstanceRegistry instanceRegistry, InstanceEventStore eventStore, + HealthGroupsCache healthGroupsCache) { + return new InstancesController(instanceRegistry, eventStore, healthGroupsCache); } @Bean diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java new file mode 100644 index 00000000000..e2d02199208 --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java @@ -0,0 +1,65 @@ +/* + * 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.server.services; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import de.codecentric.boot.admin.server.domain.values.InstanceId; + +/** + * Cache for health groups per instance. + */ +public class HealthGroupsCache { + + private final ConcurrentMap> cache = new ConcurrentHashMap<>(); + + /** + * Update the health groups for an instance. If groups is null or empty, the entry is + * removed from the cache. + * @param instanceId the instance id + * @param groups the health groups list + */ + public void updateGroups(InstanceId instanceId, List groups) { + if (groups == null || groups.isEmpty()) { + this.cache.remove(instanceId); + } + else { + this.cache.put(instanceId, List.copyOf(groups)); + } + } + + /** + * Get the health groups for an instance. + * @param instanceId the instance id + * @return the list of health groups, or an empty list if none are cached + */ + public List getGroups(InstanceId instanceId) { + return this.cache.getOrDefault(instanceId, Collections.emptyList()); + } + + /** + * Remove the health groups entry for an instance. + * @param instanceId the instance id + */ + public void remove(InstanceId instanceId) { + this.cache.remove(instanceId); + } + +} diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java index 77f8639c638..c47f7207ff0 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java @@ -19,6 +19,7 @@ import java.time.Duration; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.logging.Level; @@ -62,11 +63,17 @@ public class StatusUpdater { private Duration timeout = Duration.ofSeconds(10); + private HealthGroupsCache healthGroupsCache; + public StatusUpdater timeout(Duration timeout) { this.timeout = timeout; return this; } + public void setHealthGroupsCache(HealthGroupsCache healthGroupsCache) { + this.healthGroupsCache = healthGroupsCache; + } + public Mono updateStatus(InstanceId id) { return this.repository.computeIfPresent(id, (key, instance) -> this.doUpdateStatus(instance)).then(); } @@ -80,7 +87,7 @@ protected Mono doUpdateStatus(Instance instance) { return this.instanceWebClient.instance(instance) .get() .uri(Endpoint.HEALTH) - .exchangeToMono(this::convertStatusInfo) + .exchangeToMono((response) -> this.convertStatusInfo(response, instance.getId())) .log(log.getName(), Level.FINEST) .timeout(getTimeoutWithMargin()) .doOnError((ex) -> logError(instance, ex)) @@ -96,7 +103,7 @@ private Duration getTimeoutWithMargin() { return this.timeout.minusSeconds(1).abs(); } - protected Mono convertStatusInfo(ClientResponse response) { + protected Mono convertStatusInfo(ClientResponse response, InstanceId instanceId) { boolean hasCompatibleContentType = response.headers() .contentType() .filter((mt) -> mt.isCompatibleWith(MediaType.APPLICATION_JSON) @@ -106,6 +113,7 @@ protected Mono convertStatusInfo(ClientResponse response) { StatusInfo statusInfoFromStatus = this.getStatusInfoFromStatus(response.statusCode(), emptyMap()); if (hasCompatibleContentType) { return response.bodyToMono(RESPONSE_TYPE).map((body) -> { + extractAndCacheGroups(instanceId, body); if (body.get("status") instanceof String) { return StatusInfo.from(body); } @@ -148,4 +156,11 @@ protected void logError(Instance instance, Throwable ex) { } } + private void extractAndCacheGroups(InstanceId instanceId, Map body) { + if (this.healthGroupsCache != null && body.get("groups") instanceof List groupsList) { + List groups = groupsList.stream().filter(String.class::isInstance).map(String.class::cast).toList(); + this.healthGroupsCache.updateGroups(instanceId, groups); + } + } + } diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstancesController.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstancesController.java index 109efc0d7a2..9f4f79e7414 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstancesController.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstancesController.java @@ -19,6 +19,7 @@ import java.net.URI; import java.time.Duration; import java.util.Collections; +import java.util.List; import java.util.Map; import org.slf4j.Logger; @@ -42,6 +43,7 @@ import de.codecentric.boot.admin.server.domain.values.InstanceId; import de.codecentric.boot.admin.server.domain.values.Registration; import de.codecentric.boot.admin.server.eventstore.InstanceEventStore; +import de.codecentric.boot.admin.server.services.HealthGroupsCache; import de.codecentric.boot.admin.server.services.InstanceRegistry; /** @@ -62,9 +64,13 @@ public class InstancesController { private final InstanceEventStore eventStore; - public InstancesController(InstanceRegistry registry, InstanceEventStore eventStore) { + private final HealthGroupsCache healthGroupsCache; + + public InstancesController(InstanceRegistry registry, InstanceEventStore eventStore, + HealthGroupsCache healthGroupsCache) { this.registry = registry; this.eventStore = eventStore; + this.healthGroupsCache = healthGroupsCache; } /** @@ -118,6 +124,19 @@ public Mono> instance(@PathVariable String id) { .defaultIfEmpty(ResponseEntity.notFound().build()); } + /** + * Get health groups for an instance. + * @param id the instance identifier. + * @return the health groups list. + */ + @GetMapping(path = "/instances/{id}/health-groups", produces = MediaType.APPLICATION_JSON_VALUE) + public Mono>> healthGroups(@PathVariable String id) { + InstanceId instanceId = InstanceId.of(id); + return this.registry.getInstance(instanceId) + .map((instance) -> ResponseEntity.ok(this.healthGroupsCache.getGroups(instanceId))) + .defaultIfEmpty(ResponseEntity.notFound().build()); + } + /** * Unregister an instance * @param id the instance id. diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheTest.java new file mode 100644 index 00000000000..74fa5320854 --- /dev/null +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheTest.java @@ -0,0 +1,81 @@ +/* + * 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.server.services; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import de.codecentric.boot.admin.server.domain.values.InstanceId; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HealthGroupsCacheTest { + + private HealthGroupsCache cache; + + private InstanceId instanceId; + + @BeforeEach + void setUp() { + this.cache = new HealthGroupsCache(); + this.instanceId = InstanceId.of("test-instance"); + } + + @Test + void updateAndGetGroups() { + List groups = List.of("liveness", "readiness"); + this.cache.updateGroups(this.instanceId, groups); + assertThat(this.cache.getGroups(this.instanceId)).containsExactly("liveness", "readiness"); + } + + @Test + void getGroupsReturnsEmptyListForUnknownInstance() { + assertThat(this.cache.getGroups(InstanceId.of("unknown"))).isEmpty(); + } + + @Test + void updateGroupsWithNullRemovesEntry() { + this.cache.updateGroups(this.instanceId, List.of("liveness", "readiness")); + this.cache.updateGroups(this.instanceId, null); + assertThat(this.cache.getGroups(this.instanceId)).isEmpty(); + } + + @Test + void updateGroupsWithEmptyListRemovesEntry() { + this.cache.updateGroups(this.instanceId, List.of("liveness", "readiness")); + this.cache.updateGroups(this.instanceId, List.of()); + assertThat(this.cache.getGroups(this.instanceId)).isEmpty(); + } + + @Test + void removeGroups() { + this.cache.updateGroups(this.instanceId, List.of("liveness", "readiness")); + this.cache.remove(this.instanceId); + assertThat(this.cache.getGroups(this.instanceId)).isEmpty(); + } + + @Test + void returnedListIsUnmodifiable() { + this.cache.updateGroups(this.instanceId, List.of("liveness", "readiness")); + List groups = this.cache.getGroups(this.instanceId); + assertThatThrownBy(() -> groups.add("test")).isInstanceOf(UnsupportedOperationException.class); + } + +} 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..366fd4c7a91 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 @@ -69,6 +69,10 @@ class StatusUpdaterTest { private Instance instance; + private HealthGroupsCache healthGroupsCache; + + private InstanceId instanceId; + @BeforeAll static void setUp() { StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); @@ -84,7 +88,8 @@ void setup() { this.wireMock.start(); this.eventStore = new InMemoryEventStore(); this.repository = new EventsourcingInstanceRepository(this.eventStore); - this.instance = Instance.create(InstanceId.of("id")) + this.instanceId = InstanceId.of("id"); + this.instance = Instance.create(this.instanceId) .register(Registration.create("foo", this.wireMock.url("/health")).build()); StepVerifier.create(this.repository.save(this.instance)).expectNextCount(1).verifyComplete(); @@ -95,6 +100,9 @@ void setup() { .filter(timeout(Duration.ofSeconds(2), emptyMap())) .build(), new ApiMediaTypeHandler()); + + this.healthGroupsCache = new HealthGroupsCache(); + this.updater.setHealthGroupsCache(this.healthGroupsCache); } @AfterEach @@ -265,4 +273,28 @@ void should_retry() { .verifyComplete(); } + @Test + void should_cache_health_groups() { + String body = "{ \"status\" : \"UP\", \"groups\" : [\"liveness\", \"readiness\"] }"; + this.wireMock.stubFor( + get("/health").willReturn(okForContentType(ApiVersion.LATEST.getProducedMimeType().toString(), body) + .withHeader("Content-Length", Integer.toString(body.length())))); + + StepVerifier.create(this.updater.updateStatus(this.instanceId)).verifyComplete(); + + assertThat(this.healthGroupsCache.getGroups(this.instanceId)).containsExactly("liveness", "readiness"); + } + + @Test + void should_handle_missing_groups_in_health_response() { + String body = "{ \"status\" : \"UP\" }"; + this.wireMock.stubFor( + get("/health").willReturn(okForContentType(ApiVersion.LATEST.getProducedMimeType().toString(), body) + .withHeader("Content-Length", Integer.toString(body.length())))); + + StepVerifier.create(this.updater.updateStatus(this.instanceId)).verifyComplete(); + + assertThat(this.healthGroupsCache.getGroups(this.instanceId)).isEmpty(); + } + } From c33b00f6856f50cb998481c9633f19e078ae47fc Mon Sep 17 00:00:00 2001 From: ulrichschulte Date: Fri, 5 Jun 2026 16:53:42 +0200 Subject: [PATCH 2/6] #5352: Cleanup health groups cache --- .../src/main/frontend/services/instance.ts | 6 +- .../config/AdminServerAutoConfiguration.java | 12 +++- .../config/AdminServerWebConfiguration.java | 2 +- .../server/services/HealthGroupsCache.java | 2 +- .../HealthGroupsCacheCleanupTrigger.java | 61 +++++++++++++++++ .../admin/server/services/StatusUpdater.java | 30 +++++---- .../admin/server/web/InstancesController.java | 4 +- .../HealthGroupsCacheCleanupTriggerTest.java | 67 +++++++++++++++++++ .../server/services/StatusUpdaterTest.java | 6 +- 9 files changed, 162 insertions(+), 28 deletions(-) create mode 100644 spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupTrigger.java create mode 100644 spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupTriggerTest.java diff --git a/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts b/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts index 273ba5b6536..cd55155ae5d 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AxiosError, AxiosInstance } from 'axios'; +import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import saveAs from 'file-saver'; import { Observable, concat, from, ignoreElements } from 'rxjs'; @@ -231,8 +231,8 @@ class Instance { }); } - async fetchCachedHealthGroups() { - return this.axios.get('health-groups'); + async fetchCachedHealthGroups(): Promise> { + return this.axios.get('health-groups'); } async fetchHealthGroup(groupName: string) { diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java index 78b3bc3fe72..75dd92206d4 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java @@ -43,6 +43,7 @@ import de.codecentric.boot.admin.server.services.EndpointDetector; import de.codecentric.boot.admin.server.services.HashingInstanceUrlIdGenerator; import de.codecentric.boot.admin.server.services.HealthGroupsCache; +import de.codecentric.boot.admin.server.services.HealthGroupsCacheCleanupTrigger; import de.codecentric.boot.admin.server.services.InfoUpdateTrigger; import de.codecentric.boot.admin.server.services.InfoUpdater; import de.codecentric.boot.admin.server.services.InstanceFilter; @@ -109,9 +110,7 @@ public StatusUpdater statusUpdater(InstanceRepository instanceRepository, InstanceWebClient.Builder instanceWebClientBuilder, HealthGroupsCache healthGroupsCache) { StatusUpdater updater = new StatusUpdater(instanceRepository, instanceWebClientBuilder.build(), - new ApiMediaTypeHandler()); - - updater.setHealthGroupsCache(healthGroupsCache); + new ApiMediaTypeHandler(), healthGroupsCache); AdminServerProperties.MonitorProperties monitorProperties = this.adminServerProperties.getMonitor(); @@ -145,6 +144,13 @@ public StatusUpdateTrigger statusUpdateTrigger(StatusUpdater statusUpdater, Publ monitorProperties.getStatusMaxBackoff()); } + @Bean(initMethod = "start", destroyMethod = "stop") + @ConditionalOnMissingBean + public HealthGroupsCacheCleanupTrigger healthGroupsCacheCleanupTrigger(Publisher events, + HealthGroupsCache healthGroupsCache) { + return new HealthGroupsCacheCleanupTrigger(events, healthGroupsCache); + } + @Bean @ConditionalOnMissingBean public EndpointDetector endpointDetector(InstanceRepository instanceRepository, diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java index 48722c560d5..cf8d901ba07 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * Copyright 2014-2025 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. diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java index e2d02199208..0823c282df9 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2025 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. diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupTrigger.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupTrigger.java new file mode 100644 index 00000000000..cbdf8c1405a --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupTrigger.java @@ -0,0 +1,61 @@ +/* + * Copyright 2014-2025 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.server.services; + +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.events.InstanceDeregisteredEvent; +import de.codecentric.boot.admin.server.domain.events.InstanceEvent; + +/** + * Triggers cleanup of {@link de.codecentric.boot.admin.server.domain.entities.Instance} + * specific data in {@link HealthGroupsCache} on receiving an + * {@link InstanceDeregisteredEvent}. + */ +public class HealthGroupsCacheCleanupTrigger extends AbstractEventHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(HealthGroupsCacheCleanupTrigger.class); + + private final HealthGroupsCache healthGroupsCache; + + /** + * Creates a trigger to cleanup the health groups cache on deregistering of an + * {@link de.codecentric.boot.admin.server.domain.entities.Instance}. + * @param publisher publisher of {@link InstanceEvent}s events + * @param healthGroupsCache the cache to inform about deregistration of an + * {@link de.codecentric.boot.admin.server.domain.entities.Instance} + */ + public HealthGroupsCacheCleanupTrigger(final Publisher publisher, + final HealthGroupsCache healthGroupsCache) { + super(publisher, InstanceDeregisteredEvent.class); + + this.healthGroupsCache = healthGroupsCache; + } + + @Override + protected Publisher handle(final Flux publisher) { + return publisher.flatMap((event) -> { + this.healthGroupsCache.remove(event.getInstance()); + return Mono.empty(); + }); + } + +} diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java index c47f7207ff0..a2545617c1c 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2025 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. @@ -63,17 +63,13 @@ public class StatusUpdater { private Duration timeout = Duration.ofSeconds(10); - private HealthGroupsCache healthGroupsCache; + private final HealthGroupsCache healthGroupsCache; public StatusUpdater timeout(Duration timeout) { this.timeout = timeout; return this; } - public void setHealthGroupsCache(HealthGroupsCache healthGroupsCache) { - this.healthGroupsCache = healthGroupsCache; - } - public Mono updateStatus(InstanceId id) { return this.repository.computeIfPresent(id, (key, instance) -> this.doUpdateStatus(instance)).then(); } @@ -103,7 +99,11 @@ private Duration getTimeoutWithMargin() { return this.timeout.minusSeconds(1).abs(); } - protected Mono convertStatusInfo(ClientResponse response, InstanceId instanceId) { + protected Mono convertStatusInfo(ClientResponse response) { + return convertStatusInfo(response, null); + } + + private Mono convertStatusInfo(ClientResponse response, InstanceId instanceId) { boolean hasCompatibleContentType = response.headers() .contentType() .filter((mt) -> mt.isCompatibleWith(MediaType.APPLICATION_JSON) @@ -112,13 +112,15 @@ protected Mono convertStatusInfo(ClientResponse response, InstanceId StatusInfo statusInfoFromStatus = this.getStatusInfoFromStatus(response.statusCode(), emptyMap()); if (hasCompatibleContentType) { - return response.bodyToMono(RESPONSE_TYPE).map((body) -> { - extractAndCacheGroups(instanceId, body); - if (body.get("status") instanceof String) { - return StatusInfo.from(body); - } - return getStatusInfoFromStatus(response.statusCode(), body); - }).defaultIfEmpty(statusInfoFromStatus); + return response.bodyToMono(RESPONSE_TYPE) + .doOnNext((body) -> extractAndCacheGroups(instanceId, body)) + .map((body) -> { + if (body.get("status") instanceof String) { + return StatusInfo.from(body); + } + return getStatusInfoFromStatus(response.statusCode(), body); + }) + .defaultIfEmpty(statusInfoFromStatus); } return response.releaseBody().then(Mono.just(statusInfoFromStatus)); } diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstancesController.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstancesController.java index 9f4f79e7414..760c23d0002 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstancesController.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstancesController.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2025 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. @@ -133,7 +133,7 @@ public Mono> instance(@PathVariable String id) { public Mono>> healthGroups(@PathVariable String id) { InstanceId instanceId = InstanceId.of(id); return this.registry.getInstance(instanceId) - .map((instance) -> ResponseEntity.ok(this.healthGroupsCache.getGroups(instanceId))) + .map((_instance) -> ResponseEntity.ok(this.healthGroupsCache.getGroups(instanceId))) .defaultIfEmpty(ResponseEntity.notFound().build()); } diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupTriggerTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupTriggerTest.java new file mode 100644 index 00000000000..8817bda4fda --- /dev/null +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupTriggerTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2014-2025 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.server.services; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.test.publisher.TestPublisher; + +import de.codecentric.boot.admin.server.domain.events.InstanceDeregisteredEvent; +import de.codecentric.boot.admin.server.domain.events.InstanceEvent; +import de.codecentric.boot.admin.server.domain.values.InstanceId; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class HealthGroupsCacheCleanupTriggerTest { + + private final TestPublisher events = TestPublisher.create(); + + private HealthGroupsCache cache; + + private HealthGroupsCacheCleanupTrigger trigger; + + @BeforeEach + void setUp() { + this.cache = new HealthGroupsCache(); + this.trigger = new HealthGroupsCacheCleanupTrigger(this.events.flux(), this.cache); + this.trigger.start(); + await().until(this.events::wasSubscribed); + } + + @Test + void should_remove_cache_entry_on_deregistration() { + InstanceId instanceId = InstanceId.of("test-id"); + this.cache.updateGroups(instanceId, List.of("liveness", "readiness")); + + this.events.next(new InstanceDeregisteredEvent(instanceId, 1L)); + + assertThat(this.cache.getGroups(instanceId)).isEmpty(); + } + + @Test + void should_not_fail_on_unknown_instance() { + InstanceId instanceId = InstanceId.of("test-id"); + + this.events.next(new InstanceDeregisteredEvent(instanceId, 1L)); + + assertThat(this.cache.getGroups(instanceId)).isEmpty(); + } + +} 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 366fd4c7a91..94dcd736f51 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 @@ -93,16 +93,14 @@ void setup() { .register(Registration.create("foo", this.wireMock.url("/health")).build()); StepVerifier.create(this.repository.save(this.instance)).expectNextCount(1).verifyComplete(); + this.healthGroupsCache = new HealthGroupsCache(); this.updater = new StatusUpdater(this.repository, InstanceWebClient.builder() .filter(rewriteEndpointUrl()) .filter(retry(0, singletonMap(Endpoint.HEALTH, 1))) .filter(timeout(Duration.ofSeconds(2), emptyMap())) .build(), - new ApiMediaTypeHandler()); - - this.healthGroupsCache = new HealthGroupsCache(); - this.updater.setHealthGroupsCache(this.healthGroupsCache); + new ApiMediaTypeHandler(), this.healthGroupsCache); } @AfterEach From 8b88d3e766c62b131dd9420db3a4f11e1c23e92e Mon Sep 17 00:00:00 2001 From: Ulrich Schulte Date: Fri, 12 Jun 2026 08:15:10 +0200 Subject: [PATCH 3/6] Update spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java Co-authored-by: Cosimo Damiano Prete <8491864+cdprete@users.noreply.github.com> --- .../codecentric/boot/admin/server/services/StatusUpdater.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java index a2545617c1c..309b39937a4 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java @@ -158,7 +158,7 @@ protected void logError(Instance instance, Throwable ex) { } } - private void extractAndCacheGroups(InstanceId instanceId, Map body) { + private void extractAndCacheHealthGroups(InstanceId instanceId, Map body) { if (this.healthGroupsCache != null && body.get("groups") instanceof List groupsList) { List groups = groupsList.stream().filter(String.class::isInstance).map(String.class::cast).toList(); this.healthGroupsCache.updateGroups(instanceId, groups); From 29d10f7f6d847864cf49834eaa284acbf509f2bd Mon Sep 17 00:00:00 2001 From: ulrichschulte Date: Fri, 12 Jun 2026 08:23:03 +0200 Subject: [PATCH 4/6] Fix renaming of method --- .../codecentric/boot/admin/server/services/StatusUpdater.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java index 309b39937a4..43887823cd5 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/StatusUpdater.java @@ -113,7 +113,7 @@ private Mono convertStatusInfo(ClientResponse response, InstanceId i StatusInfo statusInfoFromStatus = this.getStatusInfoFromStatus(response.statusCode(), emptyMap()); if (hasCompatibleContentType) { return response.bodyToMono(RESPONSE_TYPE) - .doOnNext((body) -> extractAndCacheGroups(instanceId, body)) + .doOnNext((body) -> extractAndCacheHealthGroups(instanceId, body)) .map((body) -> { if (body.get("status") instanceof String) { return StatusInfo.from(body); From 20c2edc92ff818da9922a70d7fe26ed5ce4e3ca2 Mon Sep 17 00:00:00 2001 From: ulrichschulte Date: Fri, 12 Jun 2026 08:32:46 +0200 Subject: [PATCH 5/6] #5352: Define interface for HealthGroupsCache --- .../config/AdminServerAutoConfiguration.java | 3 +- .../server/services/HealthGroupsCache.java | 24 ++------- .../services/InMemoryHealthGroupsCache.java | 53 +++++++++++++++++++ .../HealthGroupsCacheCleanupTriggerTest.java | 2 +- .../services/HealthGroupsCacheTest.java | 2 +- .../server/services/StatusUpdaterTest.java | 2 +- 6 files changed, 62 insertions(+), 24 deletions(-) create mode 100644 spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/InMemoryHealthGroupsCache.java diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java index 75dd92206d4..0237ddb7f07 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java @@ -44,6 +44,7 @@ import de.codecentric.boot.admin.server.services.HashingInstanceUrlIdGenerator; import de.codecentric.boot.admin.server.services.HealthGroupsCache; import de.codecentric.boot.admin.server.services.HealthGroupsCacheCleanupTrigger; +import de.codecentric.boot.admin.server.services.InMemoryHealthGroupsCache; import de.codecentric.boot.admin.server.services.InfoUpdateTrigger; import de.codecentric.boot.admin.server.services.InfoUpdater; import de.codecentric.boot.admin.server.services.InstanceFilter; @@ -101,7 +102,7 @@ public InstanceIdGenerator instanceIdGenerator() { @Bean @ConditionalOnMissingBean public HealthGroupsCache healthGroupsCache() { - return new HealthGroupsCache(); + return new InMemoryHealthGroupsCache(); } @Bean diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java index 0823c282df9..bc7cc1122a7 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java @@ -16,19 +16,14 @@ package de.codecentric.boot.admin.server.services; -import java.util.Collections; import java.util.List; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; import de.codecentric.boot.admin.server.domain.values.InstanceId; /** * Cache for health groups per instance. */ -public class HealthGroupsCache { - - private final ConcurrentMap> cache = new ConcurrentHashMap<>(); +public interface HealthGroupsCache { /** * Update the health groups for an instance. If groups is null or empty, the entry is @@ -36,30 +31,19 @@ public class HealthGroupsCache { * @param instanceId the instance id * @param groups the health groups list */ - public void updateGroups(InstanceId instanceId, List groups) { - if (groups == null || groups.isEmpty()) { - this.cache.remove(instanceId); - } - else { - this.cache.put(instanceId, List.copyOf(groups)); - } - } + void updateGroups(InstanceId instanceId, List groups); /** * Get the health groups for an instance. * @param instanceId the instance id * @return the list of health groups, or an empty list if none are cached */ - public List getGroups(InstanceId instanceId) { - return this.cache.getOrDefault(instanceId, Collections.emptyList()); - } + List getGroups(InstanceId instanceId); /** * Remove the health groups entry for an instance. * @param instanceId the instance id */ - public void remove(InstanceId instanceId) { - this.cache.remove(instanceId); - } + void remove(InstanceId instanceId); } diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/InMemoryHealthGroupsCache.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/InMemoryHealthGroupsCache.java new file mode 100644 index 00000000000..c2851ffc033 --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/InMemoryHealthGroupsCache.java @@ -0,0 +1,53 @@ +/* + * Copyright 2014-2025 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.server.services; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import de.codecentric.boot.admin.server.domain.values.InstanceId; + +/** + * In-memory {@link HealthGroupsCache} implementation backed by a {@link ConcurrentMap}. + */ +public class InMemoryHealthGroupsCache implements HealthGroupsCache { + + private final ConcurrentMap> cache = new ConcurrentHashMap<>(); + + @Override + public void updateGroups(InstanceId instanceId, List groups) { + if (groups == null || groups.isEmpty()) { + this.cache.remove(instanceId); + } + else { + this.cache.put(instanceId, List.copyOf(groups)); + } + } + + @Override + public List getGroups(InstanceId instanceId) { + return this.cache.getOrDefault(instanceId, Collections.emptyList()); + } + + @Override + public void remove(InstanceId instanceId) { + this.cache.remove(instanceId); + } + +} diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupTriggerTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupTriggerTest.java index 8817bda4fda..23c6dcc3f10 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupTriggerTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupTriggerTest.java @@ -39,7 +39,7 @@ class HealthGroupsCacheCleanupTriggerTest { @BeforeEach void setUp() { - this.cache = new HealthGroupsCache(); + this.cache = new InMemoryHealthGroupsCache(); this.trigger = new HealthGroupsCacheCleanupTrigger(this.events.flux(), this.cache); this.trigger.start(); await().until(this.events::wasSubscribed); diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheTest.java index 74fa5320854..321df01a338 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheTest.java @@ -34,7 +34,7 @@ class HealthGroupsCacheTest { @BeforeEach void setUp() { - this.cache = new HealthGroupsCache(); + this.cache = new InMemoryHealthGroupsCache(); this.instanceId = InstanceId.of("test-instance"); } 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 94dcd736f51..0935a8ba519 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 @@ -93,7 +93,7 @@ void setup() { .register(Registration.create("foo", this.wireMock.url("/health")).build()); StepVerifier.create(this.repository.save(this.instance)).expectNextCount(1).verifyComplete(); - this.healthGroupsCache = new HealthGroupsCache(); + this.healthGroupsCache = new InMemoryHealthGroupsCache(); this.updater = new StatusUpdater(this.repository, InstanceWebClient.builder() .filter(rewriteEndpointUrl()) From 56236db9d915ee8e37b4636aefebad89eb7a1a26 Mon Sep 17 00:00:00 2001 From: ulrichschulte Date: Fri, 12 Jun 2026 08:54:39 +0200 Subject: [PATCH 6/6] #5352: Re-fetch the (server-cached) group list on every instance change --- .../instances/details/details-health.spec.ts | 15 +++++++++------ .../views/instances/details/details-health.vue | 10 ++-------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.spec.ts index ac2c3e90a04..086b483acf7 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.spec.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.spec.ts @@ -99,7 +99,7 @@ describe('DetailsHealth', () => { }); describe('SSE reactive updates', () => { - it('should call fetchCachedHealthGroups once on mount, not on SSE version changes', async () => { + it('should re-fetch cached health groups on SSE version changes', async () => { const baseApp = applications[0]; const instance1 = new Application(baseApp).instances[0]; instance1.fetchCachedHealthGroups = vi @@ -113,7 +113,8 @@ describe('DetailsHealth', () => { await screen.findAllByRole('status'); expect(instance1.fetchCachedHealthGroups).toHaveBeenCalledTimes(1); - // Same instance, different version (SSE update) — should NOT call fetchCachedHealthGroups again + // Same instance, different version (SSE update) — should re-fetch the + // (server-cached) group list so it self-corrects when groups change. const instance2 = new Application({ ...baseApp, instances: [ @@ -126,15 +127,17 @@ describe('DetailsHealth', () => { }).instances[0]; instance2.fetchCachedHealthGroups = vi .fn() - .mockResolvedValue({ data: [] }); + .mockResolvedValue({ data: ['liveness', 'readiness'] }); await rerender({ instance: instance2 }); - // Original instance's mock should still be 1 (no additional calls) - expect(instance1.fetchCachedHealthGroups).toHaveBeenCalledTimes(1); + // The new instance's cached groups should have been fetched on the SSE update. + await waitFor(() => { + expect(instance2.fetchCachedHealthGroups).toHaveBeenCalledTimes(1); + }); }); - it('should reactively update through multiple SSE status changes without extra HTTP calls', async () => { + it('should reactively update health status and details through SSE status changes', async () => { const baseApp = applications[0]; const instance1 = new Application(baseApp).instances[0]; diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue index bf00b109263..d1c86de06f6 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue @@ -175,15 +175,9 @@ export default defineComponent({ this.healthGroupOpenStatus = {}; this.healthGroupLoadingMap = {}; this.healthGroupsError = null; - this.fetchHealthGroups(); - } else { - // Same instance, SSE update (e.g. status change) — collapse groups and clear stale data - for (const group of this.healthGroups) { - group.data = null; - } - this.healthGroupOpenStatus = {}; - this.healthGroupLoadingMap = {}; } + // Re-fetch the (server-cached) group list on every instance change + this.fetchHealthGroups(); }, isHealthGroupOpen(groupName: string) { return this.healthGroupOpenStatus[groupName]?.isOpen ?? false;