Skip to content

Commit 8f3553b

Browse files
authored
#4511: reload details on instance switch (#4544)
1 parent 81c77a3 commit 8f3553b

File tree

4 files changed

+276
-56
lines changed

4 files changed

+276
-56
lines changed

spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.spec.ts

Lines changed: 115 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -10,75 +10,134 @@ import { render } from '@/test-utils';
1010
import DetailsHealth from '@/views/instances/details/details-health.vue';
1111

1212
describe('DetailsHealth', () => {
13-
describe('Health Group', () => {
14-
beforeEach(() => {
15-
server.use(
16-
http.get('/instances/:instanceId/actuator/health', () => {
17-
return HttpResponse.json({
18-
instance: 'UP',
19-
groups: ['liveness'],
20-
});
21-
}),
22-
http.get('/instances/:instanceId/actuator/health/liveness', () => {
23-
return HttpResponse.json({
24-
status: 'UP',
25-
details: {
26-
disk: { status: 'UNKNOWN' },
27-
database: { status: 'UNKNOWN' },
28-
},
29-
});
30-
}),
31-
);
13+
beforeEach(() => {
14+
server.use(
15+
http.get('/instances/:instanceId/actuator/health', () => {
16+
return HttpResponse.json({
17+
instance: 'UP',
18+
groups: ['liveness'],
19+
});
20+
}),
21+
http.get('/instances/:instanceId/actuator/health/liveness', () => {
22+
return HttpResponse.json({
23+
status: 'UP',
24+
details: {
25+
disk: { status: 'UNKNOWN' },
26+
database: { status: 'UNKNOWN' },
27+
},
28+
});
29+
}),
30+
);
31+
});
32+
33+
it('should display groups as part of health section', async () => {
34+
const application = new Application(applications[0]);
35+
const instance = application.instances[0];
36+
37+
render(DetailsHealth, {
38+
props: {
39+
instance,
40+
},
3241
});
3342

34-
it('should display groups as part of health section', async () => {
35-
const application = new Application(applications[0]);
36-
const instance = application.instances[0];
43+
await waitFor(() =>
44+
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
45+
);
3746

38-
render(DetailsHealth, {
39-
props: {
40-
instance,
41-
},
42-
});
47+
expect(
48+
await screen.findByRole('button', {
49+
name: /instances.details.health_group.title: liveness/,
50+
}),
51+
).toBeVisible();
52+
});
4353

44-
await waitFor(() =>
45-
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
46-
);
54+
it('health groups are toggleable, when details are available', async () => {
55+
const application = new Application(applications[0]);
56+
const instance = application.instances[0];
57+
instance.statusInfo = { status: 'UP', details: {} };
4758

48-
expect(
49-
await screen.findByRole('button', {
50-
name: /instances.details.health_group.title: liveness/,
51-
}),
52-
).toBeVisible();
59+
render(DetailsHealth, {
60+
props: {
61+
instance,
62+
},
5363
});
5464

55-
it('health groups are toggleable, when details are available', async () => {
56-
const application = new Application(applications[0]);
57-
const instance = application.instances[0];
58-
instance.statusInfo = {};
65+
await waitFor(() =>
66+
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
67+
);
5968

60-
render(DetailsHealth, {
61-
props: {
62-
instance,
63-
},
64-
});
69+
const button = screen.queryByRole('button', {
70+
name: /instances.details.health_group.title: liveness/,
71+
});
72+
expect(button).toBeVisible();
6573

66-
await waitFor(() =>
67-
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
68-
);
74+
expect(screen.queryByLabelText('disk')).toBeNull();
75+
expect(screen.queryByLabelText('database')).toBeNull();
6976

70-
const button = screen.queryByRole('button', {
71-
name: /instances.details.health_group.title: liveness/,
72-
});
73-
expect(button).toBeVisible();
77+
await userEvent.click(button);
78+
79+
expect(screen.queryByLabelText('disk')).toBeDefined();
80+
expect(screen.queryByLabelText('database')).toBeDefined();
81+
});
82+
83+
it('should update health details when instance prop changes (watch)', async () => {
84+
const application = new Application(applications[0]);
85+
const instance1 = application.instances[0];
86+
const instance2 = {
87+
...instance1,
88+
id: 'other-id',
89+
statusInfo: { status: 'DOWN', details: {} },
90+
};
91+
92+
const { rerender } = render(DetailsHealth, {
93+
props: {
94+
instance: instance1,
95+
},
96+
});
7497

75-
expect(screen.queryByLabelText('disk')).toBeNull();
76-
expect(screen.queryByLabelText('database')).toBeNull();
98+
await waitFor(() =>
99+
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
100+
);
77101

78-
await userEvent.click(button);
102+
// Simulate prop change
103+
await rerender({ instance: instance2 });
79104

80-
expect(screen.queryByLabelText('disk')).toBeDefined();
81-
expect(screen.queryByLabelText('database')).toBeDefined();
105+
// Wait for the component to react to the prop change
106+
await waitFor(() =>
107+
expect(
108+
screen.queryByRole('button', {
109+
name: /instances.details.health_group.title: liveness/,
110+
}),
111+
).toBeVisible(),
112+
);
113+
});
114+
115+
it('should not display health group button if no groups are present', async () => {
116+
server.use(
117+
http.get('/instances/:instanceId/actuator/health', () => {
118+
return HttpResponse.json({
119+
instance: 'UP',
120+
groups: [],
121+
});
122+
}),
123+
);
124+
const application = new Application(applications[0]);
125+
const instance = application.instances[0];
126+
127+
render(DetailsHealth, {
128+
props: {
129+
instance,
130+
},
82131
});
132+
133+
await waitFor(() =>
134+
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
135+
);
136+
137+
expect(
138+
screen.queryByRole('button', {
139+
name: /instances.details.health_group.title: liveness/,
140+
}),
141+
).toBeNull();
83142
});
84143
});

spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-health.vue

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,28 @@ export default {
9898
isOpen: boolean;
9999
collapsible: boolean;
100100
},
101+
currentInstanceId: null,
101102
}),
102103
computed: {
103104
health() {
104105
return this.liveHealth || this.instance.statusInfo;
105106
},
106107
},
108+
watch: {
109+
instance: {
110+
handler: 'reloadHealth',
111+
immediate: true,
112+
},
113+
},
107114
created() {
108115
this.fetchHealth();
109116
},
110117
methods: {
118+
reloadHealth() {
119+
if (this.instance.id !== this.currentInstanceId) {
120+
this.fetchHealth();
121+
}
122+
},
111123
isHealthGroupOpen(groupName: string) {
112124
return this.healthGroupOpenStatus[groupName].isOpen === true;
113125
},
@@ -125,6 +137,7 @@ export default {
125137
this.loading = true;
126138
try {
127139
const res = await this.instance.fetchHealth();
140+
this.currentInstanceId = this.instance.id;
128141
this.liveHealth = res.data;
129142
130143
if (Array.isArray(res.data.groups)) {
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { screen, waitFor } from '@testing-library/vue';
2+
import { AxiosHeaders } from 'axios';
3+
import { HttpResponse, http } from 'msw';
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
import DetailsInfo from './details-info.vue';
7+
8+
import { applications } from '@/mocks/applications/data';
9+
import { server } from '@/mocks/server';
10+
import Application from '@/services/application';
11+
import { render } from '@/test-utils';
12+
13+
describe('DetailsInfo', () => {
14+
beforeEach(() => {
15+
server.use(
16+
http.get('/instances/:instanceId/actuator/info', () => {
17+
return HttpResponse.json({
18+
app: { version: '1.0.0', name: 'TestApp' },
19+
java: { version: '17' },
20+
});
21+
}),
22+
);
23+
});
24+
25+
it('should render info table with keys and values', async () => {
26+
const application = new Application(applications[0]);
27+
const instance = application.instances[0];
28+
instance.hasEndpoint = () => true;
29+
// Use AxiosResponse type for the mock
30+
// eslint-disable-next-line @typescript-eslint/no-var-requires
31+
32+
instance.fetchInfo = async () => ({
33+
data: {
34+
app: { version: '1.0.0', name: 'TestApp' },
35+
java: { version: '17' },
36+
},
37+
status: 200,
38+
statusText: 'OK',
39+
headers: new AxiosHeaders(),
40+
config: { headers: new AxiosHeaders() },
41+
});
42+
43+
render(DetailsInfo, {
44+
props: { instance },
45+
});
46+
47+
await waitFor(() => {
48+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
49+
});
50+
51+
expect(await screen.findByText('app')).toBeVisible();
52+
expect(await screen.findByText('java')).toBeVisible();
53+
// YAML-formatted output is rendered in a <pre> block, so match the full string
54+
expect(
55+
await screen.findByText(/version: 1.0.0[\s\S]*name: TestApp/),
56+
).toBeVisible();
57+
expect(await screen.findByText(/version: '17'/)).toBeVisible();
58+
});
59+
60+
it('should show no info message if info is empty', async () => {
61+
const application = new Application(applications[0]);
62+
const instance = application.instances[0];
63+
instance.hasEndpoint = () => true;
64+
instance.fetchInfo = async () => ({
65+
data: {},
66+
status: 200,
67+
statusText: 'OK',
68+
headers: new AxiosHeaders(),
69+
config: { headers: new AxiosHeaders() },
70+
});
71+
72+
render(DetailsInfo, {
73+
props: { instance },
74+
});
75+
76+
await waitFor(() => {
77+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
78+
});
79+
80+
expect(
81+
await screen.findByText('instances.details.info.no_info_provided'),
82+
).toBeVisible();
83+
});
84+
85+
it('should show error alert if fetch fails', async () => {
86+
const application = new Application(applications[0]);
87+
const instance = application.instances[0];
88+
instance.hasEndpoint = () => true;
89+
instance.fetchInfo = async () => {
90+
throw new Error('fail');
91+
};
92+
93+
render(DetailsInfo, {
94+
props: { instance },
95+
});
96+
97+
await waitFor(() => {
98+
expect(screen.getByRole('alert')).toBeVisible();
99+
});
100+
expect(screen.getByRole('alert')).toHaveTextContent(
101+
'Fetching of data failed.',
102+
);
103+
});
104+
105+
it('should call fetchInfo when instance changes (watcher)', async () => {
106+
const application = new Application(applications[0]);
107+
const instance1 = application.instances[0];
108+
const instance2 = {
109+
...instance1,
110+
id: 'other-id',
111+
hasEndpoint: () => true,
112+
fetchInfo: async () => ({ data: { foo: 'bar' } }),
113+
};
114+
instance1.hasEndpoint = () => true;
115+
const fetchInfoSpy = vi
116+
.fn()
117+
.mockResolvedValue({ data: { app: { version: '1.0.0' } } });
118+
instance1.fetchInfo = fetchInfoSpy;
119+
120+
const { rerender } = render(DetailsInfo, {
121+
props: { instance: instance1 },
122+
});
123+
124+
await waitFor(() => {
125+
expect(fetchInfoSpy).toHaveBeenCalled();
126+
});
127+
128+
// Now rerender with a new instance (different id)
129+
await rerender({ instance: instance2 });
130+
131+
// Should show the new info from instance2
132+
expect(await screen.findByText('foo')).toBeVisible();
133+
expect(await screen.findByText('bar')).toBeVisible();
134+
});
135+
});

spring-boot-admin-server-ui/src/main/frontend/views/instances/details/details-info.vue

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export default {
5454
error: null,
5555
loading: false,
5656
liveInfo: null,
57+
currentInstanceId: null,
5758
}),
5859
computed: {
5960
info() {
@@ -63,12 +64,24 @@ export default {
6364
return Object.keys(this.info).length <= 0;
6465
},
6566
},
67+
watch: {
68+
instance: {
69+
handler: 'reloadInfo',
70+
immediate: true,
71+
},
72+
},
6673
created() {
6774
this.fetchInfo();
6875
},
6976
methods: {
77+
reloadInfo() {
78+
if (this.instance.id !== this.currentInstanceId) {
79+
this.fetchInfo();
80+
}
81+
},
7082
async fetchInfo() {
7183
if (this.instance.hasEndpoint('info')) {
84+
this.currentInstanceId = this.instance.id;
7285
this.loading = true;
7386
this.error = null;
7487

0 commit comments

Comments
 (0)