diff --git a/spring-boot-admin-server-ui/.storybook/preview.js b/spring-boot-admin-server-ui/.storybook/preview.js index 04de9c96924..60460106dd3 100644 --- a/spring-boot-admin-server-ui/.storybook/preview.js +++ b/spring-boot-admin-server-ui/.storybook/preview.js @@ -5,10 +5,17 @@ import Vue from "vue/dist/vue.js"; import components from "@/components"; import i18n from "@/i18n"; import VueI18n from "vue-i18n"; +import mappingsEndpoint from '@/mocks/instance/mappings'; + +import { initialize, mswDecorator } from 'msw-storybook-addon'; + +// Initialize MSW +initialize(); Vue.use(VueI18n); Vue.use(components); + export const parameters = { actions: {argTypesRegex: "^on[A-Z].*"}, controls: { @@ -17,6 +24,12 @@ export const parameters = { date: /Date$/, }, }, + msw: { + handlers: { + auth: null, + others: [...mappingsEndpoint] + } + } } export const decorators = [ @@ -25,7 +38,8 @@ export const decorators = [ return Vue.extend({ i18n, components: {wrapped}, - template: `` + template: ` + ` }) - }, + }, mswDecorator ] diff --git a/spring-boot-admin-server-ui/package-lock.json b/spring-boot-admin-server-ui/package-lock.json index 42b1b6714c7..c0e858146e7 100644 --- a/spring-boot-admin-server-ui/package-lock.json +++ b/spring-boot-admin-server-ui/package-lock.json @@ -70,6 +70,7 @@ "jest-each": "26.6.2", "jest-environment-jsdom-sixteen": "2.0.0", "msw": "0.35.0", + "msw-storybook-addon": "1.5.0", "node-sass": "6.0.1", "sass-loader": "10.2.0", "style-loader": "1.3.0", @@ -24679,6 +24680,19 @@ "url": "https://opencollective.com/mswjs" } }, + "node_modules/msw-storybook-addon": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-1.5.0.tgz", + "integrity": "sha512-2TmCREX+lVFlYwu+l9bwoxeI5+iXinJ7xOAahDKNRhgIrymaRTjAmCQx3h2NT7DJDvZKQcPstgCdhl03Q7ocHA==", + "dev": true, + "dependencies": { + "@storybook/addons": "^6.0.0", + "is-node-process": "^1.0.1" + }, + "peerDependencies": { + "msw": "^0.35.0" + } + }, "node_modules/msw/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -55515,6 +55529,16 @@ } } }, + "msw-storybook-addon": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-1.5.0.tgz", + "integrity": "sha512-2TmCREX+lVFlYwu+l9bwoxeI5+iXinJ7xOAahDKNRhgIrymaRTjAmCQx3h2NT7DJDvZKQcPstgCdhl03Q7ocHA==", + "dev": true, + "requires": { + "@storybook/addons": "^6.0.0", + "is-node-process": "^1.0.1" + } + }, "multicast-dns": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-6.2.3.tgz", diff --git a/spring-boot-admin-server-ui/package.json b/spring-boot-admin-server-ui/package.json index b30c7896e6e..1e59c1c38a0 100644 --- a/spring-boot-admin-server-ui/package.json +++ b/spring-boot-admin-server-ui/package.json @@ -8,7 +8,7 @@ "lint": "vue-cli-service lint", "lint:fix": "vue-cli-service lint --fix", "watch": "vue-cli-service build --watch", - "storybook": "start-storybook -p 6006", + "storybook": "start-storybook -p 6006 -s public", "build-storybook": "build-storybook" }, "engines": { @@ -81,6 +81,7 @@ "jest-each": "26.6.2", "jest-environment-jsdom-sixteen": "2.0.0", "msw": "0.35.0", + "msw-storybook-addon": "1.5.0", "node-sass": "6.0.1", "sass-loader": "10.2.0", "style-loader": "1.3.0", @@ -168,5 +169,8 @@ "**/*.spec.js" ], "testURL": "http://example.com" + }, + "msw": { + "workerDirectory": "public" } } diff --git a/spring-boot-admin-server-ui/public/mockServiceWorker.js b/spring-boot-admin-server-ui/public/mockServiceWorker.js new file mode 100644 index 00000000000..86167669ad2 --- /dev/null +++ b/spring-boot-admin-server-ui/public/mockServiceWorker.js @@ -0,0 +1,338 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (0.35.0). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = 'f0a916b13c8acc2b526a03a6d26df85f' +const bypassHeaderName = 'x-msw-bypass' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + return self.skipWaiting() +}) + +self.addEventListener('activate', async function (event) { + return self.clients.claim() +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll() + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +// Resolve the "master" client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMasterClient(event) { + const client = await self.clients.get(event.clientId) + + if (client.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll() + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function handleRequest(event, requestId) { + const client = await resolveMasterClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: serializeHeaders(clonedResponse.headers), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +async function getResponse(event, client, requestId) { + const { request } = event + const requestClone = request.clone() + const getOriginalResponse = () => fetch(requestClone) + + // Bypass mocking when the request client is not active. + if (!client) { + return getOriginalResponse() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return await getOriginalResponse() + } + + // Bypass requests with the explicit bypass header + if (requestClone.headers.get(bypassHeaderName) === 'true') { + const cleanRequestHeaders = serializeHeaders(requestClone.headers) + + // Remove the bypass header to comply with the CORS preflight check. + delete cleanRequestHeaders[bypassHeaderName] + + const originalRequest = new Request(requestClone, { + headers: new Headers(cleanRequestHeaders), + }) + + return fetch(originalRequest) + } + + // Send the request to the client-side MSW. + const reqHeaders = serializeHeaders(request.headers) + const body = await request.text() + + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: reqHeaders, + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body, + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_SUCCESS': { + return delayPromise( + () => respondWithMock(clientMessage), + clientMessage.payload.delay, + ) + } + + case 'MOCK_NOT_FOUND': { + return getOriginalResponse() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.payload + const networkError = new Error(message) + networkError.name = name + + // Rejecting a request Promise emulates a network error. + throw networkError + } + + case 'INTERNAL_ERROR': { + const parsedBody = JSON.parse(clientMessage.payload.body) + + console.error( + `\ +[MSW] Uncaught exception in the request handler for "%s %s": + +${parsedBody.location} + +This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ +`, + request.method, + request.url, + ) + + return respondWithMock(clientMessage) + } + } + + return getOriginalResponse() +} + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = uuidv4() + + return event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +function serializeHeaders(headers) { + const reqHeaders = {} + headers.forEach((value, name) => { + reqHeaders[name] = reqHeaders[name] + ? [].concat(reqHeaders[name]).concat(value) + : value + }) + return reqHeaders +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(JSON.stringify(message), [channel.port2]) + }) +} + +function delayPromise(cb, duration) { + return new Promise((resolve) => { + setTimeout(() => resolve(cb()), duration) + }) +} + +function respondWithMock(clientMessage) { + return new Response(clientMessage.payload.body, { + ...clientMessage.payload, + headers: clientMessage.payload.headers, + }) +} + +function uuidv4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0 + const v = c == 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} diff --git a/spring-boot-admin-server-ui/src/main/frontend/mocks/browser.js b/spring-boot-admin-server-ui/src/main/frontend/mocks/browser.js new file mode 100644 index 00000000000..2428b407589 --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/mocks/browser.js @@ -0,0 +1,4 @@ +import {setupWorker} from 'msw' +import {handlers} from '@/handlers'; + +export const worker = setupWorker(...handlers) diff --git a/spring-boot-admin-server-ui/src/main/frontend/mocks/instance/mappings/auditevents_data.js b/spring-boot-admin-server-ui/src/main/frontend/mocks/instance/mappings/auditevents_data.js new file mode 100644 index 00000000000..ccd1654ce45 --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/mocks/instance/mappings/auditevents_data.js @@ -0,0 +1,293 @@ +let now = new Date(); +const today = now.getFullYear() + "-" + (now.getMonth()+1) + "-" + now.getDate(); + +export const auditeventsresponse = { + "events": [ + { + "timestamp": today +"T05:03:58.546Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T05:03:57.435400Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "12345" + } + }, + { + "timestamp": today +"T05:03:55.729744Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T05:03:55.729498Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "12345" + } + }, + { + "timestamp": today +"T04:42:49.715651Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T04:42:49.715194Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "12345" + } + }, + { + "timestamp": today +"T04:42:49.156299Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T04:42:49.156030Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "12345" + } + }, + { + "timestamp": today +"T04:42:48.773277Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T04:42:48.773099Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "12345" + } + }, + { + "timestamp": today +"T04:42:05.304815Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T04:42:05.297055Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "12345" + } + }, + { + "timestamp": today +"T04:37:54.450217Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "application", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T04:37:54.449951Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "application", + "sessionId": "anonymous", + "objectId": "12345" + } + }, + { + "timestamp": today +"T04:29:40.238505Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "application", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T04:29:40.238317Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "application", + "sessionId": "anonymous", + "objectId": "12345" + } + }, + { + "timestamp": today +"T01:15:34.825846Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T01:15:34.825565Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "12345" + } + }, + { + "timestamp": today +"T01:15:34.001904Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T01:15:34.001034Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "12345" + } + }, + { + "timestamp": today +"T01:15:33.561225Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T01:15:33.560174Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "12345" + } + }, + { + "timestamp": today +"T01:14:41.743788Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T01:14:41.742988Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "12345" + } + }, + { + "timestamp": today +"T01:14:13.969356Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "\"Test\"", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "Test" + } + }, + { + "timestamp": today +"T01:14:13.954901Z", + "principal": "anonymous", + "type": "CREATE_TEST", + "data": { + "json": "{\"id\":\"12345\",\"timestamp\":\"2021-10-12T14:48:34.738201+08:00\",\"gender\":\"M\"}", + "source": "DUM", + "sessionId": "anonymous", + "objectId": "12345" + } + } + ] +} diff --git a/spring-boot-admin-server-ui/src/main/frontend/mocks/instance/mappings/index.js b/spring-boot-admin-server-ui/src/main/frontend/mocks/instance/mappings/index.js index 6868aeaef97..87bc068c33b 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/mocks/instance/mappings/index.js +++ b/spring-boot-admin-server-ui/src/main/frontend/mocks/instance/mappings/index.js @@ -1,5 +1,6 @@ import { rest } from 'msw'; import { mappings } from './data'; +import { auditeventsresponse } from "./auditevents_data"; const mappingsEndpoint = [ rest.get( @@ -8,6 +9,12 @@ const mappingsEndpoint = [ return res(ctx.status(200), ctx.json(mappings)); } ), + rest.get( + '/instances/:instanceId/actuator/auditevents', + (req, res, ctx) => { + return res(ctx.status(200), ctx.json(auditeventsresponse)); + } + ), ]; export default mappingsEndpoint; diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/auditevents/auditevents-list.stories.js b/spring-boot-admin-server-ui/src/main/frontend/views/instances/auditevents/auditevents-list.stories.js new file mode 100644 index 00000000000..425dfb93066 --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/auditevents/auditevents-list.stories.js @@ -0,0 +1,20 @@ +import Index from './index'; +import Instance from "@/services/instance"; +import {applications} from '@/mocks/applications/data' + +export default { + component: Index, + title: 'SBA View/AuditeventsList', +}; + +const Template = (args, {argTypes}) => ({ + components: {Index}, + props: Object.keys(argTypes), + template: '' +}); + +export const Test = Template.bind({}); +Test.args = { + instance: new Instance({id: 'bba333956ae6', ...applications[0].instances[0]}) +}; +