Skip to content

Commit 2d9a00a

Browse files
authored
Fix/4511 init metrics data on instance switch (#4634)
1 parent 51cac99 commit 2d9a00a

File tree

8 files changed

+478
-32
lines changed

8 files changed

+478
-32
lines changed

spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/resources/application.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,6 @@ management:
3434
enabled: true
3535
health:
3636
show-details: ALWAYS
37-
server:
38-
port: 9999
3937

4038

4139
spring:

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

Lines changed: 100 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { screen, waitFor } from '@testing-library/vue';
2-
import { enableAutoUnmount, shallowMount } from '@vue/test-utils';
2+
import { enableAutoUnmount } from '@vue/test-utils';
33
import { HttpResponse, http } from 'msw';
44
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
55

@@ -34,6 +34,30 @@ describe('DetailsCache', () => {
3434
const MISSES_PER_INTERVAL = [0, 0, 8];
3535
const TOTAL_PER_INTERVAL = [0, 8, 24];
3636

37+
const stubChart = {
38+
props: ['data'],
39+
template: `
40+
<div data-test="chart">
41+
{{ JSON.stringify($props.data) }}
42+
</div>
43+
`,
44+
};
45+
46+
const renderComponent = async (stubs = {}) => {
47+
const application = new Application(applications[0]);
48+
const instance = application.instances[0];
49+
return render(DetailsCache, {
50+
global: {
51+
stubs,
52+
},
53+
props: {
54+
instance,
55+
cacheName: CACHE_NAME,
56+
index: 0,
57+
},
58+
});
59+
};
60+
3761
beforeEach(() => {
3862
const hitsGenerator = (function* () {
3963
yield* HITS;
@@ -68,21 +92,6 @@ describe('DetailsCache', () => {
6892
);
6993
});
7094

71-
async function renderComponent(stubs = {}) {
72-
const application = new Application(applications[0]);
73-
const instance = application.instances[0];
74-
return render(DetailsCache, {
75-
global: {
76-
stubs,
77-
},
78-
props: {
79-
instance,
80-
cacheName: CACHE_NAME,
81-
index: 0,
82-
},
83-
});
84-
}
85-
8695
it('should render cache name', async () => {
8796
await renderComponent();
8897

@@ -114,49 +123,110 @@ describe('DetailsCache', () => {
114123
const application = new Application(applications[0]);
115124
const instance = application.instances[0];
116125

117-
const vueWrapper = shallowMount(DetailsCache, {
126+
const { container } = await render(DetailsCache, {
127+
global: { stubs: { cacheChart: stubChart } },
118128
props: {
119129
instance,
120130
cacheName: CACHE_NAME,
121131
index: 0,
122132
},
123133
});
124134

125-
await waitFor(() => {
126-
expect(vueWrapper.vm.chartData).toHaveLength(3);
127-
});
135+
// wait until chart stub receives at least 3 data points
136+
await waitFor(
137+
() => {
138+
const el = container.querySelector('[data-test="chart"]');
139+
expect(el).toBeTruthy();
140+
const text = (el?.textContent as string) || '[]';
141+
const parsed = JSON.parse(text);
142+
expect(parsed).toHaveLength(3);
143+
},
144+
{ timeout: 2000 },
145+
);
146+
147+
const chartText =
148+
(container.querySelector('[data-test="chart"]')?.textContent as string) ||
149+
'[]';
150+
const chartData = JSON.parse(chartText);
128151

129-
for (let index = 0; index < vueWrapper.vm.chartData.length; index++) {
130-
expect(vueWrapper.vm.chartData[index].total).toEqual(TOTAL[index]);
152+
for (let index = 0; index < chartData.length; index++) {
153+
expect(chartData[index].total).toEqual(TOTAL[index]);
131154
}
132155
});
133156

134157
it('should calculate hits, misses and total per interval', async () => {
135158
const application = new Application(applications[0]);
136159
const instance = application.instances[0];
137160

138-
const vueWrapper = shallowMount(DetailsCache, {
161+
const { container } = await render(DetailsCache, {
162+
global: { stubs: { cacheChart: stubChart } },
139163
props: {
140164
instance,
141165
cacheName: CACHE_NAME,
142166
index: 0,
143167
},
144168
});
145169

146-
await waitFor(() => {
147-
expect(vueWrapper.vm.chartData).toHaveLength(3);
148-
});
170+
// wait until chart stub receives at least 3 data points
171+
await waitFor(
172+
() => {
173+
const el = container.querySelector('[data-test="chart"]');
174+
expect(el).toBeTruthy();
175+
const text = (el?.textContent as string) || '[]';
176+
const parsed = JSON.parse(text);
177+
expect(parsed).toHaveLength(3);
178+
},
179+
{ timeout: 2000 },
180+
);
149181

150-
for (let index = 0; index < vueWrapper.vm.chartData.length; index++) {
151-
expect(vueWrapper.vm.chartData[index].hitsPerInterval).toEqual(
182+
const chartText2 =
183+
(container.querySelector('[data-test="chart"]')?.textContent as string) ||
184+
'[]';
185+
const chartData = JSON.parse(chartText2);
186+
187+
for (let index = 0; index < chartData.length; index++) {
188+
expect(chartData[index].hitsPerInterval).toEqual(
152189
HITS_PER_INTERVAL[index],
153190
);
154-
expect(vueWrapper.vm.chartData[index].missesPerInterval).toEqual(
191+
expect(chartData[index].missesPerInterval).toEqual(
155192
MISSES_PER_INTERVAL[index],
156193
);
157-
expect(vueWrapper.vm.chartData[index].totalPerInterval).toEqual(
194+
expect(chartData[index].totalPerInterval).toEqual(
158195
TOTAL_PER_INTERVAL[index],
159196
);
160197
}
161198
});
199+
200+
it('should reinitialize metrics when instance changes', async () => {
201+
const application = new Application(applications[0]);
202+
const instance = application.instances[0];
203+
204+
const { getByText, rerender, queryByText } = await render(DetailsCache, {
205+
props: {
206+
instance,
207+
cacheName: CACHE_NAME,
208+
index: 0,
209+
},
210+
});
211+
212+
// wait until initial fetch rendered a numeric value
213+
await waitFor(() => {
214+
expect(getByText(`${HITS[0]}`)).toBeTruthy();
215+
});
216+
217+
// simulate switching to a different instance
218+
const newApp = new Application({
219+
name: 'Other',
220+
statusTimestamp: Date.now(),
221+
instances: [{ id: 'other-1', statusInfo: { status: 'UP' } }],
222+
});
223+
const newInstance = newApp.instances[0];
224+
225+
await rerender({ instance: newInstance, cacheName: CACHE_NAME, index: 0 });
226+
227+
// component should have reset its rendered data
228+
await waitFor(() => {
229+
expect(queryByText(`${HITS[0]}`)).not.toBeInTheDocument();
230+
});
231+
});
162232
});

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export default {
111111
shouldFetchCacheHits: true,
112112
shouldFetchCacheMisses: true,
113113
chartData: [],
114+
currentInstanceId: null,
114115
}),
115116
computed: {
116117
ratio() {
@@ -126,7 +127,25 @@ export default {
126127
return undefined;
127128
},
128129
},
130+
watch: {
131+
instance: {
132+
handler: 'initCacheMetrics',
133+
immediate: true,
134+
},
135+
},
129136
methods: {
137+
initCacheMetrics() {
138+
if (this.instance.id !== this.currentInstanceId) {
139+
this.currentInstanceId = this.instance.id;
140+
this.hasLoaded = false;
141+
this.error = null;
142+
this.current = null;
143+
this.chartData = [];
144+
this.shouldFetchCacheSize = true;
145+
this.shouldFetchCacheHits = true;
146+
this.shouldFetchCacheMisses = true;
147+
}
148+
},
130149
async fetchMetrics() {
131150
const [hit, miss, size] = await Promise.all([
132151
this.fetchCacheHits(),
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { screen, waitFor } from '@testing-library/vue';
2+
import { enableAutoUnmount } from '@vue/test-utils';
3+
import { HttpResponse, http } from 'msw';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
import { applications } from '@/mocks/applications/data';
7+
import { server } from '@/mocks/server';
8+
import Application from '@/services/application';
9+
import { render } from '@/test-utils';
10+
import DetailsDatasource from '@/views/instances/details/details-datasource.vue';
11+
12+
vi.mock('@/sba-config', async () => {
13+
const sbaConfig: any = await vi.importActual('@/sba-config');
14+
return {
15+
default: {
16+
...sbaConfig.default,
17+
uiSettings: {
18+
pollTimer: {
19+
datasource: 100,
20+
},
21+
},
22+
},
23+
};
24+
});
25+
26+
describe('DetailsDatasource', () => {
27+
enableAutoUnmount(afterEach);
28+
29+
const DATA_SOURCE = 'my-ds';
30+
const ACTIVE = [2.0, 3.0, 4.0];
31+
const MIN = [1.0, 1.0, 1.0];
32+
const MAX = [5.0, -1.0, 10.0]; // include unlimited (-1) case
33+
34+
beforeEach(() => {
35+
const activeGenerator = (function* () {
36+
yield* ACTIVE;
37+
})();
38+
const minGenerator = (function* () {
39+
yield* MIN;
40+
})();
41+
const maxGenerator = (function* () {
42+
yield* MAX;
43+
})();
44+
45+
server.use(
46+
http.get(
47+
'/instances/:instanceId/actuator/metrics/jdbc.connections.active',
48+
() => {
49+
return HttpResponse.json({
50+
measurements: [{ value: activeGenerator.next()?.value }],
51+
});
52+
},
53+
),
54+
http.get(
55+
'/instances/:instanceId/actuator/metrics/jdbc.connections.min',
56+
() => {
57+
return HttpResponse.json({
58+
measurements: [{ value: minGenerator.next()?.value }],
59+
});
60+
},
61+
),
62+
http.get(
63+
'/instances/:instanceId/actuator/metrics/jdbc.connections.max',
64+
() => {
65+
return HttpResponse.json({
66+
measurements: [{ value: maxGenerator.next()?.value }],
67+
});
68+
},
69+
),
70+
);
71+
});
72+
73+
async function renderComponent(stubs = {}) {
74+
const application = new Application(applications[0]);
75+
const instance = application.instances[0];
76+
return render(DetailsDatasource, {
77+
global: {
78+
stubs,
79+
},
80+
props: {
81+
instance,
82+
dataSource: DATA_SOURCE,
83+
},
84+
});
85+
}
86+
87+
it('renders panel title with datasource name', async () => {
88+
await renderComponent();
89+
90+
// title uses i18n keys in the test environment; ensure heading is present
91+
const heading = await screen.findByRole('heading');
92+
expect(heading).toBeVisible();
93+
});
94+
95+
it('renders active, min and max values', async () => {
96+
await renderComponent();
97+
98+
// labels use i18n keys; assert numeric values are rendered
99+
expect(await screen.findByText(`${ACTIVE[0]}`)).toBeVisible();
100+
expect(await screen.findByText(`${MIN[0]}`)).toBeVisible();
101+
expect(await screen.findByText(`${MAX[0]}`)).toBeVisible();
102+
});
103+
104+
it('handles unlimited max (-1) and pushes chartData points', async () => {
105+
const application = new Application(applications[0]);
106+
const instance = application.instances[0];
107+
108+
await render(DetailsDatasource, {
109+
props: {
110+
instance,
111+
dataSource: DATA_SOURCE,
112+
},
113+
});
114+
115+
// wait until initial metric value is visible
116+
await waitFor(() => {
117+
expect(screen.getByText(`${ACTIVE[0]}`)).toBeVisible();
118+
});
119+
120+
// second measurement MAX[1] === -1 should be displayed as 'unlimited' in rendered markup
121+
expect(
122+
await screen.findByText('instances.details.datasource.unlimited'),
123+
).toBeVisible();
124+
});
125+
126+
it('should reinitialize metrics when instance changes', async () => {
127+
const application = new Application(applications[0]);
128+
const instance = application.instances[0];
129+
130+
const { rerender } = await render(DetailsDatasource, {
131+
props: {
132+
instance,
133+
dataSource: DATA_SOURCE,
134+
},
135+
});
136+
137+
// Wait for the component to poll and populate chartData (3 samples)
138+
await waitFor(() => {
139+
// find any of the numeric texts to ensure initial fetch happened
140+
expect(screen.getByText(`${ACTIVE[0]}`)).toBeVisible();
141+
});
142+
143+
const newApp = new Application({
144+
name: 'Other',
145+
statusTimestamp: Date.now(),
146+
instances: [{ id: 'other-1', statusInfo: { status: 'UP' } }],
147+
});
148+
const newInstance = newApp.instances[0];
149+
150+
await rerender({ instance: newInstance, dataSource: DATA_SOURCE });
151+
152+
// After rerender with a different instance, the component should reset
153+
await waitFor(() => {
154+
expect(screen.queryByText(`${ACTIVE[0]}`)).not.toBeInTheDocument();
155+
});
156+
});
157+
});

0 commit comments

Comments
 (0)