diff --git a/spring-boot-admin-server-ui/package-lock.json b/spring-boot-admin-server-ui/package-lock.json index a75a5287db6..3183eaa887f 100644 --- a/spring-boot-admin-server-ui/package-lock.json +++ b/spring-boot-admin-server-ui/package-lock.json @@ -1358,9 +1358,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -3823,9 +3823,9 @@ } }, "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -5036,9 +5036,9 @@ } }, "node_modules/@vue/language-core/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -5502,9 +5502,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -6765,9 +6765,9 @@ } }, "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -7568,16 +7568,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -7736,9 +7736,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -7906,9 +7906,9 @@ "license": "MIT" }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -8622,14 +8622,11 @@ } }, "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } + "license": "MIT" }, "node_modules/js-stringify": { "version": "1.0.2", @@ -11548,9 +11545,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { 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..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,6 +231,10 @@ class Instance { }); } + async fetchCachedHealthGroups(): Promise> { + 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 f1b90c7bbb3..5b5bfebb5ae 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: { @@ -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: { @@ -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 }); @@ -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: { @@ -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: [ @@ -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 }, @@ -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 }); @@ -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'); @@ -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({ @@ -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({ @@ -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, { @@ -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({ @@ -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); }); }); }); 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..8bad6f5d79c 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 @@ -154,9 +154,13 @@ export default defineComponent({ } return false; }, + healthGroupsKey() { + // Only re-fetch on meaningful status changes; deliberately excludes + return `${this.instance.id}:${this.instance.statusInfo?.status ?? ''}:${this.instance.statusTimestamp ?? ''}`; + }, }, watch: { - instance: { + healthGroupsKey: { handler: 'onInstanceChanged', immediate: true, }, @@ -175,15 +179,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; @@ -214,10 +212,22 @@ export default defineComponent({ this.healthGroupsError = null; try { - const res = await this.instance.fetchHealth(); + const res = await this.instance.fetchCachedHealthGroups(); + + if (Array.isArray(res.data)) { + const currentNames = this.healthGroups.map((g) => g.name); + const incomingNames = res.data; + const unchanged = + currentNames.length === incomingNames.length && + currentNames.every((name, idx) => name === incomingNames[idx]); + + // Skip reassignment when the group list is unchanged to preserve + // the accordion open/loading state and avoid needless re-renders. + if (unchanged) { + return; + } - if (Array.isArray(res.data.groups)) { - this.healthGroups = res.data.groups.map((name: string) => ({ + this.healthGroups = incomingNames.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 6ca96b0e603..affafd2a51f 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,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.HealthGroupsCacheCleanupListener; +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; @@ -96,15 +99,22 @@ 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) { AdminServerProperties.MonitorProperties monitorProperties = this.adminServerProperties.getMonitor(); StatusUpdater updater = new StatusUpdater(instanceRepository, instanceWebClientBuilder.build(), - new ApiMediaTypeHandler(), monitorProperties.getStatusChangeDetectionStrategy().asPredicate()); + new ApiMediaTypeHandler(), monitorProperties.getStatusChangeDetectionStrategy().asPredicate(), + healthGroupsCache); Duration timeout = monitorProperties.getDefaultTimeout(); Duration interval = monitorProperties.getStatusInterval(); @@ -136,6 +146,13 @@ public StatusUpdateTrigger statusUpdateTrigger(StatusUpdater statusUpdater, Publ monitorProperties.getStatusMaxBackoff()); } + @Bean + @ConditionalOnMissingBean + public HealthGroupsCacheCleanupListener healthGroupsCacheCleanupListener(Publisher events, + HealthGroupsCache healthGroupsCache) { + return new HealthGroupsCacheCleanupListener(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 52972b72efd..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. @@ -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..bc7cc1122a7 --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCache.java @@ -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 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 getGroups(InstanceId instanceId); + + /** + * Remove the health groups entry for an instance. + * @param instanceId the instance id + */ + void remove(InstanceId instanceId); + +} diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupListener.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupListener.java new file mode 100644 index 00000000000..a773fdf79fd --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupListener.java @@ -0,0 +1,77 @@ +/* + * 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 jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; + +import de.codecentric.boot.admin.server.domain.events.InstanceDeregisteredEvent; +import de.codecentric.boot.admin.server.domain.events.InstanceEvent; + +/** + * Evicts stale {@link de.codecentric.boot.admin.server.domain.entities.Instance} specific + * data from the {@link HealthGroupsCache} whenever an {@link InstanceDeregisteredEvent} + * is received. + *

+ * Since Spring Boot Admin's {@link InstanceEvent}s are propagated through a reactive + * {@link Publisher} rather than the Spring {@code ApplicationContext} event mechanism, + * this listener subscribes to that stream directly. Cache eviction is a lightweight, + * in-memory operation reacting to a rarely occurring event, so it runs on the publishing + * thread. + */ +public class HealthGroupsCacheCleanupListener { + + private final Publisher publisher; + + private final HealthGroupsCache healthGroupsCache; + + @Nullable private Disposable subscription; + + /** + * Creates a listener evicting health groups cache entries on deregistration of an + * {@link de.codecentric.boot.admin.server.domain.entities.Instance}. + * @param publisher publisher of {@link InstanceEvent}s + * @param healthGroupsCache the cache to evict entries from on deregistration of an + * {@link de.codecentric.boot.admin.server.domain.entities.Instance} + */ + public HealthGroupsCacheCleanupListener(final Publisher publisher, + final HealthGroupsCache healthGroupsCache) { + this.publisher = publisher; + this.healthGroupsCache = healthGroupsCache; + } + + @PostConstruct + public void start() { + this.subscription = Flux.from(this.publisher) + .ofType(InstanceDeregisteredEvent.class) + .doOnNext((event) -> this.healthGroupsCache.remove(event.getInstance())) + .subscribe(); + } + + @PreDestroy + public void stop() { + if (this.subscription != null) { + this.subscription.dispose(); + this.subscription = null; + } + } + +} 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/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 e4a946cadb9..30cfcd7e6dc 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. @@ -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.function.BiPredicate; @@ -64,14 +65,16 @@ public class StatusUpdater { private final BiPredicate statusChangeDetectionPredicate; - public StatusUpdater(InstanceRepository repository, InstanceWebClient instanceWebClient, - ApiMediaTypeHandler apiMediaTypeHandler) { - this(repository, instanceWebClient, apiMediaTypeHandler, - DEFAULT_STATUS_CHANGE_DETECTION_STRATEGY.asPredicate()); - } + private final HealthGroupsCache healthGroupsCache; private Duration timeout = Duration.ofSeconds(10); + public StatusUpdater(InstanceRepository repository, InstanceWebClient instanceWebClient, + ApiMediaTypeHandler apiMediaTypeHandler, HealthGroupsCache healthGroupsCache) { + this(repository, instanceWebClient, apiMediaTypeHandler, DEFAULT_STATUS_CHANGE_DETECTION_STRATEGY.asPredicate(), + healthGroupsCache); + } + public StatusUpdater timeout(Duration timeout) { this.timeout = timeout; return this; @@ -90,7 +93,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)) @@ -107,6 +110,10 @@ private Duration getTimeoutWithMargin() { } 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) @@ -115,12 +122,15 @@ protected Mono convertStatusInfo(ClientResponse response) { StatusInfo statusInfoFromStatus = this.getStatusInfoFromStatus(response.statusCode(), emptyMap()); if (hasCompatibleContentType) { - return response.bodyToMono(RESPONSE_TYPE).map((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) -> extractAndCacheHealthGroups(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)); } @@ -158,4 +168,15 @@ protected void logError(Instance instance, Throwable ex) { } } + private void extractAndCacheHealthGroups(InstanceId instanceId, Map body) { + if (this.healthGroupsCache != null && instanceId != null && body.get("groups") instanceof List groupsList) { + List groups = groupsList.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .distinct() + .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..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. @@ -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/HealthGroupsCacheCleanupListenerTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupListenerTest.java new file mode 100644 index 00000000000..a70e419a2c2 --- /dev/null +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheCleanupListenerTest.java @@ -0,0 +1,73 @@ +/* + * 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.AfterEach; +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 HealthGroupsCacheCleanupListenerTest { + + private final TestPublisher events = TestPublisher.create(); + + private HealthGroupsCache cache; + + private HealthGroupsCacheCleanupListener listener; + + @BeforeEach + void setUp() { + this.cache = new InMemoryHealthGroupsCache(); + this.listener = new HealthGroupsCacheCleanupListener(this.events.flux(), this.cache); + this.listener.start(); + await().until(this.events::wasSubscribed); + } + + @AfterEach + void tearDown() { + this.listener.stop(); + } + + @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/HealthGroupsCacheTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/services/HealthGroupsCacheTest.java new file mode 100644 index 00000000000..321df01a338 --- /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 InMemoryHealthGroupsCache(); + 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 bdb33c0cbec..acde0549167 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 @@ -72,6 +72,10 @@ class StatusUpdaterTest { private Instance instance; + private HealthGroupsCache healthGroupsCache; + + private InstanceId instanceId; + @BeforeAll static void setUp() { StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); @@ -87,10 +91,12 @@ 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(); + this.healthGroupsCache = new InMemoryHealthGroupsCache(); this.updater = createWith(StatusChangeDetectionStrategy.STATUS_ONLY); } @@ -101,7 +107,7 @@ private StatusUpdater createWith(StatusChangeDetectionStrategy statusChangeDetec .filter(retry(0, singletonMap(Endpoint.HEALTH, 1))) .filter(timeout(Duration.ofSeconds(2), emptyMap())) .build(), - new ApiMediaTypeHandler(), statusChangeDetectionStrategy.asPredicate()); + new ApiMediaTypeHandler(), statusChangeDetectionStrategy.asPredicate(), this.healthGroupsCache); } @AfterEach @@ -312,4 +318,28 @@ private void shouldUpdateStatusDetails(Map details) { }).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(); + } + }