diff --git a/spring-boot-admin-server-ui/package-lock.json b/spring-boot-admin-server-ui/package-lock.json index a7bfaeb8027..9275ab8e762 100644 --- a/spring-boot-admin-server-ui/package-lock.json +++ b/spring-boot-admin-server-ui/package-lock.json @@ -78,6 +78,7 @@ "@vue/eslint-config-typescript": "^14.0.0", "@vue/test-utils": "2.4.6", "autoprefixer": "10.4.24", + "axios-mock-adapter": "^2.1.0", "babel-loader": "10.0.0", "eslint": "^10.0.0", "eslint-config-prettier": "^10.0.0", @@ -4770,6 +4771,20 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-mock-adapter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz", + "integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, "node_modules/babel-loader": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz", @@ -6021,9 +6036,9 @@ "license": "Apache-2.0" }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -7273,9 +7288,10 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -7780,6 +7796,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", diff --git a/spring-boot-admin-server-ui/package.json b/spring-boot-admin-server-ui/package.json index b9411d04585..9cc7c393fa5 100644 --- a/spring-boot-admin-server-ui/package.json +++ b/spring-boot-admin-server-ui/package.json @@ -89,6 +89,7 @@ "@vue/eslint-config-typescript": "^14.0.0", "@vue/test-utils": "2.4.6", "autoprefixer": "10.4.24", + "axios-mock-adapter": "^2.1.0", "babel-loader": "10.0.0", "eslint": "^10.0.0", "eslint-config-prettier": "^10.0.0", diff --git a/spring-boot-admin-server-ui/src/main/frontend/__mocks__/@stekoe/vue-toast-notificationcenter.js b/spring-boot-admin-server-ui/src/main/frontend/__mocks__/@stekoe/vue-toast-notificationcenter.js new file mode 100644 index 00000000000..6a39169d5dd --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/__mocks__/@stekoe/vue-toast-notificationcenter.js @@ -0,0 +1,15 @@ +// __mocks__/@stekoe/vue-toast-notificationcenter.js +import { vi } from 'vitest'; + +// Ensure errorSpy is available +if (!globalThis.errorSpy) { + globalThis.errorSpy = vi.fn(); +} + +export const useNotificationCenter = () => ({ error: globalThis.errorSpy }); + +export default { + install(app) { + app.config.globalProperties.$nc = { error: globalThis.errorSpy }; + }, +}; diff --git a/spring-boot-admin-server-ui/src/main/frontend/services/instance.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/services/instance.spec.ts index efdfeb958b7..c11d903b86d 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/services/instance.spec.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/services/instance.spec.ts @@ -1,3 +1,4 @@ +import { AxiosError } from 'axios'; import { describe, expect, test, vi } from 'vitest'; import Instance from '@/services/instance'; @@ -40,4 +41,128 @@ describe('Instance', () => { expect(instance.showUrl()).toEqual(expectUrlToBeShownOnUI); }, ); + + describe('fetchMetric', () => { + const instance = new Instance({ + id: 'test-id', + registration: { name: 'test' }, + availableMetrics: ['test.metric', 'cache.size', 'cache.gets'], + }); + test('should pass suppressToast option to axios config', async () => { + // Spy on axios.get + const axiosGetSpy = vi.spyOn(instance.axios, 'get'); + + // Mock the axios request + axiosGetSpy.mockResolvedValue({ + data: { + measurements: [{ value: 42 }], + }, + }); + + await instance.fetchMetric( + 'test.metric', + { tag: 'value' }, + { + suppressToast: true, + }, + ); + + // Verify suppressToast was passed in config + expect(axiosGetSpy).toHaveBeenCalledWith( + expect.stringContaining('actuator/metrics/test.metric'), + expect.objectContaining({ + suppressToast: true, + }), + ); + }); + + test('should work without options parameter for backward compatibility', async () => { + const axiosGetSpy = vi.spyOn(instance.axios, 'get'); + + axiosGetSpy.mockResolvedValue({ + data: { + measurements: [{ value: 42 }], + }, + }); + + await instance.fetchMetric('test.metric', { tag: 'value' }); + + // Verify it was called without suppressToast + expect(axiosGetSpy).toHaveBeenCalledWith( + expect.stringContaining('actuator/metrics/test.metric'), + expect.objectContaining({ + suppressToast: undefined, + }), + ); + }); + + test('should pass suppressToast=false when explicitly set to false', async () => { + const axiosGetSpy = vi.spyOn(instance.axios, 'get'); + + axiosGetSpy.mockResolvedValue({ + data: { + measurements: [{ value: 42 }], + }, + }); + + await instance.fetchMetric( + 'test.metric', + { tag: 'value' }, + { + suppressToast: false, + }, + ); + + expect(axiosGetSpy).toHaveBeenCalledWith( + expect.stringContaining('actuator/metrics/test.metric'), + expect.objectContaining({ + suppressToast: false, + }), + ); + }); + + test('should include tags in request parameters', async () => { + const axiosGetSpy = vi.spyOn(instance.axios, 'get'); + + axiosGetSpy.mockResolvedValue({ + data: { + measurements: [{ value: 42 }], + }, + }); + + await instance.fetchMetric('cache.gets', { + name: 'my-cache', + result: 'hit', + }); + + const callArgs = axiosGetSpy.mock.calls[0]; + const params = callArgs[1]?.params as URLSearchParams; + + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.getAll('tag')).toContain('name:my-cache'); + expect(params.getAll('tag')).toContain('result:hit'); + }); + + test('should pass suppressToast function to axios config', async () => { + const axiosGetSpy = vi.spyOn(instance.axios, 'get'); + + axiosGetSpy.mockResolvedValue({ + data: { + measurements: [{ value: 42 }], + }, + }); + + const suppressFn = (err: AxiosError) => err.response?.status === 404; + await instance.fetchMetric( + 'cache.size', + {}, + { suppressToast: suppressFn }, + ); + + expect(axiosGetSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ suppressToast: suppressFn }), + ); + }); + }); }); 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 55612ecb62a..8881cea5812 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 { AxiosInstance } from 'axios'; +import { AxiosError, AxiosInstance } from 'axios'; import saveAs from 'file-saver'; import { Observable, concat, from, ignoreElements } from 'rxjs'; @@ -29,6 +29,17 @@ import { useSbaConfig } from '@/sba-config'; import { actuatorMimeTypes } from '@/services/spring-mime-types'; import { transformToJSON } from '@/utils/transformToJSON'; +// Extend AxiosRequestConfig to allow suppressToast +declare module 'axios' { + interface AxiosRequestConfig { + suppressToast?: boolean | ((error: AxiosError) => boolean); + } +} + +export type FetchMetricOptions = { + suppressToast?: boolean | ((error: AxiosError) => boolean); +}; + const isInstanceActuatorRequest = (url: string) => url.match(/^instances[/][^/]+[/]actuator([/].*)?$/); @@ -167,7 +178,11 @@ class Instance { return response; } - async fetchMetric(metric: string, tags: Record) { + async fetchMetric( + metric: string, + tags?: Record, + options?: FetchMetricOptions, + ) { if (this.availableMetrics.length === 0) { try { await this.fetchMetrics(); @@ -203,6 +218,7 @@ class Instance { } return this.axios.get(uri`actuator/metrics/${metric}`, { params, + suppressToast: options?.suppressToast, }); } diff --git a/spring-boot-admin-server-ui/src/main/frontend/tests/setup.ts b/spring-boot-admin-server-ui/src/main/frontend/tests/setup.ts index 382a2af9a5a..e455d3e9f7e 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/tests/setup.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/tests/setup.ts @@ -31,6 +31,17 @@ global.EventSource = class { global.SBA = sbaConfig; +// Mock localStorage globally for all tests +Object.defineProperty(global, 'localStorage', { + value: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }, + writable: true, +}); + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterAll(() => server.close()); afterEach(() => server.resetHandlers()); diff --git a/spring-boot-admin-server-ui/src/main/frontend/utils/axios.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/utils/axios.spec.ts index a69386c4de2..3fa6921c2d9 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/utils/axios.spec.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/utils/axios.spec.ts @@ -13,9 +13,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import axios, { AxiosError } from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { redirectOn401 } from './axios'; +import { redirectOn401, registerErrorToastInterceptor } from './axios'; + +// Initialize errorSpy BEFORE any mocks or imports +globalThis.errorSpy = vi.fn(); + +// Mock sba-config to enable toasts +vi.mock('../sba-config', () => ({ + default: { + uiSettings: { + enableToasts: true, + }, + csrf: { + parameterName: '_csrf', + headerName: 'X-XSRF-TOKEN', + }, + }, +})); + +// Use manual mock for @stekoe/vue-toast-notificationcenter +vi.mock('@stekoe/vue-toast-notificationcenter'); describe('redirectOn401', () => { beforeEach(() => { @@ -78,3 +99,63 @@ describe('redirectOn401', () => { expect(window.location.assign).not.toBeCalled(); }); }); + +describe('registerErrorToastInterceptor', () => { + let axiosInstance; + let mock; + + beforeEach(() => { + globalThis.errorSpy.mockClear(); + axiosInstance = axios.create(); + // Pass a mock notification center directly + registerErrorToastInterceptor(axiosInstance, { + error: globalThis.errorSpy, + }); + mock = new MockAdapter(axiosInstance); + }); + + afterEach(() => { + if (mock) mock.restore(); + vi.restoreAllMocks(); + }); + + it('shows toast by default', async () => { + mock.onGet('/fail').reply(500); + await expect(axiosInstance.get('/fail')).rejects.toBeDefined(); + expect(globalThis.errorSpy).toHaveBeenCalled(); + }); + + it('suppresses toast if suppressToast is true', async () => { + mock.onGet('/fail').reply(500); + await expect( + axiosInstance.get('/fail', { suppressToast: true }), + ).rejects.toBeDefined(); + expect(globalThis.errorSpy).not.toHaveBeenCalled(); + }); + + it('shows toast if suppressToast is false', async () => { + mock.onGet('/fail').reply(500); + await expect( + axiosInstance.get('/fail', { suppressToast: false }), + ).rejects.toBeDefined(); + expect(globalThis.errorSpy).toHaveBeenCalled(); + }); + + it('suppresses toast if suppressToast function returns true', async () => { + mock.onGet('/fail').reply(404); + const suppressFn = (err: AxiosError) => err.response?.status === 404; + await expect( + axiosInstance.get('/fail', { suppressToast: suppressFn }), + ).rejects.toBeDefined(); + expect(globalThis.errorSpy).not.toHaveBeenCalled(); + }); + + it('shows toast if suppressToast function returns false', async () => { + mock.onGet('/fail').reply(500); + const suppressFn = (err: AxiosError) => err.response?.status === 404; + await expect( + axiosInstance.get('/fail', { suppressToast: suppressFn }), + ).rejects.toBeDefined(); + expect(globalThis.errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/spring-boot-admin-server-ui/src/main/frontend/utils/axios.ts b/spring-boot-admin-server-ui/src/main/frontend/utils/axios.ts index 2eeca7e893d..b0747df1ca6 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/utils/axios.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/utils/axios.ts @@ -42,21 +42,34 @@ axios.interceptors.response.use((response) => response, redirectOn401()); export default axios; -export const registerErrorToastInterceptor = (axios: AxiosInstance): void => { +export const registerErrorToastInterceptor = ( + axios, + notificationCenter = nc, +) => { if (sbaConfig.uiSettings.enableToasts) { axios.interceptors.response.use( (response) => response, (error: AxiosError) => { - const data = error.request; - const message = ` - Request failed: ${data.statusText}
- ${data.responseURL} + const suppress = error.config?.suppressToast; + let shouldSuppress = false; + if (typeof suppress === 'function') { + shouldSuppress = suppress(error); + } else { + shouldSuppress = !!suppress; + } + if (!shouldSuppress) { + const data = error.response; + const message = ` + Request failed: ${data?.statusText}
+ ${data?.config?.url || data?.request?.responseURL || ''} `; - nc.error(message, { - context: data.status ?? 'axios', - title: `Error ${data.status}`, - duration: 10000, - }); + notificationCenter.error(message, { + context: data?.status ?? 'axios', + title: `Error ${data?.status}`, + duration: 10000, + }); + } + return Promise.reject(error); }, ); } 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..90252d3b9f7 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 @@ -48,7 +48,7 @@ describe('DetailsCache', () => { const instance = application.instances[0]; return render(DetailsCache, { global: { - stubs, + stubs: { cacheChart: stubChart, ...stubs }, }, props: { instance, 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..96f15120788 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 @@ -202,9 +202,14 @@ export default { async fetchCacheSize() { if (this.shouldFetchCacheSize) { try { - const response = await this.instance.fetchMetric('cache.size', { - name: this.cacheName, - }); + const suppressFn = (err) => err.response?.status === 404; + const response = await this.instance.fetchMetric( + 'cache.size', + { + name: this.cacheName, + }, + { suppressToast: suppressFn }, + ); return response.data.measurements[0].value; } catch (error) { this.shouldFetchCacheSize = false;