From cf460f39ebd535ba7187bdcfd2aae6ca9c042c8f Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Mon, 25 Jan 2021 15:47:52 -0500 Subject: [PATCH] feat(actionRecordMiddleware): issues/52 limited action log (#561) * actionRecordMiddleware, session store state actions * downloadHelpers, helpers, download strings as files --- .env | 1 + config/cspell.config.json | 4 +- .../downloadHelpers.test.js.snap | 19 ++++ .../__snapshots__/helpers.test.js.snap | 3 + src/common/__tests__/downloadHelpers.test.js | 53 +++++++++++ src/common/downloadHelpers.js | 72 ++++++++++++++ src/common/helpers.js | 8 ++ src/common/index.js | 3 +- .../middleware/actionRecordMiddleware.js | 95 +++++++++++++++++++ src/redux/middleware/index.js | 5 + 10 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 src/common/__tests__/__snapshots__/downloadHelpers.test.js.snap create mode 100644 src/common/__tests__/downloadHelpers.test.js create mode 100644 src/common/downloadHelpers.js create mode 100644 src/redux/middleware/actionRecordMiddleware.js diff --git a/.env b/.env index 74cff4b07..5b8ffa991 100644 --- a/.env +++ b/.env @@ -17,6 +17,7 @@ REACT_APP_UI_DISABLED_TABLE=false REACT_APP_UI_DISABLED_TABLE_HOSTS=false REACT_APP_UI_DISABLED_TABLE_SUBSCRIPTIONS=false REACT_APP_UI_LOGGER_ID=curiosity +REACT_APP_UI_LOGGER_FILE=curiosity_debug_log_{0}.json REACT_APP_UI_WINDOW_ID=curiosity REACT_APP_AJAX_TIMEOUT=60000 diff --git a/config/cspell.config.json b/config/cspell.config.json index d27e6ac87..605dcfc97 100644 --- a/config/cspell.config.json +++ b/config/cspell.config.json @@ -15,6 +15,7 @@ "fadein", "flyout", "generatedid", + "HHmmss", "ibmpower", "ibmz", "ipsum", @@ -45,6 +46,7 @@ "uxui", "voronoi", "woot", - "wrappable" + "wrappable", + "YYYYMMDD" ] } diff --git a/src/common/__tests__/__snapshots__/downloadHelpers.test.js.snap b/src/common/__tests__/__snapshots__/downloadHelpers.test.js.snap new file mode 100644 index 000000000..04ccf7ca5 --- /dev/null +++ b/src/common/__tests__/__snapshots__/downloadHelpers.test.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DownloadHelpers should attempt to download data: data download 1`] = ` +Object { + "data": "", + "fileName": "download.txt", +} +`; + +exports[`DownloadHelpers should have specific functions: helpers 1`] = ` +Object { + "debugLog": [Function], + "downloadData": [Function], +} +`; + +exports[`DownloadHelpers should throw an error attempting to access a log: access error 1`] = `[Error: debuglog error, URL.createObjectURL is not a function]`; + +exports[`DownloadHelpers should throw an error attempting to download data: download error 1`] = `[TypeError: URL.createObjectURL is not a function]`; diff --git a/src/common/__tests__/__snapshots__/helpers.test.js.snap b/src/common/__tests__/__snapshots__/helpers.test.js.snap index 0e2bdb45d..fbd297679 100644 --- a/src/common/__tests__/__snapshots__/helpers.test.js.snap +++ b/src/common/__tests__/__snapshots__/helpers.test.js.snap @@ -21,6 +21,7 @@ Object { "UI_LINK_REPORT_ACCURACY_RECOMMENDATIONS": "https://access.redhat.com/solutions/subscription-watch-mismatch", "UI_LOCALE_DEFAULT": "en-US", "UI_LOCALE_DEFAULT_DESC": "English", + "UI_LOGGER_FILE": "curiosity_debug_log_{0}.json", "UI_LOGGER_ID": "curiosity", "UI_NAME": "subscriptions", "UI_PATH": "/", @@ -58,6 +59,7 @@ Object { "UI_LINK_REPORT_ACCURACY_RECOMMENDATIONS": "https://access.redhat.com/solutions/subscription-watch-mismatch", "UI_LOCALE_DEFAULT": "en-US", "UI_LOCALE_DEFAULT_DESC": "English", + "UI_LOGGER_FILE": "curiosity_debug_log_{0}.json", "UI_LOGGER_ID": "curiosity", "UI_NAME": "subscriptions", "UI_PATH": "/", @@ -95,6 +97,7 @@ Object { "UI_LINK_REPORT_ACCURACY_RECOMMENDATIONS": "https://access.redhat.com/solutions/subscription-watch-mismatch", "UI_LOCALE_DEFAULT": "en-US", "UI_LOCALE_DEFAULT_DESC": "English", + "UI_LOGGER_FILE": "curiosity_debug_log_{0}.json", "UI_LOGGER_ID": "curiosity", "UI_NAME": "subscriptions", "UI_PATH": "/", diff --git a/src/common/__tests__/downloadHelpers.test.js b/src/common/__tests__/downloadHelpers.test.js new file mode 100644 index 000000000..17f42c2a0 --- /dev/null +++ b/src/common/__tests__/downloadHelpers.test.js @@ -0,0 +1,53 @@ +import { downloadHelpers } from '../downloadHelpers'; + +/** + * ToDo: evaluate the clearing of timers with pending and real + * It's unclear if this is actively helping/necessary... + */ +describe('DownloadHelpers', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('should have specific functions', () => { + expect(downloadHelpers).toMatchSnapshot('helpers'); + }); + + it('should throw an error attempting to download data', done => { + downloadHelpers.downloadData().catch(error => { + expect(error).toMatchSnapshot('download error'); + done(); + }); + }); + + it('should throw an error attempting to access a log', done => { + downloadHelpers.debugLog().catch(error => { + expect(error).toMatchSnapshot('access error'); + done(); + }); + }); + + it('should attempt to download data', done => { + window.URL = { + createObjectURL: jest.fn(), + revokeObjectURL: jest.fn() + }; + + downloadHelpers.downloadData().then(value => { + expect(value).toMatchSnapshot('data download'); + done(); + }); + + jest.runAllTimers(); + + window.URL = { + createObjectURL: undefined, + revokeObjectURL: undefined + }; + }); +}); diff --git a/src/common/downloadHelpers.js b/src/common/downloadHelpers.js new file mode 100644 index 000000000..736ad4dc0 --- /dev/null +++ b/src/common/downloadHelpers.js @@ -0,0 +1,72 @@ +import moment from 'moment/moment'; +import { helpers } from './helpers'; +import { dateHelpers } from './dateHelpers'; + +/** + * Download data to a file + * + * @param {object} options + * @param {string} options.data + * @param {string} options.fileName + * @param {string} options.fileType + * @returns {Promise} + */ +const downloadData = options => { + const { data = '', fileName = 'download.txt', fileType = 'text/plain' } = options || {}; + return new Promise((resolve, reject) => { + try { + const { document, navigator, URL } = window; + const blob = new Blob([data], { type: fileType }); + + if (navigator?.msSaveBlob) { + navigator.msSaveBlob(blob, fileName); + resolve({ fileName, data }); + } else { + const anchorTag = document.createElement('a'); + + anchorTag.href = URL.createObjectURL(blob); + anchorTag.style.display = 'none'; + anchorTag.download = fileName; + + document.body.appendChild(anchorTag); + + anchorTag.click(); + + setTimeout(() => { + document.body.removeChild(anchorTag); + URL.revokeObjectURL(blob); + resolve({ fileName, data }); + }, 250); + } + } catch (error) { + reject(error); + } + }); +}; + +/** + * Download the debug log file. + */ +const debugLog = async () => { + try { + const { sessionStorage } = window; + const fileName = `${helpers.UI_LOGGER_FILE}`.replace( + '{0}', + moment(dateHelpers.getCurrentDate()).format('YYYYMMDD_HHmmss') + ); + const data = JSON.stringify(JSON.parse(sessionStorage.getItem(`${helpers.UI_LOGGER_ID}`)), null, 2); + + await downloadData({ data, fileName, fileType: 'application/json' }); + } catch (e) { + throw new Error(`debuglog error, ${e.message}`); + } +}; + +const downloadHelpers = { + downloadData, + debugLog +}; + +helpers.browserExpose({ debugLog }, { limit: false }); + +export { downloadHelpers as default, downloadHelpers, downloadData, debugLog }; diff --git a/src/common/helpers.js b/src/common/helpers.js index 52a591438..7a0925b53 100644 --- a/src/common/helpers.js +++ b/src/common/helpers.js @@ -206,6 +206,13 @@ const UI_LOCALE_DEFAULT_DESC = process.env.REACT_APP_CONFIG_SERVICE_LOCALES_DEFA */ const UI_LOGGER_ID = process.env.REACT_APP_UI_LOGGER_ID || 'GUI'; +/** + * UI state logging file name. + * + * @type {string} + */ +const UI_LOGGER_FILE = process.env.REACT_APP_UI_LOGGER_FILE || 'debug_log_{0}.json'; + /** * UI packaged application name. * See dotenv config files for updating. @@ -281,6 +288,7 @@ const helpers = { UI_LOCALE_DEFAULT, UI_LOCALE_DEFAULT_DESC, UI_LOGGER_ID, + UI_LOGGER_FILE, UI_NAME, UI_PATH, UI_VERSION, diff --git a/src/common/index.js b/src/common/index.js index a2f805594..08e252605 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -1,4 +1,5 @@ import { helpers } from './helpers'; import { dateHelpers } from './dateHelpers'; +import { downloadHelpers } from './downloadHelpers'; -export { helpers as default, helpers, dateHelpers }; +export { helpers as default, helpers, dateHelpers, downloadHelpers }; diff --git a/src/redux/middleware/actionRecordMiddleware.js b/src/redux/middleware/actionRecordMiddleware.js new file mode 100644 index 000000000..e53b1664f --- /dev/null +++ b/src/redux/middleware/actionRecordMiddleware.js @@ -0,0 +1,95 @@ +/** + * Modify actions for privacy. + * + * @param {object} action + * @param {string} action.type + * @param {object} action.payload + * @returns {object} + */ +const sanitizeActionHeaders = ({ type, payload, ...action }) => { + if (payload) { + let updatedPayload = { ...payload, headers: {} }; + + if (Array.isArray(payload)) { + updatedPayload = payload.map(({ headers, ...obj }) => ({ ...obj, headers: {} })); + } + + return { type, payload: updatedPayload, ...action }; + } + + return { type, ...action }; +}; + +/** + * Return existing sessionStorage log. + * + * @param {string} id + * @param {number} limit + * @returns {Array} + */ +const getActions = (id, limit) => { + const { sessionStorage } = window; + const item = sessionStorage.getItem(id); + let parsedItems = (item && (JSON.parse(item) || {})?.actions) || null; + + if (parsedItems?.length && limit > 0) { + parsedItems = parsedItems.slice(limit * -1); + } + + return parsedItems; +}; + +/** + * Store actions against an id in sessionStorage. + * + * @param {object} action + * @param {object} config + * @param {number} config.id + * @param {number} config.limit + */ +const recordAction = (action, { id, limit, ...config }) => { + const { navigator, sessionStorage } = window; + const items = getActions(id, limit) || []; + const priorItem = items[items.length - 1]; + const updatedAction = sanitizeActionHeaders(action); + const actionObj = { + diff: 0, + timestamp: Date.now(), + action: updatedAction + }; + + if (priorItem && priorItem.timestamp) { + actionObj.diff = actionObj.timestamp - priorItem.timestamp; + } + + items.push(actionObj); + sessionStorage.setItem( + id, + JSON.stringify({ + browser: navigator.userAgent, + timestamp: new Date(), + ...config, + actions: items + }) + ); +}; + +/** + * Expose settings and record middleware. + * + * @param {object} config + * @returns {Function} + */ +const actionRecordMiddleware = (config = {}) => { + return () => next => action => { + recordAction(action, { + id: 'actionRecordMiddleware/v1', + limitResults: 100, + ...config + }); + + return next(action); + }; +}; + +export { actionRecordMiddleware as default, actionRecordMiddleware }; diff --git a/src/redux/middleware/index.js b/src/redux/middleware/index.js index cdd947c27..9d9406e91 100644 --- a/src/redux/middleware/index.js +++ b/src/redux/middleware/index.js @@ -4,6 +4,7 @@ import thunkMiddleware from 'redux-thunk'; import { notificationsMiddleware } from '@redhat-cloud-services/frontend-components-notifications/cjs'; import { multiActionMiddleware } from './multiActionMiddleware'; import { statusMiddleware } from './statusMiddleware'; +import { actionRecordMiddleware } from './actionRecordMiddleware'; import { reduxHelpers } from '../common/reduxHelpers'; /** @@ -34,6 +35,10 @@ const reduxMiddleware = [ statusMiddleware(), multiActionMiddleware, promiseMiddleware, + actionRecordMiddleware({ + id: process.env.REACT_APP_UI_LOGGER_ID, + app: { version: process.env.REACT_APP_UI_VERSION } + }), notificationsMiddleware(notificationsOptions) ];