diff --git a/.circleci/config.yml b/.circleci/config.yml index 5211cc51c8..2b4aa0f03e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -183,7 +183,7 @@ jobs: # Test jobs unit-tests-ui: docker: - - image: cimg/node:16.15.1 + - image: cimg/node:18.15.0 steps: - checkout - restore_cache: @@ -227,7 +227,7 @@ jobs: - ./node_modules unit-tests-api: docker: - - image: cimg/node:16.15.1 + - image: cimg/node:18.15.0 steps: - checkout - restore_cache: @@ -345,6 +345,8 @@ jobs: parallelism: << parameters.parallelism >> steps: - checkout + - node/install: + node-version: '18.15.0' - attach_workspace: at: . - run: sudo apt-get install net-tools @@ -385,7 +387,7 @@ jobs: - checkout - attach_workspace: at: . - - run: choco install nodejs --version=16.15.1 + - run: choco install nodejs --version=18.15.0 - run: command: | cd tests/e2e && export TEST_FILES=$(circleci tests glob "tests/**/*.e2e.ts" | circleci tests split --split-by=timings) && cd ../.. @@ -534,7 +536,7 @@ jobs: steps: - checkout - node/install: - node-version: '16.15.1' + node-version: '18.15.0' - attach_workspace: at: . - run: @@ -599,7 +601,7 @@ jobs: - release/redisstack macosx: macos: - xcode: 14.3.0 + xcode: 14.2.0 resource_class: macos.x86.medium.gen2 parameters: env: @@ -610,7 +612,7 @@ jobs: steps: - checkout - node/install: - node-version: '16.15.1' + node-version: '18.15.0' - attach_workspace: at: . - run: @@ -684,7 +686,7 @@ jobs: - run: name: Build windows exe command: | - choco install nodejs --version=16.15.1 + choco install nodejs --version=18.15.0 # set ALL_REDIS_COMMANDS=$(curl $ALL_REDIS_COMMANDS_RAW_URL) yarn install yarn --cwd redisinsight/api/ install @@ -1179,6 +1181,12 @@ workflows: - windows: name: Build app - Windows (stage) requires: *stageElectronBuildRequires + # e2e desktop tests on AppImage build + - e2e-app-image: + name: E2ETest (AppImage) + parallelism: 2 + requires: + - Build app - Linux (stage) # release to AWS (stage) - release-aws-test: name: Release AWS stage diff --git a/.circleci/e2e/test.app-image.sh b/.circleci/e2e/test.app-image.sh index f666e17712..66c7b75c3e 100755 --- a/.circleci/e2e/test.app-image.sh +++ b/.circleci/e2e/test.app-image.sh @@ -6,13 +6,16 @@ yarn --cwd tests/e2e install # mount app resources ./release/*.AppImage --appimage-mount >> apppath & +# create folder before tests run to prevent permissions issue +mkdir -p tests/e2e/remote + # run rte docker-compose -f tests/e2e/rte.docker-compose.yml build docker-compose -f tests/e2e/rte.docker-compose.yml up --force-recreate -d -V ./tests/e2e/wait-for-redis.sh localhost 12000 && \ # run tests -COMMON_URL=$(tail -n 1 apppath)/resources/app.asar/index.html \ +COMMON_URL=$(tail -n 1 apppath)/resources/app.asar/dist/renderer/index.html \ ELECTRON_PATH=$(tail -n 1 apppath)/redisinsight \ SOCKETS_CORS=true \ yarn --cwd tests/e2e dotenv -e .desktop.env yarn --cwd tests/e2e test:desktop:ci diff --git a/.circleci/e2e/test.exe.cmd b/.circleci/e2e/test.exe.cmd index 2291a259dc..417b54541c 100755 --- a/.circleci/e2e/test.exe.cmd +++ b/.circleci/e2e/test.exe.cmd @@ -1,7 +1,7 @@ @echo off -set COMMON_URL=%USERPROFILE%/AppData/Local/Programs/redisinsight/resources/app.asar/index.html -set ELECTRON_PATH=%USERPROFILE%/AppData/Local/Programs/redisinsight/RedisInsight-preview.exe +set COMMON_URL=%USERPROFILE%/AppData/Local/Programs/redisinsight/resources/app.asar/dist/renderer/index.html +set ELECTRON_PATH=%USERPROFILE%/AppData/Local/Programs/redisinsight/RedisInsight-v2.exe set OSS_STANDALONE_HOST=%E2E_CLOUD_DATABASE_HOST% set OSS_STANDALONE_PORT=%E2E_CLOUD_DATABASE_PORT% set OSS_STANDALONE_USERNAME=%E2E_CLOUD_DATABASE_USERNAME% diff --git a/.eslintrc.js b/.eslintrc.js index fb8ebf6309..c5166c431b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,17 +1,63 @@ module.exports = { root: true, + env: { + node: true, + }, extends: ['airbnb-typescript'], plugins: ['@typescript-eslint'], parser: '@typescript-eslint/parser', rules: { - 'max-len': ['warn', 120], + semi: ['error', 'never'], + quotes: [2, 'single', { avoidEscape: true }], + 'max-len': ['error', { ignoreComments: true, ignoreStrings: true, ignoreRegExpLiterals: true, code: 120 }], 'class-methods-use-this': 'off', 'import/no-extraneous-dependencies': 'off', // temporary disabled + '@typescript-eslint/semi': ['error', 'never'], + 'object-curly-newline': 'off', + 'import/prefer-default-export': 'off', + '@typescript-eslint/comma-dangle': 'off', + 'implicit-arrow-linebreak': 'off', + 'import/order': [ + 1, + { + groups: [ + 'external', + 'builtin', + 'internal', + 'sibling', + 'parent', + 'index', + ], + pathGroups: [ + { + pattern: 'desktopSrc/**', + group: 'internal', + position: 'after' + }, + { + pattern: 'uiSrc/**', + group: 'internal', + position: 'after' + }, + { + pattern: 'apiSrc/**', + group: 'internal', + position: 'after' + }, + ], + warnOnUnassignedImports: true, + pathGroupsExcludedImportTypes: ['builtin'] + }, + ], }, parserOptions: { project: './tsconfig.json', + ecmaVersion: 2020, + sourceType: 'module', + createDefaultProgram: true, }, ignorePatterns: [ 'redisinsight/ui', + 'redisinsight/api', ], -}; +} diff --git a/.gitignore b/.gitignore index 98db37c172..45008c9023 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,9 @@ dll main.js main.js.map vendor +redisinsight/main.js.LICENSE.txt +redisinsight/main.prod.js.LICENSE.txt + # E2E tests report /tests/e2e/report diff --git a/Dockerfile b/Dockerfile index 6511bddc49..d964e5c64a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:16.15.1-alpine as front +FROM node:18.15.0-alpine as front RUN apk update RUN apk add --no-cache --virtual .gyp \ python3 \ @@ -20,7 +20,7 @@ ENV SEGMENT_WRITE_KEY=${SEGMENT_WRITE_KEY} RUN yarn build:web RUN yarn build:statics -FROM node:16.15.1-alpine as back +FROM node:18.15.0-alpine as back WORKDIR /usr/src/app COPY redisinsight/api/package.json redisinsight/api/yarn.lock ./ RUN yarn install @@ -29,7 +29,7 @@ COPY --from=front /usr/src/app/redisinsight/api/static ./static COPY --from=front /usr/src/app/redisinsight/api/defaults ./defaults RUN yarn run build:prod -FROM node:16.15.1-slim +FROM node:18.15.0-slim # Set up mDNS functionality, to play well with Redis Enterprise # clusters on the network. RUN set -ex \ diff --git a/api.Dockerfile b/api.Dockerfile index a4660a35a5..2b06187b04 100644 --- a/api.Dockerfile +++ b/api.Dockerfile @@ -1,4 +1,4 @@ -FROM node:16.15.1-alpine as build +FROM node:18.15.0-alpine as build RUN apk update && apk add bash libsecret dbus-x11 gnome-keyring RUN dbus-uuidgen > /var/lib/dbus/machine-id @@ -19,7 +19,7 @@ RUN yarn install --production RUN cp .yarnclean.prod .yarnclean && yarn autoclean --force # Production image -FROM node:16.15.1-alpine as production +FROM node:18.15.0-alpine as production RUN apk update && apk add bash libsecret dbus-x11 gnome-keyring RUN dbus-uuidgen > /var/lib/dbus/machine-id diff --git a/configs/paths.js b/configs/paths.js deleted file mode 100644 index 2a08f20afa..0000000000 --- a/configs/paths.js +++ /dev/null @@ -1,17 +0,0 @@ -// paths.js - -// Paths will export some path variables that we'll -// use in other Webpack config and server files - -const path = require('path'); -const fs = require('fs'); - -const appDirectory = fs.realpathSync(process.cwd()); -const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath); - -module.exports = { - appAssets: resolveApp('ui/src/assets'), // For images and other assets - appBuild: resolveApp('ui/dist'), // Prod built files end up here - appConfig: resolveApp('ui/config'), // App config files - appSrc: resolveApp('ui/src'), // App source -}; diff --git a/configs/webpack.config.base.js b/configs/webpack.config.base.ts similarity index 70% rename from configs/webpack.config.base.js rename to configs/webpack.config.base.ts index a58581b672..a2bf7a3213 100644 --- a/configs/webpack.config.base.js +++ b/configs/webpack.config.base.ts @@ -1,11 +1,13 @@ -import path from 'path'; import webpack from 'webpack'; -import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'; +import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin'; +import webpackPaths from './webpack.paths'; import { dependencies as externals } from '../redisinsight/package.json'; -export default { +const configuration: webpack.Configuration = { externals: [...Object.keys(externals || {})], + stats: 'errors-only', + module: { rules: [ { @@ -22,28 +24,22 @@ export default { }, output: { - path: path.join(__dirname, '..'), - // commonjs2 https://github.com/webpack/webpack/issues/1114 - libraryTarget: 'commonjs2', + path: webpackPaths.riPath, + // https://github.com/webpack/webpack/issues/1114 + library: { + type: 'commonjs2', + }, }, resolve: { extensions: ['.js', '.jsx', '.json', '.ts', '.tsx', '.scss'], - plugins: [ - new TsconfigPathsPlugin({ - configFile: path.join(__dirname, '..', 'tsconfig.json'), - }), - ], - alias: { - src: path.resolve(__dirname, '../redisinsight/api/src'), - apiSrc: path.resolve(__dirname, '../redisinsight/api/src'), - uiSrc: path.resolve(__dirname, '../redisinsight/ui/src'), - }, - modules: [path.join(__dirname, '../redisinsight/api'), 'node_modules'], + modules: [webpackPaths.apiPath, 'node_modules'], + plugins: [new TsconfigPathsPlugins()], }, plugins: [ new webpack.EnvironmentPlugin({ + NODE_ENV: 'production', }), new webpack.IgnorePlugin({ @@ -81,3 +77,5 @@ export default { }), ], }; + +export default configuration; diff --git a/configs/webpack.config.eslint.js b/configs/webpack.config.eslint.js index b1cf088a40..d7dac976b0 100644 --- a/configs/webpack.config.eslint.js +++ b/configs/webpack.config.eslint.js @@ -2,4 +2,4 @@ /* eslint import/no-unresolved: off, import/no-self-import: off */ require('@babel/register'); -module.exports = require('./webpack.config.renderer.dev.babel').default; +module.exports = require('./webpack.config.renderer.dev').default; diff --git a/configs/webpack.config.main.prod.babel.js b/configs/webpack.config.main.prod.ts similarity index 81% rename from configs/webpack.config.main.prod.babel.js rename to configs/webpack.config.main.prod.ts index 2a4857c0c7..edbc249f67 100644 --- a/configs/webpack.config.main.prod.babel.js +++ b/configs/webpack.config.main.prod.ts @@ -1,11 +1,12 @@ import path from 'path'; import webpack from 'webpack'; import { merge } from 'webpack-merge'; -import { toString } from 'lodash' +import { toString } from 'lodash'; import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; import baseConfig from './webpack.config.base'; import DeleteSourceMaps from '../scripts/DeleteSourceMaps'; import { version } from '../redisinsight/package.json'; +import webpackPaths from './webpack.paths'; DeleteSourceMaps(); @@ -23,19 +24,17 @@ export default merge(baseConfig, { target: 'electron-main', - entry: './redisinsight/main.dev.ts', - - resolve: { - alias: { - ['apiSrc']: path.resolve(__dirname, '../redisinsight/api/src'), - ['src']: path.resolve(__dirname, '../redisinsight/api/src'), - }, - extensions: ['.tsx', '.ts', '.js', '.jsx'], + entry: { + main: path.join(webpackPaths.desktopPath, 'index.ts'), + preload: path.join(webpackPaths.desktopPath, 'preload.ts'), }, output: { - path: path.join(__dirname, '../redisinsight'), - filename: 'main.prod.js', + path: webpackPaths.distMainPath, + filename: '[name].js', + library: { + type: 'umd', + }, }, // optimization: { @@ -46,10 +45,6 @@ export default merge(baseConfig, { // ], // }, - // alias: { - // 'apiSrc': path.resolve(__dirname, '../redisinsight/api/src/') - // }, - plugins: [ new BundleAnalyzerPlugin({ analyzerMode: process.env.OPEN_ANALYZER === 'true' ? 'server' : 'disabled', @@ -75,6 +70,10 @@ export default merge(baseConfig, { ? process.env.CONNECTIONS_TIMEOUT_DEFAULT : toString(30 * 1000), // 30 sec }), + + new webpack.DefinePlugin({ + 'process.type': '"browser"', + }), ], /** diff --git a/configs/webpack.config.main.stage.babel.js b/configs/webpack.config.main.stage.ts similarity index 93% rename from configs/webpack.config.main.stage.babel.js rename to configs/webpack.config.main.stage.ts index 42956ea34a..ce04fc8c7d 100644 --- a/configs/webpack.config.main.stage.babel.js +++ b/configs/webpack.config.main.stage.ts @@ -1,8 +1,8 @@ import webpack from 'webpack'; import { merge } from 'webpack-merge'; +import { toString } from 'lodash'; import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; -import { toString } from 'lodash' -import mainProdConfig from './webpack.config.main.prod.babel'; +import mainProdConfig from './webpack.config.main.prod'; import DeleteSourceMaps from '../scripts/DeleteSourceMaps'; import { version } from '../redisinsight/package.json'; diff --git a/configs/webpack.config.preload.dev.ts b/configs/webpack.config.preload.dev.ts new file mode 100644 index 0000000000..d7fa071ed3 --- /dev/null +++ b/configs/webpack.config.preload.dev.ts @@ -0,0 +1,64 @@ +import path from 'path'; +import webpack from 'webpack'; +import { merge } from 'webpack-merge'; +import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; +import baseConfig from './webpack.config.base'; +import webpackPaths from './webpack.paths'; + +const configuration: webpack.Configuration = { + devtool: 'inline-source-map', + + mode: 'development', + + target: 'electron-preload', + + entry: path.join(webpackPaths.desktopPath, 'preload.ts'), + + output: { + path: webpackPaths.dllPath, + filename: 'preload.js', + library: { + type: 'umd', + }, + }, + + plugins: [ + new BundleAnalyzerPlugin({ + analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', + }), + + /** + * Create global constants which can be configured at compile time. + * + * Useful for allowing different behaviour between development builds and + * release builds + * + * NODE_ENV should be production so that modules do not perform certain + * development checks + * + * By default, use 'development' as NODE_ENV. This can be overriden with + * 'staging', for example, by changing the ENV variables in the npm scripts + */ + new webpack.EnvironmentPlugin({ + NODE_ENV: 'development', + }), + + new webpack.LoaderOptionsPlugin({ + debug: true, + }), + ], + + /** + * Disables webpack processing of __dirname and __filename. + * If you run the bundle in node.js it falls back to these values of node.js. + * https://github.com/webpack/webpack/issues/2010 + */ + node: { + __dirname: false, + __filename: false, + }, + + watch: true, +}; + +export default merge(baseConfig, configuration); diff --git a/configs/webpack.config.renderer.dev.dll.babel.js b/configs/webpack.config.renderer.dev.dll.ts similarity index 55% rename from configs/webpack.config.renderer.dev.dll.babel.js rename to configs/webpack.config.renderer.dev.dll.ts index 2518a6f4e2..fe10966838 100644 --- a/configs/webpack.config.renderer.dev.dll.babel.js +++ b/configs/webpack.config.renderer.dev.dll.ts @@ -1,17 +1,17 @@ import webpack from 'webpack'; import path from 'path'; import { merge } from 'webpack-merge'; -import { toString } from 'lodash' import baseConfig from './webpack.config.base'; +import webpackPaths from './webpack.paths'; import { dependencies } from '../package.json'; import { dependencies as dependenciesApi } from '../redisinsight/package.json'; console.log('dependenciesApi', dependenciesApi); -const dist = path.join(__dirname, '../dll'); +const dist = webpackPaths.dllPath; export default merge(baseConfig, { - context: path.join(__dirname, '..'), + context: webpackPaths.rootPath, devtool: 'eval', @@ -24,17 +24,19 @@ export default merge(baseConfig, { /** * Use `module` from `webpack.config.renderer.dev.js` */ - module: require('./webpack.config.renderer.dev.babel').default.module, + module: require('./webpack.config.renderer.dev').default.module, entry: { renderer: [...Object.keys(dependencies || {}), ...Object.keys(dependenciesApi || {})], }, output: { - library: 'renderer', path: dist, filename: '[name].dev.dll.js', - libraryTarget: 'var', + library: { + name: 'renderer', + type: 'var', + }, }, stats: 'errors-only', @@ -47,29 +49,16 @@ export default merge(baseConfig, { new webpack.EnvironmentPlugin({ NODE_ENV: 'development', - APP_ENV: 'electron', - API_PREFIX: 'api', - BASE_API_URL: 'http://localhost', - RESOURCES_BASE_URL: 'http://localhost', - SCAN_COUNT_DEFAULT: '500', - SCAN_TREE_COUNT_DEFAULT: '10000', - PIPELINE_COUNT_DEFAULT: '5', - SEGMENT_WRITE_KEY: - 'SEGMENT_WRITE_KEY' in process.env ? process.env.SEGMENT_WRITE_KEY : 'SOURCE_WRITE_KEY', - CONNECTIONS_TIMEOUT_DEFAULT: 'CONNECTIONS_TIMEOUT_DEFAULT' in process.env - ? process.env.CONNECTIONS_TIMEOUT_DEFAULT - : toString(30 * 1000), // 30 sec }), new webpack.LoaderOptionsPlugin({ debug: true, options: { - context: path.join(__dirname, '..'), + context: webpackPaths.desktopPath, output: { - path: path.join(__dirname, '../dll'), + path: webpackPaths.dllPath, }, }, }), - ], }); diff --git a/configs/webpack.config.renderer.dev.babel.js b/configs/webpack.config.renderer.dev.ts similarity index 55% rename from configs/webpack.config.renderer.dev.babel.js rename to configs/webpack.config.renderer.dev.ts index 19726262dd..df91978331 100644 --- a/configs/webpack.config.renderer.dev.babel.js +++ b/configs/webpack.config.renderer.dev.ts @@ -1,45 +1,62 @@ +import 'webpack-dev-server'; import path from 'path'; +import { execSync, spawn } from 'child_process'; +import fs from 'fs'; +import chalk from 'chalk'; import webpack from 'webpack'; import { merge } from 'webpack-merge'; -import { spawn } from 'child_process'; -import { toString } from 'lodash' import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'; +import HtmlWebpackPlugin from 'html-webpack-plugin'; import baseConfig from './webpack.config.base'; +import webpackPaths from './webpack.paths'; import { version } from '../redisinsight/package.json'; const port = process.env.PORT || 1212; -const publicPath = `http://localhost:${port}/dist`; -const dllDir = path.join(__dirname, '../dll'); -const manifest = path.resolve(dllDir, 'renderer.json'); -const requiredByDLLConfig = module.parent.filename.includes('webpack.config.renderer.dev.dll'); +const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json'); +const skipDLLs = + module.parent?.filename.includes('webpack.config.renderer.dev.dll') || + module.parent?.filename.includes('webpack.config.eslint'); + +const htmlPagesNames = ['splash.ejs', 'index.ejs'] +/** + * Warn if the DLL is not built + */ +if ( + !skipDLLs && + !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest)) +) { + console.log( + chalk.black.bgYellow.bold( + 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"' + ) + ); + execSync('npm run postinstall'); +} function employCache(loaders) { return ['cache-loader'].concat(loaders); } -export default merge(baseConfig, { - devtool: 'inline-source-map', +const configuration: webpack.Configuration = { + devtool: 'source-map', mode: 'development', - target: 'electron-renderer', + target: ['web', 'electron-renderer'], entry: [ - 'core-js', - 'regenerator-runtime/runtime', - // require.resolve('../redisinsight/main.renderer.ts'), - require.resolve('../redisinsight/ui/indexElectron.tsx'), + `webpack-dev-server/client?http://localhost:${port}/dist`, + 'webpack/hot/only-dev-server', + path.join(webpackPaths.uiPath, 'indexElectron.tsx'), ], output: { - publicPath: `http://localhost:${port}/dist/`, + path: webpackPaths.desktopPath, + publicPath: '/', filename: 'renderer.dev.js', - }, - - resolve: { - alias: { - apiSrc: path.resolve(__dirname, '../redisinsight/api/src'), + library: { + type: 'umd', }, }, @@ -193,32 +210,20 @@ export default merge(baseConfig, { ], }, plugins: [ - requiredByDLLConfig - ? null - : new webpack.DllReferencePlugin({ - context: path.join(__dirname, '../dll'), - manifest: require(manifest), - sourceType: 'var', - }), + ...(skipDLLs + ? [] + : [ + new webpack.DllReferencePlugin({ + context: webpackPaths.dllPath, + manifest: require(manifest), + sourceType: 'var', + }), + ]), new webpack.NoEmitOnErrorsPlugin(), new webpack.EnvironmentPlugin({ NODE_ENV: 'development', - APP_ENV: 'electron', - API_PREFIX: 'api', - BASE_API_URL: 'http://localhost', - RESOURCES_BASE_URL: 'http://localhost', - SCAN_COUNT_DEFAULT: '500', - SCAN_TREE_COUNT_DEFAULT: '10000', - PIPELINE_COUNT_DEFAULT: '5', - BUILD_TYPE: 'ELECTRON', - APP_VERSION: version, - SEGMENT_WRITE_KEY: - 'SEGMENT_WRITE_KEY' in process.env ? process.env.SEGMENT_WRITE_KEY : 'SOURCE_WRITE_KEY', - CONNECTIONS_TIMEOUT_DEFAULT: 'CONNECTIONS_TIMEOUT_DEFAULT' in process.env - ? process.env.CONNECTIONS_TIMEOUT_DEFAULT - : toString(30 * 1000), // 30 sec }), new webpack.LoaderOptionsPlugin({ @@ -228,6 +233,41 @@ export default merge(baseConfig, { new ReactRefreshWebpackPlugin(), new MonacoWebpackPlugin({ languages: ['json'], features: ['!rename'] }), + + ...htmlPagesNames.map((htmlPageName) => ( + new HtmlWebpackPlugin({ + filename: path.join(`${htmlPageName.split('.')?.[0]}.html`), + template: path.join(webpackPaths.desktopPath, htmlPageName), + minify: { + collapseWhitespace: true, + removeAttributeQuotes: true, + removeComments: true, + }, + isBrowser: false, + env: process.env.NODE_ENV, + isDevelopment: process.env.NODE_ENV !== 'production', + nodeModules: webpackPaths.appNodeModulesPath, + }) + )), + + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': JSON.stringify('development'), + 'process.env.APP_ENV': JSON.stringify('web'), + 'process.env.API_PREFIX': JSON.stringify('api'), + 'process.env.BASE_API_URL': JSON.stringify('http://localhost'), + 'process.env.RESOURCES_BASE_URL': JSON.stringify('http://localhost'), + 'process.env.SCAN_COUNT_DEFAULT': JSON.stringify('500'), + 'process.env.SCAN_TREE_COUNT_DEFAULT': JSON.stringify('10000'), + 'process.env.PIPELINE_COUNT_DEFAULT': JSON.stringify('5'), + 'process.env.BUILD_TYPE': JSON.stringify('ELECTRON'), + 'process.env.APP_VERSION': JSON.stringify(version), + 'process.env.CONNECTIONS_TIMEOUT_DEFAULT': 'CONNECTIONS_TIMEOUT_DEFAULT' in process.env + ? JSON.stringify(process.env.CONNECTIONS_TIMEOUT_DEFAULT) + : JSON.stringify(30 * 1000), + 'process.env.SEGMENT_WRITE_KEY': 'SEGMENT_WRITE_KEY' in process.env + ? JSON.stringify(process.env.SEGMENT_WRITE_KEY) + : JSON.stringify('SOURCE_WRITE_KEY'), + }), ], node: { @@ -237,33 +277,44 @@ export default merge(baseConfig, { devServer: { port, - publicPath, compress: true, - noInfo: false, - stats: 'errors-only', - inline: true, - lazy: false, hot: true, headers: { 'Access-Control-Allow-Origin': '*' }, - contentBase: path.join(__dirname, 'dist'), - watchOptions: { - aggregateTimeout: 300, - ignored: /node_modules/, - poll: 100, + static: { + publicPath: '/', }, historyApiFallback: { verbose: true, - disableDotRule: false, + disableDotRule: true, }, - before() { - console.log('Starting Main Process...'); - spawn('npm', ['run', 'start:main'], { + setupMiddlewares(middlewares) { + console.log('Starting preload.js builder...'); + const preloadProcess = spawn('npm', ['run', 'start:preload'], { shell: true, - env: process.env, stdio: 'inherit', }) .on('close', (code) => process.exit(code)) .on('error', (spawnError) => console.error(spawnError)); + + console.log('Starting Main Process...'); + let args = ['run', 'start:main']; + if (process.env.MAIN_ARGS) { + args = args.concat( + ['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat() + ); + } + spawn('npm', args, { + shell: true, + stdio: 'inherit', + }) + .on('close', (code) => { + preloadProcess.kill(); + process.exit(code); + }) + .on('error', (spawnError) => console.error(spawnError)); + return middlewares; }, }, -}); +}; + +export default merge(baseConfig, configuration); diff --git a/configs/webpack.config.renderer.prod.babel.js b/configs/webpack.config.renderer.prod.ts similarity index 65% rename from configs/webpack.config.renderer.prod.babel.js rename to configs/webpack.config.renderer.prod.ts index f0c894b9c1..4a50ec9c7c 100644 --- a/configs/webpack.config.renderer.prod.babel.js +++ b/configs/webpack.config.renderer.prod.ts @@ -1,17 +1,21 @@ import path from 'path'; import webpack from 'webpack'; -import { toString } from 'lodash' import MiniCssExtractPlugin from 'mini-css-extract-plugin'; -import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; -import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'; import { merge } from 'webpack-merge'; -import TerserPlugin from 'terser-webpack-plugin'; +import HtmlWebpackPlugin from 'html-webpack-plugin'; import baseConfig from './webpack.config.base'; import DeleteSourceMaps from '../scripts/DeleteSourceMaps'; +import webpackPaths from './webpack.paths'; +import { version } from '../redisinsight/package.json'; DeleteSourceMaps(); +const htmlPagesNames = ['splash.ejs', 'index.ejs'] +const apiUrl = process.env.SERVER_TLS_CERT && process.env.SERVER_TLS_KEY + ? 'https://localhost' + : 'http://localhost' + const devtoolsConfig = process.env.DEBUG_PROD === 'true' ? { @@ -19,24 +23,22 @@ const devtoolsConfig = } : {}; -export default merge(baseConfig, { +const configuration: webpack.Configuration = { ...devtoolsConfig, mode: 'production', - target: 'electron-renderer', + target: ['web', 'electron-renderer'], - entry: [ - 'core-js', - 'regenerator-runtime/runtime', - // path.join(__dirname, '../redisinsight/main.renderer.ts'), - path.join(__dirname, '../redisinsight/ui/indexElectron.tsx'), - ], + entry: [path.join(webpackPaths.uiPath, 'indexElectron.tsx')], output: { - path: path.join(__dirname, '../redisinsight/dist'), - publicPath: './dist/', - filename: 'renderer.prod.js', + path: webpackPaths.distRendererPath, + publicPath: './', + filename: 'renderer.js', + library: { + type: 'umd', + }, }, module: { @@ -177,43 +179,46 @@ export default merge(baseConfig, { ], }, - optimization: { - minimize: true, - minimizer: [ - new TerserPlugin({ - parallel: true, - }), - new CssMinimizerPlugin(), - ], - }, - plugins: [ new MonacoWebpackPlugin({ languages: ['json'], features: ['!rename'] }), new webpack.EnvironmentPlugin({ NODE_ENV: 'production', - DEBUG_PROD: false, - API_PREFIX: 'api', - BASE_API_URL: process.env.SERVER_TLS_CERT && process.env.SERVER_TLS_KEY ? 'https://localhost' : 'http://localhost', - RESOURCES_BASE_URL: process.env.SERVER_TLS_CERT && process.env.SERVER_TLS_KEY ? 'https://localhost' : 'http://localhost', - APP_ENV: 'electron', - SCAN_COUNT_DEFAULT: '500', - SCAN_TREE_COUNT_DEFAULT: '10000', - PIPELINE_COUNT_DEFAULT: '5', - SEGMENT_WRITE_KEY: - 'SEGMENT_WRITE_KEY' in process.env ? process.env.SEGMENT_WRITE_KEY : 'SOURCE_WRITE_KEY', - CONNECTIONS_TIMEOUT_DEFAULT: 'CONNECTIONS_TIMEOUT_DEFAULT' in process.env - ? process.env.CONNECTIONS_TIMEOUT_DEFAULT - : toString(30 * 1000), // 30 sec }), new MiniCssExtractPlugin({ filename: 'style.css', }), - new BundleAnalyzerPlugin({ - analyzerMode: process.env.OPEN_ANALYZER === 'true' ? 'server' : 'disabled', - openAnalyzer: process.env.OPEN_ANALYZER === 'true', + ...htmlPagesNames.map((htmlPageName) => ( + new HtmlWebpackPlugin({ + filename: path.join(`${htmlPageName.split('.')?.[0]}.html`), + template: path.join(webpackPaths.desktopPath, htmlPageName), + isBrowser: false, + isDevelopment: false, + }) + )), + + new webpack.DefinePlugin({ + 'process.type': '"renderer"', + 'process.env.NODE_ENV': JSON.stringify('development'), + 'process.env.APP_ENV': JSON.stringify('electron'), + 'process.env.API_PREFIX': JSON.stringify('api'), + 'process.env.BASE_API_URL': JSON.stringify(apiUrl), + 'process.env.RESOURCES_BASE_URL': JSON.stringify(apiUrl), + 'process.env.SCAN_COUNT_DEFAULT': JSON.stringify('500'), + 'process.env.SCAN_TREE_COUNT_DEFAULT': JSON.stringify('10000'), + 'process.env.PIPELINE_COUNT_DEFAULT': JSON.stringify('5'), + 'process.env.BUILD_TYPE': JSON.stringify('ELECTRON'), + 'process.env.APP_VERSION': JSON.stringify(version), + 'process.env.CONNECTIONS_TIMEOUT_DEFAULT': 'CONNECTIONS_TIMEOUT_DEFAULT' in process.env + ? JSON.stringify(process.env.CONNECTIONS_TIMEOUT_DEFAULT) + : JSON.stringify(30 * 1000), + 'process.env.SEGMENT_WRITE_KEY': 'SEGMENT_WRITE_KEY' in process.env + ? JSON.stringify(process.env.SEGMENT_WRITE_KEY) + : JSON.stringify('SOURCE_WRITE_KEY'), }), ], -}); +}; + +export default merge(baseConfig, configuration); diff --git a/configs/webpack.config.renderer.stage.babel.js b/configs/webpack.config.renderer.stage.babel.js deleted file mode 100644 index b549b4e980..0000000000 --- a/configs/webpack.config.renderer.stage.babel.js +++ /dev/null @@ -1,32 +0,0 @@ -import webpack from 'webpack'; -import { merge } from 'webpack-merge'; -import { toString } from 'lodash' -import baseConfig from './webpack.config.base'; -import rendererProdConfig from './webpack.config.renderer.prod.babel'; -import DeleteSourceMaps from '../scripts/DeleteSourceMaps'; - -DeleteSourceMaps(); - -export default merge(baseConfig, { - ...rendererProdConfig, - - plugins: [ - ...rendererProdConfig.plugins, - - new webpack.EnvironmentPlugin({ - NODE_ENV: 'staging', - DEBUG_PROD: false, - API_PREFIX: 'api', - BASE_API_URL: process.env.SERVER_TLS_CERT && process.env.SERVER_TLS_KEY ? 'https://localhost' : 'http://localhost', - RESOURCES_BASE_URL: process.env.SERVER_TLS_CERT && process.env.SERVER_TLS_KEY ? 'https://localhost' : 'http://localhost', - APP_ENV: 'electron', - SCAN_COUNT_DEFAULT: '500', - SCAN_COUNT_MEMORY_ANALYSES: '10000', - SEGMENT_WRITE_KEY: - 'SEGMENT_WRITE_KEY' in process.env ? process.env.SEGMENT_WRITE_KEY : 'SOURCE_WRITE_KEY', - CONNECTIONS_TIMEOUT_DEFAULT: 'CONNECTIONS_TIMEOUT_DEFAULT' in process.env - ? process.env.CONNECTIONS_TIMEOUT_DEFAULT - : toString(30 * 1000), // 30 sec - }), - ], -}); diff --git a/configs/webpack.config.renderer.stage.ts b/configs/webpack.config.renderer.stage.ts new file mode 100644 index 0000000000..d6e5af3eb6 --- /dev/null +++ b/configs/webpack.config.renderer.stage.ts @@ -0,0 +1,42 @@ +import webpack from 'webpack'; +import { merge } from 'webpack-merge'; +import baseConfig from './webpack.config.base'; +import rendererProdConfig from './webpack.config.renderer.prod'; +import DeleteSourceMaps from '../scripts/DeleteSourceMaps'; +import { version } from '../redisinsight/package.json'; + +DeleteSourceMaps(); + +const apiUrl = process.env.SERVER_TLS_CERT && process.env.SERVER_TLS_KEY + ? 'https://localhost' + : 'http://localhost' + +export default merge(baseConfig, { + ...rendererProdConfig, + + devtool: 'source-map', + + plugins: [ + ...rendererProdConfig.plugins, + + new webpack.DefinePlugin({ + 'process.type': '"renderer"', + 'process.env.NODE_ENV': JSON.stringify('staging'), + 'process.env.APP_ENV': JSON.stringify('electron'), + 'process.env.API_PREFIX': JSON.stringify('api'), + 'process.env.BASE_API_URL': JSON.stringify(apiUrl), + 'process.env.RESOURCES_BASE_URL': JSON.stringify(apiUrl), + 'process.env.SCAN_COUNT_DEFAULT': JSON.stringify('500'), + 'process.env.SCAN_TREE_COUNT_DEFAULT': JSON.stringify('10000'), + 'process.env.PIPELINE_COUNT_DEFAULT': JSON.stringify('5'), + 'process.env.BUILD_TYPE': JSON.stringify('ELECTRON'), + 'process.env.APP_VERSION': JSON.stringify(version), + 'process.env.CONNECTIONS_TIMEOUT_DEFAULT': 'CONNECTIONS_TIMEOUT_DEFAULT' in process.env + ? JSON.stringify(process.env.CONNECTIONS_TIMEOUT_DEFAULT) + : JSON.stringify(30 * 1000), + 'process.env.SEGMENT_WRITE_KEY': 'SEGMENT_WRITE_KEY' in process.env + ? JSON.stringify(process.env.SEGMENT_WRITE_KEY) + : JSON.stringify('SOURCE_WRITE_KEY'), + }), + ], +}); diff --git a/configs/webpack.config.web.common.babel.js b/configs/webpack.config.web.common.ts similarity index 82% rename from configs/webpack.config.web.common.babel.js rename to configs/webpack.config.web.common.ts index 973b9ea30c..772ebd06ca 100644 --- a/configs/webpack.config.web.common.babel.js +++ b/configs/webpack.config.web.common.ts @@ -6,6 +6,7 @@ import webpack from 'webpack'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'; import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'; +import webpackPaths from './webpack.paths'; import { dependencies as externals } from '../redisinsight/package.json'; import { dependencies as externalsApi } from '../redisinsight/api/package.json'; @@ -18,16 +19,11 @@ export default { rules: [ { test: /\.(js|jsx|ts|tsx)?$/, - // exclude: /node_modules/, - include: [path.resolve(__dirname, '../redisinsight/ui')], + include: [webpackPaths.uiPath], exclude: [ /node_modules/, - path.resolve(__dirname, '../menu.ts'), - path.resolve(__dirname, 'menu.ts'), - path.resolve(__dirname, '../Menu.ts'), - path.resolve(__dirname, 'Menu.ts'), - path.resolve(__dirname, '../redisinsight/main.dev.ts'), - path.resolve(__dirname, '../redisinsight/api'), + webpackPaths.apiPath, + webpackPaths.desktopPath, ], use: { loader: 'babel-loader', @@ -48,8 +44,7 @@ export default { ], }, - // context: path.resolve(__dirname, '../redisinsight/api/src'), - context: path.resolve(__dirname, '../redisinsight/ui'), + context: webpackPaths.uiPath, /** * Determine the array of extensions that should be used to resolve modules. @@ -69,6 +64,10 @@ export default { }, plugins: [ + new webpack.DefinePlugin({ + 'window.app.config.apiPort': JSON.stringify('5000'), + }), + new HtmlWebpackPlugin({ template: 'index.html.ejs' }), new MonacoWebpackPlugin({ languages: ['json'], features: ['!rename'] }), diff --git a/configs/webpack.config.web.dev.babel.js b/configs/webpack.config.web.dev.ts similarity index 96% rename from configs/webpack.config.web.dev.babel.js rename to configs/webpack.config.web.dev.ts index 556918c9f8..952a08321b 100644 --- a/configs/webpack.config.web.dev.babel.js +++ b/configs/webpack.config.web.dev.ts @@ -10,7 +10,7 @@ import webpack from 'webpack'; import { merge } from 'webpack-merge'; import ip from 'ip'; import { toString } from 'lodash' -import commonConfig from './webpack.config.web.common.babel'; +import commonConfig from './webpack.config.web.common'; function employCache(loaders) { return ['cache-loader'].concat(loaders); @@ -18,7 +18,7 @@ function employCache(loaders) { const HOST = process.env.PUBLIC_DEV ? ip.address(): 'localhost' -export default merge(commonConfig, { +const configuration: webpack.Configuration = { target: 'web', mode: 'development', @@ -176,9 +176,9 @@ export default merge(commonConfig, { host: HOST, allowedHosts: 'all', port: 8080, - hot: true, // enable HMR on the server historyApiFallback: true, }, + plugins: [ new webpack.HotModuleReplacementPlugin({ multiStep: true, @@ -202,7 +202,6 @@ export default merge(commonConfig, { NODE_ENV: 'development', APP_ENV: 'web', API_PREFIX: 'api', - API_PORT: '5000', BASE_API_URL: `http://${HOST}`, RESOURCES_BASE_URL: `http://${HOST}`, PIPELINE_COUNT_DEFAULT: '5', @@ -223,6 +222,8 @@ export default merge(commonConfig, { ], externals: { - react: 'React', + // react: 'React', }, -}); +}; + +export default merge(commonConfig, configuration); diff --git a/configs/webpack.config.web.prod.babel.js b/configs/webpack.config.web.prod.ts similarity index 97% rename from configs/webpack.config.web.prod.babel.js rename to configs/webpack.config.web.prod.ts index b3d6101aa5..517e0518bb 100644 --- a/configs/webpack.config.web.prod.babel.js +++ b/configs/webpack.config.web.prod.ts @@ -6,7 +6,7 @@ import TerserPlugin from 'terser-webpack-plugin'; import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; -import commonConfig from './webpack.config.web.common.babel'; +import commonConfig from './webpack.config.web.common'; import DeleteDistWeb from '../scripts/DeleteDistWeb'; DeleteDistWeb(); @@ -18,7 +18,7 @@ const devtoolsConfig = } : {}; -export default merge(commonConfig, { +const configuration: webpack.Configuration = { ...devtoolsConfig, mode: 'production', @@ -208,4 +208,6 @@ export default merge(commonConfig, { ], }, externals: {}, -}); +}; + +export default merge(commonConfig, configuration); diff --git a/configs/webpack.paths.ts b/configs/webpack.paths.ts new file mode 100644 index 0000000000..e3770aeef8 --- /dev/null +++ b/configs/webpack.paths.ts @@ -0,0 +1,46 @@ +const path = require('path'); + +const rootPath = path.join(__dirname, '..'); + +const riPath = path.join(rootPath, 'redisinsight'); + +const apiPath = path.join(riPath, 'api'); +const uiPath = path.join(riPath, 'ui'); +const apiSrcPath = path.join(apiPath, 'src'); +const uiSrcPath = path.join(uiPath, 'src'); +const desktopPath = path.join(riPath, 'desktop'); +const desktopSrcPath = path.join(desktopPath, 'src'); + +const dllPath = path.join(desktopPath, 'dll'); + +const releasePath = path.join(rootPath, 'release'); +const appPackagePath = path.join(riPath, 'package.json'); +const appNodeModulesPath = path.join(releasePath, 'node_modules'); +const buildAppPackagePath = path.join(releasePath, 'package.json'); +const srcNodeModulesPath = path.join(apiPath, 'node_modules'); + +const distPath = path.join(riPath, 'dist'); +const distMainPath = path.join(distPath, 'main'); +const distRendererPath = path.join(distPath, 'renderer'); + + +export default { + rootPath, + dllPath, + apiPath, + uiPath, + riPath, + apiSrcPath, + uiSrcPath, + releasePath, + desktopPath, + desktopSrcPath, + appPackagePath, + appNodeModulesPath, + srcNodeModulesPath, + distPath, + distMainPath, + distRendererPath, + buildAppPackagePath, + buildPath: releasePath, +}; diff --git a/electron-builder.json b/electron-builder.json index 652d518368..08d0c2b394 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -3,12 +3,8 @@ "appId": "org.RedisLabs.RedisInsight-V2", "copyright": "Copyright © 2023 Redis Ltd.", "files": [ - "dist/", - "node_modules/", - "index.html", - "splash.html", - "main.prod.js", - "main.prod.js.map", + "dist", + "node_modules", "package.json" ], "afterSign": "electron-builder-notarize", @@ -33,7 +29,7 @@ "type": "distribution", "hardenedRuntime": true, "darkModeSupport": true, - "bundleVersion": "11", + "bundleVersion": "40", "icon": "resources/icon.icns", "artifactName": "${productName}-${os}-${arch}.${ext}", "entitlements": "resources/entitlements.mac.plist", diff --git a/package.json b/package.json index f2a458f433..23e2578895 100644 --- a/package.json +++ b/package.json @@ -6,26 +6,26 @@ "private": true, "scripts": { "build": "cross-env NODE_ENV=development concurrently \"yarn build:main\" \"yarn build:renderer\"", - "build:stage": "cross-env NODE_ENV=staging concurrently \"yarn build:api:stage && yarn build:main:stage\" \"yarn build:renderer:stage\"", + "build:stage": "cross-env NODE_ENV=staging TS_NODE_TRANSPILE_ONLY=true concurrently \"yarn build:api:stage && yarn build:main:stage\" \"yarn build:renderer:stage\"", "build:prod": "cross-env NODE_ENV=production concurrently \"yarn build:api && yarn build:main\" \"yarn build:renderer\"", "build:api": "yarn --cwd redisinsight/api/ build:prod", "build:api:stage": "yarn --cwd redisinsight/api/ build:stage", - "build:main": "webpack --config ./configs/webpack.config.main.prod.babel.js", - "build:main:stage": "webpack --config ./configs/webpack.config.main.stage.babel.js", - "build:web": "webpack --config ./configs/webpack.config.web.prod.babel.js", + "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack.config.main.prod.ts", + "build:main:stage": "cross-env TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack.config.main.stage.ts", + "build:web": "cross-env TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack.config.web.prod.ts", "build:defaults": "yarn --cwd redisinsight/api build:defaults", "build:statics": "yarn build:defaults && sh ./scripts/build-statics.sh", "build:statics:win": "yarn build:defaults && ./scripts/build-statics.cmd", - "build:renderer": "webpack --config ./configs/webpack.config.renderer.prod.babel.js", - "build:renderer:stage": "webpack --config ./configs/webpack.config.renderer.stage.babel.js", - "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir redisinsight/ui", + "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack.config.renderer.prod.ts", + "build:renderer:stage": "cross-env NODE_ENV=staging TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack.config.renderer.stage.ts", + "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir redisinsight", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", "lint:ui": "eslint ./redisinsight/ui --ext .js,.jsx,.ts,.tsx", "lint:api": "yarn --cwd redisinsight/api lint", "lint:e2e": "yarn --cwd tests/e2e lint", "package": "yarn package:dev", - "package:prod": "yarn build:prod && electron-builder build -p never", - "package:stage": "yarn build:stage && electron-builder build -p never", + "package:prod": "ts-node ./scripts/prebuild.js dist && yarn build:prod && electron-builder build -p never", + "package:stage": "ts-node ./scripts/prebuild.js dist && yarn build:stage && electron-builder build -p never", "package:mas": "electron-builder build -p never -m mas:universal -c ./electron-builder-mas.js", "package:mas:dev": "electron-builder build -p never -m mas-dev:universal -c ./electron-builder-mas.js", "package:dev": "yarn build && cross-env DEBUG=electron-builder electron-builder build -p never", @@ -33,11 +33,13 @@ "package:mac": "yarn build:prod && electron-builder build --mac -p never", "package:mac:arm": "yarn build:prod && electron-builder build --mac --arm64 -p never", "package:linux": "yarn build:prod && electron-builder build --linux -p never", - "postinstall": "skip-postinstall || (electron-builder install-app-deps && yarn webpack --config ./configs/webpack.config.renderer.dev.dll.babel.js && opencollective-postinstall && yarn-deduplicate yarn.lock)", - "start": "cross-env NODE_ENV=development webpack serve --config ./configs/webpack.config.renderer.dev.babel.js", - "start:main": "cross-env NODE_ENV=development electron -r ./scripts/BabelRegister redisinsight/main.dev.ts", - "start:web": "webpack serve --config ./configs/webpack.config.web.dev.babel.js", - "start:web:public": "cross-env PUBLIC_DEV=true webpack serve --config ./configs/webpack.config.web.dev.babel.js", + "postinstall": "skip-postinstall || (electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack.config.renderer.dev.dll.ts && opencollective-postinstall && yarn-deduplicate yarn.lock)", + "start": "ts-node ./scripts/check-port-in-use.js && yarn start:renderer", + "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack.config.renderer.dev.ts", + "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack.config.preload.dev.ts", + "start:main": "cross-env NODE_ENV=development electron -r ./scripts/BabelRegister -r tsconfig-paths/register redisinsight/desktop/index.ts", + "start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack.config.web.dev.ts", + "start:web:public": "cross-env PUBLIC_DEV=true NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack.config.web.dev.ts", "test": "jest ./redisinsight/ui -w 1", "test:watch": "jest ./redisinsight/ui --watch -w 1", "test:cov": "jest ./redisinsight/ui --coverage -w 1", @@ -77,7 +79,7 @@ ], "homepage": "https://github.com/RedisInsight/RedisInsight#readme", "resolutions": { - "**/node-sass": "^6.0.1", + "**/node-sass": "^8.0.0", "**/trim": "0.0.3" }, "devDependencies": { @@ -137,6 +139,7 @@ "@types/segment-analytics": "^0.0.34", "@types/supertest": "^2.0.8", "@types/uuid": "^8.3.4", + "@types/webpack-bundle-analyzer": "^4.6.0", "@types/webpack-env": "^1.15.2", "@typescript-eslint/eslint-plugin": "^4.8.1", "@typescript-eslint/parser": "^4.8.1", @@ -152,7 +155,7 @@ "cross-env": "^7.0.2", "css-loader": "^5.0.1", "css-minimizer-webpack-plugin": "^2.0.0", - "electron": "19.0.7", + "electron": "25.1.1", "electron-builder": "^23.6.0", "electron-builder-notarize": "^1.5.1", "electron-debug": "^3.2.0", @@ -171,7 +174,7 @@ "eslint-plugin-react-hooks": "^4.0.8", "eslint-plugin-sonarjs": "^0.10.0", "file-loader": "^6.0.0", - "html-webpack-plugin": "^4.5.0", + "html-webpack-plugin": "^5.5.0", "husky": "^4.2.5", "identity-obj-proxy": "^3.0.0", "ioredis-mock": "^5.5.4", @@ -180,18 +183,18 @@ "jest-runner-groups": "^2.2.0", "jest-when": "^3.2.1", "lint-staged": "^10.2.11", - "mini-css-extract-plugin": "^1.3.1", + "mini-css-extract-plugin": "2.7.2", "moment": "^2.29.3", "monaco-editor-webpack-plugin": "^6.0.0", "msw": "^0.45.0", - "node-sass": "^6.0.1", + "node-sass": "^8.0.0", "opencollective-postinstall": "^2.0.3", "react-hot-loader": "^4.13.0", "react-refresh": "^0.9.0", "redux-mock-store": "^1.5.4", "regenerator-runtime": "^0.13.5", "rimraf": "^3.0.2", - "sass-loader": "^10.2.0", + "sass-loader": "^13.2.2", "skip-postinstall": "^1.0.0", "socket.io-mock": "^1.3.2", "source-map-support": "^0.5.19", @@ -201,13 +204,13 @@ "ts-jest": "27.1.5", "ts-loader": "^6.2.1", "ts-mockito": "^2.6.1", - "ts-node": "^8.6.2", + "ts-node": "^10.9.1", "tsconfig-paths": "^3.9.0", - "tsconfig-paths-webpack-plugin": "^3.3.0", + "tsconfig-paths-webpack-plugin": "^4.0.1", "typescript": "^4.0.5", "url-loader": "^4.1.0", "webpack": "^5.5.1", - "webpack-bundle-analyzer": "^4.1.0", + "webpack-bundle-analyzer": "^4.8.0", "webpack-cli": "^4.3.0", "webpack-dev-server": "^4.13.3", "webpack-merge": "^5.4.0", @@ -227,7 +230,6 @@ "connection-string": "^4.3.2", "d3": "^7.6.1", "date-fns": "^2.16.1", - "detect-port": "^1.3.0", "electron-context-menu": "^3.1.0", "electron-log": "^4.2.4", "electron-store": "^8.0.0", @@ -235,6 +237,7 @@ "file-saver": "^2.0.5", "formik": "^2.2.9", "fzstd": "^0.1.0", + "get-port": "^7.0.0", "gzip-js": "^0.3.2", "html-entities": "^2.3.2", "html-react-parser": "^1.2.4", diff --git a/redisinsight/about-panel.ts b/redisinsight/about-panel.ts deleted file mode 100644 index 48efdf1fc2..0000000000 --- a/redisinsight/about-panel.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { app } from 'electron'; -import path from 'path'; - -const ICON_PATH = app.isPackaged - ? path.join(process.resourcesPath, 'resources', 'icon.png') - : path.join(__dirname, '../resources', 'icon.png'); - -export default { - applicationName: 'RedisInsight-v2', - applicationVersion: - `${app.getVersion() || '2.26.0'}${process.env.NODE_ENV !== 'production' ? `-dev-${process.getCreationTime()}` : ''}`, - copyright: `Copyright © ${new Date().getFullYear()} Redis Ltd.`, - iconPath: ICON_PATH, -}; diff --git a/redisinsight/api/.jest.setup.ts b/redisinsight/api/.jest.setup.ts new file mode 100644 index 0000000000..9bb678998e --- /dev/null +++ b/redisinsight/api/.jest.setup.ts @@ -0,0 +1,7 @@ +// Workaround for @Type test coverage +jest.mock("class-transformer", () => { + return { + ...(jest.requireActual("class-transformer") as Object), + Type: (f: Function) => f() && jest.requireActual("class-transformer").Type(f), + }; +}); diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index 722ae4b716..af1c656545 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -58,7 +58,7 @@ export default { tlsKey: process.env.SERVER_TLS_KEY, staticContent: !!process.env.SERVER_STATIC_CONTENT || false, buildType: process.env.BUILD_TYPE || 'ELECTRON', - appVersion: process.env.APP_VERSION || '2.26.0', + appVersion: process.env.APP_VERSION || '2.28.0', requestTimeout: parseInt(process.env.REQUEST_TIMEOUT, 10) || 25000, excludeRoutes: [], excludeAuthRoutes: [], @@ -74,6 +74,8 @@ export default { }, redis_cloud: { url: process.env.REDIS_CLOUD_URL || 'https://api-cloudapi.qa.redislabs.com/v1', + cloudDiscoveryTimeout: parseInt(process.env.RI_CLOUD_DISCOVERY_TIMEOUT, 10) || 60 * 1000, // 1 min + cloudDatabaseConnectionTimeout: parseInt(process.env.RI_CLOUD_DATABASE_CONNECTION_TIMEOUT, 10) || 30 * 1000, }, redis_clients: { idleSyncInterval: parseInt(process.env.CLIENTS_IDLE_SYNC_INTERVAL, 10) || 1000 * 60 * 60, // 1hr diff --git a/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json index 17fb431396..cbfb7661b4 100644 --- a/redisinsight/api/config/features-config.json +++ b/redisinsight/api/config/features-config.json @@ -1,9 +1,9 @@ { - "version": 1, + "version": 2, "features": { "insightsRecommendations": { "flag": true, - "perc": [], + "perc": [[0,30]], "filters": [ { "name": "agreements.analytics", diff --git a/redisinsight/api/config/ormconfig.ts b/redisinsight/api/config/ormconfig.ts index 04feb42f8e..00e722d087 100644 --- a/redisinsight/api/config/ormconfig.ts +++ b/redisinsight/api/config/ormconfig.ts @@ -17,6 +17,7 @@ import { BrowserHistoryEntity } from 'src/modules/browser/entities/browser-histo import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; import { FeatureEntity } from 'src/modules/feature/entities/feature.entity'; import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; +import { CloudDatabaseDetailsEntity } from 'src/modules/cloud/autodiscovery/entities/cloud-database-details.entity'; import migrations from '../migration'; import * as config from '../src/utils/config'; @@ -44,6 +45,7 @@ const ormConfig = { CustomTutorialEntity, FeatureEntity, FeaturesConfigEntity, + CloudDatabaseDetailsEntity, ], migrations, }; diff --git a/redisinsight/api/config/swagger.ts b/redisinsight/api/config/swagger.ts index 17cc786cc2..2f246b94a8 100644 --- a/redisinsight/api/config/swagger.ts +++ b/redisinsight/api/config/swagger.ts @@ -5,7 +5,7 @@ const SWAGGER_CONFIG: Omit = { info: { title: 'RedisInsight Backend API', description: 'RedisInsight Backend API', - version: '2.26.0', + version: '2.28.0', }, tags: [], }; diff --git a/redisinsight/api/migration/1686719451753-database-redis-server.ts b/redisinsight/api/migration/1686719451753-database-redis-server.ts new file mode 100644 index 0000000000..71272302fb --- /dev/null +++ b/redisinsight/api/migration/1686719451753-database-redis-server.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class DatabaseRedisServer1686719451753 implements MigrationInterface { + name = 'DatabaseRedisServer1686719451753' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean, "verifyServerCert" boolean, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar DEFAULT ('[]'), "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), "db" integer, "encryption" varchar, "tlsServername" varchar, "new" boolean, "ssh" boolean, "timeout" integer, "compressor" varchar NOT NULL DEFAULT ('NONE'), "version" varchar, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new", "ssh", "timeout", "compressor") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new", "ssh", "timeout", "compressor" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean, "verifyServerCert" boolean, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar DEFAULT ('[]'), "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), "db" integer, "encryption" varchar, "tlsServername" varchar, "new" boolean, "ssh" boolean, "timeout" integer, "compressor" varchar NOT NULL DEFAULT ('NONE'), CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new", "ssh", "timeout", "compressor") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new", "ssh", "timeout", "compressor" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + } + +} diff --git a/redisinsight/api/migration/1687166457712-cloud-database-details.ts b/redisinsight/api/migration/1687166457712-cloud-database-details.ts new file mode 100644 index 0000000000..f2622aec40 --- /dev/null +++ b/redisinsight/api/migration/1687166457712-cloud-database-details.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CloudDatabaseDetails1687166457712 implements MigrationInterface { + name = 'CloudDatabaseDetails1687166457712' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "database_cloud_details" ("id" varchar PRIMARY KEY NOT NULL, "cloudId" integer NOT NULL, "subscriptionType" varchar NOT NULL, "planMemoryLimit" integer, "memoryLimitMeasurementUnit" integer, "databaseId" varchar, CONSTRAINT "REL_f41ee5027391b3be8ad95e3d15" UNIQUE ("databaseId"))`); + await queryRunner.query(`CREATE TABLE "temporary_database_cloud_details" ("id" varchar PRIMARY KEY NOT NULL, "cloudId" integer NOT NULL, "subscriptionType" varchar NOT NULL, "planMemoryLimit" integer, "memoryLimitMeasurementUnit" integer, "databaseId" varchar, CONSTRAINT "REL_f41ee5027391b3be8ad95e3d15" UNIQUE ("databaseId"), CONSTRAINT "FK_f41ee5027391b3be8ad95e3d158" FOREIGN KEY ("databaseId") REFERENCES "database_instance" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_cloud_details"("id", "cloudId", "subscriptionType", "planMemoryLimit", "memoryLimitMeasurementUnit", "databaseId") SELECT "id", "cloudId", "subscriptionType", "planMemoryLimit", "memoryLimitMeasurementUnit", "databaseId" FROM "database_cloud_details"`); + await queryRunner.query(`DROP TABLE "database_cloud_details"`); + await queryRunner.query(`ALTER TABLE "temporary_database_cloud_details" RENAME TO "database_cloud_details"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_cloud_details" RENAME TO "temporary_database_cloud_details"`); + await queryRunner.query(`CREATE TABLE "database_cloud_details" ("id" varchar PRIMARY KEY NOT NULL, "cloudId" integer NOT NULL, "subscriptionType" varchar NOT NULL, "planMemoryLimit" integer, "memoryLimitMeasurementUnit" integer, "databaseId" varchar, CONSTRAINT "REL_f41ee5027391b3be8ad95e3d15" UNIQUE ("databaseId"))`); + await queryRunner.query(`INSERT INTO "database_cloud_details"("id", "cloudId", "subscriptionType", "planMemoryLimit", "memoryLimitMeasurementUnit", "databaseId") SELECT "id", "cloudId", "subscriptionType", "planMemoryLimit", "memoryLimitMeasurementUnit", "databaseId" FROM "temporary_database_cloud_details"`); + await queryRunner.query(`DROP TABLE "temporary_database_cloud_details"`); + await queryRunner.query(`DROP TABLE "database_cloud_details"`); + } + +} diff --git a/redisinsight/api/migration/1687435940110-database-recommendation-unique.ts b/redisinsight/api/migration/1687435940110-database-recommendation-unique.ts new file mode 100644 index 0000000000..1a750d866c --- /dev/null +++ b/redisinsight/api/migration/1687435940110-database-recommendation-unique.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class DatabaseRecommendationUnique1687435940110 implements MigrationInterface { + name = 'DatabaseRecommendationUnique1687435940110' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_2487bdd9dbde3fdf65bcb96fc5"`); + await queryRunner.query(`DROP INDEX "IDX_d6107e5e16648b038c511f3b00"`); + await queryRunner.query(`CREATE TABLE "temporary_database_recommendations" ("id" varchar PRIMARY KEY NOT NULL, "databaseId" varchar NOT NULL, "name" varchar NOT NULL, "read" boolean NOT NULL DEFAULT (0), "disabled" boolean NOT NULL DEFAULT (0), "vote" varchar, "hide" boolean NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "params" blob, "encryption" varchar, CONSTRAINT "UQ_b772d2856a42685ce4227321251" UNIQUE ("databaseId", "name"), CONSTRAINT "FK_2487bdd9dbde3fdf65bcb96fc52" FOREIGN KEY ("databaseId") REFERENCES "database_instance" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT OR IGNORE INTO "temporary_database_recommendations"("id", "databaseId", "name", "read", "disabled", "vote", "hide", "createdAt", "params", "encryption") SELECT "id", "databaseId", "name", "read", "disabled", "vote", "hide", "createdAt", "params", "encryption" FROM "database_recommendations"`); + await queryRunner.query(`DROP TABLE "database_recommendations"`); + await queryRunner.query(`ALTER TABLE "temporary_database_recommendations" RENAME TO "database_recommendations"`); + await queryRunner.query(`CREATE INDEX "IDX_2487bdd9dbde3fdf65bcb96fc5" ON "database_recommendations" ("databaseId") `); + await queryRunner.query(`CREATE INDEX "IDX_d6107e5e16648b038c511f3b00" ON "database_recommendations" ("createdAt") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_d6107e5e16648b038c511f3b00"`); + await queryRunner.query(`DROP INDEX "IDX_2487bdd9dbde3fdf65bcb96fc5"`); + await queryRunner.query(`ALTER TABLE "database_recommendations" RENAME TO "temporary_database_recommendations"`); + await queryRunner.query(`CREATE TABLE "database_recommendations" ("id" varchar PRIMARY KEY NOT NULL, "databaseId" varchar NOT NULL, "name" varchar NOT NULL, "read" boolean NOT NULL DEFAULT (0), "disabled" boolean NOT NULL DEFAULT (0), "vote" varchar, "hide" boolean NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "params" blob, "encryption" varchar, CONSTRAINT "FK_2487bdd9dbde3fdf65bcb96fc52" FOREIGN KEY ("databaseId") REFERENCES "database_instance" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_recommendations"("id", "databaseId", "name", "read", "disabled", "vote", "hide", "createdAt", "params", "encryption") SELECT "id", "databaseId", "name", "read", "disabled", "vote", "hide", "createdAt", "params", "encryption" FROM "temporary_database_recommendations"`); + await queryRunner.query(`DROP TABLE "temporary_database_recommendations"`); + await queryRunner.query(`CREATE INDEX "IDX_d6107e5e16648b038c511f3b00" ON "database_recommendations" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_2487bdd9dbde3fdf65bcb96fc5" ON "database_recommendations" ("databaseId") `); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index 11ae966a9d..9689f0b823 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -32,6 +32,9 @@ import { customTutorials1677135091633 } from './1677135091633-custom-tutorials'; import { databaseRecommendations1681900503586 } from './1681900503586-database-recommendations'; import { databaseRecommendationParams1683006064293 } from './1683006064293-database-recommendation-params'; import { Feature1684931530343 } from './1684931530343-feature'; +import { DatabaseRedisServer1686719451753 } from './1686719451753-database-redis-server'; +import { DatabaseRecommendationUnique1687435940110 } from './1687435940110-database-recommendation-unique'; +import { CloudDatabaseDetails1687166457712 } from './1687166457712-cloud-database-details'; export default [ initialMigration1614164490968, @@ -68,4 +71,7 @@ export default [ databaseRecommendations1681900503586, databaseRecommendationParams1683006064293, Feature1684931530343, + DatabaseRedisServer1686719451753, + DatabaseRecommendationUnique1687435940110, + CloudDatabaseDetails1687166457712, ]; diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 3a3936e142..f02e3163dc 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -21,9 +21,9 @@ "format": "prettier --write \"src/**/*.ts\"", "lint": "eslint --ext .ts .", "start": "nest start", - "start:dev": "cross-env NODE_ENV=development SERVER_STATIC_CONTENT=1 nest start --watch", + "start:dev": "cross-env NODE_ENV=development BUILD_TYPE=DOCKER_ON_PREMISE SERVER_STATIC_CONTENT=1 nest start --watch", "start:debug": "nest start --debug --watch", - "start:stage": "cross-env NODE_ENV=staging SERVER_STATIC_CONTENT=true node dist/src/main", + "start:stage": "cross-env NODE_ENV=staging BUILD_TYPE=DOCKER_ON_PREMISE SERVER_STATIC_CONTENT=true node dist/src/main", "start:prod": "cross-env NODE_ENV=production node dist/src/main", "test": "cross-env NODE_ENV=test ./node_modules/.bin/jest -w 1", "test:watch": "cross-env NODE_ENV=test jest --watch -w 1", @@ -32,8 +32,8 @@ "test:e2e": "jest --config ./test/jest-e2e.json -w 1", "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./config/ormconfig.ts", "test:api": "cross-env NODE_ENV=test ts-mocha --paths -p test/api/api.tsconfig.json --config ./test/api/.mocharc.yml", - "test:api:cov": "nyc --reporter=html --reporter=text --reporter=text-summary yarn run test:api", - "test:api:ci:cov": "nyc -r text -r text-summary -r html yarn run test:api --reporter mocha-multi-reporters --reporter-options configFile=test/api/reporters.json && nyc merge .nyc_output ./coverage/test-run-coverage.json", + "test:api:cov": "cross-env BUILD_TYPE=DOCKER_ON_PREMISE nyc --reporter=html --reporter=text --reporter=text-summary yarn run test:api", + "test:api:ci:cov": "cross-env BUILD_TYPE=DOCKER_ON_PREMISE nyc -r text -r text-summary -r html yarn run test:api --reporter mocha-multi-reporters --reporter-options configFile=test/api/reporters.json && nyc merge .nyc_output ./coverage/test-run-coverage.json", "typeorm:migrate": "cross-env NODE_ENV=staging yarn typeorm migration:generate ./migration/migration", "typeorm:run": "yarn typeorm migration:run" }, @@ -71,8 +71,8 @@ "rxjs": "^7.5.6", "socket.io": "^4.4.0", "source-map-support": "^0.5.19", - "sqlite3": "^5.0.11", - "ssh2": "^1.11.0", + "sqlite3": "5.1.6", + "ssh2": "^1.14.0", "swagger-ui-express": "^4.1.4", "typeorm": "^0.3.9", "uuid": "^8.3.2", @@ -143,6 +143,7 @@ ".spec.ts$" ], "testEnvironment": "node", + "setupFilesAfterEnv": ["/../.jest.setup.ts"], "moduleNameMapper": { "src/(.*)": "/$1", "apiSrc/(.*)": "/$1", diff --git a/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts new file mode 100644 index 0000000000..2f66f94316 --- /dev/null +++ b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts @@ -0,0 +1,279 @@ +import { + CloudAccountInfo, + CloudDatabase, CloudDatabaseProtocol, + CloudDatabaseStatus, + CloudSubscription, + CloudSubscriptionStatus, CloudSubscriptionType, ICloudApiAccount, ICloudApiDatabase, ICloudApiSubscription, +} from 'src/modules/cloud/autodiscovery/models'; +import { + AddCloudDatabaseDto, AddCloudDatabaseResponse, + CloudAuthDto, + GetCloudSubscriptionDatabaseDto, + GetCloudSubscriptionDatabasesDto, +} from 'src/modules/cloud/autodiscovery/dto'; +import { ActionStatus } from 'src/common/models'; + +export const mockCloudApiAccount: ICloudApiAccount = { + id: 40131, + name: 'Redis Labs', + createdTimestamp: '2018-12-23T15:15:31Z', + updatedTimestamp: '2020-06-03T13:16:59Z', + key: { + name: 'QA-HashedIn-Test-API-Key-2', + accountId: 40131, + accountName: 'Redis Labs', + allowedSourceIps: ['0.0.0.0/0'], + createdTimestamp: '2020-04-06T09:22:38Z', + owner: { + name: 'Cloud Account', + email: 'cloud.account@redislabs.com', + }, + httpSourceIp: '198.141.36.229', + }, +}; + +export const mockCloudAccountInfo = Object.assign(new CloudAccountInfo(), { + accountId: mockCloudApiAccount.id, + accountName: mockCloudApiAccount.name, + ownerEmail: mockCloudApiAccount.key.owner.email, + ownerName: mockCloudApiAccount.key.owner.name, +}); + +export const mockCloudApiSubscription: ICloudApiSubscription = { + id: 108353, + name: 'external CA', + status: CloudSubscriptionStatus.Active, + paymentMethodId: 8240, + memoryStorage: 'ram', + storageEncryption: false, + numberOfDatabases: 7, + subscriptionPricing: [ + { + type: 'Shards', + typeDetails: 'high-throughput', + quantity: 2, + quantityMeasurement: 'shards', + pricePerUnit: 0.124, + priceCurrency: 'USD', + pricePeriod: 'hour', + }, + ], + cloudDetails: [ + { + provider: 'AWS', + cloudAccountId: 16424, + totalSizeInGb: 0.0323, + regions: [ + { + region: 'us-east-1', + networking: [ + { + deploymentCIDR: '10.0.0.0/24', + subnetId: 'subnet-0a2dd5829daf83024', + }, + ], + preferredAvailabilityZones: ['us-east-1a'], + multipleAvailabilityZones: false, + }, + ], + }, + ], +}; + +export const mockCloudSubscription = Object.assign(new CloudSubscription(), { + id: mockCloudApiSubscription.id, + type: CloudSubscriptionType.Flexible, + name: mockCloudApiSubscription.name, + numberOfDatabases: mockCloudApiSubscription.numberOfDatabases, + provider: mockCloudApiSubscription.cloudDetails[0].provider, + region: mockCloudApiSubscription.cloudDetails[0].regions[0].region, + status: mockCloudApiSubscription.status, +}); + +export const mockCloudSubscriptionFixed = Object.assign(new CloudSubscription(), { + ...mockCloudSubscription, + type: CloudSubscriptionType.Fixed, +}); + +export const mockCloudApiDatabase: ICloudApiDatabase = { + databaseId: 50859754, + name: 'bdb', + protocol: CloudDatabaseProtocol.Redis, + provider: 'GCP', + region: 'us-central1', + redisVersionCompliance: '5.0.5', + status: CloudDatabaseStatus.Active, + memoryLimitInGb: 1.0, + memoryUsedInMb: 6.0, + memoryStorage: 'ram', + supportOSSClusterApi: false, + dataPersistence: 'none', + replication: true, + dataEvictionPolicy: 'volatile-lru', + throughputMeasurement: { + by: 'operations-per-second', + value: 25000, + }, + activatedOn: '2019-12-31T09:38:41Z', + lastModified: '2019-12-31T09:38:41Z', + publicEndpoint: + 'redis-14621.c34097.us-central1-mz.gcp.qa-cloud.rlrcp.com:14621', + privateEndpoint: + 'redis-14621.internal.c34097.us-central1-mz.gcp.qa-cloud.rlrcp.com:14621', + replicaOf: { + endpoints: [ + 'redis-19669.c9244.us-central1-mz.gcp.cloud.rlrcp.com:19669', + 'redis-14074.c9243.us-central1-mz.gcp.cloud.rlrcp.com:14074', + ], + }, + clustering: { + numberOfShards: 1, + regexRules: [], + hashingPolicy: 'standard', + }, + security: { + sslClientAuthentication: false, + sourceIps: ['0.0.0.0/0'], + }, + modules: [ + { + id: 1, + name: 'ReJSON', + version: 'v10007', + }, + ], + alerts: [], +}; + +export const mockCloudApiDatabaseFixed: ICloudApiDatabase = { + ...mockCloudApiDatabase, + protocol: CloudDatabaseProtocol.Stack, + planMemoryLimit: 256, + memoryLimitMeasurementUnit: 'MB', +}; + +export const mockCloudDatabase = Object.assign(new CloudDatabase(), { + subscriptionId: mockCloudSubscription.id, + subscriptionType: CloudSubscriptionType.Flexible, + databaseId: mockCloudApiDatabase.databaseId, + name: mockCloudApiDatabase.name, + publicEndpoint: mockCloudApiDatabase.publicEndpoint, + status: mockCloudApiDatabase.status, + sslClientAuthentication: false, + modules: ['ReJSON'], + options: { + enabledBackup: false, + enabledClustering: false, + enabledDataPersistence: false, + enabledRedisFlash: false, + enabledReplication: true, + isReplicaDestination: true, + persistencePolicy: 'none', + }, + cloudDetails: { + cloudId: mockCloudApiDatabase.databaseId, + subscriptionType: CloudSubscriptionType.Flexible, + }, +}); + +export const mockCloudDatabaseFixed = Object.assign(new CloudDatabase(), { + ...mockCloudDatabase, + subscriptionType: CloudSubscriptionType.Fixed, + cloudDetails: { + cloudId: mockCloudApiDatabase.databaseId, + subscriptionType: CloudSubscriptionType.Fixed, + planMemoryLimit: mockCloudApiDatabaseFixed.planMemoryLimit, + memoryLimitMeasurementUnit: mockCloudApiDatabaseFixed.memoryLimitMeasurementUnit, + }, +}); + +export const mockCloudDatabaseFromList = Object.assign(new CloudDatabase(), { + ...mockCloudDatabase, + options: { + ...mockCloudDatabase.options, + isReplicaSource: false, + }, +}); + +export const mockCloudDatabaseFromListFixed = Object.assign(new CloudDatabase(), { + ...mockCloudDatabaseFixed, + options: { + ...mockCloudDatabaseFixed.options, + isReplicaSource: false, + }, +}); + +export const mockCloudApiSubscriptionDatabases = { + accountId: mockCloudAccountInfo.accountId, + subscription: [ + { + subscriptionId: mockCloudSubscription.id, + numberOfDatabases: mockCloudSubscription.numberOfDatabases, + databases: [mockCloudApiDatabase], + }, + ], +}; + +export const mockCloudApiSubscriptionDatabasesFixed = { + ...mockCloudApiSubscriptionDatabases, + subscription: { + ...mockCloudApiSubscriptionDatabases.subscription[0], + databases: [mockCloudApiDatabaseFixed], + }, +}; + +export const mockCloudAuthDto: CloudAuthDto = { + apiKey: 'api_key', + apiSecret: 'api_secret_key', +}; + +export const mockGetCloudSubscriptionDatabasesDto = Object.assign(new GetCloudSubscriptionDatabasesDto(), { + subscriptionId: mockCloudSubscription.id, + subscriptionType: mockCloudSubscription.type, +}); + +export const mockGetCloudSubscriptionDatabasesDtoFixed = Object.assign(new GetCloudSubscriptionDatabasesDto(), { + subscriptionId: mockCloudSubscription.id, + subscriptionType: CloudSubscriptionType.Fixed, +}); + +export const mockGetCloudSubscriptionDatabaseDto = Object.assign(new GetCloudSubscriptionDatabaseDto(), { + subscriptionId: mockCloudSubscription.id, + subscriptionType: mockCloudSubscription.type, + databaseId: mockCloudDatabase.databaseId, +}); + +export const mockGetCloudSubscriptionDatabaseDtoFixed = Object.assign(new GetCloudSubscriptionDatabaseDto(), { + ...mockGetCloudSubscriptionDatabaseDto, + subscriptionType: mockCloudSubscriptionFixed.type, +}); + +export const mockAddCloudDatabaseDto = Object.assign(new AddCloudDatabaseDto(), { + ...mockGetCloudSubscriptionDatabaseDto, +}); + +export const mockAddCloudDatabaseDtoFixed = Object.assign(new AddCloudDatabaseDto(), { + ...mockGetCloudSubscriptionDatabaseDto, + subscriptionType: CloudSubscriptionType.Fixed, +}); + +export const mockAddCloudDatabaseResponse = Object.assign(new AddCloudDatabaseResponse(), { + ...mockAddCloudDatabaseDto, + status: ActionStatus.Success, + message: 'Added', + databaseDetails: mockCloudDatabase, +}); + +export const mockAddCloudDatabaseResponseFixed = Object.assign(new AddCloudDatabaseResponse(), { + ...mockAddCloudDatabaseDtoFixed, + status: ActionStatus.Success, + message: 'Added', + databaseDetails: mockCloudDatabaseFixed, +}); + +export const mockCloudAutodiscoveryAnalytics = jest.fn(() => ({ + sendGetRECloudSubsSucceedEvent: jest.fn(), + sendGetRECloudSubsFailedEvent: jest.fn(), + sendGetRECloudDbsSucceedEvent: jest.fn(), + sendGetRECloudDbsFailedEvent: jest.fn(), +})); diff --git a/redisinsight/api/src/__mocks__/database-recommendation.ts b/redisinsight/api/src/__mocks__/database-recommendation.ts index d3c869c335..d3ed5ffdcf 100644 --- a/redisinsight/api/src/__mocks__/database-recommendation.ts +++ b/redisinsight/api/src/__mocks__/database-recommendation.ts @@ -1,17 +1,35 @@ import { DatabaseRecommendation } from 'src/modules/database-recommendation/models'; +import { DatabaseRecommendationEntity } + from 'src/modules/database-recommendation/entities/database-recommendation.entity'; +import { EncryptionStrategy } from 'src/modules/encryption/models'; import { mockDatabaseId } from 'src/__mocks__/databases'; export const mockDatabaseRecommendationId = 'databaseRecommendationID'; +export const mockRecommendationName = 'string'; + +export const mockDatabaseRecommendationParamsEncrypted = 'recommendation.params_ENCRYPTED'; + +export const mockDatabaseRecommendationParamsPlain = []; + export const mockDatabaseRecommendation = Object.assign(new DatabaseRecommendation(), { id: mockDatabaseRecommendationId, - name: 'string', + name: mockRecommendationName, databaseId: mockDatabaseId, read: false, disabled: false, hide: false, + params: mockDatabaseRecommendationParamsPlain, }); +export const mockDatabaseRecommendationEntity = new DatabaseRecommendationEntity( + { + ...mockDatabaseRecommendation, + params: mockDatabaseRecommendationParamsEncrypted, + encryption: EncryptionStrategy.KEYTAR, + }, +); + export const mockDatabaseRecommendationService = () => ({ create: jest.fn(), list: jest.fn(), diff --git a/redisinsight/api/src/__mocks__/databases.ts b/redisinsight/api/src/__mocks__/databases.ts index c4a567f60d..917a1ec8cb 100644 --- a/redisinsight/api/src/__mocks__/databases.ts +++ b/redisinsight/api/src/__mocks__/databases.ts @@ -15,6 +15,8 @@ import { mockSshOptionsPrivateKey, mockSshOptionsPrivateKeyEntity, } from 'src/__mocks__/ssh'; +import { CloudDatabaseDetails, CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models'; +import { CloudDatabaseDetailsEntity } from 'src/modules/cloud/autodiscovery/entities/cloud-database-details.entity'; export const mockDatabaseId = 'a77b23c1-7816-4ea4-b61f-d37795a0f805-db-id'; @@ -35,6 +37,19 @@ export const mockDatabase = Object.assign(new Database(), { timeout: 30_000, new: false, compressor: Compressor.NONE, + version: '7.0', +}); + +export const mockDatabaseCloudDetails = Object.assign(new CloudDatabaseDetails(), { + subscriptionType: CloudSubscriptionType.Fixed, + cloudId: 500001, + planMemoryLimit: 256, + memoryLimitMeasurementUnit: 'MB', +}); + +export const mockDatabaseWithCloudDetails = Object.assign(new Database(), { + ...mockDatabase, + cloudDetails: mockDatabaseCloudDetails, }); export const mockDatabaseEntity = Object.assign(new DatabaseEntity(), { @@ -42,6 +57,14 @@ export const mockDatabaseEntity = Object.assign(new DatabaseEntity(), { encryption: null, }); +export const mockDatabaseEntityWithCloudDetails = Object.assign(new DatabaseEntity(), { + ...mockDatabaseEntity, + cloudDetails: Object.assign(new CloudDatabaseDetailsEntity(), { + id: 'some-uuid', + ...mockDatabaseCloudDetails, + }), +}); + export const mockDatabaseWithSshBasic = Object.assign(new Database(), { ...mockDatabase, ssh: true, @@ -219,6 +242,7 @@ export const mockDatabaseInfoProvider = jest.fn(() => ({ isCluster: jest.fn(), isSentinel: jest.fn(), determineDatabaseModules: jest.fn(), + determineDatabaseServer: jest.fn(), determineSentinelMasterGroups: jest.fn().mockReturnValue([mockSentinelMasterDto]), determineClusterNodes: jest.fn().mockResolvedValue(mockClusterNodes), getRedisGeneralInfo: jest.fn().mockResolvedValueOnce(mockRedisGeneralInfo), diff --git a/redisinsight/api/src/__mocks__/index.ts b/redisinsight/api/src/__mocks__/index.ts index 4298564992..14782b0e09 100644 --- a/redisinsight/api/src/__mocks__/index.ts +++ b/redisinsight/api/src/__mocks__/index.ts @@ -23,3 +23,4 @@ export * from './ssh'; export * from './browser-history'; export * from './database-recommendation'; export * from './feature'; +export * from './cloud-autodiscovery'; diff --git a/redisinsight/api/src/__mocks__/redis-enterprise.ts b/redisinsight/api/src/__mocks__/redis-enterprise.ts index 93c7b274f3..58488cb87b 100644 --- a/redisinsight/api/src/__mocks__/redis-enterprise.ts +++ b/redisinsight/api/src/__mocks__/redis-enterprise.ts @@ -1,7 +1,5 @@ import { RedisEnterpriseDatabase } from 'src/modules/redis-enterprise/dto/cluster.dto'; import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; -import { GetRedisCloudSubscriptionResponse, RedisCloudDatabase } from 'src/modules/redis-enterprise/dto/cloud.dto'; -import { RedisCloudSubscriptionStatus } from 'src/modules/redis-enterprise/models/redis-cloud-subscriptions'; export const mockRedisEnterpriseDatabaseDto: RedisEnterpriseDatabase = { uid: 1, @@ -16,31 +14,7 @@ export const mockRedisEnterpriseDatabaseDto: RedisEnterpriseDatabase = { password: null, }; -export const mockRedisCloudSubscriptionDto: GetRedisCloudSubscriptionResponse = { - id: 1, - name: 'Basic subscription example', - numberOfDatabases: 1, - provider: 'AWS', - region: 'us-east-1', - status: RedisCloudSubscriptionStatus.Active, -}; - -export const mockRedisCloudDatabaseDto: RedisCloudDatabase = { - databaseId: 51166493, - subscriptionId: 1, - modules: [], - name: 'Database', - options: {}, - publicEndpoint: 'redis.us-east-1-1.rlrcp.com:12315', - sslClientAuthentication: false, - status: RedisEnterpriseDatabaseStatus.Active, -}; - export const mockRedisEnterpriseAnalytics = jest.fn(() => ({ sendGetREClusterDbsSucceedEvent: jest.fn(), sendGetREClusterDbsFailedEvent: jest.fn(), - sendGetRECloudSubsSucceedEvent: jest.fn(), - sendGetRECloudSubsFailedEvent: jest.fn(), - sendGetRECloudDbsSucceedEvent: jest.fn(), - sendGetRECloudDbsFailedEvent: jest.fn(), })); diff --git a/redisinsight/api/src/app.module.ts b/redisinsight/api/src/app.module.ts index 4a4fd36a07..d89dd4a734 100644 --- a/redisinsight/api/src/app.module.ts +++ b/redisinsight/api/src/app.module.ts @@ -22,6 +22,7 @@ import { AutodiscoveryModule } from 'src/modules/autodiscovery/autodiscovery.mod import { DatabaseImportModule } from 'src/modules/database-import/database-import.module'; import { DummyAuthMiddleware } from 'src/common/middlewares/dummy-auth.middleware'; import { CustomTutorialModule } from 'src/modules/custom-tutorial/custom-tutorial.module'; +import { CloudModule } from 'src/modules/cloud/cloud.module'; import { BrowserModule } from './modules/browser/browser.module'; import { RedisEnterpriseModule } from './modules/redis-enterprise/redis-enterprise.module'; import { RedisSentinelModule } from './modules/redis-sentinel/redis-sentinel.module'; @@ -42,6 +43,7 @@ const PATH_CONFIG = config.get('dir_path'); RouterModule.forRoutes(routes), AutodiscoveryModule, RedisEnterpriseModule, + CloudModule.register(), RedisSentinelModule, BrowserModule, CliModule, diff --git a/redisinsight/api/src/common/constants/api.ts b/redisinsight/api/src/common/constants/api.ts index 6e044bfe2c..c929428b14 100644 --- a/redisinsight/api/src/common/constants/api.ts +++ b/redisinsight/api/src/common/constants/api.ts @@ -1,3 +1,4 @@ export const API_PARAM_DATABASE_ID = 'dbInstance'; export const API_HEADER_DATABASE_INDEX = 'ri-db-index'; +export const API_HEADER_WINDOW_ID = 'x-window-id'; export const API_PARAM_CLI_CLIENT_ID = 'uuid'; diff --git a/redisinsight/api/src/common/interceptors/timeout.interceptor.ts b/redisinsight/api/src/common/interceptors/timeout.interceptor.ts index f726898d0f..e7b1e13988 100644 --- a/redisinsight/api/src/common/interceptors/timeout.interceptor.ts +++ b/redisinsight/api/src/common/interceptors/timeout.interceptor.ts @@ -18,13 +18,16 @@ export class TimeoutInterceptor implements NestInterceptor { private readonly message: string; - constructor(message: string = 'Request timeout') { + private readonly timeout: number; + + constructor(message: string = 'Request timeout', timeoutMs?: number) { this.message = message; + this.timeout = timeoutMs ?? serverConfig.requestTimeout; } intercept(context: ExecutionContext, next: CallHandler): Observable { return next.handle().pipe( - timeout(serverConfig.requestTimeout), + timeout(this.timeout), catchError((err) => { if (err instanceof TimeoutError) { const { method, url } = context.switchToHttp().getRequest(); diff --git a/redisinsight/api/src/constants/custom-error-codes.ts b/redisinsight/api/src/constants/custom-error-codes.ts new file mode 100644 index 0000000000..08c661b65b --- /dev/null +++ b/redisinsight/api/src/constants/custom-error-codes.ts @@ -0,0 +1,3 @@ +export enum CustomErrorCodes { + WindowUnauthorized = 10_001, +} diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index dba176f778..269d4f8356 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -15,6 +15,7 @@ export default { WRONG_DATABASE_TYPE: 'Wrong database type.', CONNECTION_TIMEOUT: 'The connection has timed out, please check the connection details.', + SERVER_CLOSED_CONNECTION: 'Server closed the connection.', AUTHENTICATION_FAILED: () => 'Failed to authenticate, please check the username or password.', INCORRECT_DATABASE_URL: (url) => `Could not connect to ${url}, please check the connection details.`, INCORRECT_CERTIFICATES: (url) => `Could not connect to ${url}, please check the CA or Client certificate.`, @@ -64,4 +65,6 @@ export default { APP_SETTINGS_NOT_FOUND: () => 'Could not find application settings.', SERVER_INFO_NOT_FOUND: () => 'Could not find server info.', INCREASE_MINIMUM_LIMIT: (count: string) => `Set MAXSEARCHRESULTS to at least ${count}.`, + INVALID_WINDOW_ID: 'Invalid window id.', + UNDEFINED_WINDOW_ID: 'Undefined window id.', }; diff --git a/redisinsight/api/src/constants/index.ts b/redisinsight/api/src/constants/index.ts index bba5b52d42..b3c510097b 100644 --- a/redisinsight/api/src/constants/index.ts +++ b/redisinsight/api/src/constants/index.ts @@ -10,3 +10,4 @@ export * from './telemetry-events'; export * from './app-events'; export * from './redis-connection'; export * from './recommendations'; +export * from './custom-error-codes'; diff --git a/redisinsight/api/src/constants/telemetry-events.ts b/redisinsight/api/src/constants/telemetry-events.ts index 9d39221de1..88c3983d01 100644 --- a/redisinsight/api/src/constants/telemetry-events.ts +++ b/redisinsight/api/src/constants/telemetry-events.ts @@ -72,6 +72,9 @@ export enum TelemetryEvents { FeatureFlagConfigUpdateError = 'FEATURE_FLAG_CONFIG_UPDATE_ERROR', FeatureFlagInvalidRemoteConfig = 'FEATURE_FLAG_INVALID_REMOTE_CONFIG', FeatureFlagRecalculated = 'FEATURE_FLAG_RECALCULATED', + + // Insights + InsightsRecommendationGenerated = 'INSIGHTS_RECOMMENDATION_GENERATED', } export enum CommandType { diff --git a/redisinsight/api/src/core.module.ts b/redisinsight/api/src/core.module.ts index eb5167d60c..58eb8610ae 100644 --- a/redisinsight/api/src/core.module.ts +++ b/redisinsight/api/src/core.module.ts @@ -10,6 +10,7 @@ import { AnalyticsModule } from 'src/modules/analytics/analytics.module'; import { SshModule } from 'src/modules/ssh/ssh.module'; import { NestjsFormDataModule } from 'nestjs-form-data'; import { FeatureModule } from 'src/modules/feature/feature.module'; +import { AuthModule } from 'src/modules/auth/auth.module'; @Global() @Module({ @@ -25,6 +26,7 @@ import { FeatureModule } from 'src/modules/feature/feature.module'; SshModule, NestjsFormDataModule, FeatureModule.register(), + AuthModule.register(), ], exports: [ EncryptionModule, diff --git a/redisinsight/api/src/main.ts b/redisinsight/api/src/main.ts index 37d37f1a2e..f4297851d6 100644 --- a/redisinsight/api/src/main.ts +++ b/redisinsight/api/src/main.ts @@ -1,20 +1,26 @@ import 'dotenv/config'; import { NestFactory } from '@nestjs/core'; import { SwaggerModule } from '@nestjs/swagger'; -import { NestApplicationOptions } from '@nestjs/common'; +import { INestApplication, NestApplicationOptions } from '@nestjs/common'; import * as bodyParser from 'body-parser'; import { WinstonModule } from 'nest-winston'; import { GlobalExceptionFilter } from 'src/exceptions/global-exception.filter'; import { get } from 'src/utils'; import { migrateHomeFolder } from 'src/init-helper'; import { LogFileProvider } from 'src/modules/profiler/providers/log-file.provider'; +import { WindowsAuthAdapter } from 'src/modules/auth/window-auth/adapters/window-auth.adapter'; import { AppModule } from './app.module'; import SWAGGER_CONFIG from '../config/swagger'; import LOGGER_CONFIG from '../config/logger'; const serverConfig = get('server'); -export default async function bootstrap(): Promise { +interface IApp { + app: INestApplication + gracefulShutdown: Function +} + +export default async function bootstrap(): Promise { await migrateHomeFolder(); const port = process.env.API_PORT || serverConfig.port; @@ -44,6 +50,8 @@ export default async function bootstrap(): Promise { app, SwaggerModule.createDocument(app, SWAGGER_CONFIG), ); + } else { + app.useWebSocketAdapter(new WindowsAuthAdapter(app)); } const logFileProvider = app.get(LogFileProvider); @@ -67,7 +75,7 @@ export default async function bootstrap(): Promise { process.on('SIGTERM', gracefulShutdown); process.on('SIGINT', gracefulShutdown); - return gracefulShutdown; + return { app, gracefulShutdown }; } if (process.env.APP_ENV !== 'electron') { diff --git a/redisinsight/api/src/modules/auth/auth.module.ts b/redisinsight/api/src/modules/auth/auth.module.ts new file mode 100644 index 0000000000..5f40176d8f --- /dev/null +++ b/redisinsight/api/src/modules/auth/auth.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import config from 'src/utils/config'; +import { WindowAuthModule } from './window-auth/window-auth.module'; +import { BuildType } from '../server/models/server'; + +const SERVER_CONFIG = config.get('server'); + +@Module({}) +export class AuthModule { + static register() { + const imports = []; + + if (SERVER_CONFIG.buildType === BuildType.Electron) { + imports.push(WindowAuthModule); + } + + return { + module: AuthModule, + imports, + }; + } +} diff --git a/redisinsight/api/src/modules/auth/window-auth/adapters/window-auth.adapter.ts b/redisinsight/api/src/modules/auth/window-auth/adapters/window-auth.adapter.ts new file mode 100644 index 0000000000..f25b3d401f --- /dev/null +++ b/redisinsight/api/src/modules/auth/window-auth/adapters/window-auth.adapter.ts @@ -0,0 +1,35 @@ +import { INestApplication, Logger } from '@nestjs/common'; +import { IoAdapter } from '@nestjs/platform-socket.io'; +import { BaseWsInstance, MessageMappingProperties } from '@nestjs/websockets'; +import { get } from 'lodash'; +import { Observable } from 'rxjs'; +import { Socket } from 'socket.io'; +import { API_HEADER_WINDOW_ID } from 'src/common/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { WindowAuthService } from '../window-auth.service'; + +export class WindowsAuthAdapter extends IoAdapter { + private windowAuthService: WindowAuthService; + private logger = new Logger('WindowsAuthAdapter'); + + constructor(private app: INestApplication) { + super(app); + this.windowAuthService = this.app.get(WindowAuthService); + } + + async bindMessageHandlers( + socket: Socket, + handlers: MessageMappingProperties[], + transform: (data: any) => Observable, + ) { + const windowId = (get(socket, `handshake.headers.${API_HEADER_WINDOW_ID}`) as string) || ''; + const isAuthorized = await this.windowAuthService?.isAuthorized(windowId); + + if (!isAuthorized) { + this.logger.error(ERROR_MESSAGES.UNDEFINED_WINDOW_ID); + return; + } + + return super.bindMessageHandlers(socket, handlers, transform); + } +} diff --git a/redisinsight/api/src/modules/auth/window-auth/constants/exceptions.ts b/redisinsight/api/src/modules/auth/window-auth/constants/exceptions.ts new file mode 100644 index 0000000000..e65d7fdcb2 --- /dev/null +++ b/redisinsight/api/src/modules/auth/window-auth/constants/exceptions.ts @@ -0,0 +1,16 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { CustomErrorCodes } from 'src/constants'; + +export class WindowUnauthorizedException extends HttpException { + constructor(message) { + super( + { + statusCode: HttpStatus.UNAUTHORIZED, + errorCode: CustomErrorCodes.WindowUnauthorized, + message, + error: 'Window Unauthorized', + }, + HttpStatus.UNAUTHORIZED, + ); + } +} diff --git a/redisinsight/api/src/modules/auth/window-auth/middleware/window.auth.middleware.ts b/redisinsight/api/src/modules/auth/window-auth/middleware/window.auth.middleware.ts new file mode 100644 index 0000000000..b6d52101eb --- /dev/null +++ b/redisinsight/api/src/modules/auth/window-auth/middleware/window.auth.middleware.ts @@ -0,0 +1,35 @@ +import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { API_HEADER_WINDOW_ID } from 'src/common/constants'; +import { WindowAuthService } from '../window-auth.service'; +import { WindowUnauthorizedException } from '../constants/exceptions'; + +@Injectable() +export class WindowAuthMiddleware implements NestMiddleware { + private logger = new Logger('WindowAuthMiddleware'); + + constructor(private windowAuthService: WindowAuthService) {} + + async use(req: Request, res: Response, next: NextFunction): Promise { + const { windowId } = WindowAuthMiddleware.getWindowIdFromReq(req); + const isAuthorized = await this.windowAuthService.isAuthorized(windowId); + + if (!isAuthorized) { + this.throwError(req, ERROR_MESSAGES.UNDEFINED_WINDOW_ID); + } + + next(); + } + + private static getWindowIdFromReq(req: Request) { + return { windowId: `${req?.headers?.[API_HEADER_WINDOW_ID]}` }; + } + + private throwError(req: Request, message: string) { + const { method, url } = req; + this.logger.error(`${message} ${method} ${url}`); + + throw new WindowUnauthorizedException(message); + } +} diff --git a/redisinsight/api/src/modules/auth/window-auth/strategies/abstract.window.auth.strategy.ts b/redisinsight/api/src/modules/auth/window-auth/strategies/abstract.window.auth.strategy.ts new file mode 100644 index 0000000000..7e04c558a6 --- /dev/null +++ b/redisinsight/api/src/modules/auth/window-auth/strategies/abstract.window.auth.strategy.ts @@ -0,0 +1,3 @@ +export abstract class AbstractWindowAuthStrategy { + abstract isAuthorized(data: any): Promise; +} diff --git a/redisinsight/api/src/modules/auth/window-auth/window-auth.module.ts b/redisinsight/api/src/modules/auth/window-auth/window-auth.module.ts new file mode 100644 index 0000000000..e85468bdee --- /dev/null +++ b/redisinsight/api/src/modules/auth/window-auth/window-auth.module.ts @@ -0,0 +1,18 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import config from 'src/utils/config'; +import { WindowAuthService } from './window-auth.service'; +import { WindowAuthMiddleware } from './middleware/window.auth.middleware'; + +const SERVER_CONFIG = config.get('server'); + +@Module({ + providers: [WindowAuthService], +}) +export class WindowAuthModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(WindowAuthMiddleware) + .exclude(...SERVER_CONFIG.excludeAuthRoutes) + .forRoutes('*'); + } +} diff --git a/redisinsight/api/src/modules/auth/window-auth/window-auth.service.spec.ts b/redisinsight/api/src/modules/auth/window-auth/window-auth.service.spec.ts new file mode 100644 index 0000000000..6fcce96c70 --- /dev/null +++ b/redisinsight/api/src/modules/auth/window-auth/window-auth.service.spec.ts @@ -0,0 +1,28 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WindowAuthService } from './window-auth.service'; +import { AbstractWindowAuthStrategy } from './strategies/abstract.window.auth.strategy'; + +export class TestAuthStrategy extends AbstractWindowAuthStrategy { + async isAuthorized(): Promise { + return true; + } +} + +const testStrategy = new TestAuthStrategy(); + +describe('WindowAuthService', () => { + let windowAuthService: WindowAuthService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [WindowAuthService], + exports: [WindowAuthService], + }).compile(); + + windowAuthService = module.get(WindowAuthService); + }); + it('Should set strategy to window auth service and call it', async () => { + windowAuthService.setStrategy(testStrategy); + expect(await windowAuthService.isAuthorized('')).toEqual(true); + }); +}); diff --git a/redisinsight/api/src/modules/auth/window-auth/window-auth.service.ts b/redisinsight/api/src/modules/auth/window-auth/window-auth.service.ts new file mode 100644 index 0000000000..c58686a8c4 --- /dev/null +++ b/redisinsight/api/src/modules/auth/window-auth/window-auth.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { AbstractWindowAuthStrategy } from './strategies/abstract.window.auth.strategy'; + +@Injectable() +export class WindowAuthService { + private strategy: AbstractWindowAuthStrategy = null; + + /** + * Return strategy on how we are going to work with app(electron) windows auth + * @param strategy + */ + setStrategy(strategy: AbstractWindowAuthStrategy): void { + this.strategy = strategy; + } + + isAuthorized(id: string = ''): Promise { + return this.strategy?.isAuthorized?.(id); + } +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodicovery.analytics.spec.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodicovery.analytics.spec.ts new file mode 100644 index 0000000000..2cefbcc9a0 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodicovery.analytics.spec.ts @@ -0,0 +1,234 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { InternalServerErrorException } from '@nestjs/common'; +import { CloudAutodiscoveryAnalytics } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics'; +import { + CloudDatabaseStatus, + CloudSubscriptionStatus, + CloudSubscriptionType +} from 'src/modules/cloud/autodiscovery/models'; +import { mockCloudDatabase, mockCloudDatabaseFixed, mockCloudSubscription } from 'src/__mocks__'; + +describe('CloudAutodiscoveryAnalytics', () => { + let service: CloudAutodiscoveryAnalytics; + let sendEventMethod; + let sendFailedEventMethod; + const httpException = new InternalServerErrorException(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventEmitter2, + CloudAutodiscoveryAnalytics, + ], + }).compile(); + + service = await module.get(CloudAutodiscoveryAnalytics); + sendEventMethod = jest.spyOn( + service, + 'sendEvent', + ); + sendFailedEventMethod = jest.spyOn( + service, + 'sendFailedEvent', + ); + }); + + describe('sendGetRECloudSubsSucceedEvent', () => { + it('should emit event with active subscriptions', () => { + service.sendGetRECloudSubsSucceedEvent([ + mockCloudSubscription, + mockCloudSubscription, + ], CloudSubscriptionType.Flexible); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: 2, + totalNumberOfSubscriptions: 2, + type: CloudSubscriptionType.Flexible, + }, + ); + }); + it('should emit event with active and not active subscription', () => { + service.sendGetRECloudSubsSucceedEvent([ + { + ...mockCloudSubscription, + status: CloudSubscriptionStatus.Error, + }, + mockCloudSubscription, + ], CloudSubscriptionType.Flexible); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: 1, + totalNumberOfSubscriptions: 2, + type: CloudSubscriptionType.Flexible, + }, + ); + }); + it('should emit event without active subscriptions', () => { + service.sendGetRECloudSubsSucceedEvent([ + { + ...mockCloudSubscription, + status: CloudSubscriptionStatus.Error, + }, + { + ...mockCloudSubscription, + status: CloudSubscriptionStatus.Error, + }, + ], CloudSubscriptionType.Flexible); + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: 0, + totalNumberOfSubscriptions: 2, + type: CloudSubscriptionType.Flexible, + }, + ); + }); + it('should emit GetRECloudSubsSucceedEvent event for empty list', () => { + service.sendGetRECloudSubsSucceedEvent([], CloudSubscriptionType.Flexible); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: 0, + totalNumberOfSubscriptions: 0, + type: CloudSubscriptionType.Flexible, + }, + ); + }); + it('should emit GetRECloudSubsSucceedEvent event for undefined input value', () => { + service.sendGetRECloudSubsSucceedEvent(undefined, CloudSubscriptionType.Fixed); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: 0, + totalNumberOfSubscriptions: 0, + type: CloudSubscriptionType.Fixed, + }, + ); + }); + it('should not throw on error when sending GetRECloudSubsSucceedEvent event', () => { + const input: any = {}; + + expect(() => service.sendGetRECloudSubsSucceedEvent(input, CloudSubscriptionType.Flexible)).not.toThrow(); + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + }); + + describe('sendGetRECloudSubsFailedEvent', () => { + it('should emit GetRECloudSubsFailedEvent event', () => { + service.sendGetRECloudSubsFailedEvent(httpException, CloudSubscriptionType.Fixed); + + expect(sendFailedEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoveryFailed, + httpException, + { type: CloudSubscriptionType.Fixed }, + ); + }); + }); + + describe('sendGetRECloudDbsSucceedEvent', () => { + it('should emit event with active databases', () => { + service.sendGetRECloudDbsSucceedEvent([ + mockCloudDatabase, + mockCloudDatabaseFixed, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: 2, + totalNumberOfDatabases: 2, + fixed: 1, + flexible: 1, + }, + ); + }); + it('should emit event with active and not active database', () => { + service.sendGetRECloudDbsSucceedEvent([ + { + ...mockCloudDatabase, + status: CloudDatabaseStatus.Pending, + }, + mockCloudDatabase, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: 1, + totalNumberOfDatabases: 2, + fixed: 0, + flexible: 2, + }, + ); + }); + it('should emit event without active databases', () => { + service.sendGetRECloudDbsSucceedEvent([ + { + ...mockCloudDatabase, + status: CloudDatabaseStatus.Pending, + }, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: 0, + totalNumberOfDatabases: 1, + fixed: 0, + flexible: 1, + }, + ); + }); + it('should emit event for empty list', () => { + service.sendGetRECloudDbsSucceedEvent([]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: 0, + totalNumberOfDatabases: 0, + fixed: 0, + flexible: 0, + }, + ); + }); + it('should emit event for undefined input value', () => { + service.sendGetRECloudDbsSucceedEvent(undefined); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: 0, + totalNumberOfDatabases: 0, + fixed: 0, + flexible: 0, + }, + ); + }); + it('should not throw on error', () => { + const input: any = {}; + + expect(() => service.sendGetRECloudDbsSucceedEvent(input)).not.toThrow(); + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + }); + + describe('sendGetRECloudDbsFailedEvent', () => { + it('should emit event', () => { + service.sendGetRECloudDbsFailedEvent(httpException); + + expect(sendFailedEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoveryFailed, + httpException, + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics.ts new file mode 100644 index 0000000000..20008a8529 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics.ts @@ -0,0 +1,62 @@ +import { countBy } from 'lodash'; +import { HttpException, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { TelemetryBaseService } from 'src/modules/analytics/telemetry.base.service'; +import { + CloudDatabase, + CloudDatabaseStatus, + CloudSubscription, + CloudSubscriptionStatus, CloudSubscriptionType, +} from 'src/modules/cloud/autodiscovery/models'; + +@Injectable() +export class CloudAutodiscoveryAnalytics extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } + + sendGetRECloudSubsSucceedEvent(subscriptions: CloudSubscription[] = [], type: CloudSubscriptionType) { + try { + this.sendEvent( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: subscriptions.filter( + (sub) => sub.status === CloudSubscriptionStatus.Active, + ).length, + totalNumberOfSubscriptions: subscriptions.length, + type, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendGetRECloudSubsFailedEvent(exception: HttpException, type: CloudSubscriptionType) { + this.sendFailedEvent(TelemetryEvents.RECloudSubscriptionsDiscoveryFailed, exception, { type }); + } + + sendGetRECloudDbsSucceedEvent(databases: CloudDatabase[] = []) { + try { + this.sendEvent( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: databases.filter( + (db) => db.status === CloudDatabaseStatus.Active, + ).length, + totalNumberOfDatabases: databases.length, + fixed: 0, + flexible: 0, + ...countBy(databases, 'subscriptionType'), + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendGetRECloudDbsFailedEvent(exception: HttpException) { + this.sendFailedEvent(TelemetryEvents.RECloudDatabasesDiscoveryFailed, exception); + } +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts new file mode 100644 index 0000000000..4d2bf04981 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts @@ -0,0 +1,431 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import axios, { AxiosError } from 'axios'; +import { ForbiddenException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + mockAddCloudDatabaseResponse, + mockAddCloudDatabaseResponseFixed, + mockCloudAccountInfo, + mockCloudApiAccount, + mockCloudApiDatabase, mockCloudApiDatabaseFixed, + mockCloudApiSubscription, + mockCloudApiSubscriptionDatabases, + mockCloudApiSubscriptionDatabasesFixed, + mockCloudAuthDto, + mockCloudAutodiscoveryAnalytics, + mockCloudDatabase, + mockCloudDatabaseFixed, + mockCloudDatabaseFromList, mockCloudDatabaseFromListFixed, + mockCloudSubscription, mockCloudSubscriptionFixed, + mockDatabaseService, + mockGetCloudSubscriptionDatabaseDto, + mockGetCloudSubscriptionDatabaseDtoFixed, + mockGetCloudSubscriptionDatabasesDto, mockGetCloudSubscriptionDatabasesDtoFixed, + MockType, +} from 'src/__mocks__'; +import { DatabaseService } from 'src/modules/database/database.service'; +import { CloudAutodiscoveryService } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.service'; +import { CloudAutodiscoveryAnalytics } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics'; +import { CloudDatabaseStatus, CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models'; +import { ActionStatus } from 'src/common/models'; + +const mockedAxios = axios as jest.Mocked; +jest.mock('axios'); +mockedAxios.create = jest.fn(() => mockedAxios); + +const mockUnauthenticatedErrorMessage = 'Request failed with status code 401'; +const mockApiUnauthenticatedResponse = { + message: mockUnauthenticatedErrorMessage, + response: { + status: 401, + }, +}; + +describe('CloudAutodiscoveryService', () => { + let service: CloudAutodiscoveryService; + let analytics: MockType; + let databaseService: MockType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CloudAutodiscoveryService, + { + provide: DatabaseService, + useFactory: mockDatabaseService, + }, + { + provide: CloudAutodiscoveryAnalytics, + useFactory: mockCloudAutodiscoveryAnalytics, + }, + ], + }).compile(); + + service = module.get(CloudAutodiscoveryService); + analytics = module.get(CloudAutodiscoveryAnalytics); + databaseService = module.get(DatabaseService); + }); + + describe('getAccount', () => { + it('successfully get Redis Enterprise Cloud account', async () => { + const response = { + status: 200, + data: { account: mockCloudApiAccount }, + }; + mockedAxios.get.mockResolvedValue(response); + + expect(await service.getAccount(mockCloudAuthDto)).toEqual(mockCloudAccountInfo); + }); + it('Should throw Forbidden exception', async () => { + mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); + + await expect(service.getAccount(mockCloudAuthDto)).rejects.toThrow( + ForbiddenException, + ); + }); + }); + + describe('getSubscriptions', () => { + it('successfully get Redis Enterprise Cloud subscriptions', async () => { + const response = { + status: 200, + data: { subscriptions: [mockCloudApiSubscription] }, + }; + mockedAxios.get.mockResolvedValue(response); + + expect(await service.getSubscriptions(mockCloudAuthDto)).toEqual([{ + ...mockCloudSubscriptionFixed, + type: CloudSubscriptionType.Fixed, + }, { + ...mockCloudSubscription, + type: CloudSubscriptionType.Flexible, + }]); + expect(analytics.sendGetRECloudSubsSucceedEvent) + .toHaveBeenCalledWith([{ + ...mockCloudSubscriptionFixed, + }], CloudSubscriptionType.Fixed); + expect(analytics.sendGetRECloudSubsSucceedEvent) + .toHaveBeenCalledWith([{ + ...mockCloudSubscription, + }], CloudSubscriptionType.Flexible); + }); + it('should throw forbidden error when get subscriptions', async () => { + mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); + + await expect(service.getSubscriptions(mockCloudAuthDto)).rejects.toThrow( + ForbiddenException, + ); + + expect(analytics.sendGetRECloudSubsFailedEvent) + .toHaveBeenCalledWith(service['getApiError']( + mockApiUnauthenticatedResponse as AxiosError, + 'Failed to get RE cloud subscriptions', + ), CloudSubscriptionType.Flexible); + }); + }); + + describe('getSubscriptionDatabase', () => { + it('successfully get database from Redis Cloud subscriptions', async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockCloudApiDatabase, + }); + + expect(await service.getSubscriptionDatabase( + mockCloudAuthDto, + mockGetCloudSubscriptionDatabaseDto, + )).toEqual(mockCloudDatabase); + }); + it('successfully get fixed database from Redis Cloud subscriptions', async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockCloudApiDatabaseFixed, + }); + + expect(await service.getSubscriptionDatabase( + mockCloudAuthDto, + mockGetCloudSubscriptionDatabaseDtoFixed, + )).toEqual(mockCloudDatabaseFixed); + }); + it('the user could not be authenticated', async () => { + mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); + + await expect( + service.getSubscriptionDatabase(mockCloudAuthDto, mockGetCloudSubscriptionDatabaseDto), + ).rejects.toThrow(ForbiddenException); + }); + it('database not found', async () => { + const apiResponse = { + message: `Subscription ${mockCloudSubscription.id} database ${mockCloudDatabase.databaseId} not found`, + response: { + status: 404, + }, + }; + mockedAxios.get.mockRejectedValue(apiResponse); + + await expect( + service.getSubscriptionDatabase(mockCloudAuthDto, mockGetCloudSubscriptionDatabaseDto), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('getSubscriptionDatabases', () => { + it('successfully get cloud databases', async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockCloudApiSubscriptionDatabases, + }); + + expect(await service.getSubscriptionDatabases(mockCloudAuthDto, mockGetCloudSubscriptionDatabasesDto)) + .toEqual([mockCloudDatabaseFromList]); + }); + it('successfully get cloud fixed databases', async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockCloudApiSubscriptionDatabasesFixed, + }); + + expect(await service.getSubscriptionDatabases(mockCloudAuthDto, mockGetCloudSubscriptionDatabasesDtoFixed)) + .toEqual([mockCloudDatabaseFromListFixed]); + }); + it('the user could not be authenticated', async () => { + mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); + + await expect(service.getSubscriptionDatabases(mockCloudAuthDto, mockGetCloudSubscriptionDatabasesDto)) + .rejects.toThrow(ForbiddenException); + }); + it('subscription not found', async () => { + mockedAxios.get.mockRejectedValue({ + message: `Subscription ${mockCloudSubscription.id} not found`, + response: { + status: 404, + }, + }); + + await expect(service.getSubscriptionDatabases(mockCloudAuthDto, mockGetCloudSubscriptionDatabasesDto)) + .rejects.toThrow(NotFoundException); + }); + }); + + describe('getDatabases', () => { + beforeEach(() => { + service.getSubscriptionDatabases = jest.fn().mockResolvedValue([]); + }); + it('should call getSubscriptionDatabases 2 times', async () => { + await service.getDatabases(mockCloudAuthDto, { + subscriptions: [ + { subscriptionId: 86070, subscriptionType: CloudSubscriptionType.Flexible }, + { subscriptionId: 86071, subscriptionType: CloudSubscriptionType.Flexible }, + ], + }); + + expect(service.getSubscriptionDatabases).toHaveBeenCalledTimes(2); + }); + it('should call getSubscriptionDatabases 2 times (different types)', async () => { + await service.getDatabases(mockCloudAuthDto, { + subscriptions: [ + { subscriptionId: 86070, subscriptionType: CloudSubscriptionType.Flexible }, + { subscriptionId: 86071, subscriptionType: CloudSubscriptionType.Fixed }, + ], + }); + + expect(service.getSubscriptionDatabases).toHaveBeenCalledTimes(2); + }); + it('should call getSubscriptionDatabases 2 times (same id but different types)', async () => { + await service.getDatabases(mockCloudAuthDto, { + subscriptions: [ + { subscriptionId: 86070, subscriptionType: CloudSubscriptionType.Flexible }, + { subscriptionId: 86070, subscriptionType: CloudSubscriptionType.Fixed }, + ], + }); + + expect(service.getSubscriptionDatabases).toHaveBeenCalledTimes(2); + }); + it('should call getSubscriptionDatabases 2 times (uniq by id and type)', async () => { + await service.getDatabases(mockCloudAuthDto, { + subscriptions: [ + { subscriptionId: 86070, subscriptionType: CloudSubscriptionType.Flexible }, + { subscriptionId: 86071, subscriptionType: CloudSubscriptionType.Fixed }, + { subscriptionId: 86071, subscriptionType: CloudSubscriptionType.Fixed }, + ], + }); + + expect(service.getSubscriptionDatabases).toHaveBeenCalledTimes(2); + }); + it('subscription not found', async () => { + service.getSubscriptionDatabases = jest + .fn() + .mockRejectedValue(new NotFoundException()); + + await expect( + service.getDatabases(mockCloudAuthDto, { + subscriptions: [ + { subscriptionId: 86070, subscriptionType: CloudSubscriptionType.Flexible }, + { subscriptionId: 86071, subscriptionType: CloudSubscriptionType.Fixed }, + ], + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('getApiError', () => { + const title = 'Failed to get databases in RE cloud subscription'; + const mockError: AxiosError = { + name: '', + message: mockUnauthenticatedErrorMessage, + isAxiosError: true, + config: null, + response: { + statusText: mockUnauthenticatedErrorMessage, + data: null, + headers: {}, + config: null, + status: 401, + }, + toJSON: () => null, + }; + it('should throw ForbiddenException', async () => { + const result = service['getApiError'](mockError, title); + + expect(result).toBeInstanceOf(ForbiddenException); + }); + it('should throw InternalServerErrorException from response', async () => { + const errorMessage = 'Request failed with status code 500'; + const error = { + ...mockError, + message: errorMessage, + response: { + ...mockError.response, + status: 500, + statusText: errorMessage, + }, + }; + const result = service['getApiError'](error, title); + + expect(result).toBeInstanceOf(InternalServerErrorException); + }); + it('should throw InternalServerErrorException', async () => { + const error = { + ...mockError, + message: 'Request failed with status code 500', + response: undefined, + }; + const result = service['getApiError'](error, title); + + expect(result).toBeInstanceOf(InternalServerErrorException); + }); + it('should throw InternalServerErrorException with error from data', async () => { + const error = { + ...mockError, + message: 'Request failed with status code 500', + response: { + data: { + error: 'Service Unavailable', + }, + }, + }; + const result = service['getApiError'](error as AxiosError, title); + + expect(result).toBeInstanceOf(InternalServerErrorException); + }); + }); + + describe('addRedisCloudDatabases', () => { + let spy; + + beforeEach(() => { + spy = jest.spyOn(service, 'getSubscriptionDatabase'); + }); + + it('should successfully add 1 fixed and 1 flexible databases', async () => { + spy.mockResolvedValueOnce(mockCloudDatabase); + spy.mockResolvedValueOnce(mockCloudDatabaseFixed); + + const result = await service.addRedisCloudDatabases(mockCloudAuthDto, [ + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + ]); + + expect(result).toEqual([ + mockAddCloudDatabaseResponse, + mockAddCloudDatabaseResponseFixed, + ]); + }); + + it('should successfully add 1 fixed database and report 1 error without database details (404)', async () => { + spy.mockRejectedValueOnce(new NotFoundException()); + spy.mockResolvedValueOnce(mockCloudDatabaseFixed); + + const result = await service.addRedisCloudDatabases(mockCloudAuthDto, [ + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + ]); + + expect(result).toEqual([ + { + ...mockAddCloudDatabaseResponse, + error: { + message: 'Not Found', + statusCode: 404, + }, + message: 'Not Found', + status: 'fail', + databaseDetails: undefined, // no database details when database wasn't fetched from cloud + }, + mockAddCloudDatabaseResponseFixed, + ]); + }); + + it('should successfully add 1 fixed database and report 1 error with database details', async () => { + spy.mockResolvedValueOnce(mockCloudDatabase); + spy.mockResolvedValueOnce(mockCloudDatabaseFixed); + databaseService.create.mockRejectedValueOnce(new Error('Connectivity issue')); + + const result = await service.addRedisCloudDatabases(mockCloudAuthDto, [ + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + ]); + + expect(result).toEqual([ + { + ...mockAddCloudDatabaseResponse, + message: 'Connectivity issue', + status: ActionStatus.Fail, + }, + mockAddCloudDatabaseResponseFixed, + ]); + }); + + it('should successfully add 1 fixed database and report 1 error if db is not actives', async () => { + spy.mockResolvedValueOnce({ + ...mockCloudDatabase, + status: CloudDatabaseStatus.Pending, + }); + spy.mockResolvedValueOnce(mockCloudDatabaseFixed); + + const result = await service.addRedisCloudDatabases(mockCloudAuthDto, [ + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + ]); + + expect(result).toEqual([ + { + ...mockAddCloudDatabaseResponse, + error: { + error: 'Service Unavailable', + message: 'The base is inactive.', + statusCode: 503, + }, + message: 'The base is inactive.', + status: ActionStatus.Fail, + databaseDetails: { + ...mockAddCloudDatabaseResponse.databaseDetails, + status: CloudDatabaseStatus.Pending, + }, + }, + mockAddCloudDatabaseResponseFixed, + ]); + }); + }); +}); diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts new file mode 100644 index 0000000000..fcf765b6cd --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts @@ -0,0 +1,331 @@ +import { + ForbiddenException, + HttpException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import axios, { AxiosError, AxiosResponse } from 'axios'; +import { uniqBy } from 'lodash'; +import config from 'src/utils/config'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { + AddCloudDatabaseDto, + AddCloudDatabaseResponse, + CloudAuthDto, + GetCloudDatabasesDto, + GetCloudSubscriptionDatabaseDto, + GetCloudSubscriptionDatabasesDto, +} from 'src/modules/cloud/autodiscovery/dto'; +import { + CloudAccountInfo, + CloudDatabase, + CloudDatabaseStatus, + CloudSubscription, + CloudSubscriptionType, +} from 'src/modules/cloud/autodiscovery/models'; +import { DatabaseService } from 'src/modules/database/database.service'; +import { HostingProvider } from 'src/modules/database/entities/database.entity'; +import { ActionStatus } from 'src/common/models'; +import { + parseCloudAccountResponse, + parseCloudDatabaseResponse, + parseCloudDatabasesInSubscriptionResponse, + parseCloudSubscriptionsResponse, +} from 'src/modules/cloud/autodiscovery/utils/redis-cloud-converter'; +import { CloudAutodiscoveryAnalytics } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics'; + +@Injectable() +export class CloudAutodiscoveryService { + private logger = new Logger('CloudAutodiscoveryService'); + + private config = config.get('redis_cloud'); + + private api = axios.create(); + + constructor( + private readonly databaseService: DatabaseService, + private readonly analytics: CloudAutodiscoveryAnalytics, + ) {} + + /** + * Get api base for fixed subscriptions + * @param type + * @private + */ + getApiBase(type?: CloudSubscriptionType): string { + return `${this.config.url}${type === CloudSubscriptionType.Fixed ? '/fixed' : ''}`; + } + + /** + * Generates auth headers to attach to the request + * @param apiKey + * @param apiSecret + * @private + */ + static getAuthHeaders({ apiKey, apiSecret }: CloudAuthDto) { + return { + 'x-api-key': apiKey, + 'x-api-secret-key': apiSecret, + }; + } + + /** + * Generates proper error based on api response + * @param error + * @param errorTitle + * @private + */ + private getApiError(error: AxiosError, errorTitle: string): HttpException { + const { response } = error; + if (response) { + if (response.status === 401 || response.status === 403) { + this.logger.error(`${errorTitle}. ${error.message}`); + return new ForbiddenException(ERROR_MESSAGES.REDIS_CLOUD_FORBIDDEN); + } + if (response.status === 500) { + this.logger.error(`${errorTitle}. ${error.message}`); + return new InternalServerErrorException( + ERROR_MESSAGES.SERVER_NOT_AVAILABLE, + ); + } + if (response.data) { + const { data } = response; + this.logger.error( + `${errorTitle} ${error.message}`, + JSON.stringify(data), + ); + return new InternalServerErrorException(data.description || data.error); + } + } + this.logger.error(`${errorTitle}. ${error.message}`); + return new InternalServerErrorException(ERROR_MESSAGES.SERVER_NOT_AVAILABLE); + } + + /** + * Get cloud account short info + * @param authDto + */ + async getAccount(authDto: CloudAuthDto): Promise { + this.logger.log('Getting cloud account.'); + try { + const { + data: { account }, + }: AxiosResponse = await this.api.get(`${this.config.url}/`, { + headers: CloudAutodiscoveryService.getAuthHeaders(authDto), + }); + + this.logger.log('Succeed to get RE cloud account.'); + + return parseCloudAccountResponse(account); + } catch (error) { + throw this.getApiError(error, 'Failed to get RE cloud account'); + } + } + + /** + * Get list of account subscriptions + * @param authDto + * @param type + */ + async getSubscriptionsByType(authDto: CloudAuthDto, type: CloudSubscriptionType): Promise { + this.logger.log(`Getting cloud ${type} subscriptions.`); + try { + const { + data: { subscriptions }, + }: AxiosResponse = await this.api.get( + `${this.getApiBase(type)}/subscriptions`, + { + headers: CloudAutodiscoveryService.getAuthHeaders(authDto), + }, + ); + this.logger.log('Succeed to get cloud flexible subscriptions.'); + const result = parseCloudSubscriptionsResponse(subscriptions, type); + this.analytics.sendGetRECloudSubsSucceedEvent(result, type); + return result; + } catch (error) { + const exception = this.getApiError(error, 'Failed to get cloud flexible subscriptions'); + this.analytics.sendGetRECloudSubsFailedEvent(exception, type); + throw exception; + } + } + + async getSubscriptions(authDto: CloudAuthDto): Promise { + return [].concat(...await Promise.all([ + this.getSubscriptionsByType(authDto, CloudSubscriptionType.Fixed), + this.getSubscriptionsByType(authDto, CloudSubscriptionType.Flexible), + ])); + } + + /** + * Get single database details + * @param authDto + * @param dto + */ + async getSubscriptionDatabase( + authDto: CloudAuthDto, + dto: GetCloudSubscriptionDatabaseDto, + ): Promise { + const { subscriptionId, databaseId, subscriptionType } = dto; + this.logger.log( + `Getting database in RE cloud subscription. subscription id: ${subscriptionId}, database id: ${databaseId}`, + ); + try { + const { data }: AxiosResponse = await this.api.get( + `${this.getApiBase(dto.subscriptionType)}/subscriptions/${subscriptionId}/databases/${databaseId}`, + { + headers: CloudAutodiscoveryService.getAuthHeaders(authDto), + }, + ); + this.logger.log('Succeed to get databases in RE cloud subscription.'); + return parseCloudDatabaseResponse(data, subscriptionId, subscriptionType); + } catch (error) { + const { response } = error; + if (response?.status === 404) { + this.logger.error( + `Failed to get databases in RE cloud subscription. ${response?.data?.message}.`, + ); + throw new NotFoundException(response?.data?.message); + } + throw this.getApiError( + error, + 'Failed to get databases in RE cloud subscription', + ); + } + } + + /** + * Get list of databases for subscription + * @param authDto + * @param dto + */ + async getSubscriptionDatabases( + authDto: CloudAuthDto, + dto: GetCloudSubscriptionDatabasesDto, + ): Promise { + const { subscriptionId, subscriptionType } = dto; + this.logger.log( + `Getting databases in RE cloud subscription. subscription id: ${subscriptionId}`, + ); + try { + const { data }: AxiosResponse = await this.api.get( + `${this.getApiBase(subscriptionType)}/subscriptions/${subscriptionId}/databases`, + { + headers: CloudAutodiscoveryService.getAuthHeaders(authDto), + }, + ); + this.logger.log('Succeed to get databases in RE cloud subscription.'); + return parseCloudDatabasesInSubscriptionResponse(data, subscriptionType); + } catch (error) { + const { response } = error; + let exception: HttpException; + if (response?.status === 404) { + const message = `Subscription ${subscriptionId} not found`; + this.logger.error( + `Failed to get databases in RE cloud subscription. ${message}.`, + ); + exception = new NotFoundException(message); + } else { + exception = this.getApiError( + error, + 'Failed to get databases in RE cloud subscription', + ); + } + throw exception; + } + } + + /** + * Get get all databases from specified multiple subscriptions + * @param authDto + * @param dto + */ + async getDatabases( + authDto: CloudAuthDto, + dto: GetCloudDatabasesDto, + ): Promise { + const subscriptions = uniqBy( + dto.subscriptions, + ({ subscriptionId, subscriptionType }) => [subscriptionId, subscriptionType].join(), + ); + + this.logger.log('Getting databases in RE cloud subscriptions.'); + let result = []; + try { + await Promise.all( + subscriptions.map(async (subscription) => { + const databases = await this.getSubscriptionDatabases(authDto, subscription); + result = [...result, ...databases]; + }), + ); + this.analytics.sendGetRECloudDbsSucceedEvent(result); + return result; + } catch (exception) { + this.analytics.sendGetRECloudDbsFailedEvent(exception); + throw exception; + } + } + + async addRedisCloudDatabases( + authDto: CloudAuthDto, + addDatabasesDto: AddCloudDatabaseDto[], + ): Promise { + this.logger.log('Adding Redis Cloud databases.'); + + return Promise.all( + addDatabasesDto.map( + async ( + dto: AddCloudDatabaseDto, + ): Promise => { + let database; + try { + database = await this.getSubscriptionDatabase(authDto, dto); + + const { + publicEndpoint, name, password, status, + } = database; + if (status !== CloudDatabaseStatus.Active) { + const exception = new ServiceUnavailableException(ERROR_MESSAGES.DATABASE_IS_INACTIVE); + return { + ...dto, + status: ActionStatus.Fail, + message: exception.message, + error: exception?.getResponse(), + databaseDetails: database, + }; + } + const [host, port] = publicEndpoint.split(':'); + + await this.databaseService.create({ + host, + port: parseInt(port, 10), + name, + nameFromProvider: name, + password, + provider: HostingProvider.RE_CLOUD, + cloudDetails: database?.cloudDetails, + timeout: this.config.cloudDatabaseConnectionTimeout, + }); + + return { + ...dto, + status: ActionStatus.Success, + message: 'Added', + databaseDetails: database, + }; + } catch (error) { + return { + ...dto, + status: ActionStatus.Fail, + message: error.message, + error: error?.response, + databaseDetails: database, + }; + } + }, + ), + ); + } +} diff --git a/redisinsight/api/src/modules/redis-enterprise/controllers/cloud.controller.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts similarity index 50% rename from redisinsight/api/src/modules/redis-enterprise/controllers/cloud.controller.ts rename to redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts index 2975245896..e21300dbb9 100644 --- a/redisinsight/api/src/modules/redis-enterprise/controllers/cloud.controller.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts @@ -1,40 +1,44 @@ import { Body, ClassSerializerInterceptor, - Controller, + Controller, Get, Post, Res, UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; import { TimeoutInterceptor } from 'src/common/interceptors/timeout.interceptor'; -import { ApiTags } from '@nestjs/swagger'; -import { RedisEnterpriseDatabase } from 'src/modules/redis-enterprise/dto/cluster.dto'; -import { - CloudAuthDto, - GetCloudAccountShortInfoResponse, - GetDatabasesInMultipleCloudSubscriptionsDto, - GetRedisCloudSubscriptionResponse, - RedisCloudDatabase, -} from 'src/modules/redis-enterprise/dto/cloud.dto'; +import { ApiHeaders, ApiTags } from '@nestjs/swagger'; +import { CloudAccountInfo, CloudDatabase, CloudSubscription } from 'src/modules/cloud/autodiscovery/models'; import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; -import { RedisCloudService } from 'src/modules/redis-enterprise/redis-cloud.service'; -import { - AddMultipleRedisCloudDatabasesDto, - AddRedisCloudDatabaseResponse, -} from 'src/modules/redis-enterprise/dto/redis-enterprise-cloud.dto'; import { Response } from 'express'; import { ActionStatus } from 'src/common/models'; import { BuildType } from 'src/modules/server/models/server'; +import { CloudAutodiscoveryService } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.service'; +import { + AddCloudDatabaseResponse, + AddCloudDatabasesDto, + CloudAuthDto, + GetCloudDatabasesDto, +} from 'src/modules/cloud/autodiscovery/dto'; +import { CloudAuthHeaders } from 'src/modules/cloud/autodiscovery/decorators/cloud-auth.decorator'; +import config from 'src/utils/config'; + +const cloudConf = config.get('redis_cloud'); -@ApiTags('Redis Enterprise Cloud') +@ApiTags('Cloud Autodiscovery') +@ApiHeaders([{ + name: 'x-cloud-api-key', +}, { + name: 'x-cloud-api-secret', +}]) @UsePipes(new ValidationPipe({ transform: true })) -@Controller('redis-enterprise/cloud') -export class CloudController { - constructor(private redisCloudService: RedisCloudService) {} +@UseInterceptors(new TimeoutInterceptor(undefined, cloudConf.cloudDiscoveryTimeout)) +@Controller('cloud/autodiscovery') +export class CloudAutodiscoveryController { + constructor(private service: CloudAutodiscoveryService) {} - @Post('get-account') - @UseInterceptors(new TimeoutInterceptor()) + @Get('account') @ApiEndpoint({ description: 'Get current account', statusCode: 200, @@ -43,18 +47,15 @@ export class CloudController { { status: 200, description: 'Account Details.', - type: RedisEnterpriseDatabase, + type: CloudAccountInfo, }, ], }) - async getAccount( - @Body() dto: CloudAuthDto, - ): Promise { - return await this.redisCloudService.getAccount(dto); + async getAccount(@CloudAuthHeaders() authDto: CloudAuthDto): Promise { + return await this.service.getAccount(authDto); } - @Post('get-subscriptions') - @UseInterceptors(new TimeoutInterceptor()) + @Get('subscriptions') @ApiEndpoint({ description: 'Get information about current account’s subscriptions.', statusCode: 200, @@ -63,15 +64,13 @@ export class CloudController { { status: 200, description: 'Redis cloud subscription list.', - type: GetRedisCloudSubscriptionResponse, + type: CloudSubscription, isArray: true, }, ], }) - async getSubscriptions( - @Body() dto: CloudAuthDto, - ): Promise { - return await this.redisCloudService.getSubscriptions(dto); + async getSubscriptions(@CloudAuthHeaders() authDto: CloudAuthDto): Promise { + return await this.service.getSubscriptions(authDto); } @Post('get-databases') @@ -84,17 +83,16 @@ export class CloudController { { status: 200, description: 'Databases list.', - type: RedisCloudDatabase, + type: CloudDatabase, isArray: true, }, ], }) async getDatabases( - @Body() dto: GetDatabasesInMultipleCloudSubscriptionsDto, - ): Promise { - return await this.redisCloudService.getDatabasesInMultipleSubscriptions( - dto, - ); + @CloudAuthHeaders() authDto: CloudAuthDto, + @Body() dto: GetCloudDatabasesDto, + ): Promise { + return await this.service.getDatabases(authDto, dto); } @Post('databases') @@ -106,23 +104,20 @@ export class CloudController { { status: 201, description: 'Added databases list.', - type: AddRedisCloudDatabaseResponse, + type: AddCloudDatabaseResponse, isArray: true, }, ], }) - @UsePipes(new ValidationPipe({ transform: true })) async addRedisCloudDatabases( - @Body() dto: AddMultipleRedisCloudDatabasesDto, + @CloudAuthHeaders() authDto: CloudAuthDto, + @Body() dto: AddCloudDatabasesDto, @Res() res: Response, ): Promise { - const { databases, ...connectionDetails } = dto; - const result = await this.redisCloudService.addRedisCloudDatabases( - connectionDetails, - databases, - ); + const { databases } = dto; + const result = await this.service.addRedisCloudDatabases(authDto, databases); const hasSuccessResult = result.some( - (addResponse: AddRedisCloudDatabaseResponse) => addResponse.status === ActionStatus.Success, + (addResponse: AddCloudDatabaseResponse) => addResponse.status === ActionStatus.Success, ); if (!hasSuccessResult) { return res.status(200).json(result); diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.module.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.module.ts new file mode 100644 index 0000000000..44b243ccb7 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { CloudAutodiscoveryController } from 'src/modules/cloud/autodiscovery/cloud.autodiscovery.controller'; +import { CloudAutodiscoveryService } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.service'; +import { CloudAutodiscoveryAnalytics } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics'; + +@Module({ + controllers: [CloudAutodiscoveryController], + providers: [ + CloudAutodiscoveryService, + CloudAutodiscoveryAnalytics, + ], +}) +export class CloudAutodiscoveryModule {} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/decorators/cloud-auth.decorator.ts b/redisinsight/api/src/modules/cloud/autodiscovery/decorators/cloud-auth.decorator.ts new file mode 100644 index 0000000000..6c621f6a42 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/decorators/cloud-auth.decorator.ts @@ -0,0 +1,25 @@ +import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { Validator } from 'class-validator'; +import { plainToClass } from 'class-transformer'; +import { CloudAuthDto } from 'src/modules/cloud/autodiscovery/dto'; + +const validator = new Validator(); + +export const cloudAuthDtoFromRequestHeadersFactory = (data: unknown, ctx: ExecutionContext): CloudAuthDto => { + const request = ctx.switchToHttp().getRequest(); + + const dto = plainToClass(CloudAuthDto, { + apiKey: request.headers['x-cloud-api-key'], + apiSecret: request.headers['x-cloud-api-secret'], + }); + + const errors = validator.validateSync(dto); + + if (errors?.length) { + throw new UnauthorizedException('Required authentication credentials were not provided'); + } + + return dto; +}; + +export const CloudAuthHeaders = createParamDecorator(cloudAuthDtoFromRequestHeadersFactory); diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.dto.ts new file mode 100644 index 0000000000..fe63e3e837 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsDefined, IsEnum, IsInt, IsNotEmpty, +} from 'class-validator'; +import { CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models'; + +export class AddCloudDatabaseDto { + @ApiProperty({ + description: 'Subscription id', + type: Number, + }) + @IsDefined() + @IsNotEmpty() + @IsInt({ always: true }) + subscriptionId: number; + + @IsEnum(CloudSubscriptionType) + @IsNotEmpty() + subscriptionType: CloudSubscriptionType; + + @ApiProperty({ + description: 'Database id', + type: Number, + }) + @IsDefined() + @IsNotEmpty() + @IsInt({ always: true }) + databaseId: number; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.response.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.response.ts new file mode 100644 index 0000000000..7f7357b74b --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.response.ts @@ -0,0 +1,41 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ActionStatus } from 'src/common/models'; +import { CloudDatabase } from 'src/modules/cloud/autodiscovery/models'; + +export class AddCloudDatabaseResponse { + @ApiProperty({ + description: 'Subscription id', + type: Number, + }) + subscriptionId: number; + + @ApiProperty({ + description: 'Database id', + type: Number, + }) + databaseId: number; + + @ApiProperty({ + description: 'Add Redis Cloud database status', + default: ActionStatus.Success, + enum: ActionStatus, + }) + status: ActionStatus; + + @ApiProperty({ + description: 'Message', + type: String, + }) + message: string; + + @ApiPropertyOptional({ + description: 'The database details.', + type: CloudDatabase, + }) + databaseDetails?: CloudDatabase; + + @ApiPropertyOptional({ + description: 'Error', + }) + error?: string | object; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-databases.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-databases.dto.ts new file mode 100644 index 0000000000..b528dc7943 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-databases.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayNotEmpty, IsArray, IsDefined, ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { AddCloudDatabaseDto } from 'src/modules/cloud/autodiscovery/dto/add-cloud-database.dto'; + +export class AddCloudDatabasesDto { + @ApiProperty({ + description: 'Cloud databases list.', + type: AddCloudDatabaseDto, + isArray: true, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @ValidateNested() + @Type(() => AddCloudDatabaseDto) + databases: AddCloudDatabaseDto[]; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/cloud-auth.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/cloud-auth.dto.ts new file mode 100644 index 0000000000..9f8958aece --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/cloud-auth.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsNotEmpty, IsString } from 'class-validator'; + +export class CloudAuthDto { + @ApiProperty({ + description: 'Cloud API account key', + type: String, + }) + @IsDefined() + @IsNotEmpty() + @IsString({ always: true }) + apiKey: string; + + @ApiProperty({ + description: 'Cloud API secret key', + type: String, + }) + @IsDefined() + @IsNotEmpty() + @IsString({ always: true }) + apiSecret: string; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts new file mode 100644 index 0000000000..8b3cc75116 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayNotEmpty, IsArray, IsNotEmpty, ValidateNested +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + GetCloudSubscriptionDatabasesDto, +} from 'src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto'; + +export class GetCloudDatabasesDto { + @ApiProperty({ + description: 'Subscriptions where to discover databases', + type: GetCloudSubscriptionDatabasesDto, + isArray: true, + }) + @IsArray() + @ArrayNotEmpty() + @ValidateNested() + @Type(() => GetCloudSubscriptionDatabasesDto) + subscriptions: GetCloudSubscriptionDatabasesDto[]; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts new file mode 100644 index 0000000000..cd76fdfd7f --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsDefined, IsEnum, IsInt, IsNotEmpty, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models'; + +export class GetCloudSubscriptionDatabaseDto { + @ApiProperty({ + description: 'Subscription Id', + type: Number, + }) + @IsDefined() + @IsNotEmpty() + @IsInt({ always: true }) + @Type(() => Number) + subscriptionId: number; + + @IsEnum(CloudSubscriptionType) + @IsNotEmpty() + subscriptionType: CloudSubscriptionType; + + @ApiProperty({ + description: 'Database Id', + type: Number, + }) + @IsDefined() + @IsNotEmpty() + @IsInt({ always: true }) + @Type(() => Number) + databaseId: number; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts new file mode 100644 index 0000000000..0b08d885c5 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsDefined, IsEnum, IsInt, IsNotEmpty, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models'; + +export class GetCloudSubscriptionDatabasesDto { + @ApiProperty({ + description: 'Subscription Id', + type: Number, + }) + @IsDefined() + @IsNotEmpty() + @IsInt({ always: true }) + @Type(() => Number) + subscriptionId: number; + + @ApiProperty({ + description: 'Subscription Id', + enum: CloudSubscriptionType, + }) + @IsEnum(CloudSubscriptionType) + @IsNotEmpty() + subscriptionType: CloudSubscriptionType; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/index.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/index.ts new file mode 100644 index 0000000000..143033e141 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/index.ts @@ -0,0 +1,7 @@ +export * from './add-cloud-database.dto'; +export * from './add-cloud-database.response'; +export * from './add-cloud-databases.dto'; +export * from './cloud-auth.dto'; +export * from './get-cloud-subscription-database.dto'; +export * from './get-cloud-subscription-databases.dto'; +export * from './get-cloud-databases.dto'; diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/entities/cloud-database-details.entity.ts b/redisinsight/api/src/modules/cloud/autodiscovery/entities/cloud-database-details.entity.ts new file mode 100644 index 0000000000..a5cf501643 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/entities/cloud-database-details.entity.ts @@ -0,0 +1,39 @@ +import { + Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn, +} from 'typeorm'; +import { Expose } from 'class-transformer'; +import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; + +@Entity('database_cloud_details') +export class CloudDatabaseDetailsEntity { + @Expose() + @PrimaryGeneratedColumn('uuid') + id: string; + + @Expose() + @Column({ nullable: false }) + cloudId: number; + + @Expose() + @Column({ nullable: false }) + subscriptionType: string; + + @Expose() + @Column({ nullable: true }) + planMemoryLimit: number; + + @Expose() + @Column({ nullable: true }) + memoryLimitMeasurementUnit: number; + + @OneToOne( + () => DatabaseEntity, + (database) => database.cloudDetails, + { + nullable: true, + onDelete: 'CASCADE', + }, + ) + @JoinColumn() + database: DatabaseEntity; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-account-info.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-account-info.ts new file mode 100644 index 0000000000..37f01f842b --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-account-info.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CloudAccountInfo { + @ApiProperty({ + description: 'Account id', + type: Number, + }) + accountId: number; + + @ApiProperty({ + description: 'Account name', + type: String, + }) + accountName: string; + + @ApiProperty({ + description: 'Account owner name', + type: String, + }) + ownerName: string; + + @ApiProperty({ + description: 'Account owner email', + type: String, + }) + ownerEmail: string; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts new file mode 100644 index 0000000000..9bd3b51944 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts @@ -0,0 +1,141 @@ +import { CloudDatabaseProtocol, CloudDatabaseStatus } from 'src/modules/cloud/autodiscovery/models/cloud-database'; +import { CloudSubscriptionStatus } from 'src/modules/cloud/autodiscovery/models/cloud-subscription'; + +// common interfaces +interface ICloudApiAlert { + name: string; + value: number; +} + +// Database interfaces +interface ICloudApiDatabaseClustering { + numberOfShards: number; + regexRules: any[]; + hashingPolicy: string; +} + +export interface ICloudApiDatabaseModule { + id: number; + name: string; + version: string; + description?: string; + parameters?: any[]; +} + +interface ICloudApiDatabaseSecurity { + password?: string; + sslClientAuthentication: boolean; + sourceIps: string[]; +} + +export interface ICloudApiDatabase { + databaseId: number; + name: string; + protocol: CloudDatabaseProtocol; + provider: string; + region: string; + redisVersionCompliance: string; + status: CloudDatabaseStatus; + memoryLimitInGb: number; + memoryUsedInMb: number; + memoryStorage: string; + supportOSSClusterApi: boolean; + dataPersistence: string; + replication: boolean; + periodicBackupPath?: string; + dataEvictionPolicy: string; + throughputMeasurement: { + by: string; + value: number; + }; + activatedOn: string; + lastModified: string; + publicEndpoint: string; + privateEndpoint: string; + replicaOf: { + endpoints: string[]; + }; + clustering: ICloudApiDatabaseClustering; + security: ICloudApiDatabaseSecurity; + modules: ICloudApiDatabaseModule[]; + alerts: ICloudApiAlert[]; + planMemoryLimit?: number; + memoryLimitMeasurementUnit?: string; +} + +export interface ICloudApiSubscriptionDatabasesSubscription { + subscriptionId: number; + numberOfDatabases: number; + databases: ICloudApiDatabase[]; +} + +export interface ICloudApiSubscriptionDatabases { + accountId: number; + subscription: ICloudApiSubscriptionDatabasesSubscription | ICloudApiSubscriptionDatabasesSubscription[]; +} + +// Account interfaces +export interface ICloudApiAccountOwner { + name: string; + email: string; +} + +interface ICloudApiAccountKey { + name: string; + accountId: number; + accountName: string; + allowedSourceIps: string[]; + createdTimestamp: string; + owner: ICloudApiAccountOwner; + httpSourceIp: string; +} + +export interface ICloudApiAccount { + id: number; + name: string; + createdTimestamp: string; + updatedTimestamp: string; + key: ICloudApiAccountKey; +} + +// Subscription interfaces +interface ICloudApiSubscriptionPricing { + type: string; + typeDetails?: string; + quantity: number; + quantityMeasurement: string; + pricePerUnit?: number; + priceCurrency?: string; + pricePeriod?: string; +} + +interface ICloudApiSubscriptionRegion { + region: string; + networking: any[]; + preferredAvailabilityZones: string[]; + multipleAvailabilityZones: boolean; +} + +interface ICloudApiSubscriptionDetails { + provider: string; + cloudAccountId: number; + totalSizeInGb: number; + regions: ICloudApiSubscriptionRegion[]; +} + +export interface ICloudApiSubscription { + id: number; + name: string; + status: CloudSubscriptionStatus; + paymentMethodId: number; + memoryStorage: string; + storageEncryption: boolean; + numberOfDatabases: number; + subscriptionPricing: ICloudApiSubscriptionPricing[]; + cloudDetails: ICloudApiSubscriptionDetails[]; +} + +export interface ICloudApiSubscriptions { + accountId: number; + subscriptions: ICloudApiSubscription[]; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database-details.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database-details.ts new file mode 100644 index 0000000000..1195c13fa1 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database-details.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsNotEmpty, IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models/cloud-subscription'; + +export class CloudDatabaseDetails { + @ApiProperty({ + description: 'Database id from the cloud', + type: Number, + }) + @Expose() + @IsNotEmpty() + @IsInt({ always: true }) + cloudId: number; + + @ApiProperty({ + description: 'Subscription type', + enum: () => CloudSubscriptionType, + example: CloudSubscriptionType.Flexible, + }) + @Expose() + @IsNotEmpty() + @IsEnum(CloudSubscriptionType) + subscriptionType: CloudSubscriptionType; + + @ApiPropertyOptional({ + description: 'Plan memory limit', + type: Number, + example: 256, + }) + @Expose() + @IsOptional() + @IsNumber() + planMemoryLimit?: number; + + @ApiPropertyOptional({ + description: 'Memory limit units', + type: String, + example: 'MB', + }) + @Expose() + @IsOptional() + @IsString() + memoryLimitMeasurementUnit?: string; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts new file mode 100644 index 0000000000..de6c727879 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts @@ -0,0 +1,108 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models/cloud-subscription'; +import { CloudDatabaseDetails } from 'src/modules/cloud/autodiscovery/models/cloud-database-details'; + +export enum CloudDatabaseProtocol { + Redis = 'redis', + Stack = 'stack', + Memcached = 'memcached', +} + +export enum CloudDatabasePersistencePolicy { + AofEveryOneSecond = 'aof-every-1-second', + AofEveryWrite = 'aof-every-write', + SnapshotEveryOneHour = 'snapshot-every-1-hour', + SnapshotEverySixHours = 'snapshot-every-6-hours', + SnapshotEveryTwelveHours = 'snapshot-every-12-hours', + None = 'none', +} + +export enum CloudDatabaseMemoryStorage { + Ram = 'ram', + RamAndFlash = 'ram-and-flash', +} + +export enum CloudDatabaseStatus { + Pending = 'pending', + CreationFailed = 'creation-failed', + Active = 'active', + ActiveChangePending = 'active-change-pending', + ImportPending = 'import-pending', + DeletePending = 'delete-pending', + Recovery = 'recovery', +} + +export class CloudDatabase { + @ApiProperty({ + description: 'Subscription id', + type: Number, + }) + @Expose() + subscriptionId: number; + + @ApiProperty({ + description: 'Subscription type', + enum: CloudSubscriptionType, + }) + @Expose() + subscriptionType: CloudSubscriptionType; + + @ApiProperty({ + description: 'Database id', + type: Number, + }) + @Expose() + databaseId: number; + + @ApiProperty({ + description: 'Database name', + type: String, + }) + @Expose() + name: string; + + @ApiProperty({ + description: 'Address your Redis Cloud database is available on', + type: String, + }) + @Expose() + publicEndpoint: string; + + @ApiProperty({ + description: 'Database status', + enum: CloudDatabaseStatus, + default: CloudDatabaseStatus.Active, + }) + @Expose() + status: CloudDatabaseStatus; + + @ApiProperty({ + description: 'Is ssl authentication enabled or not', + type: Boolean, + }) + @Expose() + sslClientAuthentication: boolean; + + @ApiProperty({ + description: 'Information about the modules loaded to the database', + type: String, + isArray: true, + }) + @Expose() + modules: string[]; + + @ApiProperty({ + description: 'Additional database options', + type: Object, + }) + @Expose() + options: any; + + @Expose({ groups: ['security'] }) + password?: string; + + @Expose() + @Type(() => CloudDatabaseDetails) + cloudDetails?: CloudDatabaseDetails; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-subscription.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-subscription.ts new file mode 100644 index 0000000000..8ec4e0c062 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-subscription.ts @@ -0,0 +1,59 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum CloudSubscriptionStatus { + Active = 'active', + NotActivated = 'not_activated', + Deleting = 'deleting', + Pending = 'pending', + Error = 'error', +} + +export enum CloudSubscriptionType { + Flexible = 'flexible', + Fixed = 'fixed', +} + +export class CloudSubscription { + @ApiProperty({ + description: 'Subscription id', + type: Number, + }) + id: number; + + @ApiProperty({ + description: 'Subscription name', + type: String, + }) + name: string; + + @ApiProperty({ + description: 'Subscription type', + enum: CloudSubscriptionType, + }) + type: CloudSubscriptionType; + + @ApiProperty({ + description: 'Number of databases in subscription', + type: Number, + }) + numberOfDatabases: number; + + @ApiProperty({ + description: 'Subscription status', + enum: CloudSubscriptionStatus, + default: CloudSubscriptionStatus.Active, + }) + status: CloudSubscriptionStatus; + + @ApiPropertyOptional({ + description: 'Subscription provider', + type: String, + }) + provider?: string; + + @ApiPropertyOptional({ + description: 'Subscription region', + type: String, + }) + region?: string; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/index.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/index.ts new file mode 100644 index 0000000000..484cf1a428 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/index.ts @@ -0,0 +1,5 @@ +export * from './cloud-account-info'; +export * from './cloud-api.interface'; +export * from './cloud-database'; +export * from './cloud-database-details'; +export * from './cloud-subscription'; diff --git a/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.spec.ts b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.spec.ts similarity index 82% rename from redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.spec.ts rename to redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.spec.ts index a2a6f7d74d..cbf813848e 100644 --- a/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.spec.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.spec.ts @@ -1,5 +1,5 @@ import { AdditionalRedisModuleName } from 'src/constants'; -import { convertRECloudModuleName } from 'src/modules/redis-enterprise/utils/redis-cloud-converter'; +import { convertRECloudModuleName } from 'src/modules/cloud/autodiscovery/utils/redis-cloud-converter'; describe('convertRedisCloudModuleName', () => { it('should return exist module name', () => { diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts new file mode 100644 index 0000000000..4a413bbc50 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts @@ -0,0 +1,139 @@ +import { RE_CLOUD_MODULES_NAMES } from 'src/constants'; +import { get, find, isArray } from 'lodash'; +import { + CloudAccountInfo, + CloudDatabase, CloudDatabaseMemoryStorage, + CloudDatabasePersistencePolicy, CloudDatabaseProtocol, + CloudSubscription, CloudSubscriptionType, ICloudApiDatabase, +} from 'src/modules/cloud/autodiscovery/models'; +import { plainToClass } from 'class-transformer'; + +export function convertRECloudModuleName(name: string): string { + return RE_CLOUD_MODULES_NAMES[name] ?? name; +} + +export const parseCloudAccountResponse = (account: any): CloudAccountInfo => plainToClass(CloudAccountInfo, { + accountId: account.id, + accountName: account.name, + ownerName: get(account, ['key', 'owner', 'name']), + ownerEmail: get(account, ['key', 'owner', 'email']), +}); + +export const parseCloudSubscriptionsResponse = ( + subscriptions: any[], + type: CloudSubscriptionType, +): CloudSubscription[] => { + const result: CloudSubscription[] = []; + if (subscriptions?.length) { + subscriptions.forEach((subscription): void => { + result.push(plainToClass(CloudSubscription, { + id: subscription.id, + type, + name: subscription.name, + numberOfDatabases: subscription.numberOfDatabases, + status: subscription.status, + provider: get(subscription, ['cloudDetails', 0, 'provider'], get(subscription, 'provider')), + region: get(subscription, [ + 'cloudDetails', + 0, + 'regions', + 0, + 'region', + ], get(subscription, 'region')), + })); + }); + } + return result; +}; + +export const parseCloudDatabaseResponse = ( + database: ICloudApiDatabase, + subscriptionId: number, + subscriptionType: CloudSubscriptionType, +): CloudDatabase => { + const { + databaseId, name, publicEndpoint, status, security, planMemoryLimit, memoryLimitMeasurementUnit, + } = database; + + return plainToClass(CloudDatabase, { + subscriptionId, + subscriptionType, + databaseId, + name, + publicEndpoint, + status, + password: security?.password, + sslClientAuthentication: security.sslClientAuthentication, + modules: database.modules + .map((module) => convertRECloudModuleName(module.name)), + options: { + enabledDataPersistence: + database.dataPersistence !== CloudDatabasePersistencePolicy.None, + persistencePolicy: database.dataPersistence, + enabledRedisFlash: + database.memoryStorage === CloudDatabaseMemoryStorage.RamAndFlash, + enabledReplication: database.replication, + enabledBackup: !!database.periodicBackupPath, + enabledClustering: database.clustering.numberOfShards > 1, + isReplicaDestination: !!database.replicaOf, + }, + cloudDetails: { + cloudId: databaseId, + subscriptionType, + planMemoryLimit, + memoryLimitMeasurementUnit, + }, + }, { groups: ['security'] }); +}; + +export const findReplicasForDatabase = (databases: any[], sourceDatabaseId: number): any[] => { + const sourceDatabase = find(databases, { + databaseId: sourceDatabaseId, + }); + if (!sourceDatabase) { + return []; + } + return databases.filter((replica): boolean => { + const endpoints = get(replica, ['replicaOf', 'endpoints']); + if ( + replica.databaseId === sourceDatabaseId + || !endpoints + || !endpoints.length + ) { + return false; + } + return endpoints.some((endpoint: string): boolean => ( + endpoint.includes(sourceDatabase.publicEndpoint) + || endpoint.includes(sourceDatabase.privateEndpoint) + )); + }); +}; + +export const parseCloudDatabasesInSubscriptionResponse = ( + response: any, + subscriptionType: CloudSubscriptionType, +): CloudDatabase[] => { + const subscription = isArray(response.subscription) ? response.subscription[0] : response.subscription; + + const { subscriptionId, databases } = subscription; + + let result: CloudDatabase[] = []; + databases.forEach((database): void => { + // We do not send the databases which have 'memcached' as their protocol. + if ([CloudDatabaseProtocol.Redis, CloudDatabaseProtocol.Stack].includes(database.protocol)) { + result.push(parseCloudDatabaseResponse(database, subscriptionId, subscriptionType)); + } + }); + result = result.map((database) => ({ + ...database, + subscriptionType, + options: { + ...database.options, + isReplicaSource: !!findReplicasForDatabase( + databases, + database.databaseId, + ).length, + }, + })); + return result; +}; diff --git a/redisinsight/api/src/modules/cloud/cloud.module.ts b/redisinsight/api/src/modules/cloud/cloud.module.ts new file mode 100644 index 0000000000..6fabefd287 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/cloud.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CloudAutodiscoveryModule } from 'src/modules/cloud/autodiscovery/cloud.autodiscovery.module'; + +@Module({}) +export class CloudModule { + static register() { + return { + module: CloudModule, + imports: [CloudAutodiscoveryModule], + }; + } +} diff --git a/redisinsight/api/src/modules/database-recommendation/database-recommendation.analytics.spec.ts b/redisinsight/api/src/modules/database-recommendation/database-recommendation.analytics.spec.ts new file mode 100644 index 0000000000..48afe0d8d7 --- /dev/null +++ b/redisinsight/api/src/modules/database-recommendation/database-recommendation.analytics.spec.ts @@ -0,0 +1,48 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + mockDatabase, + mockDatabaseRecommendation, + mockDatabaseWithTlsAuth, +} from 'src/__mocks__'; +import { TelemetryEvents } from 'src/constants'; +import { DatabaseRecommendationAnalytics } from './database-recommendation.analytics'; + +const provider = 'cloud'; + +describe('DatabaseRecommendationAnalytics', () => { + let service: DatabaseRecommendationAnalytics; + let sendEventSpy; + let sendFailedEventSpy; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventEmitter2, + DatabaseRecommendationAnalytics, + ], + }).compile(); + + service = await module.get(DatabaseRecommendationAnalytics); + sendEventSpy = jest.spyOn(service as any, 'sendEvent'); + sendFailedEventSpy = jest.spyOn(service as any, 'sendFailedEvent'); + }); + + describe('sendInstanceAddedEvent', () => { + it('should emit event with recommendationName and provider', () => { + service.sendCreatedRecommendationEvent( + mockDatabaseRecommendation, + mockDatabaseWithTlsAuth, + ); + + expect(sendEventSpy).toHaveBeenCalledWith( + TelemetryEvents.InsightsRecommendationGenerated, + { + recommendationName: mockDatabaseRecommendation.name, + databaseId: mockDatabase.id, + provider: mockDatabaseWithTlsAuth.provider, + }, + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/database-recommendation/database-recommendation.analytics.ts b/redisinsight/api/src/modules/database-recommendation/database-recommendation.analytics.ts new file mode 100644 index 0000000000..961ca2ec88 --- /dev/null +++ b/redisinsight/api/src/modules/database-recommendation/database-recommendation.analytics.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryBaseService } from 'src/modules/analytics/telemetry.base.service'; +import { TelemetryEvents } from 'src/constants'; +import { DatabaseRecommendation } from './models'; +import { Database } from '../database/models/database'; + +@Injectable() +export class DatabaseRecommendationAnalytics extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } + + sendCreatedRecommendationEvent(recommendation: DatabaseRecommendation, database: Database): void { + try { + this.sendEvent( + TelemetryEvents.InsightsRecommendationGenerated, + { + recommendationName: recommendation.name, + databaseId: database.id, + provider: database.provider, + }, + ); + } catch (e) { + // ignore + } + } +} diff --git a/redisinsight/api/src/modules/database-recommendation/database-recommendation.module.ts b/redisinsight/api/src/modules/database-recommendation/database-recommendation.module.ts index 5894183d20..b8a26bb254 100644 --- a/redisinsight/api/src/modules/database-recommendation/database-recommendation.module.ts +++ b/redisinsight/api/src/modules/database-recommendation/database-recommendation.module.ts @@ -10,6 +10,7 @@ import { DatabaseRecommendationGateway } from 'src/modules/database-recommendati import { DatabaseRecommendationEmitter, } from 'src/modules/database-recommendation/providers/database-recommendation.emitter'; +import { DatabaseRecommendationAnalytics } from 'src/modules/database-recommendation/database-recommendation.analytics'; @Module({}) export class DatabaseRecommendationModule { @@ -25,6 +26,7 @@ export class DatabaseRecommendationModule { RecommendationProvider, DatabaseRecommendationGateway, DatabaseRecommendationEmitter, + DatabaseRecommendationAnalytics, { provide: DatabaseRecommendationRepository, useClass: databaseRecommendationRepository, diff --git a/redisinsight/api/src/modules/database-recommendation/database-recommendation.service.ts b/redisinsight/api/src/modules/database-recommendation/database-recommendation.service.ts index 6358062fed..7693b91203 100644 --- a/redisinsight/api/src/modules/database-recommendation/database-recommendation.service.ts +++ b/redisinsight/api/src/modules/database-recommendation/database-recommendation.service.ts @@ -11,6 +11,7 @@ import { } from 'src/modules/database-recommendation/dto/database-recommendations.response'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { ModifyDatabaseRecommendationDto, DeleteDatabaseRecommendationResponse } from './dto'; +import { DatabaseRecommendationAnalytics } from './database-recommendation.analytics'; import { DatabaseService } from '../database/database.service'; @Injectable() @@ -21,19 +22,22 @@ export class DatabaseRecommendationService { private readonly databaseRecommendationRepository: DatabaseRecommendationRepository, private readonly scanner: RecommendationScanner, private readonly databaseService: DatabaseService, + private readonly analytics: DatabaseRecommendationAnalytics, ) {} /** * Create recommendation entity * @param clientMetadata - * @param recommendationName + * @param entity */ - public async create(clientMetadata: ClientMetadata, recommendationName: string): Promise { - const entity = plainToClass( - DatabaseRecommendation, - { databaseId: clientMetadata?.databaseId, name: recommendationName }, - ); - return this.databaseRecommendationRepository.create(entity); + public async create(clientMetadata: ClientMetadata, entity: DatabaseRecommendation): Promise { + const recommendation = await this.databaseRecommendationRepository.create(entity); + + const database = await this.databaseService.get(clientMetadata?.databaseId); + + this.analytics.sendCreatedRecommendationEvent(recommendation, database); + + return recommendation; } /** @@ -74,7 +78,7 @@ export class DatabaseRecommendationService { { databaseId: newClientMetadata?.databaseId, ...recommendation }, ); - return await this.databaseRecommendationRepository.create(entity); + return await this.create(newClientMetadata, entity); } } diff --git a/redisinsight/api/src/modules/database-recommendation/entities/database-recommendation.entity.ts b/redisinsight/api/src/modules/database-recommendation/entities/database-recommendation.entity.ts index b6a29dc5b2..2fdc5514fb 100644 --- a/redisinsight/api/src/modules/database-recommendation/entities/database-recommendation.entity.ts +++ b/redisinsight/api/src/modules/database-recommendation/entities/database-recommendation.entity.ts @@ -1,11 +1,12 @@ import { - Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, JoinColumn, Index, + Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, JoinColumn, Index, Unique, } from 'typeorm'; import { Expose } from 'class-transformer'; import { DataAsJsonString } from 'src/common/decorators'; import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; @Entity('database_recommendations') +@Unique(['databaseId', 'name']) export class DatabaseRecommendationEntity { @PrimaryGeneratedColumn('uuid') @Expose() diff --git a/redisinsight/api/src/modules/database-recommendation/models/database-recommendation.ts b/redisinsight/api/src/modules/database-recommendation/models/database-recommendation.ts index f9ac3907c9..574291a86c 100644 --- a/redisinsight/api/src/modules/database-recommendation/models/database-recommendation.ts +++ b/redisinsight/api/src/modules/database-recommendation/models/database-recommendation.ts @@ -50,6 +50,9 @@ export class DatabaseRecommendation { type: Boolean, example: false, }) + @Expose() + @IsOptional() + @IsBoolean() disabled?: boolean; @ApiPropertyOptional({ diff --git a/redisinsight/api/src/modules/database-recommendation/repositories/local.database.recommendation.repository.spec.ts b/redisinsight/api/src/modules/database-recommendation/repositories/local.database.recommendation.repository.spec.ts new file mode 100644 index 0000000000..ffecc21059 --- /dev/null +++ b/redisinsight/api/src/modules/database-recommendation/repositories/local.database.recommendation.repository.spec.ts @@ -0,0 +1,116 @@ +import { when } from 'jest-when'; +import { InternalServerErrorException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + mockEncryptionService, + mockRepository, + mockDatabaseRecommendationEntity, + mockRecommendationName, + mockClientMetadata, + mockDatabaseRecommendation, + MockType, +} from 'src/__mocks__'; +import { EncryptionService } from 'src/modules/encryption/encryption.service'; +import { LocalDatabaseRecommendationRepository } + from 'src/modules/database-recommendation/repositories/local.database.recommendation.repository'; +import { DatabaseRecommendationEntity } + from 'src/modules/database-recommendation/entities/database-recommendation.entity'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +describe('LocalDatabaseRecommendationRepository', () => { + let service: LocalDatabaseRecommendationRepository; + let encryptionService: MockType; + let repository: MockType>; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LocalDatabaseRecommendationRepository, + { + provide: getRepositoryToken(DatabaseRecommendationEntity), + useFactory: mockRepository, + }, + { + provide: EncryptionService, + useFactory: mockEncryptionService, + }, + EventEmitter2, + ], + }).compile(); + + repository = await module.get(getRepositoryToken(DatabaseRecommendationEntity)); + encryptionService = await module.get(EncryptionService); + service = module.get(LocalDatabaseRecommendationRepository); + + repository.findOneBy.mockResolvedValue(mockDatabaseRecommendationEntity); + repository.save.mockResolvedValue(mockDatabaseRecommendationEntity); + repository.update.mockResolvedValue(mockDatabaseRecommendationEntity); + + when(encryptionService.encrypt) + .calledWith(JSON.stringify(mockDatabaseRecommendation.params)) + .mockReturnValue({ + encryption: mockDatabaseRecommendationEntity.encryption, + data: mockDatabaseRecommendationEntity.params, + }); + when(encryptionService.decrypt) + .calledWith(mockDatabaseRecommendationEntity.params, jasmine.anything()) + .mockReturnValue(JSON.stringify(mockDatabaseRecommendation.params)); + }); + + describe('isExist', () => { + it('should return true when receive database entity', async () => { + expect(await service.isExist(mockClientMetadata, mockRecommendationName)).toEqual(true); + }); + + it('should return false when no database received', async () => { + repository.findOneBy.mockResolvedValueOnce(null); + expect(await service.isExist(mockClientMetadata, mockRecommendationName)).toEqual(false); + }); + + it('should return false when received error', async () => { + repository.findOneBy.mockRejectedValueOnce(new Error()); + expect(await service.isExist(mockClientMetadata, mockRecommendationName)).toEqual(false); + }); + }); + + describe('create', () => { + it('should create recommendation', async () => { + const result = await service.create(mockDatabaseRecommendation); + + expect(result).toEqual(mockDatabaseRecommendation); + }); + + it('should not create recommendation', async () => { + repository.save.mockRejectedValueOnce(new Error()); + + const result = await service.create(mockDatabaseRecommendation); + + expect(result).toEqual(null); + }); + }); + + describe('delete', () => { + it('should delete recommendation by id', async () => { + repository.delete.mockResolvedValueOnce({ affected: 1 }); + + expect(await service.delete(mockClientMetadata, 'id')).toEqual(undefined); + }); + + it('should return InternalServerErrorException when recommendation does not found', async () => { + repository.delete.mockResolvedValueOnce({ affected: 0 }); + + try { + await service.delete(mockClientMetadata, 'id'); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual(ERROR_MESSAGES.DATABASE_RECOMMENDATION_NOT_FOUND); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/database-recommendation/repositories/local.database.recommendation.repository.ts b/redisinsight/api/src/modules/database-recommendation/repositories/local.database.recommendation.repository.ts index d69f6ff683..5bc4eceb48 100644 --- a/redisinsight/api/src/modules/database-recommendation/repositories/local.database.recommendation.repository.ts +++ b/redisinsight/api/src/modules/database-recommendation/repositories/local.database.recommendation.repository.ts @@ -46,17 +46,23 @@ export class LocalDatabaseRecommendationRepository extends DatabaseRecommendatio async create(entity: DatabaseRecommendation): Promise { this.logger.log('Creating database recommendation'); - const model = await this.repository.save( - await this.modelEncryptor.encryptEntity(plainToClass(DatabaseRecommendationEntity, entity)), - ); + try { + const model = await this.repository.save( + await this.modelEncryptor.encryptEntity(plainToClass(DatabaseRecommendationEntity, entity)), + ); - const recommendation = classToClass( - DatabaseRecommendation, - await this.modelEncryptor.decryptEntity(model, true), - ); - this.eventEmitter.emit(RecommendationEvents.NewRecommendation, [recommendation]); + const recommendation = classToClass( + DatabaseRecommendation, + await this.modelEncryptor.decryptEntity(model, true), + ); + this.eventEmitter.emit(RecommendationEvents.NewRecommendation, [recommendation]); - return recommendation; + return recommendation; + } catch (err) { + this.logger.error('Failed to create database recommendation', err); + + return null; + } } /** diff --git a/redisinsight/api/src/modules/database/database-connection.service.spec.ts b/redisinsight/api/src/modules/database/database-connection.service.spec.ts index 8d0dadd544..5a8ca67bf5 100644 --- a/redisinsight/api/src/modules/database/database-connection.service.spec.ts +++ b/redisinsight/api/src/modules/database/database-connection.service.spec.ts @@ -31,6 +31,7 @@ describe('DatabaseConnectionService', () => { let redisConnectionFactory: MockType; let analytics: MockType; let recommendationService: MockType; + let databaseInfoProvider: MockType; beforeEach(async () => { jest.clearAllMocks(); @@ -74,6 +75,7 @@ describe('DatabaseConnectionService', () => { redisConnectionFactory = await module.get(RedisConnectionFactory); analytics = await module.get(DatabaseAnalytics); recommendationService = module.get(DatabaseRecommendationService); + databaseInfoProvider = module.get(DatabaseInfoProvider); }); describe('connect', () => { @@ -103,6 +105,13 @@ describe('DatabaseConnectionService', () => { mockRedisGeneralInfo, ); }); + + it('should call databaseInfoProvider', async () => { + expect(await service.connect(mockCommonClientMetadata)).toEqual(undefined); + + expect(databaseInfoProvider.determineDatabaseServer).toHaveBeenCalled(); + expect(databaseInfoProvider.determineDatabaseModules).toHaveBeenCalled(); + }); }); describe('getOrCreateClient', () => { diff --git a/redisinsight/api/src/modules/database/database-connection.service.ts b/redisinsight/api/src/modules/database/database-connection.service.ts index 200720a061..ba946f5088 100644 --- a/redisinsight/api/src/modules/database/database-connection.service.ts +++ b/redisinsight/api/src/modules/database/database-connection.service.ts @@ -43,6 +43,7 @@ export class DatabaseConnectionService { lastConnection: new Date(), timeout: client.options.connectTimeout, modules: await this.databaseInfoProvider.determineDatabaseModules(client), + version: await this.databaseInfoProvider.determineDatabaseServer(client), }; // !Temporary. Refresh cluster nodes on connection @@ -60,7 +61,7 @@ export class DatabaseConnectionService { await this.repository.update(clientMetadata.databaseId, toUpdate); - const generalInfo = await this.databaseInfoProvider.getRedisGeneralInfo(client) + const generalInfo = await this.databaseInfoProvider.getRedisGeneralInfo(client); this.recommendationService.check( clientMetadata, diff --git a/redisinsight/api/src/modules/database/database.service.spec.ts b/redisinsight/api/src/modules/database/database.service.spec.ts index ef997f6cfd..ae287505ca 100644 --- a/redisinsight/api/src/modules/database/database.service.spec.ts +++ b/redisinsight/api/src/modules/database/database.service.spec.ts @@ -5,8 +5,20 @@ import { omit, get, update } from 'lodash'; import { classToClass } from 'src/utils'; import { - mockDatabase, mockDatabaseAnalytics, mockDatabaseFactory, mockDatabaseInfoProvider, mockDatabaseRepository, - mockRedisService, MockType, mockRedisGeneralInfo, mockRedisConnectionFactory, mockDatabaseWithTls, mockDatabaseWithTlsAuth, mockDatabaseWithSshPrivateKey, mockSentinelDatabaseWithTlsAuth, + mockDatabase, + mockDatabaseAnalytics, + mockDatabaseFactory, + mockDatabaseInfoProvider, + mockDatabaseRepository, + mockRedisService, + MockType, + mockRedisGeneralInfo, + mockRedisConnectionFactory, + mockDatabaseWithTls, + mockDatabaseWithTlsAuth, + mockDatabaseWithSshPrivateKey, + mockSentinelDatabaseWithTlsAuth, + mockDatabaseWithCloudDetails, } from 'src/__mocks__'; import { DatabaseAnalytics } from 'src/modules/database/database.analytics'; import { DatabaseService } from 'src/modules/database/database.service'; @@ -113,6 +125,12 @@ describe('DatabaseService', () => { expect(analytics.sendInstanceAddedEvent).toHaveBeenCalledWith(mockDatabase, mockRedisGeneralInfo); expect(analytics.sendInstanceAddFailedEvent).not.toHaveBeenCalled(); }); + it('should create new database with cloud details and send analytics event', async () => { + databaseRepository.create.mockResolvedValueOnce(mockDatabaseWithCloudDetails); + expect(await service.create(mockDatabaseWithCloudDetails)).toEqual(mockDatabaseWithCloudDetails); + expect(analytics.sendInstanceAddedEvent).toHaveBeenCalledWith(mockDatabaseWithCloudDetails, mockRedisGeneralInfo); + expect(analytics.sendInstanceAddFailedEvent).not.toHaveBeenCalled(); + }); it('should not fail when collecting data for analytics event', async () => { redisConnectionFactory.createRedisConnection.mockRejectedValueOnce(new Error()); expect(await service.create(mockDatabase)).toEqual(mockDatabase); diff --git a/redisinsight/api/src/modules/database/dto/create.database.dto.ts b/redisinsight/api/src/modules/database/dto/create.database.dto.ts index f90e7933b2..0de6caf14e 100644 --- a/redisinsight/api/src/modules/database/dto/create.database.dto.ts +++ b/redisinsight/api/src/modules/database/dto/create.database.dto.ts @@ -15,6 +15,7 @@ import { clientCertTransformer } from 'src/modules/certificate/transformers/clie import { CreateBasicSshOptionsDto } from 'src/modules/ssh/dto/create.basic-ssh-options.dto'; import { CreateCertSshOptionsDto } from 'src/modules/ssh/dto/create.cert-ssh-options.dto'; import { sshOptionsTransformer } from 'src/modules/ssh/transformers/ssh-options.transformer'; +import { CloudDatabaseDetails } from 'src/modules/cloud/autodiscovery/models/cloud-database-details'; @ApiExtraModels( CreateCaCertificateDto, UseCaCertificateDto, @@ -23,7 +24,7 @@ import { sshOptionsTransformer } from 'src/modules/ssh/transformers/ssh-options. ) export class CreateDatabaseDto extends PickType(Database, [ 'host', 'port', 'name', 'db', 'username', 'password', 'timeout', 'nameFromProvider', 'provider', - 'tls', 'tlsServername', 'verifyServerCert', 'sentinelMaster', 'ssh', 'compressor', + 'tls', 'tlsServername', 'verifyServerCert', 'sentinelMaster', 'ssh', 'compressor', 'cloudDetails', ] as const) { @ApiPropertyOptional({ description: 'CA Certificate', @@ -66,4 +67,15 @@ export class CreateDatabaseDto extends PickType(Database, [ @Type(sshOptionsTransformer) @ValidateNested() sshOptions?: CreateBasicSshOptionsDto | CreateCertSshOptionsDto; + + @ApiPropertyOptional({ + description: 'Cloud details', + type: CloudDatabaseDetails, + }) + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(() => CloudDatabaseDetails) + @ValidateNested() + cloudDetails?: CloudDatabaseDetails; } diff --git a/redisinsight/api/src/modules/database/entities/database.entity.ts b/redisinsight/api/src/modules/database/entities/database.entity.ts index a42cad5f23..bef7bf7a01 100644 --- a/redisinsight/api/src/modules/database/entities/database.entity.ts +++ b/redisinsight/api/src/modules/database/entities/database.entity.ts @@ -7,6 +7,7 @@ import { DataAsJsonString } from 'src/common/decorators'; import { Expose, Transform, Type } from 'class-transformer'; import { SentinelMaster } from 'src/modules/redis-sentinel/models/sentinel-master'; import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; +import { CloudDatabaseDetailsEntity } from 'src/modules/cloud/autodiscovery/entities/cloud-database-details.entity'; export enum HostingProvider { UNKNOWN = 'UNKNOWN', @@ -195,10 +196,27 @@ export class DatabaseEntity { @Type(() => SshOptionsEntity) sshOptions: SshOptionsEntity; + @Expose() + @OneToOne( + () => CloudDatabaseDetailsEntity, + (cloudDetails) => cloudDetails.database, + { + eager: true, + onDelete: 'CASCADE', + cascade: true, + }, + ) + @Type(() => CloudDatabaseDetailsEntity) + cloudDetails: CloudDatabaseDetailsEntity; + @Expose() @Column({ nullable: false, default: Compressor.NONE, }) compressor: Compressor; + + @Expose() + @Column({ nullable: true }) + version: string; } diff --git a/redisinsight/api/src/modules/database/models/database.ts b/redisinsight/api/src/modules/database/models/database.ts index 957daae8f6..c61ae2cf2c 100644 --- a/redisinsight/api/src/modules/database/models/database.ts +++ b/redisinsight/api/src/modules/database/models/database.ts @@ -21,6 +21,7 @@ import { Endpoint } from 'src/common/models'; import { AdditionalRedisModule } from 'src/modules/database/models/additional.redis.module'; import { SshOptions } from 'src/modules/ssh/models/ssh-options'; import { Default } from 'src/common/decorators'; +import { CloudDatabaseDetails } from 'src/modules/cloud/autodiscovery/models/cloud-database-details'; const CONNECTIONS_CONFIG = config.get('connections'); @@ -256,6 +257,17 @@ export class Database { @ValidateNested() sshOptions?: SshOptions; + @ApiPropertyOptional({ + description: 'Cloud details', + type: CloudDatabaseDetails, + }) + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(() => CloudDatabaseDetails) + @ValidateNested() + cloudDetails?: CloudDatabaseDetails; + @ApiPropertyOptional({ description: 'Database compressor', default: Compressor.NONE, @@ -265,4 +277,14 @@ export class Database { @IsEnum(Compressor) @IsOptional() compressor?: Compressor = Compressor.NONE; + + @ApiPropertyOptional({ + description: 'The version your Redis server', + type: String, + }) + @Expose() + @IsString() + @IsNotEmpty() + @IsOptional() + version?: string; } diff --git a/redisinsight/api/src/modules/database/providers/database-info.provider.spec.ts b/redisinsight/api/src/modules/database/providers/database-info.provider.spec.ts index a2eb3711ac..88720cd0f2 100644 --- a/redisinsight/api/src/modules/database/providers/database-info.provider.spec.ts +++ b/redisinsight/api/src/modules/database/providers/database-info.provider.spec.ts @@ -311,6 +311,18 @@ describe('DatabaseInfoProvider', () => { }); }); + describe('determineDatabaseServer', () => { + it('get modules by using MODULE LIST command', async () => { + when(mockIORedisClient.call) + .calledWith('info', ['server']) + .mockResolvedValue(mockRedisServerInfoResponse); + + const result = await service.determineDatabaseServer(mockIORedisClient); + + expect(result).toEqual(mockRedisGeneralInfo.version); + }); + }); + describe('getRedisGeneralInfo', () => { beforeEach(() => { service.getDatabasesCount = jest.fn().mockResolvedValue(16); diff --git a/redisinsight/api/src/modules/database/providers/database-info.provider.ts b/redisinsight/api/src/modules/database/providers/database-info.provider.ts index c0818f82a3..5a20b248f5 100644 --- a/redisinsight/api/src/modules/database/providers/database-info.provider.ts +++ b/redisinsight/api/src/modules/database/providers/database-info.provider.ts @@ -88,6 +88,20 @@ export class DatabaseInfoProvider { } } + /** + * Determine database server version using "module list" command + * @param client + */ + public async determineDatabaseServer(client: any): Promise { + try { + const reply = convertRedisInfoReplyToObject(await client.call('info', ['server'])); + return reply['server']?.redis_version; + } catch (e) { + // continue regardless of error + } + return null; + } + /** * Determine database modules by using "command info" command for each listed (known/supported) module * @param client diff --git a/redisinsight/api/src/modules/database/providers/database.factory.ts b/redisinsight/api/src/modules/database/providers/database.factory.ts index c9316e49ab..953491ddc7 100644 --- a/redisinsight/api/src/modules/database/providers/database.factory.ts +++ b/redisinsight/api/src/modules/database/providers/database.factory.ts @@ -51,6 +51,7 @@ export class DatabaseFactory { } model.modules = await this.databaseInfoProvider.determineDatabaseModules(client); + model.version = await this.databaseInfoProvider.determineDatabaseServer(client); model.lastConnection = new Date(); await client.disconnect(); diff --git a/redisinsight/api/src/modules/database/repositories/local.database.repository.spec.ts b/redisinsight/api/src/modules/database/repositories/local.database.repository.spec.ts index aa59a16810..1008d4115b 100644 --- a/redisinsight/api/src/modules/database/repositories/local.database.repository.spec.ts +++ b/redisinsight/api/src/modules/database/repositories/local.database.repository.spec.ts @@ -9,12 +9,12 @@ import { mockClusterDatabaseWithTlsAuth, mockClusterDatabaseWithTlsAuthEntity, mockDatabase, - mockDatabaseEntity, + mockDatabaseEntity, mockDatabaseEntityWithCloudDetails, mockDatabaseId, mockDatabasePasswordEncrypted, mockDatabasePasswordPlain, mockDatabaseSentinelMasterPasswordEncrypted, - mockDatabaseSentinelMasterPasswordPlain, + mockDatabaseSentinelMasterPasswordPlain, mockDatabaseWithCloudDetails, mockDatabaseWithSshBasic, mockDatabaseWithSshBasicEntity, mockDatabaseWithSshPrivateKey, @@ -48,7 +48,7 @@ import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; const listFields = [ 'id', 'name', 'host', 'port', 'db', 'timeout', - 'connectionType', 'modules', 'lastConnection', + 'connectionType', 'modules', 'lastConnection', 'version', ]; describe('LocalDatabaseRepository', () => { @@ -247,6 +247,16 @@ describe('LocalDatabaseRepository', () => { expect(clientCertRepository.create).not.toHaveBeenCalled(); }); + it('should create standalone database with cloud details', async () => { + repository.save.mockResolvedValue(mockDatabaseEntityWithCloudDetails); + + const result = await service.create(mockDatabaseWithCloudDetails); + + expect(result).toEqual(mockDatabaseWithCloudDetails); + expect(caCertRepository.create).not.toHaveBeenCalled(); + expect(clientCertRepository.create).not.toHaveBeenCalled(); + }); + it('should create standalone database (with existing certificates)', async () => { repository.save.mockResolvedValueOnce(mockDatabaseWithTlsAuthEntity); diff --git a/redisinsight/api/src/modules/database/repositories/local.database.repository.ts b/redisinsight/api/src/modules/database/repositories/local.database.repository.ts index e87f0d80bd..5d9b3a2432 100644 --- a/redisinsight/api/src/modules/database/repositories/local.database.repository.ts +++ b/redisinsight/api/src/modules/database/repositories/local.database.repository.ts @@ -77,7 +77,7 @@ export class LocalDatabaseRepository extends DatabaseRepository { .createQueryBuilder('d') .select([ 'd.id', 'd.name', 'd.host', 'd.port', 'd.db', 'd.new', 'd.timeout', - 'd.connectionType', 'd.modules', 'd.lastConnection', 'd.provider', + 'd.connectionType', 'd.modules', 'd.lastConnection', 'd.provider', 'd.version', ]) .getMany(); diff --git a/redisinsight/api/src/modules/redis-enterprise/dto/cloud.dto.ts b/redisinsight/api/src/modules/redis-enterprise/dto/cloud.dto.ts deleted file mode 100644 index 3d0041d18d..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/dto/cloud.dto.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsDefined, IsInt, IsNotEmpty, IsString, -} from 'class-validator'; -import { Exclude, Transform, Type } from 'class-transformer'; -import { RedisCloudSubscriptionStatus } from '../models/redis-cloud-subscriptions'; -import { RedisEnterpriseDatabaseStatus } from '../models/redis-enterprise-database'; - -export class CloudAuthDto { - @ApiProperty({ - description: 'Cloud API account key', - type: String, - }) - @IsDefined() - @IsNotEmpty() - @IsString({ always: true }) - apiKey: string; - - @ApiProperty({ - description: 'Cloud API secret key', - type: String, - }) - @IsDefined() - @IsNotEmpty() - @IsString({ always: true }) - apiSecretKey: string; -} - -export class GetDatabasesInCloudSubscriptionDto extends CloudAuthDto { - @ApiProperty({ - description: 'Subscription Id', - type: Number, - }) - @IsDefined() - @IsNotEmpty() - @IsInt({ always: true }) - @Type(() => Number) - subscriptionId: number; -} - -export class GetDatabaseInCloudSubscriptionDto extends CloudAuthDto { - @ApiProperty({ - description: 'Subscription Id', - type: Number, - }) - @IsDefined() - @IsNotEmpty() - @IsInt({ always: true }) - @Type(() => Number) - subscriptionId: number; - - @ApiProperty({ - description: 'Database Id', - type: Number, - }) - @IsDefined() - @IsNotEmpty() - @IsInt({ always: true }) - @Type(() => Number) - databaseId: number; -} - -export class GetDatabasesInMultipleCloudSubscriptionsDto extends CloudAuthDto { - @ApiProperty({ - description: 'Subscription Ids', - type: Number, - isArray: true, - }) - @IsDefined() - @IsInt({ each: true }) - @Type(() => Number) - @Transform((value: number | number[]) => { - if (typeof value === 'number') { - return [value]; - } - return value; - }) - subscriptionIds: number[]; -} - -export class GetCloudAccountShortInfoResponse { - @ApiProperty({ - description: 'Account id', - type: Number, - }) - accountId: number; - - @ApiProperty({ - description: 'Account name', - type: String, - }) - accountName: string; - - @ApiProperty({ - description: 'Account owner name', - type: String, - }) - ownerName: string; - - @ApiProperty({ - description: 'Account owner email', - type: String, - }) - ownerEmail: string; -} - -export class GetRedisCloudSubscriptionResponse { - @ApiProperty({ - description: 'Subscription id', - type: Number, - }) - id: number; - - @ApiProperty({ - description: 'Subscription name', - type: String, - }) - name: string; - - @ApiProperty({ - description: 'Number of databases in subscription', - type: Number, - }) - numberOfDatabases: number; - - @ApiProperty({ - description: 'Subscription status', - enum: RedisCloudSubscriptionStatus, - default: RedisCloudSubscriptionStatus.Active, - }) - status: RedisCloudSubscriptionStatus; - - @ApiPropertyOptional({ - description: 'Subscription provider', - type: String, - }) - provider?: string; - - @ApiPropertyOptional({ - description: 'Subscription region', - type: String, - }) - region?: string; -} - -export class RedisCloudDatabase { - @ApiProperty({ - description: 'Subscription id', - type: Number, - }) - subscriptionId: number; - - @ApiProperty({ - description: 'Database id', - type: Number, - }) - databaseId: number; - - @ApiProperty({ - description: 'Database name', - type: String, - }) - name: string; - - @ApiProperty({ - description: 'Address your Redis Cloud database is available on', - type: String, - }) - publicEndpoint: string; - - @ApiProperty({ - description: 'Database status', - enum: RedisEnterpriseDatabaseStatus, - default: RedisEnterpriseDatabaseStatus.Active, - }) - status: RedisEnterpriseDatabaseStatus; - - @ApiProperty({ - description: 'Is ssl authentication enabled or not', - type: Boolean, - }) - sslClientAuthentication: boolean; - - @ApiProperty({ - description: 'Information about the modules loaded to the database', - type: String, - isArray: true, - }) - modules: string[]; - - @ApiProperty({ - description: 'Additional database options', - type: Object, - }) - options: any; - - @Exclude() - password?: string; - - constructor(partial: Partial) { - Object.assign(this, partial); - } -} diff --git a/redisinsight/api/src/modules/redis-enterprise/dto/redis-enterprise-cloud.dto.ts b/redisinsight/api/src/modules/redis-enterprise/dto/redis-enterprise-cloud.dto.ts deleted file mode 100644 index a5743fabd8..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/dto/redis-enterprise-cloud.dto.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - ArrayNotEmpty, - IsArray, - IsDefined, - IsInt, - IsNotEmpty, - ValidateNested, -} from 'class-validator'; -import { Type } from 'class-transformer'; -import { - CloudAuthDto, - RedisCloudDatabase, -} from 'src/modules/redis-enterprise/dto/cloud.dto'; -import { ActionStatus } from 'src/common/models'; - -export class AddRedisCloudDatabaseDto { - @ApiProperty({ - description: 'Subscription id', - type: Number, - }) - @IsDefined() - @IsNotEmpty() - @IsInt({ always: true }) - subscriptionId: number; - - @ApiProperty({ - description: 'Database id', - type: Number, - }) - @IsDefined() - @IsNotEmpty() - @IsInt({ always: true }) - databaseId: number; -} - -export class AddMultipleRedisCloudDatabasesDto extends CloudAuthDto { - @ApiProperty({ - description: 'Cloud databases list.', - type: AddRedisCloudDatabaseDto, - isArray: true, - }) - @IsDefined() - @IsArray() - @ArrayNotEmpty() - @ValidateNested() - @Type(() => AddRedisCloudDatabaseDto) - databases: AddRedisCloudDatabaseDto[]; -} - -export class AddRedisCloudDatabaseResponse { - @ApiProperty({ - description: 'Subscription id', - type: Number, - }) - subscriptionId: number; - - @ApiProperty({ - description: 'Database id', - type: Number, - }) - databaseId: number; - - @ApiProperty({ - description: 'Add Redis Cloud database status', - default: ActionStatus.Success, - enum: ActionStatus, - }) - status: ActionStatus; - - @ApiProperty({ - description: 'Message', - type: String, - }) - message: string; - - @ApiPropertyOptional({ - description: 'The database details.', - type: RedisCloudDatabase, - }) - databaseDetails?: RedisCloudDatabase; - - @ApiPropertyOptional({ - description: 'Error', - }) - error?: string | object; -} diff --git a/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-account.ts b/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-account.ts deleted file mode 100644 index 1d6feb14ea..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-account.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface IRedisCloudAccount { - id: number; - name: string; - createdTimestamp: string; - updatedTimestamp: string; - key: IRedisCloudAccountKey; -} - -interface IRedisCloudAccountKey { - name: string; - accountId: number; - accountName: string; - allowedSourceIps: string[]; - createdTimestamp: string; - owner: IRedisCloudAccountOwner; - httpSourceIp: string; -} - -interface IRedisCloudAccountOwner { - name: string; - email: string; -} diff --git a/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-database.ts b/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-database.ts deleted file mode 100644 index 440d23d684..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-database.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; - -export interface IRedisCloudDatabasesResponse { - accountId: number; - subscription: { - subscriptionId: number; - numberOfDatabases: number; - databases: IRedisCloudDatabase[]; - }[]; -} - -export interface IRedisCloudDatabase { - databaseId: number; - name: string; - protocol: RedisCloudDatabaseProtocol; - provider: string; - region: string; - redisVersionCompliance: string; - status: RedisEnterpriseDatabaseStatus; - memoryLimitInGb: number; - memoryUsedInMb: number; - memoryStorage: string; - supportOSSClusterApi: boolean; - dataPersistence: string; - replication: boolean; - periodicBackupPath?: string; - dataEvictionPolicy: string; - throughputMeasurement: { - by: string; - value: number; - }; - activatedOn: string; - lastModified: string; - publicEndpoint: string; - privateEndpoint: string; - replicaOf: { - endpoints: string[]; - }; - clustering: IRedisCloudDatabaseClustering; - security: IRedisCloudDatabaseSecurity; - modules: IRedisCloudDatabaseModule[]; - alerts: IRedisCloudAlert[]; -} - -export enum RedisCloudDatabaseProtocol { - Redis = 'redis', - Memcached = 'memcached', -} - -export enum RedisCloudMemoryStorage { - Ram = 'ram', - RamAndFlash = 'ram-and-flash', -} - -export enum RedisPersistencePolicy { - AofEveryOneSecond = 'aof-every-1-second', - AofEveryWrite = 'aof-every-write', - SnapshotEveryOneHour = 'snapshot-every-1-hour', - SnapshotEverySixHours = 'snapshot-every-6-hours', - SnapshotEveryTwelveHours = 'snapshot-every-12-hours', - None = 'none', -} - -export interface IRedisCloudDatabaseModule { - id: number; - name: string; - version: string; - description?: string; - parameters?: any[]; -} - -interface IRedisCloudDatabaseSecurity { - password?: string; - sslClientAuthentication: boolean; - sourceIps: string[]; -} - -interface IRedisCloudDatabaseClustering { - numberOfShards: number; - regexRules: any[]; - hashingPolicy: string; -} - -interface IRedisCloudAlert { - name: string; - value: number; -} diff --git a/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-subscriptions.ts b/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-subscriptions.ts deleted file mode 100644 index ed43e5230f..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-subscriptions.ts +++ /dev/null @@ -1,48 +0,0 @@ -export interface IRedisCloudSubscriptionsResponse { - accountId: number; - subscriptions: IRedisCloudSubscription[]; -} - -export interface IRedisCloudSubscription { - id: number; - name: string; - status: RedisCloudSubscriptionStatus; - paymentMethodId: number; - memoryStorage: string; - storageEncryption: boolean; - numberOfDatabases: number; - subscriptionPricing: IRedisCloudSubscriptionPricing[]; - cloudDetails: IRedisCloudSubscriptionCloudDetails[]; -} - -interface IRedisCloudSubscriptionCloudDetails { - provider: string; - cloudAccountId: number; - totalSizeInGb: number; - regions: IRedisCloudSubscriptionRegion[]; -} - -interface IRedisCloudSubscriptionPricing { - type: string; - typeDetails?: string; - quantity: number; - quantityMeasurement: string; - pricePerUnit?: number; - priceCurrency?: string; - pricePeriod?: string; -} - -interface IRedisCloudSubscriptionRegion { - region: string; - networking: any[]; - preferredAvailabilityZones: string[]; - multipleAvailabilityZones: boolean; -} - -export enum RedisCloudSubscriptionStatus { - Active = 'active', - NotActivated = 'not_activated', - Deleting = 'deleting', - Pending = 'pending', - Error = 'error', -} diff --git a/redisinsight/api/src/modules/redis-enterprise/models/redis-enterprise-database.ts b/redisinsight/api/src/modules/redis-enterprise/models/redis-enterprise-database.ts index f8dc510ca9..9931013efe 100644 --- a/redisinsight/api/src/modules/redis-enterprise/models/redis-enterprise-database.ts +++ b/redisinsight/api/src/modules/redis-enterprise/models/redis-enterprise-database.ts @@ -1,3 +1,12 @@ +export enum RedisEnterprisePersistencePolicy { + AofEveryOneSecond = 'aof-every-1-second', + AofEveryWrite = 'aof-every-write', + SnapshotEveryOneHour = 'snapshot-every-1-hour', + SnapshotEverySixHours = 'snapshot-every-6-hours', + SnapshotEveryTwelveHours = 'snapshot-every-12-hours', + None = 'none', +} + export interface IRedisEnterpriseDatabase { gradual_src_mode: string; group_uid: number; diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-cloud.service.spec.ts b/redisinsight/api/src/modules/redis-enterprise/redis-cloud.service.spec.ts deleted file mode 100644 index 7322e21313..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/redis-cloud.service.spec.ts +++ /dev/null @@ -1,494 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import axios, { AxiosError } from 'axios'; -import { - ForbiddenException, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; -import { mockDatabaseService, mockRedisEnterpriseAnalytics } from 'src/__mocks__'; -import { IRedisCloudAccount } from 'src/modules/redis-enterprise/models/redis-cloud-account'; -import { - CloudAuthDto, - GetCloudAccountShortInfoResponse, - RedisCloudDatabase, - GetRedisCloudSubscriptionResponse, -} from 'src/modules/redis-enterprise/dto/cloud.dto'; -import { - IRedisCloudSubscription, - RedisCloudSubscriptionStatus, -} from 'src/modules/redis-enterprise/models/redis-cloud-subscriptions'; -import { - IRedisCloudDatabase, - IRedisCloudDatabasesResponse, - RedisCloudDatabaseProtocol, -} from 'src/modules/redis-enterprise/models/redis-cloud-database'; -import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; -import { RedisCloudService } from 'src/modules/redis-enterprise/redis-cloud.service'; -import { RedisEnterpriseAnalytics } from 'src/modules/redis-enterprise/redis-enterprise.analytics'; -import { DatabaseService } from 'src/modules/database/database.service'; - -const mockedAxios = axios as jest.Mocked; -jest.mock('axios'); -mockedAxios.create = jest.fn(() => mockedAxios); - -const mockCloudAuthDto: CloudAuthDto = { - apiKey: 'api_key', - apiSecretKey: 'api_secret_key', -}; -const mockRedisCloudAccount: IRedisCloudAccount = { - id: 40131, - name: 'Redis Labs', - createdTimestamp: '2018-12-23T15:15:31Z', - updatedTimestamp: '2020-06-03T13:16:59Z', - key: { - name: 'QA-HashedIn-Test-API-Key-2', - accountId: 40131, - accountName: 'Redis Labs', - allowedSourceIps: ['0.0.0.0/0'], - createdTimestamp: '2020-04-06T09:22:38Z', - owner: { - name: 'Cloud Account', - email: 'cloud.account@redislabs.com', - }, - httpSourceIp: '198.141.36.229', - }, -}; - -const mockRedisCloudSubscription: IRedisCloudSubscription = { - id: 108353, - name: 'external CA', - status: RedisCloudSubscriptionStatus.Active, - paymentMethodId: 8240, - memoryStorage: 'ram', - storageEncryption: false, - numberOfDatabases: 7, - subscriptionPricing: [ - { - type: 'Shards', - typeDetails: 'high-throughput', - quantity: 2, - quantityMeasurement: 'shards', - pricePerUnit: 0.124, - priceCurrency: 'USD', - pricePeriod: 'hour', - }, - ], - cloudDetails: [ - { - provider: 'AWS', - cloudAccountId: 16424, - totalSizeInGb: 0.0323, - regions: [ - { - region: 'us-east-1', - networking: [ - { - deploymentCIDR: '10.0.0.0/24', - subnetId: 'subnet-0a2dd5829daf83024', - }, - ], - preferredAvailabilityZones: ['us-east-1a'], - multipleAvailabilityZones: false, - }, - ], - }, - ], -}; - -const mockRedisCloudDatabase: IRedisCloudDatabase = { - databaseId: 50859754, - name: 'bdb', - protocol: RedisCloudDatabaseProtocol.Redis, - provider: 'GCP', - region: 'us-central1', - redisVersionCompliance: '5.0.5', - status: RedisEnterpriseDatabaseStatus.Active, - memoryLimitInGb: 1.0, - memoryUsedInMb: 6.0, - memoryStorage: 'ram', - supportOSSClusterApi: false, - dataPersistence: 'none', - replication: true, - dataEvictionPolicy: 'volatile-lru', - throughputMeasurement: { - by: 'operations-per-second', - value: 25000, - }, - activatedOn: '2019-12-31T09:38:41Z', - lastModified: '2019-12-31T09:38:41Z', - publicEndpoint: - 'redis-14621.c34097.us-central1-mz.gcp.qa-cloud.rlrcp.com:14621', - privateEndpoint: - 'redis-14621.internal.c34097.us-central1-mz.gcp.qa-cloud.rlrcp.com:14621', - replicaOf: { - endpoints: [ - 'redis-19669.c9244.us-central1-mz.gcp.cloud.rlrcp.com:19669', - 'redis-14074.c9243.us-central1-mz.gcp.cloud.rlrcp.com:14074', - ], - }, - clustering: { - numberOfShards: 1, - regexRules: [], - hashingPolicy: 'standard', - }, - security: { - sslClientAuthentication: false, - sourceIps: ['0.0.0.0/0'], - }, - modules: [ - { - id: 1, - name: 'ReJSON', - version: 'v10007', - }, - ], - alerts: [], -}; - -const mockUnauthenticatedErrorMessage = 'Request failed with status code 401'; -const mockApiUnauthenticatedResponse = { - message: mockUnauthenticatedErrorMessage, - response: { - status: 401, - }, -}; - -const mockParsedRedisCloudDatabase: RedisCloudDatabase = { - subscriptionId: mockRedisCloudSubscription.id, - databaseId: mockRedisCloudDatabase.databaseId, - name: mockRedisCloudDatabase.name, - publicEndpoint: mockRedisCloudDatabase.publicEndpoint, - status: mockRedisCloudDatabase.status, - sslClientAuthentication: false, - password: undefined, - modules: ['ReJSON'], - options: { - enabledBackup: false, - enabledClustering: false, - enabledDataPersistence: false, - enabledRedisFlash: false, - enabledReplication: true, - isReplicaDestination: true, - persistencePolicy: 'none', - }, -}; - -const mockRedisCloudDatabasesResponse: IRedisCloudDatabasesResponse = { - accountId: 40131, - subscription: [ - { - subscriptionId: 86070, - numberOfDatabases: 1, - databases: [mockRedisCloudDatabase], - }, - ], -}; - -describe('RedisCloudService', () => { - let service: RedisCloudService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - RedisCloudService, - { - provide: DatabaseService, - useFactory: mockDatabaseService, - }, - { - provide: RedisEnterpriseAnalytics, - useFactory: mockRedisEnterpriseAnalytics, - }, - ], - }).compile(); - - service = module.get(RedisCloudService); - }); - - describe('getAccount', () => { - let parseCloudAccountResponse: jest.SpyInstance< - GetCloudAccountShortInfoResponse, - [account: IRedisCloudAccount] - >; - beforeEach(() => { - parseCloudAccountResponse = jest.spyOn( - service, - 'parseCloudAccountResponse', - ); - }); - - it('successfully get Redis Enterprise Cloud account', async () => { - const response = { - status: 200, - data: { account: mockRedisCloudAccount }, - }; - mockedAxios.get.mockResolvedValue(response); - - await expect(service.getAccount(mockCloudAuthDto)).resolves.not.toThrow(); - expect(mockedAxios.get).toHaveBeenCalled(); - expect(parseCloudAccountResponse).toHaveBeenCalledWith( - mockRedisCloudAccount, - ); - }); - it('Should throw Forbidden exception', async () => { - mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); - - await expect(service.getAccount(mockCloudAuthDto)).rejects.toThrow( - ForbiddenException, - ); - }); - }); - - describe('getSubscriptions', () => { - let parseCloudSubscriptionsResponse: jest.SpyInstance< - GetRedisCloudSubscriptionResponse[], - [subscriptions: IRedisCloudSubscription[]] - >; - beforeEach(() => { - parseCloudSubscriptionsResponse = jest.spyOn( - service, - 'parseCloudSubscriptionsResponse', - ); - }); - - it('successfully get Redis Enterprise Cloud subscriptions', async () => { - const response = { - status: 200, - data: { subscriptions: [mockRedisCloudSubscription] }, - }; - mockedAxios.get.mockResolvedValue(response); - - await expect( - service.getSubscriptions(mockCloudAuthDto), - ).resolves.not.toThrow(); - expect(mockedAxios.get).toHaveBeenCalled(); - expect(parseCloudSubscriptionsResponse).toHaveBeenCalledWith([ - mockRedisCloudSubscription, - ]); - }); - it('should throw forbidden error when get subscriptions', async () => { - mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); - - await expect(service.getSubscriptions(mockCloudAuthDto)).rejects.toThrow( - ForbiddenException, - ); - }); - }); - - describe('getDatabasesInSubscription', () => { - let parseCloudDatabasesResponse: jest.SpyInstance< - RedisCloudDatabase[], - [response: IRedisCloudDatabasesResponse] - >; - beforeEach(() => { - parseCloudDatabasesResponse = jest.spyOn( - service, - 'parseCloudDatabasesInSubscriptionResponse', - ); - }); - - it('successfully get Redis Enterprise Cloud databases', async () => { - const response = { - status: 200, - data: mockRedisCloudDatabasesResponse, - }; - mockedAxios.get.mockResolvedValue(response); - - await expect( - service.getDatabasesInSubscription({ - ...mockCloudAuthDto, - subscriptionId: 86070, - }), - ).resolves.not.toThrow(); - expect(mockedAxios.get).toHaveBeenCalled(); - expect(parseCloudDatabasesResponse).toHaveBeenCalledWith( - mockRedisCloudDatabasesResponse, - ); - }); - it('the user could not be authenticated', async () => { - mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); - - await expect( - service.getDatabasesInSubscription({ - ...mockCloudAuthDto, - subscriptionId: 86070, - }), - ).rejects.toThrow(ForbiddenException); - }); - it('subscription not found', async () => { - const subscriptionId = mockRedisCloudSubscription.id; - const apiResponse = { - message: `Subscription ${subscriptionId} not found`, - response: { - status: 404, - }, - }; - mockedAxios.get.mockRejectedValue(apiResponse); - - await expect( - service.getDatabasesInSubscription({ - ...mockCloudAuthDto, - subscriptionId, - }), - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('getDatabasesInMultipleSubscriptions', () => { - beforeEach(() => { - service.getDatabasesInSubscription = jest.fn().mockResolvedValue([]); - }); - it('should call getDatabasesInSubscription', async () => { - await service.getDatabasesInMultipleSubscriptions({ - ...mockCloudAuthDto, - subscriptionIds: [86070, 86071], - }); - - expect(service.getDatabasesInSubscription).toHaveBeenCalledTimes(2); - }); - it('should not call getDatabasesInSubscription for duplicated ids', async () => { - await service.getDatabasesInMultipleSubscriptions({ - ...mockCloudAuthDto, - subscriptionIds: [86070, 86070, 86071], - }); - - expect(service.getDatabasesInSubscription).toHaveBeenCalledTimes(2); - }); - it('subscription not found', async () => { - service.getDatabasesInSubscription = jest - .fn() - .mockRejectedValue(new NotFoundException()); - - await expect( - service.getDatabasesInMultipleSubscriptions({ - ...mockCloudAuthDto, - subscriptionIds: [86070, 86071], - }), - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('getDatabase', () => { - let parseCloudDatabaseResponse: jest.SpyInstance< - RedisCloudDatabase, - [database: IRedisCloudDatabase, subscriptionId: number] - >; - const subscriptionId = mockRedisCloudSubscription.id; - const databaseId = mockRedisCloudSubscription.id; - beforeEach(() => { - parseCloudDatabaseResponse = jest.spyOn( - service, - 'parseCloudDatabaseResponse', - ); - }); - - it('successfully get database from Redis Cloud subscriptions', async () => { - const response = { - status: 200, - data: mockRedisCloudDatabase, - }; - mockedAxios.get.mockResolvedValue(response); - - await expect( - service.getDatabase({ - ...mockCloudAuthDto, - subscriptionId, - databaseId, - }), - ).resolves.not.toThrow(); - expect(mockedAxios.get).toHaveBeenCalled(); - expect(parseCloudDatabaseResponse).toHaveBeenCalledWith( - mockRedisCloudDatabase, - subscriptionId, - ); - }); - it('the user could not be authenticated', async () => { - mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); - - await expect( - service.getDatabase({ - ...mockCloudAuthDto, - subscriptionId, - databaseId, - }), - ).rejects.toThrow(ForbiddenException); - }); - it('database not found', async () => { - const apiResponse = { - message: `Subscription ${subscriptionId} database ${databaseId} not found`, - response: { - status: 404, - }, - }; - mockedAxios.get.mockRejectedValue(apiResponse); - - await expect( - service.getDatabase({ - ...mockCloudAuthDto, - subscriptionId, - databaseId, - }), - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('parseCloudDatabaseResponse', () => { - const subscriptionId = mockRedisCloudSubscription.id; - it('should return correct value', () => { - const result = service.parseCloudDatabaseResponse( - mockRedisCloudDatabase, - subscriptionId, - ); - - expect(result).toEqual(mockParsedRedisCloudDatabase); - }); - }); - - describe('_getApiError', () => { - const title = 'Failed to get databases in RE cloud subscription'; - const mockError: AxiosError = { - name: '', - message: mockUnauthenticatedErrorMessage, - isAxiosError: true, - config: null, - response: { - statusText: mockUnauthenticatedErrorMessage, - data: null, - headers: {}, - config: null, - status: 401, - }, - toJSON: () => null, - }; - it('should throw ForbiddenException', async () => { - const result = service.getApiError(mockError, title); - - expect(result).toBeInstanceOf(ForbiddenException); - }); - it('should throw InternalServerErrorException from response', async () => { - const errorMessage = 'Request failed with status code 500'; - const error = { - ...mockError, - message: errorMessage, - response: { - ...mockError.response, - status: 500, - statusText: errorMessage, - }, - }; - const result = service.getApiError(error, title); - - expect(result).toBeInstanceOf(InternalServerErrorException); - }); - it('should throw InternalServerErrorException', async () => { - const error = { - ...mockError, - message: 'Request failed with status code 500', - response: undefined, - }; - const result = service.getApiError(error, title); - - expect(result).toBeInstanceOf(InternalServerErrorException); - }); - }); -}); diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-cloud.service.ts b/redisinsight/api/src/modules/redis-enterprise/redis-cloud.service.ts deleted file mode 100644 index ce8b8766f1..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/redis-cloud.service.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { - ForbiddenException, - HttpException, - Injectable, - InternalServerErrorException, - Logger, - NotFoundException, ServiceUnavailableException, -} from '@nestjs/common'; -import axios, { AxiosError, AxiosResponse } from 'axios'; -import { get, find, uniq } from 'lodash'; -import config from 'src/utils/config'; -import ERROR_MESSAGES from 'src/constants/error-messages'; -import { IRedisCloudAccount } from 'src/modules/redis-enterprise/models/redis-cloud-account'; -import { - CloudAuthDto, - GetCloudAccountShortInfoResponse, - GetDatabaseInCloudSubscriptionDto, - GetDatabasesInCloudSubscriptionDto, - GetDatabasesInMultipleCloudSubscriptionsDto, - RedisCloudDatabase, - GetRedisCloudSubscriptionResponse, -} from 'src/modules/redis-enterprise/dto/cloud.dto'; -import { IRedisCloudSubscription } from 'src/modules/redis-enterprise/models/redis-cloud-subscriptions'; -import { - IRedisCloudDatabase, - IRedisCloudDatabaseModule, - IRedisCloudDatabasesResponse, - RedisPersistencePolicy, - RedisCloudDatabaseProtocol, - RedisCloudMemoryStorage, -} from 'src/modules/redis-enterprise/models/redis-cloud-database'; -import { convertRECloudModuleName } from 'src/modules/redis-enterprise/utils/redis-cloud-converter'; -import { RedisEnterpriseAnalytics } from 'src/modules/redis-enterprise/redis-enterprise.analytics'; -import { - AddRedisCloudDatabaseDto, - AddRedisCloudDatabaseResponse, -} from 'src/modules/redis-enterprise/dto/redis-enterprise-cloud.dto'; -import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; -import { DatabaseService } from 'src/modules/database/database.service'; -import { HostingProvider } from 'src/modules/database/entities/database.entity'; -import { ActionStatus } from 'src/common/models'; - -@Injectable() -export class RedisCloudService { - private logger = new Logger('RedisCloudBusinessService'); - - private config = config.get('redis_cloud'); - - private api = axios.create(); - - constructor( - private readonly databaseService: DatabaseService, - private readonly analytics: RedisEnterpriseAnalytics, - ) {} - - async getAccount( - dto: CloudAuthDto, - ): Promise { - this.logger.log('Getting RE cloud account.'); - const { apiKey, apiSecretKey } = dto; - try { - const { - data: { account }, - }: AxiosResponse = await this.api.get(`${this.config.url}/`, { - headers: this.getAuthHeaders(apiKey, apiSecretKey), - }); - this.logger.log('Succeed to get RE cloud account.'); - - return this.parseCloudAccountResponse(account); - } catch (error) { - throw this.getApiError(error, 'Failed to get RE cloud account'); - } - } - - async getSubscriptions( - dto: CloudAuthDto, - ): Promise { - this.logger.log('Getting RE cloud subscriptions.'); - const { apiKey, apiSecretKey } = dto; - try { - const { - data: { subscriptions }, - }: AxiosResponse = await this.api.get( - `${this.config.url}/subscriptions`, - { - headers: this.getAuthHeaders(apiKey, apiSecretKey), - }, - ); - this.logger.log('Succeed to get RE cloud subscriptions.'); - const result = this.parseCloudSubscriptionsResponse(subscriptions); - this.analytics.sendGetRECloudSubsSucceedEvent(result); - return result; - } catch (error) { - const exception = this.getApiError(error, 'Failed to get RE cloud subscriptions'); - this.analytics.sendGetRECloudSubsFailedEvent(exception); - throw exception; - } - } - - async getDatabasesInSubscription( - dto: GetDatabasesInCloudSubscriptionDto, - ): Promise { - const { apiKey, apiSecretKey, subscriptionId } = dto; - this.logger.log( - `Getting databases in RE cloud subscription. subscription id: ${subscriptionId}`, - ); - try { - const { data }: AxiosResponse = await this.api.get( - `${this.config.url}/subscriptions/${subscriptionId}/databases`, - { - headers: this.getAuthHeaders(apiKey, apiSecretKey), - }, - ); - this.logger.log('Succeed to get databases in RE cloud subscription.'); - return this.parseCloudDatabasesInSubscriptionResponse(data); - } catch (error) { - const { response } = error; - let exception: HttpException; - if (response?.status === 404) { - const message = `Subscription ${subscriptionId} not found`; - this.logger.error( - `Failed to get databases in RE cloud subscription. ${message}.`, - ); - exception = new NotFoundException(message); - } else { - exception = this.getApiError( - error, - 'Failed to get databases in RE cloud subscription', - ); - } - throw exception; - } - } - - async getDatabase( - dto: GetDatabaseInCloudSubscriptionDto, - ): Promise { - const { - apiKey, apiSecretKey, subscriptionId, databaseId, - } = dto; - this.logger.log( - `Getting database in RE cloud subscription. subscription id: ${subscriptionId}, database id: ${databaseId}`, - ); - try { - const { data }: AxiosResponse = await this.api.get( - `${this.config.url}/subscriptions/${subscriptionId}/databases/${databaseId}`, - { - headers: this.getAuthHeaders(apiKey, apiSecretKey), - }, - ); - this.logger.log('Succeed to get databases in RE cloud subscription.'); - return this.parseCloudDatabaseResponse(data, subscriptionId); - } catch (error) { - const { response } = error; - if (response?.status === 404) { - this.logger.error( - `Failed to get databases in RE cloud subscription. ${response?.data?.message}.`, - ); - throw new NotFoundException(response?.data?.message); - } - throw this.getApiError( - error, - 'Failed to get databases in RE cloud subscription', - ); - } - } - - async getDatabasesInMultipleSubscriptions( - dto: GetDatabasesInMultipleCloudSubscriptionsDto, - ): Promise { - const { apiKey, apiSecretKey } = dto; - const subscriptionIds = uniq(dto.subscriptionIds); - this.logger.log('Getting databases in RE cloud subscriptions.'); - let result = []; - try { - await Promise.all( - subscriptionIds.map(async (subscriptionId: number) => { - const databases = await this.getDatabasesInSubscription({ - apiKey, - apiSecretKey, - subscriptionId, - }); - result = [...result, ...databases]; - }), - ); - this.analytics.sendGetRECloudDbsSucceedEvent(result); - return result; - } catch (exception) { - this.analytics.sendGetRECloudDbsFailedEvent(exception); - throw exception; - } - } - - parseCloudAccountResponse( - account: IRedisCloudAccount, - ): GetCloudAccountShortInfoResponse { - return { - accountId: account.id, - accountName: account.name, - ownerName: get(account, ['key', 'owner', 'name']), - ownerEmail: get(account, ['key', 'owner', 'email']), - }; - } - - parseCloudSubscriptionsResponse( - subscriptions: IRedisCloudSubscription[], - ): GetRedisCloudSubscriptionResponse[] { - const result: GetRedisCloudSubscriptionResponse[] = []; - if (subscriptions?.length) { - subscriptions.forEach((subscription: IRedisCloudSubscription): void => { - result.push({ - id: subscription.id, - name: subscription.name, - numberOfDatabases: subscription.numberOfDatabases, - status: subscription.status, - provider: get(subscription, ['cloudDetails', 0, 'provider']), - region: get(subscription, [ - 'cloudDetails', - 0, - 'regions', - 0, - 'region', - ]), - }); - }); - } - return result; - } - - parseCloudDatabasesInSubscriptionResponse( - response: IRedisCloudDatabasesResponse, - ): RedisCloudDatabase[] { - const subscription = response.subscription[0]; - const { subscriptionId, databases } = subscription; - let result: RedisCloudDatabase[] = []; - databases.forEach((database: IRedisCloudDatabase): void => { - // We do not send the databases which have 'memcached' as their protocol. - if (database.protocol === RedisCloudDatabaseProtocol.Redis) { - result.push(this.parseCloudDatabaseResponse(database, subscriptionId)); - } - }); - result = result.map((database) => ({ - ...database, - options: { - ...database.options, - isReplicaSource: !!this.findReplicasForDatabase( - databases, - database.databaseId, - ).length, - }, - })); - return result; - } - - parseCloudDatabaseResponse( - database: IRedisCloudDatabase, - subscriptionId: number, - ): RedisCloudDatabase { - const { - databaseId, name, publicEndpoint, status, security, - } = database; - return new RedisCloudDatabase({ - subscriptionId, - databaseId, - name, - publicEndpoint, - status, - password: security?.password, - sslClientAuthentication: security.sslClientAuthentication, - modules: database.modules - .map((module: IRedisCloudDatabaseModule) => convertRECloudModuleName(module.name)), - options: { - enabledDataPersistence: - database.dataPersistence !== RedisPersistencePolicy.None, - persistencePolicy: database.dataPersistence, - enabledRedisFlash: - database.memoryStorage === RedisCloudMemoryStorage.RamAndFlash, - enabledReplication: database.replication, - enabledBackup: !!database.periodicBackupPath, - enabledClustering: database.clustering.numberOfShards > 1, - isReplicaDestination: !!database.replicaOf, - }, - }); - } - - getApiError(error: AxiosError, errorTitle: string): HttpException { - const { response } = error; - if (response) { - if (response.status === 401 || response.status === 403) { - this.logger.error(`${errorTitle}. ${error.message}`); - return new ForbiddenException(ERROR_MESSAGES.REDIS_CLOUD_FORBIDDEN); - } - if (response.status === 500) { - this.logger.error(`${errorTitle}. ${error.message}`); - return new InternalServerErrorException( - ERROR_MESSAGES.SERVER_NOT_AVAILABLE, - ); - } - if (response.data) { - const { data } = response; - this.logger.error( - `${errorTitle} ${error.message}`, - JSON.stringify(data), - ); - return new InternalServerErrorException(data.description || data.error); - } - } - this.logger.error(`${errorTitle}. ${error.message}`); - return new InternalServerErrorException(ERROR_MESSAGES.SERVER_NOT_AVAILABLE); - } - - private getAuthHeaders(apiKey: string, apiSecretKey: string) { - return { - 'x-api-key': apiKey, - 'x-api-secret-key': apiSecretKey, - }; - } - - private findReplicasForDatabase( - databases: IRedisCloudDatabase[], - sourceDatabaseId: number, - ): IRedisCloudDatabase[] { - const sourceDatabase: IRedisCloudDatabase = find(databases, { - databaseId: sourceDatabaseId, - }); - if (!sourceDatabase) { - return []; - } - return databases.filter((replica: IRedisCloudDatabase): boolean => { - const endpoints = get(replica, ['replicaOf', 'endpoints']); - if ( - replica.databaseId === sourceDatabaseId - || !endpoints - || !endpoints.length - ) { - return false; - } - return endpoints.some((endpoint: string): boolean => ( - endpoint.includes(sourceDatabase.publicEndpoint) - || endpoint.includes(sourceDatabase.privateEndpoint) - )); - }); - } - - public async addRedisCloudDatabases( - auth: CloudAuthDto, - addDatabasesDto: AddRedisCloudDatabaseDto[], - ): Promise { - this.logger.log('Adding Redis Cloud databases.'); - let result: AddRedisCloudDatabaseResponse[]; - try { - result = await Promise.all( - addDatabasesDto.map( - async ( - dto: AddRedisCloudDatabaseDto, - ): Promise => { - const database = await this.getDatabase({ - ...auth, - ...dto, - }); - try { - const { - publicEndpoint, name, password, status, - } = database; - if (status !== RedisEnterpriseDatabaseStatus.Active) { - const exception = new ServiceUnavailableException(ERROR_MESSAGES.DATABASE_IS_INACTIVE); - return { - ...dto, - status: ActionStatus.Fail, - message: exception.message, - error: exception?.getResponse(), - databaseDetails: database, - }; - } - const [host, port] = publicEndpoint.split(':'); - - await this.databaseService.create({ - host, - port: parseInt(port, 10), - name, - nameFromProvider: name, - password, - provider: HostingProvider.RE_CLOUD, - }); - - return { - ...dto, - status: ActionStatus.Success, - message: 'Added', - databaseDetails: database, - }; - } catch (error) { - return { - ...dto, - status: ActionStatus.Fail, - message: error.message, - error: error?.response, - databaseDetails: database, - }; - } - }, - ), - ); - } catch (error) { - this.logger.error('Failed to add Redis Cloud databases.', error); - throw error; - } - return result; - } -} diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.spec.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.spec.ts index 268c091bdc..b9009dbaac 100644 --- a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.spec.ts +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.spec.ts @@ -2,12 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { TelemetryEvents } from 'src/constants'; import { - mockRedisCloudDatabaseDto, - mockRedisCloudSubscriptionDto, mockRedisEnterpriseDatabaseDto, } from 'src/__mocks__'; import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; -import { RedisCloudSubscriptionStatus } from 'src/modules/redis-enterprise/models/redis-cloud-subscriptions'; import { InternalServerErrorException } from '@nestjs/common'; import { RedisEnterpriseAnalytics } from 'src/modules/redis-enterprise/redis-enterprise.analytics'; @@ -128,185 +125,4 @@ describe('RedisEnterpriseAnalytics', () => { ); }); }); - - describe('sendGetRECloudSubsSucceedEvent', () => { - it('should emit event with active subscriptions', () => { - service.sendGetRECloudSubsSucceedEvent([ - mockRedisCloudSubscriptionDto, - mockRedisCloudSubscriptionDto, - ]); - - expect(sendEventMethod).toHaveBeenCalledWith( - TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, - { - numberOfActiveSubscriptions: 2, - totalNumberOfSubscriptions: 2, - }, - ); - }); - it('should emit event with active and not active subscription', () => { - service.sendGetRECloudSubsSucceedEvent([ - { - ...mockRedisCloudSubscriptionDto, - status: RedisCloudSubscriptionStatus.Error, - }, - mockRedisCloudSubscriptionDto, - ]); - - expect(sendEventMethod).toHaveBeenCalledWith( - TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, - { - numberOfActiveSubscriptions: 1, - totalNumberOfSubscriptions: 2, - }, - ); - }); - it('should emit event without active subscriptions', () => { - service.sendGetRECloudSubsSucceedEvent([ - { - ...mockRedisCloudSubscriptionDto, - status: RedisCloudSubscriptionStatus.Error, - }, - { - ...mockRedisCloudSubscriptionDto, - status: RedisCloudSubscriptionStatus.Error, - }, - ]); - expect(sendEventMethod).toHaveBeenCalledWith( - TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, - { - numberOfActiveSubscriptions: 0, - totalNumberOfSubscriptions: 2, - }, - ); - }); - it('should emit GetRECloudSubsSucceedEvent event for empty list', () => { - service.sendGetRECloudSubsSucceedEvent([]); - - expect(sendEventMethod).toHaveBeenCalledWith( - TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, - { - numberOfActiveSubscriptions: 0, - totalNumberOfSubscriptions: 0, - }, - ); - }); - it('should emit GetRECloudSubsSucceedEvent event for undefined input value', () => { - service.sendGetRECloudSubsSucceedEvent(undefined); - - expect(sendEventMethod).toHaveBeenCalledWith( - TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, - { - numberOfActiveSubscriptions: 0, - totalNumberOfSubscriptions: 0, - }, - ); - }); - it('should not throw on error when sending GetRECloudSubsSucceedEvent event', () => { - const input: any = {}; - - expect(() => service.sendGetRECloudSubsSucceedEvent(input)).not.toThrow(); - expect(sendEventMethod).not.toHaveBeenCalled(); - }); - }); - - describe('sendGetRECloudSubsFailedEvent', () => { - it('should emit GetRECloudSubsFailedEvent event', () => { - service.sendGetRECloudSubsFailedEvent(httpException); - - expect(sendFailedEventMethod).toHaveBeenCalledWith( - TelemetryEvents.RECloudSubscriptionsDiscoveryFailed, - httpException, - ); - }); - }); - - describe('sendGetRECloudDbsSucceedEvent', () => { - it('should emit event with active databases', () => { - service.sendGetRECloudDbsSucceedEvent([ - mockRedisCloudDatabaseDto, - mockRedisCloudDatabaseDto, - ]); - - expect(sendEventMethod).toHaveBeenCalledWith( - TelemetryEvents.RECloudDatabasesDiscoverySucceed, - { - numberOfActiveDatabases: 2, - totalNumberOfDatabases: 2, - }, - ); - }); - it('should emit event with active and not active database', () => { - service.sendGetRECloudDbsSucceedEvent([ - { - ...mockRedisCloudDatabaseDto, - status: RedisEnterpriseDatabaseStatus.Pending, - }, - mockRedisCloudDatabaseDto, - ]); - - expect(sendEventMethod).toHaveBeenCalledWith( - TelemetryEvents.RECloudDatabasesDiscoverySucceed, - { - numberOfActiveDatabases: 1, - totalNumberOfDatabases: 2, - }, - ); - }); - it('should emit event without active databases', () => { - service.sendGetRECloudDbsSucceedEvent([ - { - ...mockRedisCloudDatabaseDto, - status: RedisEnterpriseDatabaseStatus.Pending, - }, - ]); - - expect(sendEventMethod).toHaveBeenCalledWith( - TelemetryEvents.RECloudDatabasesDiscoverySucceed, - { - numberOfActiveDatabases: 0, - totalNumberOfDatabases: 1, - }, - ); - }); - it('should emit event for empty list', () => { - service.sendGetRECloudDbsSucceedEvent([]); - - expect(sendEventMethod).toHaveBeenCalledWith( - TelemetryEvents.RECloudDatabasesDiscoverySucceed, - { - numberOfActiveDatabases: 0, - totalNumberOfDatabases: 0, - }, - ); - }); - it('should emit event for undefined input value', () => { - service.sendGetRECloudDbsSucceedEvent(undefined); - - expect(sendEventMethod).toHaveBeenCalledWith( - TelemetryEvents.RECloudDatabasesDiscoverySucceed, - { - numberOfActiveDatabases: 0, - totalNumberOfDatabases: 0, - }, - ); - }); - it('should not throw on error', () => { - const input: any = {}; - - expect(() => service.sendGetRECloudDbsSucceedEvent(input)).not.toThrow(); - expect(sendEventMethod).not.toHaveBeenCalled(); - }); - }); - - describe('sendGetRECloudDbsFailedEvent', () => { - it('should emit event', () => { - service.sendGetRECloudDbsFailedEvent(httpException); - - expect(sendFailedEventMethod).toHaveBeenCalledWith( - TelemetryEvents.RECloudDatabasesDiscoveryFailed, - httpException, - ); - }); - }); }); diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.ts index 4a6197f2e4..e6c77d221f 100644 --- a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.ts +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.ts @@ -3,8 +3,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { TelemetryEvents } from 'src/constants'; import { RedisEnterpriseDatabase } from 'src/modules/redis-enterprise/dto/cluster.dto'; import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; -import { RedisCloudSubscriptionStatus } from 'src/modules/redis-enterprise/models/redis-cloud-subscriptions'; -import { GetRedisCloudSubscriptionResponse, RedisCloudDatabase } from 'src/modules/redis-enterprise/dto/cloud.dto'; import { TelemetryBaseService } from 'src/modules/analytics/telemetry.base.service'; @Injectable() @@ -32,44 +30,4 @@ export class RedisEnterpriseAnalytics extends TelemetryBaseService { sendGetREClusterDbsFailedEvent(exception: HttpException) { this.sendFailedEvent(TelemetryEvents.REClusterDiscoveryFailed, exception); } - - sendGetRECloudSubsSucceedEvent(subscriptions: GetRedisCloudSubscriptionResponse[] = []) { - try { - this.sendEvent( - TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, - { - numberOfActiveSubscriptions: subscriptions.filter( - (sub) => sub.status === RedisCloudSubscriptionStatus.Active, - ).length, - totalNumberOfSubscriptions: subscriptions.length, - }, - ); - } catch (e) { - // continue regardless of error - } - } - - sendGetRECloudSubsFailedEvent(exception: HttpException) { - this.sendFailedEvent(TelemetryEvents.RECloudSubscriptionsDiscoveryFailed, exception); - } - - sendGetRECloudDbsSucceedEvent(databases: RedisCloudDatabase[] = []) { - try { - this.sendEvent( - TelemetryEvents.RECloudDatabasesDiscoverySucceed, - { - numberOfActiveDatabases: databases.filter( - (db) => db.status === RedisEnterpriseDatabaseStatus.Active, - ).length, - totalNumberOfDatabases: databases.length, - }, - ); - } catch (e) { - // continue regardless of error - } - } - - sendGetRECloudDbsFailedEvent(exception: HttpException) { - this.sendFailedEvent(TelemetryEvents.RECloudDatabasesDiscoveryFailed, exception); - } } diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.module.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.module.ts index e3923ef68c..5659a2abfe 100644 --- a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.module.ts +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.module.ts @@ -1,15 +1,12 @@ import { Module } from '@nestjs/common'; import { RedisEnterpriseService } from 'src/modules/redis-enterprise/redis-enterprise.service'; -import { RedisCloudService } from 'src/modules/redis-enterprise/redis-cloud.service'; import { RedisEnterpriseAnalytics } from 'src/modules/redis-enterprise/redis-enterprise.analytics'; import { ClusterController } from './controllers/cluster.controller'; -import { CloudController } from './controllers/cloud.controller'; @Module({ - controllers: [ClusterController, CloudController], + controllers: [ClusterController], providers: [ RedisEnterpriseService, - RedisCloudService, RedisEnterpriseAnalytics, ], }) diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.spec.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.spec.ts index 5794beb85b..c521df34a7 100644 --- a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.spec.ts +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.spec.ts @@ -9,8 +9,8 @@ import { RedisEnterpriseDatabaseAofPolicy, RedisEnterpriseDatabasePersistence, RedisEnterpriseDatabaseStatus, + RedisEnterprisePersistencePolicy, } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; -import { RedisPersistencePolicy } from 'src/modules/redis-enterprise/models/redis-cloud-database'; import { RedisEnterpriseService } from 'src/modules/redis-enterprise/redis-enterprise.service'; import { ClusterConnectionDetailsDto } from 'src/modules/redis-enterprise/dto/cluster.dto'; import { RedisEnterpriseAnalytics } from 'src/modules/redis-enterprise/redis-enterprise.analytics'; @@ -221,7 +221,7 @@ describe('RedisEnterpriseService', () => { data_persistence: RedisEnterpriseDatabasePersistence.Aof, aof_policy: RedisEnterpriseDatabaseAofPolicy.AofEveryOneSecond, }); - expect(result).toEqual(RedisPersistencePolicy.AofEveryOneSecond); + expect(result).toEqual(RedisEnterprisePersistencePolicy.AofEveryOneSecond); }); it('should return AofEveryWrite', async () => { const result = service.getDatabasePersistencePolicy({ @@ -229,7 +229,7 @@ describe('RedisEnterpriseService', () => { data_persistence: RedisEnterpriseDatabasePersistence.Aof, aof_policy: RedisEnterpriseDatabaseAofPolicy.AofEveryWrite, }); - expect(result).toEqual(RedisPersistencePolicy.AofEveryWrite); + expect(result).toEqual(RedisEnterprisePersistencePolicy.AofEveryWrite); }); it('should return SnapshotEveryOneHour', async () => { const result = service.getDatabasePersistencePolicy({ @@ -237,7 +237,7 @@ describe('RedisEnterpriseService', () => { data_persistence: RedisEnterpriseDatabasePersistence.Snapshot, snapshot_policy: [{ secs: 3600 }], }); - expect(result).toEqual(RedisPersistencePolicy.SnapshotEveryOneHour); + expect(result).toEqual(RedisEnterprisePersistencePolicy.SnapshotEveryOneHour); }); it('should return SnapshotEverySixHours', async () => { const result = service.getDatabasePersistencePolicy({ @@ -245,7 +245,7 @@ describe('RedisEnterpriseService', () => { data_persistence: RedisEnterpriseDatabasePersistence.Snapshot, snapshot_policy: [{ secs: 21600 }], }); - expect(result).toEqual(RedisPersistencePolicy.SnapshotEverySixHours); + expect(result).toEqual(RedisEnterprisePersistencePolicy.SnapshotEverySixHours); }); it('should return SnapshotEveryTwelveHours', async () => { const result = service.getDatabasePersistencePolicy({ @@ -253,14 +253,14 @@ describe('RedisEnterpriseService', () => { data_persistence: RedisEnterpriseDatabasePersistence.Snapshot, snapshot_policy: [{ secs: 43200 }], }); - expect(result).toEqual(RedisPersistencePolicy.SnapshotEveryTwelveHours); + expect(result).toEqual(RedisEnterprisePersistencePolicy.SnapshotEveryTwelveHours); }); it('should return None', async () => { const result = service.getDatabasePersistencePolicy({ ...mockREClusterDatabase, data_persistence: null, }); - expect(result).toEqual(RedisPersistencePolicy.None); + expect(result).toEqual(RedisEnterprisePersistencePolicy.None); }); }); diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.ts index c03c98b57d..53a918f660 100644 --- a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.ts +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.ts @@ -14,8 +14,8 @@ import { IRedisEnterpriseReplicaSource, RedisEnterpriseDatabaseAofPolicy, RedisEnterpriseDatabasePersistence, + RedisEnterprisePersistencePolicy, } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; -import { RedisPersistencePolicy } from 'src/modules/redis-enterprise/models/redis-cloud-database'; import { ClusterConnectionDetailsDto, RedisEnterpriseDatabase, @@ -140,27 +140,27 @@ export class RedisEnterpriseService { private getDatabasePersistencePolicy( database: IRedisEnterpriseDatabase, - ): RedisPersistencePolicy { + ): RedisEnterprisePersistencePolicy { // eslint-disable-next-line @typescript-eslint/naming-convention const { data_persistence, aof_policy, snapshot_policy } = database; if (data_persistence === RedisEnterpriseDatabasePersistence.Aof) { return aof_policy === RedisEnterpriseDatabaseAofPolicy.AofEveryOneSecond - ? RedisPersistencePolicy.AofEveryOneSecond - : RedisPersistencePolicy.AofEveryWrite; + ? RedisEnterprisePersistencePolicy.AofEveryOneSecond + : RedisEnterprisePersistencePolicy.AofEveryWrite; } if (data_persistence === RedisEnterpriseDatabasePersistence.Snapshot) { const { secs } = snapshot_policy.pop(); if (secs === 3600) { - return RedisPersistencePolicy.SnapshotEveryOneHour; + return RedisEnterprisePersistencePolicy.SnapshotEveryOneHour; } if (secs === 21600) { - return RedisPersistencePolicy.SnapshotEverySixHours; + return RedisEnterprisePersistencePolicy.SnapshotEverySixHours; } if (secs === 43200) { - return RedisPersistencePolicy.SnapshotEveryTwelveHours; + return RedisEnterprisePersistencePolicy.SnapshotEveryTwelveHours; } } - return RedisPersistencePolicy.None; + return RedisEnterprisePersistencePolicy.None; } private findReplicasForDatabase( diff --git a/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.ts b/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.ts deleted file mode 100644 index 1e8dbac14a..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { RE_CLOUD_MODULES_NAMES } from 'src/constants'; - -export function convertRECloudModuleName(name: string): string { - return RE_CLOUD_MODULES_NAMES[name] ?? name; -} diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.ts index 7b2a4b3b77..014275b6a9 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.ts @@ -1,5 +1,5 @@ import Redis, { Cluster, RedisOptions } from 'ioredis'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; import { Database } from 'src/modules/database/models/database'; import apiConfig from 'src/utils/config'; import { ConnectionOptions } from 'tls'; @@ -10,6 +10,7 @@ import { ClientMetadata } from 'src/common/models'; import { ClusterOptions } from 'ioredis/built/cluster/ClusterOptions'; import { SshTunnelProvider } from 'src/modules/ssh/ssh-tunnel.provider'; import { TunnelConnectionLostException } from 'src/modules/ssh/exceptions'; +import ERROR_MESSAGES from 'src/constants/error-messages'; const REDIS_CLIENTS_CONFIG = apiConfig.get('redis_clients'); @@ -195,6 +196,10 @@ export class RedisConnectionFactory { this.logger.error('Failed connection to the redis database.', e); reject(e); }); + connection.on('end', (): void => { + this.logger.error(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION); + reject(new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); + }); connection.on('ready', (): void => { this.logger.log('Successfully connected to the redis database'); resolve(connection); @@ -241,6 +246,10 @@ export class RedisConnectionFactory { this.logger.error('Failed connection to the redis oss cluster', e); reject(!isEmpty(e.lastNodeError) ? e.lastNodeError : e); }); + cluster.on('end', (): void => { + this.logger.error(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION); + reject(new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); + }); cluster.on('ready', (): void => { this.logger.log('Successfully connected to the redis oss cluster.'); resolve(cluster); @@ -271,6 +280,10 @@ export class RedisConnectionFactory { this.logger.error('Failed connection to the redis oss sentinel', e); reject(e); }); + client.on('end', (): void => { + this.logger.error(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION); + reject(new InternalServerErrorException(ERROR_MESSAGES.SERVER_CLOSED_CONNECTION)); + }); client.on('ready', (): void => { this.logger.log('Successfully connected to the redis oss sentinel.'); resolve(client); diff --git a/redisinsight/api/src/modules/server/server.service.spec.ts b/redisinsight/api/src/modules/server/server.service.spec.ts index 22c6fd6f9e..14b25e2932 100644 --- a/redisinsight/api/src/modules/server/server.service.spec.ts +++ b/redisinsight/api/src/modules/server/server.service.spec.ts @@ -34,6 +34,7 @@ const mockEventPayload: ITelemetryEvent = { appVersion: SERVER_CONFIG.appVersion, osPlatform: process.platform, buildType: SERVER_CONFIG.buildType, + port: SERVER_CONFIG.port, }, nonTracking: true, }; diff --git a/redisinsight/api/src/modules/server/server.service.ts b/redisinsight/api/src/modules/server/server.service.ts index 0b9c3c60f1..37b59e756b 100644 --- a/redisinsight/api/src/modules/server/server.service.ts +++ b/redisinsight/api/src/modules/server/server.service.ts @@ -51,7 +51,7 @@ export class ServerService implements OnApplicationBootstrap { this.eventEmitter.emit(AppAnalyticsEvents.Initialize, { anonymousId: server.id, sessionId: this.sessionId, - appType: this.getAppType(SERVER_CONFIG.buildType), + appType: ServerService.getAppType(SERVER_CONFIG.buildType), ...(await this.featuresConfigService.getControlInfo()), }); @@ -63,6 +63,7 @@ export class ServerService implements OnApplicationBootstrap { appVersion: SERVER_CONFIG.appVersion, osPlatform: process.platform, buildType: SERVER_CONFIG.buildType, + port: process.env.API_PORT || SERVER_CONFIG.port, }, nonTracking: true, }); @@ -85,7 +86,7 @@ export class ServerService implements OnApplicationBootstrap { appVersion: SERVER_CONFIG.appVersion, osPlatform: process.platform, buildType: SERVER_CONFIG.buildType, - appType: this.getAppType(SERVER_CONFIG.buildType), + appType: ServerService.getAppType(SERVER_CONFIG.buildType), encryptionStrategies: await this.encryptionService.getAvailableEncryptionStrategies(), fixedDatabaseId: REDIS_STACK_CONFIG?.id, ...(await this.featuresConfigService.getControlInfo()), @@ -98,7 +99,7 @@ export class ServerService implements OnApplicationBootstrap { } } - getAppType(buildType: string): AppType { + static getAppType(buildType: string): AppType { switch (buildType) { case BuildType.DockerOnPremise: return AppType.Docker; diff --git a/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.ts b/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.ts index 5ced83a7c6..8642b2e152 100644 --- a/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.ts +++ b/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.ts @@ -22,7 +22,10 @@ export class SshTunnelProvider { reject(new UnableToCreateLocalServerException(e.message)); }); - detectPort(50000) + detectPort({ + hostname: '127.0.0.1', + port: 50000, + }) .then((port) => { server.listen({ host: '127.0.0.1', diff --git a/redisinsight/api/test/api/analytics/analytics.test.ts b/redisinsight/api/test/api/analytics/analytics.test.ts index 658163567f..eaa795ab3e 100644 --- a/redisinsight/api/test/api/analytics/analytics.test.ts +++ b/redisinsight/api/test/api/analytics/analytics.test.ts @@ -4,14 +4,18 @@ import { it, deps, requirements, + serverConfig, } from '../deps'; const { analytics } = deps; - describe('Analytics', () => { requirements('rte.serverType=local'); it('APPLICATION_STARTED', () => { + if(serverConfig.get('server').buildType !== 'ELECTRON') { + return + } + const appStarted = analytics.findEvent({ event: 'APPLICATION_STARTED', }) @@ -23,12 +27,13 @@ describe('Analytics', () => { const found = appStarted || appFirstStarted; if (!found) { - fail('APPLICATION_STARTED or APPLICATION_FIRST_START events were not found'); + expect.fail('APPLICATION_STARTED or APPLICATION_FIRST_START events were not found'); } - expect(found?.properties).to.have.all.keys('appVersion', 'osPlatform', 'buildType', 'controlNumber', 'controlGroup'); + expect(found?.properties).to.have.all.keys('appVersion', 'osPlatform', 'buildType', 'controlNumber', 'controlGroup', 'port'); expect(found?.properties?.appVersion).to.be.a('string'); expect(found?.properties?.osPlatform).to.be.a('string'); expect(found?.properties?.buildType).to.be.a('string'); + expect(found?.properties?.port).to.be.a('number'); }); }); diff --git a/redisinsight/api/test/api/cli/POST-databases-id-cli-uuid-send_command.test.ts b/redisinsight/api/test/api/cli/POST-databases-id-cli-uuid-send_command.test.ts index ab738e5654..c933d5cb56 100644 --- a/redisinsight/api/test/api/cli/POST-databases-id-cli-uuid-send_command.test.ts +++ b/redisinsight/api/test/api/cli/POST-databases-id-cli-uuid-send_command.test.ts @@ -11,6 +11,7 @@ import { validateApiCall, requirements, serverConfig } from '../deps'; +import { ServerService } from 'src/modules/server/server.service'; const { server, request, constants, rte, analytics } = deps; // endpoint to test @@ -98,7 +99,7 @@ describe('POST /databases/:instanceId/cli/:uuid/send-command', () => { capability: 'string', command: 'SET', outputFormat: 'TEXT', - buildType: serverConfig.get('server').buildType, + buildType: ServerService.getAppType(serverConfig.get('server').buildType), }, }); } diff --git a/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-account.test.ts b/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-account.test.ts new file mode 100644 index 0000000000..8e85751047 --- /dev/null +++ b/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-account.test.ts @@ -0,0 +1,93 @@ +import { + describe, + deps, + requirements, + Joi, + nock, getMainCheckFn, + serverConfig, +} from '../deps'; +import { mockCloudAccountInfo, mockCloudApiAccount } from 'src/__mocks__/cloud-autodiscovery'; +const { request, server, constants } = deps; + +const endpoint = () => request(server).get(`/cloud/autodiscovery/account`); + +const headers = { + 'x-cloud-api-key': constants.TEST_CLOUD_API_KEY, + 'x-cloud-api-secret': constants.TEST_CLOUD_API_SECRET_KEY, +} + +const responseSchema = Joi.object().keys({ + accountId: Joi.number().required(), + accountName: Joi.string().required(), + ownerName: Joi.string().required(), + ownerEmail: Joi.string().required(), +}).required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +const nockScope = nock(serverConfig.get('redis_cloud').url); + +describe('GET /cloud/autodiscovery/account', () => { + requirements('rte.serverType=local'); + + describe('Common', () => { + [ + { + before: () => { + nockScope.get('/') + .reply(200, { account: mockCloudApiAccount }); + }, + name: 'Should get account info', + headers, + responseSchema, + responseBody: mockCloudAccountInfo, + }, + { + before: () => { + nockScope.get('/') + .reply(403, { + response: { + status: 403, + data: { message: 'Unauthorized for this action' }, + } + }); + }, + name: 'Should throw Forbidden error when api returned 403 error', + headers, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + }, + { + before: () => { + nockScope.get('/') + .reply(401, { + response: { + status: 401, + data: '', + } + }); + }, + name: 'Should throw Forbidden error when api returns 401 error', + headers, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + }, + { + name: 'Should throw Unauthorized error when api key or secret was not provided', + headers: {}, + statusCode: 401, + responseBody: { + statusCode: 401, + error: 'Unauthorized', + message: 'Required authentication credentials were not provided', + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-subscriptions.test.ts b/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-subscriptions.test.ts new file mode 100644 index 0000000000..d891031868 --- /dev/null +++ b/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-subscriptions.test.ts @@ -0,0 +1,108 @@ +import { + describe, + deps, + requirements, + Joi, getMainCheckFn, serverConfig +} from '../deps'; +import { nock } from '../../helpers/test'; +import { + mockCloudApiSubscription, + mockCloudSubscription, + mockCloudSubscriptionFixed +} from 'src/__mocks__/cloud-autodiscovery'; +const { request, server, constants } = deps; + +const endpoint = () => request(server).get(`/cloud/autodiscovery/subscriptions`); + +const headers = { + 'x-cloud-api-key': constants.TEST_CLOUD_API_KEY, + 'x-cloud-api-secret': constants.TEST_CLOUD_API_SECRET_KEY, +} + +const responseSchema = Joi.array().items(Joi.object().keys({ + id: Joi.number().required(), + name: Joi.string().required(), + numberOfDatabases: Joi.number().required(), + status: Joi.string().required(), + provider: Joi.string(), + region: Joi.string(), + type: Joi.string(), +})).required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +const nockScope = nock(serverConfig.get('redis_cloud').url); + +describe('GET /cloud/autodiscovery/subscriptions', () => { + requirements('rte.serverType=local'); + + describe('Common', () => { + [ + { + before: () => { + nockScope + .get('/fixed/subscriptions') + .reply(200, { subscriptions: [mockCloudApiSubscription] }) + .get('/subscriptions') + .reply(200, { subscriptions: [mockCloudApiSubscription] }); + }, + headers, + name: 'Should get subscriptions list', + responseSchema, + responseBody: [mockCloudSubscriptionFixed, mockCloudSubscription], + }, + { + before: () => { + nockScope + .get('/fixed/subscriptions') + .reply(200, { subscriptions: [mockCloudApiSubscription] }) + .get('/subscriptions') + .reply(403, { + response: { + status: 403, + data: { message: 'Unauthorized for this action' }, + } + }); + }, + headers, + name: 'Should throw Forbidden error when api returned 403 error', + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + }, + { + before: () => { + nockScope + .get('/fixed/subscriptions') + .reply(401, { + response: { + status: 401, + data: '', + } + }) + .get('/subscriptions') + .reply(200, { subscriptions: [mockCloudApiSubscription] }); + }, + name: 'Should throw Forbidden error when api returned 401', + headers, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + }, + { + name: 'Should throw Unauthorized error when api key or secret was not provided', + headers: {}, + statusCode: 401, + responseBody: { + statusCode: 401, + error: 'Unauthorized', + message: 'Required authentication credentials were not provided', + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-databases.test.ts b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-databases.test.ts new file mode 100644 index 0000000000..05c0649ac5 --- /dev/null +++ b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-databases.test.ts @@ -0,0 +1,215 @@ +import { + describe, + deps, + expect, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + Joi, getMainCheckFn, serverConfig, +} from '../deps'; +import { nock } from '../../helpers/test'; +import { + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + mockCloudApiDatabase, mockCloudApiDatabaseFixed, mockCloudDatabase, mockCloudDatabaseFixed, +} from 'src/__mocks__/cloud-autodiscovery'; +const { request, server, constants } = deps; + +const endpoint = () => request(server).post(`/cloud/autodiscovery/databases`); + +const dataSchema = Joi.object({ + databases: Joi.array().items({ + databaseId: Joi.number().allow(true).required().label('.databaseId'), + subscriptionId: Joi.number().allow(true).required().label('.subscriptionId'), + subscriptionType: Joi.string().valid('fixed', 'flexible').required().label('subscriptionType'), + }).required().messages({ + 'any.required': '{#label} should not be empty', + 'array.sparse': '{#label} must be either object or array', + 'array.base': 'property {#label} must be either object or array', + }), +}).strict(); + +const validInputData = { + databases: [{ + databaseId: 1, + subscriptionId: constants.TEST_CLOUD_SUBSCRIPTION_ID, + subscriptionType: 'fixed', + }] +} + +const headers = { + 'x-cloud-api-key': constants.TEST_CLOUD_API_KEY, + 'x-cloud-api-secret': constants.TEST_CLOUD_API_SECRET_KEY, +} + +const responseSchema = Joi.array().items(Joi.object().keys({ + subscriptionId: Joi.number().required(), + subscriptionType: Joi.string().valid('fixed', 'flexible').required(), + databaseId: Joi.number().required(), + status: Joi.string().valid('fail', 'success').required(), + message: Joi.string().required(), + databaseDetails: Joi.object().required(), +})).required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +const nockScope = nock(serverConfig.get('redis_cloud').url); + +describe('POST /cloud/subscriptions/databases', () => { + requirements('rte.serverType=local'); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData, 'data', { headers }).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common mocked to localhost', () => { + requirements('rte.type=STANDALONE', '!rte.pass', '!rte.tls'); + [ + { + before: () => { + nockScope + .get(`/subscriptions/${mockAddCloudDatabaseDto.subscriptionId}/databases/${mockAddCloudDatabaseDto.databaseId}`) + .reply(200, { + ...mockCloudApiDatabase, + publicEndpoint: `${constants.TEST_REDIS_HOST}:${constants.TEST_REDIS_PORT}`, + }) + .get(`/fixed/subscriptions/${mockAddCloudDatabaseDtoFixed.subscriptionId}/databases/${mockAddCloudDatabaseDtoFixed.databaseId}`) + .reply(200, { + ...mockCloudApiDatabaseFixed, + publicEndpoint: `${constants.TEST_REDIS_HOST}:${constants.TEST_REDIS_PORT}`, + }); + }, + name: 'Should add 2 databases', + data: { + databases: [ + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + ] + }, + headers, + responseSchema, + statusCode: 201, + checkFn: ({ body }) => { + expect(body).to.deep.eq([{ + ...mockAddCloudDatabaseDto, + message: 'Added', + status: 'success', + databaseDetails: { + ...mockCloudDatabase, + publicEndpoint: `${constants.TEST_REDIS_HOST}:${constants.TEST_REDIS_PORT}`, + } + }, { + ...mockAddCloudDatabaseDtoFixed, + message: 'Added', + status: 'success', + databaseDetails: { + ...mockCloudDatabaseFixed, + publicEndpoint: `${constants.TEST_REDIS_HOST}:${constants.TEST_REDIS_PORT}`, + } + }]); + }, + }, + ].map(mainCheckFn); + }); + + describe('Common fails', async () => { + [ + { + before: () => { + nockScope + .get(`/fixed/subscriptions/${mockAddCloudDatabaseDtoFixed.subscriptionId}/databases/${mockAddCloudDatabaseDtoFixed.databaseId}`) + .reply(403, { + response: { + status: 403, + data: { message: 'Unauthorized for this action' }, + } + }); + }, + name: 'Should throw Forbidden error when api returns 403', + headers, + data: { + databases: [ + mockAddCloudDatabaseDtoFixed, + ], + }, + responseBody: [{ + ...mockAddCloudDatabaseDtoFixed, + status: 'fail', + message: 'Error fetching account details.', + error: { + statusCode: 403, + error: 'Forbidden', + message: 'Error fetching account details.', + }, + }], + }, + { + before: () => { + nockScope.get(`/subscriptions/${mockAddCloudDatabaseDto.subscriptionId}/databases/${mockAddCloudDatabaseDto.databaseId}`) + .reply(401, { + response: { + status: 401, + data: '', + } + }); + }, + name: 'Should throw Forbidden error when api returns 401', + headers, + data: { + databases: [ + mockAddCloudDatabaseDto, + ], + }, + responseBody: [{ + ...mockAddCloudDatabaseDto, + status: 'fail', + message: 'Error fetching account details.', + error: { + statusCode: 403, + error: 'Forbidden', + message: 'Error fetching account details.', + }, + }], + }, + { + before: () => { + nockScope.get(`/subscriptions/${mockAddCloudDatabaseDto.subscriptionId}/databases/${mockAddCloudDatabaseDto.databaseId}`) + .reply(404, { + response: { + status: 404, + data: 'Database was not found', + } + }); + }, + name: 'Should throw Not Found error when subscription id is not found', + headers, + data: { + databases: [ + mockAddCloudDatabaseDto, + ], + }, + responseBody: [{ + ...mockAddCloudDatabaseDto, + status: 'fail', + message: 'Not Found', + error: { + statusCode: 404, + message: 'Not Found', + }, + }], + }, + { + name: 'Should throw Unauthorized error when api key or secret was not provided', + headers: {}, + statusCode: 401, + responseBody: { + statusCode: 401, + error: 'Unauthorized', + message: 'Required authentication credentials were not provided', + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts new file mode 100644 index 0000000000..26e850b2ef --- /dev/null +++ b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts @@ -0,0 +1,184 @@ +import { + describe, + deps, + expect, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + Joi, getMainCheckFn, serverConfig, +} from '../deps'; +import { nock } from '../../helpers/test'; +import { + mockCloudApiSubscriptionDatabases, + mockCloudApiSubscriptionDatabasesFixed, + mockCloudDatabaseFromList, + mockCloudDatabaseFromListFixed, + mockGetCloudSubscriptionDatabasesDto, + mockGetCloudSubscriptionDatabasesDtoFixed, +} from 'src/__mocks__/cloud-autodiscovery'; +const { request, server, constants } = deps; + +const endpoint = () => request(server).post(`/cloud/autodiscovery/get-databases`); + +const dataSchema = Joi.object({ + subscriptions: Joi.array().items({ + subscriptionId: Joi.number().allow(true).required().label('.subscriptionId'), // todo: review transform rules + subscriptionType: Joi.string().valid('fixed', 'flexible').required().label('subscriptionType'), + }).required().messages({ + 'any.required': '{#label} should not be empty', + 'array.sparse': '{#label} must be either object or array', + 'array.base': 'property {#label} must be either object or array', + }), +}).strict(); + +const validInputData = { + subscriptions: [{ + subscriptionId: constants.TEST_CLOUD_SUBSCRIPTION_ID, + subscriptionType: 'fixed', + }] +} + +const headers = { + 'x-cloud-api-key': constants.TEST_CLOUD_API_KEY, + 'x-cloud-api-secret': constants.TEST_CLOUD_API_SECRET_KEY, +} + +const responseSchema = Joi.array().items(Joi.object().keys({ + subscriptionId: Joi.number().required(), + subscriptionType: Joi.string().valid('fixed', 'flexible').required(), + databaseId: Joi.number().required(), + name: Joi.string().required(), + publicEndpoint: Joi.string().required(), + status: Joi.string().required(), + sslClientAuthentication: Joi.boolean().required(), + modules: Joi.array().required(), + options: Joi.object().required(), + cloudDetails: Joi.object().keys({ + cloudId: Joi.number().required(), + subscriptionType: Joi.string().valid('fixed', 'flexible').required(), + planMemoryLimit: Joi.number(), + memoryLimitMeasurementUnit: Joi.string(), + }).required(), +})).required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +const nockScope = nock(serverConfig.get('redis_cloud').url); + +describe('POST /cloud/subscriptions/get-databases', () => { + requirements('rte.serverType=local'); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData, 'data', { headers }).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', async () => { + [ + { + before: () => { + nockScope.get(`/subscriptions/${mockGetCloudSubscriptionDatabasesDto.subscriptionId}/databases`) + .reply(200, mockCloudApiSubscriptionDatabases); + }, + name: 'Should get databases list inside subscription', + data: { + subscriptions: [mockGetCloudSubscriptionDatabasesDto] + }, + headers, + responseSchema, + checkFn: ({ body }) => { + expect(body).to.deep.eq([mockCloudDatabaseFromList]); + }, + }, + { + before: () => { + nockScope.get(`/fixed/subscriptions/${mockGetCloudSubscriptionDatabasesDtoFixed.subscriptionId}/databases`) + .reply(200, mockCloudApiSubscriptionDatabasesFixed); + }, + name: 'Should get databases list inside fixed subscription', + data: { + subscriptions: [mockGetCloudSubscriptionDatabasesDtoFixed] + }, + headers, + responseSchema, + checkFn: ({ body }) => { + expect(body).to.deep.eq([mockCloudDatabaseFromListFixed]); + }, + }, + { + before: () => { + nockScope.get(`/subscriptions/${mockGetCloudSubscriptionDatabasesDto.subscriptionId}/databases`) + .reply(403, { + response: { + status: 403, + data: { message: 'Unauthorized for this action' }, + } + }); + }, + name: 'Should throw Forbidden error when api returns 403', + headers, + data: { + subscriptions: [mockGetCloudSubscriptionDatabasesDto] + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + }, + { + before: () => { + nockScope.get(`/subscriptions/${mockGetCloudSubscriptionDatabasesDto.subscriptionId}/databases`) + .reply(401, { + response: { + status: 401, + data: '', + } + }); + }, + name: 'Should throw Forbidden error when api returns 401', + headers, + data: { + subscriptions: [mockGetCloudSubscriptionDatabasesDto] + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + }, + { + before: () => { + nockScope.get(`/subscriptions/${mockGetCloudSubscriptionDatabasesDto.subscriptionId}/databases`) + .reply(404, { + response: { + status: 404, + data: 'Subscription is not found', + } + }); + }, + name: 'Should throw Not Found error when subscription id is not found', + headers, + data: { + subscriptions: [mockGetCloudSubscriptionDatabasesDto] + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + }, + }, + { + name: 'Should throw Unauthorized error when api key or secret was not provided', + headers: {}, + statusCode: 401, + responseBody: { + statusCode: 401, + error: 'Unauthorized', + message: 'Required authentication credentials were not provided', + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_account.test.ts b/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_account.test.ts deleted file mode 100644 index 19e40963a4..0000000000 --- a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_account.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - describe, - it, - deps, - validateApiCall, - requirements, - generateInvalidDataTestCases, - validateInvalidDataTestCase, - Joi, -} from '../deps'; -const { request, server, constants } = deps; - -const endpoint = () => request(server).post(`/redis-enterprise/cloud/get-account`); - -const dataSchema = Joi.object({ - apiKey: Joi.string().required(), - apiSecretKey: Joi.string().required(), -}).strict(); - -const validInputData = { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, -} - -const responseSchema = Joi.object().keys({ - accountId: Joi.number().required(), - accountName: Joi.string().required(), - ownerName: Joi.string().required(), - ownerEmail: Joi.string().required(), -}).required(); - -const mainCheckFn = async (testCase) => { - it(testCase.name, async () => { - await validateApiCall({ - endpoint, - ...testCase, - }); - }); -}; - -describe('POST /redis-enterprise/cloud/get-account', () => { - requirements('rte.cloud'); - - describe('Validation', () => { - generateInvalidDataTestCases(dataSchema, validInputData).map( - validateInvalidDataTestCase(endpoint, dataSchema), - ); - }); - - describe('Common', () => { - [ - { - name: 'Should get account info', - data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - }, - responseSchema, - }, - { - name: 'Should throw Forbidden error when api key is incorrect', - data: { - apiKey: 'wrong-api-key', - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - }, - statusCode: 403, - responseBody: { - statusCode: 403, - error: 'Forbidden', - }, - - }, - { - name: 'Should throw Forbidden error when api secret key is incorrect', - data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: 'wrong-api-secret-key', - }, - statusCode: 403, - responseBody: { - statusCode: 403, - error: 'Forbidden', - }, - - }, - ].map(mainCheckFn); - }); -}); diff --git a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_databases.test.ts b/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_databases.test.ts deleted file mode 100644 index e284423543..0000000000 --- a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_databases.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { - describe, - it, - before, - deps, - validateApiCall, - requirements, - generateInvalidDataTestCases, - validateInvalidDataTestCase, - expect, - _, - Joi, -} from '../deps'; -const { request, server, constants } = deps; - -const endpoint = () => request(server).post(`/redis-enterprise/cloud/get-databases`); - -const dataSchema = Joi.object({ - apiKey: Joi.string().required(), - apiSecretKey: Joi.string().required(), - subscriptionIds: Joi.number().allow(true).required(), // todo: review transform rules -}).strict(); - -const validInputData = { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - subscriptionIds: 1 -} - -const responseSchema = Joi.array().items(Joi.object().keys({ - subscriptionId: Joi.number().required(), - databaseId: Joi.number().required(), - name: Joi.string().required(), - publicEndpoint: Joi.string().required(), - status: Joi.string().required(), - sslClientAuthentication: Joi.boolean().required(), - modules: Joi.array().required(), - options: Joi.object().required(), -})).required(); - -const mainCheckFn = async (testCase) => { - it(testCase.name, async () => { - await validateApiCall({ - endpoint, - ...testCase, - }); - }); -}; - -describe('POST /redis-enterprise/cloud/get-databases', () => { - requirements('rte.cloud'); - - describe('Validation', () => { - generateInvalidDataTestCases(dataSchema, validInputData).map( - validateInvalidDataTestCase(endpoint, dataSchema), - ); - }); - - describe('Common', async () => { - [ - { - name: 'Should get databases list inside subscription', - data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - subscriptionIds: [constants.TEST_CLOUD_SUBSCRIPTION_ID] - }, - responseSchema, - checkFn: ({ body }) => { - const database = _.find(body, { name: constants.TEST_CLOUD_DATABASE_NAME }); - expect(database.publicEndpoint).to.eql(`${constants.TEST_REDIS_HOST}:${constants.TEST_REDIS_PORT}`); - }, - }, - { - name: 'Should throw Forbidden error when api key is incorrect', - data: { - apiKey: 'wrong-api-key', - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - subscriptionIds: [constants.TEST_CLOUD_SUBSCRIPTION_ID] - }, - statusCode: 403, - responseBody: { - statusCode: 403, - error: 'Forbidden', - }, - }, - { - name: 'Should throw Forbidden error when api secret key is incorrect', - data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: 'wrong-api-secret-key', - subscriptionIds: [constants.TEST_CLOUD_SUBSCRIPTION_ID] - }, - statusCode: 403, - responseBody: { - statusCode: 403, - error: 'Forbidden', - }, - }, - { - name: 'Should throw Not Found error when subscription id is not found', - data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - subscriptionIds: [1] - }, - statusCode: 404, - responseBody: { - statusCode: 404, - error: 'Not Found', - }, - - }, - ].map(mainCheckFn); - }); -}); diff --git a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_subscriptions.test.ts b/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_subscriptions.test.ts deleted file mode 100644 index 64b6953382..0000000000 --- a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_subscriptions.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - describe, - it, - deps, - validateApiCall, - requirements, - generateInvalidDataTestCases, - validateInvalidDataTestCase, - expect, - _, - Joi, -} from '../deps'; -const { request, server, constants } = deps; - -const endpoint = () => request(server).post(`/redis-enterprise/cloud/get-subscriptions`); - -const dataSchema = Joi.object({ - apiKey: Joi.string().required(), - apiSecretKey: Joi.string().required(), -}).strict(); - -const validInputData = { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, -} - -const responseSchema = Joi.array().items(Joi.object().keys({ - id: Joi.number().required(), - name: Joi.string().required(), - numberOfDatabases: Joi.number().required(), - status: Joi.string().required(), - provider: Joi.string(), - region: Joi.string(), -})).required(); - -const mainCheckFn = async (testCase) => { - it(testCase.name, async () => { - await validateApiCall({ - endpoint, - ...testCase, - }); - }); -}; - -describe('POST /redis-enterprise/cloud/get-subscriptions', () => { - requirements('rte.cloud'); - - describe('Validation', () => { - generateInvalidDataTestCases(dataSchema, validInputData).map( - validateInvalidDataTestCase(endpoint, dataSchema), - ); - }); - - describe('Common', () => { - [ - { - name: 'Should get subscriptions list', - data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - }, - responseSchema, - checkFn: ({ body }) => { - expect(_.findIndex(body, { name: constants.TEST_CLOUD_SUBSCRIPTION_NAME })).to.gte(0); - }, - }, - { - name: 'Should throw Forbidden error when api key is incorrect', - data: { - apiKey: 'wrong-api-key', - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - }, - statusCode: 403, - responseBody: { - statusCode: 403, - error: 'Forbidden', - }, - - }, - { - name: 'Should throw Forbidden error when api secret key is incorrect', - data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: 'wrong-api-secret-key', - }, - statusCode: 403, - responseBody: { - statusCode: 403, - error: 'Forbidden', - }, - - }, - ].map(mainCheckFn); - }); -}); diff --git a/redisinsight/api/test/api/database-recommendations/WS-new-recommendations.test.ts b/redisinsight/api/test/api/database-recommendations/WS-new-recommendations.test.ts index d9b1c8d88a..94b781a245 100644 --- a/redisinsight/api/test/api/database-recommendations/WS-new-recommendations.test.ts +++ b/redisinsight/api/test/api/database-recommendations/WS-new-recommendations.test.ts @@ -50,9 +50,7 @@ describe('WS new recommendations', () => { expect(recommendationsResponse.recommendations[0].name).to.eq('bigSets'); expect(recommendationsResponse.recommendations[0].databaseId).to.eq(constants.TEST_INSTANCE_ID); expect(recommendationsResponse.recommendations[0].read).to.eq(false); - // expect(recommendationsResponse.recommendations[0].disabled).to.eq(false); - // todo: investigate if it should return false vs undefined - expect(recommendationsResponse.recommendations[0].disabled).to.eq(undefined); + expect(recommendationsResponse.recommendations[0].disabled).to.eq(false); expect(recommendationsResponse.totalUnread).to.eq(1); }); }); diff --git a/redisinsight/api/test/api/database/GET-databases.test.ts b/redisinsight/api/test/api/database/GET-databases.test.ts index 1d777560ef..84582f3e66 100644 --- a/redisinsight/api/test/api/database/GET-databases.test.ts +++ b/redisinsight/api/test/api/database/GET-databases.test.ts @@ -17,6 +17,7 @@ const responseSchema = Joi.array().items(Joi.object().keys({ compressor: Joi.string().valid('NONE', 'LZ4', 'GZIP', 'ZSTD', 'SNAPPY').allow(null), connectionType: Joi.string().valid('STANDALONE', 'SENTINEL', 'CLUSTER', 'NOT CONNECTED').required(), lastConnection: Joi.string().isoDate().allow(null).required(), + version: Joi.string().allow(null).required(), modules: Joi.array().items(Joi.object().keys({ name: Joi.string().required(), version: Joi.number().integer().required(), diff --git a/redisinsight/api/test/api/database/PATCH-databases-id.test.ts b/redisinsight/api/test/api/database/PATCH-databases-id.test.ts index ba65ce9781..535aa13171 100644 --- a/redisinsight/api/test/api/database/PATCH-databases-id.test.ts +++ b/redisinsight/api/test/api/database/PATCH-databases-id.test.ts @@ -188,7 +188,7 @@ describe(`PATCH /databases/:id`, () => { after: async () => { newDatabase = await localDb.getInstanceById(constants.TEST_INSTANCE_ID_3); expect(newDatabase).to.contain({ - ..._.omit(oldDatabase, ['modules', 'provider', 'lastConnection', 'new', 'timeout', 'compressor']), + ..._.omit(oldDatabase, ['modules', 'provider', 'lastConnection', 'new', 'timeout', 'compressor', 'version']), host: constants.TEST_REDIS_HOST, port: constants.TEST_REDIS_PORT, }); diff --git a/redisinsight/api/test/api/database/POST-databases.test.ts b/redisinsight/api/test/api/database/POST-databases.test.ts index ac8cc17209..58ea2231f0 100644 --- a/redisinsight/api/test/api/database/POST-databases.test.ts +++ b/redisinsight/api/test/api/database/POST-databases.test.ts @@ -10,6 +10,7 @@ import { generateInvalidDataTestCases, validateInvalidDataTestCase, getMainCheckFn, serverConfig, } from '../deps'; import { databaseSchema } from './constants'; +import { ServerService } from 'src/modules/server/server.service'; const { rte, request, server, localDb, constants, analytics } = deps; const endpoint = () => request(server).post(`/${constants.API.DATABASES}`); @@ -167,7 +168,7 @@ describe('POST /databases', () => { // RedisJSON: { loaded: false }, // RedisTimeSeries: { loaded: false }, // customModules: [], - buildType: serverConfig.get('server').buildType, + buildType: ServerService.getAppType(serverConfig.get('server').buildType), }, }); }, diff --git a/redisinsight/api/test/api/database/PUT-databases-id.test.ts b/redisinsight/api/test/api/database/PUT-databases-id.test.ts index 49233d5876..d447de553a 100644 --- a/redisinsight/api/test/api/database/PUT-databases-id.test.ts +++ b/redisinsight/api/test/api/database/PUT-databases-id.test.ts @@ -174,7 +174,7 @@ describe(`PUT /databases/:id`, () => { after: async () => { newDatabase = await localDb.getInstanceById(constants.TEST_INSTANCE_ID_3); expect(newDatabase).to.contain({ - ..._.omit(oldDatabase, ['modules', 'provider', 'lastConnection', 'new', 'ssh', 'timeout']), + ..._.omit(oldDatabase, ['modules', 'provider', 'lastConnection', 'new', 'ssh', 'timeout', 'version']), host: constants.TEST_REDIS_HOST, port: constants.TEST_REDIS_PORT, }); diff --git a/redisinsight/api/test/api/database/constants.ts b/redisinsight/api/test/api/database/constants.ts index f0a4962f43..549aa5c7b5 100644 --- a/redisinsight/api/test/api/database/constants.ts +++ b/redisinsight/api/test/api/database/constants.ts @@ -45,4 +45,11 @@ export const databaseSchema = Joi.object().keys({ privateKey: Joi.string().allow(null), passphrase: Joi.string().allow(null), }).allow(null), + version: Joi.string().allow(null), + cloudDetails: Joi.object().keys({ + cloudId: Joi.number().required(), + subscriptionType: Joi.string().valid('fixed', 'flexible').required(), + planMemoryLimit: Joi.number(), + memoryLimitMeasurementUnit: Joi.string(), + }).allow(null), }); diff --git a/redisinsight/api/test/api/deps.ts b/redisinsight/api/test/api/deps.ts index 032e6e7892..88293c6db4 100644 --- a/redisinsight/api/test/api/deps.ts +++ b/redisinsight/api/test/api/deps.ts @@ -12,6 +12,12 @@ import { testEnv } from '../helpers/test'; import * as redis from '../helpers/redis'; import { initCloudDatabase } from '../helpers/cloud'; +// Just dummy jest module implementation to be able to use common mocked models in UTests and ITests +global['jest'] = { + // @ts-ignore + fn: () => {} +}; + /** * Initialize dependencies */ diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index dc2033c459..87c0a22f18 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -103,10 +103,10 @@ export const constants = { // cloud TEST_CLOUD_RTE: process.env.TEST_CLOUD_RTE, TEST_CLOUD_API: process.env.REDIS_CLOUD_URL || process.env.TEST_CLOUD_API || 'https://api.qa.redislabs.com/v1', - TEST_CLOUD_API_KEY: process.env.TEST_CLOUD_API_KEY, - TEST_CLOUD_API_SECRET_KEY: process.env.TEST_CLOUD_API_SECRET_KEY, + TEST_CLOUD_API_KEY: process.env.TEST_CLOUD_API_KEY || 'TEST_CLOUD_API_KEY', + TEST_CLOUD_API_SECRET_KEY: process.env.TEST_CLOUD_API_SECRET_KEY || 'TEST_CLOUD_API_SECRET_KEY', TEST_CLOUD_SUBSCRIPTION_NAME: process.env.TEST_CLOUD_SUBSCRIPTION_NAME || 'ITests', - TEST_CLOUD_SUBSCRIPTION_ID: process.env.TEST_CLOUD_SUBSCRIPTION_ID, + TEST_CLOUD_SUBSCRIPTION_ID: process.env.TEST_CLOUD_SUBSCRIPTION_ID || 1, TEST_CLOUD_DATABASE_NAME: process.env.TEST_CLOUD_DATABASE_NAME || 'ITests-db', STANDALONE: 'STANDALONE', @@ -496,7 +496,7 @@ export const constants = { TEST_RECOMMENDATION_NAME_1: RECOMMENDATION_NAMES.BIG_SETS, TEST_RECOMMENDATION_NAME_2: RECOMMENDATION_NAMES.BIG_STRINGS, - TEST_RECOMMENDATION_NAME_3: RECOMMENDATION_NAMES.BIG_STRINGS, + TEST_RECOMMENDATION_NAME_3: RECOMMENDATION_NAMES.AVOID_LOGICAL_DATABASES, TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION: { name: RECOMMENDATION_NAMES.LUA_SCRIPT, diff --git a/redisinsight/api/test/helpers/local-db.ts b/redisinsight/api/test/helpers/local-db.ts index 051987af27..0b59383135 100644 --- a/redisinsight/api/test/helpers/local-db.ts +++ b/redisinsight/api/test/helpers/local-db.ts @@ -19,6 +19,7 @@ export const repositories = { CUSTOM_TUTORIAL: 'CustomTutorialEntity', FEATURES_CONFIG: 'FeaturesConfigEntity', FEATURE: 'FeatureEntity', + CLOUD_DATABASE_DETAILS: 'CloudDatabaseDetailsEntity', } let localDbConnection; @@ -403,6 +404,7 @@ export const createDatabaseInstances = async () => { connectionType: 'STANDALONE', ...instance, modules: '[]', + version: '7.0', }); } } diff --git a/redisinsight/api/test/helpers/test.ts b/redisinsight/api/test/helpers/test.ts index 5d8de66edc..62969a6f10 100644 --- a/redisinsight/api/test/helpers/test.ts +++ b/redisinsight/api/test/helpers/test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as fsExtra from 'fs-extra'; import * as chai from 'chai'; +import * as nock from 'nock'; import * as Joi from 'joi'; import * as AdmZip from 'adm-zip'; import * as diff from 'object-diff'; @@ -13,7 +14,7 @@ import { cloneDeep, isMatch, isObject, set, isArray } from 'lodash'; import { generateInvalidDataArray } from './test/dataGenerator'; import serverConfig from 'src/utils/config'; -export { _, path, fs, fsExtra, AdmZip, serverConfig, axios } +export { _, path, fs, fsExtra, AdmZip, serverConfig, axios, nock } export const expect = chai.expect; export const testEnv: Record = {}; export { Joi, describe, it, before, after, beforeEach }; @@ -25,6 +26,7 @@ interface ITestCaseInput { endpoint: Function; // function that returns prepared supertest with url data?: any; attach?: any[]; + headers?: Record; fields?: [string, string][]; query?: any; statusCode?: number; @@ -42,6 +44,7 @@ interface ITestCaseInput { export const validateApiCall = async function ({ endpoint, data, + headers, attach, fields, query, @@ -57,6 +60,10 @@ export const validateApiCall = async function ({ request.send(typeof data === 'function' ? data() : data); } + if (headers) { + request.set(headers); + } + if (attach) { request.attach(...attach); } @@ -185,16 +192,19 @@ const badRequestCheckFn = (schema, data) => { * @param schema * @param validData * @param target + * @param extra */ export const generateInvalidDataTestCases = ( schema, validData, target = 'data', + extra: any = {}, ) => { return generateInvalidDataArray(schema).map(({ path, value }) => { return { name: `Validation error when ${target}: ${path.join('.')} = "${value}"`, [target]: path?.length ? set(cloneDeep(validData), path, value) : value, + ...extra, }; }); }; diff --git a/redisinsight/api/test/test-runs/cloud-st/docker-compose.yml b/redisinsight/api/test/test-runs/cloud-st/docker-compose.yml index 7dca6eca63..98d81a1ab0 100644 --- a/redisinsight/api/test/test-runs/cloud-st/docker-compose.yml +++ b/redisinsight/api/test/test-runs/cloud-st/docker-compose.yml @@ -8,5 +8,5 @@ services: TEST_CLOUD_API_KEY: ${TEST_CLOUD_API_KEY} TEST_CLOUD_API_SECRET_KEY: ${TEST_CLOUD_API_SECRET_KEY} redis: - image: node:16.15.1-alpine + image: node:18.15.0-alpine entrypoint: [ "echo", "Dummy Service" ] diff --git a/redisinsight/api/test/test-runs/local.build.yml b/redisinsight/api/test/test-runs/local.build.yml index a6612dc1e0..93a5370668 100644 --- a/redisinsight/api/test/test-runs/local.build.yml +++ b/redisinsight/api/test/test-runs/local.build.yml @@ -25,7 +25,7 @@ services: # dummy service to prevent docker validation errors app: - image: node:16.15.1-alpine + image: node:18.15.0-alpine networks: default: diff --git a/redisinsight/api/test/test-runs/test.Dockerfile b/redisinsight/api/test/test-runs/test.Dockerfile index d4e4c509e2..0d1bd6f837 100644 --- a/redisinsight/api/test/test-runs/test.Dockerfile +++ b/redisinsight/api/test/test-runs/test.Dockerfile @@ -1,4 +1,4 @@ -FROM node:16.15.1-alpine as test +FROM node:18.15.0-alpine as test RUN apk update && apk add bash libsecret dbus-x11 gnome-keyring RUN dbus-uuidgen > /var/lib/dbus/machine-id diff --git a/redisinsight/api/yarn.lock b/redisinsight/api/yarn.lock index c5c847ae11..9ebba885cd 100644 --- a/redisinsight/api/yarn.lock +++ b/redisinsight/api/yarn.lock @@ -1750,7 +1750,7 @@ arrify@^1.0.0: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA== -asn1@^0.2.4: +asn1@^0.2.6: version "0.2.6" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== @@ -2612,10 +2612,10 @@ cosmiconfig@^7.0.1: path-type "^4.0.0" yaml "^1.10.0" -cpu-features@~0.0.4: - version "0.0.7" - resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.7.tgz#81ba93e1d0a729fd25132a54c3ff689c37b542f7" - integrity sha512-fjzFmsUKKCrC9GrM1eQTvQx18e+kjXFzjRLvJPNEDjk31+bJ6ZiV6uchv/hzbzXVIgbWdrEyyX1IFKwse65+8w== +cpu-features@~0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.8.tgz#a2d464b023b8ad09004c8cdca23b33f192f63546" + integrity sha512-BbHBvtYhUhksqTjr6bhNOjGgMnhwhGTQmOoZGD+K7BCaQDCuZl/Ve1ZxUSMRwVC4D/rkCPQ2MAIeYzrWyK7eEg== dependencies: buildcheck "~0.0.6" nan "^2.17.0" @@ -5904,7 +5904,7 @@ mz@^2.4.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nan@^2.16.0, nan@^2.17.0: +nan@^2.17.0: version "2.17.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== @@ -7480,10 +7480,10 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -sqlite3@^5.0.11: - version "5.0.11" - resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-5.0.11.tgz#102c835d70be66da9d95a383fd6ea084a082ef7f" - integrity sha512-4akFOr7u9lJEeAWLJxmwiV43DJcGV7w3ab7SjQFAFaTVyknY3rZjvXTKIVtWqUoY4xwhjwoHKYs2HDW2SoHVsA== +sqlite3@5.1.6: + version "5.1.6" + resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-5.1.6.tgz#1d4fbc90fe4fbd51e952e0a90fd8f6c2b9098e97" + integrity sha512-olYkWoKFVNSSSQNvxVUfjiVbz3YtBwTJj+mfV5zpHmqW3sELx2Cf4QCdirMelhM5Zh+KDVaKgQHqCxrqiWHybw== dependencies: "@mapbox/node-pre-gyp" "^1.0.0" node-addon-api "^4.2.0" @@ -7491,16 +7491,16 @@ sqlite3@^5.0.11: optionalDependencies: node-gyp "8.x" -ssh2@^1.11.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.11.0.tgz#ce60186216971e12f6deb553dcf82322498fe2e4" - integrity sha512-nfg0wZWGSsfUe/IBJkXVll3PEZ//YH2guww+mP88gTpuSU4FtZN7zu9JoeTGOyCNx2dTDtT9fOpWwlzyj4uOOw== +ssh2@^1.14.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.14.0.tgz#8f68440e1b768b66942c9e4e4620b2725b3555bb" + integrity sha512-AqzD1UCqit8tbOKoj6ztDDi1ffJZ2rV2SwlgrVVrHPkV5vWqGJOVp5pmtj18PunkPJAuKQsnInyKV+/Nb2bUnA== dependencies: - asn1 "^0.2.4" + asn1 "^0.2.6" bcrypt-pbkdf "^1.0.2" optionalDependencies: - cpu-features "~0.0.4" - nan "^2.16.0" + cpu-features "~0.0.8" + nan "^2.17.0" ssri@^8.0.0, ssri@^8.0.1: version "8.0.1" diff --git a/redisinsight/desktop/app.ts b/redisinsight/desktop/app.ts new file mode 100644 index 0000000000..bc104f3d4c --- /dev/null +++ b/redisinsight/desktop/app.ts @@ -0,0 +1,53 @@ +/* eslint global-require: off, no-console: off */ +import { app, nativeTheme } from 'electron' + +import { + initElectronHandlers, + initLogging, + WindowType, + windowFactory, + AboutPanelOptions, + checkForUpdate, + installExtensions, + initTray, + initAutoUpdaterHandlers, + launchApiServer +} from 'desktopSrc/lib' +import { wrapErrorMessageSensitiveData } from 'desktopSrc/utils' +import { configMain as config } from 'desktopSrc/config' + +if (!config.isProduction) { + const sourceMapSupport = require('source-map-support') + sourceMapSupport.install() +} + +const init = async () => { + await launchApiServer() + initLogging() + initElectronHandlers() + initAutoUpdaterHandlers() + initTray() + + nativeTheme.themeSource = config.themeSource + + checkForUpdate(process.env.MANUAL_UPGRADES_LINK || process.env.UPGRADES_LINK) + + app.setName(config.name) + app.setAppUserModelId(config.name) + if (process.platform !== 'darwin') { + app.setAboutPanelOptions(AboutPanelOptions) + } + + await installExtensions() + + try { + await app.whenReady() + const splashWindow = await windowFactory(WindowType.Splash) + await windowFactory(WindowType.Main, splashWindow) + } catch (_err) { + const error = _err as Error + console.log(wrapErrorMessageSensitiveData(error)) + } +} + +export default init diff --git a/redisinsight/desktop/config.json b/redisinsight/desktop/config.json new file mode 100644 index 0000000000..24b1e83010 --- /dev/null +++ b/redisinsight/desktop/config.json @@ -0,0 +1,41 @@ +{ + "build": "release", + "defaultPort": 5530, + "host": "localhost", + "debug": false, + "themeSource": "dark", + "appName": "RedisInsight", + "mainWindow": { + "show": false, + "width": 1300, + "height": 860, + "minHeight": 680, + "minWidth": 960, + "webPreferences": { + "contextIsolation": true, + "nodeIntegration": false, + "nodeIntegrationInWorker": false, + "webSecurity": true, + "spellcheck": true, + "allowRunningInsecureContent": false, + "scrollBounce": true + } + }, + "splashWindow": { + "width": 500, + "height": 200, + "transparent": true, + "frame": false, + "resizable": false, + "alwaysOnTop": true, + "title": "RedisInsight", + "webPreferences": { + "nodeIntegration": false, + "contextIsolation": true + } + }, + "crashReporter": false, + "updater": { + "downloadUrl": "" + } +} diff --git a/redisinsight/desktop/index.ejs b/redisinsight/desktop/index.ejs new file mode 100644 index 0000000000..bb61a1b52d --- /dev/null +++ b/redisinsight/desktop/index.ejs @@ -0,0 +1,10 @@ + + + + + RedisInsight + + +
+ + diff --git a/redisinsight/desktop/index.ts b/redisinsight/desktop/index.ts new file mode 100644 index 0000000000..db5b0de850 --- /dev/null +++ b/redisinsight/desktop/index.ts @@ -0,0 +1,3 @@ +import app from './app' + +app() diff --git a/redisinsight/desktop/preload.ts b/redisinsight/desktop/preload.ts new file mode 100644 index 0000000000..b3d9e3cb24 --- /dev/null +++ b/redisinsight/desktop/preload.ts @@ -0,0 +1,28 @@ +import { contextBridge, ipcRenderer } from 'electron' +import { configRenderer as config } from 'desktopSrc/config/configRenderer' +import { IpcEvent } from 'uiSrc/electron/constants' +import { WindowApp } from 'uiSrc/types' + +const ipcHandler = { + invoke: (channel: IpcEvent, data?: any) => { + // whitelist channels + if (Object.values(IpcEvent).includes(channel)) { + return ipcRenderer.invoke(channel, data) + } + + return new Error('channel is not allowed') + } +} + +contextBridge.exposeInMainWorld('app', { + // Send data from main to render + sendWindowId: ((windowId: any) => { + ipcRenderer.on('sendWindowId', windowId) + }), + ipc: ipcHandler, + config: { + apiPort: config.apiPort + } +} as WindowApp) + +export type IPCHandler = typeof ipcHandler diff --git a/redisinsight/splash.html b/redisinsight/desktop/splash.ejs similarity index 99% rename from redisinsight/splash.html rename to redisinsight/desktop/splash.ejs index 01d7e391aa..1a2a3720b2 100644 --- a/redisinsight/splash.html +++ b/redisinsight/desktop/splash.ejs @@ -20,7 +20,8 @@ } .copyright { - color: #B5B6C0 + color: #B5B6C0; + font-size: 11px; } .container { @@ -79,11 +80,9 @@ - - -
- - - diff --git a/redisinsight/main.dev.ts b/redisinsight/main.dev.ts deleted file mode 100644 index 2f2680e2ea..0000000000 --- a/redisinsight/main.dev.ts +++ /dev/null @@ -1,426 +0,0 @@ -/* eslint global-require: off, no-console: off */ - -/** - * This module executes inside of electron's main process. You can start - * electron renderer process from here and communicate with the other processes - * through IPC. - * - * When running `yarn build` or `yarn build-main`, this file is compiled to - * `../ui/main.prod.js` using webpack. This gives us some performance wins. - */ -import 'core-js/stable'; -import 'regenerator-runtime/runtime'; -import path from 'path'; -import { - app, - BrowserWindow, - nativeTheme, - shell, - dialog, - ipcMain, - Tray, -} from 'electron'; -import { autoUpdater, UpdateDownloadedEvent } from 'electron-updater'; -import log from 'electron-log'; -import installExtension, { - REDUX_DEVTOOLS, - REACT_DEVELOPER_TOOLS, -} from 'electron-devtools-installer'; -import Store from 'electron-store'; -import detectPort from 'detect-port'; -import contextMenu from 'electron-context-menu'; -// eslint-disable-next-line import/no-cycle -import MenuBuilder from './menu'; -import AboutPanelOptions from './about-panel'; -// eslint-disable-next-line import/no-cycle -import TrayBuilder from './tray'; -import server from './api/dist/src/main'; -import { ElectronStorageItem, IpcEvent } from './ui/src/electron/constants'; - -if (process.env.NODE_ENV !== 'production') { - log.transports.file.getFile().clear(); -} - -log.info('App starting.....'); - -// Replacing sensitive data inside error message -// todo: split main.ts file and make proper structure -const wrapErrorMessageSensitiveData = (e: Error) => { - const regexp = /(\/[^\s]*\/)|(\\[^\s]*\\)/ig; - e.message = e.message.replace(regexp, (_match, unixPath, winPath): string => { - if (unixPath) { - return '*****/'; - } - if (winPath) { - return '*****\\'; - } - - return _match; - }); - - return e; -}; - -export default class AppUpdater { - constructor(url: string = '') { - log.info('AppUpdater initialization'); - log.transports.file.level = 'info'; - - try { - autoUpdater.setFeedURL({ - provider: 'generic', - url, - }); - } catch (error) { - log.error(wrapErrorMessageSensitiveData(error)); - } - - autoUpdater.checkForUpdatesAndNotify(); - autoUpdater.autoDownload = true; - autoUpdater.autoInstallOnAppQuit = true; - } -} - -if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line import/no-extraneous-dependencies - const sourceMapSupport = require('source-map-support'); - sourceMapSupport.install(); -} - -const installExtensions = async () => { - const extensions = [REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS]; - const forceDownload = !!process.env.UPGRADE_EXTENSIONS; - - return installExtension(extensions, { - forceDownload, - loadExtensionOptions: { allowFileAccess: true }, - }) - .then((name) => console.log(`Added Extension: ${name}`)) - .catch((err) => console.log('An error occurred: ', wrapErrorMessageSensitiveData(err).toString())); -}; - -let store: Store; -let tray: TrayBuilder; -let trayInstance: Tray; -let isQuiting = false; - -export const getDisplayAppInTrayValue = (): boolean => { - if (process.platform === 'linux') { - return false; - } - return !!store?.get(ElectronStorageItem.isDisplayAppInTray); -}; - -/** - * Backend part... - */ -const port = 5001; - -let backendGracefulShutdown: Function; -const launchApiServer = async () => { - try { - const detectPortConst = await detectPort(port); - process.env.API_PORT = detectPortConst?.toString(); - log.info('Available port:', detectPortConst); - backendGracefulShutdown = await server(); - } catch (error) { - log.error('Catch server error:', wrapErrorMessageSensitiveData(error)); - } -}; - -const bootstrap = async () => { - await launchApiServer(); - nativeTheme.themeSource = 'dark'; - - store = new Store(); - - if (getDisplayAppInTrayValue()) { - tray = new TrayBuilder(); - trayInstance = tray.buildTray(); - } - - const upgradeUrl = process.env.MANUAL_UPGRADES_LINK || process.env.UPGRADES_LINK; - - if (upgradeUrl && !process.mas) { - new AppUpdater(upgradeUrl); - } - - app.setName('RedisInsight-v2'); - app.setAppUserModelId('RedisInsight-v2'); - if (process.platform !== 'darwin') { - app.setAboutPanelOptions(AboutPanelOptions); - } - - if (process.env.NODE_ENV !== 'production') { - await installExtensions(); - } -}; - -export const windows = new Set(); - -const getAssetPath = (...paths: string[]): string => { - const RESOURCES_PATH = app.isPackaged - ? path.join(process.resourcesPath, 'resources') - : path.join(__dirname, '../resources'); - - return path.join(RESOURCES_PATH, ...paths); -}; - -const titleSplash = 'RedisInsight'; -export const createSplashScreen = async () => { - const splash = new BrowserWindow({ - width: 500, - height: 200, - transparent: true, - frame: false, - resizable: false, - alwaysOnTop: true, - title: titleSplash, - icon: getAssetPath('icon.png'), - webPreferences: { - nodeIntegration: true, - contextIsolation: false, - }, - }); - - splash.loadURL(`file://${__dirname}/splash.html`); - - return splash; -}; - -export const createWindow = async (splash: BrowserWindow | null = null) => { - let x; - let y; - const currentWindow = BrowserWindow.getFocusedWindow(); - - if (currentWindow && currentWindow?.getTitle() !== titleSplash) { - const [currentWindowX, currentWindowY] = currentWindow.getPosition(); - x = currentWindowX + 24; - y = currentWindowY + 24; - } - let newWindow: BrowserWindow | null = new BrowserWindow({ - x, - y, - show: false, - width: 1300, - height: 860, - minHeight: 680, - minWidth: 960, - // frame: process.platform === 'darwin', - // titleBarStyle: 'hidden', - icon: getAssetPath('icon.png'), - webPreferences: { - nodeIntegration: true, - nodeIntegrationInWorker: true, - webSecurity: true, - contextIsolation: false, - spellcheck: true, - allowRunningInsecureContent: false, - scrollBounce: true, - }, - }); - - newWindow.loadURL(`file://${__dirname}/index.html`); - - newWindow.webContents.on('did-finish-load', () => { - if (!newWindow) { - throw new Error('"newWindow" is not defined'); - } - - const zoomFactor = store?.get(ElectronStorageItem.zoomFactor) as number ?? null; - if (zoomFactor) { - newWindow?.webContents.setZoomFactor(zoomFactor); - } - - if (!trayInstance?.isDestroyed()) { - tray?.updateTooltip(newWindow.webContents.getTitle()); - } - - if (process.env.START_MINIMIZED) { - newWindow.minimize(); - } else { - newWindow?.show(); - newWindow?.focus(); - splash?.destroy(); - } - }); - - newWindow.on('page-title-updated', () => { - if (newWindow && !trayInstance?.isDestroyed()) { - tray?.updateTooltip(newWindow.webContents.getTitle()); - tray?.buildContextMenu(); - } - }); - - newWindow.on('close', (event) => { - if (!isQuiting && getDisplayAppInTrayValue() && windows.size === 1) { - event.preventDefault(); - newWindow?.hide(); - app.dock?.hide(); - } - }); - - newWindow.on('closed', () => { - if (newWindow) { - windows.delete(newWindow); - newWindow = null; - } - - if (!trayInstance?.isDestroyed()) { - tray?.buildContextMenu(); - } - }); - - newWindow.on('focus', () => { - if (newWindow) { - const menuBuilder = new MenuBuilder(newWindow); - menuBuilder.buildMenu(); - - if (!trayInstance?.isDestroyed()) { - tray?.updateTooltip(newWindow.webContents.getTitle()); - } - } - }); - - // Open urls in the user's browser - newWindow.webContents.on('new-window', (event, url) => { - event.preventDefault(); - shell.openExternal(url); - }); - - // event newWindow.webContents.on('context-menu', ...) - contextMenu({ window: newWindow, showInspectElement: true }); - - windows.add(newWindow); - if (!trayInstance?.isDestroyed()) { - tray?.buildContextMenu(); - tray?.updateTooltip(newWindow.webContents.getTitle()); - } - - return newWindow; -}; - -export const getWindows = () => windows; - -export const updateDisplayAppInTray = (value: boolean) => { - store?.set(ElectronStorageItem.isDisplayAppInTray, value); - if (!value) { - trayInstance?.destroy(); - return; - } - tray = new TrayBuilder(); - trayInstance = tray.buildTray(); - - const currentWindow = BrowserWindow.getFocusedWindow(); - if (currentWindow) { - tray.updateTooltip(currentWindow.webContents.getTitle()); - } -}; - -export const setToQuiting = () => { - isQuiting = true; -}; - -export const setValueToStore = (key: ElectronStorageItem, value: any) => { - store?.set(key, value); -}; - -/** - * Add event listeners... - */ - -app.on('window-all-closed', () => { - log.info('window-all-closed'); - // Respect the OSX convention of having the application in memory even - // after all windows have been closed - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -app.on('continue-activity-error', (event, type, error) => { - log.info('event', event); - log.info('type', type); - log.info('error', error); - // Respect the OSX convention of having the application in memory even - // after all windows have been closed - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -app.whenReady() - .then(bootstrap) - .then(createSplashScreen) - .then(createWindow) - .catch((e) => console.log(wrapErrorMessageSensitiveData(e))); - -app.on('activate', () => { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (windows.size === 0) createWindow(); -}); - -function sendStatusToWindow(text: string) { - log.info(text); - // newWindow?.webContents.send('message', text); -} - -autoUpdater.on('checking-for-update', () => { - sendStatusToWindow('Checking for update...'); -}); -autoUpdater.on('update-available', () => { - sendStatusToWindow('Update available.'); - store?.set(ElectronStorageItem.isUpdateAvailable, true); -}); -autoUpdater.on('update-not-available', () => { - sendStatusToWindow('Update not available.'); - store?.set(ElectronStorageItem.isUpdateAvailable, false); -}); -autoUpdater.on('error', (err: Error) => { - sendStatusToWindow(`Error in auto-updater. ${wrapErrorMessageSensitiveData(err)}`); -}); -autoUpdater.on('download-progress', (progressObj) => { - let logMessage = `Download speed: ${progressObj.bytesPerSecond}`; - logMessage += ` - Downloaded ${progressObj.percent}%`; - logMessage += ` (${progressObj.transferred}/${progressObj.total})`; - sendStatusToWindow(logMessage); -}); -autoUpdater.on('update-downloaded', (info: UpdateDownloadedEvent) => { - sendStatusToWindow('Update downloaded'); - log.info('releaseNotes', info.releaseNotes); - log.info('releaseDate', info.releaseDate); - log.info('releaseName', info.releaseName); - log.info('version', info.version); - log.info('files', info.files); - - // set updateDownloaded to electron storage for Telemetry send event APPLICATION_UPDATED - store?.set(ElectronStorageItem.updateDownloaded, true); - store?.set(ElectronStorageItem.updateDownloadedForTelemetry, true); - store?.set(ElectronStorageItem.updateDownloadedVersion, info.version); - store?.set(ElectronStorageItem.updatePreviousVersion, app.getVersion()); -}); - -app.on('certificate-error', (event, _webContents, _url, _error, _certificate, callback) => { - // Skip error due to self-signed certificate - event.preventDefault(); - callback(true); -}); - -app.on('quit', () => { - try { - backendGracefulShutdown?.(); - } catch (e) { - // ignore any error - } -}); -// ipc events -ipcMain.handle(IpcEvent.getAppVersion, () => app?.getVersion()); - -ipcMain.handle(IpcEvent.getStoreValue, (_event, key) => store?.get(key)); - -ipcMain.handle(IpcEvent.deleteStoreValue, (_event, key) => store?.delete(key)); - -dialog.showErrorBox = (title: string, content: string) => { - log.error('Dialog shows error:', `\n${title}\n${content}`); -}; diff --git a/redisinsight/main.prod.js.LICENSE.txt b/redisinsight/main.prod.js.LICENSE.txt deleted file mode 100644 index 969b42efcf..0000000000 --- a/redisinsight/main.prod.js.LICENSE.txt +++ /dev/null @@ -1,327 +0,0 @@ -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ - -/*! - * FileStreamRotator - * Copyright(c) 2012-2017 Holiday Extras. - * Copyright(c) 2017 Roger C. - * MIT Licensed - */ - -/*! - * accepts - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * body-parser - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2014-2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * body-parser - * Copyright(c) 2014-2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * bytes - * Copyright(c) 2012-2014 TJ Holowaychuk - * Copyright(c) 2015 Jed Watson - * MIT Licensed - */ - -/*! - * content-disposition - * Copyright(c) 2014-2017 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * content-type - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * cookie - * Copyright(c) 2012-2014 Roman Shtylman - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * depd - * Copyright(c) 2014 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * depd - * Copyright(c) 2014-2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * depd - * Copyright(c) 2014-2017 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * depd - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * destroy - * Copyright(c) 2014 Jonathan Ong - * MIT Licensed - */ - -/*! - * ee-first - * Copyright(c) 2014 Jonathan Ong - * MIT Licensed - */ - -/*! - * encodeurl - * Copyright(c) 2016 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * escape-html - * Copyright(c) 2012-2013 TJ Holowaychuk - * Copyright(c) 2015 Andreas Lubbe - * Copyright(c) 2015 Tiancheng "Timothy" Gu - * MIT Licensed - */ - -/*! - * etag - * Copyright(c) 2014-2016 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * express - * Copyright(c) 2009-2013 TJ Holowaychuk - * Copyright(c) 2013 Roman Shtylman - * Copyright(c) 2014-2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * express - * Copyright(c) 2009-2013 TJ Holowaychuk - * Copyright(c) 2014-2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * finalhandler - * Copyright(c) 2014-2017 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * forwarded - * Copyright(c) 2014-2017 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * fresh - * Copyright(c) 2012 TJ Holowaychuk - * Copyright(c) 2016-2017 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * http-errors - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2016 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * media-typer - * Copyright(c) 2014 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * merge-descriptors - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * methods - * Copyright(c) 2013-2014 TJ Holowaychuk - * Copyright(c) 2015-2016 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * mime-db - * Copyright(c) 2014 Jonathan Ong - * MIT Licensed - */ - -/*! - * mime-types - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * negotiator - * Copyright(c) 2012 Federico Romero - * Copyright(c) 2012-2014 Isaac Z. Schlueter - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * on-finished - * Copyright(c) 2013 Jonathan Ong - * Copyright(c) 2014 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * parseurl - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2014-2017 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * proxy-addr - * Copyright(c) 2014-2016 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * range-parser - * Copyright(c) 2012-2014 TJ Holowaychuk - * Copyright(c) 2015-2016 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * raw-body - * Copyright(c) 2013-2014 Jonathan Ong - * Copyright(c) 2014-2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * send - * Copyright(c) 2012 TJ Holowaychuk - * Copyright(c) 2014-2016 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * serve-static - * Copyright(c) 2010 Sencha Inc. - * Copyright(c) 2011 TJ Holowaychuk - * Copyright(c) 2014-2016 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * statuses - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2016 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * toidentifier - * Copyright(c) 2016 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * type-is - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2014-2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * unpipe - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - * vary - * Copyright(c) 2014-2017 Douglas Christopher Wilson - * MIT Licensed - */ - -/*! - */ - -/*! ***************************************************************************** -Copyright (C) Microsoft. All rights reserved. -Licensed under the Apache License, Version 2.0 (the "License"); you may not use -this file except in compliance with the License. You may obtain a copy of the -License at http://www.apache.org/licenses/LICENSE-2.0 - -THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED -WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, -MERCHANTABLITY OR NON-INFRINGEMENT. - -See the Apache Version 2.0 License for specific language governing permissions -and limitations under the License. -***************************************************************************** */ - -/*! ***************************************************************************** -Copyright (c) Microsoft Corporation. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. -***************************************************************************** */ - -/*! http://mths.be/fromcodepoint v0.1.0 by @mathias */ - -/*! safe-buffer. MIT License. Feross Aboukhadijeh */ - -/** - * @license - * Lodash - * Copyright OpenJS Foundation and other contributors - * Released under MIT license - * Based on Underscore.js 1.8.3 - * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors - */ - -//! moment.js - -//! moment.js locale configuration diff --git a/redisinsight/main.renderer.ts b/redisinsight/main.renderer.ts deleted file mode 100644 index 9ab5f32874..0000000000 --- a/redisinsight/main.renderer.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Titlebar, Color } from 'custom-electron-titlebar'; - -const MyTitleBar = new Titlebar({ - backgroundColor: Color.fromHex('#101317'), - shadow: true, -}); - -MyTitleBar.updateTitle('RedisInsight'); diff --git a/redisinsight/package.json b/redisinsight/package.json index 5c855ba39e..9584ffdf0c 100644 --- a/redisinsight/package.json +++ b/redisinsight/package.json @@ -2,9 +2,9 @@ "name": "redisinsight", "productName": "RedisInsight", "private": true, - "version": "2.26.0", + "version": "2.28.0", "description": "RedisInsight", - "main": "./main.prod.js", + "main": "./dist/main/main.js", "author": { "name": "Redis Ltd.", "email": "support@redis.com", @@ -13,7 +13,7 @@ "scripts": {}, "dependencies": { "keytar": "^7.9.0", - "sqlite3": "^5.0.11", - "ssh2": "^1.11.0" + "sqlite3": "5.1.6", + "ssh2": "^1.14.0" } } diff --git a/redisinsight/tray.ts b/redisinsight/tray.ts deleted file mode 100644 index 90522eb3fb..0000000000 --- a/redisinsight/tray.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { - app, - Menu, - shell, - Tray, - nativeImage, - BrowserWindow, - MenuItemConstructorOptions, -} from 'electron'; -import path from 'path'; -// eslint-disable-next-line import/no-cycle -import { createWindow, setToQuiting, windows } from './main.dev'; - -export default class TrayBuilder { - public tray: Tray; - - constructor() { - const iconRelevantPath = process.platform === 'darwin' - ? '../resources/icon-tray-white.png' - : '../resources/icon-tray-colored.png'; - const iconPath = path.join(__dirname, iconRelevantPath); - const icon = nativeImage.createFromPath(iconPath); - const iconTray = icon.resize({ height: 16, width: 16 }); - iconTray.setTemplateImage(true); - - this.tray = new Tray(iconTray); - } - - buildOpenAppSubMenu() { - if (windows.size > 1) { - return { - label: 'Open RedisInsight', - type: 'submenu', - submenu: [ - { - label: 'All', - click: () => { - this.openApp(); - }, - }, - { - type: 'separator', - }, - ...[...windows].map((window) => ({ - label: window.webContents.getTitle(), - click: () => { - window.show(); - }, - })), - ], - }; - } - - return { - label: 'Open RedisInsight', - click: () => { - this.openApp(); - }, - }; - } - - buildContextMenu() { - const contextMenu = Menu.buildFromTemplate([ - this.buildOpenAppSubMenu(), - { type: 'separator' }, - { - label: 'About', - click: () => { - this.openApp(); - - app.showAboutPanel(); - }, - }, - { - label: 'Learn More', - click() { - shell.openExternal('https://docs.redis.com/latest/ri/'); - }, - }, - { type: 'separator' }, - { - label: 'Quit', - click: () => { - setToQuiting(); - app.quit(); - }, - }, - ] as MenuItemConstructorOptions[]); - - this.tray.setContextMenu(contextMenu); - } - - buildTray() { - this.tray.setToolTip(app.name); - this.buildContextMenu(); - - if (process.platform !== 'darwin') { - this.tray.on('click', () => { - this.openApp(); - }); - } - - return this.tray; - } - - updateTooltip(name: string) { - this.tray.setToolTip(name); - } - - private openApp() { - if (windows.size) { - windows.forEach((window: BrowserWindow) => window.show()); - app.dock?.show(); - } - - if (!windows.size) { - createWindow(); - } - } -} diff --git a/redisinsight/ui/indexElectron.tsx b/redisinsight/ui/indexElectron.tsx index 81f37a7af5..641a01f9a4 100644 --- a/redisinsight/ui/indexElectron.tsx +++ b/redisinsight/ui/indexElectron.tsx @@ -1,11 +1,16 @@ import React from 'react' -import { render } from 'react-dom' +import { createRoot } from 'react-dom/client' import AppElectron from 'uiSrc/electron/AppElectron' import { listenPluginsEvents } from 'uiSrc/plugins/pluginEvents' import 'uiSrc/styles/base/_fonts.scss' import 'uiSrc/styles/main.scss' -listenPluginsEvents() +window.app.sendWindowId((_e: any, windowId: string = '') => { + window.windowId = windowId || window.windowId -const rootEl = document.getElementById('root') -render(, rootEl) + listenPluginsEvents() + + const rootEl = document.getElementById('root') + const root = createRoot(rootEl!) + root.render() +}) diff --git a/redisinsight/ui/src/App.scss b/redisinsight/ui/src/App.scss index 62bf3e8dad..f2764d43e3 100644 --- a/redisinsight/ui/src/App.scss +++ b/redisinsight/ui/src/App.scss @@ -36,6 +36,8 @@ input[type='number'] { } .euiScreenReaderOnly { + // fix additional scroll + display: none; // position: absolute !important; } diff --git a/redisinsight/ui/src/assets/img/icons/bulk_actions.svg b/redisinsight/ui/src/assets/img/icons/bulk_actions.svg index 9b531b16cf..0de97d13d6 100644 --- a/redisinsight/ui/src/assets/img/icons/bulk_actions.svg +++ b/redisinsight/ui/src/assets/img/icons/bulk_actions.svg @@ -1,6 +1,5 @@ - - - - - + + + + diff --git a/redisinsight/ui/src/assets/img/icons/redis_db_blue.svg b/redisinsight/ui/src/assets/img/icons/redis_db_blue.svg new file mode 100644 index 0000000000..27b7454154 --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/redis_db_blue.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/icons/treeview.svg b/redisinsight/ui/src/assets/img/icons/treeview.svg index 26128a5441..c261414df0 100644 --- a/redisinsight/ui/src/assets/img/icons/treeview.svg +++ b/redisinsight/ui/src/assets/img/icons/treeview.svg @@ -1,4 +1,4 @@ - + diff --git a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx index 4f682e6195..410bb5458a 100644 --- a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx +++ b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx @@ -19,6 +19,7 @@ import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { isProcessingBulkAction } from 'uiSrc/pages/browser/components/bulk-actions/utils' import { BrowserStorageItem, BulkActionsServerEvent, BulkActionsStatus, BulkActionsType, SocketEvent } from 'uiSrc/constants' import { addErrorNotification } from 'uiSrc/slices/app/notifications' +import { CustomHeaders } from 'uiSrc/constants/api' interface IProps { retryDelay?: number @@ -43,6 +44,7 @@ const BulkActionsConfig = ({ retryDelay = 5000 } : IProps) => { socketRef.current = io(`${getBaseApiUrl()}/bulk-actions`, { forceNew: true, query: { instanceId }, + extraHeaders: { [CustomHeaders.WindowId]: window.windowId || '' }, rejectUnauthorized: false, }) diff --git a/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx b/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx index fa6691f45a..bf36ba8d25 100644 --- a/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx +++ b/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx @@ -19,7 +19,7 @@ import { processUnsupportedCommand, processUnrepeatableNumber, } from 'uiSrc/slices/cli/cli-output' -import { CommandMonitor, CommandPSubscribe, Pages } from 'uiSrc/constants' +import { CommandMonitor, CommandPSubscribe, CommandSubscribe, Pages } from 'uiSrc/constants' import { getCommandRepeat, isRepeatCountCorrect } from 'uiSrc/utils' import { ConnectionType } from 'uiSrc/slices/interfaces' import { ClusterNodeRole } from 'uiSrc/slices/interfaces/cli' @@ -107,6 +107,13 @@ const CliBodyWrapper = () => { return } + // Flow if SUBSCRIBE command was executed + if (checkUnsupportedCommand([CommandSubscribe.toLowerCase()], commandLine)) { + dispatch(concatToOutput(cliTexts.SUBSCRIBE_COMMAND_CLI(Pages.pubSub(instanceId)))) + resetCommand() + return + } + if (unsupportedCommand) { dispatch(processUnsupportedCommand(commandLine, unsupportedCommand, resetCommand)) return diff --git a/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx b/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx index 6033b5c41f..80f5658d62 100644 --- a/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx +++ b/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx @@ -1,6 +1,6 @@ import React, { useContext, useEffect } from 'react' -import { DatabaseOverview } from 'uiSrc/components' import { useDispatch, useSelector } from 'react-redux' +import { DatabaseOverview } from 'uiSrc/components' import { connectedInstanceOverviewSelector, connectedInstanceSelector, diff --git a/redisinsight/ui/src/components/global-subscriptions/CommonAppSubscription/CommonAppSubscription.tsx b/redisinsight/ui/src/components/global-subscriptions/CommonAppSubscription/CommonAppSubscription.tsx index e4726f72bf..f042b9157c 100644 --- a/redisinsight/ui/src/components/global-subscriptions/CommonAppSubscription/CommonAppSubscription.tsx +++ b/redisinsight/ui/src/components/global-subscriptions/CommonAppSubscription/CommonAppSubscription.tsx @@ -12,6 +12,7 @@ import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { setTotalUnread } from 'uiSrc/slices/recommendations/recommendations' import { RecommendationsSocketEvents } from 'uiSrc/constants/recommendations' import { getFeatureFlagsSuccess } from 'uiSrc/slices/app/features' +import { CustomHeaders } from 'uiSrc/constants/api' const CommonAppSubscription = () => { const { id: instanceId } = useSelector(connectedInstanceSelector) @@ -28,7 +29,8 @@ const CommonAppSubscription = () => { socketRef.current = io(`${getBaseApiUrl()}`, { forceNew: false, rejectUnauthorized: false, - reconnection: true + reconnection: true, + extraHeaders: { [CustomHeaders.WindowId]: window.windowId || '' }, }) socketRef.current.on(SocketEvent.Connect, () => { diff --git a/redisinsight/ui/src/components/index.ts b/redisinsight/ui/src/components/index.ts index 495a8350fb..0bed0f66e9 100644 --- a/redisinsight/ui/src/components/index.ts +++ b/redisinsight/ui/src/components/index.ts @@ -26,6 +26,7 @@ import ShowChildByCondition from './show-child-by-condition' import RecommendationVoting from './recommendation-voting' import RecommendationCopyComponent from './recommendation-copy-component' import FeatureFlagComponent from './feature-flag-component' +import { ModuleNotLoaded, FilterNotAvailable } from './messages' export { NavigationMenu, @@ -59,4 +60,6 @@ export { RecommendationVoting, RecommendationCopyComponent, FeatureFlagComponent, + ModuleNotLoaded, + FilterNotAvailable } diff --git a/redisinsight/ui/src/components/json-viewer/JSONViewer.tsx b/redisinsight/ui/src/components/json-viewer/JSONViewer.tsx index 47b588afd9..6d8ef318c3 100644 --- a/redisinsight/ui/src/components/json-viewer/JSONViewer.tsx +++ b/redisinsight/ui/src/components/json-viewer/JSONViewer.tsx @@ -9,7 +9,7 @@ interface Props { } const JSONViewer = (props: Props) => { - const { value, expanded = false, space = 4 } = props + const { value, expanded = false, space = 2 } = props try { JSON.parse(value) diff --git a/redisinsight/ui/src/components/keys-summary/KeysSummary.spec.tsx b/redisinsight/ui/src/components/keys-summary/KeysSummary.spec.tsx index 05a03d4275..493ebc099a 100644 --- a/redisinsight/ui/src/components/keys-summary/KeysSummary.spec.tsx +++ b/redisinsight/ui/src/components/keys-summary/KeysSummary.spec.tsx @@ -28,14 +28,14 @@ describe('KeysSummary', () => { const { queryByTestId } = render( ) - expect(queryByTestId('keys-summary')).toHaveTextContent('Results: 1 key.') + expect(queryByTestId('keys-summary')).toHaveTextContent('Results: 1.') }) it('should Keys summary show proper text with count > 1', () => { const { queryByTestId } = render( ) - expect(queryByTestId('keys-summary')).toHaveTextContent('Results: 2 keys.') + expect(queryByTestId('keys-summary')).toHaveTextContent('Results: 2.') }) it('should not render Scan more button if showScanMore = false ', () => { diff --git a/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx b/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx index 9d4bd4b4e8..6691d206ef 100644 --- a/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx +++ b/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx @@ -47,14 +47,13 @@ const KeysSummary = (props: Props) => { {'Results: '} {numberWithSpaces(resultsLength)} - {` key${resultsLength !== 1 ? 's' : ''}. `} + {'. '} {'Scanned '} {numberWithSpaces(scannedDisplay)} {' / '} {nullableNumberWithSpaces(totalItemsCount)} - {' keys'} diff --git a/redisinsight/ui/src/components/live-time-recommendations/LiveTimeRecommendations.tsx b/redisinsight/ui/src/components/live-time-recommendations/LiveTimeRecommendations.tsx index 2a441343be..6f734b076f 100644 --- a/redisinsight/ui/src/components/live-time-recommendations/LiveTimeRecommendations.tsx +++ b/redisinsight/ui/src/components/live-time-recommendations/LiveTimeRecommendations.tsx @@ -75,6 +75,8 @@ const LiveTimeRecommendations = () => { // To prevent duplication emit for FlyOut close event // https://github.com/elastic/eui/issues/3437 const isCloseEventSent = useRef(false) + // Flyout onClose did not updated between rerenders + const recommendationsState = useRef([]) const dispatch = useDispatch() const history = useHistory() @@ -108,6 +110,10 @@ const LiveTimeRecommendations = () => { } }, [isContentVisible]) + useEffect(() => { + recommendationsState.current = recommendations + }, [recommendations]) + const toggleContent = () => { dispatch(setIsContentVisible(!isContentVisible)) } @@ -145,7 +151,7 @@ const LiveTimeRecommendations = () => { dispatch(setIsContentVisible(false)) sendEventTelemetry({ event: TelemetryEvent.INSIGHTS_PANEL_CLOSED, - eventData: getTelemetryData(recommendations), + eventData: getTelemetryData(recommendationsState.current), }) isCloseEventSent.current = true } diff --git a/redisinsight/ui/src/components/live-time-recommendations/components/recommendation/Recommendation.tsx b/redisinsight/ui/src/components/live-time-recommendations/components/recommendation/Recommendation.tsx index bbed498995..e91ac53084 100644 --- a/redisinsight/ui/src/components/live-time-recommendations/components/recommendation/Recommendation.tsx +++ b/redisinsight/ui/src/components/live-time-recommendations/components/recommendation/Recommendation.tsx @@ -135,12 +135,27 @@ const Recommendation = ({ setIsLoading(false) } + const onRecommendationLinkClick = () => { + sendEventTelemetry({ + event: TelemetryEvent.INSIGHTS_RECOMMENDATION_LINK_CLICKED, + eventData: { + databaseId: instanceId, + name: recommendationsContent[name]?.telemetryEvent ?? name, + provider + } + }) + setIsLoading(false) + } + const recommendationContent = () => ( {renderRecommendationContent( recommendationsContent[name]?.content, params, - recommendationsContent[name]?.telemetryEvent ?? name, + { + onClickLink: onRecommendationLinkClick, + telemetryName: recommendationsContent[name]?.telemetryEvent ?? name, + }, true )} {!!params?.keys?.length && ( diff --git a/redisinsight/ui/src/components/messages/filter-not-available/FilterNotAvailable.spec.tsx b/redisinsight/ui/src/components/messages/filter-not-available/FilterNotAvailable.spec.tsx new file mode 100644 index 0000000000..23e3c59ee8 --- /dev/null +++ b/redisinsight/ui/src/components/messages/filter-not-available/FilterNotAvailable.spec.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' + +import FilterNotAvailable from './FilterNotAvailable' + +describe('FilterNotAvailable', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/messages/filter-not-available/FilterNotAvailable.tsx b/redisinsight/ui/src/components/messages/filter-not-available/FilterNotAvailable.tsx new file mode 100644 index 0000000000..0e2e8a1b12 --- /dev/null +++ b/redisinsight/ui/src/components/messages/filter-not-available/FilterNotAvailable.tsx @@ -0,0 +1,47 @@ +import React from 'react' + +import { EuiIcon, EuiText, EuiTitle, EuiSpacer, EuiLink, EuiButton } from '@elastic/eui' +import RedisDbBlueIcon from 'uiSrc/assets/img/icons/redis_db_blue.svg' + +import styles from './styles.module.scss' + +const GET_STARTED_LINK = 'https://redis.com/try-free/?utm_source=redisinsight&utm_medium=main&utm_campaign=browser_filter' +const LEARN_MORE_LINK = 'https://redis.io/docs/stack/about/?utm_source=redisinsight&utm_medium=main&utm_campaign=browser_filter' + +const FilterNotAvailable = () => ( +
+ + +

Upgrade your Redis database to version 6 or above

+
+ Filtering by data type is supported in Redis 6 and above. + + Create a free Redis Stack database that supports filtering and extends the core capabilities of open-source Redis. + +
+ + Get Started For Free + + + + Learn More + +
+
+) + +export default FilterNotAvailable diff --git a/redisinsight/ui/src/components/messages/filter-not-available/index.ts b/redisinsight/ui/src/components/messages/filter-not-available/index.ts new file mode 100644 index 0000000000..669d5f5ab1 --- /dev/null +++ b/redisinsight/ui/src/components/messages/filter-not-available/index.ts @@ -0,0 +1,3 @@ +import FilterNotAvailable from './FilterNotAvailable' + +export default FilterNotAvailable diff --git a/redisinsight/ui/src/components/messages/filter-not-available/styles.module.scss b/redisinsight/ui/src/components/messages/filter-not-available/styles.module.scss new file mode 100644 index 0000000000..2544a27777 --- /dev/null +++ b/redisinsight/ui/src/components/messages/filter-not-available/styles.module.scss @@ -0,0 +1,24 @@ +.container { + padding: 40px 60px; + text-align: center; + + .title { + font-family: 'Graphik', sans-serif; + font-size: 28px; + font-weight: 600; + word-break: break-word; + margin-top: 20px; + margin-bottom: 20px; + } + + .linksWrapper { + display: flex; + flex-direction: column; + align-items: center; + + .link { + color: var(--wbTextColor) !important; + text-decoration: none !important; + } + } +} diff --git a/redisinsight/ui/src/components/messages/index.ts b/redisinsight/ui/src/components/messages/index.ts new file mode 100644 index 0000000000..60d80c061c --- /dev/null +++ b/redisinsight/ui/src/components/messages/index.ts @@ -0,0 +1,7 @@ +import ModuleNotLoaded from './module-not-loaded' +import FilterNotAvailable from './filter-not-available' + +export { + ModuleNotLoaded, + FilterNotAvailable +} diff --git a/redisinsight/ui/src/pages/workbench/components/module-not-loaded/ModuleNotLoaded.spec.tsx b/redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/workbench/components/module-not-loaded/ModuleNotLoaded.spec.tsx rename to redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.spec.tsx diff --git a/redisinsight/ui/src/pages/workbench/components/module-not-loaded/ModuleNotLoaded.tsx b/redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.tsx similarity index 81% rename from redisinsight/ui/src/pages/workbench/components/module-not-loaded/ModuleNotLoaded.tsx rename to redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.tsx index b19cd639a6..7bc14cf3a2 100644 --- a/redisinsight/ui/src/pages/workbench/components/module-not-loaded/ModuleNotLoaded.tsx +++ b/redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.tsx @@ -19,6 +19,7 @@ import styles from './styles.module.scss' export interface IProps { moduleName: RedisDefaultModules id: string + type?: 'workbench' | 'browser' } const MIN_ELEMENT_WIDTH = 1210 @@ -49,7 +50,7 @@ const ListItem = ({ item }: { item: string }) => ( ) -const ModuleNotLoaded = ({ moduleName, id }: IProps) => { +const ModuleNotLoaded = ({ moduleName, id, type = 'workbench' }: IProps) => { const [width, setWidth] = useState(0) const module = MODULE_TEXT_VIEW[moduleName] @@ -61,8 +62,24 @@ const ModuleNotLoaded = ({ moduleName, id }: IProps) => { } }) + const getStartedLink = (baseUrl: string) => { + try { + const url = new URL(baseUrl) + url.searchParams.append('utm_source', 'redisinsight') + url.searchParams.append('utm_medium', 'app') + url.searchParams.append('utm_campaign', type === 'browser' ? 'redisinsight_browser_search' : 'redisinsight_workbench') + return url.toString() + } catch (e) { + return baseUrl + } + } + return ( -
MAX_ELEMENT_WIDTH })}> +
MAX_ELEMENT_WIDTH || type === 'browser', + [styles.modal]: type === 'browser', + })} + >
{width > MAX_ELEMENT_WIDTH @@ -96,7 +113,7 @@ const ModuleNotLoaded = ({ moduleName, id }: IProps) => { className={cx(styles.text, styles.link)} external={false} target="_blank" - href={CONTENT[moduleName]?.link} + href={getStartedLink(CONTENT[moduleName]?.link)} data-testid="learn-more-link" > Learn More @@ -105,7 +122,7 @@ const ModuleNotLoaded = ({ moduleName, id }: IProps) => { className={styles.link} external={false} target="_blank" - href="https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight_workbench" + href={getStartedLink('https://redis.com/try-free')} data-testid="get-started-link" > { const newSocket = io(`${getBaseApiUrl()}/monitor`, { forceNew: true, query: { instanceId }, + extraHeaders: { [CustomHeaders.WindowId]: window.windowId || '' }, rejectUnauthorized: false, }) dispatch(setSocket(newSocket)) diff --git a/redisinsight/ui/src/components/monitor/Monitor/Monitor.tsx b/redisinsight/ui/src/components/monitor/Monitor/Monitor.tsx index 0c47e061cb..ef920992b2 100644 --- a/redisinsight/ui/src/components/monitor/Monitor/Monitor.tsx +++ b/redisinsight/ui/src/components/monitor/Monitor/Monitor.tsx @@ -28,7 +28,6 @@ export interface Props { isShowHelper: boolean isSaveToFile: boolean isShowCli: boolean - scrollViewOnAppear: boolean handleRunMonitor: (isSaveToLog?: boolean) => void } @@ -143,8 +142,8 @@ const Monitor = (props: Props) => { {({ width, height }) => ( diff --git a/redisinsight/ui/src/components/monitor/Monitor/styles.module.scss b/redisinsight/ui/src/components/monitor/Monitor/styles.module.scss index 421d07b624..a1a085f386 100644 --- a/redisinsight/ui/src/components/monitor/Monitor/styles.module.scss +++ b/redisinsight/ui/src/components/monitor/Monitor/styles.module.scss @@ -37,28 +37,34 @@ height: 100%; position: relative; overflow: auto; - padding-left: 12px; } .content { - @include euiScrollBar; - - width: 100%; height: 100%; - position: relative; - overflow: auto; - + width: 100%; display: flex; flex-direction: column; - margin-bottom: 6px; - - &:first-child { - padding-top: 10px; - } - - &:last-child { - padding-bottom: 10px; - } + overflow: hidden; + padding: 12px 4px 12px 12px; + + //@include euiScrollBar; + // + //width: 100%; + //height: 100%; + //position: relative; + //overflow: auto; + // + //display: flex; + //flex-direction: column; + //margin-bottom: 6px; + // + //&:first-child { + // padding-top: 10px; + //} + // + //&:last-child { + // padding-bottom: 10px; + //} } .startContainer { @@ -180,10 +186,10 @@ .item { display: block; - overflow: hidden; & > span { padding-right: 5px; + word-break: break-word; } } diff --git a/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.spec.tsx b/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.spec.tsx index 31437b20d1..b28832f0ce 100644 --- a/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.spec.tsx +++ b/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.spec.tsx @@ -15,10 +15,10 @@ describe('MonitorOutputList', () => { expect(render()).toBeTruthy() }) - it('should "ReactVirtualized__Grid" be in the DOM', () => { + it('should render items properly', () => { const item = { time: '112', args: ['ttl'], source: '12', database: '0' } - const mockItems = [item] + const mockItems = [item, item] const { container } = render() - expect(container.getElementsByClassName('ReactVirtualized__Grid').length).toBe(1) + expect(container.getElementsByClassName('item').length).toBe(2) }) }) diff --git a/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.tsx b/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.tsx index 7f7bbe8d04..1fe4645ac9 100644 --- a/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.tsx +++ b/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.tsx @@ -1,12 +1,11 @@ -import React, { useEffect, useRef } from 'react' +import React, { useCallback, useEffect, useRef } from 'react' import cx from 'classnames' import { EuiTextColor } from '@elastic/eui' -import { CellMeasurer, List, CellMeasurerCache, ListRowProps } from 'react-virtualized' +import { ListChildComponentProps, ListOnScrollProps, VariableSizeList as List } from 'react-window' import { DEFAULT_ERROR_MESSAGE, getFormatTime } from 'uiSrc/utils' import styles from 'uiSrc/components/monitor/Monitor/styles.module.scss' -import 'react-virtualized/styles.css' export interface Props { compressed: boolean @@ -18,28 +17,67 @@ export interface Props { const PROTRUDING_OFFSET = 2 const MIDDLE_SCREEN_RESOLUTION = 460 const SMALL_SCREEN_RESOLUTION = 360 +const MIN_ROW_HEIGHT = 17 const MonitorOutputList = (props: Props) => { const { compressed, items = [], width = 0, height = 0 } = props - const cache = new CellMeasurerCache({ - defaultHeight: 17, - fixedWidth: true, - fixedHeight: false - }) - + const autoScrollRef = useRef(true) + const rowHeights = useRef<{ [key: number]: number }>({}) + const outerRef = useRef(null) const listRef = useRef(null) + const hasMountedRef = useRef(false) + + useEffect(() => { + if (autoScrollRef.current) { + setTimeout(() => { + scrollToBottom() + }, 0) + } + }, [items]) + + const getRowHeight = (index: number) => ( + rowHeights.current[index] > MIN_ROW_HEIGHT ? (rowHeights.current[index] + 2) : MIN_ROW_HEIGHT + ) + + const setRowHeight = (index: number, size: number) => { + listRef.current?.resetAfterIndex(0) + if (size > MIN_ROW_HEIGHT) { + rowHeights.current[index] = size + return + } + + if (rowHeights.current[index]) { + delete rowHeights.current[index] + } + } - const clearCacheAndUpdate = () => { - listRef?.current?.scrollToRow(items.length - 1) + const scrollToBottom = () => { + listRef.current?.scrollToItem(items.length - 1, 'end') requestAnimationFrame(() => { - listRef?.current?.scrollToRow(items.length - 1) + listRef.current?.scrollToItem(items.length - 1, 'end') }) } - useEffect(() => { - clearCacheAndUpdate() - }, [items]) + const handleScroll = useCallback((e: ListOnScrollProps) => { + if (!hasMountedRef.current) { + hasMountedRef.current = true + return + } + + if (!outerRef.current) { + return + } + + if (e.scrollOffset + outerRef.current.offsetHeight === outerRef.current.scrollHeight) { + autoScrollRef.current = true + return + } + + if (!e.scrollUpdateWasRequested) { + autoScrollRef.current = false + } + }, []) const getArgs = (args: string[]): JSX.Element => ( @@ -54,51 +92,47 @@ const MonitorOutputList = (props: Props) => { ) - const rowRenderer = ({ parent, index, key, style }: ListRowProps) => { + const Row = ({ index, style }: ListChildComponentProps) => { const { time = '', args = [], database = '', source = '', isError, message = '' } = items[index] + const rowRef = useRef(null) + + useEffect(() => { + if (!rowRef.current) return + setRowHeight(index, rowRef.current?.clientHeight) + }, [rowRef]) + return ( - - {({ registerChild, measure }) => ( -
- {!isError && ( - <> - {width > MIDDLE_SCREEN_RESOLUTION && ( - - {getFormatTime(time)} -   - - )} - {width > SMALL_SCREEN_RESOLUTION && ({`[${database} ${source}] `})} - {getArgs(args)} - - )} - {isError && ( - {message ?? DEFAULT_ERROR_MESSAGE} +
+ {!isError && ( +
+ {width > MIDDLE_SCREEN_RESOLUTION && ( + {getFormatTime(time)}  )} + {width > SMALL_SCREEN_RESOLUTION && ({`[${database} ${source}] `})} + {getArgs(args)}
)} - + {isError && ( + {message ?? DEFAULT_ERROR_MESSAGE} + )} +
) } return ( + outerRef={outerRef} + onScroll={handleScroll} + overscanCount={30} + > + {Row} + ) } diff --git a/redisinsight/ui/src/components/monitor/MonitorWrapper.tsx b/redisinsight/ui/src/components/monitor/MonitorWrapper.tsx index e027e5a811..0ba1281091 100644 --- a/redisinsight/ui/src/components/monitor/MonitorWrapper.tsx +++ b/redisinsight/ui/src/components/monitor/MonitorWrapper.tsx @@ -40,7 +40,6 @@ const MonitorWrapper = () => {
void @@ -146,6 +146,7 @@ const MultiSearch = (props: Props) => { aria-label="Search" disabled={disableSubmit} className={styles.searchButton} + iconSize="s" onClick={handleSubmit} data-testid="search-btn" /> @@ -268,18 +269,18 @@ const MultiSearch = (props: Props) => { /> )} + {disableSubmit && ( + + {SubmitBtn()} + + )} + {!disableSubmit && SubmitBtn()}
- {disableSubmit && ( - - {SubmitBtn()} - - )} - {!disableSubmit && SubmitBtn()}
) diff --git a/redisinsight/ui/src/components/multi-search/styles.module.scss b/redisinsight/ui/src/components/multi-search/styles.module.scss index 43b7caca3e..b24f031f0f 100644 --- a/redisinsight/ui/src/components/multi-search/styles.module.scss +++ b/redisinsight/ui/src/components/multi-search/styles.module.scss @@ -12,10 +12,10 @@ height: 100%; display: flex; align-items: center; - padding: 6px 12px 6px 6px; - border: 1px solid var(--separatorColor); - border-right: 0; - background-color: var(--browserTableRowEven); + padding: 5px 6px; + border: 1px solid var(--controlsBorderColor); + border-radius: 0 4px 4px 0; + background-color: var(--euiColorEmptyShade); background-repeat: no-repeat; background-size: 0 100%; transition: box-shadow 150ms ease-in, background-image 150ms ease-in, background-size 150ms ease-in, @@ -73,10 +73,10 @@ width: 100%; min-width: 180px; - background: var(--browserTableRowEven); - border: 1px solid var(--separatorColor); + background: var(--euiColorEmptyShade); + border: 1px solid var(--controlsBorderColor); border-radius: 4px; - z-index: 3; + z-index: 1001; padding: 4px 0; font-size: 13px; @@ -93,7 +93,7 @@ &:hover, &.focused { - background: var(--comboBoxBadgeBgColor); + background: var(--hoverInListColorDarken); .suggestionRemoveBtn { visibility: visible; pointer-events: auto; @@ -137,16 +137,17 @@ user-select: none; &:hover { - background: var(--comboBoxBadgeBgColor); + background: var(--hoverInListColorDarken); } } .searchButton { - width: 48px; - height: 100%; + width: 24px; + height: 24px; background-color: var(--euiColorSecondary); color: var(--euiColorPrimaryText) !important; - border-radius: 0 4px 4px 0; + border-radius: 4px; + margin-left: 12px; &:hover:not(:disabled), &:focus, @@ -161,7 +162,6 @@ &:disabled { border: 1px solid var(--separatorColor); - border-left: none; background-color: var(--euiColorLightestShade); } } diff --git a/redisinsight/ui/src/components/page-placeholder/PagePlaceholder.tsx b/redisinsight/ui/src/components/page-placeholder/PagePlaceholder.tsx index ad3e3bb1e6..4190fb4822 100644 --- a/redisinsight/ui/src/components/page-placeholder/PagePlaceholder.tsx +++ b/redisinsight/ui/src/components/page-placeholder/PagePlaceholder.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { ReactComponent as LogoIcon } from 'uiSrc/assets/img/logo.svg' import { EuiLoadingLogo, EuiEmptyPrompt } from '@elastic/eui' +import { ReactComponent as LogoIcon } from 'uiSrc/assets/img/logo.svg' const PagePlaceholder = () => ( <> diff --git a/redisinsight/ui/src/components/pub-sub-config/PubSubConfig.tsx b/redisinsight/ui/src/components/pub-sub-config/PubSubConfig.tsx index cf007f73aa..33e414c3fa 100644 --- a/redisinsight/ui/src/components/pub-sub-config/PubSubConfig.tsx +++ b/redisinsight/ui/src/components/pub-sub-config/PubSubConfig.tsx @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux' import { io, Socket } from 'socket.io-client' import { SocketEvent } from 'uiSrc/constants' +import { CustomHeaders } from 'uiSrc/constants/api' import { PubSubEvent } from 'uiSrc/constants/pubSub' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { PubSubSubscription } from 'uiSrc/slices/interfaces/pubsub' @@ -37,6 +38,7 @@ const PubSubConfig = ({ retryDelay = 5000 } : IProps) => { socketRef.current = io(`${getBaseApiUrl()}/pub-sub`, { forceNew: true, query: { instanceId }, + extraHeaders: { [CustomHeaders.WindowId]: window.windowId || '' }, rejectUnauthorized: false, }) diff --git a/redisinsight/ui/src/components/query-card/QueryCard.spec.tsx b/redisinsight/ui/src/components/query-card/QueryCard.spec.tsx index 06c547a6fa..1776285e4f 100644 --- a/redisinsight/ui/src/components/query-card/QueryCard.spec.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCard.spec.tsx @@ -120,4 +120,21 @@ describe('QueryCard', () => { expect(summaryString).toEqual(summaryText) }) + + it('should render QueryCardCliResultWrapper when command is null', () => { + const { queryByTestId } = render( + + ) + const queryCommonResultEl = queryByTestId('query-common-result-wrapper') + const queryCliResultEl = queryByTestId('query-cli-result-wrapper') + + expect(queryCommonResultEl).toBeInTheDocument() + expect(queryCliResultEl).not.toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/components/query-card/QueryCard.tsx b/redisinsight/ui/src/components/query-card/QueryCard.tsx index 11d7551551..368658f6ee 100644 --- a/redisinsight/ui/src/components/query-card/QueryCard.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCard.tsx @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' import { EuiLoadingContent, keys } from '@elastic/eui' import { useParams } from 'react-router-dom' +import { isNull } from 'lodash' import { WBQueryType, ProfileQueryType, DEFAULT_TEXT_VIEW_TYPE } from 'uiSrc/pages/workbench/constants' import { RunQueryMode, ResultsMode, ResultsSummary } from 'uiSrc/slices/interfaces/workbench' @@ -194,7 +195,7 @@ const QueryCard = (props: Props) => { /> {isOpen && ( <> - {React.isValidElement(commonError) && !isGroupResults(resultsMode) + {React.isValidElement(commonError) && (!isGroupResults(resultsMode) || isNull(command)) ? : ( <> diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx b/redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx index 3b949dd17d..ebfcdfcf47 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx @@ -1,6 +1,10 @@ import React from 'react' -import { render } from 'uiSrc/utils/test-utils' -import QueryCardCliGroupResult from './QueryCardCliGroupResult' +import { instance, mock } from 'ts-mockito' +import { render, screen } from 'uiSrc/utils/test-utils' +import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' +import QueryCardCliGroupResult, { Props } from './QueryCardCliGroupResult' + +const mockedProps = mock() describe('QueryCardCliGroupResult', () => { it('should render', () => { @@ -11,10 +15,59 @@ describe('QueryCardCliGroupResult', () => { }], status: 'success' }] - expect(render()).toBeTruthy() + expect(render()).toBeTruthy() }) it('Should render result when result is undefined', () => { - expect(render()).toBeTruthy() + expect(render()).toBeTruthy() + }) + + it('should render error when command is psubscribe', () => { + const mockResult = [ + { + response: [ + { + id: 'id', + command: 'psubscribe', + response: 'response', + status: CommandExecutionStatus.Success + } + ] + } + ] + const { container } = render( + + ) + const errorBtn = container.querySelector('[data-test-subj="pubsub-page-btn"]') + + expect(errorBtn).toBeInTheDocument() + }) + + it('should render (nil) when response is null', () => { + const mockResult = [ + { + response: [ + { + id: 'id', + command: 'psubscribe', + response: null, + status: CommandExecutionStatus.Success + } + ] + } + ] + const { container } = render( + + ) + const errorBtn = container.querySelector('[data-test-subj="pubsub-page-btn"]') + + expect(errorBtn).not.toBeInTheDocument() + expect(screen.getByText('(nil)')).toBeInTheDocument() }) }) diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.tsx b/redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.tsx index 90567aee30..fcd2fd75b5 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.tsx @@ -1,4 +1,4 @@ -import { flatten } from 'lodash' +import { flatten, isNull } from 'lodash' import React from 'react' import { CommandExecutionResult } from 'uiSrc/slices/interfaces' @@ -21,7 +21,7 @@ const QueryCardCliGroupResult = (props: Props) => { isFullScreen={isFullScreen} items={flatten(result?.[0]?.response.map((item: any) => { const commonError = CommonErrorResponse(item.id, item.command, item.response) - if (React.isValidElement(commonError)) { + if (React.isValidElement(commonError) && !isNull(item.response)) { return ([wbSummaryCommand(item.command), commonError]) } return flatten(cliParseCommandsGroupResult(item, db)) diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.spec.tsx b/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.spec.tsx index c3b80656e1..a763d02457 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.spec.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.spec.tsx @@ -4,7 +4,7 @@ import { instance, mock } from 'ts-mockito' import { PluginEvents } from 'uiSrc/plugins/pluginEvents' import { pluginApi } from 'uiSrc/services/PluginAPI' import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' -import { formatToText } from 'uiSrc/utils' +import { formatToText, replaceEmptyValue } from 'uiSrc/utils' import { sendPluginCommandAction, getPluginStateAction, setPluginStateAction } from 'uiSrc/slices/app/plugins' import QueryCardCliPlugin, { Props } from './QueryCardCliPlugin' @@ -19,7 +19,8 @@ jest.mock('uiSrc/services/PluginAPI', () => ({ jest.mock('uiSrc/utils', () => ({ ...jest.requireActual('uiSrc/utils'), - formatToText: jest.fn() + formatToText: jest.fn(), + replaceEmptyValue: jest.fn(), })) jest.mock('uiSrc/slices/app/plugins', () => ({ @@ -156,7 +157,9 @@ describe('QueryCardCliPlugin', () => { }) it('should subscribes and call formatToText', () => { - const formatToTextMock = jest.fn(); + const formatToTextMock = jest.fn() + const replaceEmptyValueMock = jest.fn(); + (replaceEmptyValue as jest.Mock).mockImplementation(replaceEmptyValueMock).mockReturnValue([]); (formatToText as jest.Mock).mockImplementation(formatToTextMock) const onEventMock = jest.fn().mockImplementation( (_iframeId: string, event: string, callback: (dat: any) => void) => { @@ -172,4 +175,22 @@ describe('QueryCardCliPlugin', () => { expect(formatToTextMock).toBeCalledWith([], 'info') }) + + it('should subscribes and call replaceEmptyValue', () => { + const replaceEmptyValueMock = jest.fn(); + (replaceEmptyValue as jest.Mock).mockImplementation(replaceEmptyValueMock) + const onEventMock = jest.fn().mockImplementation( + (_iframeId: string, event: string, callback: (dat: any) => void) => { + if (event === PluginEvents.formatRedisReply) { + callback({ requestId: '1', data: { response: [], command: 'info' } }) + } + } + ); + + (pluginApi.onEvent as jest.Mock).mockImplementation(onEventMock) + + render() + + expect(replaceEmptyValueMock).toBeCalledWith([]) + }) }) diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx b/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx index 82331a6c72..0b07487bca 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx @@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid' import { EuiFlexItem, EuiIcon, EuiLoadingContent, EuiTextColor } from '@elastic/eui' import { pluginApi } from 'uiSrc/services/PluginAPI' import { ThemeContext } from 'uiSrc/contexts/themeContext' -import { getBaseApiUrl, Nullable, formatToText } from 'uiSrc/utils' +import { getBaseApiUrl, Nullable, formatToText, replaceEmptyValue } from 'uiSrc/utils' import { Theme } from 'uiSrc/constants' import { CommandExecutionResult, IPluginVisualization, RunQueryMode } from 'uiSrc/slices/interfaces' import { PluginEvents } from 'uiSrc/plugins/pluginEvents' @@ -161,7 +161,7 @@ const QueryCardCliPlugin = (props: Props) => { { requestId, data }: { requestId: string, data: { response: any, command: string } } ) => { try { - const reply = formatToText(data?.response || '(nil)', data.command) + const reply = formatToText(replaceEmptyValue(data?.response), data.command) sendMessageToPlugin({ event: PluginEvents.formatRedisReply, diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx b/redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx index bfa1525816..fc39010b43 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx @@ -5,7 +5,7 @@ import { isArray } from 'lodash' import { CommandExecutionResult } from 'uiSrc/slices/interfaces' import { ResultsMode } from 'uiSrc/slices/interfaces/workbench' -import { cliParseTextResponse, formatToText, isGroupResults, Maybe } from 'uiSrc/utils' +import { cliParseTextResponse, formatToText, replaceEmptyValue, isGroupResults, Maybe } from 'uiSrc/utils' import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' import QueryCardCliDefaultResult from '../QueryCardCliDefaultResult' @@ -16,7 +16,6 @@ export interface Props { query: string result: Maybe loading?: boolean - status?: string resultsMode?: ResultsMode isNotStored?: boolean isFullScreen?: boolean @@ -27,7 +26,7 @@ const QueryCardCliResultWrapper = (props: Props) => { const { result = [], query, loading, resultsMode, isNotStored, isFullScreen, db } = props return ( -
+
{!loading && (
{isNotStored && ( @@ -42,9 +41,9 @@ const QueryCardCliResultWrapper = (props: Props) => { )} diff --git a/redisinsight/ui/src/components/query-card/QueryCardCommonResult/QueryCardCommonResult.tsx b/redisinsight/ui/src/components/query-card/QueryCardCommonResult/QueryCardCommonResult.tsx index 048fbafe64..2bd589a173 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardCommonResult/QueryCardCommonResult.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardCommonResult/QueryCardCommonResult.tsx @@ -13,7 +13,7 @@ const QueryCardCommonResult = (props: Props) => { const { result, loading } = props return ( -
+
{!loading && (
{ result || '(nil)' } diff --git a/redisinsight/ui/src/components/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx b/redisinsight/ui/src/components/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx index c6564c7da4..2260d2c7b9 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx @@ -9,13 +9,13 @@ import { getCommandRepeat, isRepeatCountCorrect } from 'uiSrc/utils' +import { ModuleNotLoaded } from 'uiSrc/components' import { cliTexts, SelectCommand } from 'uiSrc/constants/cliOutput' -import { CommandMonitor, CommandPSubscribe, Pages } from 'uiSrc/constants' +import { CommandMonitor, CommandPSubscribe, CommandSubscribe, Pages } from 'uiSrc/constants' import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' - import { cliSettingsSelector } from 'uiSrc/slices/cli/cli-settings' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import ModuleNotLoaded from 'uiSrc/pages/workbench/components/module-not-loaded' + import { showMonitor } from 'uiSrc/slices/cli/monitor' const CommonErrorResponse = (id: string, command = '', result?: any) => { @@ -30,6 +30,10 @@ const CommonErrorResponse = (id: string, command = '', result?: any) => { if (checkUnsupportedCommand([CommandMonitor.toLowerCase()], commandLine)) { return cliTexts.MONITOR_COMMAND(() => { dispatch(showMonitor()) }) } + // Flow if SUBSCRIBE command was executed + if (checkUnsupportedCommand([CommandSubscribe.toLowerCase()], commandLine)) { + return cliTexts.SUBSCRIBE_COMMAND(Pages.pubSub(instanceId)) + } // Flow if PSUBSCRIBE command was executed if (checkUnsupportedCommand([CommandPSubscribe.toLowerCase()], commandLine)) { return cliTexts.PSUBSCRIBE_COMMAND(Pages.pubSub(instanceId)) diff --git a/redisinsight/ui/src/components/recommendation-voting/RecommendationVoting.tsx b/redisinsight/ui/src/components/recommendation-voting/RecommendationVoting.tsx index 2e0f2b3d8d..cc5bd99017 100644 --- a/redisinsight/ui/src/components/recommendation-voting/RecommendationVoting.tsx +++ b/redisinsight/ui/src/components/recommendation-voting/RecommendationVoting.tsx @@ -31,7 +31,7 @@ const RecommendationVoting = ({ vote, name, id = '', live = false, containerClas gutterSize={live ? 'none' : 'l'} data-testid="recommendation-voting" > - Is this useful? + Is this useful?
{Object.values(Vote).map((option) => ( ), + SUBSCRIBE_COMMAND: (path: string = '') => ( + + {'Use '} + + Pub/Sub + + {' tool to subscribe to channels.'} + + ), PSUBSCRIBE_COMMAND_CLI: (path: string = '') => ( [ cliTexts.PSUBSCRIBE_COMMAND(path), '\n', ] ), + SUBSCRIBE_COMMAND_CLI: (path: string = '') => ( + [ + cliTexts.SUBSCRIBE_COMMAND(path), + '\n', + ] + ), MONITOR_COMMAND: (onClick: () => void) => ( {'Use '} diff --git a/redisinsight/ui/src/constants/commands.ts b/redisinsight/ui/src/constants/commands.ts index 55c9d6f820..7c902ddaa7 100644 --- a/redisinsight/ui/src/constants/commands.ts +++ b/redisinsight/ui/src/constants/commands.ts @@ -89,6 +89,7 @@ export enum CommandPrefix { export const CommandMonitor = 'MONITOR' export const CommandPSubscribe = 'PSUBSCRIBE' +export const CommandSubscribe = 'SUBSCRIBE' export enum CommandRediSearch { Search = 'FT.SEARCH', diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 02559dbde3..0bc8c4803b 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -1190,7 +1190,7 @@ { "type": "link", "value": { - "href": "https://redis.io/docs/stack/search/?utm_source=redisinsight&utm_medium=recommendation&utm_campaign=stringToJson/", + "href": "https://redis.io/docs/stack/search/", "name": "query and search capabilities" } } diff --git a/redisinsight/ui/src/constants/help-texts.tsx b/redisinsight/ui/src/constants/help-texts.tsx index d97a4b88d3..fc995977eb 100644 --- a/redisinsight/ui/src/constants/help-texts.tsx +++ b/redisinsight/ui/src/constants/help-texts.tsx @@ -37,21 +37,6 @@ export default {
), - FILTER_UNSUPPORTED: ( - <> - Filtering per Key types is available for Redis databases v. 6.0 or later. - Update your Redis database or create a new  - - free up-to-date - -  Redis database. - - ), REMOVING_MULTIPLE_ELEMENTS_NOT_SUPPORT: ( <> Removing multiple elements is available for Redis databases v. 6.2 or diff --git a/redisinsight/ui/src/constants/index.ts b/redisinsight/ui/src/constants/index.ts index a424dea36d..8bf85f01fb 100644 --- a/redisinsight/ui/src/constants/index.ts +++ b/redisinsight/ui/src/constants/index.ts @@ -3,8 +3,8 @@ import BrowserStorageItem from './storage' import ApiStatusCode from './apiStatusCode' import apiErrors from './apiErrors' -export * from './themes' export * from './keys' +export * from './themes' export * from './table' export * from './redisinsight' export * from './commands' @@ -26,4 +26,5 @@ export * from './streamViews' export * from './bulkActions' export * from './workbench' export * from './featureFlags' +export * from './serverVersions' export { ApiEndpoints, BrowserStorageItem, ApiStatusCode, apiErrors } diff --git a/redisinsight/ui/src/constants/serverVersions.ts b/redisinsight/ui/src/constants/serverVersions.ts new file mode 100644 index 0000000000..48e1617fb6 --- /dev/null +++ b/redisinsight/ui/src/constants/serverVersions.ts @@ -0,0 +1,4 @@ +export const ServerVersions = { + MIN_SERVER_VERSION: '6.2.6', + SERVER_VERSION: '7.2', +} diff --git a/redisinsight/ui/src/electron/components/ConfigElectron/ConfigElectron.tsx b/redisinsight/ui/src/electron/components/ConfigElectron/ConfigElectron.tsx index 41f82770eb..bc1ae0b17f 100644 --- a/redisinsight/ui/src/electron/components/ConfigElectron/ConfigElectron.tsx +++ b/redisinsight/ui/src/electron/components/ConfigElectron/ConfigElectron.tsx @@ -1,11 +1,11 @@ import { useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { ipcCheckUpdates, ipcSendEvents } from 'uiSrc/electron/utils' import { appAnalyticsInfoSelector, appServerInfoSelector, appElectronInfoSelector } from 'uiSrc/slices/app/info' +import { ipcCheckUpdates, ipcSendEvents } from 'uiSrc/electron/utils' import { ipcDeleteDownloadedVersion } from 'uiSrc/electron/utils/ipcDeleteStoreValues' const ConfigElectron = () => { diff --git a/redisinsight/ui/src/electron/utils/ipcCheckUpdates.ts b/redisinsight/ui/src/electron/utils/ipcCheckUpdates.ts index bdd77d5b33..2f0297d80f 100644 --- a/redisinsight/ui/src/electron/utils/ipcCheckUpdates.ts +++ b/redisinsight/ui/src/electron/utils/ipcCheckUpdates.ts @@ -1,4 +1,3 @@ -import { ipcRenderer } from 'electron' import { Dispatch } from 'react' import { omit } from 'lodash' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' @@ -10,13 +9,13 @@ import { GetServerInfoResponse } from 'apiSrc/modules/server/dto/server.dto' import { ElectronStorageItem, IpcEvent } from '../constants' export const ipcCheckUpdates = async (serverInfo: GetServerInfoResponse, dispatch: Dispatch) => { - const isUpdateDownloaded = await ipcRenderer.invoke( + const isUpdateDownloaded = await window.app.ipc.invoke( IpcEvent.getStoreValue, ElectronStorageItem.updateDownloaded ) - const isUpdateAvailable = await ipcRenderer.invoke( + const isUpdateAvailable = await window.app.ipc.invoke( IpcEvent.getStoreValue, ElectronStorageItem.isUpdateAvailable ) - const updateDownloadedVersion = await ipcRenderer.invoke( + const updateDownloadedVersion = await window.app.ipc.invoke( IpcEvent.getStoreValue, ElectronStorageItem.updateDownloadedVersion ) @@ -36,7 +35,7 @@ export const ipcCheckUpdates = async (serverInfo: GetServerInfoResponse, dispatc )) } - await ipcRenderer.invoke(IpcEvent.deleteStoreValue, ElectronStorageItem.updateDownloaded) + await window.app.ipc.invoke(IpcEvent.deleteStoreValue, ElectronStorageItem.updateDownloaded) } if (updateDownloadedVersion && !isUpdateAvailable && serverInfo.appVersion === updateDownloadedVersion) { @@ -47,19 +46,19 @@ export const ipcCheckUpdates = async (serverInfo: GetServerInfoResponse, dispatc } export const ipcSendEvents = async (serverInfo: GetServerInfoResponse) => { - const isUpdateDownloadedForTelemetry = await ipcRenderer.invoke( + const isUpdateDownloadedForTelemetry = await window.app.ipc.invoke( IpcEvent.getStoreValue, ElectronStorageItem.updateDownloadedForTelemetry ) - const isUpdateAvailable = await ipcRenderer.invoke( + const isUpdateAvailable = await window.app.ipc.invoke( IpcEvent.getStoreValue, ElectronStorageItem.isUpdateAvailable ) if (isUpdateDownloadedForTelemetry && !isUpdateAvailable) { - const newVer = await ipcRenderer.invoke( + const newVer = await window.app.ipc.invoke( IpcEvent.getStoreValue, ElectronStorageItem.updateDownloadedVersion ) - const prevVer = await ipcRenderer.invoke( + const prevVer = await window.app.ipc.invoke( IpcEvent.getStoreValue, ElectronStorageItem.updatePreviousVersion ) @@ -71,6 +70,9 @@ export const ipcSendEvents = async (serverInfo: GetServerInfoResponse) => { toVersion: newVer }, }) - await ipcRenderer.invoke(IpcEvent.deleteStoreValue, ElectronStorageItem.updateDownloadedForTelemetry) + await window.app.ipc.invoke( + IpcEvent.deleteStoreValue, + ElectronStorageItem.updateDownloadedForTelemetry, + ) } } diff --git a/redisinsight/ui/src/electron/utils/ipcDeleteStoreValues.ts b/redisinsight/ui/src/electron/utils/ipcDeleteStoreValues.ts index 4878f6fa21..019c6dab08 100644 --- a/redisinsight/ui/src/electron/utils/ipcDeleteStoreValues.ts +++ b/redisinsight/ui/src/electron/utils/ipcDeleteStoreValues.ts @@ -1,6 +1,5 @@ -import { ipcRenderer } from 'electron' import { ElectronStorageItem, IpcEvent } from 'uiSrc/electron/constants' export const ipcDeleteDownloadedVersion = async () => { - await ipcRenderer.invoke(IpcEvent.deleteStoreValue, ElectronStorageItem.updateDownloadedVersion) + await window.electron.ipcRenderer.invoke(IpcEvent.deleteStoreValue, ElectronStorageItem.updateDownloadedVersion) } diff --git a/redisinsight/ui/src/packages/clients-list/src/components/json-view/JSONView.tsx b/redisinsight/ui/src/packages/clients-list/src/components/json-view/JSONView.tsx index d5c0f3fe69..b639ed83bb 100644 --- a/redisinsight/ui/src/packages/clients-list/src/components/json-view/JSONView.tsx +++ b/redisinsight/ui/src/packages/clients-list/src/components/json-view/JSONView.tsx @@ -31,7 +31,7 @@ const JSONView = (props: Props) => { )} {!formattedValue && (
- +
)} diff --git a/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx b/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx index c0f4838d82..d4ce4dbf77 100644 --- a/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx +++ b/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx @@ -17,7 +17,7 @@ import KeyDetailsWrapper, { Props as KeyDetailsWrapperProps } from './components/key-details/KeyDetailsWrapper' import AddKey, { Props as AddKeyProps } from './components/add-key/AddKey' -import KeysHeader from './components/keys-header' +import BrowserSearchPanel from './components/browser-search-panel' import { Props as KeysHeaderProps } from './components/keys-header/KeysHeader' jest.mock('./components/key-list/KeyList', () => ({ @@ -38,7 +38,7 @@ jest.mock('./components/key-details/KeyDetailsWrapper', () => ({ default: jest.fn(), })) -jest.mock('./components/keys-header', () => ({ +jest.mock('./components/browser-search-panel', () => ({ __esModule: true, namedExport: jest.fn(), default: jest.fn(), @@ -68,7 +68,7 @@ const mockAddKey = (props: AddKeyProps) => (
) -const mockKeysHeader = (props: KeysHeaderProps) => ( +const mockBrowserSearchPanel = (props: KeysHeaderProps) => (