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
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 @@ -56,9 +56,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 @@ -78,9 +78,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 @@ -92,9 +90,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 @@ -107,22 +105,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 @@ -133,24 +131,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 @@ -178,9 +177,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 @@ -197,8 +196,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 @@ -220,8 +219,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 @@ -270,8 +269,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 @@ -309,8 +308,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 @@ -326,16 +325,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 @@ -347,18 +346,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();

@cdprete cdprete Jun 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should do this only if the instance has changed in some meaningful way regarding its status, which means to not do it if the event that gets sent is INFO_UPDATED.

If the process InfoContributor is enabled, the data returned by https://docs.spring.io/spring-boot/api/rest/actuator/info.html changes on every poll, which would mean we're re-fetching the health groups over and over for no real reason.

},
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) => ({

@cdprete cdprete Jun 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @ulischulte.

Will this assignment trigger a UI re-rendering?
If it's so, we should maybe check the content of the response first and set the new state (health groups + reset of state of the accorditions) only if there is indeed a change in the collection of the returned group names.
Otherwise, on every fetch, we'll be re-rendering the UI for nothing if nothing really has changed in the context of the health groups.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it will trigger a re-rendering of UI. I will add a check to prevent this in terms of UX, as it will also reset the collapsed state. Changing groups in backend will not happen that often, though.

@cdprete cdprete Jun 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing groups in backend will not happen that often, though.

I was wondering exactly because their update will be very rare.

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