Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
537eeee
#5352: Cache health groups and expose via health-groups endpoint
ulischulte Jun 5, 2026
c0c3dbf
#5352: Cleanup health groups cache
ulischulte Jun 5, 2026
58d8efd
Update spring-boot-admin-server/src/main/java/de/codecentric/boot/adm…
ulischulte Jun 12, 2026
e0a1510
Fix renaming of method
ulischulte Jun 12, 2026
95f761b
#5352: Define interface for HealthGroupsCache
ulischulte Jun 12, 2026
ffe3250
#5352: Re-fetch the (server-cached) group list on every instance change
ulischulte Jun 12, 2026
b469f7a
chore: update project version to 4.1.1-SNAPSHOT in pom.xml
SteKoe Jun 19, 2026
475ab1d
feat(server): Add messageThreadId handling in super groups for Telegr…
AliArtukov Jun 19, 2026
2ff8c42
chore: update project version to 4.1.2-SNAPSHOT in pom.xml
SteKoe Jun 19, 2026
5691298
fix(deps): update dependency axios to v1.18.0 (#5466)
renovate[bot] Jun 19, 2026
f6c5b77
chore(deps): update storybook monorepo to v10.4.5 (#5467)
renovate[bot] Jun 20, 2026
9f572bf
chore(deps): update vitest monorepo to v4.1.9 (#5468)
renovate[bot] Jun 20, 2026
fcc8b99
chore(deps): update typescript-eslint monorepo to v8.61.1 (#5469)
renovate[bot] Jun 20, 2026
bea4298
chore(deps): update happy-dom monorepo to v20.10.4 (#5470)
renovate[bot] Jun 21, 2026
efe9d90
chore(deps): update storybook monorepo to v10.4.6 (#5471)
renovate[bot] Jun 21, 2026
054d3bf
chore(deps): update central-publishing-maven-plugin.version to v0.11.…
renovate[bot] Jun 21, 2026
5c30e6b
chore(deps): update happy-dom monorepo to v20.10.5 (#5473)
renovate[bot] Jun 22, 2026
70c0331
chore(deps): update node.js to v24.17.0 (#5474)
renovate[bot] Jun 22, 2026
37c8c21
chore(deps): update node.js version to v24.17.0 (#5475)
renovate[bot] Jun 22, 2026
f58b4b5
fix(deps): update dependency vue-i18n to v11.4.6 (#5479)
renovate[bot] Jun 23, 2026
6eeb2ff
chore(deps): update happy-dom monorepo (#5478)
renovate[bot] Jun 23, 2026
51698f5
chore(deps): update dependency cronstrue to v3.20.0 (#5480)
renovate[bot] Jun 23, 2026
2df8e89
chore(deps): update dependency org.cyclonedx:cyclonedx-maven-plugin t…
renovate[bot] Jun 26, 2026
7fa7793
chore(deps): update dependency cronstrue to v3.21.0 (#5486)
renovate[bot] Jun 27, 2026
2848a65
chore(deps): update dependency globals to v17.7.0 (#5487)
renovate[bot] Jun 27, 2026
59da836
fix(deps): update dependency axios to v1.18.1 (#5488)
renovate[bot] Jun 27, 2026
26949b3
chore(deps): update typescript-eslint monorepo to v8.62.0 (#5489)
renovate[bot] Jun 27, 2026
e0135aa
chore(deps): update node.js to v24.18.0 (#5490)
renovate[bot] Jun 28, 2026
89cdcd0
chore(deps): update node.js to v24.18.0 (#5491)
renovate[bot] Jun 28, 2026
fa09e9f
chore(deps): update dependency vite to v8.1.0 (#5493)
renovate[bot] Jun 28, 2026
e075b45
refactor(viewRegistry): replace shallowRef with defineComponent for i…
SteKoe Jun 29, 2026
b01c2d0
chore(deps): add vite-plugin-vue-devtools and update dependencies in …
SteKoe Jun 29, 2026
76886db
refactor(index): specify views type as DefineComponent for better typ…
SteKoe Jun 29, 2026
d260a11
refactor(hex-mesh): optimize font size update and improve layout calc…
SteKoe Jun 29, 2026
c90f9a4
refactor(hex-mesh): streamline layout calculation by consolidating pa…
SteKoe Jul 1, 2026
c7106c4
fix(deps): update dependency js-yaml to v5 (#5484)
renovate[bot] Jul 1, 2026
9e49902
chore(deps): update actions/checkout action to v7 (#5477)
renovate[bot] Jul 1, 2026
4aa9d39
chore(deps): update actions/cache action to v6 (#5494)
renovate[bot] Jul 1, 2026
383b32e
chore(tests): add regression tests for UiController (#5481)
amirdeljouyi Jul 1, 2026
1f03598
chore(deps): update dependency org.graalvm.buildtools:native-maven-pl…
renovate[bot] Jul 1, 2026
ad4f738
fix(deps): update font awesome to v7.3.0 (#5498)
renovate[bot] Jul 1, 2026
04b71eb
fix(deps): update dependency vue to v3.5.39 (#5497)
renovate[bot] Jul 1, 2026
fbd1014
fix(deps): update dependency asciidoctor to v4 (#5492)
renovate[bot] Jul 1, 2026
e5997f7
chore(deps): update dependency qs to v6.15.3 (#5496)
renovate[bot] Jul 1, 2026
11559e9
chore(deps): update dependency prettier to v3.8.5 (#5499)
renovate[bot] Jul 1, 2026
70ef150
fix(deps): update dependency iso8601-duration to v2.1.4 (#5500)
renovate[bot] Jul 1, 2026
570d8a1
chore(deps): update dependency js-yaml to v5.2.0 (#5502)
renovate[bot] Jul 2, 2026
ff61ac6
chore(deps): update dependency eslint to v10.6.0 (#5501)
renovate[bot] Jul 2, 2026
6530b56
fix(deps): update dependency @fortawesome/vue-fontawesome to v3.3.0 (…
renovate[bot] Jul 2, 2026
727f883
chore(deps): update dependency vite-plugin-vue-devtools to v8.1.5 (#5…
renovate[bot] Jul 3, 2026
6b1b4d6
#5352: Replaced the old trigger-based pattern with an event listener,…
ulischulte Jul 3, 2026
670eec9
Merge branch 'master' into feature/5352-healthgroups-endpoint
ulischulte Jul 3, 2026
ac792dd
Merge remote-tracking branch 'origin/master' into feature/5352-health…
ulischulte Jul 3, 2026
5aa6808
apply changes from master
ulischulte Jul 3, 2026
dc1799a
Merge branch 'master' into feature/5352-healthgroups-endpoint
ulischulte Jul 6, 2026
b0c3735
Merge remote-tracking branch 'origin/master' into feature/5352-health…
ulischulte Jul 10, 2026
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();
Comment thread
SteKoe marked this conversation as resolved.
},
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fetchHealthGroups() now compares incoming vs. current group names and returns early when unchanged, preserving accordion open/loading state.

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