From d9e0e139b17d5825d0890e3fc1f2847b56a6a68f Mon Sep 17 00:00:00 2001 From: ulrichschulte Date: Thu, 23 Apr 2026 21:32:00 +0200 Subject: [PATCH] Removed calls to client actuator endpoints in the health and info detail views. Instead, these views now reactively consume the health and info data that are already kept up-to-date via SSE events from the backend's StatusUpdater and InfoUpdater. Health groups are fetched once per instance (not on every SSE update) and group details are loaded lazily on click. This preserves the #5286 fix (no 1+N requests on SSE status changes) while restoring health group functionality. --- .../instances/details/details-health.spec.ts | 457 +++++++++++------- .../instances/details/details-health.vue | 227 ++++----- .../instances/details/details-info.spec.ts | 295 +++++------ .../views/instances/details/details-info.vue | 69 +-- 4 files changed, 483 insertions(+), 565 deletions(-) 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 fbdf71e32f8..1bd3faa6ee7 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,74 +1,40 @@ import userEvent from '@testing-library/user-event'; import { screen, waitFor } from '@testing-library/vue'; -import { HttpResponse, http } from 'msw'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AxiosResponse } from 'axios'; +import { describe, expect, it, vi } from 'vitest'; import { applications } from '@/mocks/applications/data'; -import { server } from '@/mocks/server'; 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'], - }); - }), - http.get('/instances/:instanceId/actuator/health/liveness', () => { - return HttpResponse.json({ - status: 'UP', - details: { - disk: { status: 'UNKNOWN' }, - database: { status: 'UNKNOWN' }, - }, - }); - }), - ); - }); - - it('should display groups as part of health section', async () => { + it('should display health status from instance.statusInfo', async () => { const application = new Application(applications[0]); const instance = application.instances[0]; + // Mock fetchHealth for groups (will be called once on mount) + instance.fetchHealth = vi + .fn() + .mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } }); + render(DetailsHealth, { props: { instance, }, }); - await waitFor(() => - expect(screen.queryByRole('status')).not.toBeInTheDocument(), - ); - - expect( - await screen.findByRole('button', { - name: /instances.details.health_group.title: liveness/, - }), - ).toBeVisible(); + const statusBadges = await screen.findAllByRole('status'); + expect(statusBadges[0]).toHaveTextContent('UP'); }); - it('health groups are toggleable, when details are available', async () => { + it('should display health details from instance.statusInfo', async () => { const application = new Application(applications[0]); const instance = application.instances[0]; - instance.statusInfo = { status: 'UP', details: {} }; + + instance.fetchHealth = vi + .fn() + .mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } }); render(DetailsHealth, { props: { @@ -76,59 +42,17 @@ describe('DetailsHealth', () => { }, }); - await waitFor(() => - expect(screen.queryByRole('status')).not.toBeInTheDocument(), - ); - - const button = screen.queryByRole('button', { - name: /instances.details.health_group.title: liveness/, - }); - expect(button).toBeVisible(); - - expect(screen.queryByLabelText('disk')).toBeNull(); - expect(screen.queryByLabelText('database')).toBeNull(); - - await userEvent.click(button); - - expect(screen.queryByLabelText('disk')).toBeDefined(); - expect(screen.queryByLabelText('database')).toBeDefined(); + expect(await screen.findByLabelText('db')).toBeInTheDocument(); + expect(await screen.findByLabelText('diskSpace')).toBeInTheDocument(); + expect(await screen.findByLabelText('ping')).toBeInTheDocument(); }); - it('should update health details when instance prop changes (watch)', async () => { - const application = new Application(applications[0]); - const instance1 = application.instances[0]; - const instance2 = { - ...instance1, - id: 'other-id', - statusInfo: { status: 'DOWN', details: {} }, - }; - - const { rerender } = render(DetailsHealth, { - props: { - instance: instance1, - }, - }); - - await waitFor(() => - expect(screen.queryByRole('status')).not.toBeInTheDocument(), - ); - - // Simulate prop change - await rerender({ instance: instance2 }); - - // Wait for the component to react to the prop change - await waitFor(() => - expect( - screen.queryByRole('button', { - name: /instances.details.health_group.title: liveness/, - }), - ).toBeVisible(), - ); - }); - - it('should refetch health when instance version changes (SSE update)', async () => { + it('should update when instance prop changes', async () => { const application = new Application(applications[0]); const instance1 = application.instances[0]; + instance1.fetchHealth = vi + .fn() + .mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } }); const { rerender } = render(DetailsHealth, { props: { @@ -136,133 +60,290 @@ describe('DetailsHealth', () => { }, }); - await waitFor(() => { - expect(healthHandlerSpy).toHaveBeenCalledTimes(1); - }); + let statusBadges = await screen.findAllByRole('status'); + expect(statusBadges[0]).toHaveTextContent('UP'); const instance2 = new Application({ - ...application, + ...applications[0], instances: [ { - ...instance1, - id: instance1.id, - version: (instance1.version ?? 0) + 1, + ...applications[0].instances[0], + statusInfo: { status: 'DOWN', details: {} }, }, ], }).instances[0]; + instance2.fetchHealth = vi + .fn() + .mockResolvedValue({ data: { status: 'DOWN', groups: [] } }); await rerender({ instance: instance2 }); - await waitFor(() => { - expect(healthHandlerSpy).toHaveBeenCalledTimes(2); - }); + statusBadges = await screen.findAllByRole('status'); + expect(statusBadges[0]).toHaveTextContent('DOWN'); }); - it('should ignore stale health response when instance updates quickly', async () => { + it('should handle empty/missing details gracefully', 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 + const instance = application.instances[0]; + instance.statusInfo = { status: 'UP', details: {} }; + instance.fetchHealth = vi .fn() - .mockReturnValueOnce(p1.promise) - .mockReturnValueOnce(p2.promise); - instance1.fetchHealth = fetchHealthSpy; - instance1.fetchHealthGroup = vi.fn().mockResolvedValue({ data: {} }); + .mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } }); - const { rerender } = render(DetailsHealth, { + render(DetailsHealth, { props: { - instance: instance1, + instance, }, }); - await waitFor(() => { - expect(fetchHealthSpy).toHaveBeenCalledTimes(1); - }); + const statusBadges = await screen.findAllByRole('status'); + expect(statusBadges[0]).toHaveTextContent('UP'); + }); - const instance2 = new Application({ - ...application, - instances: [ - { - ...instance1, - id: instance1.id, - version: (instance1.version ?? 0) + 1, - }, - ], - }).instances[0]; - instance2.fetchHealth = fetchHealthSpy; - instance2.fetchHealthGroup = instance1.fetchHealthGroup; + describe('SSE reactive updates', () => { + it('should call fetchHealth once on mount, not 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); + + const { rerender } = render(DetailsHealth, { + props: { instance: instance1 }, + }); + + await screen.findAllByRole('status'); + expect(fetchHealthSpy1).toHaveBeenCalledTimes(1); + + // Same instance, different version (SSE update) — should NOT call fetchHealth again + const instance2 = new Application({ + ...baseApp, + instances: [ + { + ...baseApp.instances[0], + version: (baseApp.instances[0].version || 1) + 1, + statusInfo: { status: 'DOWN', details: {} }, + }, + ], + }).instances[0]; + const fetchHealthSpy2 = vi.spyOn(instance2, 'fetchHealth'); + fetchHealthSpy2.mockResolvedValue({ + data: { status: 'DOWN', groups: [] }, + } as AxiosResponse); - await rerender({ instance: instance2 }); + await rerender({ instance: instance2 }); - await waitFor(() => { - expect(fetchHealthSpy).toHaveBeenCalledTimes(2); + // Original instance's spy should still be 1 (no additional calls) + expect(fetchHealthSpy1).toHaveBeenCalledTimes(1); }); - // Resolve second (newer) response first. - p2.resolve({ data: { status: 'UP', groups: [] } }); - await waitFor(() => { - expect(screen.getByText('UP')).toBeInTheDocument(); - }); + it('should reactively update through multiple SSE status changes without extra HTTP calls', async () => { + const baseApp = applications[0]; + + const instance1 = new Application(baseApp).instances[0]; + instance1.fetchHealth = vi + .fn() + .mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } }); + + const { rerender } = render(DetailsHealth, { + props: { instance: instance1 }, + }); + + let statusBadges = await screen.findAllByRole('status'); + expect(statusBadges[0]).toHaveTextContent('UP'); + + // SSE update: same id, different version + const instance2 = new Application({ + ...baseApp, + instances: [ + { + ...baseApp.instances[0], + version: 4, + statusInfo: { + status: 'DOWN', + details: { + db: { + status: 'DOWN', + details: { error: 'Connection refused' }, + }, + }, + }, + }, + ], + }).instances[0]; + instance2.fetchHealth = vi + .fn() + .mockResolvedValue({ data: { status: 'DOWN', groups: [] } }); + + await rerender({ instance: instance2 }); - // Resolve first (older) response afterwards; UI must not regress. - p1.resolve({ data: { status: 'DOWN', groups: [] } }); - await waitFor(() => { - expect(screen.queryByText('DOWN')).not.toBeInTheDocument(); + statusBadges = await screen.findAllByRole('status'); + expect(statusBadges[0]).toHaveTextContent('DOWN'); + expect(await screen.findByLabelText('db')).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', () => { - return HttpResponse.json({ - instance: 'UP', - groups: [], - }); - }), - ); - const application = new Application(applications[0]); - const instance = application.instances[0]; + describe('lazy health groups', () => { + 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'] }, + }); + const fetchGroupSpy = vi.spyOn(instance, 'fetchHealthGroup'); - render(DetailsHealth, { - props: { - instance, - }, + render(DetailsHealth, { + props: { instance }, + }); + + await screen.findAllByRole('status'); + + expect( + await screen.findByRole('button', { name: /liveness/ }), + ).toBeInTheDocument(); + expect( + await screen.findByRole('button', { name: /readiness/ }), + ).toBeInTheDocument(); + expect(fetchGroupSpy).not.toHaveBeenCalled(); }); - await waitFor(() => - expect(screen.queryByRole('status')).not.toBeInTheDocument(), - ); + 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'] }, + }); + const fetchGroupSpy = vi.spyOn(instance, 'fetchHealthGroup'); + fetchGroupSpy.mockResolvedValue({ + data: { + status: 'UP', + details: { + customDetails: { + status: 'UP', + details: { error: 'no property sources located' }, + }, + evenMoreDiskSpace: { + status: 'UP', + details: { + total: 994662584320, + free: 300063879168, + threshold: 10485760, + exists: true, + }, + }, + }, + }, + } as AxiosResponse); + + render(DetailsHealth, { + props: { instance }, + }); - expect( - screen.queryByRole('button', { - name: /instances.details.health_group.title: liveness/, - }), - ).toBeNull(); - }); + const button = await screen.findByRole('button', { + name: /custom-group/, + }); + await userEvent.click(button); - it('should fetch health details only once on startup', async () => { - const application = new Application(applications[0]); - const instance = application.instances[0]; + await waitFor(() => { + expect(fetchGroupSpy).toHaveBeenCalledWith('custom-group'); + }); - render(DetailsHealth, { - props: { - instance, - }, + // custom-group has service component + expect(await screen.findByLabelText('customDetails')).toBeInTheDocument(); + expect( + await screen.findByLabelText('evenMoreDiskSpace'), + ).toBeInTheDocument(); }); - await waitFor(() => { - expect(healthHandlerSpy).toHaveBeenCalledTimes(1); + 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'] }, + }); + const fetchGroupSpy = vi.spyOn(instance, 'fetchHealthGroup'); + fetchGroupSpy.mockResolvedValue({ + data: { status: 'UP', details: { service: { status: 'UP' } } }, + } as AxiosResponse); + + render(DetailsHealth, { + props: { instance }, + }); + + const button = await screen.findByRole('button', { + name: /custom-group/, + }); + + // First click — fetch & show + await userEvent.click(button); + await screen.findByLabelText('service'); + + // Second click — hide + await userEvent.click(button); + expect(screen.queryByLabelText('service')).not.toBeInTheDocument(); + + // Third click — show again + await userEvent.click(button); + expect(await screen.findByLabelText('service')).toBeInTheDocument(); + + // fetchHealthGroup should only be called once (first click) + expect(fetchGroupSpy).toHaveBeenCalledTimes(1); }); - // Verify that the handler is not called again after the initial fetch - await waitFor( - () => { - expect(healthHandlerSpy).toHaveBeenCalledTimes(1); - }, - { timeout: 1000 }, - ); + 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: [] }, + }); + + render(DetailsHealth, { + props: { instance }, + }); + + await screen.findAllByRole('status'); + + expect( + screen.queryByRole('button', { name: /Health Group/ }), + ).not.toBeInTheDocument(); + }); + + it('should re-fetch groups when instance id changes', async () => { + const app1 = new Application(applications[0]).instances[0]; + app1.fetchHealth = vi + .fn() + .mockResolvedValue({ data: { status: 'UP', groups: ['liveness'] } }); + + const { rerender } = render(DetailsHealth, { + props: { instance: app1 }, + }); + + await waitFor(() => { + expect(app1.fetchHealth).toHaveBeenCalledTimes(1); + }); + + const app2 = new Application({ + ...applications[0], + instances: [ + { + ...applications[0].instances[0], + id: 'different-id', + }, + ], + }).instances[0]; + app2.fetchHealth = vi + .fn() + .mockResolvedValue({ data: { status: 'UP', groups: ['readiness'] } }); + + await rerender({ instance: app2 }); + + await waitFor(() => { + expect(app2.fetchHealth).toHaveBeenCalledTimes(1); + }); + + // Original instance should still have only 1 call + expect(app1.fetchHealth).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 e5b03911c85..4b219a03c6f 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 @@ -19,7 +19,6 @@ :id="`health-details-panel__${instance.id}`" v-model="panelOpen" :title="$t('instances.details.health.title')" - :loading="loading" >