From db6697433dbfda8e0e4a02b26f3293eb2f47aba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sat, 14 Mar 2026 14:53:50 +0100 Subject: [PATCH 1/3] fix: reinitialize metrics and fetch data on instance version changes --- .../src/main/frontend/services/instance.ts | 2 + .../instances/details/details-cache.spec.ts | 35 +++++ .../views/instances/details/details-cache.vue | 16 +- .../details/details-datasource.spec.ts | 33 +++++ .../instances/details/details-datasource.vue | 16 +- .../instances/details/details-health.spec.ts | 104 ++++++++++++- .../instances/details/details-health.vue | 24 ++- .../instances/details/details-info.spec.ts | 120 ++++++++++++++- .../views/instances/details/details-info.vue | 26 +++- .../instances/details/details-memory.spec.ts | 140 ++++++++++++++++++ .../instances/details/details-memory.vue | 16 +- .../instances/details/details-metadata.vue | 4 +- .../instances/details/details-threads.spec.ts | 35 +++++ .../instances/details/details-threads.vue | 16 +- .../views/instances/details/index.spec.ts | 124 ++++++++++++++++ .../views/instances/details/index.vue | 57 +++++-- 16 files changed, 729 insertions(+), 39 deletions(-) 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 c67dada41ee..9bb551c5824 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 @@ -40,6 +40,7 @@ class Instance { public availableMetrics: string[] = []; public tags: { [key: string]: string }[]; public statusTimestamp: string; + public version?: number; public buildVersion: string; public statusInfo: StatusInfo; @@ -534,6 +535,7 @@ type StatusInfo = { type InstanceData = { id: string; + version?: number; registration: Registration; endpoints?: Endpoint[]; availableMetrics?: string[]; diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-cache.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-cache.spec.ts index 43c64902cc9..36683f7e41b 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-cache.spec.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-cache.spec.ts @@ -229,4 +229,39 @@ describe('DetailsCache', () => { expect(queryByText(`${HITS[0]}`)).not.toBeInTheDocument(); }); }); + + it('should reinitialize metrics when instance version changes (SSE update)', async () => { + const application = new Application(applications[0]); + const instance1 = application.instances[0]; + + const { getByText, rerender, queryByText } = await render(DetailsCache, { + props: { + instance: instance1, + cacheName: CACHE_NAME, + index: 0, + }, + }); + + // wait until initial fetch rendered a numeric value + await waitFor(() => { + expect(getByText(`${HITS[0]}`)).toBeTruthy(); + }); + + const instance2 = new Application({ + ...application, + instances: [ + { + ...instance1, + id: instance1.id, + version: (instance1.version ?? 0) + 1, + }, + ], + }).instances[0]; + + await rerender({ instance: instance2, cacheName: CACHE_NAME, index: 0 }); + + await waitFor(() => { + expect(queryByText(`${HITS[0]}`)).not.toBeInTheDocument(); + }); + }); }); diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-cache.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-cache.vue index 5c49b26ccba..8b668930e21 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-cache.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-cache.vue @@ -116,6 +116,7 @@ export default { shouldFetchCacheMisses: true, chartData: [], currentInstanceId: null, + currentInstanceUpdateKey: null, }), computed: { ratio() { @@ -139,8 +140,15 @@ export default { }, methods: { initCacheMetrics() { - if (this.instance.id !== this.currentInstanceId) { + const updateKey = + this.instance.version ?? this.instance.statusTimestamp ?? this.instance.id; + const firstInit = this.currentInstanceId === null; + if ( + this.instance.id !== this.currentInstanceId || + updateKey !== this.currentInstanceUpdateKey + ) { this.currentInstanceId = this.instance.id; + this.currentInstanceUpdateKey = updateKey; this.hasLoaded = false; this.error = null; this.current = null; @@ -148,6 +156,12 @@ export default { this.shouldFetchCacheSize = true; this.shouldFetchCacheHits = true; this.shouldFetchCacheMisses = true; + + // Restart polling immediately so SSE updates refresh the view. + if (!firstInit) { + this.unsubscribe(); + this.subscribe(); + } } }, async fetchMetrics() { diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-datasource.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-datasource.spec.ts index a89a821a14a..b3c53d7f909 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-datasource.spec.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-datasource.spec.ts @@ -163,4 +163,37 @@ describe('DetailsDatasource', () => { expect(screen.queryByText(`${ACTIVE[0]}`)).not.toBeInTheDocument(); }); }); + + it('should reinitialize metrics when instance version changes (SSE update)', async () => { + const application = new Application(applications[0]); + const instance1 = application.instances[0]; + + const { rerender } = await render(DetailsDatasource, { + props: { + instance: instance1, + dataSource: DATA_SOURCE, + }, + }); + + await waitFor(() => { + expect(screen.getByText(`${ACTIVE[0]}`)).toBeVisible(); + }); + + const instance2 = new Application({ + ...application, + instances: [ + { + ...instance1, + id: instance1.id, + version: (instance1.version ?? 0) + 1, + }, + ], + }).instances[0]; + + await rerender({ instance: instance2, dataSource: DATA_SOURCE }); + + await waitFor(() => { + expect(screen.queryByText(`${ACTIVE[0]}`)).not.toBeInTheDocument(); + }); + }); }); diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-datasource.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-datasource.vue index 58fc09f2e53..a84124b9810 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-datasource.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-datasource.vue @@ -96,6 +96,7 @@ export default { current: null, chartData: [], currentInstanceId: null, + currentInstanceUpdateKey: null, }), watch: { instance: { @@ -105,12 +106,25 @@ export default { }, methods: { initDatasourceMetrics() { - if (this.instance.id !== this.currentInstanceId) { + const updateKey = + this.instance.version ?? this.instance.statusTimestamp ?? this.instance.id; + const firstInit = this.currentInstanceId === null; + if ( + this.instance.id !== this.currentInstanceId || + updateKey !== this.currentInstanceUpdateKey + ) { this.currentInstanceId = this.instance.id; + this.currentInstanceUpdateKey = updateKey; this.hasLoaded = false; this.error = null; this.current = null; this.chartData = []; + + // Restart polling immediately so SSE updates refresh the view. + if (!firstInit) { + this.unsubscribe(); + this.subscribe(); + } } }, async fetchMetrics() { 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 6c1694fdaf8..dcd2f33e28b 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 @@ -1,7 +1,7 @@ import userEvent from '@testing-library/user-event'; import { screen, waitFor } from '@testing-library/vue'; import { HttpResponse, http } from 'msw'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { applications } from '@/mocks/applications/data'; import { server } from '@/mocks/server'; @@ -9,10 +9,24 @@ import Application from '@/services/application'; import { render } from '@/test-utils'; import DetailsHealth from '@/views/instances/details/details-health.vue'; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe('DetailsHealth', () => { + const healthHandlerSpy = vi.fn(); + beforeEach(() => { + healthHandlerSpy.mockReset(); server.use( http.get('/instances/:instanceId/actuator/health', () => { + healthHandlerSpy(); return HttpResponse.json({ instance: 'UP', groups: ['liveness'], @@ -112,6 +126,94 @@ describe('DetailsHealth', () => { ); }); + it('should refetch health when instance version changes (SSE update)', async () => { + const application = new Application(applications[0]); + const instance1 = application.instances[0]; + + const { rerender } = render(DetailsHealth, { + props: { + instance: instance1, + }, + }); + + await waitFor(() => { + expect(healthHandlerSpy).toHaveBeenCalledTimes(1); + }); + + const instance2 = new Application({ + ...application, + instances: [ + { + ...instance1, + id: instance1.id, + version: (instance1.version ?? 0) + 1, + }, + ], + }).instances[0]; + + await rerender({ instance: instance2 }); + + await waitFor(() => { + expect(healthHandlerSpy).toHaveBeenCalledTimes(2); + }); + }); + + it('should ignore stale health response when instance updates quickly', async () => { + const application = new Application(applications[0]); + const instance1 = application.instances[0]; + + const p1 = deferred<{ data: any }>(); + const p2 = deferred<{ data: any }>(); + const fetchHealthSpy = vi + .fn() + .mockReturnValueOnce(p1.promise) + .mockReturnValueOnce(p2.promise); + instance1.fetchHealth = fetchHealthSpy; + instance1.fetchHealthGroup = vi.fn().mockResolvedValue({ data: {} }); + + const { rerender } = render(DetailsHealth, { + props: { + instance: instance1, + }, + }); + + await waitFor(() => { + expect(fetchHealthSpy).toHaveBeenCalledTimes(1); + }); + + const instance2 = new Application({ + ...application, + instances: [ + { + ...instance1, + id: instance1.id, + version: (instance1.version ?? 0) + 1, + }, + ], + }).instances[0]; + instance2.fetchHealth = fetchHealthSpy; + instance2.fetchHealthGroup = instance1.fetchHealthGroup; + + await rerender({ instance: instance2 }); + + await waitFor(() => { + expect(fetchHealthSpy).toHaveBeenCalledTimes(2); + }); + + // Resolve second (newer) response first. + p2.resolve({ data: { status: 'UP', groups: [] } }); + await waitFor(() => { + expect(screen.getByText('UP')).toBeInTheDocument(); + }); + + // Resolve first (older) response afterwards; UI must not regress. + p1.resolve({ data: { status: 'DOWN', groups: [] } }); + await waitFor(() => { + expect(screen.queryByText('DOWN')).not.toBeInTheDocument(); + }); + expect(screen.getByText('UP')).toBeInTheDocument(); + }); + it('should not display health group button if no groups are present', async () => { server.use( http.get('/instances/:instanceId/actuator/health', () => { 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 fbf6cfd7505..fc75353d107 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 @@ -116,6 +116,8 @@ export default { collapsible: boolean; }, currentInstanceId: null, + currentInstanceUpdateKey: null, + fetchToken: 0, }), computed: { health() { @@ -128,12 +130,14 @@ export default { immediate: true, }, }, - created() { - this.fetchHealth(); - }, methods: { reloadHealth() { - if (this.instance.id !== this.currentInstanceId) { + const updateKey = + this.instance.version ?? this.instance.statusTimestamp ?? this.instance.id; + if ( + this.instance.id !== this.currentInstanceId || + updateKey !== this.currentInstanceUpdateKey + ) { this.fetchHealth(); } }, @@ -150,11 +154,17 @@ export default { } }, async fetchHealth() { + const token = ++this.fetchToken; this.error = null; this.loading = true; try { const res = await this.instance.fetchHealth(); + if (token !== this.fetchToken) { + return; + } this.currentInstanceId = this.instance.id; + this.currentInstanceUpdateKey = + this.instance.version ?? this.instance.statusTimestamp ?? this.instance.id; this.liveHealth = res.data; if (Array.isArray(res.data.groups)) { @@ -188,9 +198,15 @@ export default { .reduce((acc, curr) => ({ ...acc, ...curr }), {}); } } catch (error) { + if (token !== this.fetchToken) { + return; + } console.warn('Fetching live health failed:', error); this.error = error; } finally { + if (token !== this.fetchToken) { + return; + } this.loading = false; } }, diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-info.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-info.spec.ts index 26d847f9b58..10c132f8a80 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-info.spec.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-info.spec.ts @@ -10,6 +10,16 @@ import { server } from '@/mocks/server'; import Application from '@/services/application'; import { render } from '@/test-utils'; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe('DetailsInfo', () => { beforeEach(() => { server.use( @@ -103,12 +113,17 @@ describe('DetailsInfo', () => { it('should call fetchInfo when instance changes (watcher)', async () => { const application = new Application(applications[0]); const instance1 = application.instances[0]; - const instance2 = { - ...instance1, - id: 'other-id', - hasEndpoint: () => true, - fetchInfo: async () => ({ data: { foo: 'bar' } }), - }; + const instance2 = new Application({ + ...application, + instances: [ + { + ...instance1, + id: 'other-id', + }, + ], + }).instances[0]; + instance2.hasEndpoint = () => true; + instance2.fetchInfo = async () => ({ data: { foo: 'bar' } }); instance1.hasEndpoint = () => true; const fetchInfoSpy = vi .fn() @@ -130,4 +145,97 @@ describe('DetailsInfo', () => { expect(await screen.findByText('foo')).toBeVisible(); expect(await screen.findByText('bar')).toBeVisible(); }); + + it('should call fetchInfo when instance version changes (SSE update)', async () => { + const application = new Application(applications[0]); + const instance1 = application.instances[0]; + instance1.hasEndpoint = () => true; + + const fetchInfoSpy = vi + .fn() + .mockResolvedValueOnce({ data: { app: { version: '1.0.0' } } }) + .mockResolvedValueOnce({ data: { app: { version: '2.0.0' } } }); + instance1.fetchInfo = fetchInfoSpy; + + const { rerender } = render(DetailsInfo, { + props: { instance: instance1 }, + }); + + await waitFor(() => { + expect(fetchInfoSpy).toHaveBeenCalledTimes(1); + }); + + const instance2 = new Application({ + ...application, + instances: [ + { + ...instance1, + id: instance1.id, + version: (instance1.version ?? 0) + 1, + }, + ], + }).instances[0]; + instance2.hasEndpoint = () => true; + instance2.fetchInfo = fetchInfoSpy; + + await rerender({ instance: instance2 }); + + await waitFor(() => { + expect(fetchInfoSpy).toHaveBeenCalledTimes(2); + }); + + expect(await screen.findByText(/version: 2.0.0/)).toBeVisible(); + }); + + it('should ignore stale info response when instance updates quickly', async () => { + const application = new Application(applications[0]); + const instance1 = application.instances[0]; + instance1.hasEndpoint = () => true; + + const p1 = deferred<{ data: any }>(); + const p2 = deferred<{ data: any }>(); + const fetchInfoSpy = vi + .fn() + .mockReturnValueOnce(p1.promise) + .mockReturnValueOnce(p2.promise); + instance1.fetchInfo = fetchInfoSpy; + + const { rerender } = render(DetailsInfo, { + props: { instance: instance1 }, + }); + + await waitFor(() => { + expect(fetchInfoSpy).toHaveBeenCalledTimes(1); + }); + + const instance2 = new Application({ + ...application, + instances: [ + { + ...instance1, + id: instance1.id, + version: (instance1.version ?? 0) + 1, + }, + ], + }).instances[0]; + instance2.hasEndpoint = () => true; + instance2.fetchInfo = fetchInfoSpy; + + await rerender({ instance: instance2 }); + + await waitFor(() => { + expect(fetchInfoSpy).toHaveBeenCalledTimes(2); + }); + + // Resolve second (newer) response first. + p2.resolve({ data: { app: { version: '2.0.0' } } }); + expect(await screen.findByText(/version: 2.0.0/)).toBeVisible(); + + // Resolve first (older) response afterwards; UI must not regress. + p1.resolve({ data: { app: { version: '1.0.0' } } }); + await waitFor(() => { + expect(screen.queryByText(/version: 1.0.0/)).not.toBeInTheDocument(); + }); + expect(await screen.findByText(/version: 2.0.0/)).toBeVisible(); + }); }); diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-info.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-info.vue index 07dc42d7919..8f641020ec4 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-info.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-info.vue @@ -66,32 +66,45 @@ const error = ref(null); const loading = ref(false); const liveInfo = ref(null); const currentInstanceId = ref(null); +const currentInstanceUpdateKey = ref(null); +const fetchToken = ref(0); const info = computed(() => formatInfo(liveInfo.value || props.instance.info)); const isEmptyInfo = computed(() => Object.keys(info.value).length <= 0); async function fetchInfo() { if (props.instance.hasEndpoint('info')) { + const token = ++fetchToken.value; + currentInstanceId.value = props.instance.id; + currentInstanceUpdateKey.value = + props.instance.version ?? props.instance.statusTimestamp ?? props.instance.id; loading.value = true; error.value = null; try { const res = await props.instance.fetchInfo(); + if (token !== fetchToken.value) { + return; + } liveInfo.value = res.data; } catch (err) { + if (token !== fetchToken.value) { + return; + } error.value = err; console.warn('Fetching info failed:', err); } finally { + if (token !== fetchToken.value) { + return; + } loading.value = false; } } } function reloadInfo() { - if (props.instance.id !== currentInstanceId.value) { - fetchInfo(); - } + fetchInfo(); } function formatInfo(info) { @@ -109,12 +122,13 @@ function formatInfo(info) { } watch( - () => props.instance, + () => [ + props.instance.id, + props.instance.version ?? props.instance.statusTimestamp, + ], () => reloadInfo(), { immediate: true }, ); - -fetchInfo();