Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -231,6 +231,10 @@ class Instance {
});
}

async fetchCachedHealthGroups(): Promise<AxiosResponse<string[]>> {
return this.axios.get<string[]>('health-groups');
}

async fetchHealthGroup(groupName: string) {
return await this.axios.get(uri`actuator/health/${groupName}`, {
validateStatus: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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: {
Expand All @@ -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 });

Expand All @@ -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: {
Expand All @@ -101,22 +99,22 @@ describe('DetailsHealth', () => {
});

describe('SSE reactive updates', () => {
it('should call fetchHealth 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];
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 re-fetch the
// (server-cached) group list so it self-corrects when groups change.
const instance2 = new Application({
...baseApp,
instances: [
Expand All @@ -127,24 +125,25 @@ describe('DetailsHealth', () => {
},
],
}).instances[0];
const fetchHealthSpy2 = vi.spyOn(instance2, 'fetchHealth');
fetchHealthSpy2.mockResolvedValue({
data: { status: 'DOWN', groups: [] },
} as AxiosResponse);
instance2.fetchCachedHealthGroups = vi
.fn()
.mockResolvedValue({ data: ['liveness', 'readiness'] });

await rerender({ instance: instance2 });

// Original instance's spy should still be 1 (no additional calls)
expect(fetchHealthSpy1).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];
instance1.fetchHealth = vi
instance1.fetchCachedHealthGroups = vi
.fn()
.mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } });
.mockResolvedValue({ data: ['liveness'] });

const { rerender } = render(DetailsHealth, {
props: { instance: instance1 },
Expand Down Expand Up @@ -172,9 +171,9 @@ describe('DetailsHealth', () => {
},
],
}).instances[0];
instance2.fetchHealth = vi
instance2.fetchCachedHealthGroups = vi
.fn()
.mockResolvedValue({ data: { status: 'DOWN', groups: [] } });
.mockResolvedValue({ data: [] });

await rerender({ instance: instance2 });

Expand All @@ -188,8 +187,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');

Expand All @@ -211,8 +210,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({
Expand Down Expand Up @@ -259,8 +258,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({
Expand Down Expand Up @@ -294,8 +293,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, {
Expand All @@ -311,16 +310,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({
Expand All @@ -332,18 +331,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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -214,10 +208,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,
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
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.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;
Expand Down Expand Up @@ -96,13 +99,19 @@ public InstanceIdGenerator instanceIdGenerator() {
return new HashingInstanceUrlIdGenerator();
}

@Bean
@ConditionalOnMissingBean
public HealthGroupsCache healthGroupsCache() {
return new InMemoryHealthGroupsCache();
}

@Bean
@ConditionalOnMissingBean
public StatusUpdater statusUpdater(InstanceRepository instanceRepository,
InstanceWebClient.Builder instanceWebClientBuilder) {
InstanceWebClient.Builder instanceWebClientBuilder, HealthGroupsCache healthGroupsCache) {

StatusUpdater updater = new StatusUpdater(instanceRepository, instanceWebClientBuilder.build(),
new ApiMediaTypeHandler());
new ApiMediaTypeHandler(), healthGroupsCache);

AdminServerProperties.MonitorProperties monitorProperties = this.adminServerProperties.getMonitor();

Expand Down Expand Up @@ -136,6 +145,13 @@ public StatusUpdateTrigger statusUpdateTrigger(StatusUpdater statusUpdater, Publ
monitorProperties.getStatusMaxBackoff());
}

@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnMissingBean
public HealthGroupsCacheCleanupTrigger healthGroupsCacheCleanupTrigger(Publisher<InstanceEvent> events,
HealthGroupsCache healthGroupsCache) {
return new HealthGroupsCacheCleanupTrigger(events, healthGroupsCache);
}

@Bean
@ConditionalOnMissingBean
public EndpointDetector endpointDetector(InstanceRepository instanceRepository,
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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 de.codecentric.boot.admin.server.domain.values.InstanceId;

/**
* Cache for health groups per instance.
*/
public interface HealthGroupsCache {

/**
* 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
*/
void updateGroups(InstanceId instanceId, List<String> 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
*/
List<String> getGroups(InstanceId instanceId);

/**
* Remove the health groups entry for an instance.
* @param instanceId the instance id
*/
void remove(InstanceId instanceId);

}
Loading