From 2687face2a53b39ec10c6a61501942367c245007 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 18 Oct 2022 08:36:58 +0300 Subject: [PATCH 001/201] #RI-3604 change path for Guides and Tutorials auto-update sources --- redisinsight/api/config/default.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index a767200649..95892157da 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -107,14 +107,14 @@ export default { }, guides: { updateUrl: process.env.GUIDES_UPDATE_URL - || 'https://github.com/RedisInsight/Guides/releases/download/latest', + || 'https://github.com/RedisInsight/Guides/releases/download/release', zip: process.env.GUIDES_ZIP || dataZipFileName, buildInfo: process.env.GUIDES_CHECKSUM || buildInfoFileName, devMode: !!process.env.GUIDES_DEV_PATH, }, tutorials: { updateUrl: process.env.TUTORIALS_UPDATE_URL - || 'https://github.com/RedisInsight/Tutorials/releases/download/latest', + || 'https://github.com/RedisInsight/Tutorials/releases/download/release', zip: process.env.TUTORIALS_ZIP || dataZipFileName, buildInfo: process.env.TUTORIALS_CHECKSUM || buildInfoFileName, devMode: !!process.env.TUTORIALS_DEV_PATH, From a439387857c2fede769f1921259cf55d293d55f1 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 10 Nov 2022 09:23:20 +0400 Subject: [PATCH 002/201] #RI-3527-add luascript recommendation --- .../database-analysis.module.ts | 2 + .../database-analysis.service.ts | 9 +- .../entities/database-analysis.entity.ts | 11 ++ .../models/database-analysis.ts | 10 ++ .../modules/database-analysis/models/index.ts | 1 + .../models/recommendation.ts | 12 ++ .../providers/database-analysis.provider.ts | 7 +- .../database-recommendations.module.ts | 9 ++ .../database-recommendations.provider.ts | 56 +++++++++ .../ui/src/assets/img/code-changes.svg | 3 + .../src/assets/img/configuration-changes.svg | 3 + redisinsight/ui/src/assets/img/upgrade.svg | 4 + .../constants/dbAnalisisRecomendations.json | 44 +++++++ .../recommendations-view/Recommendations.tsx | 117 ++++++++++++++++++ .../components/recommendations-view/index.ts | 3 + .../recommendations-view/styles.module.scss | 97 +++++++++++++++ .../sub-tabs/DatabaseAnalysisTabs.tsx | 69 +++++++++++ .../components/sub-tabs/constants.tsx | 23 ++++ .../components/sub-tabs/index.ts | 3 + .../components/sub-tabs/styles.module.scss | 11 ++ 20 files changed, 490 insertions(+), 4 deletions(-) create mode 100644 redisinsight/api/src/modules/database-analysis/models/recommendation.ts create mode 100644 redisinsight/api/src/modules/db-recommendations/database-recommendations.module.ts create mode 100644 redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts create mode 100644 redisinsight/ui/src/assets/img/code-changes.svg create mode 100644 redisinsight/ui/src/assets/img/configuration-changes.svg create mode 100644 redisinsight/ui/src/assets/img/upgrade.svg create mode 100644 redisinsight/ui/src/constants/dbAnalisisRecomendations.json create mode 100644 redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx create mode 100644 redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/index.ts create mode 100644 redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss create mode 100644 redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/DatabaseAnalysisTabs.tsx create mode 100644 redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/constants.tsx create mode 100644 redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/index.ts create mode 100644 redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/styles.module.scss diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.module.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.module.ts index 90b09ff06c..452933eaff 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.module.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.module.ts @@ -5,8 +5,10 @@ import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/databa import { DatabaseAnalysisProvider } from 'src/modules/database-analysis/providers/database-analysis.provider'; import { KeysScanner } from 'src/modules/database-analysis/scanner/keys-scanner'; import { KeyInfoProvider } from 'src/modules/database-analysis/scanner/key-info/key-info.provider'; +import { DatabaseRecommendationsModule } from 'src/modules/db-recommendations/database-recommendations.module'; @Module({ + imports: [DatabaseRecommendationsModule], controllers: [DatabaseAnalysisController], providers: [ DatabaseAnalysisService, diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index b1add19ae0..ac16c8f4ce 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -1,5 +1,6 @@ import { HttpException, Injectable, Logger } from '@nestjs/common'; import { IFindRedisClientInstanceByOptions } from 'src/modules/redis/redis.service'; +import { RecommendationsService } from 'src/modules/db-recommendations/providers/database-recommendations.provider'; import { catchAclError } from 'src/utils'; import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/database-analyzer'; import { plainToClass } from 'class-transformer'; @@ -16,9 +17,10 @@ export class DatabaseAnalysisService { constructor( private readonly databaseConnectionService: DatabaseConnectionService, - private readonly analyzer: DatabaseAnalyzer, - private readonly databaseAnalysisProvider: DatabaseAnalysisProvider, - private readonly scanner: KeysScanner, + private recommendationsService: RecommendationsService, + private analyzer: DatabaseAnalyzer, + private databaseAnalysisProvider: DatabaseAnalysisProvider, + private scanner: KeysScanner, ) {} /** @@ -56,6 +58,7 @@ export class DatabaseAnalysisService { databaseId: clientOptions.instanceId, ...dto, progress, + recommendations: await this.recommendationsService.getRecommendations(clientOptions), }, [].concat(...scanResults.map((nodeResult) => nodeResult.keys)))); return this.databaseAnalysisProvider.create(analysis); diff --git a/redisinsight/api/src/modules/database-analysis/entities/database-analysis.entity.ts b/redisinsight/api/src/modules/database-analysis/entities/database-analysis.entity.ts index 1874261ad7..37a8000c25 100644 --- a/redisinsight/api/src/modules/database-analysis/entities/database-analysis.entity.ts +++ b/redisinsight/api/src/modules/database-analysis/entities/database-analysis.entity.ts @@ -118,6 +118,17 @@ export class DatabaseAnalysisEntity { @Column({ nullable: true }) encryption: string; + @Column({ nullable: true, type: 'blob' }) + @Transform((object) => JSON.stringify(object), { toClassOnly: true }) + @Transform((str) => { + try { + return JSON.parse(str); + } catch (e) { + return undefined; + } + }, { toPlainOnly: true }) + recommendations: string; + @CreateDateColumn() @Index() @Expose() diff --git a/redisinsight/api/src/modules/database-analysis/models/database-analysis.ts b/redisinsight/api/src/modules/database-analysis/models/database-analysis.ts index 9579ab2013..80e9479074 100644 --- a/redisinsight/api/src/modules/database-analysis/models/database-analysis.ts +++ b/redisinsight/api/src/modules/database-analysis/models/database-analysis.ts @@ -6,6 +6,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { ScanFilter } from 'src/modules/database-analysis/models/scan-filter'; import { AnalysisProgress } from 'src/modules/database-analysis/models/analysis-progress'; import { SumGroup } from 'src/modules/database-analysis/models/sum-group'; +import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; export class DatabaseAnalysis { @ApiProperty({ @@ -114,4 +115,13 @@ export class DatabaseAnalysis { @Expose() @Type(() => SumGroup) expirationGroups: SumGroup[]; + + @ApiProperty({ + description: 'Expiration groups', + isArray: true, + type: () => Recommendation, + }) + @Expose() + @Type(() => Recommendation) + recommendations: Recommendation[]; } diff --git a/redisinsight/api/src/modules/database-analysis/models/index.ts b/redisinsight/api/src/modules/database-analysis/models/index.ts index 6eb8a3c250..7e2256b782 100644 --- a/redisinsight/api/src/modules/database-analysis/models/index.ts +++ b/redisinsight/api/src/modules/database-analysis/models/index.ts @@ -6,3 +6,4 @@ export * from './simple-summary'; export * from './database-analysis'; export * from './short-database-analysis'; export * from './sum-group'; +export * from './recommendation'; diff --git a/redisinsight/api/src/modules/database-analysis/models/recommendation.ts b/redisinsight/api/src/modules/database-analysis/models/recommendation.ts new file mode 100644 index 0000000000..e3910bbfe1 --- /dev/null +++ b/redisinsight/api/src/modules/database-analysis/models/recommendation.ts @@ -0,0 +1,12 @@ +import { Expose } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class Recommendation { + @ApiProperty({ + description: 'Recommendation name', + type: String, + example: 'luaScript', + }) + @Expose() + name: string; +} diff --git a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts index 8731ba4f97..388e8b5c05 100644 --- a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts +++ b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts @@ -26,6 +26,7 @@ export class DatabaseAnalysisProvider { 'filter', 'progress', 'expirationGroups', + 'recommendations', ]; constructor( @@ -40,7 +41,11 @@ export class DatabaseAnalysisProvider { * @param analysis */ async create(analysis: Partial): Promise { - const entity = await this.repository.save(await this.encryptEntity(plainToClass(DatabaseAnalysisEntity, analysis))); + const entity = await this.repository.save( + await this.encryptEntity(plainToClass(DatabaseAnalysisEntity, { + ...analysis, + })), + ); // cleanup history and ignore error if any try { diff --git a/redisinsight/api/src/modules/db-recommendations/database-recommendations.module.ts b/redisinsight/api/src/modules/db-recommendations/database-recommendations.module.ts new file mode 100644 index 0000000000..097526deae --- /dev/null +++ b/redisinsight/api/src/modules/db-recommendations/database-recommendations.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { RecommendationsService } from './providers/database-recommendations.provider'; + +@Module({ + providers: [RecommendationsService], + exports: [RecommendationsService], +}) +export class DatabaseRecommendationsModule {} diff --git a/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts b/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts new file mode 100644 index 0000000000..9c931c7636 --- /dev/null +++ b/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cluster, Command } from 'ioredis'; +import { get, max } from 'lodash'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/redis/redis.service'; +import { DatabaseConnectionService } from 'src/modules/database/database-connection.service'; +import { convertRedisInfoReplyToObject } from 'src/utils'; +import { AppTool } from 'src/models'; + +const minNumberOfCachedScripts = 10; + +@Injectable() +export class RecommendationsService { + private logger = new Logger('DatabaseRecommendationsService'); + + constructor( + private readonly databaseConnectionService: DatabaseConnectionService, + ) {} + + public async getRecommendations( + clientOptions: IFindRedisClientInstanceByOptions, + ) { + const recommendations = []; + if (await this.getLuaScriptRecommendation(clientOptions)) { + recommendations.push({ name: 'luaScript' }); + } + + return recommendations; + } + + async getLuaScriptRecommendation( + clientOptions: IFindRedisClientInstanceByOptions, + ): Promise { + let nodes = []; + const client = await this.databaseConnectionService.createClient({ + databaseId: clientOptions.instanceId, + namespace: AppTool.Common, + }); + + if (client instanceof Cluster) { + nodes = client.nodes('master'); + } else { + nodes = [client]; + } + + const nodesNumbersOfCachedScripts = Promise.all(nodes.map(async (node) => { + const info = convertRedisInfoReplyToObject( + await node.sendCommand( + new Command('info', ['memory'], { replyEncoding: 'utf8' }), + ) as string, + ); + return get(info, 'memory.number_of_cached_scripts', {}); + })); + + return max(await nodesNumbersOfCachedScripts) > minNumberOfCachedScripts; + } +} diff --git a/redisinsight/ui/src/assets/img/code-changes.svg b/redisinsight/ui/src/assets/img/code-changes.svg new file mode 100644 index 0000000000..d421faa639 --- /dev/null +++ b/redisinsight/ui/src/assets/img/code-changes.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/assets/img/configuration-changes.svg b/redisinsight/ui/src/assets/img/configuration-changes.svg new file mode 100644 index 0000000000..337938737e --- /dev/null +++ b/redisinsight/ui/src/assets/img/configuration-changes.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/assets/img/upgrade.svg b/redisinsight/ui/src/assets/img/upgrade.svg new file mode 100644 index 0000000000..9bd84c72bc --- /dev/null +++ b/redisinsight/ui/src/assets/img/upgrade.svg @@ -0,0 +1,4 @@ + + + + diff --git a/redisinsight/ui/src/constants/dbAnalisisRecomendations.json b/redisinsight/ui/src/constants/dbAnalisisRecomendations.json new file mode 100644 index 0000000000..dea76553d4 --- /dev/null +++ b/redisinsight/ui/src/constants/dbAnalisisRecomendations.json @@ -0,0 +1,44 @@ +{ + "luaScript": { + "id": "luaScript", + "title":"Avoid dynamic Lua script", + "content": [ + { + "id": "1", + "type": "span", + "value": "Refrain from generating dynamic scripts, which can cause your Lua cache to grow and get out of control. Memory is consumed as scripts are loaded. If you have to use dynamic Lua scripts, then remember to track your Lua memory consumption and flush the cache periodically with a SCRIPT FLUSH, also do not hardcode and/or programmatically generate key names in your Lua scripts because it makes them useless in a clustered Redis setup. " + }, + { + "id": "2", + "type": "link", + "value": { + "href": "https://docs.redis.com/latest/ri/memory-optimizations/", + "name": "Read more" + } + } + ], + "badges": ["configuration_changes", "code_changes", "upgrade"] + }, + "1": { + "id": "1", + "title":"Avoid", + "content": [ + { + "id": "1", + "type": "paragraph", + "value": "some text as paragraph" + }, + { + "id": "2", + "type": "span", + "value": "some text as span" + }, + { + "id": "3", + "type": "image", + "value": "uiSrc/assets/img/welcome_bg_dark.jpg" + } + ], + "badges": ["configuration_changes", "code_changes"] + } +} \ No newline at end of file diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx new file mode 100644 index 0000000000..f0da01639c --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import cx from 'classnames' +import { + EuiAccordion, + EuiPanel, + EuiTitle, + EuiText, + EuiTextColor, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiLink, + EuiImage, +} from '@elastic/eui' +import { dbAnalysisSelector } from 'uiSrc/slices/analytics/dbAnalysis' +import recommendationsContent from 'uiSrc/constants/dbAnalisisRecomendations.json' +import { ReactComponent as CodeIcon } from 'uiSrc/assets/img/code-changes.svg' +import { ReactComponent as ConfigurationIcon } from 'uiSrc/assets/img/configuration-changes.svg' +import { ReactComponent as UpgradeIcon } from 'uiSrc/assets/img/upgrade.svg' +import logo from 'uiSrc/assets/img/welcome_bg_dark.jpg' + +import styles from './styles.module.scss' + +const badgesContent = [ + { id: 'code_changes', icon: , name: 'Code Changes' }, + { id: 'configuration_changes', icon: , name: 'Configuration Changes' }, + { id: 'upgrade', icon: , name: 'Upgrade' }, +] + +const renderBadges = (badges) => ( + + {badgesContent.map(({ id, icon, name }) => (badges.indexOf(id) === -1 + ? + : ( + +
+ {icon} + {name} +
+
+ )))} +
+) + +const parseContent = ({ type, value }: { type: string, value: any }) => { + switch (type) { + case 'paragraph': + return {value} + case 'span': + return {value} + case 'link': + return {value.name} + case 'image': + return + default: + return value + } +} + +const Recommendations = () => { + const { data, loading } = useSelector(dbAnalysisSelector) + const { recommendations = [] } = data ?? {} + + if (loading) { + return ( +
+ + Uploading recommendations... +
+ ) + } + + if (!recommendations.length) { + return ( +
+ No Recommendations at the moment +
+ ) + } + + return ( +
+ +

{`RECOMMENDATIONS (${recommendations.length}):`}

+
+ {recommendations.map(({ name }) => { + const { id, title = '', content = '', badges } = recommendationsContent[name] + return ( +
+ + + {content.map((item: { type: string, value: any, id: string }) => + ( + + {parseContent(item)} + + ))} + + +
+ {renderBadges(badges)} +
+
+ ) + })} +
+ ) +} + +export default Recommendations diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/index.ts b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/index.ts new file mode 100644 index 0000000000..5635179102 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/index.ts @@ -0,0 +1,3 @@ +import Recommendations from './Recommendations' + +export default Recommendations diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss new file mode 100644 index 0000000000..fd40b23db0 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -0,0 +1,97 @@ +@import "@elastic/eui/src/global_styling/mixins/helpers"; +@import "@elastic/eui/src/components/table/mixins"; +@import "@elastic/eui/src/global_styling/index"; + +.wrapper { + @include euiScrollBar; + overflow-y: auto; + overflow-x: hidden; + max-height: 100%; +} + +.title { + margin: 32px 0 24px; +} + +.container { + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + height: 100%; + + .emptyMessage { + font: normal normal 500 16px/19px Graphik, sans-serif; + } + + .spinner { + width: 38px; + height: 38px; + margin-bottom: 12px; + border-top-color: var(--euiColorPrimary) !important; + } + + .spinnerText { + color: var(--htmlColor); + } +} + +.recommendation { + border-radius: 8px; + border: 1px solid #323232; + background-color: var(--euiColorLightestShade); + margin-bottom: 6px; + padding: 18px; + + .accordion { + margin-bottom: 18px; + } + + .accordionContent { + padding: 18px 0 17px !important; + } + + :global(.euiAccordion__triggerWrapper) { + background-color: transparent; + } + + :global(.euiPanel.euiPanel--subdued) { + background-color: transparent; + } + + :global(.euiAccordion.euiAccordion-isOpen .euiAccordion__triggerWrapper) { + border-bottom: none; + } + + :global(.euiAccordion .euiAccordion__triggerWrapper) { + border-bottom: 1px solid var(--separatorColor); + } + + .accordionBtn { + font: normal normal 500 16px/19px Graphik, sans-serif; + padding-bottom: 22px; + } + + .badgesContainer { + margin-right: 48px; + + .badge { + margin: 18px; + + .badgeIcon { + margin-right: 14px; + fill: var(--badgeIconColor); + } + + .badgeWrapper { + display: flex; + align-items: center; + } + } + } + + .span { + display: inline; + font: normal normal normal 14px/24px Graphik, sans-serif; + } +} \ No newline at end of file diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/DatabaseAnalysisTabs.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/DatabaseAnalysisTabs.tsx new file mode 100644 index 0000000000..ca609f19c7 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/DatabaseAnalysisTabs.tsx @@ -0,0 +1,69 @@ +import React, { useMemo } from 'react' +import { EuiTab, EuiTabs } from '@elastic/eui' +import { isNull } from 'lodash' +import { useDispatch, useSelector } from 'react-redux' +import { EmptyMessage } from 'uiSrc/pages/databaseAnalysis/constants' +import { EmptyAnalysisMessage } from 'uiSrc/pages/databaseAnalysis/components' +import { setDatabaseAnalysisViewTab, dbAnalysisViewTabSelector } from 'uiSrc/slices/analytics/dbAnalysis' +import { DatabaseAnalysisViewTab } from 'uiSrc/slices/interfaces/analytics' +import { ShortDatabaseAnalysis, DatabaseAnalysis } from 'apiSrc/modules/database-analysis/models' + +import { databaseAnalysisTabs } from './constants' +import styles from './styles.module.scss' + +interface Props { + loading: boolean + reports: ShortDatabaseAnalysis[] + data: DatabaseAnalysis +} + +const DatabaseAnalysisTabs = (props: Props) => { + const { loading, reports, data } = props + + const viewTab = useSelector(dbAnalysisViewTabSelector) + + const dispatch = useDispatch() + + const selectedTabContent = useMemo(() => databaseAnalysisTabs.find((tab) => tab.id === viewTab)?.content, [viewTab]) + + const onSelectedTabChanged = (id: DatabaseAnalysisViewTab) => { + dispatch(setDatabaseAnalysisViewTab(id)) + } + + const renderTabs = () => ( + databaseAnalysisTabs.map(({ id, name }) => ( + onSelectedTabChanged(id)} + isSelected={id === viewTab} + > + {name} + + ))) + + if (!loading && !reports.length) { + return ( +
+ +
+ ) + } + if (!loading && !!reports.length && isNull(data?.totalKeys)) { + return ( +
+ +
+ ) + } + + return ( + <> + {renderTabs()} +
+ {selectedTabContent} +
+ + ) +} + +export default DatabaseAnalysisTabs diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/constants.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/constants.tsx new file mode 100644 index 0000000000..58b6df423a --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/constants.tsx @@ -0,0 +1,23 @@ +import React, { ReactNode } from 'react' +import { DatabaseAnalysisViewTab } from 'uiSrc/slices/interfaces/analytics' +import AnalysisDataView from '../analysis-data-view' +import Recommendations from '../recommendations-view' + +interface DatabaseAnalysisTabs { + id: DatabaseAnalysisViewTab, + name: string, + content: ReactNode +} + +export const databaseAnalysisTabs: DatabaseAnalysisTabs[] = [ + { + id: DatabaseAnalysisViewTab.DataSummary, + name: 'Data Summary', + content: + }, + { + id: DatabaseAnalysisViewTab.Recommendations, + name: 'Recommendations', + content: + }, +] diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/index.ts b/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/index.ts new file mode 100644 index 0000000000..dbfeb488e6 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/index.ts @@ -0,0 +1,3 @@ +import DatabaseAnalysisTabs from './DatabaseAnalysisTabs' + +export default DatabaseAnalysisTabs diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/styles.module.scss new file mode 100644 index 0000000000..a8b2530f68 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/styles.module.scss @@ -0,0 +1,11 @@ +@import "@elastic/eui/src/global_styling/mixins/helpers"; +@import "@elastic/eui/src/components/table/mixins"; +@import "@elastic/eui/src/global_styling/index"; + +.container { + height: calc(100% - 126px); +} + +.emptyMessageWrapper { + height: calc(100% - 96px); +} \ No newline at end of file From 4cce0eea2852702fbd9cf897824d8d6a9e6923b6 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 10 Nov 2022 09:24:13 +0400 Subject: [PATCH 003/201] #RI-3527-add luascript recommendation --- .../databaseAnalysis/DatabaseAnalysisPage.tsx | 8 +++-- .../analysis-data-view/AnalysisDataView.tsx | 30 +++++++------------ .../analysis-data-view/styles.module.scss | 2 +- .../empty-analysis-message/styles.module.scss | 3 +- .../ui/src/slices/analytics/dbAnalysis.ts | 8 ++++- .../ui/src/slices/interfaces/analytics.ts | 6 ++++ .../themes/dark_theme/_dark_theme.lazy.scss | 3 ++ .../themes/dark_theme/_theme_color.scss | 3 ++ .../themes/light_theme/_light_theme.lazy.scss | 3 ++ .../themes/light_theme/_theme_color.scss | 3 ++ 10 files changed, 44 insertions(+), 25 deletions(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/DatabaseAnalysisPage.tsx b/redisinsight/ui/src/pages/databaseAnalysis/DatabaseAnalysisPage.tsx index 61cb5a42ed..2dc4d1c0cc 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/DatabaseAnalysisPage.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/DatabaseAnalysisPage.tsx @@ -17,7 +17,7 @@ import { sendPageViewTelemetry, sendEventTelemetry, TelemetryPageView, Telemetry import { formatLongName, getDbIndex, setTitle } from 'uiSrc/utils' import Header from './components/header' -import AnalysisDataView from './components/analysis-data-view' +import DatabaseAnalysisTabs from './components/sub-tabs' import styles from './styles.module.scss' const DatabaseAnalysisPage = () => { @@ -89,7 +89,11 @@ const DatabaseAnalysisPage = () => { progress={data?.progress} analysisLoading={analysisLoading} /> - + ) } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.tsx index 1c6e84cf7f..00aedda093 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' + import cx from 'classnames' -import { isNull } from 'lodash' import { useParams } from 'react-router-dom' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { Nullable } from 'uiSrc/utils' +import { dbAnalysisSelector, dbAnalysisReportsSelector } from 'uiSrc/slices/analytics/dbAnalysis' import { DEFAULT_EXTRAPOLATION, EmptyMessage, SectionName } from 'uiSrc/pages/databaseAnalysis/constants' import { TopKeys, @@ -12,18 +13,14 @@ import { SummaryPerData, ExpirationGroupsView } from 'uiSrc/pages/databaseAnalysis/components' -import { ShortDatabaseAnalysis, DatabaseAnalysis } from 'apiSrc/modules/database-analysis/models' +// import { ShortDatabaseAnalysis, DatabaseAnalysis } from 'apiSrc/modules/database-analysis/models' import styles from './styles.module.scss' -export interface Props { - data: Nullable - reports: ShortDatabaseAnalysis[] - loading: boolean -} +const AnalysisDataView = () => { + const { loading, data } = useSelector(dbAnalysisSelector) + const { data: reports } = useSelector(dbAnalysisReportsSelector) -const AnalysisDataView = (props: Props) => { - const { loading, reports = [], data } = props const [extrapolation, setExtrapolation] = useState(DEFAULT_EXTRAPOLATION) const { instanceId } = useParams<{ instanceId: string }>() @@ -46,17 +43,12 @@ const AnalysisDataView = (props: Props) => { }) } + if (!loading && !!reports.length && data?.totalKeys?.total === 0) { + return () + } + return ( <> - {!loading && !reports.length && ( - - )} - {!loading && !!reports.length && data?.totalKeys?.total === 0 && ( - - )} - {!loading && !!reports.length && isNull(data?.totalKeys) && ( - - )}
) => { state.history.showNoExpiryGroup = payload }, + setDatabaseAnalysisViewTab: (state, { payload }: PayloadAction) => { + state.selectedViewTab = payload + }, } }) export const dbAnalysisSelector = (state: RootState) => state.analytics.databaseAnalysis export const dbAnalysisReportsSelector = (state: RootState) => state.analytics.databaseAnalysis.history +export const dbAnalysisViewTabSelector = (state: RootState) => state.analytics.databaseAnalysis.selectedViewTab export const { setDatabaseAnalysisInitialState, @@ -71,6 +76,7 @@ export const { loadDBAnalysisReportsError, setSelectedAnalysisId, setShowNoExpiryGroup, + setDatabaseAnalysisViewTab, } = databaseAnalysisSlice.actions // The reducer diff --git a/redisinsight/ui/src/slices/interfaces/analytics.ts b/redisinsight/ui/src/slices/interfaces/analytics.ts index a28cddd868..2e5b4b4b68 100644 --- a/redisinsight/ui/src/slices/interfaces/analytics.ts +++ b/redisinsight/ui/src/slices/interfaces/analytics.ts @@ -23,6 +23,7 @@ export interface StateDatabaseAnalysis { loading: boolean error: string data: Nullable + selectedViewTab: DatabaseAnalysisViewTab history: { loading: boolean error: string @@ -41,3 +42,8 @@ export enum AnalyticsViewTab { DatabaseAnalysis = 'DatabaseAnalysis', SlowLog = 'SlowLog', } + +export enum DatabaseAnalysisViewTab { + DataSummary = 'DataSummary', + Recommendations = 'Recommendations', +} diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index 2dee6e4865..39a11d6eeb 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss @@ -175,4 +175,7 @@ // Pub/Sub --pubSubClientsBadge: #{$pubSubClientsBadge}; + + // Database analysis + --badgeIconColor: #{$badgeIconColor}; } diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss index bcfb32c4f2..7d7a086ee5 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -135,3 +135,6 @@ $wbActiveIconColor: #8ba2ff; // PubSub $pubSubClientsBadge: #008000; + +// Database analysis +$badgeIconColor : #D8AB52; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss index a9fbfa450e..b413a7a29a 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss @@ -177,4 +177,7 @@ // Pub/Sub --pubSubClientsBadge: #{$pubSubClientsBadge}; + + // Database analysis + --badgeIconColor: #{$badgeIconColor}; } diff --git a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss index e925a43fe6..c7da8d8e30 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -132,3 +132,6 @@ $wbActiveIconColor: #3163D8; // Pub/Sub $pubSubClientsBadge: #b5cea8; + +// Database analysis +$badgeIconColor : #415681; From 74cbda3db968de4a6f43ab920bf8f504d66bdef6 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 10 Nov 2022 09:55:02 +0400 Subject: [PATCH 004/201] #RI-3527-remove deprecated code --- ...ns.json => dbAnalysisRecommendations.json} | 26 +--------- .../analysis-data-view/AnalysisDataView.tsx | 1 - .../recommendations-view/Recommendations.tsx | 48 +----------------- .../components/recommendations-view/utils.tsx | 49 +++++++++++++++++++ .../components/sub-tabs/styles.module.scss | 2 +- 5 files changed, 54 insertions(+), 72 deletions(-) rename redisinsight/ui/src/constants/{dbAnalisisRecomendations.json => dbAnalysisRecommendations.json} (61%) create mode 100644 redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx diff --git a/redisinsight/ui/src/constants/dbAnalisisRecomendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json similarity index 61% rename from redisinsight/ui/src/constants/dbAnalisisRecomendations.json rename to redisinsight/ui/src/constants/dbAnalysisRecommendations.json index dea76553d4..4a63e8d3c9 100644 --- a/redisinsight/ui/src/constants/dbAnalisisRecomendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -17,28 +17,6 @@ } } ], - "badges": ["configuration_changes", "code_changes", "upgrade"] - }, - "1": { - "id": "1", - "title":"Avoid", - "content": [ - { - "id": "1", - "type": "paragraph", - "value": "some text as paragraph" - }, - { - "id": "2", - "type": "span", - "value": "some text as span" - }, - { - "id": "3", - "type": "image", - "value": "uiSrc/assets/img/welcome_bg_dark.jpg" - } - ], - "badges": ["configuration_changes", "code_changes"] + "badges": ["upgrade"] } -} \ No newline at end of file +} diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.tsx index 00aedda093..804cfaa37e 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.tsx @@ -13,7 +13,6 @@ import { SummaryPerData, ExpirationGroupsView } from 'uiSrc/pages/databaseAnalysis/components' -// import { ShortDatabaseAnalysis, DatabaseAnalysis } from 'apiSrc/modules/database-analysis/models' import styles from './styles.module.scss' diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index f0da01639c..846b81a568 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -6,58 +6,14 @@ import { EuiPanel, EuiTitle, EuiText, - EuiTextColor, - EuiFlexGroup, - EuiFlexItem, EuiLoadingSpinner, - EuiLink, - EuiImage, } from '@elastic/eui' import { dbAnalysisSelector } from 'uiSrc/slices/analytics/dbAnalysis' -import recommendationsContent from 'uiSrc/constants/dbAnalisisRecomendations.json' -import { ReactComponent as CodeIcon } from 'uiSrc/assets/img/code-changes.svg' -import { ReactComponent as ConfigurationIcon } from 'uiSrc/assets/img/configuration-changes.svg' -import { ReactComponent as UpgradeIcon } from 'uiSrc/assets/img/upgrade.svg' -import logo from 'uiSrc/assets/img/welcome_bg_dark.jpg' +import recommendationsContent from 'uiSrc/constants/dbAnalysisRecommendations.json' +import { parseContent, renderBadges } from './utils' import styles from './styles.module.scss' -const badgesContent = [ - { id: 'code_changes', icon: , name: 'Code Changes' }, - { id: 'configuration_changes', icon: , name: 'Configuration Changes' }, - { id: 'upgrade', icon: , name: 'Upgrade' }, -] - -const renderBadges = (badges) => ( - - {badgesContent.map(({ id, icon, name }) => (badges.indexOf(id) === -1 - ? - : ( - -
- {icon} - {name} -
-
- )))} -
-) - -const parseContent = ({ type, value }: { type: string, value: any }) => { - switch (type) { - case 'paragraph': - return {value} - case 'span': - return {value} - case 'link': - return {value.name} - case 'image': - return - default: - return value - } -} - const Recommendations = () => { const { data, loading } = useSelector(dbAnalysisSelector) const { recommendations = [] } = data ?? {} diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx new file mode 100644 index 0000000000..fb0ea72339 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { + EuiTextColor, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiImage, +} from '@elastic/eui' +import { ReactComponent as CodeIcon } from 'uiSrc/assets/img/code-changes.svg' +import { ReactComponent as ConfigurationIcon } from 'uiSrc/assets/img/configuration-changes.svg' +import { ReactComponent as UpgradeIcon } from 'uiSrc/assets/img/upgrade.svg' + +import styles from './styles.module.scss' + +const badgesContent = [ + { id: 'code_changes', icon: , name: 'Code Changes' }, + { id: 'configuration_changes', icon: , name: 'Configuration Changes' }, + { id: 'upgrade', icon: , name: 'Upgrade' }, +] + +export const renderBadges = (badges: string[]) => ( + + {badgesContent.map(({ id, icon, name }) => (badges.indexOf(id) === -1 + ? + : ( + +
+ {icon} + {name} +
+
+ )))} +
+) + +export const parseContent = ({ type, value }: { type: string, value: any }) => { + switch (type) { + case 'paragraph': + return {value} + case 'span': + return {value} + case 'link': + return {value.name} + case 'image': + return + default: + return value + } +} diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/styles.module.scss index a8b2530f68..7e288fa094 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/styles.module.scss @@ -8,4 +8,4 @@ .emptyMessageWrapper { height: calc(100% - 96px); -} \ No newline at end of file +} From af415056578e2f7e8465e2f030c04e4041b1de31 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 10 Nov 2022 10:01:11 +0400 Subject: [PATCH 005/201] #RI-3527-remove deprecated code --- .../components/recommendations-view/styles.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index fd40b23db0..dab0d51d1c 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -94,4 +94,4 @@ display: inline; font: normal normal normal 14px/24px Graphik, sans-serif; } -} \ No newline at end of file +} From 5d018b4ab0772a622c0dfd98ac9e3998f7e61af3 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 10 Nov 2022 10:31:55 +0400 Subject: [PATCH 006/201] #RI-3527-fix recommendation be --- .../database-analysis/database-analysis.service.ts | 8 ++++---- .../entities/database-analysis.entity.ts | 10 ++-------- .../database-analysis/models/database-analysis.ts | 2 +- .../providers/database-analysis.provider.ts | 2 +- .../providers/database-recommendations.provider.ts | 12 +++++++++--- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index ac16c8f4ce..86a10c6a99 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -17,10 +17,10 @@ export class DatabaseAnalysisService { constructor( private readonly databaseConnectionService: DatabaseConnectionService, - private recommendationsService: RecommendationsService, - private analyzer: DatabaseAnalyzer, - private databaseAnalysisProvider: DatabaseAnalysisProvider, - private scanner: KeysScanner, + private readonly recommendationsService: RecommendationsService, + private readonly analyzer: DatabaseAnalyzer, + private readonly databaseAnalysisProvider: DatabaseAnalysisProvider, + private readonly scanner: KeysScanner, ) {} /** diff --git a/redisinsight/api/src/modules/database-analysis/entities/database-analysis.entity.ts b/redisinsight/api/src/modules/database-analysis/entities/database-analysis.entity.ts index 37a8000c25..936b8bade3 100644 --- a/redisinsight/api/src/modules/database-analysis/entities/database-analysis.entity.ts +++ b/redisinsight/api/src/modules/database-analysis/entities/database-analysis.entity.ts @@ -119,14 +119,8 @@ export class DatabaseAnalysisEntity { encryption: string; @Column({ nullable: true, type: 'blob' }) - @Transform((object) => JSON.stringify(object), { toClassOnly: true }) - @Transform((str) => { - try { - return JSON.parse(str); - } catch (e) { - return undefined; - } - }, { toPlainOnly: true }) + @DataAsJsonString() + @Expose() recommendations: string; @CreateDateColumn() diff --git a/redisinsight/api/src/modules/database-analysis/models/database-analysis.ts b/redisinsight/api/src/modules/database-analysis/models/database-analysis.ts index 80e9479074..2ae523b071 100644 --- a/redisinsight/api/src/modules/database-analysis/models/database-analysis.ts +++ b/redisinsight/api/src/modules/database-analysis/models/database-analysis.ts @@ -117,7 +117,7 @@ export class DatabaseAnalysis { expirationGroups: SumGroup[]; @ApiProperty({ - description: 'Expiration groups', + description: 'Recommendations', isArray: true, type: () => Recommendation, }) diff --git a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts index 388e8b5c05..c2fef9bec2 100644 --- a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts +++ b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts @@ -68,7 +68,7 @@ export class DatabaseAnalysisProvider { this.logger.error(`Database analysis with id:${id} was not Found`); throw new NotFoundException(ERROR_MESSAGES.DATABASE_ANALYSIS_NOT_FOUND); } - + console.log(classToClass(DatabaseAnalysis, await this.decryptEntity(entity, true))) return classToClass(DatabaseAnalysis, await this.decryptEntity(entity, true)); } diff --git a/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts b/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts index 9c931c7636..7e997c0e18 100644 --- a/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts +++ b/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Cluster, Command } from 'ioredis'; import { get, max } from 'lodash'; import { IFindRedisClientInstanceByOptions } from 'src/modules/redis/redis.service'; @@ -10,12 +10,14 @@ const minNumberOfCachedScripts = 10; @Injectable() export class RecommendationsService { - private logger = new Logger('DatabaseRecommendationsService'); - constructor( private readonly databaseConnectionService: DatabaseConnectionService, ) {} + /** + * Get database recommendations + * @param clientOptions + */ public async getRecommendations( clientOptions: IFindRedisClientInstanceByOptions, ) { @@ -27,6 +29,10 @@ export class RecommendationsService { return recommendations; } + /** + * Check lua script recommendation + * @param clientOptions + */ async getLuaScriptRecommendation( clientOptions: IFindRedisClientInstanceByOptions, ): Promise { From d85f125bfe57cabe6d20e311bb3baa60411aa427 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Fri, 11 Nov 2022 15:45:57 +0400 Subject: [PATCH 007/201] #RI-3527-add tests, implement demo improvements --- .../constants/dbAnalysisRecommendations.json | 2 +- .../DatabaseAnalysisPage.spec.tsx | 11 -- .../databaseAnalysis/DatabaseAnalysisPage.tsx | 2 +- .../AnalysisDataView.spec.tsx | 160 ++++++++++++++---- .../analysis-data-view/AnalysisDataView.tsx | 2 +- .../DatabaseAnalysisTabs.spec.tsx | 83 +++++++++ .../DatabaseAnalysisTabs.tsx | 18 +- .../{sub-tabs => data-nav-tabs}/constants.tsx | 6 +- .../{sub-tabs => data-nav-tabs}/index.ts | 0 .../styles.module.scss | 0 .../Recommendations.spec.tsx | 135 +++++++++++++++ .../recommendations-view/Recommendations.tsx | 20 +-- .../recommendations-view/styles.module.scss | 33 ++-- .../components/recommendations-view/utils.tsx | 13 +- .../components/top-namespace/TopNamespace.tsx | 5 + 15 files changed, 395 insertions(+), 95 deletions(-) create mode 100644 redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.spec.tsx rename redisinsight/ui/src/pages/databaseAnalysis/components/{sub-tabs => data-nav-tabs}/DatabaseAnalysisTabs.tsx (76%) rename redisinsight/ui/src/pages/databaseAnalysis/components/{sub-tabs => data-nav-tabs}/constants.tsx (77%) rename redisinsight/ui/src/pages/databaseAnalysis/components/{sub-tabs => data-nav-tabs}/index.ts (100%) rename redisinsight/ui/src/pages/databaseAnalysis/components/{sub-tabs => data-nav-tabs}/styles.module.scss (100%) create mode 100644 redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 4a63e8d3c9..7cde7e7a48 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -17,6 +17,6 @@ } } ], - "badges": ["upgrade"] + "badges": ["code_changes"] } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/DatabaseAnalysisPage.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/DatabaseAnalysisPage.spec.tsx index d7669a613b..6e066f47a8 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/DatabaseAnalysisPage.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/DatabaseAnalysisPage.spec.tsx @@ -7,17 +7,6 @@ import DatabaseAnalysisPage from './DatabaseAnalysisPage' jest.mock('uiSrc/slices/analytics/dbAnalysis', () => ({ ...jest.requireActual('uiSrc/slices/analytics/dbAnalysis'), fetchDBAnalysisReportsHistory: jest.fn(), - dbAnalysisSelector: jest.fn().mockReturnValue({ - loading: false, - error: '', - data: null, - history: { - loading: false, - error: '', - data: [], - selectedAnalysis: null, - } - }), })) /** diff --git a/redisinsight/ui/src/pages/databaseAnalysis/DatabaseAnalysisPage.tsx b/redisinsight/ui/src/pages/databaseAnalysis/DatabaseAnalysisPage.tsx index 2dc4d1c0cc..1580584ad0 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/DatabaseAnalysisPage.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/DatabaseAnalysisPage.tsx @@ -17,7 +17,7 @@ import { sendPageViewTelemetry, sendEventTelemetry, TelemetryPageView, Telemetry import { formatLongName, getDbIndex, setTitle } from 'uiSrc/utils' import Header from './components/header' -import DatabaseAnalysisTabs from './components/sub-tabs' +import DatabaseAnalysisTabs from './components/data-nav-tabs' import styles from './styles.module.scss' const DatabaseAnalysisPage = () => { diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.spec.tsx index a463a14a23..552eb718c6 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.spec.tsx @@ -1,21 +1,44 @@ import React from 'react' -import { instance, mock } from 'ts-mockito' import { MOCK_ANALYSIS_REPORT_DATA } from 'uiSrc/mocks/data/analysis' import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/analytics/clusterDetailsHandlers' +import { dbAnalysisSelector, dbAnalysisReportsSelector } from 'uiSrc/slices/analytics/dbAnalysis' import { SectionName } from 'uiSrc/pages/databaseAnalysis' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { formatBytes, getGroupTypeDisplay } from 'uiSrc/utils' import { numberWithSpaces } from 'uiSrc/utils/numbers' import { fireEvent, render, screen, within } from 'uiSrc/utils/test-utils' -import AnalysisDataView, { Props } from './AnalysisDataView' +import AnalysisDataView from './AnalysisDataView' jest.mock('uiSrc/telemetry', () => ({ ...jest.requireActual('uiSrc/telemetry'), sendEventTelemetry: jest.fn(), })) -const mockedProps = mock() +const mockdbAnalysisSelector = jest.requireActual('uiSrc/slices/analytics/dbAnalysis') +const mockdbAnalysisReportsSelector = jest.requireActual('uiSrc/slices/analytics/dbAnalysis') + +jest.mock('uiSrc/slices/analytics/dbAnalysis', () => ({ + ...jest.requireActual('uiSrc/slices/analytics/dbAnalysis'), + dbAnalysisSelector: jest.fn().mockReturnValue({ + loading: false, + error: '', + data: null, + history: { + loading: false, + error: '', + data: [], + selectedAnalysis: null, + } + }), + dbAnalysisReportsSelector: jest.fn().mockReturnValue({ + loading: false, + error: '', + data: [], + selectedAnalysis: null, + }), +})) + const mockReports = [ { id: MOCK_ANALYSIS_REPORT_DATA.id, @@ -34,34 +57,42 @@ const extrapolateResultsId = 'extrapolate-results' describe('AnalysisDataView', () => { it('should render', () => { - expect(render()).toBeTruthy() + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + })) + + expect(render()).toBeTruthy() }) it('should render only table when loading="true"', () => { - render() + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + loading: true + })) + + render() expect(screen.queryByTestId('empty-analysis-no-reports')).not.toBeInTheDocument() expect(screen.queryByTestId('empty-analysis-no-keys')).not.toBeInTheDocument() }) it('should render empty-data-message-no-keys when total=0 ', () => { - const mockedData = { totalKeys: { total: 0 } } + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { totalKeys: { total: 0 } }, + })); + (dbAnalysisReportsSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisReportsSelector, + data: mockReports, + })) + render( - + ) expect(screen.queryByTestId('empty-analysis-no-reports')).not.toBeInTheDocument() expect(screen.queryByTestId('empty-analysis-no-keys')).toBeInTheDocument() }) - - it('should render empty-data-message-no-reports when reports=[] ', () => { - render( - - ) - - expect(screen.queryByTestId('empty-analysis-no-reports')).toBeInTheDocument() - expect(screen.queryByTestId('empty-analysis-no-keys')).not.toBeInTheDocument() - }) }) /** @@ -78,9 +109,18 @@ describe('AnalysisDataView', () => { scanned: 40, processed: 40 } - } + }; + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: mockedData, + })); + (dbAnalysisReportsSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisReportsSelector, + data: mockReports, + })) + render( - + ) expect(screen.getByTestId('total-memory-value')).toHaveTextContent(`~${formatBytes(mockedData.totalMemory.total * 2, 3)}`) @@ -111,9 +151,17 @@ describe('AnalysisDataView', () => { scanned: 40, processed: 40 } - } + }; + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: mockedData, + })); + (dbAnalysisReportsSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisReportsSelector, + data: mockReports, + })) render( - + ) fireEvent.click(within(screen.getByTestId(summaryContainerId)).getByTestId(extrapolateResultsId)) @@ -146,9 +194,17 @@ describe('AnalysisDataView', () => { scanned: 40, processed: 40 } - } + }; + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: mockedData, + })); + (dbAnalysisReportsSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisReportsSelector, + data: mockReports, + })) render( - + ) const expirationGroup = mockedData.expirationGroups[1] @@ -165,9 +221,17 @@ describe('AnalysisDataView', () => { scanned: 40, processed: 40 } - } + }; + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: mockedData, + })); + (dbAnalysisReportsSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisReportsSelector, + data: mockReports, + })) render( - + ) fireEvent.click(within(screen.getByTestId(analyticsTTLContainerId)).getByTestId(extrapolateResultsId)) @@ -185,9 +249,17 @@ describe('AnalysisDataView', () => { scanned: 40, processed: 40 } - } + }; + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: mockedData, + })); + (dbAnalysisReportsSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisReportsSelector, + data: mockReports, + })) render( - + ) const nspTopKeyItem = mockedData.topKeysNsp[0] @@ -206,9 +278,17 @@ describe('AnalysisDataView', () => { scanned: 40, processed: 40 } - } + }; + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: mockedData, + })); + (dbAnalysisReportsSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisReportsSelector, + data: mockReports, + })) render( - + ) fireEvent.click(within(screen.getByTestId(topNameSpacesContainerId)).getByTestId(extrapolateResultsId)) @@ -228,9 +308,17 @@ describe('AnalysisDataView', () => { scanned: 10000, processed: 80 } - } + }; + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: mockedData, + })); + (dbAnalysisReportsSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisReportsSelector, + data: mockReports, + })) render( - + ) expect(screen.queryByTestId(extrapolateResultsId)).not.toBeInTheDocument() @@ -259,12 +347,20 @@ describe('AnalysisDataView', () => { scanned: 10000, processed: 40 } - } + }; + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: mockedData, + })); + (dbAnalysisReportsSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisReportsSelector, + data: mockReports, + })) const sendEventTelemetryMock = jest.fn() sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) render( - + ) const clickAndCheckTelemetry = (el: HTMLInputElement, section: SectionName) => { diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.tsx index 804cfaa37e..b4b0403a87 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/analysis-data-view/AnalysisDataView.tsx @@ -42,7 +42,7 @@ const AnalysisDataView = () => { }) } - if (!loading && !!reports.length && data?.totalKeys?.total === 0) { + if (!loading && !!reports?.length && data?.totalKeys?.total === 0) { return () } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.spec.tsx new file mode 100644 index 0000000000..18dd4fceea --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.spec.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { instance, mock } from 'ts-mockito' +import { MOCK_ANALYSIS_REPORT_DATA } from 'uiSrc/mocks/data/analysis' +import { render, screen, mockedStore, cleanup, fireEvent } from 'uiSrc/utils/test-utils' +import { DatabaseAnalysisViewTab } from 'uiSrc/slices/interfaces/analytics' +import { setDatabaseAnalysisViewTab } from 'uiSrc/slices/analytics/dbAnalysis' + +import DatabaseAnalysisTabs, { Props } from './DatabaseAnalysisTabs' + +const mockedProps = mock() + +const mockReports = [ + { + id: MOCK_ANALYSIS_REPORT_DATA.id, + createdAt: '2022-09-23T05:30:23.000Z' + } +] + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('DatabaseAnalysisTabs', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call setDatabaseAnalysisViewTab', () => { + render() + + fireEvent.click(screen.getByTestId(`${DatabaseAnalysisViewTab.Recommendations}-tab`)) + + const expectedActions = [setDatabaseAnalysisViewTab(DatabaseAnalysisViewTab.Recommendations)] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should render encrypt message', () => { + const mockData = { + totalKeys: null + } + render() + + expect(screen.queryByTestId('empty-encrypt-wrapper')).toBeTruthy() + }) + + describe('recommendations count', () => { + it('should render "Recommendation (3)" in the tab name', () => { + const mockData = { + recommendations: [ + { name: 'luaScript' }, + { name: 'luaScript' }, + { name: 'luaScript' }, + ] + } + + render() + + expect(screen.queryByTestId(`${DatabaseAnalysisViewTab.Recommendations}-tab`)).toHaveTextContent('Recommendations (3)') + }) + + it('should render "Recommendation (3)" in the tab name', () => { + const mockData = { + recommendations: [{ name: 'luaScript' }] + } + render() + + expect(screen.queryByTestId(`${DatabaseAnalysisViewTab.Recommendations}-tab`)).toHaveTextContent('Recommendations (1)') + }) + + it('should render "Recommendation" in the tab name', () => { + const mockData = { + recommendations: [] + } + render() + + expect(screen.queryByTestId(`${DatabaseAnalysisViewTab.Recommendations}-tab`)).toHaveTextContent('Recommendations') + }) + }) +}) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/DatabaseAnalysisTabs.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.tsx similarity index 76% rename from redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/DatabaseAnalysisTabs.tsx rename to redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.tsx index ca609f19c7..b06f999fc0 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/DatabaseAnalysisTabs.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.tsx @@ -6,15 +6,16 @@ import { EmptyMessage } from 'uiSrc/pages/databaseAnalysis/constants' import { EmptyAnalysisMessage } from 'uiSrc/pages/databaseAnalysis/components' import { setDatabaseAnalysisViewTab, dbAnalysisViewTabSelector } from 'uiSrc/slices/analytics/dbAnalysis' import { DatabaseAnalysisViewTab } from 'uiSrc/slices/interfaces/analytics' +import { Nullable } from 'uiSrc/utils' import { ShortDatabaseAnalysis, DatabaseAnalysis } from 'apiSrc/modules/database-analysis/models' import { databaseAnalysisTabs } from './constants' import styles from './styles.module.scss' -interface Props { +export interface Props { loading: boolean reports: ShortDatabaseAnalysis[] - data: DatabaseAnalysis + data: Nullable } const DatabaseAnalysisTabs = (props: Props) => { @@ -36,21 +37,22 @@ const DatabaseAnalysisTabs = (props: Props) => { key={id} onClick={() => onSelectedTabChanged(id)} isSelected={id === viewTab} + data-testid={`${id}-tab`} > - {name} + {name(data?.recommendations?.length)} ))) - if (!loading && !reports.length) { + if (!loading && !reports?.length) { return ( -
+
) } - if (!loading && !!reports.length && isNull(data?.totalKeys)) { + if (!loading && !!reports?.length && isNull(data?.totalKeys)) { return ( -
+
) @@ -58,7 +60,7 @@ const DatabaseAnalysisTabs = (props: Props) => { return ( <> - {renderTabs()} + {renderTabs()}
{selectedTabContent}
diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/constants.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx similarity index 77% rename from redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/constants.tsx rename to redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx index 58b6df423a..25b5935871 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/constants.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx @@ -5,19 +5,19 @@ import Recommendations from '../recommendations-view' interface DatabaseAnalysisTabs { id: DatabaseAnalysisViewTab, - name: string, + name: (count?: number) => string, content: ReactNode } export const databaseAnalysisTabs: DatabaseAnalysisTabs[] = [ { id: DatabaseAnalysisViewTab.DataSummary, - name: 'Data Summary', + name: () => 'Data Summary', content: }, { id: DatabaseAnalysisViewTab.Recommendations, - name: 'Recommendations', + name: (count?: number) => (count ? `Recommendations (${count})` : 'Recommendations'), content: }, ] diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/index.ts b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/index.ts similarity index 100% rename from redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/index.ts rename to redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/index.ts diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/databaseAnalysis/components/sub-tabs/styles.module.scss rename to redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/styles.module.scss diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx new file mode 100644 index 0000000000..7c406c5031 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -0,0 +1,135 @@ +import React from 'react' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { dbAnalysisSelector } from 'uiSrc/slices/analytics/dbAnalysis' + +import Recommendations from './Recommendations' + +const mockdbAnalysisSelector = jest.requireActual('uiSrc/slices/analytics/dbAnalysis') + +jest.mock('uiSrc/slices/analytics/dbAnalysis', () => ({ + ...jest.requireActual('uiSrc/slices/analytics/dbAnalysis'), + dbAnalysisSelector: jest.fn().mockReturnValue({ + loading: false, + error: '', + data: null, + history: { + loading: false, + error: '', + data: [], + selectedAnalysis: null, + } + }), +})) + +describe('Recommendations', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render loader', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + loading: true + })) + + render() + + expect(screen.queryByTestId('recommendations-loader')).toBeInTheDocument() + }) + + it('should not render loader', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + })) + + render() + + expect(screen.queryByTestId('recommendations-loader')).not.toBeInTheDocument() + }) + + describe('recommendations initial open', () => { + it('should render open recommendations', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [ + { name: 'luaScript' }, + { name: 'luaScript' }, + { name: 'luaScript' }, + { name: 'luaScript' }, + { name: 'luaScript' }, + ] + } + })) + + render() + + expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[1]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[2]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[3]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[4]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() + }) + + it('should render closed recommendations', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [ + { name: 'luaScript' }, + { name: 'luaScript' }, + { name: 'luaScript' }, + { name: 'luaScript' }, + { name: 'luaScript' }, + { name: 'luaScript' }, + ] + } + })) + + render() + + expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[1]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[2]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[3]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[4]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[5]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() + }) + }) + + it('should render code changes badge in luaScript recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'luaScript' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() + }) + + it('should collapse/expand', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'luaScript' }] + } + })) + + const { container } = render() + + expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() + + fireEvent.click(container.querySelector('[data-test-subj="luaScript-button"]') as HTMLInputElement) + + expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() + + fireEvent.click(container.querySelector('[data-test-subj="luaScript-button"]') as HTMLInputElement) + + expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index 846b81a568..11c6f9feca 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -1,12 +1,9 @@ import React from 'react' import { useSelector } from 'react-redux' -import cx from 'classnames' import { EuiAccordion, EuiPanel, - EuiTitle, EuiText, - EuiLoadingSpinner, } from '@elastic/eui' import { dbAnalysisSelector } from 'uiSrc/slices/analytics/dbAnalysis' import recommendationsContent from 'uiSrc/constants/dbAnalysisRecommendations.json' @@ -14,34 +11,30 @@ import recommendationsContent from 'uiSrc/constants/dbAnalysisRecommendations.js import { parseContent, renderBadges } from './utils' import styles from './styles.module.scss' +const countToCollapseRecommendations = 6 + const Recommendations = () => { const { data, loading } = useSelector(dbAnalysisSelector) const { recommendations = [] } = data ?? {} if (loading) { return ( -
- - Uploading recommendations... -
+
) } if (!recommendations.length) { return (
- No Recommendations at the moment + No Recommendations at the moment.
) } return (
- -

{`RECOMMENDATIONS (${recommendations.length}):`}

-
{recommendations.map(({ name }) => { - const { id, title = '', content = '', badges } = recommendationsContent[name] + const { id = '', title = '', content = '', badges = [] } = recommendationsContent[name] return (
{ arrowDisplay="right" buttonContent={title} buttonClassName={styles.accordionBtn} + buttonProps={{ 'data-test-subj': `${id}-button` }} className={styles.accordion} + initialIsOpen={recommendations.length < countToCollapseRecommendations} + data-testId={`${id}-accordion`} > {content.map((item: { type: string, value: any, id: string }) => diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index dab0d51d1c..35851a091c 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -7,10 +7,7 @@ overflow-y: auto; overflow-x: hidden; max-height: 100%; -} - -.title { - margin: 32px 0 24px; + padding-top: 30px; } .container { @@ -19,21 +16,14 @@ flex-direction: column; align-items: center; height: 100%; +} - .emptyMessage { - font: normal normal 500 16px/19px Graphik, sans-serif; - } - - .spinner { - width: 38px; - height: 38px; - margin-bottom: 12px; - border-top-color: var(--euiColorPrimary) !important; - } - - .spinnerText { - color: var(--htmlColor); - } +.loadingWrapper { + width: 100%; + height: 129px; + margin-top: 30px; + background-color: var(--euiColorLightestShade); + border-radius: 4px; } .recommendation { @@ -76,7 +66,7 @@ margin-right: 48px; .badge { - margin: 18px; + margin: 18px 18px 14px; .badgeIcon { margin-right: 14px; @@ -90,8 +80,11 @@ } } + .text { + font: normal normal normal 14px/24px Graphik, sans-serif; + } + .span { display: inline; - font: normal normal normal 14px/24px Graphik, sans-serif; } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx index fb0ea72339..efdd95543b 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx @@ -4,8 +4,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, - EuiImage, + EuiSpacer, } from '@elastic/eui' +import cx from 'classnames' import { ReactComponent as CodeIcon } from 'uiSrc/assets/img/code-changes.svg' import { ReactComponent as ConfigurationIcon } from 'uiSrc/assets/img/configuration-changes.svg' import { ReactComponent as UpgradeIcon } from 'uiSrc/assets/img/upgrade.svg' @@ -24,7 +25,7 @@ export const renderBadges = (badges: string[]) => ( ? : ( -
+
{icon} {name}
@@ -36,13 +37,13 @@ export const renderBadges = (badges: string[]) => ( export const parseContent = ({ type, value }: { type: string, value: any }) => { switch (type) { case 'paragraph': - return {value} + return {value} case 'span': - return {value} + return {value} case 'link': return {value.name} - case 'image': - return + case 'spacer': + return default: return value } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/top-namespace/TopNamespace.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/top-namespace/TopNamespace.tsx index 523bff42a7..b6bd110345 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/top-namespace/TopNamespace.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/top-namespace/TopNamespace.tsx @@ -1,4 +1,5 @@ import { EuiButton, EuiLink, EuiSwitch, EuiTitle } from '@elastic/eui' +import { isNull } from 'lodash' import cx from 'classnames' import React, { useEffect, useState } from 'react' import { useDispatch } from 'react-redux' @@ -38,6 +39,10 @@ const TopNamespace = (props: Props) => { return } + if (isNull(data)) { + return null + } + const handleTreeViewClick = (e: React.MouseEvent) => { e.preventDefault() From 97d8f795a41e17c4bd12c7023a8cbfc7d40a98e1 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 14 Nov 2022 14:23:32 +0400 Subject: [PATCH 008/201] #RI-3527-resolve comments --- ...50002-database-analysis-recommendations.ts | 28 +++++++++ redisinsight/api/migration/index.ts | 2 + .../database-analysis.module.ts | 4 +- .../database-analysis.service.ts | 30 +++++++-- .../providers/database-analysis.provider.ts | 1 - .../database-analysis/scanner/keys-scanner.ts | 1 + .../database-recommendations.module.ts | 9 --- .../database-recommendations.provider.ts | 62 ------------------- .../providers/recommendation.provider.ts | 50 +++++++++++++++ .../recommendation/recommendation.module.ts | 9 +++ redisinsight/api/src/utils/base.helper.ts | 5 ++ .../GET-databases-id-analysis-id.test.ts | 3 +- .../POST-databases-id-analysis.test.ts | 2 + .../test/api/database-analysis/constants.ts | 5 ++ redisinsight/api/test/helpers/constants.ts | 4 +- redisinsight/api/test/helpers/local-db.ts | 1 + 16 files changed, 136 insertions(+), 80 deletions(-) create mode 100644 redisinsight/api/migration/1668420950002-database-analysis-recommendations.ts delete mode 100644 redisinsight/api/src/modules/db-recommendations/database-recommendations.module.ts delete mode 100644 redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts create mode 100644 redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts create mode 100644 redisinsight/api/src/modules/recommendation/recommendation.module.ts diff --git a/redisinsight/api/migration/1668420950002-database-analysis-recommendations.ts b/redisinsight/api/migration/1668420950002-database-analysis-recommendations.ts new file mode 100644 index 0000000000..ccb61d2874 --- /dev/null +++ b/redisinsight/api/migration/1668420950002-database-analysis-recommendations.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class databaseAnalysisRecommendations1668420950002 implements MigrationInterface { + name = 'databaseAnalysisRecommendations1668420950002' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_d174a8edc2201d6c5781f0126a"`); + await queryRunner.query(`DROP INDEX "IDX_fdd0daeb4d8f226cf1ff79bebb"`); + await queryRunner.query(`CREATE TABLE "temporary_database_analysis" ("id" varchar PRIMARY KEY NOT NULL, "databaseId" varchar NOT NULL, "filter" blob, "delimiter" varchar NOT NULL, "progress" blob, "totalKeys" blob, "totalMemory" blob, "topKeysNsp" blob, "topMemoryNsp" blob, "topKeysLength" blob, "topKeysMemory" blob, "encryption" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "expirationGroups" blob, "recommendations" blob, CONSTRAINT "FK_d174a8edc2201d6c5781f0126ae" FOREIGN KEY ("databaseId") REFERENCES "database_instance" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_analysis"("id", "databaseId", "filter", "delimiter", "progress", "totalKeys", "totalMemory", "topKeysNsp", "topMemoryNsp", "topKeysLength", "topKeysMemory", "encryption", "createdAt", "expirationGroups") SELECT "id", "databaseId", "filter", "delimiter", "progress", "totalKeys", "totalMemory", "topKeysNsp", "topMemoryNsp", "topKeysLength", "topKeysMemory", "encryption", "createdAt", "expirationGroups" FROM "database_analysis"`); + await queryRunner.query(`DROP TABLE "database_analysis"`); + await queryRunner.query(`ALTER TABLE "temporary_database_analysis" RENAME TO "database_analysis"`); + await queryRunner.query(`CREATE INDEX "IDX_d174a8edc2201d6c5781f0126a" ON "database_analysis" ("databaseId") `); + await queryRunner.query(`CREATE INDEX "IDX_fdd0daeb4d8f226cf1ff79bebb" ON "database_analysis" ("createdAt") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_fdd0daeb4d8f226cf1ff79bebb"`); + await queryRunner.query(`DROP INDEX "IDX_d174a8edc2201d6c5781f0126a"`); + await queryRunner.query(`ALTER TABLE "database_analysis" RENAME TO "temporary_database_analysis"`); + await queryRunner.query(`CREATE TABLE "database_analysis" ("id" varchar PRIMARY KEY NOT NULL, "databaseId" varchar NOT NULL, "filter" blob, "delimiter" varchar NOT NULL, "progress" blob, "totalKeys" blob, "totalMemory" blob, "topKeysNsp" blob, "topMemoryNsp" blob, "topKeysLength" blob, "topKeysMemory" blob, "encryption" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "expirationGroups" blob, CONSTRAINT "FK_d174a8edc2201d6c5781f0126ae" FOREIGN KEY ("databaseId") REFERENCES "database_instance" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_analysis"("id", "databaseId", "filter", "delimiter", "progress", "totalKeys", "totalMemory", "topKeysNsp", "topMemoryNsp", "topKeysLength", "topKeysMemory", "encryption", "createdAt", "expirationGroups") SELECT "id", "databaseId", "filter", "delimiter", "progress", "totalKeys", "totalMemory", "topKeysNsp", "topMemoryNsp", "topKeysLength", "topKeysMemory", "encryption", "createdAt", "expirationGroups" FROM "temporary_database_analysis"`); + await queryRunner.query(`DROP TABLE "temporary_database_analysis"`); + await queryRunner.query(`CREATE INDEX "IDX_fdd0daeb4d8f226cf1ff79bebb" ON "database_analysis" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_d174a8edc2201d6c5781f0126a" ON "database_analysis" ("databaseId") `); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index 954f1fd4e4..4c94f38241 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -21,6 +21,7 @@ import { databaseAnalysis1664785208236 } from './1664785208236-database-analysis import { databaseAnalysisExpirationGroups1664886479051 } from './1664886479051-database-analysis-expiration-groups'; import { workbenchExecutionTime1667368983699 } from './1667368983699-workbench-execution-time'; import { database1667477693934 } from './1667477693934-database'; +import { databaseAnalysisRecommendations1668420950002 } from './1668420950002-database-analysis-recommendations'; export default [ initialMigration1614164490968, @@ -46,4 +47,5 @@ export default [ databaseAnalysisExpirationGroups1664886479051, workbenchExecutionTime1667368983699, database1667477693934, + databaseAnalysisRecommendations1668420950002, ]; diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.module.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.module.ts index 452933eaff..d55210eb11 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.module.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.module.ts @@ -5,10 +5,10 @@ import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/databa import { DatabaseAnalysisProvider } from 'src/modules/database-analysis/providers/database-analysis.provider'; import { KeysScanner } from 'src/modules/database-analysis/scanner/keys-scanner'; import { KeyInfoProvider } from 'src/modules/database-analysis/scanner/key-info/key-info.provider'; -import { DatabaseRecommendationsModule } from 'src/modules/db-recommendations/database-recommendations.module'; +import { RecommendationModule } from 'src/modules/recommendation/recommendation.module'; @Module({ - imports: [DatabaseRecommendationsModule], + imports: [RecommendationModule], controllers: [DatabaseAnalysisController], providers: [ DatabaseAnalysisService, diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index 86a10c6a99..868740199d 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -1,6 +1,7 @@ import { HttpException, Injectable, Logger } from '@nestjs/common'; import { IFindRedisClientInstanceByOptions } from 'src/modules/redis/redis.service'; -import { RecommendationsService } from 'src/modules/db-recommendations/providers/database-recommendations.provider'; +import { uniqWith, isEqual, flatten } from 'lodash'; +import { RecommendationService } from 'src/modules/recommendation/providers/recommendation.provider'; import { catchAclError } from 'src/utils'; import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/database-analyzer'; import { plainToClass } from 'class-transformer'; @@ -8,6 +9,7 @@ import { DatabaseAnalysis, ShortDatabaseAnalysis } from 'src/modules/database-an import { DatabaseAnalysisProvider } from 'src/modules/database-analysis/providers/database-analysis.provider'; import { CreateDatabaseAnalysisDto } from 'src/modules/database-analysis/dto'; import { KeysScanner } from 'src/modules/database-analysis/scanner/keys-scanner'; +import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { DatabaseConnectionService } from 'src/modules/database/database-connection.service'; import { AppTool } from 'src/models'; @@ -17,7 +19,7 @@ export class DatabaseAnalysisService { constructor( private readonly databaseConnectionService: DatabaseConnectionService, - private readonly recommendationsService: RecommendationsService, + private readonly recommendationsService: RecommendationService, private readonly analyzer: DatabaseAnalyzer, private readonly databaseAnalysisProvider: DatabaseAnalysisProvider, private readonly scanner: KeysScanner, @@ -48,17 +50,27 @@ export class DatabaseAnalysisService { processed: 0, }; - scanResults.forEach((nodeResult) => { + scanResults.forEach(async (nodeResult) => { progress.scanned += nodeResult.progress.scanned; progress.processed += nodeResult.progress.processed; progress.total += nodeResult.progress.total; }); + const recommendations = DatabaseAnalysisService.getRecommendationsSummary( + flatten(await Promise.all( + scanResults.map(async (nodeResult) => ( + await this.recommendationsService.getRecommendations({ + client: nodeResult.client, + keys: nodeResult.keys, + }) + )), + )), + ); const analysis = plainToClass(DatabaseAnalysis, await this.analyzer.analyze({ databaseId: clientOptions.instanceId, ...dto, progress, - recommendations: await this.recommendationsService.getRecommendations(clientOptions), + recommendations, }, [].concat(...scanResults.map((nodeResult) => nodeResult.keys)))); return this.databaseAnalysisProvider.create(analysis); @@ -88,4 +100,14 @@ export class DatabaseAnalysisService { async list(databaseId: string): Promise { return this.databaseAnalysisProvider.list(databaseId); } + + /** + * Get recommendations summary + * @param recommendations + */ + + static getRecommendationsSummary(recommendations: Recommendation[]): Recommendation[] { + // if we will sent any values with recommendations, they will be calculate here + return uniqWith(recommendations, isEqual); + } } diff --git a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts index c2fef9bec2..584342db6e 100644 --- a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts +++ b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts @@ -68,7 +68,6 @@ export class DatabaseAnalysisProvider { this.logger.error(`Database analysis with id:${id} was not Found`); throw new NotFoundException(ERROR_MESSAGES.DATABASE_ANALYSIS_NOT_FOUND); } - console.log(classToClass(DatabaseAnalysis, await this.decryptEntity(entity, true))) return classToClass(DatabaseAnalysis, await this.decryptEntity(entity, true)); } diff --git a/redisinsight/api/src/modules/database-analysis/scanner/keys-scanner.ts b/redisinsight/api/src/modules/database-analysis/scanner/keys-scanner.ts index bbe4090c73..67cfdc4e16 100644 --- a/redisinsight/api/src/modules/database-analysis/scanner/keys-scanner.ts +++ b/redisinsight/api/src/modules/database-analysis/scanner/keys-scanner.ts @@ -73,6 +73,7 @@ export class KeysScanner { scanned: opts.filter.count, processed: nodeKeys.length, }, + client, }; } diff --git a/redisinsight/api/src/modules/db-recommendations/database-recommendations.module.ts b/redisinsight/api/src/modules/db-recommendations/database-recommendations.module.ts deleted file mode 100644 index 097526deae..0000000000 --- a/redisinsight/api/src/modules/db-recommendations/database-recommendations.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { RecommendationsService } from './providers/database-recommendations.provider'; - -@Module({ - providers: [RecommendationsService], - exports: [RecommendationsService], -}) -export class DatabaseRecommendationsModule {} diff --git a/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts b/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts deleted file mode 100644 index 7e997c0e18..0000000000 --- a/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Cluster, Command } from 'ioredis'; -import { get, max } from 'lodash'; -import { IFindRedisClientInstanceByOptions } from 'src/modules/redis/redis.service'; -import { DatabaseConnectionService } from 'src/modules/database/database-connection.service'; -import { convertRedisInfoReplyToObject } from 'src/utils'; -import { AppTool } from 'src/models'; - -const minNumberOfCachedScripts = 10; - -@Injectable() -export class RecommendationsService { - constructor( - private readonly databaseConnectionService: DatabaseConnectionService, - ) {} - - /** - * Get database recommendations - * @param clientOptions - */ - public async getRecommendations( - clientOptions: IFindRedisClientInstanceByOptions, - ) { - const recommendations = []; - if (await this.getLuaScriptRecommendation(clientOptions)) { - recommendations.push({ name: 'luaScript' }); - } - - return recommendations; - } - - /** - * Check lua script recommendation - * @param clientOptions - */ - async getLuaScriptRecommendation( - clientOptions: IFindRedisClientInstanceByOptions, - ): Promise { - let nodes = []; - const client = await this.databaseConnectionService.createClient({ - databaseId: clientOptions.instanceId, - namespace: AppTool.Common, - }); - - if (client instanceof Cluster) { - nodes = client.nodes('master'); - } else { - nodes = [client]; - } - - const nodesNumbersOfCachedScripts = Promise.all(nodes.map(async (node) => { - const info = convertRedisInfoReplyToObject( - await node.sendCommand( - new Command('info', ['memory'], { replyEncoding: 'utf8' }), - ) as string, - ); - return get(info, 'memory.number_of_cached_scripts', {}); - })); - - return max(await nodesNumbersOfCachedScripts) > minNumberOfCachedScripts; - } -} diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts new file mode 100644 index 0000000000..fb99922c43 --- /dev/null +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { Redis, Command } from 'ioredis'; +import { get } from 'lodash'; +import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; +import { convertRedisInfoReplyToObject } from 'src/utils'; +import { checkIsGreaterThan } from 'src/utils/base.helper'; + +const minNumberOfCachedScripts = 10; + +interface RecommendationInput { + client?: Redis, + keys?: any, + info?: any, +} + +@Injectable() +export class RecommendationService { + /** + * Get recommendations + * @param dto + */ + public async getRecommendations( + dto: RecommendationInput, + ): Promise { + // generic solution, if somewhere we will sent info, we don't need determined some recommendations + const { client, keys, info } = dto; + const recommendations = []; + if (await this.determineLuaScriptRecommendation(client)) { + recommendations.push({ name: 'luaScript' }); + } + return recommendations; + } + + /** + * Check lua script recommendation + * @param redisClient + */ + async determineLuaScriptRecommendation( + redisClient: Redis, + ): Promise { + const info = convertRedisInfoReplyToObject( + await redisClient.sendCommand( + new Command('info', ['memory'], { replyEncoding: 'utf8' }), + ) as string, + ); + const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts', {}); + + return checkIsGreaterThan(minNumberOfCachedScripts, parseInt(await nodesNumbersOfCachedScripts, 10)); + } +} diff --git a/redisinsight/api/src/modules/recommendation/recommendation.module.ts b/redisinsight/api/src/modules/recommendation/recommendation.module.ts new file mode 100644 index 0000000000..14112eb9d5 --- /dev/null +++ b/redisinsight/api/src/modules/recommendation/recommendation.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { RecommendationService } from './providers/recommendation.provider'; + +@Module({ + providers: [RecommendationService], + exports: [RecommendationService], +}) +export class RecommendationModule {} diff --git a/redisinsight/api/src/utils/base.helper.ts b/redisinsight/api/src/utils/base.helper.ts index b72fc36c4e..fe49924383 100644 --- a/redisinsight/api/src/utils/base.helper.ts +++ b/redisinsight/api/src/utils/base.helper.ts @@ -4,3 +4,8 @@ export const sortByNumberField = ( items: T[], field: string, ): T[] => sortBy(items, (o) => (o && isNumber(o[field]) ? o[field] : -Infinity)); + +export const checkIsGreaterThan = ( + conditionNumber: number, + currentCount: number, +): boolean => currentCount > conditionNumber; diff --git a/redisinsight/api/test/api/database-analysis/GET-databases-id-analysis-id.test.ts b/redisinsight/api/test/api/database-analysis/GET-databases-id-analysis-id.test.ts index f6826fded6..9c4709a0cc 100644 --- a/redisinsight/api/test/api/database-analysis/GET-databases-id-analysis-id.test.ts +++ b/redisinsight/api/test/api/database-analysis/GET-databases-id-analysis-id.test.ts @@ -1,6 +1,6 @@ import { describe, deps, before, expect, getMainCheckFn } from '../deps'; import { analysisSchema } from './constants'; -const { localDb, request, server, constants, rte } = deps; +const { localDb, request, server, constants } = deps; const endpoint = ( instanceId = constants.TEST_INSTANCE_ID, @@ -38,6 +38,7 @@ describe('GET /databases/:id/analysis/:id', () => { topKeysLength: [constants.TEST_DATABASE_ANALYSIS_TOP_KEYS_1], topKeysMemory: [constants.TEST_DATABASE_ANALYSIS_TOP_KEYS_1], expirationGroups: [constants.TEST_DATABASE_ANALYSIS_EXPIRATION_GROUP_1], + recommendations: [constants.TEST_DATABASE_ANALYSIS_RECOMMENDATION_1], }); } }, diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index 8d38826fff..17a69bf4f0 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -64,6 +64,7 @@ describe('POST /databases/:instanceId/analysis', () => { expect(body.topKeysLength.length).to.gt(0); expect(body.topKeysMemory.length).to.gt(0); expect(body.expirationGroups.length).to.gt(0); + expect(body.recommendations.length).to.eq(0); }, after: async () => { expect(await repository.count()).to.eq(5); @@ -142,6 +143,7 @@ describe('POST /databases/:instanceId/analysis', () => { expect(body.expirationGroups[0].label).to.eq('No Expiry'); expect(body.expirationGroups[0].total).to.gt(0); expect(body.expirationGroups[0].threshold).to.eq(0); + expect(body.recommendations.length).to.eq(0); }, after: async () => { expect(await repository.count()).to.eq(5); diff --git a/redisinsight/api/test/api/database-analysis/constants.ts b/redisinsight/api/test/api/database-analysis/constants.ts index a2f587db60..563a66a7d0 100644 --- a/redisinsight/api/test/api/database-analysis/constants.ts +++ b/redisinsight/api/test/api/database-analysis/constants.ts @@ -1,5 +1,9 @@ import { Joi } from '../../helpers/test'; +export const typedRecommendationSchema = Joi.object({ + name: Joi.string().required(), +}); + export const typedTotalSchema = Joi.object({ total: Joi.number().integer().required(), types: Joi.array().items(Joi.object({ @@ -55,4 +59,5 @@ export const analysisSchema = Joi.object().keys({ topKeysLength: Joi.array().items(keySchema).required().max(15), topKeysMemory: Joi.array().items(keySchema).required().max(15), expirationGroups: Joi.array().items(sumGroupSchema).required(), + recommendations: Joi.array().items(typedRecommendationSchema).required(), }).required(); diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index cf3b0b64c9..4cdc15f6be 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -451,6 +451,8 @@ export const constants = { threshold: 4 * 60 * 60 * 1000, }, - + TEST_DATABASE_ANALYSIS_RECOMMENDATION_1: { + name: 'luaScript', + }, // etc... } diff --git a/redisinsight/api/test/helpers/local-db.ts b/redisinsight/api/test/helpers/local-db.ts index 001e60ed5e..be3932addd 100644 --- a/redisinsight/api/test/helpers/local-db.ts +++ b/redisinsight/api/test/helpers/local-db.ts @@ -175,6 +175,7 @@ export const generateNDatabaseAnalysis = async ( expirationGroups: encryptData(JSON.stringify([ constants.TEST_DATABASE_ANALYSIS_EXPIRATION_GROUP_1, ])), + recommendations: encryptData(JSON.stringify([constants.TEST_DATABASE_ANALYSIS_RECOMMENDATION_1])), createdAt: new Date(), encryption: constants.TEST_ENCRYPTION_STRATEGY, ...partial, From 9328fa2ecdd32a55031b04849db1547e4c88f516 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 14 Nov 2022 14:31:56 +0400 Subject: [PATCH 009/201] #RI-3527-remove unnsessusary code --- .../modules/database-analysis/database-analysis.service.ts | 2 +- .../providers/database-analysis.provider.ts | 5 ++--- .../recommendation/providers/recommendation.provider.ts | 5 +++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index 868740199d..dbe5d1e135 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -50,7 +50,7 @@ export class DatabaseAnalysisService { processed: 0, }; - scanResults.forEach(async (nodeResult) => { + scanResults.forEach((nodeResult) => { progress.scanned += nodeResult.progress.scanned; progress.processed += nodeResult.progress.processed; progress.total += nodeResult.progress.total; diff --git a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts index 584342db6e..66d51e701b 100644 --- a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts +++ b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts @@ -42,9 +42,7 @@ export class DatabaseAnalysisProvider { */ async create(analysis: Partial): Promise { const entity = await this.repository.save( - await this.encryptEntity(plainToClass(DatabaseAnalysisEntity, { - ...analysis, - })), + await this.encryptEntity(plainToClass(DatabaseAnalysisEntity, analysis)), ); // cleanup history and ignore error if any @@ -68,6 +66,7 @@ export class DatabaseAnalysisProvider { this.logger.error(`Database analysis with id:${id} was not Found`); throw new NotFoundException(ERROR_MESSAGES.DATABASE_ANALYSIS_NOT_FOUND); } + return classToClass(DatabaseAnalysis, await this.decryptEntity(entity, true)); } diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index fb99922c43..ba1cb5e2d9 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Redis, Command } from 'ioredis'; import { get } from 'lodash'; +import { RedisString } from 'src/common/constants'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { convertRedisInfoReplyToObject } from 'src/utils'; import { checkIsGreaterThan } from 'src/utils/base.helper'; @@ -9,8 +10,8 @@ const minNumberOfCachedScripts = 10; interface RecommendationInput { client?: Redis, - keys?: any, - info?: any, + keys?: RedisString[], + info?: RedisString, } @Injectable() From 76eaf7f1cb9e3825839da7d7bf2a39e9cd03f55e Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 14 Nov 2022 14:35:06 +0400 Subject: [PATCH 010/201] #RI-3527-add null condition to recommendation view --- .../components/recommendations-view/Recommendations.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index 11c6f9feca..ad48814435 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -1,5 +1,6 @@ import React from 'react' import { useSelector } from 'react-redux' +import { isNull } from 'lodash' import { EuiAccordion, EuiPanel, @@ -23,7 +24,7 @@ const Recommendations = () => { ) } - if (!recommendations.length) { + if (isNull(recommendations) || !recommendations.length) { return (
No Recommendations at the moment. From 84d0445ea28d98f867af0a432cb3d3f76e4cf19e Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 14 Nov 2022 14:41:20 +0400 Subject: [PATCH 011/201] #RI-3527-remove unnsessusary code --- .../modules/database-analysis/database-analysis.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index dbe5d1e135..1e433dbdf3 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -19,7 +19,7 @@ export class DatabaseAnalysisService { constructor( private readonly databaseConnectionService: DatabaseConnectionService, - private readonly recommendationsService: RecommendationService, + private readonly RecommendationModule: RecommendationService, private readonly analyzer: DatabaseAnalyzer, private readonly databaseAnalysisProvider: DatabaseAnalysisProvider, private readonly scanner: KeysScanner, @@ -59,7 +59,7 @@ export class DatabaseAnalysisService { const recommendations = DatabaseAnalysisService.getRecommendationsSummary( flatten(await Promise.all( scanResults.map(async (nodeResult) => ( - await this.recommendationsService.getRecommendations({ + await this.RecommendationModule.getRecommendations({ client: nodeResult.client, keys: nodeResult.keys, }) From 7116488a294440bf653e004f5c99ef8b6a7543ad Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 14 Nov 2022 14:51:16 +0400 Subject: [PATCH 012/201] #RI-3527-remove commented code --- .../database-recommendations.provider.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts diff --git a/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts b/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts new file mode 100644 index 0000000000..eff64e7099 --- /dev/null +++ b/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { Redis, Cluster, Command } from 'ioredis'; +import { get, max } from 'lodash'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/redis/redis.service'; +// import { DatabaseConnectionService } from 'src/modules/database/database-connection.service'; +import { convertRedisInfoReplyToObject } from 'src/utils'; +import { AppTool } from 'src/models'; + +const minNumberOfCachedScripts = 10; + +@Injectable() +export class RecommendationsService { + // number of cached, + // current cached scripts from info + + // second func determint + // redis client (or info) + // constructor( + // private readonly databaseConnectionService: DatabaseConnectionService, + // ) {} + + /** + * Get database recommendations + * @param redisClient + */ + public async getRecommendations( + redisClient: Redis, + // { client?, keys?, info?, etc } + // clientOptions: IFindRedisClientInstanceByOptions, + ) { + const recommendations = []; + if (await this.getLuaScriptRecommendation(redisClient)) { + recommendations.push({ name: 'luaScript' }); + } + + return recommendations; + } + + /** + * Check lua script recommendation + * @param redisClient + */ + async getLuaScriptRecommendation( + // clientOptions: IFindRedisClientInstanceByOptions, + redisClient: Redis, + ): Promise { + // let nodes = []; + // const client = await this.databaseConnectionService.createClient({ + // databaseId: clientOptions.instanceId, + // namespace: AppTool.Common, + // }); + + // if (client instanceof Cluster) { + // nodes = client.nodes('master'); + // } else { + // nodes = [client]; + // } + + // const nodesNumbersOfCachedScripts = Promise.all(nodes.map(async (node) => { + // const info = convertRedisInfoReplyToObject( + // await node.sendCommand( + // new Command('info', ['memory'], { replyEncoding: 'utf8' }), + // ) as string, + // ); + // return get(info, 'memory.number_of_cached_scripts', {}); + // })); + + const info = convertRedisInfoReplyToObject( + await redisClient.sendCommand( + new Command('info', ['memory'], { replyEncoding: 'utf8' }), + ) as string, + ); + const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts', {}); + + return parseInt(await nodesNumbersOfCachedScripts, 10) < minNumberOfCachedScripts; + } +} From 34dafdf69c3d18da5b89e54a5ef6bf898e5cbeb8 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 14 Nov 2022 15:08:43 +0400 Subject: [PATCH 013/201] #RI-3527-remove o;d provider --- .../database-recommendations.provider.ts | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts diff --git a/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts b/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts deleted file mode 100644 index eff64e7099..0000000000 --- a/redisinsight/api/src/modules/db-recommendations/providers/database-recommendations.provider.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Redis, Cluster, Command } from 'ioredis'; -import { get, max } from 'lodash'; -import { IFindRedisClientInstanceByOptions } from 'src/modules/redis/redis.service'; -// import { DatabaseConnectionService } from 'src/modules/database/database-connection.service'; -import { convertRedisInfoReplyToObject } from 'src/utils'; -import { AppTool } from 'src/models'; - -const minNumberOfCachedScripts = 10; - -@Injectable() -export class RecommendationsService { - // number of cached, - // current cached scripts from info - - // second func determint - // redis client (or info) - // constructor( - // private readonly databaseConnectionService: DatabaseConnectionService, - // ) {} - - /** - * Get database recommendations - * @param redisClient - */ - public async getRecommendations( - redisClient: Redis, - // { client?, keys?, info?, etc } - // clientOptions: IFindRedisClientInstanceByOptions, - ) { - const recommendations = []; - if (await this.getLuaScriptRecommendation(redisClient)) { - recommendations.push({ name: 'luaScript' }); - } - - return recommendations; - } - - /** - * Check lua script recommendation - * @param redisClient - */ - async getLuaScriptRecommendation( - // clientOptions: IFindRedisClientInstanceByOptions, - redisClient: Redis, - ): Promise { - // let nodes = []; - // const client = await this.databaseConnectionService.createClient({ - // databaseId: clientOptions.instanceId, - // namespace: AppTool.Common, - // }); - - // if (client instanceof Cluster) { - // nodes = client.nodes('master'); - // } else { - // nodes = [client]; - // } - - // const nodesNumbersOfCachedScripts = Promise.all(nodes.map(async (node) => { - // const info = convertRedisInfoReplyToObject( - // await node.sendCommand( - // new Command('info', ['memory'], { replyEncoding: 'utf8' }), - // ) as string, - // ); - // return get(info, 'memory.number_of_cached_scripts', {}); - // })); - - const info = convertRedisInfoReplyToObject( - await redisClient.sendCommand( - new Command('info', ['memory'], { replyEncoding: 'utf8' }), - ) as string, - ); - const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts', {}); - - return parseInt(await nodesNumbersOfCachedScripts, 10) < minNumberOfCachedScripts; - } -} From 73def34048178051363d302a8537934eeb07893a Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 14 Nov 2022 15:51:17 +0400 Subject: [PATCH 014/201] #RI-3527-resolve comments --- .../database-analysis.service.ts | 6 ++-- .../providers/recommendation.provider.ts | 28 ++------------- .../recommendation/recommendation.module.ts | 6 ++-- .../recommendation/recommendation.service.ts | 34 +++++++++++++++++++ 4 files changed, 42 insertions(+), 32 deletions(-) create mode 100644 redisinsight/api/src/modules/recommendation/recommendation.service.ts diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index 1e433dbdf3..5e9eaab1d0 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -1,7 +1,7 @@ import { HttpException, Injectable, Logger } from '@nestjs/common'; import { IFindRedisClientInstanceByOptions } from 'src/modules/redis/redis.service'; import { uniqWith, isEqual, flatten } from 'lodash'; -import { RecommendationService } from 'src/modules/recommendation/providers/recommendation.provider'; +import { RecommendationService } from 'src/modules/recommendation/recommendation.service'; import { catchAclError } from 'src/utils'; import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/database-analyzer'; import { plainToClass } from 'class-transformer'; @@ -19,7 +19,7 @@ export class DatabaseAnalysisService { constructor( private readonly databaseConnectionService: DatabaseConnectionService, - private readonly RecommendationModule: RecommendationService, + private readonly recommendationService: RecommendationService, private readonly analyzer: DatabaseAnalyzer, private readonly databaseAnalysisProvider: DatabaseAnalysisProvider, private readonly scanner: KeysScanner, @@ -59,7 +59,7 @@ export class DatabaseAnalysisService { const recommendations = DatabaseAnalysisService.getRecommendationsSummary( flatten(await Promise.all( scanResults.map(async (nodeResult) => ( - await this.RecommendationModule.getRecommendations({ + await this.recommendationService.getRecommendations({ client: nodeResult.client, keys: nodeResult.keys, }) diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index ba1cb5e2d9..871317acd3 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -1,37 +1,13 @@ import { Injectable } from '@nestjs/common'; import { Redis, Command } from 'ioredis'; import { get } from 'lodash'; -import { RedisString } from 'src/common/constants'; -import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { convertRedisInfoReplyToObject } from 'src/utils'; import { checkIsGreaterThan } from 'src/utils/base.helper'; const minNumberOfCachedScripts = 10; -interface RecommendationInput { - client?: Redis, - keys?: RedisString[], - info?: RedisString, -} - @Injectable() -export class RecommendationService { - /** - * Get recommendations - * @param dto - */ - public async getRecommendations( - dto: RecommendationInput, - ): Promise { - // generic solution, if somewhere we will sent info, we don't need determined some recommendations - const { client, keys, info } = dto; - const recommendations = []; - if (await this.determineLuaScriptRecommendation(client)) { - recommendations.push({ name: 'luaScript' }); - } - return recommendations; - } - +export class RecommendationProvider { /** * Check lua script recommendation * @param redisClient @@ -41,7 +17,7 @@ export class RecommendationService { ): Promise { const info = convertRedisInfoReplyToObject( await redisClient.sendCommand( - new Command('info', ['memory'], { replyEncoding: 'utf8' }), + new Command('info', [], { replyEncoding: 'utf8' }), ) as string, ); const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts', {}); diff --git a/redisinsight/api/src/modules/recommendation/recommendation.module.ts b/redisinsight/api/src/modules/recommendation/recommendation.module.ts index 14112eb9d5..9eb6c38863 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.module.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; - -import { RecommendationService } from './providers/recommendation.provider'; +import { RecommendationService } from './recommendation.service'; +import { RecommendationProvider } from './providers/recommendation.provider'; @Module({ - providers: [RecommendationService], + providers: [RecommendationService, RecommendationProvider], exports: [RecommendationService], }) export class RecommendationModule {} diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts new file mode 100644 index 0000000000..54c2d456bf --- /dev/null +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { Redis } from 'ioredis'; +import { RecommendationProvider } from 'src/modules/recommendation/providers/recommendation.provider'; +import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; +import { RedisString } from 'src/common/constants'; + +interface RecommendationInput { + client?: Redis, + keys?: RedisString[], + info?: RedisString, +} + +@Injectable() +export class RecommendationService { + constructor( + private readonly recommendationProvider: RecommendationProvider, + ) {} + + /** + * Get recommendations + * @param dto + */ + public async getRecommendations( + dto: RecommendationInput, + ): Promise { + // generic solution, if somewhere we will sent info, we don't need determined some recommendations + const { client, keys, info } = dto; + const recommendations = []; + if (await this.recommendationProvider.determineLuaScriptRecommendation(client)) { + recommendations.push({ name: 'luaScript' }); + } + return recommendations; + } +} From d1a60b7034038de37718052d22bbdc8d78c42c4d Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Mon, 14 Nov 2022 15:38:23 +0100 Subject: [PATCH 015/201] add tests for avoid dynamic lua script recommendation --- .../components/recommendations-view/utils.tsx | 2 +- tests/e2e/common-actions/cli-actions.ts | 20 ++++++- .../e2e/pageObjects/memory-efficiency-page.ts | 7 +++ .../memory-efficiency/recommendations.e2e.ts | 57 +++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx index efdd95543b..8ce97ac62e 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx @@ -41,7 +41,7 @@ export const parseContent = ({ type, value }: { type: string, value: any }) => { case 'span': return {value} case 'link': - return {value.name} + return {value.name} case 'spacer': return default: diff --git a/tests/e2e/common-actions/cli-actions.ts b/tests/e2e/common-actions/cli-actions.ts index 348a31ad5a..e2e82f79fa 100644 --- a/tests/e2e/common-actions/cli-actions.ts +++ b/tests/e2e/common-actions/cli-actions.ts @@ -1,16 +1,18 @@ import { t } from 'testcafe'; +import { Common } from '../helpers/common'; import { CliPage } from '../pageObjects'; const cliPage = new CliPage(); +const common = new Common(); export class CliActions { - + /** * Check list of commands searched * @param searchedCommand Searched command in Command Helper * @param listToCompare The list with commands to compare with opened in Command Helper */ - async checkSearchedCommandInCommandHelper(searchedCommand: string, listToCompare: string[]): Promise { + async checkSearchedCommandInCommandHelper(searchedCommand: string, listToCompare: string[]): Promise { await t.typeText(cliPage.cliHelperSearch, searchedCommand, { speed: 0.5 }); //Verify results in the output const commandsCount = await cliPage.cliHelperOutputTitles.count; @@ -30,4 +32,18 @@ export class CliActions { await t.expect(cliPage.cliHelperOutputTitles.nth(i).textContent).eql(listToCompare[i], 'Results in the output not contain searched value'); } } + + /** + * Add cached scripts + * @param numberOfScripts The number of cached scripts to add + */ + async addCachedScripts(numberOfScripts: number): Promise { + const scripts: string[] = []; + + for (let i = 0; i < numberOfScripts; i++) { + scripts.push(`EVAL "return '${common.generateWord(3)}'" 0`); + } + + await cliPage.sendCommandsInCli(scripts); + } } diff --git a/tests/e2e/pageObjects/memory-efficiency-page.ts b/tests/e2e/pageObjects/memory-efficiency-page.ts index 542235a88c..712c12ca0a 100644 --- a/tests/e2e/pageObjects/memory-efficiency-page.ts +++ b/tests/e2e/pageObjects/memory-efficiency-page.ts @@ -15,6 +15,8 @@ export class MemoryEfficiencyPage { reportItem = Selector('[data-test-subj^=items-report-]'); selectedReport = Selector('[data-testid=select-report]'); sortByLength = Selector('[data-testid=btn-change-table-keys]'); + recommendationsTab = Selector('[data-testid=Recommendations-tab]'); + luaScriptButton = Selector('[data-test-subj=luaScript-button]'); // ICONS reportTooltipIcon = Selector('[data-testid=db-new-reports-icon]'); // TEXT ELEMENTS @@ -26,6 +28,7 @@ export class MemoryEfficiencyPage { topKeysKeyName = Selector('[data-testid=top-keys-table-name]'); topNamespacesEmptyContainer = Selector('[data-testid=top-namespaces-empty]'); topNamespacesEmptyMessage = Selector('[data-testid=top-namespaces-message]'); + noRecommendationsMessage = Selector('[data-testid=empty-recommendations-message]'); // TABLE namespaceTable = Selector('[data-testid=nsp-table-memory]'); nameSpaceTableRows = this.namespaceTable.find('[data-testid^=row-]'); @@ -42,4 +45,8 @@ export class MemoryEfficiencyPage { noExpiryPoint = Selector('[data-testid*=bar-0-]:not(rect[data-testid=bar-0-0])'); // LINKS treeViewLink = Selector('[data-testid=tree-view-page-link]'); + readMoreLink = Selector('[data-testid=read-more-link]'); + // CONTAINERS + luaScriptAccordion = Selector('[data-testid=luaScript-accordion]'); + luaScriptTextContainer = Selector('#luaScript'); } diff --git a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts new file mode 100644 index 0000000000..68d784ba7a --- /dev/null +++ b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts @@ -0,0 +1,57 @@ +import { MyRedisDatabasePage, MemoryEfficiencyPage } from '../../../pageObjects'; +import { rte } from '../../../helpers/constants'; +import { acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; +import { commonUrl, ossStandaloneBigConfig } from '../../../helpers/conf'; +import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; +import { CliActions } from '../../../common-actions/cli-actions'; +import { Common } from '../../../helpers/common'; + +const memoryEfficiencyPage = new MemoryEfficiencyPage(); +const myRedisDatabasePage = new MyRedisDatabasePage(); +const cliActions = new CliActions(); +const common = new Common(); + +fixture `Memory Efficiency Recommendations` + .meta({ type: 'critical_path', rte: rte.standalone }) + .page(commonUrl); +test + .before(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneBigConfig, ossStandaloneBigConfig.databaseName); + // Go to Analysis Tools page + await t.click(myRedisDatabasePage.analysisPageButton); + await t.click(memoryEfficiencyPage.newReportBtn); + }) + .after(async() => { + await deleteStandaloneDatabaseApi(ossStandaloneBigConfig); + })('Avoid dynamic Lua script recommendation', async t => { + const noRecommendationsMessage = 'No Recommendations at the moment.'; + const externalPageLink = 'https://docs.redis.com/latest/ri/memory-optimizations/'; + + // Go to Recommendations tab + await t.click(memoryEfficiencyPage.recommendationsTab); + // No recommendations message + await t.expect(memoryEfficiencyPage.noRecommendationsMessage.textContent).eql(noRecommendationsMessage, 'No recommendations message not displayed'); + // Add cached scripts and generate new report + await cliActions.addCachedScripts(10); + await t.click(memoryEfficiencyPage.newReportBtn); + // No recommendations message with 10 cached scripts + await t.expect(memoryEfficiencyPage.noRecommendationsMessage.textContent).eql(noRecommendationsMessage, 'No recommendations message not displayed'); + // Add the last cached script to see the recommendation + await cliActions.addCachedScripts(1); + await t.click(memoryEfficiencyPage.newReportBtn); + // Verify that user can see Avoid dynamic Lua script recommendation when number_of_cached_scripts> 10 + await t.expect(memoryEfficiencyPage.luaScriptAccordion.exists).ok('Avoid dynamic lua script recommendation not displayed'); + + // Verify that user can expand/collapse recommendation + const expandedTextConaiterSize = await memoryEfficiencyPage.luaScriptTextContainer.offsetHeight; + await t.click(memoryEfficiencyPage.luaScriptButton); + await t.expect(await memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).lt(expandedTextConaiterSize, 'Recommendation not collapsed'); + await t.click(memoryEfficiencyPage.luaScriptButton); + await t.expect(await memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).eql(expandedTextConaiterSize, 'Recommendation not expanded'); + + // Verify that user can navigate by link to see the recommendation + await t.click(memoryEfficiencyPage.readMoreLink); + await common.checkURL(externalPageLink); + // Close the window with external link to switch to the application window + await t.closeWindow(); + }); From 0807a4071b447a151b2b957037e12eff935c0083 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Tue, 15 Nov 2022 02:04:10 +0400 Subject: [PATCH 016/201] #RI-3527-resolve comments --- .../recommendation/providers/recommendation.provider.ts | 4 ++-- .../api/src/modules/recommendation/recommendation.service.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 871317acd3..ce56773ec3 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -20,8 +20,8 @@ export class RecommendationProvider { new Command('info', [], { replyEncoding: 'utf8' }), ) as string, ); - const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts', {}); + const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts'); - return checkIsGreaterThan(minNumberOfCachedScripts, parseInt(await nodesNumbersOfCachedScripts, 10)); + return checkIsGreaterThan(minNumberOfCachedScripts, parseInt(nodesNumbersOfCachedScripts, 10)); } } diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index 54c2d456bf..67f1e29a9f 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -26,6 +26,7 @@ export class RecommendationService { // generic solution, if somewhere we will sent info, we don't need determined some recommendations const { client, keys, info } = dto; const recommendations = []; + // TODO refactor it if (await this.recommendationProvider.determineLuaScriptRecommendation(client)) { recommendations.push({ name: 'luaScript' }); } From 70101a207a5612b082b3776ab39cbea4587ed9eb Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Tue, 15 Nov 2022 15:23:36 +0400 Subject: [PATCH 017/201] #RI-3527-resolve comments --- .../recommendation/providers/recommendation.provider.ts | 3 +-- redisinsight/api/src/utils/base.helper.ts | 5 ----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index ce56773ec3..79a4cacdf6 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { Redis, Command } from 'ioredis'; import { get } from 'lodash'; import { convertRedisInfoReplyToObject } from 'src/utils'; -import { checkIsGreaterThan } from 'src/utils/base.helper'; const minNumberOfCachedScripts = 10; @@ -22,6 +21,6 @@ export class RecommendationProvider { ); const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts'); - return checkIsGreaterThan(minNumberOfCachedScripts, parseInt(nodesNumbersOfCachedScripts, 10)); + return parseInt(nodesNumbersOfCachedScripts, 10) > minNumberOfCachedScripts; } } diff --git a/redisinsight/api/src/utils/base.helper.ts b/redisinsight/api/src/utils/base.helper.ts index fe49924383..b72fc36c4e 100644 --- a/redisinsight/api/src/utils/base.helper.ts +++ b/redisinsight/api/src/utils/base.helper.ts @@ -4,8 +4,3 @@ export const sortByNumberField = ( items: T[], field: string, ): T[] => sortBy(items, (o) => (o && isNumber(o[field]) ? o[field] : -Infinity)); - -export const checkIsGreaterThan = ( - conditionNumber: number, - currentCount: number, -): boolean => currentCount > conditionNumber; From b596d5f0438f5d0838fa64371839b309c753e693 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Tue, 15 Nov 2022 15:57:46 +0400 Subject: [PATCH 018/201] #RI-3527-fix unit tests --- .../src/modules/database-analysis/scanner/keys-scanner.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/redisinsight/api/src/modules/database-analysis/scanner/keys-scanner.spec.ts b/redisinsight/api/src/modules/database-analysis/scanner/keys-scanner.spec.ts index 743827564a..ee1b5f34f8 100644 --- a/redisinsight/api/src/modules/database-analysis/scanner/keys-scanner.spec.ts +++ b/redisinsight/api/src/modules/database-analysis/scanner/keys-scanner.spec.ts @@ -45,6 +45,7 @@ const mockScanResult = { scanned: 15, total: 1, }, + client: Object.assign(nodeClient), }; describe('KeysScanner', () => { From 49cebaa1b1166819456d2ec39bd3e920a82bd5b6 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 16 Nov 2022 10:13:35 +0400 Subject: [PATCH 019/201] #RI-3527-add more tests --- .../database-analysis.service.ts | 1 + .../database-analysis.provider.spec.ts | 4 +- .../providers/recommendation.provider.ts | 25 ++++++ .../recommendation/recommendation.service.ts | 18 ++++- .../POST-databases-id-analysis.test.ts | 79 +++++++++++++++++-- redisinsight/api/test/helpers/constants.ts | 1 + redisinsight/api/test/helpers/data/redis.ts | 14 ++++ 7 files changed, 132 insertions(+), 10 deletions(-) diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index 5e9eaab1d0..a3442530a7 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -62,6 +62,7 @@ export class DatabaseAnalysisService { await this.recommendationService.getRecommendations({ client: nodeResult.client, keys: nodeResult.keys, + total: progress.total, }) )), )), diff --git a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.spec.ts b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.spec.ts index b4b38c45da..ba57e746ff 100644 --- a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.spec.ts +++ b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.spec.ts @@ -43,6 +43,7 @@ const mockDatabaseAnalysisEntity = new DatabaseAnalysisEntity({ topKeysLength: 'ENCRYPTED:topKeysLength', topKeysMemory: 'ENCRYPTED:topKeysMemory', expirationGroups: 'ENCRYPTED:expirationGroups', + recommendations: 'ENCRYPTED:recommendations', encryption: 'KEYTAR', createdAt: new Date(), }); @@ -146,6 +147,7 @@ const mockDatabaseAnalysis = { total: 0, }, ], + recommendations: [{ name: 'luaScript'}], } as DatabaseAnalysis; describe('DatabaseAnalysisProvider', () => { @@ -175,7 +177,7 @@ describe('DatabaseAnalysisProvider', () => { // encryption mocks [ 'filter', 'totalKeys', 'totalMemory', 'topKeysNsp', 'topMemoryNsp', - 'topKeysLength', 'topKeysMemory', 'expirationGroups', + 'topKeysLength', 'topKeysMemory', 'expirationGroups', 'recommendations', ].forEach((field) => { when(encryptionService.encrypt) .calledWith(JSON.stringify(mockDatabaseAnalysis[field])) diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 79a4cacdf6..c677f66e90 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -2,8 +2,12 @@ import { Injectable } from '@nestjs/common'; import { Redis, Command } from 'ioredis'; import { get } from 'lodash'; import { convertRedisInfoReplyToObject } from 'src/utils'; +import { RedisDataType } from 'src/modules/browser/dto'; +import { Key } from 'src/modules/database-analysis/models'; const minNumberOfCachedScripts = 10; +const maxHashLength = 5000; +const maxDatabaseTotal = 1_000_000; @Injectable() export class RecommendationProvider { @@ -23,4 +27,25 @@ export class RecommendationProvider { return parseInt(nodesNumbersOfCachedScripts, 10) > minNumberOfCachedScripts; } + + /** + * Check big hashes recommendation + * @param keys + */ + async determineBigHashesRecommendation( + keys: Key[], + ): Promise { + const bigHashes = keys.filter((key) => key.type === RedisDataType.Hash && key.length > maxHashLength); + return bigHashes.length > 0; + } + + /** + * Check big hashes recommendation + * @param total + */ + async determineBigTotalRecommendation( + total: number, + ): Promise { + return total > maxDatabaseTotal; + } } diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index 67f1e29a9f..a8530688cf 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -3,11 +3,13 @@ import { Redis } from 'ioredis'; import { RecommendationProvider } from 'src/modules/recommendation/providers/recommendation.provider'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { RedisString } from 'src/common/constants'; +import { Key } from 'src/modules/database-analysis/models'; interface RecommendationInput { client?: Redis, - keys?: RedisString[], + keys?: Key[], info?: RedisString, + total?: number, } @Injectable() @@ -24,12 +26,24 @@ export class RecommendationService { dto: RecommendationInput, ): Promise { // generic solution, if somewhere we will sent info, we don't need determined some recommendations - const { client, keys, info } = dto; + const { + client, + keys, + info, + total, + } = dto; + const recommendations = []; // TODO refactor it if (await this.recommendationProvider.determineLuaScriptRecommendation(client)) { recommendations.push({ name: 'luaScript' }); } + if (await this.recommendationProvider.determineBigHashesRecommendation(keys)) { + recommendations.push({ name: 'bigHashes' }); + } + if (await this.recommendationProvider.determineBigTotalRecommendation(total)) { + recommendations.push({ name: 'useSmallerKeys' }); + } return recommendations; } } diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index 17a69bf4f0..4f07b395af 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -15,14 +15,14 @@ describe('POST /databases/:instanceId/analysis', () => { // todo: skip for RE for now since scan 0 count 10000 might return cursor and 0 keys multiple times requirements('!rte.re'); - before(async() => { - repository = await localDb.getRepository(localDb.repositories.DATABASE_ANALYSIS); + before(async () => { + repository = await localDb.getRepository(localDb.repositories.DATABASE_ANALYSIS); - await localDb.generateNDatabaseAnalysis({ - databaseId: constants.TEST_INSTANCE_ID, - }, 30, true); + await localDb.generateNDatabaseAnalysis({ + databaseId: constants.TEST_INSTANCE_ID, + }, 30, true); - await rte.data.generateKeys(true); + await rte.data.generateKeys(true); }); [ @@ -135,7 +135,7 @@ describe('POST /databases/:instanceId/analysis', () => { expect(body.topKeysLength[0].length).to.gt(0); expect(body.expirationGroups.length).to.eq(8); - for(let i = 1; i < 8; i++) { + for (let i = 1; i < 8; i++) { expect(body.expirationGroups[i].label).to.be.a('string'); expect(body.expirationGroups[i].total).to.eq(0); expect(body.expirationGroups[i].threshold).to.gt(0); @@ -149,5 +149,70 @@ describe('POST /databases/:instanceId/analysis', () => { expect(await repository.count()).to.eq(5); } }, + { + name: 'Should create new database analysis with useSmallerKeys recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const KEYS_NUMBER = 1_000_001 + await rte.data.generateNKeys(KEYS_NUMBER, false); + }, + checkFn: async ({ body }) => { + expect(body.totalKeys.total).to.gt(0); + expect(body.totalMemory.total).to.gt(0); + expect(body.topKeysNsp.length).to.gt(0); + expect(body.topMemoryNsp.length).to.gt(0); + expect(body.topKeysLength.length).to.gt(0); + expect(body.topKeysMemory.length).to.gt(0); + expect(body.recommendations).to.deep.eq([{ name: 'useSmallerKeys'}]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with bigHashes recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_HASH_FIELDS = 5001 + await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); + }, + checkFn: async ({ body }) => { + expect(body.totalKeys.total).to.gt(0); + expect(body.totalMemory.total).to.gt(0); + expect(body.topKeysNsp.length).to.gt(0); + expect(body.topMemoryNsp.length).to.gt(0); + expect(body.topKeysLength.length).to.gt(0); + expect(body.topKeysMemory.length).to.gt(0); + expect(body.recommendations).to.deep.eq([{ name: 'bigHashes'}]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with luaScript recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + await rte.data.generateNCachedScripts(11, true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([{ name: 'luaScript'}]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, ].map(mainCheckFn); }); diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index 4cdc15f6be..7668348f26 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -454,5 +454,6 @@ export const constants = { TEST_DATABASE_ANALYSIS_RECOMMENDATION_1: { name: 'luaScript', }, + // etc... } diff --git a/redisinsight/api/test/helpers/data/redis.ts b/redisinsight/api/test/helpers/data/redis.ts index 745ffbb841..d54e3eb56a 100644 --- a/redisinsight/api/test/helpers/data/redis.ts +++ b/redisinsight/api/test/helpers/data/redis.ts @@ -456,6 +456,19 @@ export const initDataHelper = (rte) => { } } + // scripts + const generateNCachedScripts = async (number: number = 10, clean: boolean) => { + if (clean) { + await truncate(); + } + + const pipeline = []; + for (let i = 0; i < number; i++) { + pipeline.push(['eval', `return ${i}`, '0']) + } + await insertKeysBasedOnEnv(pipeline); + }; + return { sendCommand, executeCommand, @@ -476,6 +489,7 @@ export const initDataHelper = (rte) => { generateStreamsWithoutStrictMode, generateNStreams, generateNGraphs, + generateNCachedScripts, getClientNodes, } } From f42c03f104406ad78cec459570a6302b2c40099a Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 16 Nov 2022 10:16:30 +0400 Subject: [PATCH 020/201] #RI-3527-add more tests --- .../database-analysis.provider.spec.ts | 2 +- .../constants/dbAnalysisRecommendations.json | 40 +++++++++++++++++++ .../Recommendations.spec.tsx | 30 ++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.spec.ts b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.spec.ts index ba57e746ff..eb06385afa 100644 --- a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.spec.ts +++ b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.spec.ts @@ -147,7 +147,7 @@ const mockDatabaseAnalysis = { total: 0, }, ], - recommendations: [{ name: 'luaScript'}], + recommendations: [{ name: 'luaScript' }], } as DatabaseAnalysis; describe('DatabaseAnalysisProvider', () => { diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 7cde7e7a48..d466f41583 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -18,5 +18,45 @@ } ], "badges": ["code_changes"] + }, + "useSmallerKeys": { + "id": "useSmallerKeys", + "title":"Use smaller keys", + "content": [ + { + "id": "1", + "type": "span", + "value": "Shorten key names to optimize memory usage. Though, in general, descriptive key names are always preferred, these large key names can eat a lot of the memory. " + }, + { + "id": "2", + "type": "link", + "value": { + "href": "https://docs.redis.com/latest/ri/memory-optimizations/", + "name": "Read more" + } + } + ], + "badges": ["code_changes"] + }, + "bigHashes": { + "id": "bigHashes", + "title": "Shard big hashes to small hashes", + "content": [ + { + "id": "1", + "type": "span", + "value": "If you have a hash with a large number of key, value pairs, and if each key, value pair is small enough - break it into smaller hashes to save memory. To shard a HASH table, choose a method of partitioning the data. Hashes themselves have keys that can be used for partitioning the keys into different shards. The number of shards is determined by the total number of keys to be stored and the shard size. Using this and the hash value you can determine the shard ID in which the key resides. Though converting big hashes to small hashes will increase the complexity of your code. " + }, + { + "id": "2", + "type": "link", + "value": { + "href": "https://docs.redis.com/latest/ri/memory-optimizations/", + "name": "Read more" + } + } + ], + "badges": ["code_changes", "configuration_changes"] } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 7c406c5031..cc044861d2 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -112,6 +112,36 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() }) + it('should render code changes badge in useSmallerKeys recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'useSmallerKeys' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() + }) + + it('should render code changes badge and configuration_changes in bigHashes recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'bigHashes' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() + }) + it('should collapse/expand', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, From 2b7f2843f245c8e1a5f577a128b6fd7eedea9377 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 16 Nov 2022 11:48:42 +0400 Subject: [PATCH 021/201] #RI-3527-update recommendation merge --- .../database-analysis.service.ts | 27 ++++++++++++++----- .../models/recommendation.ts | 9 ++++++- .../providers/recommendation.provider.ts | 22 ++++++++++----- .../recommendation/recommendation.service.ts | 17 ++++-------- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index a3442530a7..3059ab4eeb 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -1,6 +1,6 @@ import { HttpException, Injectable, Logger } from '@nestjs/common'; import { IFindRedisClientInstanceByOptions } from 'src/modules/redis/redis.service'; -import { uniqWith, isEqual, flatten } from 'lodash'; +import { omit } from 'lodash'; import { RecommendationService } from 'src/modules/recommendation/recommendation.service'; import { catchAclError } from 'src/utils'; import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/database-analyzer'; @@ -57,7 +57,7 @@ export class DatabaseAnalysisService { }); const recommendations = DatabaseAnalysisService.getRecommendationsSummary( - flatten(await Promise.all( + await Promise.all( scanResults.map(async (nodeResult) => ( await this.recommendationService.getRecommendations({ client: nodeResult.client, @@ -65,7 +65,7 @@ export class DatabaseAnalysisService { total: progress.total, }) )), - )), + ), ); const analysis = plainToClass(DatabaseAnalysis, await this.analyzer.analyze({ databaseId: clientOptions.instanceId, @@ -107,8 +107,23 @@ export class DatabaseAnalysisService { * @param recommendations */ - static getRecommendationsSummary(recommendations: Recommendation[]): Recommendation[] { - // if we will sent any values with recommendations, they will be calculate here - return uniqWith(recommendations, isEqual); + static getRecommendationsSummary(recommendations: Recommendation[][]): Recommendation[] { + const mergedRecommendations = recommendations.reduce((acc, nodeRecommendations) => { + nodeRecommendations.forEach((recommendation) => { + if (!acc[recommendation.name]) { + acc[recommendation.name] = recommendation; + } else { + acc[recommendation.name] = { + name: recommendation.name, + isActual: recommendation.isActual || acc[recommendation.name].isActual, + // merge other fields here + }; + } + }); + return acc; + }, {}); + return Object.values(mergedRecommendations) + .filter((rec: Recommendation) => rec.isActual) + .map((recommendation: Recommendation) => omit(recommendation, 'isActual')); } } diff --git a/redisinsight/api/src/modules/database-analysis/models/recommendation.ts b/redisinsight/api/src/modules/database-analysis/models/recommendation.ts index e3910bbfe1..6e59156b0b 100644 --- a/redisinsight/api/src/modules/database-analysis/models/recommendation.ts +++ b/redisinsight/api/src/modules/database-analysis/models/recommendation.ts @@ -1,5 +1,5 @@ import { Expose } from 'class-transformer'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class Recommendation { @ApiProperty({ @@ -9,4 +9,11 @@ export class Recommendation { }) @Expose() name: string; + + @ApiPropertyOptional({ + description: 'Is recommendation actual for database', + type: Boolean, + }) + @Expose() + isActual?: boolean; } diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index c677f66e90..bed35d6483 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -3,6 +3,7 @@ import { Redis, Command } from 'ioredis'; import { get } from 'lodash'; import { convertRedisInfoReplyToObject } from 'src/utils'; import { RedisDataType } from 'src/modules/browser/dto'; +import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { Key } from 'src/modules/database-analysis/models'; const minNumberOfCachedScripts = 10; @@ -17,7 +18,7 @@ export class RecommendationProvider { */ async determineLuaScriptRecommendation( redisClient: Redis, - ): Promise { + ): Promise { const info = convertRedisInfoReplyToObject( await redisClient.sendCommand( new Command('info', [], { replyEncoding: 'utf8' }), @@ -25,7 +26,10 @@ export class RecommendationProvider { ); const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts'); - return parseInt(nodesNumbersOfCachedScripts, 10) > minNumberOfCachedScripts; + return ({ + name: 'luaScript', + isActual: parseInt(nodesNumbersOfCachedScripts, 10) > minNumberOfCachedScripts, + }); } /** @@ -34,9 +38,12 @@ export class RecommendationProvider { */ async determineBigHashesRecommendation( keys: Key[], - ): Promise { + ): Promise { const bigHashes = keys.filter((key) => key.type === RedisDataType.Hash && key.length > maxHashLength); - return bigHashes.length > 0; + return ({ + name: 'bigHashes', + isActual: bigHashes.length > 0, + }); } /** @@ -45,7 +52,10 @@ export class RecommendationProvider { */ async determineBigTotalRecommendation( total: number, - ): Promise { - return total > maxDatabaseTotal; + ): Promise { + return ({ + name: 'useSmallerKeys', + isActual: total > maxDatabaseTotal, + }); } } diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index a8530688cf..8a50722cfa 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -33,17 +33,10 @@ export class RecommendationService { total, } = dto; - const recommendations = []; - // TODO refactor it - if (await this.recommendationProvider.determineLuaScriptRecommendation(client)) { - recommendations.push({ name: 'luaScript' }); - } - if (await this.recommendationProvider.determineBigHashesRecommendation(keys)) { - recommendations.push({ name: 'bigHashes' }); - } - if (await this.recommendationProvider.determineBigTotalRecommendation(total)) { - recommendations.push({ name: 'useSmallerKeys' }); - } - return recommendations; + return ([ + await this.recommendationProvider.determineLuaScriptRecommendation(client), + await this.recommendationProvider.determineBigHashesRecommendation(keys), + await this.recommendationProvider.determineBigTotalRecommendation(total), + ]); } } From 074998a1bcae16b01777f4773c6c89ac32111e81 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 16 Nov 2022 13:40:08 +0400 Subject: [PATCH 022/201] RI-3527-resolve comments --- .../database-analysis.service.ts | 29 +++++-------------- .../models/recommendation.ts | 9 +----- .../providers/recommendation.provider.ts | 19 ++++-------- .../GET-databases-id-analysis-id.test.ts | 2 +- .../POST-databases-id-analysis.test.ts | 6 ++-- redisinsight/api/test/helpers/constants.ts | 8 ++++- redisinsight/api/test/helpers/local-db.ts | 2 +- 7 files changed, 27 insertions(+), 48 deletions(-) diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index 3059ab4eeb..2fa3069476 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -1,6 +1,6 @@ import { HttpException, Injectable, Logger } from '@nestjs/common'; import { IFindRedisClientInstanceByOptions } from 'src/modules/redis/redis.service'; -import { omit } from 'lodash'; +import { isNull, flatten, uniqBy } from 'lodash'; import { RecommendationService } from 'src/modules/recommendation/recommendation.service'; import { catchAclError } from 'src/utils'; import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/database-analyzer'; @@ -57,7 +57,7 @@ export class DatabaseAnalysisService { }); const recommendations = DatabaseAnalysisService.getRecommendationsSummary( - await Promise.all( + flatten(await Promise.all( scanResults.map(async (nodeResult) => ( await this.recommendationService.getRecommendations({ client: nodeResult.client, @@ -65,7 +65,7 @@ export class DatabaseAnalysisService { total: progress.total, }) )), - ), + )), ); const analysis = plainToClass(DatabaseAnalysis, await this.analyzer.analyze({ databaseId: clientOptions.instanceId, @@ -107,23 +107,10 @@ export class DatabaseAnalysisService { * @param recommendations */ - static getRecommendationsSummary(recommendations: Recommendation[][]): Recommendation[] { - const mergedRecommendations = recommendations.reduce((acc, nodeRecommendations) => { - nodeRecommendations.forEach((recommendation) => { - if (!acc[recommendation.name]) { - acc[recommendation.name] = recommendation; - } else { - acc[recommendation.name] = { - name: recommendation.name, - isActual: recommendation.isActual || acc[recommendation.name].isActual, - // merge other fields here - }; - } - }); - return acc; - }, {}); - return Object.values(mergedRecommendations) - .filter((rec: Recommendation) => rec.isActual) - .map((recommendation: Recommendation) => omit(recommendation, 'isActual')); + static getRecommendationsSummary(recommendations: Recommendation[]): Recommendation[] { + return uniqBy( + recommendations.filter((recommendation) => !isNull(recommendation)), + 'name', + ); } } diff --git a/redisinsight/api/src/modules/database-analysis/models/recommendation.ts b/redisinsight/api/src/modules/database-analysis/models/recommendation.ts index 6e59156b0b..e3910bbfe1 100644 --- a/redisinsight/api/src/modules/database-analysis/models/recommendation.ts +++ b/redisinsight/api/src/modules/database-analysis/models/recommendation.ts @@ -1,5 +1,5 @@ import { Expose } from 'class-transformer'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; export class Recommendation { @ApiProperty({ @@ -9,11 +9,4 @@ export class Recommendation { }) @Expose() name: string; - - @ApiPropertyOptional({ - description: 'Is recommendation actual for database', - type: Boolean, - }) - @Expose() - isActual?: boolean; } diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index bed35d6483..4966f153aa 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -26,10 +26,9 @@ export class RecommendationProvider { ); const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts'); - return ({ - name: 'luaScript', - isActual: parseInt(nodesNumbersOfCachedScripts, 10) > minNumberOfCachedScripts, - }); + return parseInt(nodesNumbersOfCachedScripts, 10) > minNumberOfCachedScripts + ? { name: 'luaScript' } + : null; } /** @@ -39,11 +38,8 @@ export class RecommendationProvider { async determineBigHashesRecommendation( keys: Key[], ): Promise { - const bigHashes = keys.filter((key) => key.type === RedisDataType.Hash && key.length > maxHashLength); - return ({ - name: 'bigHashes', - isActual: bigHashes.length > 0, - }); + const bigHashes = keys.some((key) => key.type === RedisDataType.Hash && key.length > maxHashLength); + return bigHashes ? { name: 'bigHashes' } : null; } /** @@ -53,9 +49,6 @@ export class RecommendationProvider { async determineBigTotalRecommendation( total: number, ): Promise { - return ({ - name: 'useSmallerKeys', - isActual: total > maxDatabaseTotal, - }); + return total > maxDatabaseTotal ? { name: 'useSmallerKeys' } : null; } } diff --git a/redisinsight/api/test/api/database-analysis/GET-databases-id-analysis-id.test.ts b/redisinsight/api/test/api/database-analysis/GET-databases-id-analysis-id.test.ts index 9c4709a0cc..a6c6acb5cc 100644 --- a/redisinsight/api/test/api/database-analysis/GET-databases-id-analysis-id.test.ts +++ b/redisinsight/api/test/api/database-analysis/GET-databases-id-analysis-id.test.ts @@ -38,7 +38,7 @@ describe('GET /databases/:id/analysis/:id', () => { topKeysLength: [constants.TEST_DATABASE_ANALYSIS_TOP_KEYS_1], topKeysMemory: [constants.TEST_DATABASE_ANALYSIS_TOP_KEYS_1], expirationGroups: [constants.TEST_DATABASE_ANALYSIS_EXPIRATION_GROUP_1], - recommendations: [constants.TEST_DATABASE_ANALYSIS_RECOMMENDATION_1], + recommendations: [constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION], }); } }, diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index 4f07b395af..a800d7c27d 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -167,7 +167,7 @@ describe('POST /databases/:instanceId/analysis', () => { expect(body.topMemoryNsp.length).to.gt(0); expect(body.topKeysLength.length).to.gt(0); expect(body.topKeysMemory.length).to.gt(0); - expect(body.recommendations).to.deep.eq([{ name: 'useSmallerKeys'}]); + expect(body.recommendations).to.deep.eq([constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION]); }, after: async () => { expect(await repository.count()).to.eq(5); @@ -191,7 +191,7 @@ describe('POST /databases/:instanceId/analysis', () => { expect(body.topMemoryNsp.length).to.gt(0); expect(body.topKeysLength.length).to.gt(0); expect(body.topKeysMemory.length).to.gt(0); - expect(body.recommendations).to.deep.eq([{ name: 'bigHashes'}]); + expect(body.recommendations).to.deep.eq([constants.TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION]); }, after: async () => { expect(await repository.count()).to.eq(5); @@ -208,7 +208,7 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateNCachedScripts(11, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([{ name: 'luaScript'}]); + expect(body.recommendations).to.deep.eq([constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION]); }, after: async () => { expect(await repository.count()).to.eq(5); diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index 7668348f26..115662bb0c 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -451,9 +451,15 @@ export const constants = { threshold: 4 * 60 * 60 * 1000, }, - TEST_DATABASE_ANALYSIS_RECOMMENDATION_1: { + TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION: { name: 'luaScript', }, + TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION: { + name: 'bigHashes', + }, + TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION: { + name: 'useSmallerKeys', + }, // etc... } diff --git a/redisinsight/api/test/helpers/local-db.ts b/redisinsight/api/test/helpers/local-db.ts index e7deea22a2..d2114ee2ff 100644 --- a/redisinsight/api/test/helpers/local-db.ts +++ b/redisinsight/api/test/helpers/local-db.ts @@ -176,7 +176,7 @@ export const generateNDatabaseAnalysis = async ( expirationGroups: encryptData(JSON.stringify([ constants.TEST_DATABASE_ANALYSIS_EXPIRATION_GROUP_1, ])), - recommendations: encryptData(JSON.stringify([constants.TEST_DATABASE_ANALYSIS_RECOMMENDATION_1])), + recommendations: encryptData(JSON.stringify([constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION])), createdAt: new Date(), encryption: constants.TEST_ENCRYPTION_STRATEGY, ...partial, From 058878b30c93719038cf9f23fc601d8ecb5efd71 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 17 Nov 2022 13:52:21 +0400 Subject: [PATCH 023/201] #RI-3527-fix recommendation styles --- .../components/recommendations-view/styles.module.scss | 4 ++-- .../ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss | 1 + .../ui/src/styles/themes/dark_theme/_theme_color.scss | 1 + .../ui/src/styles/themes/light_theme/_light_theme.lazy.scss | 1 + .../ui/src/styles/themes/light_theme/_theme_color.scss | 1 + 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index 35851a091c..4360fd75c3 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -28,7 +28,7 @@ .recommendation { border-radius: 8px; - border: 1px solid #323232; + border: 1px solid var(--recommendationBorderColor); background-color: var(--euiColorLightestShade); margin-bottom: 6px; padding: 18px; @@ -66,7 +66,7 @@ margin-right: 48px; .badge { - margin: 18px 18px 14px; + margin: 14px 18px; .badgeIcon { margin-right: 14px; diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index 39a11d6eeb..01ad94bc75 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss @@ -178,4 +178,5 @@ // Database analysis --badgeIconColor: #{$badgeIconColor}; + --recommendationBorderColor: #{$recommendationBorderColor}; } diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss index 7d7a086ee5..1d178f1d72 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -138,3 +138,4 @@ $pubSubClientsBadge: #008000; // Database analysis $badgeIconColor : #D8AB52; +$recommendationBorderColor: #363636; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss index b413a7a29a..ec1ff06f06 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss @@ -180,4 +180,5 @@ // Database analysis --badgeIconColor: #{$badgeIconColor}; + --recommendationBorderColor: #{$recommendationBorderColor}; } diff --git a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss index c7da8d8e30..dcf5134c1a 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -135,3 +135,4 @@ $pubSubClientsBadge: #b5cea8; // Database analysis $badgeIconColor : #415681; +$recommendationBorderColor: #3953c3; From 027308fb7a452bcf6a5d81c111b81ce6453d432a Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 17 Nov 2022 13:37:01 +0100 Subject: [PATCH 024/201] add tests for use smaller keys and big hash --- .../e2e/pageObjects/memory-efficiency-page.ts | 4 + .../memory-efficiency/recommendations.e2e.ts | 101 +++++++++++++----- 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/tests/e2e/pageObjects/memory-efficiency-page.ts b/tests/e2e/pageObjects/memory-efficiency-page.ts index 712c12ca0a..4cb4947193 100644 --- a/tests/e2e/pageObjects/memory-efficiency-page.ts +++ b/tests/e2e/pageObjects/memory-efficiency-page.ts @@ -29,6 +29,8 @@ export class MemoryEfficiencyPage { topNamespacesEmptyContainer = Selector('[data-testid=top-namespaces-empty]'); topNamespacesEmptyMessage = Selector('[data-testid=top-namespaces-message]'); noRecommendationsMessage = Selector('[data-testid=empty-recommendations-message]'); + codeChangesLabel = Selector('[data-testid=code_changes]'); + configurationChangesLabel = Selector('[data-testid=configuration_changes]'); // TABLE namespaceTable = Selector('[data-testid=nsp-table-memory]'); nameSpaceTableRows = this.namespaceTable.find('[data-testid^=row-]'); @@ -49,4 +51,6 @@ export class MemoryEfficiencyPage { // CONTAINERS luaScriptAccordion = Selector('[data-testid=luaScript-accordion]'); luaScriptTextContainer = Selector('#luaScript'); + useSmallKeysAccordion = Selector('[data-testid=useSmallerKeys-accordion]'); + bigHashesAccordion = Selector('[data-testid=bigHashes-accordion]'); } diff --git a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts index 68d784ba7a..12cdbbfac3 100644 --- a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts +++ b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts @@ -1,57 +1,102 @@ -import { MyRedisDatabasePage, MemoryEfficiencyPage } from '../../../pageObjects'; +import { MyRedisDatabasePage, MemoryEfficiencyPage, BrowserPage, CliPage } from '../../../pageObjects'; import { rte } from '../../../helpers/constants'; import { acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; -import { commonUrl, ossStandaloneBigConfig } from '../../../helpers/conf'; +import { commonUrl, ossStandaloneBigConfig, ossStandaloneConfig } from '../../../helpers/conf'; import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; import { CliActions } from '../../../common-actions/cli-actions'; import { Common } from '../../../helpers/common'; +import { populateHashWithFields } from '../../../helpers/keys'; const memoryEfficiencyPage = new MemoryEfficiencyPage(); const myRedisDatabasePage = new MyRedisDatabasePage(); const cliActions = new CliActions(); const common = new Common(); +const browserPage = new BrowserPage(); +const cliPage = new CliPage(); + +const externalPageLink = 'https://docs.redis.com/latest/ri/memory-optimizations/'; +const keyName = `hugeHashKey-${common.generateWord(10)}`; fixture `Memory Efficiency Recommendations` .meta({ type: 'critical_path', rte: rte.standalone }) - .page(commonUrl); -test - .before(async t => { + .page(commonUrl) + .beforeEach(async t => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneBigConfig, ossStandaloneBigConfig.databaseName); // Go to Analysis Tools page await t.click(myRedisDatabasePage.analysisPageButton); await t.click(memoryEfficiencyPage.newReportBtn); }) - .after(async() => { + .afterEach(async() => { + await cliPage.sendCommandInCli(`SCRIPT FLUSH`); await deleteStandaloneDatabaseApi(ossStandaloneBigConfig); - })('Avoid dynamic Lua script recommendation', async t => { + }); +test('Avoid dynamic Lua script recommendation', async t => { + // Go to Recommendations tab + await t.click(memoryEfficiencyPage.recommendationsTab); + // Add cached scripts and generate new report + await cliActions.addCachedScripts(10); + await t.click(memoryEfficiencyPage.newReportBtn); + // No recommendation with 10 cached scripts + await t.expect(memoryEfficiencyPage.luaScriptAccordion.exists).notOk('Avoid dynamic lua script recommendation displayed with 10 cached scripts'); + // Add the last cached script to see the recommendation + await cliActions.addCachedScripts(1); + await t.click(memoryEfficiencyPage.newReportBtn); + // Verify that user can see Avoid dynamic Lua script recommendation when number_of_cached_scripts> 10 + await t.expect(memoryEfficiencyPage.luaScriptAccordion.exists).ok('Avoid dynamic lua script recommendation not displayed'); + + // Verify that user can see Use smaller keys recommendation when database has 1M+ keys + await t.expect(memoryEfficiencyPage.useSmallKeysAccordion.exists).ok('Use smaller keys recommendation not displayed'); + + // Verify that user can expand/collapse recommendation + const expandedTextConaiterSize = await memoryEfficiencyPage.luaScriptTextContainer.offsetHeight; + await t.click(memoryEfficiencyPage.luaScriptButton); + await t.expect(memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).lt(expandedTextConaiterSize, 'Recommendation not collapsed'); + await t.click(memoryEfficiencyPage.luaScriptButton); + await t.expect(memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).eql(expandedTextConaiterSize, 'Recommendation not expanded'); + + // Verify that user can navigate by link to see the recommendation + await t.click(memoryEfficiencyPage.readMoreLink); + await common.checkURL(externalPageLink); + // Close the window with external link to switch to the application window + await t.closeWindow(); +}); +test + .before(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await browserPage.addHashKey(keyName, '2147476121', 'field', 'value'); + // Go to Analysis Tools page + await t.click(myRedisDatabasePage.analysisPageButton); + await t.click(memoryEfficiencyPage.newReportBtn); + }) + .after(async t => { + // Clear and delete database + await t.click(myRedisDatabasePage.browserButton); + await browserPage.deleteKeyByName(keyName); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('Shard big hashes to small hashes recommendation', async t => { const noRecommendationsMessage = 'No Recommendations at the moment.'; - const externalPageLink = 'https://docs.redis.com/latest/ri/memory-optimizations/'; + // const dbParameters = { host: ossStandaloneConfig.host, port: ossStandaloneConfig.port }; + const dbParameters = { host: 'localhost', port: '8100' }; + const keyToAddParameters = { fieldsCount: 4999, keyName, fieldStartWith: 'hashField', fieldValueStartWith: 'hashValue' }; + const keyToAddParameters2 = { fieldsCount: 1, keyName, fieldStartWith: 'hashFieldLast', fieldValueStartWith: 'hashValueLast' }; // Go to Recommendations tab await t.click(memoryEfficiencyPage.recommendationsTab); // No recommendations message await t.expect(memoryEfficiencyPage.noRecommendationsMessage.textContent).eql(noRecommendationsMessage, 'No recommendations message not displayed'); - // Add cached scripts and generate new report - await cliActions.addCachedScripts(10); + + // Add 5000 fields to the hash key + await populateHashWithFields(dbParameters.host, dbParameters.port, keyToAddParameters); + // Generate new report await t.click(memoryEfficiencyPage.newReportBtn); - // No recommendations message with 10 cached scripts + // Verify that big keys recommendation not displayed when hash has 5000 fields await t.expect(memoryEfficiencyPage.noRecommendationsMessage.textContent).eql(noRecommendationsMessage, 'No recommendations message not displayed'); - // Add the last cached script to see the recommendation - await cliActions.addCachedScripts(1); + // Add the last field in hash key + await populateHashWithFields(dbParameters.host, dbParameters.port, keyToAddParameters2); + // Generate new report await t.click(memoryEfficiencyPage.newReportBtn); - // Verify that user can see Avoid dynamic Lua script recommendation when number_of_cached_scripts> 10 - await t.expect(memoryEfficiencyPage.luaScriptAccordion.exists).ok('Avoid dynamic lua script recommendation not displayed'); - - // Verify that user can expand/collapse recommendation - const expandedTextConaiterSize = await memoryEfficiencyPage.luaScriptTextContainer.offsetHeight; - await t.click(memoryEfficiencyPage.luaScriptButton); - await t.expect(await memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).lt(expandedTextConaiterSize, 'Recommendation not collapsed'); - await t.click(memoryEfficiencyPage.luaScriptButton); - await t.expect(await memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).eql(expandedTextConaiterSize, 'Recommendation not expanded'); - - // Verify that user can navigate by link to see the recommendation - await t.click(memoryEfficiencyPage.readMoreLink); - await common.checkURL(externalPageLink); - // Close the window with external link to switch to the application window - await t.closeWindow(); + // Verify that user can see Shard big hashes to small hashes recommendation when Hash length > 5,000 + await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).ok('Shard big hashes to small hashes recommendation not displayed'); + await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Big hashes recommendation not have Code Changes label'); + await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Big hashes recommendation not have Configuration Changes label'); }); From bff1701bb4d552269357f7243b79de97fdfc83a3 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 17 Nov 2022 13:38:54 +0100 Subject: [PATCH 025/201] upd --- .../critical-path/memory-efficiency/recommendations.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts index 12cdbbfac3..772355c16d 100644 --- a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts +++ b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts @@ -27,7 +27,7 @@ fixture `Memory Efficiency Recommendations` await t.click(memoryEfficiencyPage.newReportBtn); }) .afterEach(async() => { - await cliPage.sendCommandInCli(`SCRIPT FLUSH`); + await cliPage.sendCommandInCli('SCRIPT FLUSH'); await deleteStandaloneDatabaseApi(ossStandaloneBigConfig); }); test('Avoid dynamic Lua script recommendation', async t => { From 14b9182a11fc68bfbee74890dd9decfbea31c82d Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 17 Nov 2022 13:39:29 +0100 Subject: [PATCH 026/201] change host and port --- .../critical-path/memory-efficiency/recommendations.e2e.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts index 772355c16d..9cf475a009 100644 --- a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts +++ b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts @@ -75,8 +75,7 @@ test await deleteStandaloneDatabaseApi(ossStandaloneConfig); })('Shard big hashes to small hashes recommendation', async t => { const noRecommendationsMessage = 'No Recommendations at the moment.'; - // const dbParameters = { host: ossStandaloneConfig.host, port: ossStandaloneConfig.port }; - const dbParameters = { host: 'localhost', port: '8100' }; + const dbParameters = { host: ossStandaloneConfig.host, port: ossStandaloneConfig.port }; const keyToAddParameters = { fieldsCount: 4999, keyName, fieldStartWith: 'hashField', fieldValueStartWith: 'hashValue' }; const keyToAddParameters2 = { fieldsCount: 1, keyName, fieldStartWith: 'hashFieldLast', fieldValueStartWith: 'hashValueLast' }; From f6418ebbaee323a8ac01fe9bccea8659c346bb7b Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Tue, 29 Nov 2022 07:31:23 +0400 Subject: [PATCH 027/201] #RI-3571,3564,3560 - recommendations --- .../providers/recommendation.provider.ts | 60 ++++++++++++++++++- .../recommendation/recommendation.service.ts | 3 + .../POST-databases-id-analysis.test.ts | 43 ++++++++++++- redisinsight/api/test/helpers/constants.ts | 6 ++ redisinsight/api/test/helpers/data/redis.ts | 20 +++++++ 5 files changed, 127 insertions(+), 5 deletions(-) diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 4966f153aa..11b7769273 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -1,13 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { Redis, Command } from 'ioredis'; +import { Redis, Cluster, Command } from 'ioredis'; import { get } from 'lodash'; -import { convertRedisInfoReplyToObject } from 'src/utils'; +import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/utils'; import { RedisDataType } from 'src/modules/browser/dto'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { Key } from 'src/modules/database-analysis/models'; const minNumberOfCachedScripts = 10; const maxHashLength = 5000; +const maxStringLength = 200; const maxDatabaseTotal = 1_000_000; @Injectable() @@ -51,4 +52,59 @@ export class RecommendationProvider { ): Promise { return total > maxDatabaseTotal ? { name: 'useSmallerKeys' } : null; } + + /** + * Check logical databases recommendation + * @param redisClient + */ + async determineLogicalDatabasesRecommendation( + redisClient: Redis | Cluster, + ): Promise { + if (redisClient.isCluster) { + return null; + } + const info = convertRedisInfoReplyToObject( + await redisClient.info(), + ); + const keyspace = get(info, 'keyspace', {}); + const databasesWithKeys = Object.values(keyspace).filter((db) => { + const { keys } = convertBulkStringsToObject(db as string, ',', '='); + return keys > 0; + }); + return databasesWithKeys.length > 1 ? { name: 'avoidLogicalDatabases' } : null; + } + + /** + * Check combine small strings to hashes recommendation + * @param keys + */ + async determineCombineSmallStringsToHashesRecommendation( + keys: Key[], + ): Promise { + const smallString = keys.some((key) => key.type === RedisDataType.String && key.memory < maxStringLength); + return smallString ? { name: 'combineSmallStringsToHashes' } : null; + } + + /** + * Check increase set max intset entries recommendation + * @param keys + * @param redisClient + */ + async determineIncreaseSetMaxIntsetEntriesRecommendation( + redisClient: Redis | Cluster, + keys: Key[], + ): Promise { + const [, setMaxIntsetEntries] = await redisClient.sendCommand( + new Command('config', ['get', 'set-max-intset-entries'], { + replyEncoding: 'utf8', + }), + ) as string[]; + + if (!setMaxIntsetEntries) { + return null; + } + + const bigSet = keys.some((key) => key.type === RedisDataType.Set && key.length > parseInt(setMaxIntsetEntries, 10)); + return bigSet ? { name: 'increaseSetMaxIntsetEntries' } : null; + } } diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index 8a50722cfa..ece8b925b8 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -37,6 +37,9 @@ export class RecommendationService { await this.recommendationProvider.determineLuaScriptRecommendation(client), await this.recommendationProvider.determineBigHashesRecommendation(keys), await this.recommendationProvider.determineBigTotalRecommendation(total), + await this.recommendationProvider.determineLogicalDatabasesRecommendation(client), + await this.recommendationProvider.determineCombineSmallStringsToHashesRecommendation(keys), + await this.recommendationProvider.determineIncreaseSetMaxIntsetEntriesRecommendation(client, keys), ]); } } diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index a800d7c27d..a038ec076c 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -64,7 +64,6 @@ describe('POST /databases/:instanceId/analysis', () => { expect(body.topKeysLength.length).to.gt(0); expect(body.topKeysMemory.length).to.gt(0); expect(body.expirationGroups.length).to.gt(0); - expect(body.recommendations.length).to.eq(0); }, after: async () => { expect(await repository.count()).to.eq(5); @@ -143,7 +142,6 @@ describe('POST /databases/:instanceId/analysis', () => { expect(body.expirationGroups[0].label).to.eq('No Expiry'); expect(body.expirationGroups[0].total).to.gt(0); expect(body.expirationGroups[0].threshold).to.eq(0); - expect(body.recommendations.length).to.eq(0); }, after: async () => { expect(await repository.count()).to.eq(5); @@ -167,7 +165,11 @@ describe('POST /databases/:instanceId/analysis', () => { expect(body.topMemoryNsp.length).to.gt(0); expect(body.topKeysLength.length).to.gt(0); expect(body.topKeysMemory.length).to.gt(0); - expect(body.recommendations).to.deep.eq([constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION]); + expect(body.recommendations).to.deep.eq([ + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + // generateNKeys generated small strings + constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, + ]); }, after: async () => { expect(await repository.count()).to.eq(5); @@ -197,6 +199,41 @@ describe('POST /databases/:instanceId/analysis', () => { expect(await repository.count()).to.eq(5); } }, + { + name: 'Should create new database analysis with increaseSetMaxIntsetEntries recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_SET_MEMBERS = 513; + await rte.data.generateHugeNumberOfMembersForSetKey(NUMBERS_OF_SET_MEMBERS, true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with combineSmallStringsToHashes recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + await rte.data.generateStrings(true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, { name: 'Should create new database analysis with luaScript recommendation', data: { diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index 115662bb0c..a27b142223 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -460,6 +460,12 @@ export const constants = { TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION: { name: 'useSmallerKeys', }, + TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION: { + name: 'increaseSetMaxIntsetEntries', + }, + TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION: { + name: 'combineSmallStringsToHashes', + }, // etc... } diff --git a/redisinsight/api/test/helpers/data/redis.ts b/redisinsight/api/test/helpers/data/redis.ts index d54e3eb56a..c6c93ae14b 100644 --- a/redisinsight/api/test/helpers/data/redis.ts +++ b/redisinsight/api/test/helpers/data/redis.ts @@ -386,6 +386,25 @@ export const initDataHelper = (rte) => { } while (inserted < number) }; + const generateHugeNumberOfMembersForSetKey = async (number: number = 100000, clean: boolean) => { + if (clean) { + await truncate(); + } + + const batchSize = 10000; + let inserted = 0; + do { + const pipeline = []; + const limit = inserted + batchSize; + for (inserted; inserted < limit && inserted < number; inserted++) { + pipeline.push(['sadd', constants.TEST_SET_KEY_1, inserted]); + } + + await insertKeysBasedOnEnv(pipeline, true); + } while (inserted < number) + }; + + const generateHugeNumberOfTinyStringKeys = async (number: number = 100000, clean: boolean) => { if (clean) { await truncate(); @@ -490,6 +509,7 @@ export const initDataHelper = (rte) => { generateNStreams, generateNGraphs, generateNCachedScripts, + generateHugeNumberOfMembersForSetKey, getClientNodes, } } From e79d94999e0dab208fad82a5c7d1f5a8ffa35411 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Tue, 29 Nov 2022 07:35:39 +0400 Subject: [PATCH 028/201] #RI-3571-3564-3560 - recommendations --- .../constants/dbAnalysisRecommendations.json | 95 +++++++++++++++++++ .../Recommendations.spec.tsx | 45 +++++++++ .../components/recommendations-view/utils.tsx | 2 +- 3 files changed, 141 insertions(+), 1 deletion(-) diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index d466f41583..c2306013bd 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -58,5 +58,100 @@ } ], "badges": ["code_changes", "configuration_changes"] + }, + "avoidLogicalDatabases": { + "id": "avoidLogicalDatabases", + "title": "Avoid using logical databases", + "content": [ + { + "id": "1", + "type": "paragraph", + "value": "Redis supports multiple logical databases within an instance, though these logical databases are neither independent nor isolated in any other way and can freeze each other." + }, + { + "id": "2", + "type": "span", + "value": "Also, they are not supported by any clustering system (open source or Redis Enterprise clustering), and some modules do not support numbered databases as well. " + }, + { + "id": "3", + "type": "link", + "value": { + "href": "https://docs.redis.com/latest/ri/memory-optimizations/", + "name": "Read more" + } + } + ], + "badges": ["code_changes"] + }, + "combineSmallStringsToHashes": { + "id": "combineSmallStringsToHashes", + "title": "Combine small strings to hashes", + "content": [ + { + "id": "1", + "type": "paragraph", + "value": "Strings data type has an overhead of about 90 bytes on a 64-bit machine, so if there is no need for different expiration values for these keys, combine small strings into a larger hash to optimize the memory usage." + }, + { + "id": "2", + "type": "paragraph", + "value": "Also, ensure that the hash has less than hash-max-ziplist-entries elements and the size of each element is within hash-max-ziplist-values bytes." + }, + { + "id": "3", + "type": "spacer", + "value": "l" + }, + { + "id": "4", + "type": "span", + "value": "Though this approach should not be used if you need different expiration values for String keys. " + }, + { + "id": "5", + "type": "link", + "value": { + "href": "https://docs.redis.com/latest/ri/memory-optimizations/", + "name": "Read more" + } + } + ], + "badges": ["code_changes"] + }, + "increaseSetMaxIntsetEntries": { + "id": "increaseSetMaxIntsetEntries", + "title": "Increase the set-max-intset-entries", + "content": [ + { + "id": "1", + "type": "paragraph", + "value": "Several set values with IntSet encoding exceed the set-max-intset-entries. Change the configuration in reds.conf to efficiently use the IntSet encoding." + }, + { + "id": "2", + "type": "paragraph", + "value": "Though increasing this value will lead to an increase in latency of set operations and CPU utilization." + }, + { + "id": "3", + "type": "spacer", + "value": "l" + }, + { + "id": "4", + "type": "span", + "value": "Run `INFO COMMANDSTATS` before and after making this change to verify the latency numbers. " + }, + { + "id": "5", + "type": "link", + "value": { + "href": "https://docs.redis.com/latest/ri/memory-optimizations/", + "name": "Read more" + } + } + ], + "badges": ["configuration_changes"] } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index cc044861d2..9bcac116e3 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -142,6 +142,51 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) + it('should render code changes badge and configuration_changes in avoidLogicalDatabases recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'avoidLogicalDatabases' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() + }) + + it('should render code changes badge and configuration_changes in combineSmallStringsToHashes recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'combineSmallStringsToHashes' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() + }) + + it('should render code changes badge and configuration_changes in increaseSetMaxIntsetEntries recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'increaseSetMaxIntsetEntries' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() + }) + it('should collapse/expand', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx index 8ce97ac62e..4d6a08cbb7 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx @@ -37,7 +37,7 @@ export const renderBadges = (badges: string[]) => ( export const parseContent = ({ type, value }: { type: string, value: any }) => { switch (type) { case 'paragraph': - return {value} + return {value} case 'span': return {value} case 'link': From 7caa3d8e71e0f7a0985f6e28681bce70a19f7270 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Tue, 29 Nov 2022 13:52:41 +0400 Subject: [PATCH 029/201] #RI-3571-resolve conflicts --- redisinsight/api/test/helpers/data/redis.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/redisinsight/api/test/helpers/data/redis.ts b/redisinsight/api/test/helpers/data/redis.ts index 6e346e1c5a..520076a8f9 100644 --- a/redisinsight/api/test/helpers/data/redis.ts +++ b/redisinsight/api/test/helpers/data/redis.ts @@ -486,6 +486,7 @@ export const initDataHelper = (rte) => { pipeline.push(['eval', `return ${i}`, '0']) } await insertKeysBasedOnEnv(pipeline); + }; const setRedisearchConfig = async ( rule: string, From d81f1f90b4dbb326301c07825fdf459e89a08336 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 30 Nov 2022 13:02:18 +0400 Subject: [PATCH 030/201] resolve comments --- .../providers/recommendation.provider.spec.ts | 263 ++++++++++++++++++ .../providers/recommendation.provider.ts | 105 +++++-- .../recommendation/recommendation.service.ts | 19 +- .../POST-databases-id-analysis.test.ts | 65 ++++- redisinsight/api/test/helpers/constants.ts | 6 + .../constants/dbAnalysisRecommendations.json | 40 +++ 6 files changed, 458 insertions(+), 40 deletions(-) create mode 100644 redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts new file mode 100644 index 0000000000..6e823e43bb --- /dev/null +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -0,0 +1,263 @@ +import IORedis from 'ioredis'; +import { when } from 'jest-when'; +import { RecommendationProvider } from 'src/modules/recommendation/providers/recommendation.provider'; + +const nodeClient = Object.create(IORedis.prototype); +nodeClient.isCluster = false; +nodeClient.sendCommand = jest.fn(); + +const mockRedisMemoryInfoResponse_1: string = '# Memory\r\nnumber_of_cached_scripts:10\r\n'; +const mockRedisMemoryInfoResponse_2: string = '# Memory\r\nnumber_of_cached_scripts:11\r\n'; + +const mockRedisKeyspaceInfoResponse_1: string = '# Keyspace\r\ndb0:keys=2,expires=0,avg_ttl=0\r\n'; +const mockRedisKeyspaceInfoResponse_2: string = `# Keyspace\r\ndb0:keys=2,expires=0,avg_ttl=0\r\n + db1:keys=0,expires=0,avg_ttl=0\r\n`; +const mockRedisKeyspaceInfoResponse_3: string = `# Keyspace\r\ndb0:keys=2,expires=0,avg_ttl=0\r\n + db2:keys=20,expires=0,avg_ttl=0\r\n`; + +const mockRedisConfigResponse = ['name', '512']; + +const mockKeys = [ + { + name: Buffer.from('name'), type: 'string', length: 10, memory: 10, ttl: -1, + }, + { + name: Buffer.from('name'), type: 'hash', length: 10, memory: 10, ttl: -1, + }, + { + name: Buffer.from('name'), type: 'stream', length: 10, memory: 10, ttl: -1, + }, + { + name: Buffer.from('name'), type: 'set', length: 10, memory: 10, ttl: -1, + }, + { + name: Buffer.from('name'), type: 'zset', length: 10, memory: 10, ttl: -1, + }, + { + name: Buffer.from('name'), type: 'ReJSON-RL', length: 10, memory: 10, ttl: -1, + }, + { + name: Buffer.from('name'), type: 'graphdata', length: 10, memory: 10, ttl: -1, + }, + { + name: Buffer.from('name'), type: 'TSDB-TYPE', length: 10, memory: 10, ttl: -1, + }, +]; + +const mockBigHashKey = { + name: Buffer.from('name'), type: 'hash', length: 5001, memory: 10, ttl: -1, +}; + +const mockBigHashKey_2 = { + name: Buffer.from('name'), type: 'hash', length: 1001, memory: 10, ttl: -1, +}; + +const mockBigHashKey_3 = { + name: Buffer.from('name'), type: 'hash', length: 513, memory: 10, ttl: -1, +}; + +const mockBigStringKey = { + name: Buffer.from('name'), type: 'string', length: 10, memory: 201, ttl: -1, +}; + +const mockBigSet = { + name: Buffer.from('name'), type: 'set', length: 513, memory: 10, ttl: -1, +}; + +describe('RecommendationProvider', () => { + const service = new RecommendationProvider(); + + describe('determineLuaScriptRecommendation', () => { + it('should not return luaScript recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockResolvedValue(mockRedisMemoryInfoResponse_1); + + const luaScriptRecommendation = await service.determineLuaScriptRecommendation(nodeClient); + expect(luaScriptRecommendation).toEqual(null); + }); + + it('should return luaScript recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockResolvedValue(mockRedisMemoryInfoResponse_2); + + const luaScriptRecommendation = await service.determineLuaScriptRecommendation(nodeClient); + expect(luaScriptRecommendation).toEqual({ name: 'luaScript' }); + }); + + it('should not return luaScript recommendation when info command executed with error', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockRejectedValue('some error'); + + const luaScriptRecommendation = await service.determineLuaScriptRecommendation(nodeClient); + expect(luaScriptRecommendation).toEqual(null); + }); + }); + + describe('determineBigHashesRecommendation', () => { + it('should not return bigHashes recommendation', async () => { + const bigHashesRecommendation = await service.determineBigHashesRecommendation(mockKeys); + expect(bigHashesRecommendation).toEqual(null); + }); + it('should return bigHashes recommendation', async () => { + const bigHashesRecommendation = await service.determineBigHashesRecommendation( + [...mockKeys, mockBigHashKey], + ); + expect(bigHashesRecommendation).toEqual({ name: 'bigHashes' }); + }); + }); + + describe('determineBigTotalRecommendation', () => { + it('should not return useSmallerKeys recommendation', async () => { + const bigTotalRecommendation = await service.determineBigTotalRecommendation(1); + expect(bigTotalRecommendation).toEqual(null); + }); + it('should return useSmallerKeys recommendation', async () => { + const bigTotalRecommendation = await service.determineBigTotalRecommendation(1_000_001); + expect(bigTotalRecommendation).toEqual({ name: 'useSmallerKeys' }); + }); + }); + + describe('determineLogicalDatabasesRecommendation', () => { + it('should not return avoidLogicalDatabases recommendation when only one logical db', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockResolvedValue(mockRedisKeyspaceInfoResponse_1); + + const avoidLogicalDatabasesRecommendation = await service.determineLogicalDatabasesRecommendation(nodeClient); + expect(avoidLogicalDatabasesRecommendation).toEqual(null); + }); + + it('should not return avoidLogicalDatabases recommendation when only on logical db with keys', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockResolvedValue(mockRedisKeyspaceInfoResponse_2); + + const avoidLogicalDatabasesRecommendation = await service.determineLogicalDatabasesRecommendation(nodeClient); + expect(avoidLogicalDatabasesRecommendation).toEqual(null); + }); + + it('should return avoidLogicalDatabases recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockResolvedValue(mockRedisKeyspaceInfoResponse_3); + + const avoidLogicalDatabasesRecommendation = await service.determineLogicalDatabasesRecommendation(nodeClient); + expect(avoidLogicalDatabasesRecommendation).toEqual({ name: 'avoidLogicalDatabases' }); + }); + + it('should not return avoidLogicalDatabases recommendation when info command executed with error', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockRejectedValue('some error'); + + const avoidLogicalDatabasesRecommendation = await service.determineLogicalDatabasesRecommendation(nodeClient); + expect(avoidLogicalDatabasesRecommendation).toEqual(null); + }); + + it('should not return avoidLogicalDatabases recommendation when isCluster', async () => { + nodeClient.isCluster = true; + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockResolvedValue(mockRedisKeyspaceInfoResponse_3); + + const avoidLogicalDatabasesRecommendation = await service.determineLogicalDatabasesRecommendation(nodeClient); + expect(avoidLogicalDatabasesRecommendation).toEqual(null); + nodeClient.isCluster = false; + }); + }); + + describe('determineCombineSmallStringsToHashesRecommendation', () => { + it('should not return combineSmallStringsToHashes recommendation', async () => { + const smallStringRecommendation = await service.determineCombineSmallStringsToHashesRecommendation([ + mockBigStringKey, + ]); + expect(smallStringRecommendation).toEqual(null); + }); + it('should return combineSmallStringsToHashes recommendation', async () => { + const smallStringRecommendation = await service.determineCombineSmallStringsToHashesRecommendation(mockKeys); + expect(smallStringRecommendation).toEqual({ name: 'combineSmallStringsToHashes' }); + }); + }); + + describe('determineIncreaseSetMaxIntsetEntriesRecommendation', () => { + it('should not return increaseSetMaxIntsetEntries', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'config' })) + .mockResolvedValue(mockRedisConfigResponse); + + const increaseSetMaxIntsetEntriesRecommendation = await service + .determineIncreaseSetMaxIntsetEntriesRecommendation(nodeClient, mockKeys); + expect(increaseSetMaxIntsetEntriesRecommendation).toEqual(null); + }); + + it('should return increaseSetMaxIntsetEntries recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'config' })) + .mockResolvedValue(mockRedisConfigResponse); + + const increaseSetMaxIntsetEntriesRecommendation = await service + .determineIncreaseSetMaxIntsetEntriesRecommendation(nodeClient, [...mockKeys, mockBigSet]); + expect(increaseSetMaxIntsetEntriesRecommendation).toEqual({ name: 'increaseSetMaxIntsetEntries' }); + }); + + it('should not return increaseSetMaxIntsetEntries recommendation when config command executed with error', + async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'config' })) + .mockRejectedValue('some error'); + + const increaseSetMaxIntsetEntriesRecommendation = await service + .determineIncreaseSetMaxIntsetEntriesRecommendation(nodeClient, mockKeys); + expect(increaseSetMaxIntsetEntriesRecommendation).toEqual(null); + }); + }); + + describe('determineConvertHashtableToZiplistRecommendation', () => { + it('should not return convertHashtableToZiplist recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'config' })) + .mockResolvedValue(mockRedisConfigResponse); + + const convertHashtableToZiplistRecommendation = await service + .determineConvertHashtableToZiplistRecommendation(nodeClient, mockKeys); + expect(convertHashtableToZiplistRecommendation).toEqual(null); + }); + + it('should return convertHashtableToZiplist recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'config' })) + .mockResolvedValue(mockRedisConfigResponse); + + const convertHashtableToZiplistRecommendation = await service + .determineConvertHashtableToZiplistRecommendation(nodeClient, [...mockKeys, mockBigHashKey_3]); + expect(convertHashtableToZiplistRecommendation).toEqual({ name: 'convertHashtableToZiplist' }); + }); + + it('should not return convertHashtableToZiplist recommendation when config command executed with error', + async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'config' })) + .mockRejectedValue('some error'); + + const convertHashtableToZiplistRecommendation = await service + .determineConvertHashtableToZiplistRecommendation(nodeClient, mockKeys); + expect(convertHashtableToZiplistRecommendation).toEqual(null); + }); + }); + + describe('determineCompressHashFieldNamesRecommendation', () => { + it('should not return compressHashFieldNames recommendation', async () => { + const compressHashFieldNamesRecommendation = await service + .determineCompressHashFieldNamesRecommendation(mockKeys); + expect(compressHashFieldNamesRecommendation).toEqual(null); + }); + it('should return compressHashFieldNames recommendation', async () => { + const compressHashFieldNamesRecommendation = await service + .determineCompressHashFieldNamesRecommendation([mockBigHashKey_2]); + expect(compressHashFieldNamesRecommendation).toEqual({ name: 'compressHashFieldNames' }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 11b7769273..bc02df89d6 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -8,8 +8,9 @@ import { Key } from 'src/modules/database-analysis/models'; const minNumberOfCachedScripts = 10; const maxHashLength = 5000; -const maxStringLength = 200; +const maxStringMemory = 200; const maxDatabaseTotal = 1_000_000; +const maxCompressHashLength = 1000; @Injectable() export class RecommendationProvider { @@ -20,16 +21,20 @@ export class RecommendationProvider { async determineLuaScriptRecommendation( redisClient: Redis, ): Promise { - const info = convertRedisInfoReplyToObject( - await redisClient.sendCommand( - new Command('info', [], { replyEncoding: 'utf8' }), - ) as string, - ); - const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts'); + try { + const info = convertRedisInfoReplyToObject( + await redisClient.sendCommand( + new Command('info', [], { replyEncoding: 'utf8' }), + ) as string, + ); + const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts'); - return parseInt(nodesNumbersOfCachedScripts, 10) > minNumberOfCachedScripts - ? { name: 'luaScript' } - : null; + return parseInt(nodesNumbersOfCachedScripts, 10) > minNumberOfCachedScripts + ? { name: 'luaScript' } + : null; + } catch (err) { + return null; + } } /** @@ -63,15 +68,21 @@ export class RecommendationProvider { if (redisClient.isCluster) { return null; } - const info = convertRedisInfoReplyToObject( - await redisClient.info(), - ); - const keyspace = get(info, 'keyspace', {}); - const databasesWithKeys = Object.values(keyspace).filter((db) => { - const { keys } = convertBulkStringsToObject(db as string, ',', '='); - return keys > 0; - }); - return databasesWithKeys.length > 1 ? { name: 'avoidLogicalDatabases' } : null; + try { + const info = convertRedisInfoReplyToObject( + await redisClient.sendCommand( + new Command('info', ['keyspace'], { replyEncoding: 'utf8' }), + ) as string, + ); + const keyspace = get(info, 'keyspace', {}); + const databasesWithKeys = Object.values(keyspace).filter((db) => { + const { keys } = convertBulkStringsToObject(db as string, ',', '='); + return keys > 0; + }); + return databasesWithKeys.length > 1 ? { name: 'avoidLogicalDatabases' } : null; + } catch (err) { + return null; + } } /** @@ -81,7 +92,7 @@ export class RecommendationProvider { async determineCombineSmallStringsToHashesRecommendation( keys: Key[], ): Promise { - const smallString = keys.some((key) => key.type === RedisDataType.String && key.memory < maxStringLength); + const smallString = keys.some((key) => key.type === RedisDataType.String && key.memory < maxStringMemory); return smallString ? { name: 'combineSmallStringsToHashes' } : null; } @@ -94,17 +105,55 @@ export class RecommendationProvider { redisClient: Redis | Cluster, keys: Key[], ): Promise { - const [, setMaxIntsetEntries] = await redisClient.sendCommand( - new Command('config', ['get', 'set-max-intset-entries'], { - replyEncoding: 'utf8', - }), - ) as string[]; + try { + const [, setMaxIntsetEntries] = await redisClient.sendCommand( + new Command('config', ['get', 'set-max-intset-entries'], { + replyEncoding: 'utf8', + }), + ) as string[]; - if (!setMaxIntsetEntries) { + if (!setMaxIntsetEntries) { + return null; + } + const setMaxIntsetEntriesNumber = parseInt(setMaxIntsetEntries, 10); + const bigSet = keys.some((key) => key.type === RedisDataType.Set && key.length > setMaxIntsetEntriesNumber); + return bigSet ? { name: 'increaseSetMaxIntsetEntries' } : null; + } catch (err) { return null; } + } + /** + * Check convert hashtable to ziplist recommendation + * @param keys + * @param redisClient + */ - const bigSet = keys.some((key) => key.type === RedisDataType.Set && key.length > parseInt(setMaxIntsetEntries, 10)); - return bigSet ? { name: 'increaseSetMaxIntsetEntries' } : null; + async determineConvertHashtableToZiplistRecommendation( + redisClient: Redis | Cluster, + keys: Key[], + ): Promise { + try { + const [, hashMaxZiplistEntries] = await redisClient.sendCommand( + new Command('config', ['get', 'hash-max-ziplist-entries'], { + replyEncoding: 'utf8', + }), + ) as string[]; + const hashMaxZiplistEntriesNumber = parseInt(hashMaxZiplistEntries, 10); + const bigHash = keys.some((key) => key.type === RedisDataType.Hash && key.length > hashMaxZiplistEntriesNumber); + return bigHash ? { name: 'convertHashtableToZiplist' } : null; + } catch (err) { + return null; + } + } + + /** + * Check compress hash field names recommendation + * @param keys + */ + async determineCompressHashFieldNamesRecommendation( + keys: Key[], + ): Promise { + const bigHash = keys.some((key) => key.type === RedisDataType.Hash && key.length > maxCompressHashLength); + return bigHash ? { name: 'compressHashFieldNames' } : null; } } diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index ece8b925b8..3dab87a31a 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -33,13 +33,16 @@ export class RecommendationService { total, } = dto; - return ([ - await this.recommendationProvider.determineLuaScriptRecommendation(client), - await this.recommendationProvider.determineBigHashesRecommendation(keys), - await this.recommendationProvider.determineBigTotalRecommendation(total), - await this.recommendationProvider.determineLogicalDatabasesRecommendation(client), - await this.recommendationProvider.determineCombineSmallStringsToHashesRecommendation(keys), - await this.recommendationProvider.determineIncreaseSetMaxIntsetEntriesRecommendation(client, keys), - ]); + return ( + Promise.all([ + await this.recommendationProvider.determineLuaScriptRecommendation(client), + await this.recommendationProvider.determineBigHashesRecommendation(keys), + await this.recommendationProvider.determineBigTotalRecommendation(total), + await this.recommendationProvider.determineLogicalDatabasesRecommendation(client), + await this.recommendationProvider.determineCombineSmallStringsToHashesRecommendation(keys), + await this.recommendationProvider.determineIncreaseSetMaxIntsetEntriesRecommendation(client, keys), + await this.recommendationProvider.determineConvertHashtableToZiplistRecommendation(client, keys), + await this.recommendationProvider.determineCompressHashFieldNamesRecommendation(keys), + ])); } } diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index a038ec076c..712234f749 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -155,7 +155,7 @@ describe('POST /databases/:instanceId/analysis', () => { statusCode: 201, responseSchema, before: async () => { - const KEYS_NUMBER = 1_000_001 + const KEYS_NUMBER = 1_000_001; await rte.data.generateNKeys(KEYS_NUMBER, false); }, checkFn: async ({ body }) => { @@ -167,7 +167,6 @@ describe('POST /databases/:instanceId/analysis', () => { expect(body.topKeysMemory.length).to.gt(0); expect(body.recommendations).to.deep.eq([ constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - // generateNKeys generated small strings constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, ]); }, @@ -183,7 +182,7 @@ describe('POST /databases/:instanceId/analysis', () => { statusCode: 201, responseSchema, before: async () => { - const NUMBERS_OF_HASH_FIELDS = 5001 + const NUMBERS_OF_HASH_FIELDS = 5001; await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); }, checkFn: async ({ body }) => { @@ -193,7 +192,11 @@ describe('POST /databases/:instanceId/analysis', () => { expect(body.topMemoryNsp.length).to.gt(0); expect(body.topKeysLength.length).to.gt(0); expect(body.topKeysMemory.length).to.gt(0); - expect(body.recommendations).to.deep.eq([constants.TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION]); + expect(body.recommendations).to.deep.eq([ + constants.TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION, + constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, + ]); }, after: async () => { expect(await repository.count()).to.eq(5); @@ -234,6 +237,60 @@ describe('POST /databases/:instanceId/analysis', () => { expect(await repository.count()).to.eq(5); } }, + { + name: 'Should create new database analysis with convertHashtableToZiplist recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_HASH_FIELDS = 513; + await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); + }, + checkFn: async ({ body }) => { + expect(body.totalKeys.total).to.gt(0); + expect(body.totalMemory.total).to.gt(0); + expect(body.topKeysNsp.length).to.gt(0); + expect(body.topMemoryNsp.length).to.gt(0); + expect(body.topKeysLength.length).to.gt(0); + expect(body.topKeysMemory.length).to.gt(0); + expect(body.recommendations).to.deep.eq([ + constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with compressHashFieldNames recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_HASH_FIELDS = 1001; + await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); + }, + checkFn: async ({ body }) => { + expect(body.totalKeys.total).to.gt(0); + expect(body.totalMemory.total).to.gt(0); + expect(body.topKeysNsp.length).to.gt(0); + expect(body.topMemoryNsp.length).to.gt(0); + expect(body.topKeysLength.length).to.gt(0); + expect(body.topKeysMemory.length).to.gt(0); + expect(body.recommendations).to.deep.eq([ + constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, + + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, { name: 'Should create new database analysis with luaScript recommendation', data: { diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index a27b142223..1673bbc6b9 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -466,6 +466,12 @@ export const constants = { TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION: { name: 'combineSmallStringsToHashes', }, + TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION: { + name: 'convertHashtableToZiplist', + }, + TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION: { + name: 'compressHashFieldNames', + }, // etc... } diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index c2306013bd..64799dc079 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -153,5 +153,45 @@ } ], "badges": ["configuration_changes"] + }, + "convertHashtableToZiplist": { + "id": "convertHashtableToZiplist", + "title": "Increase zset-max-ziplist-entries", + "content": [ + { + "id": "1", + "type": "span", + "value": "If any value for a key exceeds zset-max-ziplist-entries, it is stored automatically as a Hashtable instead of a Ziplist, which consumes almost double the memory. So to save memory, increase the configurations and convert your hashtables to ziplist. The trade-off can be an increase in latency and possibly an increase in CPU utilization. " + }, + { + "id": "2", + "type": "link", + "value": { + "href": "https://docs.redis.com/latest/ri/memory-optimizations/", + "name": "Read more" + } + } + ], + "badges": ["configuration_changes"] +}, + "compressHashFieldNames": { + "id": "compressHashFieldNames", + "title": "Compress Hash field names", + "content": [ + { + "id": "1", + "type": "span", + "value": "Hash field name also consumes memory, so use smaller or shortened field names to reduce memory usage. " + }, + { + "id": "2", + "type": "link", + "value": { + "href": "https://docs.redis.com/latest/ri/memory-optimizations/", + "name": "Read more" + } + } + ], + "badges": ["configuration_changes"] } } From 7113bb342aa4006d71136bf83c4259ef088b5195 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 30 Nov 2022 13:07:15 +0400 Subject: [PATCH 031/201] resolve comments --- .../modules/recommendation/providers/recommendation.provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index bc02df89d6..a1908c9d39 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -24,7 +24,7 @@ export class RecommendationProvider { try { const info = convertRedisInfoReplyToObject( await redisClient.sendCommand( - new Command('info', [], { replyEncoding: 'utf8' }), + new Command('info', ['memory'], { replyEncoding: 'utf8' }), ) as string, ); const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts'); From 92a6fffbec19a6b094e33b551fac19999e099fbf Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 30 Nov 2022 13:51:35 +0400 Subject: [PATCH 032/201] add badges test --- .../Recommendations.spec.tsx | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 9bcac116e3..7cde5742db 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -172,7 +172,7 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() }) - it('should render code changes badge and configuration_changes in increaseSetMaxIntsetEntries recommendation', () => { + it('should render configuration_changes badge and configuration_changes in increaseSetMaxIntsetEntries recommendation', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, data: { @@ -182,11 +182,42 @@ describe('Recommendations', () => { render() - expect(screen.queryByTestId('code_changes')).toBeInTheDocument() + expect(screen.queryByTestId('code_changes')).not.toBeInTheDocument() expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() - expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() + }) + + it('should render code changes badge and configuration_changes in convertHashtableToZiplist recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'convertHashtableToZiplist' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).not.toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() + }) + + it('should render configuration_changes badge and configuration_changes in compressHashFieldNames recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'compressHashFieldNames' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).not.toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) + it('should collapse/expand', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, From 9d5815f82d7cfc3f4183bb5e6664b8174581a1b5 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 30 Nov 2022 11:04:19 +0100 Subject: [PATCH 033/201] add initial tests --- .../memory-efficiency/recommendations.e2e.ts | 84 ++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts index 9cf475a009..7ffdc0820a 100644 --- a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts +++ b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts @@ -1,6 +1,6 @@ -import { MyRedisDatabasePage, MemoryEfficiencyPage, BrowserPage, CliPage } from '../../../pageObjects'; +import { MyRedisDatabasePage, MemoryEfficiencyPage, BrowserPage, CliPage, AddRedisDatabasePage } from '../../../pageObjects'; import { rte } from '../../../helpers/constants'; -import { acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; +import { acceptLicenseTermsAndAddDatabaseApi, deleteCustomDatabase } from '../../../helpers/database'; import { commonUrl, ossStandaloneBigConfig, ossStandaloneConfig } from '../../../helpers/conf'; import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; import { CliActions } from '../../../common-actions/cli-actions'; @@ -13,9 +13,13 @@ const cliActions = new CliActions(); const common = new Common(); const browserPage = new BrowserPage(); const cliPage = new CliPage(); +const addRedisDatabasePage = new AddRedisDatabasePage(); const externalPageLink = 'https://docs.redis.com/latest/ri/memory-optimizations/'; const keyName = `hugeHashKey-${common.generateWord(10)}`; +const stringKeyName = `smallStringKey-${common.generateWord(10)}`; +const stringBigKeyName = `bigStringKey-${common.generateWord(10)}`; +const index = '1'; fixture `Memory Efficiency Recommendations` .meta({ type: 'critical_path', rte: rte.standalone }) @@ -99,3 +103,79 @@ test await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Big hashes recommendation not have Code Changes label'); await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Big hashes recommendation not have Configuration Changes label'); }); + test + .before(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await browserPage.addStringKey(stringKeyName, '2147476121', 'field'); + // Go to Analysis Tools page + await t.click(myRedisDatabasePage.analysisPageButton); + await t.click(memoryEfficiencyPage.newReportBtn); + // Go to Recommendations tab + await t.click(memoryEfficiencyPage.recommendationsTab); + }) + .after(async t => { + // Clear and delete database + await t.click(myRedisDatabasePage.browserButton); + await browserPage.deleteKeysByNames([stringKeyName, stringBigKeyName]); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('Combine small strings to hashes recommendation', async t => { + const command = `SET ${stringBigKeyName} "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed accumsan lectus sed diam suscipit, eu ullamcorper ligula pulvinar."`; + + // Verify that user can see Combine small strings to hashes recommendation when there are strings that are less than 200 bytes + await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).ok('Combine small strings to hashes recommendation not displayed'); + await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Combine small strings to hashes recommendation not have Code Changes label'); + + // Add String key with more than 200 bytes + await cliPage.sendCommandInCli(command); + await t.click(memoryEfficiencyPage.newReportBtn); + // Verify that user can not see recommendation when there is at least one string that are more than 200 bytes + await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).notOk('Combine small strings to hashes recommendation not displayed'); + }); +test + .before(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await browserPage.addStringKey(stringKeyName, '2147476121', 'field'); + // Go to Analysis Tools page + await t.click(myRedisDatabasePage.analysisPageButton); + await t.click(memoryEfficiencyPage.newReportBtn); + // Go to Recommendations tab + await t.click(memoryEfficiencyPage.recommendationsTab); + }) + .after(async t => { + // Clear and delete database + await t.click(myRedisDatabasePage.browserButton); + await browserPage.deleteKeyByName(stringKeyName); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('Increase the set-max-intset-entries recommendation', async t => { + // Verify that user can see Increase the set-max-intset-entries recommendation when Found sets with length > set-max-intset-entries + await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).ok('Combine small strings to hashes recommendation not displayed'); + await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Combine small strings to hashes recommendation not have Configuration Changes label'); + }); +test + .before(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await browserPage.addStringKey(stringKeyName, '2147476121', 'field'); + await t.click(myRedisDatabasePage.myRedisDBButton); + await addRedisDatabasePage.addLogicalRedisDatabase(ossStandaloneConfig, index); + await myRedisDatabasePage.clickOnDBByName(`${ossStandaloneConfig.databaseName} [${index}]`); + await browserPage.addHashKey(keyName, '2147476121', 'field', 'value'); + // Go to Analysis Tools page + await t.click(myRedisDatabasePage.analysisPageButton); + await t.click(memoryEfficiencyPage.newReportBtn); + // Go to Recommendations tab + await t.click(memoryEfficiencyPage.recommendationsTab); + }) + .after(async t => { + // Clear and delete database + await t.click(myRedisDatabasePage.browserButton); + await browserPage.deleteKeyByName(keyName); + await deleteCustomDatabase(`${ossStandaloneConfig.databaseName} [${index}]`); + await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); + await browserPage.deleteKeyByName(stringKeyName); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('Avoid using logical databases', async t => { + + // Verify that user can see Avoid using logical databases recommendation when the database supports logical databases and there are keys in more than 1 logical database + await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).ok('Avoid using logical databases recommendation not displayed'); + await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Avoid using logical databases recommendation not have Code Changes label'); + }); From 52a66af21bcacb8dd6f5eb0d036699d1cc18e3d0 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 30 Nov 2022 15:00:05 +0100 Subject: [PATCH 034/201] add recommendations tests --- tests/e2e/helpers/keys.ts | 34 ++++++++ .../e2e/pageObjects/memory-efficiency-page.ts | 3 + .../memory-efficiency/recommendations.e2e.ts | 80 +++++++++++++++---- 3 files changed, 103 insertions(+), 14 deletions(-) diff --git a/tests/e2e/helpers/keys.ts b/tests/e2e/helpers/keys.ts index d277db769e..de7c465d75 100644 --- a/tests/e2e/helpers/keys.ts +++ b/tests/e2e/helpers/keys.ts @@ -6,6 +6,7 @@ import { BrowserPage, CliPage } from '../pageObjects'; import { KeyData, AddKeyArguments } from '../pageObjects/browser-page'; import { KeyTypesTexts } from './constants'; import { Common } from './common'; +import { random } from 'lodash'; const common = new Common(); const cliPage = new CliPage(); @@ -193,6 +194,39 @@ export async function populateSetWithMembers(host: string, port: string, keyArgu }); } +/** + * Populate Zset key with members + * @param host The host of database + * @param port The port of database + * @param keyArguments The arguments of key and its members + */ + export async function populateZSetWithMembers(host: string, port: string, keyArguments: AddKeyArguments): Promise { + const dbConf = { host, port: Number(port) }; + let minScoreValue: -10; + let maxScoreValue: 10; + const client = createClient(dbConf); + const members: string[] = []; + + await client.on('error', async function(error: string) { + throw new Error(error); + }); + await client.on('connect', async function() { + if (keyArguments.membersCount != undefined) { + for (let i = 0; i < keyArguments.membersCount; i++) { + const memberName = `${keyArguments.memberStartWith}${common.generateWord(10)}`; + const scoreValue = random(minScoreValue, maxScoreValue).toString(2); + members.push(scoreValue, memberName); + } + } + await client.zadd(keyArguments.keyName, members, async(error: string) => { + if (error) { + throw error; + } + }); + await client.quit(); + }); +} + /** * Delete all keys from database * @param host The host of database diff --git a/tests/e2e/pageObjects/memory-efficiency-page.ts b/tests/e2e/pageObjects/memory-efficiency-page.ts index 309384b88d..38cd0b6a5c 100644 --- a/tests/e2e/pageObjects/memory-efficiency-page.ts +++ b/tests/e2e/pageObjects/memory-efficiency-page.ts @@ -55,4 +55,7 @@ export class MemoryEfficiencyPage { luaScriptTextContainer = Selector('#luaScript'); useSmallKeysAccordion = Selector('[data-testid=useSmallerKeys-accordion]'); bigHashesAccordion = Selector('[data-testid=bigHashes-accordion]'); + combineStringsAccordion = Selector('[data-testid=combineSmallStringsToHashes-accordion]'); + increaseSetAccordion = Selector('[data-testid=increaseSetMaxIntsetEntries-accordion]'); + avoidLogicalDbAccordion = Selector('[data-testid=avoidLogicalDatabases-accordion]'); } diff --git a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts index 7ffdc0820a..fed05fe07e 100644 --- a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts +++ b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts @@ -5,7 +5,7 @@ import { commonUrl, ossStandaloneBigConfig, ossStandaloneConfig } from '../../.. import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; import { CliActions } from '../../../common-actions/cli-actions'; import { Common } from '../../../helpers/common'; -import { populateHashWithFields } from '../../../helpers/keys'; +import { populateHashWithFields, populateSetWithMembers, populateZSetWithMembers } from '../../../helpers/keys'; const memoryEfficiencyPage = new MemoryEfficiencyPage(); const myRedisDatabasePage = new MyRedisDatabasePage(); @@ -16,10 +16,15 @@ const cliPage = new CliPage(); const addRedisDatabasePage = new AddRedisDatabasePage(); const externalPageLink = 'https://docs.redis.com/latest/ri/memory-optimizations/'; -const keyName = `hugeHashKey-${common.generateWord(10)}`; -const stringKeyName = `smallStringKey-${common.generateWord(10)}`; -const stringBigKeyName = `bigStringKey-${common.generateWord(10)}`; +const keyName = `hugeHashKey-${common.generateWord(5)}`; +const stringKeyName = `smallStringKey-${common.generateWord(5)}`; +const setKeyName = `hugeSetKey-${common.generateWord(4)}`; +const stringBigKeyName = `bigStringKey-${common.generateWord(5)}`; const index = '1'; +// const dbParameters = { host: ossStandaloneConfig.host, port: ossStandaloneConfig.port }; +const dbParameters = { host: 'localhost', port: '8100' }; +const setKeyParameters = { membersCount: 512, keyName: setKeyName, memberStartWith: 'setMember' }; +const zsetKeyParameters = { membersCount: 128, keyName: setKeyName, memberStartWith: 'zsetMember' }; fixture `Memory Efficiency Recommendations` .meta({ type: 'critical_path', rte: rte.standalone }) @@ -103,7 +108,10 @@ test await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Big hashes recommendation not have Code Changes label'); await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Big hashes recommendation not have Configuration Changes label'); }); - test + + + +test .before(async t => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); await browserPage.addStringKey(stringKeyName, '2147476121', 'field'); @@ -116,25 +124,30 @@ test .after(async t => { // Clear and delete database await t.click(myRedisDatabasePage.browserButton); - await browserPage.deleteKeysByNames([stringKeyName, stringBigKeyName]); + await browserPage.deleteKeyByName(stringBigKeyName); await deleteStandaloneDatabaseApi(ossStandaloneConfig); })('Combine small strings to hashes recommendation', async t => { const command = `SET ${stringBigKeyName} "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed accumsan lectus sed diam suscipit, eu ullamcorper ligula pulvinar."`; // Verify that user can see Combine small strings to hashes recommendation when there are strings that are less than 200 bytes - await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).ok('Combine small strings to hashes recommendation not displayed'); + await t.expect(memoryEfficiencyPage.combineStringsAccordion.exists).ok('Combine small strings to hashes recommendation not displayed for small string'); await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Combine small strings to hashes recommendation not have Code Changes label'); // Add String key with more than 200 bytes await cliPage.sendCommandInCli(command); + // Delete small String key + await cliPage.sendCommandInCli(`DEL ${stringKeyName}`); + await t.click(memoryEfficiencyPage.newReportBtn); // Verify that user can not see recommendation when there is at least one string that are more than 200 bytes - await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).notOk('Combine small strings to hashes recommendation not displayed'); + await t.expect(memoryEfficiencyPage.combineStringsAccordion.exists).notOk('Combine small strings to hashes recommendation is displayed for huge string'); }); test .before(async t => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); - await browserPage.addStringKey(stringKeyName, '2147476121', 'field'); + await browserPage.addSetKey(setKeyName, '2147476121', 'member'); + // Add 512 members to the set key + await populateSetWithMembers(dbParameters.host, dbParameters.port, setKeyParameters); // Go to Analysis Tools page await t.click(myRedisDatabasePage.analysisPageButton); await t.click(memoryEfficiencyPage.newReportBtn); @@ -144,12 +157,21 @@ test .after(async t => { // Clear and delete database await t.click(myRedisDatabasePage.browserButton); - await browserPage.deleteKeyByName(stringKeyName); + await browserPage.deleteKeyByName(setKeyName); + await cliPage.sendCommandInCli('config set set-max-intset-entries 512'); await deleteStandaloneDatabaseApi(ossStandaloneConfig); })('Increase the set-max-intset-entries recommendation', async t => { + const command = 'config set set-max-intset-entries 1024'; + // Verify that user can see Increase the set-max-intset-entries recommendation when Found sets with length > set-max-intset-entries - await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).ok('Combine small strings to hashes recommendation not displayed'); - await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Combine small strings to hashes recommendation not have Configuration Changes label'); + await t.expect(memoryEfficiencyPage.increaseSetAccordion.exists).ok('Increase the set-max-intset-entries recommendation not displayed'); + await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Increase the set-max-intset-entries recommendation not have Configuration Changes label'); + + // Change config max entries to 1024 + await cliPage.sendCommandInCli(command); + await t.click(memoryEfficiencyPage.newReportBtn); + // Verify that user can not see Increase the set-max-intset-entries recommendation when Found sets with length < set-max-intset-entries + await t.expect(memoryEfficiencyPage.increaseSetAccordion.exists).notOk('Increase the set-max-intset-entries recommendation is displayed'); }); test .before(async t => { @@ -174,8 +196,38 @@ test await browserPage.deleteKeyByName(stringKeyName); await deleteStandaloneDatabaseApi(ossStandaloneConfig); })('Avoid using logical databases', async t => { - // Verify that user can see Avoid using logical databases recommendation when the database supports logical databases and there are keys in more than 1 logical database - await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).ok('Avoid using logical databases recommendation not displayed'); + await t.expect(memoryEfficiencyPage.avoidLogicalDbAccordion.exists).ok('Avoid using logical databases recommendation not displayed'); await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Avoid using logical databases recommendation not have Code Changes label'); }); +test.only + .before(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await browserPage.addZSetKey(setKeyName, '2147476121', 'member'); + // Add 128 members to the zset key + await populateZSetWithMembers(dbParameters.host, dbParameters.port, zsetKeyParameters); + // Go to Analysis Tools page + await t.click(myRedisDatabasePage.analysisPageButton); + await t.click(memoryEfficiencyPage.newReportBtn); + // Go to Recommendations tab + await t.click(memoryEfficiencyPage.recommendationsTab); + }) + .after(async t => { + // Clear and delete database + await t.click(myRedisDatabasePage.browserButton); + await browserPage.deleteKeyByName(setKeyName); + await cliPage.sendCommandInCli('config set zset-max-ziplist-entries 128'); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('Convert hashtable to ziplist for hashes recommendation', async t => { + const command = 'config set zset-max-ziplist-entries 256'; + + // Verify that user can see Convert hashtable to ziplist for hashes recommendation when the number of hash entries exceeds hash-max-ziplist-entries + await t.expect(memoryEfficiencyPage.increaseSetAccordion.exists).ok('Increase the set-max-intset-entries recommendation not displayed'); + await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Increase the set-max-intset-entries recommendation not have Configuration Changes label'); + + // Change config max entries to 256 + await cliPage.sendCommandInCli(command); + await t.click(memoryEfficiencyPage.newReportBtn); + // Verify that user can not see Convert hashtable to ziplist for hashes recommendation when the number of hash entries not exceeds hash-max-ziplist-entries + await t.expect(memoryEfficiencyPage.increaseSetAccordion.exists).notOk('Increase the set-max-intset-entries recommendation is displayed'); + }); \ No newline at end of file From 23d4797fef7ec5df075190b0b8edc2aaed30db69 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 30 Nov 2022 18:20:16 +0400 Subject: [PATCH 035/201] #RI-3562- update recommendation text --- redisinsight/ui/src/constants/dbAnalysisRecommendations.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 64799dc079..bd05082176 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -156,12 +156,12 @@ }, "convertHashtableToZiplist": { "id": "convertHashtableToZiplist", - "title": "Increase zset-max-ziplist-entries", + "title": "Increase hash-max-ziplist-entries", "content": [ { "id": "1", "type": "span", - "value": "If any value for a key exceeds zset-max-ziplist-entries, it is stored automatically as a Hashtable instead of a Ziplist, which consumes almost double the memory. So to save memory, increase the configurations and convert your hashtables to ziplist. The trade-off can be an increase in latency and possibly an increase in CPU utilization. " + "value": "If any value for a key exceeds hash-max-ziplist-entries, it is stored automatically as a Hashtable instead of a Ziplist, which consumes almost double the memory. So to save memory, increase the configurations and convert your hashtables to ziplist. The trade-off can be an increase in latency and possibly an increase in CPU utilization. " }, { "id": "2", From fc7cb7777742cb51d45ab894a238b74a29218b32 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 30 Nov 2022 17:17:35 +0100 Subject: [PATCH 036/201] last tests --- .../e2e/pageObjects/memory-efficiency-page.ts | 2 + .../memory-efficiency/recommendations.e2e.ts | 273 +++++++++--------- 2 files changed, 134 insertions(+), 141 deletions(-) diff --git a/tests/e2e/pageObjects/memory-efficiency-page.ts b/tests/e2e/pageObjects/memory-efficiency-page.ts index 38cd0b6a5c..9b544d40ff 100644 --- a/tests/e2e/pageObjects/memory-efficiency-page.ts +++ b/tests/e2e/pageObjects/memory-efficiency-page.ts @@ -58,4 +58,6 @@ export class MemoryEfficiencyPage { combineStringsAccordion = Selector('[data-testid=combineSmallStringsToHashes-accordion]'); increaseSetAccordion = Selector('[data-testid=increaseSetMaxIntsetEntries-accordion]'); avoidLogicalDbAccordion = Selector('[data-testid=avoidLogicalDatabases-accordion]'); + convertHashToZipAccordion = Selector('[data-testid=convertHashtableToZiplist-accordion]'); + compressHashAccordion = Selector('[data-testid=compressHashFieldNames-accordion]'); } diff --git a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts index fed05fe07e..9377ff4f4d 100644 --- a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts +++ b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts @@ -5,7 +5,7 @@ import { commonUrl, ossStandaloneBigConfig, ossStandaloneConfig } from '../../.. import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; import { CliActions } from '../../../common-actions/cli-actions'; import { Common } from '../../../helpers/common'; -import { populateHashWithFields, populateSetWithMembers, populateZSetWithMembers } from '../../../helpers/keys'; +import { populateHashWithFields, populateSetWithMembers } from '../../../helpers/keys'; const memoryEfficiencyPage = new MemoryEfficiencyPage(); const myRedisDatabasePage = new MyRedisDatabasePage(); @@ -16,153 +16,138 @@ const cliPage = new CliPage(); const addRedisDatabasePage = new AddRedisDatabasePage(); const externalPageLink = 'https://docs.redis.com/latest/ri/memory-optimizations/'; -const keyName = `hugeHashKey-${common.generateWord(5)}`; +let keyName = `recomKey-${common.generateWord(10)}`; const stringKeyName = `smallStringKey-${common.generateWord(5)}`; -const setKeyName = `hugeSetKey-${common.generateWord(4)}`; -const stringBigKeyName = `bigStringKey-${common.generateWord(5)}`; const index = '1'; -// const dbParameters = { host: ossStandaloneConfig.host, port: ossStandaloneConfig.port }; -const dbParameters = { host: 'localhost', port: '8100' }; -const setKeyParameters = { membersCount: 512, keyName: setKeyName, memberStartWith: 'setMember' }; -const zsetKeyParameters = { membersCount: 128, keyName: setKeyName, memberStartWith: 'zsetMember' }; +const dbParameters = { host: ossStandaloneConfig.host, port: ossStandaloneConfig.port }; fixture `Memory Efficiency Recommendations` .meta({ type: 'critical_path', rte: rte.standalone }) .page(commonUrl) .beforeEach(async t => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneBigConfig, ossStandaloneBigConfig.databaseName); + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); // Go to Analysis Tools page await t.click(myRedisDatabasePage.analysisPageButton); - await t.click(memoryEfficiencyPage.newReportBtn); }) - .afterEach(async() => { - await cliPage.sendCommandInCli('SCRIPT FLUSH'); - await deleteStandaloneDatabaseApi(ossStandaloneBigConfig); + .afterEach(async t => { + // Clear and delete database + await t.click(myRedisDatabasePage.browserButton); + await browserPage.deleteKeyByName(keyName); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); }); -test('Avoid dynamic Lua script recommendation', async t => { - // Go to Recommendations tab - await t.click(memoryEfficiencyPage.recommendationsTab); - // Add cached scripts and generate new report - await cliActions.addCachedScripts(10); - await t.click(memoryEfficiencyPage.newReportBtn); - // No recommendation with 10 cached scripts - await t.expect(memoryEfficiencyPage.luaScriptAccordion.exists).notOk('Avoid dynamic lua script recommendation displayed with 10 cached scripts'); - // Add the last cached script to see the recommendation - await cliActions.addCachedScripts(1); - await t.click(memoryEfficiencyPage.newReportBtn); - // Verify that user can see Avoid dynamic Lua script recommendation when number_of_cached_scripts> 10 - await t.expect(memoryEfficiencyPage.luaScriptAccordion.exists).ok('Avoid dynamic lua script recommendation not displayed'); - - // Verify that user can see Use smaller keys recommendation when database has 1M+ keys - await t.expect(memoryEfficiencyPage.useSmallKeysAccordion.exists).ok('Use smaller keys recommendation not displayed'); - - // Verify that user can expand/collapse recommendation - const expandedTextConaiterSize = await memoryEfficiencyPage.luaScriptTextContainer.offsetHeight; - await t.click(memoryEfficiencyPage.luaScriptButton); - await t.expect(memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).lt(expandedTextConaiterSize, 'Recommendation not collapsed'); - await t.click(memoryEfficiencyPage.luaScriptButton); - await t.expect(memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).eql(expandedTextConaiterSize, 'Recommendation not expanded'); - - // Verify that user can navigate by link to see the recommendation - await t.click(memoryEfficiencyPage.readMoreLink); - await common.checkURL(externalPageLink); - // Close the window with external link to switch to the application window - await t.closeWindow(); -}); test .before(async t => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); - await browserPage.addHashKey(keyName, '2147476121', 'field', 'value'); + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneBigConfig, ossStandaloneBigConfig.databaseName); // Go to Analysis Tools page await t.click(myRedisDatabasePage.analysisPageButton); await t.click(memoryEfficiencyPage.newReportBtn); }) - .after(async t => { - // Clear and delete database - await t.click(myRedisDatabasePage.browserButton); - await browserPage.deleteKeyByName(keyName); - await deleteStandaloneDatabaseApi(ossStandaloneConfig); - })('Shard big hashes to small hashes recommendation', async t => { - const noRecommendationsMessage = 'No Recommendations at the moment.'; - const dbParameters = { host: ossStandaloneConfig.host, port: ossStandaloneConfig.port }; - const keyToAddParameters = { fieldsCount: 4999, keyName, fieldStartWith: 'hashField', fieldValueStartWith: 'hashValue' }; - const keyToAddParameters2 = { fieldsCount: 1, keyName, fieldStartWith: 'hashFieldLast', fieldValueStartWith: 'hashValueLast' }; - + .after(async() => { + await cliPage.sendCommandInCli('SCRIPT FLUSH'); + await deleteStandaloneDatabaseApi(ossStandaloneBigConfig); + })('Avoid dynamic Lua script recommendation', async t => { // Go to Recommendations tab await t.click(memoryEfficiencyPage.recommendationsTab); - // No recommendations message - await t.expect(memoryEfficiencyPage.noRecommendationsMessage.textContent).eql(noRecommendationsMessage, 'No recommendations message not displayed'); - - // Add 5000 fields to the hash key - await populateHashWithFields(dbParameters.host, dbParameters.port, keyToAddParameters); - // Generate new report + // Add cached scripts and generate new report + await cliActions.addCachedScripts(10); await t.click(memoryEfficiencyPage.newReportBtn); - // Verify that big keys recommendation not displayed when hash has 5000 fields - await t.expect(memoryEfficiencyPage.noRecommendationsMessage.textContent).eql(noRecommendationsMessage, 'No recommendations message not displayed'); - // Add the last field in hash key - await populateHashWithFields(dbParameters.host, dbParameters.port, keyToAddParameters2); - // Generate new report + // No recommendation with 10 cached scripts + await t.expect(memoryEfficiencyPage.luaScriptAccordion.exists).notOk('Avoid dynamic lua script recommendation displayed with 10 cached scripts'); + // Add the last cached script to see the recommendation + await cliActions.addCachedScripts(1); await t.click(memoryEfficiencyPage.newReportBtn); - // Verify that user can see Shard big hashes to small hashes recommendation when Hash length > 5,000 - await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).ok('Shard big hashes to small hashes recommendation not displayed'); - await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Big hashes recommendation not have Code Changes label'); - await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Big hashes recommendation not have Configuration Changes label'); + // Verify that user can see Avoid dynamic Lua script recommendation when number_of_cached_scripts> 10 + await t.expect(memoryEfficiencyPage.luaScriptAccordion.exists).ok('Avoid dynamic lua script recommendation not displayed'); + + // Verify that user can see Use smaller keys recommendation when database has 1M+ keys + await t.expect(memoryEfficiencyPage.useSmallKeysAccordion.exists).ok('Use smaller keys recommendation not displayed'); + + // Verify that user can expand/collapse recommendation + const expandedTextConaiterSize = await memoryEfficiencyPage.luaScriptTextContainer.offsetHeight; + await t.click(memoryEfficiencyPage.luaScriptButton); + await t.expect(memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).lt(expandedTextConaiterSize, 'Recommendation not collapsed'); + await t.click(memoryEfficiencyPage.luaScriptButton); + await t.expect(memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).eql(expandedTextConaiterSize, 'Recommendation not expanded'); + + // Verify that user can navigate by link to see the recommendation + await t.click(memoryEfficiencyPage.readMoreLink); + await common.checkURL(externalPageLink); + // Close the window with external link to switch to the application window + await t.closeWindow(); }); +test('Shard big hashes to small hashes recommendation', async t => { + keyName = `recomKey-${common.generateWord(10)}`; + const noRecommendationsMessage = 'No Recommendations at the moment.'; + const keyToAddParameters = { fieldsCount: 4999, keyName, fieldStartWith: 'hashField', fieldValueStartWith: 'hashValue' }; + const keyToAddParameters2 = { fieldsCount: 1, keyName, fieldStartWith: 'hashFieldLast', fieldValueStartWith: 'hashValueLast' }; + const command = `HSET ${keyName} field value`; + + // Create Hash key and create report + await cliPage.sendCommandInCli(command); + await t.click(memoryEfficiencyPage.newReportBtn); + // Go to Recommendations tab + await t.click(memoryEfficiencyPage.recommendationsTab); + // No recommendations message + await t.expect(memoryEfficiencyPage.noRecommendationsMessage.textContent).eql(noRecommendationsMessage, 'No recommendations message not displayed'); + // Add 5000 fields to the hash key + await populateHashWithFields(dbParameters.host, dbParameters.port, keyToAddParameters); + // Generate new report + await t.click(memoryEfficiencyPage.newReportBtn); + // Verify that big keys recommendation not displayed when hash has 5000 fields + await t.expect(memoryEfficiencyPage.noRecommendationsMessage.textContent).eql(noRecommendationsMessage, 'No recommendations message not displayed'); + // Add the last field in hash key + await populateHashWithFields(dbParameters.host, dbParameters.port, keyToAddParameters2); + // Generate new report + await t.click(memoryEfficiencyPage.newReportBtn); + // Verify that user can see Shard big hashes to small hashes recommendation when Hash length > 5,000 + await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).ok('Shard big hashes to small hashes recommendation not displayed'); + await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Big hashes recommendation not have Code Changes label'); + await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Big hashes recommendation not have Configuration Changes label'); +}); +test('Combine small strings to hashes recommendation', async t => { + keyName = `recomKey-${common.generateWord(10)}`; + const commandToAddKey = `SET ${stringKeyName} value`; + const command = `SET ${keyName} "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed accumsan lectus sed diam suscipit, eu ullamcorper ligula pulvinar."`; + // Create small String key and create report + await cliPage.sendCommandInCli(commandToAddKey); + await t.click(memoryEfficiencyPage.newReportBtn); + // Go to Recommendations tab + await t.click(memoryEfficiencyPage.recommendationsTab); + // Verify that user can see Combine small strings to hashes recommendation when there are strings that are less than 200 bytes + await t.expect(memoryEfficiencyPage.combineStringsAccordion.exists).ok('Combine small strings to hashes recommendation not displayed for small string'); + await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Combine small strings to hashes recommendation not have Code Changes label'); + + // Add String key with more than 200 bytes + await cliPage.sendCommandInCli(command); + // Delete small String key + await cliPage.sendCommandInCli(`DEL ${stringKeyName}`); + await t.click(memoryEfficiencyPage.newReportBtn); + // Verify that user can not see recommendation when there is no string that is less than 200 bytes + await t.expect(memoryEfficiencyPage.combineStringsAccordion.exists).notOk('Combine small strings to hashes recommendation is displayed for huge string'); +}); test - .before(async t => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); - await browserPage.addStringKey(stringKeyName, '2147476121', 'field'); - // Go to Analysis Tools page - await t.click(myRedisDatabasePage.analysisPageButton); - await t.click(memoryEfficiencyPage.newReportBtn); - // Go to Recommendations tab - await t.click(memoryEfficiencyPage.recommendationsTab); - }) .after(async t => { // Clear and delete database await t.click(myRedisDatabasePage.browserButton); - await browserPage.deleteKeyByName(stringBigKeyName); + await browserPage.deleteKeyByName(keyName); + // Return back set-max-intset-entries value + await cliPage.sendCommandInCli('config set set-max-intset-entries 512'); await deleteStandaloneDatabaseApi(ossStandaloneConfig); - })('Combine small strings to hashes recommendation', async t => { - const command = `SET ${stringBigKeyName} "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed accumsan lectus sed diam suscipit, eu ullamcorper ligula pulvinar."`; - - // Verify that user can see Combine small strings to hashes recommendation when there are strings that are less than 200 bytes - await t.expect(memoryEfficiencyPage.combineStringsAccordion.exists).ok('Combine small strings to hashes recommendation not displayed for small string'); - await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Combine small strings to hashes recommendation not have Code Changes label'); - - // Add String key with more than 200 bytes - await cliPage.sendCommandInCli(command); - // Delete small String key - await cliPage.sendCommandInCli(`DEL ${stringKeyName}`); + })('Increase the set-max-intset-entries recommendation', async t => { + keyName = `recomKey-${common.generateWord(10)}`; + const commandToAddKey = `SADD ${keyName} member`; + const command = 'config set set-max-intset-entries 1024'; + const setKeyParameters = { membersCount: 512, keyName, memberStartWith: 'setMember' }; - await t.click(memoryEfficiencyPage.newReportBtn); - // Verify that user can not see recommendation when there is at least one string that are more than 200 bytes - await t.expect(memoryEfficiencyPage.combineStringsAccordion.exists).notOk('Combine small strings to hashes recommendation is displayed for huge string'); - }); -test - .before(async t => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); - await browserPage.addSetKey(setKeyName, '2147476121', 'member'); - // Add 512 members to the set key + // Create Set with 513 members and create report + await cliPage.sendCommandInCli(commandToAddKey); await populateSetWithMembers(dbParameters.host, dbParameters.port, setKeyParameters); - // Go to Analysis Tools page - await t.click(myRedisDatabasePage.analysisPageButton); await t.click(memoryEfficiencyPage.newReportBtn); // Go to Recommendations tab await t.click(memoryEfficiencyPage.recommendationsTab); - }) - .after(async t => { - // Clear and delete database - await t.click(myRedisDatabasePage.browserButton); - await browserPage.deleteKeyByName(setKeyName); - await cliPage.sendCommandInCli('config set set-max-intset-entries 512'); - await deleteStandaloneDatabaseApi(ossStandaloneConfig); - })('Increase the set-max-intset-entries recommendation', async t => { - const command = 'config set set-max-intset-entries 1024'; - // Verify that user can see Increase the set-max-intset-entries recommendation when Found sets with length > set-max-intset-entries await t.expect(memoryEfficiencyPage.increaseSetAccordion.exists).ok('Increase the set-max-intset-entries recommendation not displayed'); await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Increase the set-max-intset-entries recommendation not have Configuration Changes label'); @@ -176,16 +161,12 @@ test test .before(async t => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + keyName = `recomKey-${common.generateWord(10)}`; await browserPage.addStringKey(stringKeyName, '2147476121', 'field'); await t.click(myRedisDatabasePage.myRedisDBButton); await addRedisDatabasePage.addLogicalRedisDatabase(ossStandaloneConfig, index); await myRedisDatabasePage.clickOnDBByName(`${ossStandaloneConfig.databaseName} [${index}]`); await browserPage.addHashKey(keyName, '2147476121', 'field', 'value'); - // Go to Analysis Tools page - await t.click(myRedisDatabasePage.analysisPageButton); - await t.click(memoryEfficiencyPage.newReportBtn); - // Go to Recommendations tab - await t.click(memoryEfficiencyPage.recommendationsTab); }) .after(async t => { // Clear and delete database @@ -196,38 +177,48 @@ test await browserPage.deleteKeyByName(stringKeyName); await deleteStandaloneDatabaseApi(ossStandaloneConfig); })('Avoid using logical databases', async t => { - // Verify that user can see Avoid using logical databases recommendation when the database supports logical databases and there are keys in more than 1 logical database - await t.expect(memoryEfficiencyPage.avoidLogicalDbAccordion.exists).ok('Avoid using logical databases recommendation not displayed'); - await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Avoid using logical databases recommendation not have Code Changes label'); - }); -test.only - .before(async t => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); - await browserPage.addZSetKey(setKeyName, '2147476121', 'member'); - // Add 128 members to the zset key - await populateZSetWithMembers(dbParameters.host, dbParameters.port, zsetKeyParameters); // Go to Analysis Tools page await t.click(myRedisDatabasePage.analysisPageButton); await t.click(memoryEfficiencyPage.newReportBtn); // Go to Recommendations tab await t.click(memoryEfficiencyPage.recommendationsTab); - }) + // Verify that user can see Avoid using logical databases recommendation when the database supports logical databases and there are keys in more than 1 logical database + await t.expect(memoryEfficiencyPage.avoidLogicalDbAccordion.exists).ok('Avoid using logical databases recommendation not displayed'); + await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Avoid using logical databases recommendation not have Code Changes label'); + }); +test .after(async t => { // Clear and delete database await t.click(myRedisDatabasePage.browserButton); - await browserPage.deleteKeyByName(setKeyName); - await cliPage.sendCommandInCli('config set zset-max-ziplist-entries 128'); + await browserPage.deleteKeyByName(keyName); + await cliPage.sendCommandInCli('config set hash-max-ziplist-entries 512'); await deleteStandaloneDatabaseApi(ossStandaloneConfig); - })('Convert hashtable to ziplist for hashes recommendation', async t => { - const command = 'config set zset-max-ziplist-entries 256'; + })('Compress Hash field names', async t => { + keyName = `recomKey-${common.generateWord(10)}`; + const command = 'config set hash-max-ziplist-entries 1001'; + const commandToAddKey = `HSET ${keyName} field value`; + const hashKeyParameters = { fieldsCount: 1000, keyName, fieldStartWith: 'hashField', fieldValueStartWith: 'hashValue' }; + + // Create Hash key + await cliPage.sendCommandInCli(commandToAddKey); + // Add 1000 fields to Hash key and create report + await populateHashWithFields(dbParameters.host, dbParameters.port, hashKeyParameters); + await t.click(memoryEfficiencyPage.newReportBtn); + // Go to Recommendations tab + await t.click(memoryEfficiencyPage.recommendationsTab); + + // Verify that user can see Compress Hash field names recommendation when Hash length > 1,000 + await t.expect(memoryEfficiencyPage.compressHashAccordion.exists).ok('Compress Hash field names recommendation not displayed'); + await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Compress Hash field names recommendation not have Configuration Changes label'); + // Convert hashtable to ziplist for hashes recommendation // Verify that user can see Convert hashtable to ziplist for hashes recommendation when the number of hash entries exceeds hash-max-ziplist-entries - await t.expect(memoryEfficiencyPage.increaseSetAccordion.exists).ok('Increase the set-max-intset-entries recommendation not displayed'); - await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Increase the set-max-intset-entries recommendation not have Configuration Changes label'); + await t.expect(memoryEfficiencyPage.convertHashToZipAccordion.exists).ok('Convert hashtable to ziplist for hashes recommendation not displayed'); + await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Convert hashtable to ziplist for hashes recommendation not have Configuration Changes label'); - // Change config max entries to 256 - await cliPage.sendCommandInCli(command); - await t.click(memoryEfficiencyPage.newReportBtn); - // Verify that user can not see Convert hashtable to ziplist for hashes recommendation when the number of hash entries not exceeds hash-max-ziplist-entries - await t.expect(memoryEfficiencyPage.increaseSetAccordion.exists).notOk('Increase the set-max-intset-entries recommendation is displayed'); - }); \ No newline at end of file + // Change config max entries to 1001 + await cliPage.sendCommandInCli(command); + await t.click(memoryEfficiencyPage.newReportBtn); + // Verify that user can not see Convert hashtable to ziplist for hashes recommendation when the number of hash entries not exceeds hash-max-ziplist-entries + await t.expect(memoryEfficiencyPage.convertHashToZipAccordion.exists).notOk('Convert hashtable to ziplist for hashes recommendation is displayed'); + }); From 19008eb903739f6a8f63df9160994779fbe38f4d Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 30 Nov 2022 20:20:09 +0400 Subject: [PATCH 037/201] update badge styles --- .../components/recommendations-view/styles.module.scss | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index 4360fd75c3..8d1b49428e 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -63,10 +63,14 @@ } .badgesContainer { - margin-right: 48px; - .badge { + width: 180px; margin: 14px 18px; + + &:last-child { + padding-right: 48px; + align-items: flex-end; + } .badgeIcon { margin-right: 14px; From fd674dfb2293f99549a7a4bde70204277e999d05 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 1 Dec 2022 10:47:30 +0100 Subject: [PATCH 038/201] fix --- .../critical-path/memory-efficiency/recommendations.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts index 9377ff4f4d..3957b26d5e 100644 --- a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts +++ b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts @@ -95,7 +95,7 @@ test('Shard big hashes to small hashes recommendation', async t => { // Generate new report await t.click(memoryEfficiencyPage.newReportBtn); // Verify that big keys recommendation not displayed when hash has 5000 fields - await t.expect(memoryEfficiencyPage.noRecommendationsMessage.textContent).eql(noRecommendationsMessage, 'No recommendations message not displayed'); + await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).notOk('Shard big hashes to small hashes recommendation is displayed when hash has 5000 fields'); // Add the last field in hash key await populateHashWithFields(dbParameters.host, dbParameters.port, keyToAddParameters2); // Generate new report @@ -125,7 +125,7 @@ test('Combine small strings to hashes recommendation', async t => { // Delete small String key await cliPage.sendCommandInCli(`DEL ${stringKeyName}`); await t.click(memoryEfficiencyPage.newReportBtn); - // Verify that user can not see recommendation when there is no string that is less than 200 bytes + // Verify that user can not see recommendation when there are only strings with more than 200 bytes await t.expect(memoryEfficiencyPage.combineStringsAccordion.exists).notOk('Combine small strings to hashes recommendation is displayed for huge string'); }); test From e17fa553b4b74b5f4059aa77ba5e02621f2bb104 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Fri, 2 Dec 2022 13:16:52 +0400 Subject: [PATCH 039/201] #RI-3569 - add comression for list recommendation --- redisinsight/api/src/constants/index.ts | 1 + .../api/src/constants/recommendations.ts | 11 +++ .../providers/recommendation.provider.spec.ts | 34 +++++++-- .../providers/recommendation.provider.ts | 65 ++++++++++++---- .../recommendation/recommendation.service.ts | 1 + .../POST-databases-id-analysis.test.ts | 27 ++++++- redisinsight/api/test/helpers/constants.ts | 18 +++-- redisinsight/api/test/helpers/data/redis.ts | 20 +++++ .../constants/dbAnalysisRecommendations.json | 35 +++++++++ .../Recommendations.spec.tsx | 74 +++++-------------- .../recommendations-view/Recommendations.tsx | 3 - 11 files changed, 203 insertions(+), 86 deletions(-) create mode 100644 redisinsight/api/src/constants/recommendations.ts diff --git a/redisinsight/api/src/constants/index.ts b/redisinsight/api/src/constants/index.ts index 29b0a50e30..bba5b52d42 100644 --- a/redisinsight/api/src/constants/index.ts +++ b/redisinsight/api/src/constants/index.ts @@ -9,3 +9,4 @@ export * from './redis-commands'; export * from './telemetry-events'; export * from './app-events'; export * from './redis-connection'; +export * from './recommendations'; diff --git a/redisinsight/api/src/constants/recommendations.ts b/redisinsight/api/src/constants/recommendations.ts new file mode 100644 index 0000000000..15468b0363 --- /dev/null +++ b/redisinsight/api/src/constants/recommendations.ts @@ -0,0 +1,11 @@ +export const RECOMMENDATION_NAMES = Object.freeze({ + LUA_SCRIPT: 'luaScript', + BIG_HASHES: 'bigHashes', + USE_SMALLER_KEYS: 'useSmallerKeys', + AVOID_LOGICAL_DATABASES: 'avoidLogicalDatabases', + COMBINE_SMALL_STRINGS_TO_HASHES: 'combineSmallStringsToHashes', + INCREASE_SET_MAX_INTSET_ENTRIES: 'increaseSetMaxIntsetEntries', + CONVERT_HASHTABLE_TO_ZIPLIST: 'convertHashtableToZiplist', + COMPRESS_HASH_FIELD_NAMES: 'compressHashFieldNames', + COMPRESSION_FOR_LIST: 'compressionForList', +}); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts index 6e823e43bb..5fd9ec8523 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -1,5 +1,6 @@ import IORedis from 'ioredis'; import { when } from 'jest-when'; +import { RECOMMENDATION_NAMES } from 'src/constants'; import { RecommendationProvider } from 'src/modules/recommendation/providers/recommendation.provider'; const nodeClient = Object.create(IORedis.prototype); @@ -64,6 +65,10 @@ const mockBigSet = { name: Buffer.from('name'), type: 'set', length: 513, memory: 10, ttl: -1, }; +const mockBigListKey = { + name: Buffer.from('name'), type: 'list', length: 1001, memory: 10, ttl: -1, +}; + describe('RecommendationProvider', () => { const service = new RecommendationProvider(); @@ -83,7 +88,7 @@ describe('RecommendationProvider', () => { .mockResolvedValue(mockRedisMemoryInfoResponse_2); const luaScriptRecommendation = await service.determineLuaScriptRecommendation(nodeClient); - expect(luaScriptRecommendation).toEqual({ name: 'luaScript' }); + expect(luaScriptRecommendation).toEqual({ name: RECOMMENDATION_NAMES.LUA_SCRIPT }); }); it('should not return luaScript recommendation when info command executed with error', async () => { @@ -105,7 +110,7 @@ describe('RecommendationProvider', () => { const bigHashesRecommendation = await service.determineBigHashesRecommendation( [...mockKeys, mockBigHashKey], ); - expect(bigHashesRecommendation).toEqual({ name: 'bigHashes' }); + expect(bigHashesRecommendation).toEqual({ name: RECOMMENDATION_NAMES.BIG_HASHES }); }); }); @@ -116,7 +121,7 @@ describe('RecommendationProvider', () => { }); it('should return useSmallerKeys recommendation', async () => { const bigTotalRecommendation = await service.determineBigTotalRecommendation(1_000_001); - expect(bigTotalRecommendation).toEqual({ name: 'useSmallerKeys' }); + expect(bigTotalRecommendation).toEqual({ name: RECOMMENDATION_NAMES.USE_SMALLER_KEYS }); }); }); @@ -178,7 +183,7 @@ describe('RecommendationProvider', () => { }); it('should return combineSmallStringsToHashes recommendation', async () => { const smallStringRecommendation = await service.determineCombineSmallStringsToHashesRecommendation(mockKeys); - expect(smallStringRecommendation).toEqual({ name: 'combineSmallStringsToHashes' }); + expect(smallStringRecommendation).toEqual({ name: RECOMMENDATION_NAMES.COMBINE_SMALL_STRINGS_TO_HASHES }); }); }); @@ -200,7 +205,8 @@ describe('RecommendationProvider', () => { const increaseSetMaxIntsetEntriesRecommendation = await service .determineIncreaseSetMaxIntsetEntriesRecommendation(nodeClient, [...mockKeys, mockBigSet]); - expect(increaseSetMaxIntsetEntriesRecommendation).toEqual({ name: 'increaseSetMaxIntsetEntries' }); + expect(increaseSetMaxIntsetEntriesRecommendation) + .toEqual({ name: RECOMMENDATION_NAMES.INCREASE_SET_MAX_INTSET_ENTRIES }); }); it('should not return increaseSetMaxIntsetEntries recommendation when config command executed with error', @@ -233,7 +239,8 @@ describe('RecommendationProvider', () => { const convertHashtableToZiplistRecommendation = await service .determineConvertHashtableToZiplistRecommendation(nodeClient, [...mockKeys, mockBigHashKey_3]); - expect(convertHashtableToZiplistRecommendation).toEqual({ name: 'convertHashtableToZiplist' }); + expect(convertHashtableToZiplistRecommendation) + .toEqual({ name: RECOMMENDATION_NAMES.CONVERT_HASHTABLE_TO_ZIPLIST }); }); it('should not return convertHashtableToZiplist recommendation when config command executed with error', @@ -257,7 +264,20 @@ describe('RecommendationProvider', () => { it('should return compressHashFieldNames recommendation', async () => { const compressHashFieldNamesRecommendation = await service .determineCompressHashFieldNamesRecommendation([mockBigHashKey_2]); - expect(compressHashFieldNamesRecommendation).toEqual({ name: 'compressHashFieldNames' }); + expect(compressHashFieldNamesRecommendation).toEqual({ name: RECOMMENDATION_NAMES.COMPRESS_HASH_FIELD_NAMES }); + }); + }); + + describe('determineCompressionForListRecommendation', () => { + it('should not return compressionForList recommendation', async () => { + const compressHashFieldNamesRecommendation = await service + .determineCompressionForListRecommendation(mockKeys); + expect(compressHashFieldNamesRecommendation).toEqual(null); + }); + it('should return compressionForList recommendation', async () => { + const compressHashFieldNamesRecommendation = await service + .determineCompressionForListRecommendation([mockBigListKey]); + expect(compressHashFieldNamesRecommendation).toEqual({ name: RECOMMENDATION_NAMES.COMPRESSION_FOR_LIST }); }); }); }); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index a1908c9d39..ff7b5d1d45 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { Redis, Cluster, Command } from 'ioredis'; import { get } from 'lodash'; import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/utils'; +import { RECOMMENDATION_NAMES } from 'src/constants'; import { RedisDataType } from 'src/modules/browser/dto'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { Key } from 'src/modules/database-analysis/models'; @@ -11,9 +12,12 @@ const maxHashLength = 5000; const maxStringMemory = 200; const maxDatabaseTotal = 1_000_000; const maxCompressHashLength = 1000; +const maxListLength = 1000; @Injectable() export class RecommendationProvider { + private logger = new Logger('RecommendationProvider'); + /** * Check lua script recommendation * @param redisClient @@ -30,9 +34,10 @@ export class RecommendationProvider { const nodesNumbersOfCachedScripts = get(info, 'memory.number_of_cached_scripts'); return parseInt(nodesNumbersOfCachedScripts, 10) > minNumberOfCachedScripts - ? { name: 'luaScript' } + ? { name: RECOMMENDATION_NAMES.LUA_SCRIPT } : null; } catch (err) { + this.logger.error('Can not determine Lua script recommendation', err); return null; } } @@ -44,18 +49,23 @@ export class RecommendationProvider { async determineBigHashesRecommendation( keys: Key[], ): Promise { - const bigHashes = keys.some((key) => key.type === RedisDataType.Hash && key.length > maxHashLength); - return bigHashes ? { name: 'bigHashes' } : null; + try { + const bigHashes = keys.some((key) => key.type === RedisDataType.Hash && key.length > maxHashLength); + return bigHashes ? { name: RECOMMENDATION_NAMES.BIG_HASHES } : null; + } catch (err) { + this.logger.error('Can not determine Big Hashes recommendation', err); + return null; + } } /** - * Check big hashes recommendation + * Check use smaller keys recommendation * @param total */ async determineBigTotalRecommendation( total: number, ): Promise { - return total > maxDatabaseTotal ? { name: 'useSmallerKeys' } : null; + return total > maxDatabaseTotal ? { name: RECOMMENDATION_NAMES.USE_SMALLER_KEYS } : null; } /** @@ -79,8 +89,9 @@ export class RecommendationProvider { const { keys } = convertBulkStringsToObject(db as string, ',', '='); return keys > 0; }); - return databasesWithKeys.length > 1 ? { name: 'avoidLogicalDatabases' } : null; + return databasesWithKeys.length > 1 ? { name: RECOMMENDATION_NAMES.AVOID_LOGICAL_DATABASES } : null; } catch (err) { + this.logger.error('Can not determine Logical database recommendation', err); return null; } } @@ -92,8 +103,13 @@ export class RecommendationProvider { async determineCombineSmallStringsToHashesRecommendation( keys: Key[], ): Promise { - const smallString = keys.some((key) => key.type === RedisDataType.String && key.memory < maxStringMemory); - return smallString ? { name: 'combineSmallStringsToHashes' } : null; + try { + const smallString = keys.some((key) => key.type === RedisDataType.String && key.memory < maxStringMemory); + return smallString ? { name: RECOMMENDATION_NAMES.COMBINE_SMALL_STRINGS_TO_HASHES } : null; + } catch (err) { + this.logger.error('Can not determine Combine small strings to hashes recommendation', err); + return null; + } } /** @@ -117,8 +133,9 @@ export class RecommendationProvider { } const setMaxIntsetEntriesNumber = parseInt(setMaxIntsetEntries, 10); const bigSet = keys.some((key) => key.type === RedisDataType.Set && key.length > setMaxIntsetEntriesNumber); - return bigSet ? { name: 'increaseSetMaxIntsetEntries' } : null; + return bigSet ? { name: RECOMMENDATION_NAMES.INCREASE_SET_MAX_INTSET_ENTRIES } : null; } catch (err) { + this.logger.error('Can not determine Increase set max intset entries recommendation', err); return null; } } @@ -140,8 +157,9 @@ export class RecommendationProvider { ) as string[]; const hashMaxZiplistEntriesNumber = parseInt(hashMaxZiplistEntries, 10); const bigHash = keys.some((key) => key.type === RedisDataType.Hash && key.length > hashMaxZiplistEntriesNumber); - return bigHash ? { name: 'convertHashtableToZiplist' } : null; + return bigHash ? { name: RECOMMENDATION_NAMES.CONVERT_HASHTABLE_TO_ZIPLIST } : null; } catch (err) { + this.logger.error('Can not determine Convert hashtable to ziplist recommendation', err); return null; } } @@ -153,7 +171,28 @@ export class RecommendationProvider { async determineCompressHashFieldNamesRecommendation( keys: Key[], ): Promise { - const bigHash = keys.some((key) => key.type === RedisDataType.Hash && key.length > maxCompressHashLength); - return bigHash ? { name: 'compressHashFieldNames' } : null; + try { + const bigHash = keys.some((key) => key.type === RedisDataType.Hash && key.length > maxCompressHashLength); + return bigHash ? { name: RECOMMENDATION_NAMES.COMPRESS_HASH_FIELD_NAMES } : null; + } catch (err) { + this.logger.error('Can not determine Compress hash field names recommendation', err); + return null; + } + } + + /** + * Check compression for list recommendation + * @param keys + */ + async determineCompressionForListRecommendation( + keys: Key[], + ): Promise { + try { + const bugList = keys.some((key) => key.type === RedisDataType.List && key.length > maxListLength); + return bugList ? { name: RECOMMENDATION_NAMES.COMPRESSION_FOR_LIST } : null; + } catch (err) { + this.logger.error('Can not determine Compression for list recommendation', err); + return null; + } } } diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index 3dab87a31a..3a405f012a 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -43,6 +43,7 @@ export class RecommendationService { await this.recommendationProvider.determineIncreaseSetMaxIntsetEntriesRecommendation(client, keys), await this.recommendationProvider.determineConvertHashtableToZiplistRecommendation(client, keys), await this.recommendationProvider.determineCompressHashFieldNamesRecommendation(keys), + await this.recommendationProvider.determineCompressionForListRecommendation(keys), ])); } } diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index 712234f749..5f470a2fe7 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -284,7 +284,32 @@ describe('POST /databases/:instanceId/analysis', () => { expect(body.recommendations).to.deep.eq([ constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, - + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with compressionForList recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_LIST_ELEMENTS = 1001; + await rte.data.generateHugeElementsForListKey(NUMBERS_OF_LIST_ELEMENTS, true); + }, + checkFn: async ({ body }) => { + expect(body.totalKeys.total).to.gt(0); + expect(body.totalMemory.total).to.gt(0); + expect(body.topKeysNsp.length).to.gt(0); + expect(body.topMemoryNsp.length).to.gt(0); + expect(body.topKeysLength.length).to.gt(0); + expect(body.topKeysMemory.length).to.gt(0); + expect(body.recommendations).to.deep.eq([ + constants.TEST_COMPRESSION_FOR_LIST_RECOMMENDATION, ]); }, after: async () => { diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index 1673bbc6b9..71b835694b 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -1,6 +1,7 @@ import { v4 as uuidv4 } from 'uuid'; import { randomBytes } from 'crypto'; import { getASCIISafeStringFromBuffer, getBufferFromSafeASCIIString } from "src/utils/cli-helper"; +import { RECOMMENDATION_NAMES } from 'src/constants'; const API = { DATABASES: 'databases', @@ -452,25 +453,28 @@ export const constants = { }, TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION: { - name: 'luaScript', + name: RECOMMENDATION_NAMES.LUA_SCRIPT, }, TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION: { - name: 'bigHashes', + name: RECOMMENDATION_NAMES.BIG_HASHES, }, TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION: { - name: 'useSmallerKeys', + name: RECOMMENDATION_NAMES.USE_SMALLER_KEYS, }, TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION: { - name: 'increaseSetMaxIntsetEntries', + name: RECOMMENDATION_NAMES.INCREASE_SET_MAX_INTSET_ENTRIES, }, TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION: { - name: 'combineSmallStringsToHashes', + name: RECOMMENDATION_NAMES.COMBINE_SMALL_STRINGS_TO_HASHES, }, TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION: { - name: 'convertHashtableToZiplist', + name: RECOMMENDATION_NAMES.CONVERT_HASHTABLE_TO_ZIPLIST, }, TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION: { - name: 'compressHashFieldNames', + name: RECOMMENDATION_NAMES.COMPRESS_HASH_FIELD_NAMES, + }, + TEST_COMPRESSION_FOR_LIST_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.COMPRESSION_FOR_LIST, }, // etc... diff --git a/redisinsight/api/test/helpers/data/redis.ts b/redisinsight/api/test/helpers/data/redis.ts index 520076a8f9..1bf6d8a60e 100644 --- a/redisinsight/api/test/helpers/data/redis.ts +++ b/redisinsight/api/test/helpers/data/redis.ts @@ -218,6 +218,25 @@ export const initDataHelper = (rte) => { ); }; + + const generateHugeElementsForListKey = async (number: number = 100000, clean: boolean) => { + if (clean) { + await truncate(); + } + + const batchSize = 10000; + let inserted = 0; + do { + const pipeline = []; + const limit = inserted + batchSize; + for (inserted; inserted < limit && inserted < number; inserted++) { + pipeline.push(['lpush', constants.TEST_LIST_KEY_1, inserted]); + } + + await insertKeysBasedOnEnv(pipeline, true); + } while (inserted < number) + }; + // Set const generateSets = async (clean: boolean = false) => { if (clean) { @@ -507,6 +526,7 @@ export const initDataHelper = (rte) => { generateKeys, generateHugeNumberOfFieldsForHashKey, generateHugeNumberOfTinyStringKeys, + generateHugeElementsForListKey, generateHugeStream, generateNKeys, generateRedisearchIndexes, diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index bd05082176..ad033fbf39 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -193,5 +193,40 @@ } ], "badges": ["configuration_changes"] + }, + "compressionForList": { + "id": "compressionForList", + "title": "Enable compression for the list", + "content": [ + { + "id": "1", + "type": "paragraph", + "value": "If you use long lists, and mostly access elements from the head and tail only, then you can enable compression." + }, + { + "id": "2", + "type": "paragraph", + "value": "Set list-compression-depth=1 in redis.conf to compress every list node except the head and tail of the list. Though list operations that involve elements in the center of the list will get slower, the compression can increase CPU utilization." + }, + { + "id": "3", + "type": "spacer", + "value": "l" + }, + { + "id": "4", + "type": "span", + "value": "Run `INFO COMMANDSTATS` before and after making this change to verify the latency numbers. " + }, + { + "id": "5", + "type": "link", + "value": { + "href": "https://docs.redis.com/latest/ri/memory-optimizations/", + "name": "Read more" + } + } + ], + "badges": ["configuration_changes"] } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 7cde5742db..c5a868c35c 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -47,56 +47,6 @@ describe('Recommendations', () => { expect(screen.queryByTestId('recommendations-loader')).not.toBeInTheDocument() }) - describe('recommendations initial open', () => { - it('should render open recommendations', () => { - (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ - ...mockdbAnalysisSelector, - data: { - recommendations: [ - { name: 'luaScript' }, - { name: 'luaScript' }, - { name: 'luaScript' }, - { name: 'luaScript' }, - { name: 'luaScript' }, - ] - } - })) - - render() - - expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() - expect(screen.queryAllByTestId('luaScript-accordion')[1]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() - expect(screen.queryAllByTestId('luaScript-accordion')[2]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() - expect(screen.queryAllByTestId('luaScript-accordion')[3]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() - expect(screen.queryAllByTestId('luaScript-accordion')[4]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() - }) - - it('should render closed recommendations', () => { - (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ - ...mockdbAnalysisSelector, - data: { - recommendations: [ - { name: 'luaScript' }, - { name: 'luaScript' }, - { name: 'luaScript' }, - { name: 'luaScript' }, - { name: 'luaScript' }, - { name: 'luaScript' }, - ] - } - })) - - render() - - expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() - expect(screen.queryAllByTestId('luaScript-accordion')[1]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() - expect(screen.queryAllByTestId('luaScript-accordion')[2]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() - expect(screen.queryAllByTestId('luaScript-accordion')[3]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() - expect(screen.queryAllByTestId('luaScript-accordion')[4]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() - expect(screen.queryAllByTestId('luaScript-accordion')[5]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() - }) - }) - it('should render code changes badge in luaScript recommendation', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, @@ -142,7 +92,7 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) - it('should render code changes badge and configuration_changes in avoidLogicalDatabases recommendation', () => { + it('should render code changes badge in avoidLogicalDatabases recommendation', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, data: { @@ -157,7 +107,7 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() }) - it('should render code changes badge and configuration_changes in combineSmallStringsToHashes recommendation', () => { + it('should render code changes badge in combineSmallStringsToHashes recommendation', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, data: { @@ -172,7 +122,7 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() }) - it('should render configuration_changes badge and configuration_changes in increaseSetMaxIntsetEntries recommendation', () => { + it('should render configuration_changes badge in increaseSetMaxIntsetEntries recommendation', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, data: { @@ -187,7 +137,7 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) - it('should render code changes badge and configuration_changes in convertHashtableToZiplist recommendation', () => { + it('should render code changes badge in convertHashtableToZiplist recommendation', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, data: { @@ -202,7 +152,7 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) - it('should render configuration_changes badge and configuration_changes in compressHashFieldNames recommendation', () => { + it('should render configuration_changes badge in compressHashFieldNames recommendation', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, data: { @@ -217,6 +167,20 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) + it('should render configuration_changes badge in compressionForList recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'compressionForList' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).not.toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() + }) it('should collapse/expand', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index ad48814435..8baf5567d9 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -12,8 +12,6 @@ import recommendationsContent from 'uiSrc/constants/dbAnalysisRecommendations.js import { parseContent, renderBadges } from './utils' import styles from './styles.module.scss' -const countToCollapseRecommendations = 6 - const Recommendations = () => { const { data, loading } = useSelector(dbAnalysisSelector) const { recommendations = [] } = data ?? {} @@ -45,7 +43,6 @@ const Recommendations = () => { buttonClassName={styles.accordionBtn} buttonProps={{ 'data-test-subj': `${id}-button` }} className={styles.accordion} - initialIsOpen={recommendations.length < countToCollapseRecommendations} data-testId={`${id}-accordion`} > From ce466e7659ccb156aa5154e5331974a88dc1d37e Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Fri, 2 Dec 2022 17:25:13 +0400 Subject: [PATCH 040/201] #RI-3569-fix big data tests --- .../POST-databases-id-analysis.test.ts | 367 +++++++++--------- .../Recommendations.spec.tsx | 6 +- 2 files changed, 190 insertions(+), 183 deletions(-) diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index 5f470a2fe7..a56c18d091 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -147,191 +147,198 @@ describe('POST /databases/:instanceId/analysis', () => { expect(await repository.count()).to.eq(5); } }, - { - name: 'Should create new database analysis with useSmallerKeys recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - const KEYS_NUMBER = 1_000_001; - await rte.data.generateNKeys(KEYS_NUMBER, false); - }, - checkFn: async ({ body }) => { - expect(body.totalKeys.total).to.gt(0); - expect(body.totalMemory.total).to.gt(0); - expect(body.topKeysNsp.length).to.gt(0); - expect(body.topMemoryNsp.length).to.gt(0); - expect(body.topKeysLength.length).to.gt(0); - expect(body.topKeysMemory.length).to.gt(0); - expect(body.recommendations).to.deep.eq([ - constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, - ]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with bigHashes recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - const NUMBERS_OF_HASH_FIELDS = 5001; - await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); - }, - checkFn: async ({ body }) => { - expect(body.totalKeys.total).to.gt(0); - expect(body.totalMemory.total).to.gt(0); - expect(body.topKeysNsp.length).to.gt(0); - expect(body.topMemoryNsp.length).to.gt(0); - expect(body.topKeysLength.length).to.gt(0); - expect(body.topKeysMemory.length).to.gt(0); - expect(body.recommendations).to.deep.eq([ - constants.TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION, - constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, - constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, - ]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with increaseSetMaxIntsetEntries recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - const NUMBERS_OF_SET_MEMBERS = 513; - await rte.data.generateHugeNumberOfMembersForSetKey(NUMBERS_OF_SET_MEMBERS, true); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with combineSmallStringsToHashes recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - await rte.data.generateStrings(true); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with convertHashtableToZiplist recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - const NUMBERS_OF_HASH_FIELDS = 513; - await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); - }, - checkFn: async ({ body }) => { - expect(body.totalKeys.total).to.gt(0); - expect(body.totalMemory.total).to.gt(0); - expect(body.topKeysNsp.length).to.gt(0); - expect(body.topMemoryNsp.length).to.gt(0); - expect(body.topKeysLength.length).to.gt(0); - expect(body.topKeysMemory.length).to.gt(0); - expect(body.recommendations).to.deep.eq([ - constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, - ]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with compressHashFieldNames recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - const NUMBERS_OF_HASH_FIELDS = 1001; - await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); - }, - checkFn: async ({ body }) => { - expect(body.totalKeys.total).to.gt(0); - expect(body.totalMemory.total).to.gt(0); - expect(body.topKeysNsp.length).to.gt(0); - expect(body.topMemoryNsp.length).to.gt(0); - expect(body.topKeysLength.length).to.gt(0); - expect(body.topKeysMemory.length).to.gt(0); - expect(body.recommendations).to.deep.eq([ - constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, - constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, - ]); + ].map(mainCheckFn); + + describe('recommendations', () => { + // big data always has useSmallerKeys + requirements('!rte.bigData'); + [ + // { + // name: 'Should create new database analysis with useSmallerKeys recommendation', + // data: { + // delimiter: '-', + // }, + // statusCode: 201, + // responseSchema, + // before: async () => { + // const KEYS_NUMBER = 1_000_001; + // await rte.data.generateNKeys(KEYS_NUMBER, false); + // }, + // checkFn: async ({ body }) => { + // expect(body.totalKeys.total).to.gt(0); + // expect(body.totalMemory.total).to.gt(0); + // expect(body.topKeysNsp.length).to.gt(0); + // expect(body.topMemoryNsp.length).to.gt(0); + // expect(body.topKeysLength.length).to.gt(0); + // expect(body.topKeysMemory.length).to.gt(0); + // expect(body.recommendations).to.deep.eq([ + // constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + // constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, + // ]); + // }, + // after: async () => { + // expect(await repository.count()).to.eq(5); + // } + // }, + { + name: 'Should create new database analysis with bigHashes recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_HASH_FIELDS = 5001; + await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); + }, + checkFn: async ({ body }) => { + expect(body.totalKeys.total).to.gt(0); + expect(body.totalMemory.total).to.gt(0); + expect(body.topKeysNsp.length).to.gt(0); + expect(body.topMemoryNsp.length).to.gt(0); + expect(body.topKeysLength.length).to.gt(0); + expect(body.topKeysMemory.length).to.gt(0); + expect(body.recommendations).to.deep.eq([ + constants.TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION, + constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with compressionForList recommendation', - data: { - delimiter: '-', + { + name: 'Should create new database analysis with increaseSetMaxIntsetEntries recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_SET_MEMBERS = 513; + await rte.data.generateHugeNumberOfMembersForSetKey(NUMBERS_OF_SET_MEMBERS, true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } }, - statusCode: 201, - responseSchema, - before: async () => { - const NUMBERS_OF_LIST_ELEMENTS = 1001; - await rte.data.generateHugeElementsForListKey(NUMBERS_OF_LIST_ELEMENTS, true); + { + name: 'Should create new database analysis with combineSmallStringsToHashes recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + await rte.data.generateStrings(true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } }, - checkFn: async ({ body }) => { - expect(body.totalKeys.total).to.gt(0); - expect(body.totalMemory.total).to.gt(0); - expect(body.topKeysNsp.length).to.gt(0); - expect(body.topMemoryNsp.length).to.gt(0); - expect(body.topKeysLength.length).to.gt(0); - expect(body.topKeysMemory.length).to.gt(0); - expect(body.recommendations).to.deep.eq([ - constants.TEST_COMPRESSION_FOR_LIST_RECOMMENDATION, - ]); + { + name: 'Should create new database analysis with convertHashtableToZiplist recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_HASH_FIELDS = 513; + await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); + }, + checkFn: async ({ body }) => { + expect(body.totalKeys.total).to.gt(0); + expect(body.totalMemory.total).to.gt(0); + expect(body.topKeysNsp.length).to.gt(0); + expect(body.topMemoryNsp.length).to.gt(0); + expect(body.topKeysLength.length).to.gt(0); + expect(body.topKeysMemory.length).to.gt(0); + expect(body.recommendations).to.deep.eq([ + constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with luaScript recommendation', - data: { - delimiter: '-', + { + name: 'Should create new database analysis with compressHashFieldNames recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_HASH_FIELDS = 1001; + await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); + }, + checkFn: async ({ body }) => { + expect(body.totalKeys.total).to.gt(0); + expect(body.totalMemory.total).to.gt(0); + expect(body.topKeysNsp.length).to.gt(0); + expect(body.topMemoryNsp.length).to.gt(0); + expect(body.topKeysLength.length).to.gt(0); + expect(body.topKeysMemory.length).to.gt(0); + expect(body.recommendations).to.deep.eq([ + constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } }, - statusCode: 201, - responseSchema, - before: async () => { - await rte.data.generateNCachedScripts(11, true); + { + name: 'Should create new database analysis with compressionForList recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_LIST_ELEMENTS = 1001; + await rte.data.generateHugeElementsForListKey(NUMBERS_OF_LIST_ELEMENTS, true); + }, + checkFn: async ({ body }) => { + expect(body.totalKeys.total).to.gt(0); + expect(body.totalMemory.total).to.gt(0); + expect(body.topKeysNsp.length).to.gt(0); + expect(body.topMemoryNsp.length).to.gt(0); + expect(body.topKeysLength.length).to.gt(0); + expect(body.topKeysMemory.length).to.gt(0); + expect(body.recommendations).to.deep.eq([ + constants.TEST_COMPRESSION_FOR_LIST_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION]); + { + name: 'Should create new database analysis with luaScript recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + await rte.data.generateNCachedScripts(11, true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - ].map(mainCheckFn); + ].map(mainCheckFn); + }); }); diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index c5a868c35c..926b712ae8 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -192,14 +192,14 @@ describe('Recommendations', () => { const { container } = render() - expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() fireEvent.click(container.querySelector('[data-test-subj="luaScript-button"]') as HTMLInputElement) - expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() fireEvent.click(container.querySelector('[data-test-subj="luaScript-button"]') as HTMLInputElement) - expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() }) }) From 1a7de58cfd5f37ea6c5ba4d0765ef0086b3296fa Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 5 Dec 2022 14:10:37 +0400 Subject: [PATCH 041/201] #RI-3573 - add big strings recommendation --- .../api/src/constants/recommendations.ts | 1 + .../providers/recommendation.provider.spec.ts | 17 + .../providers/recommendation.provider.ts | 21 +- .../recommendation/recommendation.service.ts | 1 + .../POST-databases-id-analysis.test.ts | 321 +++++++++++++++--- redisinsight/api/test/helpers/constants.ts | 3 + .../constants/dbAnalysisRecommendations.json | 20 ++ .../Recommendations.spec.tsx | 21 +- .../recommendations-view/Recommendations.tsx | 1 + 9 files changed, 350 insertions(+), 56 deletions(-) diff --git a/redisinsight/api/src/constants/recommendations.ts b/redisinsight/api/src/constants/recommendations.ts index 15468b0363..42b84031c4 100644 --- a/redisinsight/api/src/constants/recommendations.ts +++ b/redisinsight/api/src/constants/recommendations.ts @@ -1,6 +1,7 @@ export const RECOMMENDATION_NAMES = Object.freeze({ LUA_SCRIPT: 'luaScript', BIG_HASHES: 'bigHashes', + BIG_STRINGS: 'bigStrings', USE_SMALLER_KEYS: 'useSmallerKeys', AVOID_LOGICAL_DATABASES: 'avoidLogicalDatabases', COMBINE_SMALL_STRINGS_TO_HASHES: 'combineSmallStringsToHashes', diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts index 5fd9ec8523..e805865ee3 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -61,6 +61,10 @@ const mockBigStringKey = { name: Buffer.from('name'), type: 'string', length: 10, memory: 201, ttl: -1, }; +const mockHugeStringKey = { + name: Buffer.from('name'), type: 'string', length: 10, memory: 5_000_001, ttl: -1, +}; + const mockBigSet = { name: Buffer.from('name'), type: 'set', length: 513, memory: 10, ttl: -1, }; @@ -280,4 +284,17 @@ describe('RecommendationProvider', () => { expect(compressHashFieldNamesRecommendation).toEqual({ name: RECOMMENDATION_NAMES.COMPRESSION_FOR_LIST }); }); }); + + describe('determineBigStringsRecommendation', () => { + it('should not return bigStrings recommendation', async () => { + const bigStringsRecommendation = await service + .determineBigStringsRecommendation(mockKeys); + expect(bigStringsRecommendation).toEqual(null); + }); + it('should return bigStrings recommendation', async () => { + const bigStringsRecommendation = await service + .determineBigStringsRecommendation([mockHugeStringKey]); + expect(bigStringsRecommendation).toEqual({ name: RECOMMENDATION_NAMES.BIG_STRINGS }); + }); + }); }); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index ff7b5d1d45..9626313fa1 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -13,6 +13,7 @@ const maxStringMemory = 200; const maxDatabaseTotal = 1_000_000; const maxCompressHashLength = 1000; const maxListLength = 1000; +const bigStringMemory = 5_000_000; @Injectable() export class RecommendationProvider { @@ -188,11 +189,27 @@ export class RecommendationProvider { keys: Key[], ): Promise { try { - const bugList = keys.some((key) => key.type === RedisDataType.List && key.length > maxListLength); - return bugList ? { name: RECOMMENDATION_NAMES.COMPRESSION_FOR_LIST } : null; + const bigList = keys.some((key) => key.type === RedisDataType.List && key.length > maxListLength); + return bigList ? { name: RECOMMENDATION_NAMES.COMPRESSION_FOR_LIST } : null; } catch (err) { this.logger.error('Can not determine Compression for list recommendation', err); return null; } } + + /** + * Check big strings recommendation + * @param keys + */ + async determineBigStringsRecommendation( + keys: Key[], + ): Promise { + try { + const bigString = keys.some((key) => key.type === RedisDataType.String && key.memory > bigStringMemory); + return bigString ? { name: RECOMMENDATION_NAMES.BIG_STRINGS } : null; + } catch (err) { + this.logger.error('Can not determine Big strings recommendation', err); + return null; + } + } } diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index 3a405f012a..23f1e6bf8b 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -44,6 +44,7 @@ export class RecommendationService { await this.recommendationProvider.determineConvertHashtableToZiplistRecommendation(client, keys), await this.recommendationProvider.determineCompressHashFieldNamesRecommendation(keys), await this.recommendationProvider.determineCompressionForListRecommendation(keys), + await this.recommendationProvider.determineBigStringsRecommendation(keys), ])); } } diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index a56c18d091..490923ee27 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -153,33 +153,6 @@ describe('POST /databases/:instanceId/analysis', () => { // big data always has useSmallerKeys requirements('!rte.bigData'); [ - // { - // name: 'Should create new database analysis with useSmallerKeys recommendation', - // data: { - // delimiter: '-', - // }, - // statusCode: 201, - // responseSchema, - // before: async () => { - // const KEYS_NUMBER = 1_000_001; - // await rte.data.generateNKeys(KEYS_NUMBER, false); - // }, - // checkFn: async ({ body }) => { - // expect(body.totalKeys.total).to.gt(0); - // expect(body.totalMemory.total).to.gt(0); - // expect(body.topKeysNsp.length).to.gt(0); - // expect(body.topMemoryNsp.length).to.gt(0); - // expect(body.topKeysLength.length).to.gt(0); - // expect(body.topKeysMemory.length).to.gt(0); - // expect(body.recommendations).to.deep.eq([ - // constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - // constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, - // ]); - // }, - // after: async () => { - // expect(await repository.count()).to.eq(5); - // } - // }, { name: 'Should create new database analysis with bigHashes recommendation', data: { @@ -192,12 +165,6 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); }, checkFn: async ({ body }) => { - expect(body.totalKeys.total).to.gt(0); - expect(body.totalMemory.total).to.gt(0); - expect(body.topKeysNsp.length).to.gt(0); - expect(body.topMemoryNsp.length).to.gt(0); - expect(body.topKeysLength.length).to.gt(0); - expect(body.topKeysMemory.length).to.gt(0); expect(body.recommendations).to.deep.eq([ constants.TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION, constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, @@ -255,12 +222,6 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); }, checkFn: async ({ body }) => { - expect(body.totalKeys.total).to.gt(0); - expect(body.totalMemory.total).to.gt(0); - expect(body.topKeysNsp.length).to.gt(0); - expect(body.topMemoryNsp.length).to.gt(0); - expect(body.topKeysLength.length).to.gt(0); - expect(body.topKeysMemory.length).to.gt(0); expect(body.recommendations).to.deep.eq([ constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, ]); @@ -281,12 +242,6 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); }, checkFn: async ({ body }) => { - expect(body.totalKeys.total).to.gt(0); - expect(body.totalMemory.total).to.gt(0); - expect(body.topKeysNsp.length).to.gt(0); - expect(body.topMemoryNsp.length).to.gt(0); - expect(body.topKeysLength.length).to.gt(0); - expect(body.topKeysMemory.length).to.gt(0); expect(body.recommendations).to.deep.eq([ constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, @@ -308,16 +263,31 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateHugeElementsForListKey(NUMBERS_OF_LIST_ELEMENTS, true); }, checkFn: async ({ body }) => { - expect(body.totalKeys.total).to.gt(0); - expect(body.totalMemory.total).to.gt(0); - expect(body.topKeysNsp.length).to.gt(0); - expect(body.topMemoryNsp.length).to.gt(0); - expect(body.topKeysLength.length).to.gt(0); - expect(body.topKeysMemory.length).to.gt(0); expect(body.recommendations).to.deep.eq([ constants.TEST_COMPRESSION_FOR_LIST_RECOMMENDATION, ]); }, + after: async () => { + await rte.data.truncate(); + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with bigStrings recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const BIG_STRING_MEMORY = 5_000_001; + const bigStringValue = Buffer.alloc(BIG_STRING_MEMORY, 'a').toString(); + + await rte.data.sendCommand('set', [constants.TEST_STRING_KEY_1, bigStringValue]); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([constants.TEST_BIG_STRINGS_RECOMMENDATION]); + }, after: async () => { expect(await repository.count()).to.eq(5); } @@ -335,10 +305,259 @@ describe('POST /databases/:instanceId/analysis', () => { checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION]); }, + after: async () => { + await rte.data.sendCommand('script', ['flush']); + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with useSmallerKeys recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const KEYS_NUMBER = 1_000_001; + await rte.data.generateNKeys(KEYS_NUMBER, false); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, + ]); + }, + after: async () => { + await rte.data.truncate(); + expect(await repository.count()).to.eq(5); + } + }, + ].map(mainCheckFn); + }); + + describe('recommendations', () => { + // big data always has useSmallerKeys recommendation + requirements('rte.bigData'); + [ + { + name: 'Should create new database analysis with bigHashes recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_HASH_FIELDS = 5001; + await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION, + constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with increaseSetMaxIntsetEntries recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_SET_MEMBERS = 513; + await rte.data.generateHugeNumberOfMembersForSetKey(NUMBERS_OF_SET_MEMBERS, true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION, + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with combineSmallStringsToHashes recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + await rte.data.generateStrings(true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with convertHashtableToZiplist recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_HASH_FIELDS = 513; + await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with compressHashFieldNames recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_HASH_FIELDS = 1001; + await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with compressionForList recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_LIST_ELEMENTS = 1001; + await rte.data.generateHugeElementsForListKey(NUMBERS_OF_LIST_ELEMENTS, true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_COMPRESSION_FOR_LIST_RECOMMENDATION, + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + ]); + }, + after: async () => { + await rte.data.truncate(); + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with bigStrings recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const BIG_STRING_MEMORY = 5_000_001; + const bigStringValue = Buffer.alloc(BIG_STRING_MEMORY, 'a').toString(); + + await rte.data.sendCommand('set', [constants.TEST_STRING_KEY_1, bigStringValue]); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + constants.TEST_BIG_STRINGS_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with luaScript recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + await rte.data.generateNCachedScripts(11, true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION, + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + ]); + }, + after: async () => { + await rte.data.sendCommand('script', ['flush']); + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with useSmallerKeys recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, + ]); + }, after: async () => { expect(await repository.count()).to.eq(5); } }, ].map(mainCheckFn); }); + + // TODO + // describe('useSmallerKeys recommendation', () => { + // // generate 1M keys in cloud take a lot of time + // requirements('!rte.re'); + // [ + // { + // name: 'Should create new database analysis with useSmallerKeys recommendation', + // data: { + // delimiter: '-', + // }, + // statusCode: 201, + // responseSchema, + // before: async () => { + // const KEYS_NUMBER = 1_000_001; + // await rte.data.generateNKeys(KEYS_NUMBER, false); + // }, + // checkFn: async ({ body }) => { + // expect(body.recommendations).to.deep.eq([ + // constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + // constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, + // ]); + // }, + // after: async () => { + // expect(await repository.count()).to.eq(5); + // } + // }, + // ].map(mainCheckFn) + // }); }); diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index 71b835694b..f2368f5a74 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -476,6 +476,9 @@ export const constants = { TEST_COMPRESSION_FOR_LIST_RECOMMENDATION: { name: RECOMMENDATION_NAMES.COMPRESSION_FOR_LIST, }, + TEST_BIG_STRINGS_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.BIG_STRINGS, + }, // etc... } diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index ad033fbf39..b42008ec2e 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -228,5 +228,25 @@ } ], "badges": ["configuration_changes"] + }, + "bigStrings": { + "id": "bigStrings", + "title": "Do not store large strings", + "content": [ + { + "id": "1", + "type": "span", + "value": "Avoid storing large strings, since transferring them takes time and consumes the network bandwidth. Large keys are acceptable only to read/write portions of the string. " + }, + { + "id": "2", + "type": "link", + "value": { + "href": "https://docs.redis.com/latest/ri/memory-optimizations/", + "name": "Read more" + } + } + ], + "badges": ["configuration_changes"] } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 926b712ae8..48562884de 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -182,6 +182,21 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) + it('should render configuration_changes badge in bigStrings recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'bigStrings' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).not.toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() + }) + it('should collapse/expand', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, @@ -192,14 +207,14 @@ describe('Recommendations', () => { const { container } = render() - expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() fireEvent.click(container.querySelector('[data-test-subj="luaScript-button"]') as HTMLInputElement) - expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() fireEvent.click(container.querySelector('[data-test-subj="luaScript-button"]') as HTMLInputElement) - expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() + expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() }) }) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index 8baf5567d9..4250327689 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -43,6 +43,7 @@ const Recommendations = () => { buttonClassName={styles.accordionBtn} buttonProps={{ 'data-test-subj': `${id}-button` }} className={styles.accordion} + initialIsOpen data-testId={`${id}-accordion`} > From e7bc9fd2097d3a1f27d9ed8f5206f3f60bae1dda Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 5 Dec 2022 15:23:47 +0400 Subject: [PATCH 042/201] #RI-3569 - fix IT test --- .../POST-databases-id-analysis.test.ts | 240 +----------------- 1 file changed, 7 insertions(+), 233 deletions(-) diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index 490923ee27..6060fd92d3 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -150,8 +150,10 @@ describe('POST /databases/:instanceId/analysis', () => { ].map(mainCheckFn); describe('recommendations', () => { - // big data always has useSmallerKeys - requirements('!rte.bigData'); + beforeEach(async () => { + await rte.data.truncate() + }); + [ { name: 'Should create new database analysis with bigHashes recommendation', @@ -251,65 +253,6 @@ describe('POST /databases/:instanceId/analysis', () => { expect(await repository.count()).to.eq(5); } }, - { - name: 'Should create new database analysis with compressionForList recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - const NUMBERS_OF_LIST_ELEMENTS = 1001; - await rte.data.generateHugeElementsForListKey(NUMBERS_OF_LIST_ELEMENTS, true); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ - constants.TEST_COMPRESSION_FOR_LIST_RECOMMENDATION, - ]); - }, - after: async () => { - await rte.data.truncate(); - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with bigStrings recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - const BIG_STRING_MEMORY = 5_000_001; - const bigStringValue = Buffer.alloc(BIG_STRING_MEMORY, 'a').toString(); - - await rte.data.sendCommand('set', [constants.TEST_STRING_KEY_1, bigStringValue]); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([constants.TEST_BIG_STRINGS_RECOMMENDATION]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with luaScript recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - await rte.data.generateNCachedScripts(11, true); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION]); - }, - after: async () => { - await rte.data.sendCommand('script', ['flush']); - expect(await repository.count()).to.eq(5); - } - }, { name: 'Should create new database analysis with useSmallerKeys recommendation', data: { @@ -318,7 +261,7 @@ describe('POST /databases/:instanceId/analysis', () => { statusCode: 201, responseSchema, before: async () => { - const KEYS_NUMBER = 1_000_001; + const KEYS_NUMBER = 1_000_006; await rte.data.generateNKeys(KEYS_NUMBER, false); }, checkFn: async ({ body }) => { @@ -327,121 +270,6 @@ describe('POST /databases/:instanceId/analysis', () => { constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, ]); }, - after: async () => { - await rte.data.truncate(); - expect(await repository.count()).to.eq(5); - } - }, - ].map(mainCheckFn); - }); - - describe('recommendations', () => { - // big data always has useSmallerKeys recommendation - requirements('rte.bigData'); - [ - { - name: 'Should create new database analysis with bigHashes recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - const NUMBERS_OF_HASH_FIELDS = 5001; - await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ - constants.TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION, - constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, - constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, - constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - ]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with increaseSetMaxIntsetEntries recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - const NUMBERS_OF_SET_MEMBERS = 513; - await rte.data.generateHugeNumberOfMembersForSetKey(NUMBERS_OF_SET_MEMBERS, true); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ - constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION, - constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - ]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with combineSmallStringsToHashes recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - await rte.data.generateStrings(true); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ - constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, - constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - ]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with convertHashtableToZiplist recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - const NUMBERS_OF_HASH_FIELDS = 513; - await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ - constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, - constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - ]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - { - name: 'Should create new database analysis with compressHashFieldNames recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - const NUMBERS_OF_HASH_FIELDS = 1001; - await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ - constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, - constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, - constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - ]); - }, after: async () => { expect(await repository.count()).to.eq(5); } @@ -460,11 +288,9 @@ describe('POST /databases/:instanceId/analysis', () => { checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([ constants.TEST_COMPRESSION_FOR_LIST_RECOMMENDATION, - constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, ]); }, after: async () => { - await rte.data.truncate(); expect(await repository.count()).to.eq(5); } }, @@ -482,10 +308,7 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.sendCommand('set', [constants.TEST_STRING_KEY_1, bigStringValue]); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ - constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - constants.TEST_BIG_STRINGS_RECOMMENDATION, - ]); + expect(body.recommendations).to.deep.eq([constants.TEST_BIG_STRINGS_RECOMMENDATION]); }, after: async () => { expect(await repository.count()).to.eq(5); @@ -502,62 +325,13 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateNCachedScripts(11, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ - constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION, - constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - ]); + expect(body.recommendations).to.deep.eq([constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION]); }, after: async () => { await rte.data.sendCommand('script', ['flush']); expect(await repository.count()).to.eq(5); } }, - { - name: 'Should create new database analysis with useSmallerKeys recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ - constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, - ]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, ].map(mainCheckFn); }); - - // TODO - // describe('useSmallerKeys recommendation', () => { - // // generate 1M keys in cloud take a lot of time - // requirements('!rte.re'); - // [ - // { - // name: 'Should create new database analysis with useSmallerKeys recommendation', - // data: { - // delimiter: '-', - // }, - // statusCode: 201, - // responseSchema, - // before: async () => { - // const KEYS_NUMBER = 1_000_001; - // await rte.data.generateNKeys(KEYS_NUMBER, false); - // }, - // checkFn: async ({ body }) => { - // expect(body.recommendations).to.deep.eq([ - // constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - // constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, - // ]); - // }, - // after: async () => { - // expect(await repository.count()).to.eq(5); - // } - // }, - // ].map(mainCheckFn) - // }); }); From 6052cc688be057e77a59fe741ba03d5eb6dfe6c7 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 5 Dec 2022 16:00:33 +0400 Subject: [PATCH 043/201] #RI-3569 - fix big data test --- .../database-analysis/POST-databases-id-analysis.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index 6060fd92d3..a0a68d2399 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -261,13 +261,13 @@ describe('POST /databases/:instanceId/analysis', () => { statusCode: 201, responseSchema, before: async () => { - const KEYS_NUMBER = 1_000_006; - await rte.data.generateNKeys(KEYS_NUMBER, false); + const KEYS_NUMBER = 1_000_001; + // to avoid another recommendations + await rte.data.generateNGraphs(KEYS_NUMBER, false); }, checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([ constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, ]); }, after: async () => { From 796b418f30c39e00ba8a218bfb8ce2eecef0b605 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 5 Dec 2022 16:12:10 +0400 Subject: [PATCH 044/201] #RI-3569 - update test --- redisinsight/api/test/api/.mocharc.yml | 2 +- .../api/database-analysis/POST-databases-id-analysis.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/redisinsight/api/test/api/.mocharc.yml b/redisinsight/api/test/api/.mocharc.yml index 6185c06241..52bb8bfd9b 100644 --- a/redisinsight/api/test/api/.mocharc.yml +++ b/redisinsight/api/test/api/.mocharc.yml @@ -1,5 +1,5 @@ spec: - - 'test/**/*.test.ts' + - 'test/api/database-analysis/**/*.test.ts' require: 'test/api/api.deps.init.ts' timeout: 60000 exit: true diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index a0a68d2399..b2bb4b48a3 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -262,12 +262,12 @@ describe('POST /databases/:instanceId/analysis', () => { responseSchema, before: async () => { const KEYS_NUMBER = 1_000_001; - // to avoid another recommendations - await rte.data.generateNGraphs(KEYS_NUMBER, false); + await rte.data.generateHugeNumberOfTinyStringKeys(KEYS_NUMBER, false); }, checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([ constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, ]); }, after: async () => { From c47949360e2a829f2d8853c6a807ce9701383388 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 5 Dec 2022 16:29:36 +0400 Subject: [PATCH 045/201] #RI-3569 - fix cloud IT --- redisinsight/api/test/api/.mocharc.yml | 2 +- .../POST-databases-id-analysis.test.ts | 41 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/redisinsight/api/test/api/.mocharc.yml b/redisinsight/api/test/api/.mocharc.yml index 52bb8bfd9b..6185c06241 100644 --- a/redisinsight/api/test/api/.mocharc.yml +++ b/redisinsight/api/test/api/.mocharc.yml @@ -1,5 +1,5 @@ spec: - - 'test/api/database-analysis/**/*.test.ts' + - 'test/**/*.test.ts' require: 'test/api/api.deps.init.ts' timeout: 60000 exit: true diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index b2bb4b48a3..d61e696663 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -150,8 +150,10 @@ describe('POST /databases/:instanceId/analysis', () => { ].map(mainCheckFn); describe('recommendations', () => { + requirements('!rte.bigData'); + beforeEach(async () => { - await rte.data.truncate() + await rte.data.truncate(); }); [ @@ -261,8 +263,8 @@ describe('POST /databases/:instanceId/analysis', () => { statusCode: 201, responseSchema, before: async () => { - const KEYS_NUMBER = 1_000_001; - await rte.data.generateHugeNumberOfTinyStringKeys(KEYS_NUMBER, false); + const KEYS_NUMBER = 1_000_006; + await rte.data.generateNKeys(KEYS_NUMBER, false); }, checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([ @@ -333,5 +335,38 @@ describe('POST /databases/:instanceId/analysis', () => { } }, ].map(mainCheckFn); + + describe('useSmallerKeys recommendation', () => { + // generate 1M keys take a lot of time + requirements('!rte.cloud'); + + beforeEach(async () => { + await rte.data.truncate(); + }); + + [ + { + name: 'Should create new database analysis with useSmallerKeys recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const KEYS_NUMBER = 1_000_006; + await rte.data.generateNKeys(KEYS_NUMBER, false); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + ].map(mainCheckFn); + }); }); }); From 06eb860b5b62062858753a314df41966c4824a14 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 5 Dec 2022 16:50:21 +0400 Subject: [PATCH 046/201] #RI-3569 - fix cloud IT --- redisinsight/api/test/api/.mocharc.yml | 2 +- .../POST-databases-id-analysis.test.ts | 28 ++----------------- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/redisinsight/api/test/api/.mocharc.yml b/redisinsight/api/test/api/.mocharc.yml index 6185c06241..52bb8bfd9b 100644 --- a/redisinsight/api/test/api/.mocharc.yml +++ b/redisinsight/api/test/api/.mocharc.yml @@ -1,5 +1,5 @@ spec: - - 'test/**/*.test.ts' + - 'test/api/database-analysis/**/*.test.ts' require: 'test/api/api.deps.init.ts' timeout: 60000 exit: true diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index d61e696663..bef463a5a4 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -255,27 +255,6 @@ describe('POST /databases/:instanceId/analysis', () => { expect(await repository.count()).to.eq(5); } }, - { - name: 'Should create new database analysis with useSmallerKeys recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - const KEYS_NUMBER = 1_000_006; - await rte.data.generateNKeys(KEYS_NUMBER, false); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ - constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, - ]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, { name: 'Should create new database analysis with compressionForList recommendation', data: { @@ -338,11 +317,7 @@ describe('POST /databases/:instanceId/analysis', () => { describe('useSmallerKeys recommendation', () => { // generate 1M keys take a lot of time - requirements('!rte.cloud'); - - beforeEach(async () => { - await rte.data.truncate(); - }); + requirements('!rte.type=CLUSTER'); [ { @@ -353,6 +328,7 @@ describe('POST /databases/:instanceId/analysis', () => { statusCode: 201, responseSchema, before: async () => { + await rte.data.truncate(); const KEYS_NUMBER = 1_000_006; await rte.data.generateNKeys(KEYS_NUMBER, false); }, From 38b47c4d646b8aed0eb92281e28a9d9425c1bdeb Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 5 Dec 2022 17:01:52 +0400 Subject: [PATCH 047/201] #RI-3569 - remove unused code --- redisinsight/api/test/api/.mocharc.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/api/test/api/.mocharc.yml b/redisinsight/api/test/api/.mocharc.yml index 52bb8bfd9b..6185c06241 100644 --- a/redisinsight/api/test/api/.mocharc.yml +++ b/redisinsight/api/test/api/.mocharc.yml @@ -1,5 +1,5 @@ spec: - - 'test/api/database-analysis/**/*.test.ts' + - 'test/**/*.test.ts' require: 'test/api/api.deps.init.ts' timeout: 60000 exit: true From 79ed366d073e767d6563d209d5cf529c696997fc Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 6 Dec 2022 18:35:22 +0100 Subject: [PATCH 048/201] updates for recommendations --- .../e2e/pageObjects/memory-efficiency-page.ts | 4 + .../memory-efficiency/recommendations.e2e.ts | 143 +++--------------- 2 files changed, 24 insertions(+), 123 deletions(-) diff --git a/tests/e2e/pageObjects/memory-efficiency-page.ts b/tests/e2e/pageObjects/memory-efficiency-page.ts index 9b544d40ff..376172ca8c 100644 --- a/tests/e2e/pageObjects/memory-efficiency-page.ts +++ b/tests/e2e/pageObjects/memory-efficiency-page.ts @@ -7,6 +7,9 @@ export class MemoryEfficiencyPage { //*Target any element/component via data-id, if possible! //*The following categories are ordered alphabetically (Alerts, Buttons, Checkboxes, etc.). //------------------------------------------------------------------------------------------- + // CSS Selectors + cssCodeChangesLabel = '[data-testid=code_changes]'; + cssConfigurationChangesLabel = '[data-testid=configuration_changes]'; // BUTTONS newReportBtn = Selector('[data-testid=start-database-analysis-btn]'); expandArrowBtn = Selector('[data-testid^=expand-arrow-]'); @@ -17,6 +20,7 @@ export class MemoryEfficiencyPage { sortByLength = Selector('[data-testid=btn-change-table-keys]'); recommendationsTab = Selector('[data-testid=Recommendations-tab]'); luaScriptButton = Selector('[data-test-subj=luaScript-button]'); + useSmallKeysButton = Selector('[data-test-subj=useSmallerKeys-button]'); // ICONS reportTooltipIcon = Selector('[data-testid=db-new-reports-icon]'); // TEXT ELEMENTS diff --git a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts index 3957b26d5e..9ea4f2c7cd 100644 --- a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts +++ b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts @@ -5,7 +5,6 @@ import { commonUrl, ossStandaloneBigConfig, ossStandaloneConfig } from '../../.. import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; import { CliActions } from '../../../common-actions/cli-actions'; import { Common } from '../../../helpers/common'; -import { populateHashWithFields, populateSetWithMembers } from '../../../helpers/keys'; const memoryEfficiencyPage = new MemoryEfficiencyPage(); const myRedisDatabasePage = new MyRedisDatabasePage(); @@ -19,7 +18,6 @@ const externalPageLink = 'https://docs.redis.com/latest/ri/memory-optimizations/ let keyName = `recomKey-${common.generateWord(10)}`; const stringKeyName = `smallStringKey-${common.generateWord(5)}`; const index = '1'; -const dbParameters = { host: ossStandaloneConfig.host, port: ossStandaloneConfig.port }; fixture `Memory Efficiency Recommendations` .meta({ type: 'critical_path', rte: rte.standalone }) @@ -40,34 +38,39 @@ test await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneBigConfig, ossStandaloneBigConfig.databaseName); // Go to Analysis Tools page await t.click(myRedisDatabasePage.analysisPageButton); + // Add cached scripts and generate new report + await cliActions.addCachedScripts(11); await t.click(memoryEfficiencyPage.newReportBtn); + // Go to Recommendations tab + await t.click(memoryEfficiencyPage.recommendationsTab); }) .after(async() => { await cliPage.sendCommandInCli('SCRIPT FLUSH'); await deleteStandaloneDatabaseApi(ossStandaloneBigConfig); - })('Avoid dynamic Lua script recommendation', async t => { - // Go to Recommendations tab - await t.click(memoryEfficiencyPage.recommendationsTab); - // Add cached scripts and generate new report - await cliActions.addCachedScripts(10); - await t.click(memoryEfficiencyPage.newReportBtn); - // No recommendation with 10 cached scripts - await t.expect(memoryEfficiencyPage.luaScriptAccordion.exists).notOk('Avoid dynamic lua script recommendation displayed with 10 cached scripts'); - // Add the last cached script to see the recommendation - await cliActions.addCachedScripts(1); + })('Recommendations displaying', async t => { + const luaScriptCodeChangesLabel = memoryEfficiencyPage.luaScriptAccordion.parent().find(memoryEfficiencyPage.cssCodeChangesLabel); + const luaScriptConfigurationChangesLabel = memoryEfficiencyPage.luaScriptAccordion.parent().find(memoryEfficiencyPage.cssConfigurationChangesLabel); + await t.click(memoryEfficiencyPage.newReportBtn); // Verify that user can see Avoid dynamic Lua script recommendation when number_of_cached_scripts> 10 await t.expect(memoryEfficiencyPage.luaScriptAccordion.exists).ok('Avoid dynamic lua script recommendation not displayed'); + // Verify that user can see type of recommendation badge + await t.expect(luaScriptCodeChangesLabel.exists).ok('Avoid dynamic lua script recommendation not have Code Changes label'); + await t.expect(luaScriptConfigurationChangesLabel.exists).notOk('Avoid dynamic lua script recommendation have Configuration Changes label'); // Verify that user can see Use smaller keys recommendation when database has 1M+ keys await t.expect(memoryEfficiencyPage.useSmallKeysAccordion.exists).ok('Use smaller keys recommendation not displayed'); + // Verify that user can see all the recommendations expanded by default + await t.expect(memoryEfficiencyPage.luaScriptButton.getAttribute('aria-expanded')).eql('true', 'Avoid dynamic lua script recommendation not expanded'); + await t.expect(memoryEfficiencyPage.useSmallKeysButton.getAttribute('aria-expanded')).eql('true', 'Use smaller keys recommendation not expanded'); + // Verify that user can expand/collapse recommendation - const expandedTextConaiterSize = await memoryEfficiencyPage.luaScriptTextContainer.offsetHeight; + const expandedTextContaiterSize = await memoryEfficiencyPage.luaScriptTextContainer.offsetHeight; await t.click(memoryEfficiencyPage.luaScriptButton); - await t.expect(memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).lt(expandedTextConaiterSize, 'Recommendation not collapsed'); + await t.expect(memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).lt(expandedTextContaiterSize, 'Lua script recommendation not collapsed'); await t.click(memoryEfficiencyPage.luaScriptButton); - await t.expect(memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).eql(expandedTextConaiterSize, 'Recommendation not expanded'); + await t.expect(memoryEfficiencyPage.luaScriptTextContainer.offsetHeight).eql(expandedTextContaiterSize, 'Lua script recommendation not expanded'); // Verify that user can navigate by link to see the recommendation await t.click(memoryEfficiencyPage.readMoreLink); @@ -75,11 +78,9 @@ test // Close the window with external link to switch to the application window await t.closeWindow(); }); -test('Shard big hashes to small hashes recommendation', async t => { +test('No recommendations message', async t => { keyName = `recomKey-${common.generateWord(10)}`; const noRecommendationsMessage = 'No Recommendations at the moment.'; - const keyToAddParameters = { fieldsCount: 4999, keyName, fieldStartWith: 'hashField', fieldValueStartWith: 'hashValue' }; - const keyToAddParameters2 = { fieldsCount: 1, keyName, fieldStartWith: 'hashFieldLast', fieldValueStartWith: 'hashValueLast' }; const command = `HSET ${keyName} field value`; // Create Hash key and create report @@ -89,75 +90,7 @@ test('Shard big hashes to small hashes recommendation', async t => { await t.click(memoryEfficiencyPage.recommendationsTab); // No recommendations message await t.expect(memoryEfficiencyPage.noRecommendationsMessage.textContent).eql(noRecommendationsMessage, 'No recommendations message not displayed'); - - // Add 5000 fields to the hash key - await populateHashWithFields(dbParameters.host, dbParameters.port, keyToAddParameters); - // Generate new report - await t.click(memoryEfficiencyPage.newReportBtn); - // Verify that big keys recommendation not displayed when hash has 5000 fields - await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).notOk('Shard big hashes to small hashes recommendation is displayed when hash has 5000 fields'); - // Add the last field in hash key - await populateHashWithFields(dbParameters.host, dbParameters.port, keyToAddParameters2); - // Generate new report - await t.click(memoryEfficiencyPage.newReportBtn); - // Verify that user can see Shard big hashes to small hashes recommendation when Hash length > 5,000 - await t.expect(memoryEfficiencyPage.bigHashesAccordion.exists).ok('Shard big hashes to small hashes recommendation not displayed'); - await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Big hashes recommendation not have Code Changes label'); - await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Big hashes recommendation not have Configuration Changes label'); -}); - -test('Combine small strings to hashes recommendation', async t => { - keyName = `recomKey-${common.generateWord(10)}`; - const commandToAddKey = `SET ${stringKeyName} value`; - const command = `SET ${keyName} "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed accumsan lectus sed diam suscipit, eu ullamcorper ligula pulvinar."`; - - // Create small String key and create report - await cliPage.sendCommandInCli(commandToAddKey); - await t.click(memoryEfficiencyPage.newReportBtn); - // Go to Recommendations tab - await t.click(memoryEfficiencyPage.recommendationsTab); - // Verify that user can see Combine small strings to hashes recommendation when there are strings that are less than 200 bytes - await t.expect(memoryEfficiencyPage.combineStringsAccordion.exists).ok('Combine small strings to hashes recommendation not displayed for small string'); - await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Combine small strings to hashes recommendation not have Code Changes label'); - - // Add String key with more than 200 bytes - await cliPage.sendCommandInCli(command); - // Delete small String key - await cliPage.sendCommandInCli(`DEL ${stringKeyName}`); - await t.click(memoryEfficiencyPage.newReportBtn); - // Verify that user can not see recommendation when there are only strings with more than 200 bytes - await t.expect(memoryEfficiencyPage.combineStringsAccordion.exists).notOk('Combine small strings to hashes recommendation is displayed for huge string'); }); -test - .after(async t => { - // Clear and delete database - await t.click(myRedisDatabasePage.browserButton); - await browserPage.deleteKeyByName(keyName); - // Return back set-max-intset-entries value - await cliPage.sendCommandInCli('config set set-max-intset-entries 512'); - await deleteStandaloneDatabaseApi(ossStandaloneConfig); - })('Increase the set-max-intset-entries recommendation', async t => { - keyName = `recomKey-${common.generateWord(10)}`; - const commandToAddKey = `SADD ${keyName} member`; - const command = 'config set set-max-intset-entries 1024'; - const setKeyParameters = { membersCount: 512, keyName, memberStartWith: 'setMember' }; - - // Create Set with 513 members and create report - await cliPage.sendCommandInCli(commandToAddKey); - await populateSetWithMembers(dbParameters.host, dbParameters.port, setKeyParameters); - await t.click(memoryEfficiencyPage.newReportBtn); - // Go to Recommendations tab - await t.click(memoryEfficiencyPage.recommendationsTab); - // Verify that user can see Increase the set-max-intset-entries recommendation when Found sets with length > set-max-intset-entries - await t.expect(memoryEfficiencyPage.increaseSetAccordion.exists).ok('Increase the set-max-intset-entries recommendation not displayed'); - await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Increase the set-max-intset-entries recommendation not have Configuration Changes label'); - - // Change config max entries to 1024 - await cliPage.sendCommandInCli(command); - await t.click(memoryEfficiencyPage.newReportBtn); - // Verify that user can not see Increase the set-max-intset-entries recommendation when Found sets with length < set-max-intset-entries - await t.expect(memoryEfficiencyPage.increaseSetAccordion.exists).notOk('Increase the set-max-intset-entries recommendation is displayed'); - }); test .before(async t => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); @@ -176,7 +109,7 @@ test await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); await browserPage.deleteKeyByName(stringKeyName); await deleteStandaloneDatabaseApi(ossStandaloneConfig); - })('Avoid using logical databases', async t => { + })('Avoid using logical databases recommendation', async t => { // Go to Analysis Tools page await t.click(myRedisDatabasePage.analysisPageButton); await t.click(memoryEfficiencyPage.newReportBtn); @@ -186,39 +119,3 @@ test await t.expect(memoryEfficiencyPage.avoidLogicalDbAccordion.exists).ok('Avoid using logical databases recommendation not displayed'); await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Avoid using logical databases recommendation not have Code Changes label'); }); -test - .after(async t => { - // Clear and delete database - await t.click(myRedisDatabasePage.browserButton); - await browserPage.deleteKeyByName(keyName); - await cliPage.sendCommandInCli('config set hash-max-ziplist-entries 512'); - await deleteStandaloneDatabaseApi(ossStandaloneConfig); - })('Compress Hash field names', async t => { - keyName = `recomKey-${common.generateWord(10)}`; - const command = 'config set hash-max-ziplist-entries 1001'; - const commandToAddKey = `HSET ${keyName} field value`; - const hashKeyParameters = { fieldsCount: 1000, keyName, fieldStartWith: 'hashField', fieldValueStartWith: 'hashValue' }; - - // Create Hash key - await cliPage.sendCommandInCli(commandToAddKey); - // Add 1000 fields to Hash key and create report - await populateHashWithFields(dbParameters.host, dbParameters.port, hashKeyParameters); - await t.click(memoryEfficiencyPage.newReportBtn); - // Go to Recommendations tab - await t.click(memoryEfficiencyPage.recommendationsTab); - - // Verify that user can see Compress Hash field names recommendation when Hash length > 1,000 - await t.expect(memoryEfficiencyPage.compressHashAccordion.exists).ok('Compress Hash field names recommendation not displayed'); - await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Compress Hash field names recommendation not have Configuration Changes label'); - - // Convert hashtable to ziplist for hashes recommendation - // Verify that user can see Convert hashtable to ziplist for hashes recommendation when the number of hash entries exceeds hash-max-ziplist-entries - await t.expect(memoryEfficiencyPage.convertHashToZipAccordion.exists).ok('Convert hashtable to ziplist for hashes recommendation not displayed'); - await t.expect(memoryEfficiencyPage.configurationChangesLabel.exists).ok('Convert hashtable to ziplist for hashes recommendation not have Configuration Changes label'); - - // Change config max entries to 1001 - await cliPage.sendCommandInCli(command); - await t.click(memoryEfficiencyPage.newReportBtn); - // Verify that user can not see Convert hashtable to ziplist for hashes recommendation when the number of hash entries not exceeds hash-max-ziplist-entries - await t.expect(memoryEfficiencyPage.convertHashToZipAccordion.exists).notOk('Convert hashtable to ziplist for hashes recommendation is displayed'); - }); From 0c8adb405b653b1579aa49d60e4cc1f70926ecc3 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 8 Dec 2022 14:57:49 +0400 Subject: [PATCH 049/201] #RI-3563-3568 - add bigSets and zsetHashtableToZiplist recommendations --- .../api/src/constants/recommendations.ts | 4 +- .../providers/recommendation.provider.spec.ts | 71 ++++++++++++++++--- .../providers/recommendation.provider.ts | 47 +++++++++++- .../recommendation/recommendation.service.ts | 4 +- .../POST-databases-id-analysis.test.ts | 48 +++++++++++-- redisinsight/api/test/helpers/constants.ts | 12 +++- redisinsight/api/test/helpers/data/redis.ts | 38 ++++++++++ 7 files changed, 206 insertions(+), 18 deletions(-) diff --git a/redisinsight/api/src/constants/recommendations.ts b/redisinsight/api/src/constants/recommendations.ts index 42b84031c4..9aff07a24e 100644 --- a/redisinsight/api/src/constants/recommendations.ts +++ b/redisinsight/api/src/constants/recommendations.ts @@ -2,11 +2,13 @@ export const RECOMMENDATION_NAMES = Object.freeze({ LUA_SCRIPT: 'luaScript', BIG_HASHES: 'bigHashes', BIG_STRINGS: 'bigStrings', + BIG_SETS: 'bigSets', USE_SMALLER_KEYS: 'useSmallerKeys', AVOID_LOGICAL_DATABASES: 'avoidLogicalDatabases', COMBINE_SMALL_STRINGS_TO_HASHES: 'combineSmallStringsToHashes', INCREASE_SET_MAX_INTSET_ENTRIES: 'increaseSetMaxIntsetEntries', - CONVERT_HASHTABLE_TO_ZIPLIST: 'convertHashtableToZiplist', + HASH_HASHTABLE_TO_ZIPLIST: 'hashHashtableToZiplist', COMPRESS_HASH_FIELD_NAMES: 'compressHashFieldNames', COMPRESSION_FOR_LIST: 'compressionForList', + ZSET_HASHTABLE_TO_ZIPLIST: 'zSetHashtableToZiplist', }); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts index e805865ee3..02db94a93d 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -69,6 +69,14 @@ const mockBigSet = { name: Buffer.from('name'), type: 'set', length: 513, memory: 10, ttl: -1, }; +const mockHugeSet = { + name: Buffer.from('name'), type: 'set', length: 5001, memory: 10, ttl: -1, +}; + +const mockBigZSetKey = { + name: Buffer.from('name'), type: 'zset', length: 513, memory: 10, ttl: -1, +}; + const mockBigListKey = { name: Buffer.from('name'), type: 'list', length: 1001, memory: 10, ttl: -1, }; @@ -225,36 +233,36 @@ describe('RecommendationProvider', () => { }); }); - describe('determineConvertHashtableToZiplistRecommendation', () => { - it('should not return convertHashtableToZiplist recommendation', async () => { + describe('determineHashHashtableToZiplistRecommendation', () => { + it('should not return hashHashtableToZiplist recommendation', async () => { when(nodeClient.sendCommand) .calledWith(jasmine.objectContaining({ name: 'config' })) .mockResolvedValue(mockRedisConfigResponse); const convertHashtableToZiplistRecommendation = await service - .determineConvertHashtableToZiplistRecommendation(nodeClient, mockKeys); + .determineHashHashtableToZiplistRecommendation(nodeClient, mockKeys); expect(convertHashtableToZiplistRecommendation).toEqual(null); }); - it('should return convertHashtableToZiplist recommendation', async () => { + it('should return hashHashtableToZiplist recommendation', async () => { when(nodeClient.sendCommand) .calledWith(jasmine.objectContaining({ name: 'config' })) .mockResolvedValue(mockRedisConfigResponse); const convertHashtableToZiplistRecommendation = await service - .determineConvertHashtableToZiplistRecommendation(nodeClient, [...mockKeys, mockBigHashKey_3]); + .determineHashHashtableToZiplistRecommendation(nodeClient, [...mockKeys, mockBigHashKey_3]); expect(convertHashtableToZiplistRecommendation) - .toEqual({ name: RECOMMENDATION_NAMES.CONVERT_HASHTABLE_TO_ZIPLIST }); + .toEqual({ name: RECOMMENDATION_NAMES.HASH_HASHTABLE_TO_ZIPLIST }); }); - it('should not return convertHashtableToZiplist recommendation when config command executed with error', + it('should not return hashHashtableToZiplist recommendation when config command executed with error', async () => { when(nodeClient.sendCommand) .calledWith(jasmine.objectContaining({ name: 'config' })) .mockRejectedValue('some error'); const convertHashtableToZiplistRecommendation = await service - .determineConvertHashtableToZiplistRecommendation(nodeClient, mockKeys); + .determineHashHashtableToZiplistRecommendation(nodeClient, mockKeys); expect(convertHashtableToZiplistRecommendation).toEqual(null); }); }); @@ -297,4 +305,51 @@ describe('RecommendationProvider', () => { expect(bigStringsRecommendation).toEqual({ name: RECOMMENDATION_NAMES.BIG_STRINGS }); }); }); + + describe('determineZSetHashtableToZiplistRecommendation', () => { + it('should not return zSetHashtableToZiplist recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'config' })) + .mockResolvedValue(mockRedisConfigResponse); + + const zSetHashtableToZiplistRecommendation = await service + .determineZSetHashtableToZiplistRecommendation(nodeClient, mockKeys); + expect(zSetHashtableToZiplistRecommendation).toEqual(null); + }); + + it('should return zSetHashtableToZiplist recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'config' })) + .mockResolvedValue(mockRedisConfigResponse); + + const zSetHashtableToZiplistRecommendation = await service + .determineZSetHashtableToZiplistRecommendation(nodeClient, [...mockKeys, mockBigZSetKey]); + expect(zSetHashtableToZiplistRecommendation) + .toEqual({ name: RECOMMENDATION_NAMES.ZSET_HASHTABLE_TO_ZIPLIST }); + }); + + it('should not return zSetHashtableToZiplist recommendation when config command executed with error', + async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'config' })) + .mockRejectedValue('some error'); + + const zSetHashtableToZiplistRecommendation = await service + .determineZSetHashtableToZiplistRecommendation(nodeClient, mockKeys); + expect(zSetHashtableToZiplistRecommendation).toEqual(null); + }); + }); + + describe('determineBigSetsRecommendation', () => { + it('should not return bigSets recommendation', async () => { + const bigSetsRecommendation = await service + .determineBigSetsRecommendation(mockKeys); + expect(bigSetsRecommendation).toEqual(null); + }); + it('should return bigSets recommendation', async () => { + const bigSetsRecommendation = await service + .determineBigSetsRecommendation([mockHugeSet]); + expect(bigSetsRecommendation).toEqual({ name: RECOMMENDATION_NAMES.BIG_SETS }); + }); + }); }); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 9626313fa1..a94d5cf8bb 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -13,6 +13,7 @@ const maxStringMemory = 200; const maxDatabaseTotal = 1_000_000; const maxCompressHashLength = 1000; const maxListLength = 1000; +const maxSetLength = 5000; const bigStringMemory = 5_000_000; @Injectable() @@ -146,7 +147,7 @@ export class RecommendationProvider { * @param redisClient */ - async determineConvertHashtableToZiplistRecommendation( + async determineHashHashtableToZiplistRecommendation( redisClient: Redis | Cluster, keys: Key[], ): Promise { @@ -158,7 +159,7 @@ export class RecommendationProvider { ) as string[]; const hashMaxZiplistEntriesNumber = parseInt(hashMaxZiplistEntries, 10); const bigHash = keys.some((key) => key.type === RedisDataType.Hash && key.length > hashMaxZiplistEntriesNumber); - return bigHash ? { name: RECOMMENDATION_NAMES.CONVERT_HASHTABLE_TO_ZIPLIST } : null; + return bigHash ? { name: RECOMMENDATION_NAMES.HASH_HASHTABLE_TO_ZIPLIST } : null; } catch (err) { this.logger.error('Can not determine Convert hashtable to ziplist recommendation', err); return null; @@ -212,4 +213,46 @@ export class RecommendationProvider { return null; } } + + /** + * Check zSet hashtable to ziplist recommendation + * @param keys + * @param redisClient + */ + + async determineZSetHashtableToZiplistRecommendation( + redisClient: Redis | Cluster, + keys: Key[], + ): Promise { + try { + const [, zSetMaxZiplistEntries] = await redisClient.sendCommand( + new Command('config', ['get', 'zset-max-ziplist-entries'], { + replyEncoding: 'utf8', + }), + ) as string[]; + const zSetMaxZiplistEntriesNumber = parseInt(zSetMaxZiplistEntries, 10); + const bigHash = keys.some((key) => key.type === RedisDataType.ZSet && key.length > zSetMaxZiplistEntriesNumber); + return bigHash ? { name: RECOMMENDATION_NAMES.ZSET_HASHTABLE_TO_ZIPLIST } : null; + } catch (err) { + this.logger.error('Can not determine ZSet hashtable to ziplist recommendation', err); + return null; + } + } + + /** + * Check big sets recommendation + * @param keys + */ + + async determineBigSetsRecommendation( + keys: Key[], + ): Promise { + try { + const bigSet = keys.some((key) => key.type === RedisDataType.Set && key.length > maxSetLength); + return bigSet ? { name: RECOMMENDATION_NAMES.BIG_SETS } : null; + } catch (err) { + this.logger.error('Can not determine Big sets recommendation', err); + return null; + } + } } diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index 23f1e6bf8b..9b2f0fc1f2 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -41,10 +41,12 @@ export class RecommendationService { await this.recommendationProvider.determineLogicalDatabasesRecommendation(client), await this.recommendationProvider.determineCombineSmallStringsToHashesRecommendation(keys), await this.recommendationProvider.determineIncreaseSetMaxIntsetEntriesRecommendation(client, keys), - await this.recommendationProvider.determineConvertHashtableToZiplistRecommendation(client, keys), + await this.recommendationProvider.determineHashHashtableToZiplistRecommendation(client, keys), await this.recommendationProvider.determineCompressHashFieldNamesRecommendation(keys), await this.recommendationProvider.determineCompressionForListRecommendation(keys), await this.recommendationProvider.determineBigStringsRecommendation(keys), + await this.recommendationProvider.determineZSetHashtableToZiplistRecommendation(client, keys), + await this.recommendationProvider.determineBigSetsRecommendation(keys), ])); } } diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index bef463a5a4..9000f9141f 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -171,7 +171,7 @@ describe('POST /databases/:instanceId/analysis', () => { checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([ constants.TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION, - constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + constants.TEST_HASH_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, ]); }, @@ -215,7 +215,7 @@ describe('POST /databases/:instanceId/analysis', () => { } }, { - name: 'Should create new database analysis with convertHashtableToZiplist recommendation', + name: 'Should create new database analysis with hashHashtableToZiplist recommendation', data: { delimiter: '-', }, @@ -227,7 +227,7 @@ describe('POST /databases/:instanceId/analysis', () => { }, checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([ - constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + constants.TEST_HASH_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, ]); }, after: async () => { @@ -247,7 +247,7 @@ describe('POST /databases/:instanceId/analysis', () => { }, checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([ - constants.TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + constants.TEST_HASH_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, ]); }, @@ -313,6 +313,46 @@ describe('POST /databases/:instanceId/analysis', () => { expect(await repository.count()).to.eq(5); } }, + { + name: 'Should create new database analysis with zSetHashtableToZiplist recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_ZSET_MEMBERS = 129; + await rte.data.generateHugeMembersForSortedListKey(NUMBERS_OF_ZSET_MEMBERS, true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_ZSET_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with bigSets recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + const NUMBERS_OF_SET_MEMBERS = 5001; + await rte.data.generateHugeMembersSetKey(NUMBERS_OF_SET_MEMBERS, true); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_BIG_SETS_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, ].map(mainCheckFn); describe('useSmallerKeys recommendation', () => { diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index f2368f5a74..60335cfec2 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -467,8 +467,8 @@ export const constants = { TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION: { name: RECOMMENDATION_NAMES.COMBINE_SMALL_STRINGS_TO_HASHES, }, - TEST_CONVERT_HASHTABLE_TO_ZIPLIST_RECOMMENDATION: { - name: RECOMMENDATION_NAMES.CONVERT_HASHTABLE_TO_ZIPLIST, + TEST_HASH_HASHTABLE_TO_ZIPLIST_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.HASH_HASHTABLE_TO_ZIPLIST, }, TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION: { name: RECOMMENDATION_NAMES.COMPRESS_HASH_FIELD_NAMES, @@ -480,5 +480,13 @@ export const constants = { name: RECOMMENDATION_NAMES.BIG_STRINGS, }, + TEST_ZSET_HASHTABLE_TO_ZIPLIST_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.ZSET_HASHTABLE_TO_ZIPLIST, + }, + + TEST_BIG_SETS_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.BIG_SETS, + }, + // etc... } diff --git a/redisinsight/api/test/helpers/data/redis.ts b/redisinsight/api/test/helpers/data/redis.ts index 1bf6d8a60e..2691704d43 100644 --- a/redisinsight/api/test/helpers/data/redis.ts +++ b/redisinsight/api/test/helpers/data/redis.ts @@ -250,6 +250,24 @@ export const initDataHelper = (rte) => { ); }; + const generateHugeMembersSetKey = async (number: number = 100000, clean: boolean) => { + if (clean) { + await truncate(); + } + + const batchSize = 10000; + let inserted = 0; + do { + const pipeline = []; + const limit = inserted + batchSize; + for (inserted; inserted < limit && inserted < number; inserted++) { + pipeline.push(['sadd', constants.TEST_SET_KEY_1, inserted]); + } + + await insertKeysBasedOnEnv(pipeline, true); + } while (inserted < number) + }; + // ZSet const generateZSets = async (clean: boolean = false) => { if (clean) { @@ -286,6 +304,24 @@ export const initDataHelper = (rte) => { ); }; + const generateHugeMembersForSortedListKey = async (number: number = 100000, clean: boolean) => { + if (clean) { + await truncate(); + } + + const batchSize = 10000; + let inserted = 0; + do { + const pipeline = []; + const limit = inserted + batchSize; + for (inserted; inserted < limit && inserted < number; inserted++) { + pipeline.push(['zadd', constants.TEST_ZSET_KEY_1, inserted, inserted]); + } + + await insertKeysBasedOnEnv(pipeline, true); + } while (inserted < number) + }; + // Hash const generateHashes = async (clean: boolean = false) => { if (clean) { @@ -527,6 +563,8 @@ export const initDataHelper = (rte) => { generateHugeNumberOfFieldsForHashKey, generateHugeNumberOfTinyStringKeys, generateHugeElementsForListKey, + generateHugeMembersForSortedListKey, + generateHugeMembersSetKey, generateHugeStream, generateNKeys, generateRedisearchIndexes, From 8b3f4b4ce20804485fd2a33b3f49bceccb830837 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 8 Dec 2022 15:15:58 +0400 Subject: [PATCH 050/201] #RI-3563-3568 - fix IT --- .../POST-databases-id-analysis.test.ts | 4 +++- redisinsight/api/test/helpers/data/redis.ts | 19 ------------------- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index 9000f9141f..f4eb8a83cc 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -342,10 +342,12 @@ describe('POST /databases/:instanceId/analysis', () => { responseSchema, before: async () => { const NUMBERS_OF_SET_MEMBERS = 5001; - await rte.data.generateHugeMembersSetKey(NUMBERS_OF_SET_MEMBERS, true); + await rte.data.generateHugeNumberOfMembersForSetKey(NUMBERS_OF_SET_MEMBERS, true); }, checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([ + // by default max_intset_entries = 512 + constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION, constants.TEST_BIG_SETS_RECOMMENDATION, ]); }, diff --git a/redisinsight/api/test/helpers/data/redis.ts b/redisinsight/api/test/helpers/data/redis.ts index 2691704d43..d4f1bb6432 100644 --- a/redisinsight/api/test/helpers/data/redis.ts +++ b/redisinsight/api/test/helpers/data/redis.ts @@ -250,24 +250,6 @@ export const initDataHelper = (rte) => { ); }; - const generateHugeMembersSetKey = async (number: number = 100000, clean: boolean) => { - if (clean) { - await truncate(); - } - - const batchSize = 10000; - let inserted = 0; - do { - const pipeline = []; - const limit = inserted + batchSize; - for (inserted; inserted < limit && inserted < number; inserted++) { - pipeline.push(['sadd', constants.TEST_SET_KEY_1, inserted]); - } - - await insertKeysBasedOnEnv(pipeline, true); - } while (inserted < number) - }; - // ZSet const generateZSets = async (clean: boolean = false) => { if (clean) { @@ -564,7 +546,6 @@ export const initDataHelper = (rte) => { generateHugeNumberOfTinyStringKeys, generateHugeElementsForListKey, generateHugeMembersForSortedListKey, - generateHugeMembersSetKey, generateHugeStream, generateNKeys, generateRedisearchIndexes, From 78228eee3d90cfbb1bd570782a0742134ec82100 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 8 Dec 2022 15:19:16 +0400 Subject: [PATCH 051/201] #RI-3563-3568 - add bigSets and ZsetHastableToZiplist recommendations --- .../constants/dbAnalysisRecommendations.json | 158 +++++++++++++++++- .../Recommendations.spec.tsx | 34 +++- .../recommendations-view/Recommendations.tsx | 9 +- .../recommendations-view/styles.module.scss | 9 + .../components/recommendations-view/utils.tsx | 18 +- 5 files changed, 219 insertions(+), 9 deletions(-) diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index b42008ec2e..b63b8cd138 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -154,8 +154,8 @@ ], "badges": ["configuration_changes"] }, - "convertHashtableToZiplist": { - "id": "convertHashtableToZiplist", + "hashHashtableToZiplist": { + "id": "hashHashtableToZiplist", "title": "Increase hash-max-ziplist-entries", "content": [ { @@ -248,5 +248,159 @@ } ], "badges": ["configuration_changes"] + }, + "zSetHashtableToZiplist": { + "id": "zSetHashtableToZiplist", + "title": "the number of sorted set members exceeds zset-max-ziplist-entries", + "content": [ + { + "id": "1", + "type": "span", + "value": "If any value for a key exceeds zset-max-ziplist-entries, it is stored automatically as a Hashtable instead of a Ziplist, which consumes almost double the memory. So to save memory, increase the configurations and convert your hashtables to ziplist. The trade-off can be an increase in latency and possibly an increase in CPU utilization. " + }, + { + "id": "2", + "type": "link", + "value": { + "href": "https://docs.redis.com/latest/ri/memory-optimizations/", + "name": "Read more" + } + } + ], + "badges": ["configuration_changes"] + }, + "bigSets": { + "id": "bigSets", + "title": "Switch to Bloom filter, cuckoo filter, or HyperLogLog", + "content": [ + { + "id": "1", + "type": "span", + "value": "If you are using large " + }, + { + "id": "2", + "type": "link", + "value": { + "href": "https://redis.io/docs/data-types/sets/", + "name": "sets" + } + }, + { + "id": "3", + "type": "span", + "value": " to solve one of the following problems:" + }, + { + "id": "4", + "type": "list", + "value": [ + [ + { + "id": "1", + "type": "span", + "value": "Count the number of unique observations in a stream" + } + ], + [ + { + "id": "1", + "type": "span", + "value": "Check if an observation already appeared in the stream" + } + ], + [ + { + "id": "1", + "type": "span", + "value": "Find the fraction or the number of observations in the stream that are smaller or larger than a given value" + } + ] + ] + }, + { + "id": "5", + "type": "span", + "value": "and you are ready to trade accuracy with speed and memory usage, consider using one of the following probabilistic data structures:" + }, + { + "id": "6", + "type": "list", + "value": [ + [ + { + "id": "1", + "type": "link", + "value": { + "href": "https://redis.io/docs/data-types/hyperloglogs/", + "name": "HyperLogLog" + } + }, + { + "id": "2", + "type": "span", + "value": " can be used for estimating the number of unique observations in a set." + } + ], + [ + { + "id": "1", + "type": "link", + "value": { + "href": "https://redis.io/docs/stack/bloom/", + "name": "Bloom filter or cuckoo filter" + } + }, + { + "id": "2", + "type": "span", + "value": " can be used for checking if an observation has already appeared in the stream (false positive matches are possible, but false negatives are not)." + } + ], + [ + { + "id": "1", + "type": "link", + "value": { + "href": "https://redis.io/docs/stack/bloom/", + "name": "t-digest" + } + }, + { + "id": "2", + "type": "span", + "value": " can be used for estimating the fraction or the number of observations in the stream that are smaller or larger than a given value." + } + ] + ] + }, + { + "id": "5", + "type": "span", + "value": "Bloom filter and cuckoo filter require " + }, + { + "id": "6", + "type": "link", + "value": { + "href": "https://redis.com/modules/redis-bloom/", + "name": "RedisBloom" + } + }, + { + "id": "7", + "type": "span", + "value": ". " + }, + { + "id": "8", + "type": "link", + "value": { + "href": "https://docs.redis.com/latest/ri/memory-optimizations/", + "name": "Read more" + } + } + ], + "badges": ["configuration_changes"] } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 48562884de..50b7d348b6 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -137,11 +137,11 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) - it('should render code changes badge in convertHashtableToZiplist recommendation', () => { + it('should render code changes badge in hashHashtableToZiplist recommendation', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, data: { - recommendations: [{ name: 'convertHashtableToZiplist' }] + recommendations: [{ name: 'hashHashtableToZiplist' }] } })) @@ -197,6 +197,36 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) + it('should render configuration_changes badge in zSetHashtableToZiplist recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'zSetHashtableToZiplist' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).not.toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() + }) + + it('should render configuration_changes badge in bigSets recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'bigSets' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).not.toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() + }) + it('should collapse/expand', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index 4250327689..e59b7b65aa 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -9,7 +9,7 @@ import { import { dbAnalysisSelector } from 'uiSrc/slices/analytics/dbAnalysis' import recommendationsContent from 'uiSrc/constants/dbAnalysisRecommendations.json' -import { parseContent, renderBadges } from './utils' +import { renderContent, renderBadges } from './utils' import styles from './styles.module.scss' const Recommendations = () => { @@ -47,12 +47,13 @@ const Recommendations = () => { data-testId={`${id}-accordion`} > - {content.map((item: { type: string, value: any, id: string }) => + {renderContent(content)} + {/* {content.map((item: { type: string, value: any, id: string }) => ( - {parseContent(item)} + {renderContent(item)} - ))} + ))} */}
diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index 8d1b49428e..481f546141 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -33,6 +33,15 @@ margin-bottom: 6px; padding: 18px; + ul { + list-style: initial; + padding-left: 21px; + + li::marker { + color: var(--euiTextSubduedColor); + } + } + .accordion { margin-bottom: 18px; } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx index 4d6a08cbb7..ec9704942f 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx @@ -13,6 +13,11 @@ import { ReactComponent as UpgradeIcon } from 'uiSrc/assets/img/upgrade.svg' import styles from './styles.module.scss' +interface IContentElement { + type: string + value: any[] +} + const badgesContent = [ { id: 'code_changes', icon: , name: 'Code Changes' }, { id: 'configuration_changes', icon: , name: 'Configuration Changes' }, @@ -34,7 +39,7 @@ export const renderBadges = (badges: string[]) => ( ) -export const parseContent = ({ type, value }: { type: string, value: any }) => { +const renderContentElement = ({ type, value }: IContentElement) => { switch (type) { case 'paragraph': return {value} @@ -44,7 +49,18 @@ export const parseContent = ({ type, value }: { type: string, value: any }) => { return {value.name} case 'spacer': return + case 'list': + return ( +
    + {value.map((listElement: IContentElement[]) => ( +
  • {renderContent(listElement)}
  • + ))} +
+ ) default: return value } } + +export const renderContent = (elements: IContentElement[]) => ( + elements?.map((item) => renderContentElement(item))) From e34e07fbe2a5f0b22a1bcac9775a595db53163be Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 8 Dec 2022 15:40:42 +0400 Subject: [PATCH 052/201] #RI-3563-3568 - fix integration test --- .../POST-databases-id-analysis.test.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index f4eb8a83cc..023d1cf006 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -296,37 +296,41 @@ describe('POST /databases/:instanceId/analysis', () => { } }, { - name: 'Should create new database analysis with luaScript recommendation', + name: 'Should create new database analysis with zSetHashtableToZiplist recommendation', data: { delimiter: '-', }, statusCode: 201, responseSchema, before: async () => { - await rte.data.generateNCachedScripts(11, true); + const NUMBERS_OF_ZSET_MEMBERS = 129; + await rte.data.generateHugeMembersForSortedListKey(NUMBERS_OF_ZSET_MEMBERS, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION]); + expect(body.recommendations).to.deep.eq([ + constants.TEST_ZSET_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + ]); }, after: async () => { - await rte.data.sendCommand('script', ['flush']); expect(await repository.count()).to.eq(5); } }, { - name: 'Should create new database analysis with zSetHashtableToZiplist recommendation', + name: 'Should create new database analysis with bigSets recommendation', data: { delimiter: '-', }, statusCode: 201, responseSchema, before: async () => { - const NUMBERS_OF_ZSET_MEMBERS = 129; - await rte.data.generateHugeMembersForSortedListKey(NUMBERS_OF_ZSET_MEMBERS, true); + const NUMBERS_OF_SET_MEMBERS = 5001; + await rte.data.generateHugeNumberOfMembersForSetKey(NUMBERS_OF_SET_MEMBERS, true); }, checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([ - constants.TEST_ZSET_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + // by default max_intset_entries = 512 + constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION, + constants.TEST_BIG_SETS_RECOMMENDATION, ]); }, after: async () => { @@ -334,24 +338,20 @@ describe('POST /databases/:instanceId/analysis', () => { } }, { - name: 'Should create new database analysis with bigSets recommendation', + name: 'Should create new database analysis with luaScript recommendation', data: { delimiter: '-', }, statusCode: 201, responseSchema, before: async () => { - const NUMBERS_OF_SET_MEMBERS = 5001; - await rte.data.generateHugeNumberOfMembersForSetKey(NUMBERS_OF_SET_MEMBERS, true); + await rte.data.generateNCachedScripts(11, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ - // by default max_intset_entries = 512 - constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION, - constants.TEST_BIG_SETS_RECOMMENDATION, - ]); + expect(body.recommendations).to.deep.eq([constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION]); }, after: async () => { + await rte.data.sendCommand('script', ['flush']); expect(await repository.count()).to.eq(5); } }, From 254e72e4b03f7d27a887a1927629c685c4313365 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 8 Dec 2022 17:11:52 +0400 Subject: [PATCH 053/201] #RI-3563-3568 0 remove commented code --- .../components/recommendations-view/Recommendations.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index e59b7b65aa..d4be0e39d6 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -48,12 +48,6 @@ const Recommendations = () => { > {renderContent(content)} - {/* {content.map((item: { type: string, value: any, id: string }) => - ( - - {renderContent(item)} - - ))} */}
From d1bf1596521746d970044ba1ef6ee76a3d363ac8 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 8 Dec 2022 20:54:13 +0400 Subject: [PATCH 054/201] #RI-3563 - change recommendation title --- .../src/constants/dbAnalysisRecommendations.json | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index b63b8cd138..a05244ff1b 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -251,15 +251,25 @@ }, "zSetHashtableToZiplist": { "id": "zSetHashtableToZiplist", - "title": "the number of sorted set members exceeds zset-max-ziplist-entries", + "title": "Convert hashtable to ziplist for sorted sets", "content": [ { "id": "1", + "type": "paragraph", + "value": "Increase zset-max-ziplist-entries" + }, + { + "id": "2", + "type": "spacer", + "value": "l" + }, + { + "id": "3", "type": "span", "value": "If any value for a key exceeds zset-max-ziplist-entries, it is stored automatically as a Hashtable instead of a Ziplist, which consumes almost double the memory. So to save memory, increase the configurations and convert your hashtables to ziplist. The trade-off can be an increase in latency and possibly an increase in CPU utilization. " }, { - "id": "2", + "id": "4", "type": "link", "value": { "href": "https://docs.redis.com/latest/ri/memory-optimizations/", From 3948df8a4c6894e434af21b1fa4eda2685d84162 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Sun, 11 Dec 2022 12:39:09 +0400 Subject: [PATCH 055/201] #RI-3883 - add telemtry in recommendations --- .../DatabaseAnalysisTabs.spec.tsx | 71 +++++++++++++++++++ .../data-nav-tabs/DatabaseAnalysisTabs.tsx | 21 ++++++ .../Recommendations.spec.tsx | 29 +++++++- .../recommendations-view/Recommendations.tsx | 13 ++++ redisinsight/ui/src/telemetry/events.ts | 4 ++ 5 files changed, 137 insertions(+), 1 deletion(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.spec.tsx index 18dd4fceea..624fa17d32 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.spec.tsx @@ -2,12 +2,19 @@ import React from 'react' import { cloneDeep } from 'lodash' import { instance, mock } from 'ts-mockito' import { MOCK_ANALYSIS_REPORT_DATA } from 'uiSrc/mocks/data/analysis' +import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/analytics/clusterDetailsHandlers' import { render, screen, mockedStore, cleanup, fireEvent } from 'uiSrc/utils/test-utils' import { DatabaseAnalysisViewTab } from 'uiSrc/slices/interfaces/analytics' import { setDatabaseAnalysisViewTab } from 'uiSrc/slices/analytics/dbAnalysis' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import DatabaseAnalysisTabs, { Props } from './DatabaseAnalysisTabs' +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + const mockedProps = mock() const mockReports = [ @@ -80,4 +87,68 @@ describe('DatabaseAnalysisTabs', () => { expect(screen.queryByTestId(`${DatabaseAnalysisViewTab.Recommendations}-tab`)).toHaveTextContent('Recommendations') }) }) + + describe('Telemetry', () => { + it('should call DATABASE_ANALYSIS_DATA_SUMMARY_CLICKED telemetry event with 0 count', () => { + const sendEventTelemetryMock = jest.fn() + sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) + + const mockData = { + recommendations: [] + } + render() + + fireEvent.click(screen.getByTestId(`${DatabaseAnalysisViewTab.DataSummary}-tab`)) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.DATABASE_ANALYSIS_DATA_SUMMARY_CLICKED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + } + }) + sendEventTelemetry.mockRestore() + }) + + it('should call DATABASE_ANALYSIS_RECOMMENDATIONS_CLICKED telemetry event with 0 count', () => { + const sendEventTelemetryMock = jest.fn() + sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) + + const mockData = { + recommendations: [] + } + render() + + fireEvent.click(screen.getByTestId(`${DatabaseAnalysisViewTab.Recommendations}-tab`)) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_CLICKED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + recommendationsCount: 0, + } + }) + sendEventTelemetry.mockRestore() + }) + + it('should call DATABASE_ANALYSIS_RECOMMENDATIONS_CLICKED telemetry event with 2 count', () => { + const sendEventTelemetryMock = jest.fn() + sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) + + const mockData = { + recommendations: [{ name: 'luaScript' }, { name: 'luaScript' }] + } + render() + + fireEvent.click(screen.getByTestId(`${DatabaseAnalysisViewTab.Recommendations}-tab`)) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_CLICKED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + recommendationsCount: 2, + } + }) + sendEventTelemetry.mockRestore() + }) + }) }) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.tsx index b06f999fc0..6e41ba5f57 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from 'react' +import { useParams } from 'react-router-dom' import { EuiTab, EuiTabs } from '@elastic/eui' import { isNull } from 'lodash' import { useDispatch, useSelector } from 'react-redux' @@ -7,6 +8,7 @@ import { EmptyAnalysisMessage } from 'uiSrc/pages/databaseAnalysis/components' import { setDatabaseAnalysisViewTab, dbAnalysisViewTabSelector } from 'uiSrc/slices/analytics/dbAnalysis' import { DatabaseAnalysisViewTab } from 'uiSrc/slices/interfaces/analytics' import { Nullable } from 'uiSrc/utils' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { ShortDatabaseAnalysis, DatabaseAnalysis } from 'apiSrc/modules/database-analysis/models' import { databaseAnalysisTabs } from './constants' @@ -23,11 +25,30 @@ const DatabaseAnalysisTabs = (props: Props) => { const viewTab = useSelector(dbAnalysisViewTabSelector) + const { instanceId } = useParams<{ instanceId: string }>() + const dispatch = useDispatch() const selectedTabContent = useMemo(() => databaseAnalysisTabs.find((tab) => tab.id === viewTab)?.content, [viewTab]) const onSelectedTabChanged = (id: DatabaseAnalysisViewTab) => { + if (id === DatabaseAnalysisViewTab.DataSummary) { + sendEventTelemetry({ + event: TelemetryEvent.DATABASE_ANALYSIS_DATA_SUMMARY_CLICKED, + eventData: { + databaseId: instanceId, + } + }) + } + if (id === DatabaseAnalysisViewTab.Recommendations) { + sendEventTelemetry({ + event: TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_CLICKED, + eventData: { + databaseId: instanceId, + recommendationsCount: data?.recommendations?.length, + } + }) + } dispatch(setDatabaseAnalysisViewTab(id)) } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 50b7d348b6..807a847f35 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -1,11 +1,18 @@ import React from 'react' import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' import { dbAnalysisSelector } from 'uiSrc/slices/analytics/dbAnalysis' +import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/analytics/clusterDetailsHandlers' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import Recommendations from './Recommendations' const mockdbAnalysisSelector = jest.requireActual('uiSrc/slices/analytics/dbAnalysis') +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + jest.mock('uiSrc/slices/analytics/dbAnalysis', () => ({ ...jest.requireActual('uiSrc/slices/analytics/dbAnalysis'), dbAnalysisSelector: jest.fn().mockReturnValue({ @@ -227,7 +234,7 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) - it('should collapse/expand', () => { + it('should collapse/expand and sent proper telemetry event', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, data: { @@ -235,6 +242,10 @@ describe('Recommendations', () => { } })) + const sendEventTelemetryMock = jest.fn() + + sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) + const { container } = render() expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() @@ -242,9 +253,25 @@ describe('Recommendations', () => { fireEvent.click(container.querySelector('[data-test-subj="luaScript-button"]') as HTMLInputElement) expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).not.toBeTruthy() + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_COLLAPSED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + recommendation: 'luaScript', + } + }) + sendEventTelemetry.mockRestore() fireEvent.click(container.querySelector('[data-test-subj="luaScript-button"]') as HTMLInputElement) expect(screen.queryAllByTestId('luaScript-accordion')[0]?.classList.contains('euiAccordion-isOpen')).toBeTruthy() + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_EXPANDED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + recommendation: 'luaScript', + } + }) + sendEventTelemetry.mockRestore() }) }) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index d4be0e39d6..8f34b119b4 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -1,5 +1,6 @@ import React from 'react' import { useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' import { isNull } from 'lodash' import { EuiAccordion, @@ -8,6 +9,7 @@ import { } from '@elastic/eui' import { dbAnalysisSelector } from 'uiSrc/slices/analytics/dbAnalysis' import recommendationsContent from 'uiSrc/constants/dbAnalysisRecommendations.json' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { renderContent, renderBadges } from './utils' import styles from './styles.module.scss' @@ -16,6 +18,8 @@ const Recommendations = () => { const { data, loading } = useSelector(dbAnalysisSelector) const { recommendations = [] } = data ?? {} + const { instanceId } = useParams<{ instanceId: string }>() + if (loading) { return (
@@ -44,6 +48,15 @@ const Recommendations = () => { buttonProps={{ 'data-test-subj': `${id}-button` }} className={styles.accordion} initialIsOpen + onToggle={(isOpen) => sendEventTelemetry({ + event: isOpen + ? TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_EXPANDED + : TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_COLLAPSED, + eventData: { + databaseId: instanceId, + recommendation: id, + } + })} data-testId={`${id}-accordion`} > diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index ea9b2bf88f..31fb5f97ab 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -185,6 +185,10 @@ export enum TelemetryEvent { DATABASE_ANALYSIS_STARTED = 'DATABASE_ANALYSIS_STARTED', DATABASE_ANALYSIS_HISTORY_VIEWED = 'DATABASE_ANALYSIS_HISTORY_VIEWED', DATABASE_ANALYSIS_EXTRAPOLATION_CHANGED = 'DATABASE_ANALYSIS_EXTRAPOLATION_CHANGED', + DATABASE_ANALYSIS_RECOMMENDATIONS_CLICKED = 'DATABASE_ANALYSIS_RECOMMENDATIONS_CLICKED', + DATABASE_ANALYSIS_DATA_SUMMARY_CLICKED = 'DATABASE_ANALYSIS_DATA_SUMMARY_CLICKED', + DATABASE_ANALYSIS_RECOMMENDATIONS_EXPANDED = 'DATABASE_ANALYSIS_RECOMMENDATIONS_EXPANDED', + DATABASE_ANALYSIS_RECOMMENDATIONS_COLLAPSED = 'DATABASE_ANALYSIS_RECOMMENDATIONS_COLLAPSED', USER_SURVEY_LINK_CLICKED = 'USER_SURVEY_LINK_CLICKED', From e46737fc90efe0728f14575d418e3c6d52ee06de Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 12 Dec 2022 11:17:21 +0400 Subject: [PATCH 056/201] #RI-3883 - resolve comments --- .../recommendations-view/Recommendations.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index 8f34b119b4..7b42dd9686 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -20,6 +20,16 @@ const Recommendations = () => { const { instanceId } = useParams<{ instanceId: string }>() + const handleToggle = (isOpen: boolean, id: string) => sendEventTelemetry({ + event: isOpen + ? TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_EXPANDED + : TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_COLLAPSED, + eventData: { + databaseId: instanceId, + recommendation: id, + } + }) + if (loading) { return (
@@ -48,15 +58,7 @@ const Recommendations = () => { buttonProps={{ 'data-test-subj': `${id}-button` }} className={styles.accordion} initialIsOpen - onToggle={(isOpen) => sendEventTelemetry({ - event: isOpen - ? TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_EXPANDED - : TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_COLLAPSED, - eventData: { - databaseId: instanceId, - recommendation: id, - } - })} + onToggle={(isOpen) => handleToggle(isOpen, id)} data-testId={`${id}-accordion`} > From e63cf91638a15a141b616435f4a08398a8893b77 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Tue, 13 Dec 2022 10:31:17 +0400 Subject: [PATCH 057/201] #RI-3570 - add big connected clients recommendation --- .../api/src/constants/recommendations.ts | 1 + .../providers/recommendation.provider.spec.ts | 37 +++++++++++++++++++ .../providers/recommendation.provider.ts | 25 +++++++++++++ .../recommendation/recommendation.service.ts | 1 + .../constants/dbAnalysisRecommendations.json | 22 +++++++++++ .../Recommendations.spec.tsx | 15 ++++++++ 6 files changed, 101 insertions(+) diff --git a/redisinsight/api/src/constants/recommendations.ts b/redisinsight/api/src/constants/recommendations.ts index 9aff07a24e..05973a330c 100644 --- a/redisinsight/api/src/constants/recommendations.ts +++ b/redisinsight/api/src/constants/recommendations.ts @@ -3,6 +3,7 @@ export const RECOMMENDATION_NAMES = Object.freeze({ BIG_HASHES: 'bigHashes', BIG_STRINGS: 'bigStrings', BIG_SETS: 'bigSets', + BIG_CONNECTED_CLIENTS: 'bigConnectedClients', USE_SMALLER_KEYS: 'useSmallerKeys', AVOID_LOGICAL_DATABASES: 'avoidLogicalDatabases', COMBINE_SMALL_STRINGS_TO_HASHES: 'combineSmallStringsToHashes', diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts index 02db94a93d..c635e90ec3 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -18,6 +18,9 @@ const mockRedisKeyspaceInfoResponse_3: string = `# Keyspace\r\ndb0:keys=2,expire const mockRedisConfigResponse = ['name', '512']; +const mockRedisClientsResponse_1: string = '# Clients\r\nconnected_clients:100\r\n'; +const mockRedisClientsResponse_2: string = '# Clients\r\nconnected_clients:101\r\n'; + const mockKeys = [ { name: Buffer.from('name'), type: 'string', length: 10, memory: 10, ttl: -1, @@ -352,4 +355,38 @@ describe('RecommendationProvider', () => { expect(bigSetsRecommendation).toEqual({ name: RECOMMENDATION_NAMES.BIG_SETS }); }); }); + + describe('determineConnectionClientsRecommendation', () => { + it('should not return connectionClients recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockResolvedValue(mockRedisClientsResponse_1); + + const connectionClientsRecommendation = await service + .determineConnectionClientsRecommendation(nodeClient); + expect(connectionClientsRecommendation).toEqual(null); + }); + + it('should return connectionClients recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockResolvedValue(mockRedisClientsResponse_2); + + const connectionClientsRecommendation = await service + .determineConnectionClientsRecommendation(nodeClient); + expect(connectionClientsRecommendation) + .toEqual({ name: RECOMMENDATION_NAMES.BIG_CONNECTED_CLIENTS }); + }); + + it('should not return connectionClients recommendation when info command executed with error', + async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockRejectedValue('some error'); + + const connectionClientsRecommendation = await service + .determineConnectionClientsRecommendation(nodeClient); + expect(connectionClientsRecommendation).toEqual(null); + }); + }); }); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index a94d5cf8bb..f973811457 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -14,6 +14,7 @@ const maxDatabaseTotal = 1_000_000; const maxCompressHashLength = 1000; const maxListLength = 1000; const maxSetLength = 5000; +const maxConnectedClients = 100; const bigStringMemory = 5_000_000; @Injectable() @@ -255,4 +256,28 @@ export class RecommendationProvider { return null; } } + + /** + * Check big connected clients recommendation + * @param redisClient + */ + + async determineConnectionClientsRecommendation( + redisClient: Redis | Cluster, + ): Promise { + try { + const info = convertRedisInfoReplyToObject( + await redisClient.sendCommand( + new Command('info', ['clients'], { replyEncoding: 'utf8' }), + ) as string, + ); + const connectedClients = parseInt(get(info, 'clients.connected_clients'), 10); + + return connectedClients > maxConnectedClients + ? { name: RECOMMENDATION_NAMES.BIG_CONNECTED_CLIENTS } : null; + } catch (err) { + this.logger.error('Can not determine Connection clients recommendation', err); + return null; + } + } } diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index 9b2f0fc1f2..4cee7de155 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -47,6 +47,7 @@ export class RecommendationService { await this.recommendationProvider.determineBigStringsRecommendation(keys), await this.recommendationProvider.determineZSetHashtableToZiplistRecommendation(client, keys), await this.recommendationProvider.determineBigSetsRecommendation(keys), + await this.recommendationProvider.determineConnectionClientsRecommendation(client), ])); } } diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index a05244ff1b..3a2abb8225 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -412,5 +412,27 @@ } ], "badges": ["configuration_changes"] + }, + "bigConnectedClients": { + "id": "bigConnectedClients", + "title":"Don't open a new connection for every request / every command", + "content": [ + { + "id": "1", + "type": "paragraph", + "value": "When the value of your connected_clients is high, it usually means that your application is opening and closing a connection for every request it makes. Opening a connection is an expensive operation that adds to both client and server latency." + }, + { + "id": "2", + "type": "spacer", + "value": "l" + }, + { + "id": "2", + "type": "paragraph", + "value": "To rectify this, consult your Redis client’s documentation and configure it to use persistent connections." + } + ], + "badges": ["code_changes"] } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 50b7d348b6..f6d40e6c2b 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -227,6 +227,21 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) + it('should render code_changes badge in bigConnectedClients recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'bigConnectedClients' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() + }) + it('should collapse/expand', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, From 389ef2e963890e6ff27fd984f23451050c907488 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 14 Dec 2022 13:03:25 +0400 Subject: [PATCH 058/201] #RI-3570 - resolve comments --- redisinsight/api/src/constants/recommendations.ts | 2 +- .../recommendation/providers/recommendation.provider.spec.ts | 2 +- .../recommendation/providers/recommendation.provider.ts | 2 +- redisinsight/ui/src/constants/dbAnalysisRecommendations.json | 4 ++-- .../components/recommendations-view/Recommendations.spec.tsx | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/redisinsight/api/src/constants/recommendations.ts b/redisinsight/api/src/constants/recommendations.ts index 05973a330c..a728ecd6d0 100644 --- a/redisinsight/api/src/constants/recommendations.ts +++ b/redisinsight/api/src/constants/recommendations.ts @@ -3,7 +3,7 @@ export const RECOMMENDATION_NAMES = Object.freeze({ BIG_HASHES: 'bigHashes', BIG_STRINGS: 'bigStrings', BIG_SETS: 'bigSets', - BIG_CONNECTED_CLIENTS: 'bigConnectedClients', + BIG_AMOUNT_OF_CONNECTED_CLIENTS: 'bigAmountOfConnectedClients', USE_SMALLER_KEYS: 'useSmallerKeys', AVOID_LOGICAL_DATABASES: 'avoidLogicalDatabases', COMBINE_SMALL_STRINGS_TO_HASHES: 'combineSmallStringsToHashes', diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts index c635e90ec3..66bdb734b2 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -375,7 +375,7 @@ describe('RecommendationProvider', () => { const connectionClientsRecommendation = await service .determineConnectionClientsRecommendation(nodeClient); expect(connectionClientsRecommendation) - .toEqual({ name: RECOMMENDATION_NAMES.BIG_CONNECTED_CLIENTS }); + .toEqual({ name: RECOMMENDATION_NAMES.BIG_AMOUNT_OF_CONNECTED_CLIENTS }); }); it('should not return connectionClients recommendation when info command executed with error', diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index f973811457..8cb761ef5c 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -274,7 +274,7 @@ export class RecommendationProvider { const connectedClients = parseInt(get(info, 'clients.connected_clients'), 10); return connectedClients > maxConnectedClients - ? { name: RECOMMENDATION_NAMES.BIG_CONNECTED_CLIENTS } : null; + ? { name: RECOMMENDATION_NAMES.BIG_AMOUNT_OF_CONNECTED_CLIENTS } : null; } catch (err) { this.logger.error('Can not determine Connection clients recommendation', err); return null; diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 3a2abb8225..00cd5887d3 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -413,8 +413,8 @@ ], "badges": ["configuration_changes"] }, - "bigConnectedClients": { - "id": "bigConnectedClients", + "bigAmountOfConnectedClients": { + "id": "bigAmountOfConnectedClients", "title":"Don't open a new connection for every request / every command", "content": [ { diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 4965dbfe04..f984bf9791 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -234,11 +234,11 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) - it('should render code_changes badge in bigConnectedClients recommendation', () => { + it('should render code_changes badge in bigAmountOfConnectedClients recommendation', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, data: { - recommendations: [{ name: 'bigConnectedClients' }] + recommendations: [{ name: 'bigAmountOfConnectedClients' }] } })) From f7c9831ceb9a821dcedc91640260cdd9cd93c706 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 14 Dec 2022 19:52:32 +0400 Subject: [PATCH 059/201] #RI-3574 - add set password recommendation --- redisinsight/api/src/__mocks__/errors.ts | 6 ++ .../api/src/constants/recommendations.ts | 1 + .../providers/recommendation.provider.spec.ts | 66 +++++++++++++++++++ .../providers/recommendation.provider.ts | 39 +++++++++++ .../recommendation/recommendation.service.ts | 1 + .../constants/dbAnalysisRecommendations.json | 33 ++++++++++ .../Recommendations.spec.tsx | 15 +++++ 7 files changed, 161 insertions(+) diff --git a/redisinsight/api/src/__mocks__/errors.ts b/redisinsight/api/src/__mocks__/errors.ts index bd1fe1f44c..040c707e0b 100644 --- a/redisinsight/api/src/__mocks__/errors.ts +++ b/redisinsight/api/src/__mocks__/errors.ts @@ -6,6 +6,12 @@ export const mockRedisNoAuthError: ReplyError = { message: 'NOAUTH authentication is required', }; +export const mockRedisNoPasswordError: ReplyError = { + name: 'ReplyError', + command: 'AUTH', + message: 'ERR Client sent AUTH, but no password is set', +}; + export const mockRedisNoPermError: ReplyError = { name: 'ReplyError', command: 'GET', diff --git a/redisinsight/api/src/constants/recommendations.ts b/redisinsight/api/src/constants/recommendations.ts index a728ecd6d0..557b0c69f8 100644 --- a/redisinsight/api/src/constants/recommendations.ts +++ b/redisinsight/api/src/constants/recommendations.ts @@ -12,4 +12,5 @@ export const RECOMMENDATION_NAMES = Object.freeze({ COMPRESS_HASH_FIELD_NAMES: 'compressHashFieldNames', COMPRESSION_FOR_LIST: 'compressionForList', ZSET_HASHTABLE_TO_ZIPLIST: 'zSetHashtableToZiplist', + SET_PASSWORD: 'setPassword', }); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts index 66bdb734b2..6a3aa37794 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -1,6 +1,7 @@ import IORedis from 'ioredis'; import { when } from 'jest-when'; import { RECOMMENDATION_NAMES } from 'src/constants'; +import { mockRedisNoAuthError, mockRedisNoPasswordError } from 'src/__mocks__'; import { RecommendationProvider } from 'src/modules/recommendation/providers/recommendation.provider'; const nodeClient = Object.create(IORedis.prototype); @@ -21,6 +22,16 @@ const mockRedisConfigResponse = ['name', '512']; const mockRedisClientsResponse_1: string = '# Clients\r\nconnected_clients:100\r\n'; const mockRedisClientsResponse_2: string = '# Clients\r\nconnected_clients:101\r\n'; +const mockRedisAclListResponse_1: string[] = [ + 'user { expect(connectionClientsRecommendation).toEqual(null); }); }); + + describe('determineSetPasswordRecommendation', () => { + it('should not return setPassword recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'acl' })) + .mockResolvedValue(mockRedisAclListResponse_1); + + const setPasswordRecommendation = await service + .determineSetPasswordRecommendation(nodeClient); + expect(setPasswordRecommendation).toEqual(null); + }); + + it('should return setPassword recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'acl' })) + .mockResolvedValue(mockRedisAclListResponse_2); + + const setPasswordRecommendation = await service + .determineSetPasswordRecommendation(nodeClient); + expect(setPasswordRecommendation).toEqual({ name: RECOMMENDATION_NAMES.SET_PASSWORD }); + }); + + it('should not return setPassword recommendation when acl command executed with error', + async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'acl' })) + .mockRejectedValue('some error'); + + const setPasswordRecommendation = await service + .determineSetPasswordRecommendation(nodeClient); + expect(setPasswordRecommendation).toEqual(null); + }); + + it('should not return setPassword recommendation when acl command executed with error', + async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'auth' })) + .mockRejectedValue(mockRedisNoAuthError); + + const setPasswordRecommendation = await service + .determineSetPasswordRecommendation(nodeClient); + expect(setPasswordRecommendation).toEqual(null); + }); + + it('should return setPassword recommendation when acl command executed with no password error', + async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'auth' })) + .mockRejectedValue(mockRedisNoPasswordError); + + const setPasswordRecommendation = await service + .determineSetPasswordRecommendation(nodeClient); + expect(setPasswordRecommendation).toEqual({ name: RECOMMENDATION_NAMES.SET_PASSWORD }); + }); + }); }); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 8cb761ef5c..b2feb189c3 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -280,4 +280,43 @@ export class RecommendationProvider { return null; } } + + /** + * Check set password recommendation + * @param redisClient + */ + + async determineSetPasswordRecommendation( + redisClient: Redis | Cluster, + ): Promise { + if (await this.checkAuth(redisClient)) { + return { name: RECOMMENDATION_NAMES.SET_PASSWORD }; + } + + try { + const users = await redisClient.sendCommand( + new Command('acl', ['list'], { replyEncoding: 'utf8' }), + ) as string[]; + + const nopassUser = users.some((user) => user.split(' ')[3] === 'nopass'); + + return nopassUser ? { name: RECOMMENDATION_NAMES.SET_PASSWORD } : null; + } catch (err) { + this.logger.error('Can not determine set password recommendation', err); + return null; + } + } + + public async checkAuth(redisClient: Redis | Cluster): Promise { + try { + await redisClient.sendCommand( + new Command('auth', ['pass']), + ); + } catch (err) { + if (err.message.includes('Client sent AUTH, but no password is set')) { + return true; + } + } + return false; + } } diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index 4cee7de155..8c0a0ea2a1 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -48,6 +48,7 @@ export class RecommendationService { await this.recommendationProvider.determineZSetHashtableToZiplistRecommendation(client, keys), await this.recommendationProvider.determineBigSetsRecommendation(keys), await this.recommendationProvider.determineConnectionClientsRecommendation(client), + await this.recommendationProvider.determineSetPasswordRecommendation(client), ])); } } diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 00cd5887d3..589f715b93 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -434,5 +434,38 @@ } ], "badges": ["code_changes"] + }, + "setPassword": { + "id": "setPassword", + "title":"Set the password", + "content": [ + { + "id": "1", + "type": "span", + "value": "Protect your database by setting a password and using the " + }, + { + "id": "2", + "type": "link", + "value": { + "href": "https://redis.io/commands/auth/", + "name": "AUTH" + } + }, + { + "id": "3", + "type": "span", + "value": " command to authenticate the connection. " + }, + { + "id": "4", + "type": "link", + "value": { + "href": "https://redis.io/docs/management/security/", + "name": "Read more" + } + } + ], + "badges": ["configuration_changes"] } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index f984bf9791..585d160ce8 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -249,6 +249,21 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() }) + it('should render configuration_changes badge in setPassword recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'setPassword' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).not.toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() + }) + it('should collapse/expand and sent proper telemetry event', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, From e90956b7145fe23b80dd09ada5fab41e19a03e92 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 14 Dec 2022 19:55:34 +0400 Subject: [PATCH 060/201] #RI-3574 - change public to private --- .../modules/recommendation/providers/recommendation.provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index b2feb189c3..3ca1cf8a8d 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -307,7 +307,7 @@ export class RecommendationProvider { } } - public async checkAuth(redisClient: Redis | Cluster): Promise { + private async checkAuth(redisClient: Redis | Cluster): Promise { try { await redisClient.sendCommand( new Command('auth', ['pass']), From 1b4154edb33def69d74ae4172423da24cca6bb76 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 15 Dec 2022 10:55:17 +0400 Subject: [PATCH 061/201] #RI-3574 - fix IT --- redisinsight/api/test/api/.mocharc.yml | 2 +- .../POST-databases-id-analysis.test.ts | 89 +++++++++++-------- redisinsight/api/test/helpers/constants.ts | 4 + 3 files changed, 59 insertions(+), 36 deletions(-) diff --git a/redisinsight/api/test/api/.mocharc.yml b/redisinsight/api/test/api/.mocharc.yml index 6185c06241..52bb8bfd9b 100644 --- a/redisinsight/api/test/api/.mocharc.yml +++ b/redisinsight/api/test/api/.mocharc.yml @@ -1,5 +1,5 @@ spec: - - 'test/**/*.test.ts' + - 'test/api/database-analysis/**/*.test.ts' require: 'test/api/api.deps.init.ts' timeout: 60000 exit: true diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index 023d1cf006..c2c00cfff5 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -150,12 +150,43 @@ describe('POST /databases/:instanceId/analysis', () => { ].map(mainCheckFn); describe('recommendations', () => { - requirements('!rte.bigData'); + requirements('!rte.bigData', '!rte.pass'); beforeEach(async () => { await rte.data.truncate(); }); + describe('useSmallerKeys recommendation', () => { + // generate 1M keys take a lot of time + requirements('!rte.type=CLUSTER'); + + [ + { + name: 'Should create new database analysis with useSmallerKeys recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + await rte.data.truncate(); + const KEYS_NUMBER = 1_000_006; + await rte.data.generateNKeys(KEYS_NUMBER, false); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.deep.eq([ + constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, + constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, + constants.TEST_SET_PASSWORD_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + ].map(mainCheckFn); + }); + [ { name: 'Should create new database analysis with bigHashes recommendation', @@ -173,6 +204,7 @@ describe('POST /databases/:instanceId/analysis', () => { constants.TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION, constants.TEST_HASH_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, + constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -191,7 +223,10 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateHugeNumberOfMembersForSetKey(NUMBERS_OF_SET_MEMBERS, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION]); + expect(body.recommendations).to.deep.eq([ + constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION, + constants.TEST_SET_PASSWORD_RECOMMENDATION, + ]); }, after: async () => { expect(await repository.count()).to.eq(5); @@ -208,7 +243,10 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateStrings(true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION]); + expect(body.recommendations).to.deep.eq([ + constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, + constants.TEST_SET_PASSWORD_RECOMMENDATION, + ]); }, after: async () => { expect(await repository.count()).to.eq(5); @@ -228,6 +266,7 @@ describe('POST /databases/:instanceId/analysis', () => { checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([ constants.TEST_HASH_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -249,6 +288,7 @@ describe('POST /databases/:instanceId/analysis', () => { expect(body.recommendations).to.deep.eq([ constants.TEST_HASH_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, + constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -269,6 +309,7 @@ describe('POST /databases/:instanceId/analysis', () => { checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([ constants.TEST_COMPRESSION_FOR_LIST_RECOMMENDATION, + constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -289,7 +330,10 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.sendCommand('set', [constants.TEST_STRING_KEY_1, bigStringValue]); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([constants.TEST_BIG_STRINGS_RECOMMENDATION]); + expect(body.recommendations).to.deep.eq([ + constants.TEST_BIG_STRINGS_RECOMMENDATION, + constants.TEST_SET_PASSWORD_RECOMMENDATION, + ]); }, after: async () => { expect(await repository.count()).to.eq(5); @@ -309,6 +353,7 @@ describe('POST /databases/:instanceId/analysis', () => { checkFn: async ({ body }) => { expect(body.recommendations).to.deep.eq([ constants.TEST_ZSET_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, + constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -331,6 +376,7 @@ describe('POST /databases/:instanceId/analysis', () => { // by default max_intset_entries = 512 constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION, constants.TEST_BIG_SETS_RECOMMENDATION, + constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -348,7 +394,10 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateNCachedScripts(11, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION]); + expect(body.recommendations).to.deep.eq([ + constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION, + constants.TEST_SET_PASSWORD_RECOMMENDATION, + ]); }, after: async () => { await rte.data.sendCommand('script', ['flush']); @@ -356,35 +405,5 @@ describe('POST /databases/:instanceId/analysis', () => { } }, ].map(mainCheckFn); - - describe('useSmallerKeys recommendation', () => { - // generate 1M keys take a lot of time - requirements('!rte.type=CLUSTER'); - - [ - { - name: 'Should create new database analysis with useSmallerKeys recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - await rte.data.truncate(); - const KEYS_NUMBER = 1_000_006; - await rte.data.generateNKeys(KEYS_NUMBER, false); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ - constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, - constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, - ]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, - ].map(mainCheckFn); - }); }); }); diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index 60335cfec2..87087f24e6 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -488,5 +488,9 @@ export const constants = { name: RECOMMENDATION_NAMES.BIG_SETS, }, + TEST_SET_PASSWORD_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.SET_PASSWORD, + }, + // etc... } From ee095e9eb4267856193307431fa911e1b64c5dc0 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 15 Dec 2022 11:37:46 +0400 Subject: [PATCH 062/201] #RI-3574 - fix IT --- redisinsight/api/test/api/.mocharc.yml | 2 +- .../POST-databases-id-analysis.test.ts | 56 +++++++++++-------- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/redisinsight/api/test/api/.mocharc.yml b/redisinsight/api/test/api/.mocharc.yml index 52bb8bfd9b..6185c06241 100644 --- a/redisinsight/api/test/api/.mocharc.yml +++ b/redisinsight/api/test/api/.mocharc.yml @@ -1,5 +1,5 @@ spec: - - 'test/api/database-analysis/**/*.test.ts' + - 'test/**/*.test.ts' require: 'test/api/api.deps.init.ts' timeout: 60000 exit: true diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index c2c00cfff5..f57124e6b2 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -150,7 +150,7 @@ describe('POST /databases/:instanceId/analysis', () => { ].map(mainCheckFn); describe('recommendations', () => { - requirements('!rte.bigData', '!rte.pass'); + requirements('!rte.bigData'); beforeEach(async () => { await rte.data.truncate(); @@ -174,9 +174,31 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateNKeys(KEYS_NUMBER, false); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ + expect(body.recommendations).to.include.deep.members([ constants.TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION, constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + ].map(mainCheckFn); + }); + + + describe('setPassword recommendation', () => { + requirements('!rte.pass'); + [ + { + name: 'Should create new database analysis with useSmallerKeys recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + checkFn: async ({ body }) => { + expect(body.recommendations).to.include.deep.members([ constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, @@ -200,11 +222,10 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ + expect(body.recommendations).to.include.deep.members([ constants.TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION, constants.TEST_HASH_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, - constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -223,9 +244,8 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateHugeNumberOfMembersForSetKey(NUMBERS_OF_SET_MEMBERS, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ + expect(body.recommendations).to.include.deep.members([ constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION, - constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -243,9 +263,8 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateStrings(true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ + expect(body.recommendations).to.include.deep.members([ constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, - constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -264,9 +283,8 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ + expect(body.recommendations).to.include.deep.members([ constants.TEST_HASH_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, - constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -285,10 +303,9 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ + expect(body.recommendations).to.include.deep.members([ constants.TEST_HASH_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, constants.TEST_COMPRESS_HASH_FIELD_NAMES_RECOMMENDATION, - constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -307,9 +324,8 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateHugeElementsForListKey(NUMBERS_OF_LIST_ELEMENTS, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ + expect(body.recommendations).to.include.deep.members([ constants.TEST_COMPRESSION_FOR_LIST_RECOMMENDATION, - constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -330,9 +346,8 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.sendCommand('set', [constants.TEST_STRING_KEY_1, bigStringValue]); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ + expect(body.recommendations).to.include.deep.members([ constants.TEST_BIG_STRINGS_RECOMMENDATION, - constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -351,9 +366,8 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateHugeMembersForSortedListKey(NUMBERS_OF_ZSET_MEMBERS, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ + expect(body.recommendations).to.include.deep.members([ constants.TEST_ZSET_HASHTABLE_TO_ZIPLIST_RECOMMENDATION, - constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -372,11 +386,10 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateHugeNumberOfMembersForSetKey(NUMBERS_OF_SET_MEMBERS, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ + expect(body.recommendations).to.include.deep.members([ // by default max_intset_entries = 512 constants.TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION, constants.TEST_BIG_SETS_RECOMMENDATION, - constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { @@ -394,9 +407,8 @@ describe('POST /databases/:instanceId/analysis', () => { await rte.data.generateNCachedScripts(11, true); }, checkFn: async ({ body }) => { - expect(body.recommendations).to.deep.eq([ + expect(body.recommendations).to.include.deep.members([ constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION, - constants.TEST_SET_PASSWORD_RECOMMENDATION, ]); }, after: async () => { From 5cb805a3fc3bbe7af0f044f5a6397d166ae5243c Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Tue, 20 Dec 2022 17:09:05 +0400 Subject: [PATCH 063/201] #RI-3899 - change badges icon location --- .../constants/dbAnalysisRecommendations.json | 12 ++-- .../Recommendations.spec.tsx | 26 +++++++ .../recommendations-view/Recommendations.tsx | 23 ++++-- .../recommendations-view/styles.module.scss | 70 +++++++++++-------- .../components/recommendations-view/utils.tsx | 43 +++++++++--- 5 files changed, 120 insertions(+), 54 deletions(-) diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 589f715b93..f9269b8562 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -314,14 +314,14 @@ ], [ { - "id": "1", + "id": "2", "type": "span", "value": "Check if an observation already appeared in the stream" } ], [ { - "id": "1", + "id": "3", "type": "span", "value": "Find the fraction or the number of observations in the stream that are smaller or larger than a given value" } @@ -385,12 +385,12 @@ ] }, { - "id": "5", + "id": "7", "type": "span", "value": "Bloom filter and cuckoo filter require " }, { - "id": "6", + "id": "8", "type": "link", "value": { "href": "https://redis.com/modules/redis-bloom/", @@ -398,12 +398,12 @@ } }, { - "id": "7", + "id": "9", "type": "span", "value": ". " }, { - "id": "8", + "id": "10", "type": "link", "value": { "href": "https://docs.redis.com/latest/ri/memory-optimizations/", diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 585d160ce8..f386fd446e 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -304,4 +304,30 @@ describe('Recommendations', () => { }) sendEventTelemetry.mockRestore() }) + + it('should not render badges legend', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [] + } + })) + + render() + + expect(screen.queryByTestId('badges-legend')).not.toBeInTheDocument() + }) + + it('should render badges legend', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'luaScript' }] + } + })) + + render() + + expect(screen.queryByTestId('badges-legend')).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index 7b42dd9686..c8dec8bf53 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -6,12 +6,14 @@ import { EuiAccordion, EuiPanel, EuiText, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui' import { dbAnalysisSelector } from 'uiSrc/slices/analytics/dbAnalysis' import recommendationsContent from 'uiSrc/constants/dbAnalysisRecommendations.json' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { renderContent, renderBadges } from './utils' +import { renderContent, renderBadges, renderBadgesLegend } from './utils' import styles from './styles.module.scss' const Recommendations = () => { @@ -46,28 +48,37 @@ const Recommendations = () => { return (
+
+ {renderBadgesLegend()} +
{recommendations.map(({ name }) => { const { id = '', title = '', content = '', badges = [] } = recommendationsContent[name] + + const buttonContent = ( + + {title} + + {renderBadges(badges)} + + + ) return (
handleToggle(isOpen, id)} - data-testId={`${id}-accordion`} + data-testid={`${id}-accordion`} > {renderContent(content)} -
- {renderBadges(badges)} -
) })} diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index 481f546141..a1b6df58b5 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -8,6 +8,40 @@ overflow-x: hidden; max-height: 100%; padding-top: 30px; + + .badgesLegend { + margin: 0 22px 14px 0 !important; + + .badge { + margin: 0 0 0 24px; + } + + .badgeIcon { + margin-right: 14px; + } + } + + .badgeIcon { + margin-right: 24px; + fill: var(--badgeIconColor); + } + + .badgeWrapper { + display: flex; + align-items: center; + } + + .accordionButton :global(.euiFlexItem) { + margin: 0; + } + + :global(.euiAccordion__buttonReverse .euiAccordion__iconWrapper) { + margin-left: 0; + } + + :global(.euiFlexGroup--gutterLarge) { + margin: 0; + } } .container { @@ -31,7 +65,7 @@ border: 1px solid var(--recommendationBorderColor); background-color: var(--euiColorLightestShade); margin-bottom: 6px; - padding: 18px; + padding: 30px 18px; ul { list-style: initial; @@ -42,12 +76,8 @@ } } - .accordion { - margin-bottom: 18px; - } - .accordionContent { - padding: 18px 0 17px !important; + padding: 18px 0 0 !important; } :global(.euiAccordion__triggerWrapper) { @@ -62,35 +92,13 @@ border-bottom: none; } - :global(.euiAccordion .euiAccordion__triggerWrapper) { - border-bottom: 1px solid var(--separatorColor); + :global(.euiIEFlexWrapFix) { + display: block; + width: 100%; } .accordionBtn { font: normal normal 500 16px/19px Graphik, sans-serif; - padding-bottom: 22px; - } - - .badgesContainer { - .badge { - width: 180px; - margin: 14px 18px; - - &:last-child { - padding-right: 48px; - align-items: flex-end; - } - - .badgeIcon { - margin-right: 14px; - fill: var(--badgeIconColor); - } - - .badgeWrapper { - display: flex; - align-items: center; - } - } } .text { diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx index ec9704942f..d1d5fe20b4 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx @@ -1,6 +1,7 @@ import React from 'react' import { EuiTextColor, + EuiToolTip, EuiFlexGroup, EuiFlexItem, EuiLink, @@ -14,6 +15,7 @@ import { ReactComponent as UpgradeIcon } from 'uiSrc/assets/img/upgrade.svg' import styles from './styles.module.scss' interface IContentElement { + id: string type: string value: any[] } @@ -25,33 +27,52 @@ const badgesContent = [ ] export const renderBadges = (badges: string[]) => ( - - {badgesContent.map(({ id, icon, name }) => (badges.indexOf(id) === -1 - ? + + {badgesContent.map(({ id, name, icon }) => (badges.indexOf(id) === -1 + ? null : (
- {icon} - {name} + + {icon} +
)))}
) -const renderContentElement = ({ type, value }: IContentElement) => { +export const renderBadgesLegend = () => ( + + {badgesContent.map(({ id, icon, name }) => ( + +
+ {icon} + {name} +
+
+ ))} +
+) + +const renderContentElement = ({ id, type, value }: IContentElement) => { switch (type) { case 'paragraph': - return {value} + return {value} case 'span': - return {value} + return {value} case 'link': - return {value.name} + return {value.name} case 'spacer': - return + return case 'list': return ( -
    +
      {value.map((listElement: IContentElement[]) => (
    • {renderContent(listElement)}
    • ))} From 09205a5a0531ca12585f191f42f11dde24fc8e3b Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Tue, 20 Dec 2022 17:24:46 +0400 Subject: [PATCH 064/201] #RI-3899 - resolve comments --- .../recommendations-view/styles.module.scss | 6 +++- .../components/recommendations-view/utils.tsx | 30 +++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index a1b6df58b5..46ec75adb3 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -11,6 +11,10 @@ .badgesLegend { margin: 0 22px 14px 0 !important; + + .badgeWrapper { + margin-right: 0; + } .badge { margin: 0 0 0 24px; @@ -22,13 +26,13 @@ } .badgeIcon { - margin-right: 24px; fill: var(--badgeIconColor); } .badgeWrapper { display: flex; align-items: center; + margin-right: 24px; } .accordionButton :global(.euiFlexItem) { diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx index d1d5fe20b4..67e47706bb 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx @@ -28,22 +28,20 @@ const badgesContent = [ export const renderBadges = (badges: string[]) => ( - {badgesContent.map(({ id, name, icon }) => (badges.indexOf(id) === -1 - ? null - : ( - -
      - - {icon} - -
      -
      - )))} + {badgesContent.map(({ id, name, icon }) => (badges.indexOf(id) > -1 && ( + +
      + + {icon} + +
      +
      + )))}
      ) From b654af733719ffc748737bf044a6b5830922e038 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Fri, 23 Dec 2022 11:54:12 +0400 Subject: [PATCH 065/201] #RI-3942 - add rts recommendation --- .../api/src/constants/recommendations.ts | 1 + redisinsight/api/src/constants/regex.ts | 1 + .../providers/recommendation.provider.spec.ts | 74 ++++++++++++++++++- .../providers/recommendation.provider.ts | 45 ++++++++++- .../recommendation/recommendation.service.ts | 1 + .../POST-databases-id-analysis.test.ts | 21 +++++- redisinsight/api/test/helpers/constants.ts | 7 ++ 7 files changed, 146 insertions(+), 4 deletions(-) diff --git a/redisinsight/api/src/constants/recommendations.ts b/redisinsight/api/src/constants/recommendations.ts index 557b0c69f8..640cb330a9 100644 --- a/redisinsight/api/src/constants/recommendations.ts +++ b/redisinsight/api/src/constants/recommendations.ts @@ -13,4 +13,5 @@ export const RECOMMENDATION_NAMES = Object.freeze({ COMPRESSION_FOR_LIST: 'compressionForList', ZSET_HASHTABLE_TO_ZIPLIST: 'zSetHashtableToZiplist', SET_PASSWORD: 'setPassword', + RTS: 'RTS', }); diff --git a/redisinsight/api/src/constants/regex.ts b/redisinsight/api/src/constants/regex.ts index ef68cca6cc..8506d70ccd 100644 --- a/redisinsight/api/src/constants/regex.ts +++ b/redisinsight/api/src/constants/regex.ts @@ -3,3 +3,4 @@ export const IS_INTEGER_NUMBER_REGEX = /^\d+$/; export const IS_NON_PRINTABLE_ASCII_CHARACTER = /[^ -~\u0007\b\t\n\r]/; export const IP_ADDRESS_REGEX = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; export const PRIVATE_IP_ADDRESS_REGEX = /(^127\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)/; +export const IS_TIMESTAMP = /^\d{10,}$/; diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts index 6a3aa37794..f36d2a019e 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -1,5 +1,5 @@ import IORedis from 'ioredis'; -import { when } from 'jest-when'; +import { when, resetAllWhenMocks } from 'jest-when'; import { RECOMMENDATION_NAMES } from 'src/constants'; import { mockRedisNoAuthError, mockRedisNoPasswordError } from 'src/__mocks__'; import { RecommendationProvider } from 'src/modules/recommendation/providers/recommendation.provider'; @@ -26,12 +26,20 @@ const mockRedisAclListResponse_1: string[] = [ 'user { const service = new RecommendationProvider(); @@ -455,4 +469,60 @@ describe('RecommendationProvider', () => { expect(setPasswordRecommendation).toEqual({ name: RECOMMENDATION_NAMES.SET_PASSWORD }); }); }); + + describe('determineRTSRecommendation', () => { + it('should not return RTS recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'zscan' })) + .mockResolvedValue(mockZScanResponse_1); + + const RTSRecommendation = await service + .determineRTSRecommendation(nodeClient, mockKeys); + expect(RTSRecommendation).toEqual(null); + }); + + it('should return RTS recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'zscan' })) + .mockResolvedValueOnce(mockZScanResponse_1); + + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'zscan' })) + .mockResolvedValue(mockZScanResponse_2); + + const RTSRecommendation = await service + .determineRTSRecommendation(nodeClient, mockSortedSets); + expect(RTSRecommendation).toEqual({ name: RECOMMENDATION_NAMES.RTS }); + }); + + it('should not return RTS recommendation when only 101 sorted set contain timestamp', async () => { + let counter = 0; + while (counter <= 100) { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'zscan' })) + .mockResolvedValueOnce(mockZScanResponse_1); + counter += 1; + } + + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'zscan' })) + .mockResolvedValueOnce(mockZScanResponse_2); + + const RTSRecommendation = await service + .determineRTSRecommendation(nodeClient, mockSortedSets); + expect(RTSRecommendation).toEqual(null); + }); + + it('should not return RTS recommendation when zscan command executed with error', + async () => { + resetAllWhenMocks(); + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'zscan' })) + .mockRejectedValue('some error'); + + const RTSRecommendation = await service + .determineRTSRecommendation(nodeClient, mockKeys); + expect(RTSRecommendation).toEqual(null); + }); + }); }); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 3ca1cf8a8d..6de9d275ce 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { Redis, Cluster, Command } from 'ioredis'; import { get } from 'lodash'; import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/utils'; -import { RECOMMENDATION_NAMES } from 'src/constants'; +import { RECOMMENDATION_NAMES, IS_TIMESTAMP } from 'src/constants'; import { RedisDataType } from 'src/modules/browser/dto'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { Key } from 'src/modules/database-analysis/models'; @@ -16,6 +16,7 @@ const maxListLength = 1000; const maxSetLength = 5000; const maxConnectedClients = 100; const bigStringMemory = 5_000_000; +const sortedSetCountForCheck = 100; @Injectable() export class RecommendationProvider { @@ -307,6 +308,48 @@ export class RecommendationProvider { } } + /** + * Check set password recommendation + * @param redisClient + * @param keys + */ + + async determineRTSRecommendation( + redisClient: Redis | Cluster, + keys: Key[], + ): Promise { + try { + let processedKeysNumber = 0; + let isTimeSeries = false; + let sortedSetNumber = 0; + while ( + processedKeysNumber < keys.length + && !isTimeSeries + && sortedSetNumber <= sortedSetCountForCheck + ) { + if (keys[processedKeysNumber].type !== RedisDataType.ZSet) { + processedKeysNumber += 1; + } else { + const [, membersArray] = await redisClient.sendCommand( + // get first member-score pair + new Command('zscan', [keys[processedKeysNumber].name, '0', 'COUNT', 2], { replyEncoding: 'utf8' }), + ) as string[]; + // check is pair member-score is timestamp + if (IS_TIMESTAMP.test(membersArray[0]) && IS_TIMESTAMP.test(membersArray[1])) { + isTimeSeries = true; + } + processedKeysNumber += 1; + sortedSetNumber += 1; + } + } + + return isTimeSeries ? { name: RECOMMENDATION_NAMES.RTS } : null; + } catch (err) { + this.logger.error('Can not determine RTS recommendation', err); + return null; + } + } + private async checkAuth(redisClient: Redis | Cluster): Promise { try { await redisClient.sendCommand( diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index 8c0a0ea2a1..ea8351557a 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -49,6 +49,7 @@ export class RecommendationService { await this.recommendationProvider.determineBigSetsRecommendation(keys), await this.recommendationProvider.determineConnectionClientsRecommendation(client), await this.recommendationProvider.determineSetPasswordRecommendation(client), + await this.recommendationProvider.determineRTSRecommendation(client, keys), ])); } } diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index f57124e6b2..a8d1f413c7 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -219,7 +219,7 @@ describe('POST /databases/:instanceId/analysis', () => { responseSchema, before: async () => { const NUMBERS_OF_HASH_FIELDS = 5001; - await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); + await rte.data.sendCommand(NUMBERS_OF_HASH_FIELDS, true); }, checkFn: async ({ body }) => { expect(body.recommendations).to.include.deep.members([ @@ -416,6 +416,25 @@ describe('POST /databases/:instanceId/analysis', () => { expect(await repository.count()).to.eq(5); } }, + { + name: 'Should create new database analysis with RTS recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + before: async () => { + await rte.data.sendCommand('zadd', [constants.TEST_ZSET_TIMESTAMP_KEY, constants.TEST_ZSET_TIMESTAMP_MEMBER, constants.TEST_ZSET_TIMESTAMP_SCORE]); + }, + checkFn: async ({ body }) => { + expect(body.recommendations).to.include.deep.members([ + constants.TEST_RTS_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, ].map(mainCheckFn); }); }); diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index 87087f24e6..ef77aa1b32 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -186,6 +186,9 @@ export const constants = { TEST_ZSET_HUGE_KEY: 'big zset 1M', TEST_ZSET_HUGE_MEMBER: ' 356897', TEST_ZSET_HUGE_SCORE: 356897, + TEST_ZSET_TIMESTAMP_KEY: TEST_RUN_ID + '_zset_timestamp' + CLUSTER_HASH_SLOT, + TEST_ZSET_TIMESTAMP_MEMBER: '12345678910', + TEST_ZSET_TIMESTAMP_SCORE: 12345678910, TEST_ZSET_KEY_BIN_BUFFER_1: Buffer.concat([Buffer.from(TEST_RUN_ID), Buffer.from('zsetk'), unprintableBuf]), get TEST_ZSET_KEY_BIN_BUF_OBJ_1() { return { type: 'Buffer', data: [...this.TEST_ZSET_KEY_BIN_BUFFER_1] } }, get TEST_ZSET_KEY_BIN_ASCII_1() { return getASCIISafeStringFromBuffer(this.TEST_ZSET_KEY_BIN_BUFFER_1) }, @@ -492,5 +495,9 @@ export const constants = { name: RECOMMENDATION_NAMES.SET_PASSWORD, }, + TEST_RTS_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.RTS, + }, + // etc... } From 7aef85359c036d8200b4d5d1f7a1230aa8bfd101 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Fri, 23 Dec 2022 11:57:25 +0400 Subject: [PATCH 066/201] #RI-3942 - add rts recommendation --- .../constants/dbAnalysisRecommendations.json | 36 ++++++++ .../recommendations-view/Recommendations.tsx | 91 ++++++++++++------- .../recommendations-view/styles.module.scss | 6 ++ 3 files changed, 101 insertions(+), 32 deletions(-) diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index f9269b8562..9074620fca 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -467,5 +467,41 @@ } ], "badges": ["configuration_changes"] + }, + "RTS": { + "id": "RTS", + "title":"Optimize the use of time series", + "redisStack": true, + "content": [ + { + "id": "1", + "type": "paragraph", + "value": "If you are using sorted sets to work with time series data, consider using RedisTimeSeries to optimize the memory usage while having extraordinary query performance and small overhead during ingestion." + }, + { + "id": "2", + "type": "spacer", + "value": "l" + }, + { + "id": "3", + "type": "span", + "value": "Create a " + }, + { + "id": "4", + "type": "link", + "value": { + "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight/", + "name": "free Redis Stack database" + } + }, + { + "id": "5", + "type": "span", + "value": " to use modern data models and processing engines." + } + ], + "badges": ["configuration_changes"] } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index c8dec8bf53..6ff1b2796b 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -1,17 +1,22 @@ -import React from 'react' +import React, { useContext } from 'react' import { useSelector } from 'react-redux' import { useParams } from 'react-router-dom' -import { isNull } from 'lodash' +import { isNull, sortBy } from 'lodash' import { EuiAccordion, EuiPanel, EuiText, EuiFlexGroup, EuiFlexItem, + EuiIcon, } from '@elastic/eui' +import { ThemeContext } from 'uiSrc/contexts/themeContext' import { dbAnalysisSelector } from 'uiSrc/slices/analytics/dbAnalysis' import recommendationsContent from 'uiSrc/constants/dbAnalysisRecommendations.json' +import { Theme } from 'uiSrc/constants' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import RediStackDarkMin from 'uiSrc/assets/img/modules/redistack/RediStackDark-min.svg' +import RediStackLightMin from 'uiSrc/assets/img/modules/redistack/RediStackLight-min.svg' import { renderContent, renderBadges, renderBadgesLegend } from './utils' import styles from './styles.module.scss' @@ -20,6 +25,7 @@ const Recommendations = () => { const { data, loading } = useSelector(dbAnalysisSelector) const { recommendations = [] } = data ?? {} + const { theme } = useContext(ThemeContext) const { instanceId } = useParams<{ instanceId: string }>() const handleToggle = (isOpen: boolean, id: string) => sendEventTelemetry({ @@ -51,37 +57,58 @@ const Recommendations = () => {
      {renderBadgesLegend()}
      - {recommendations.map(({ name }) => { - const { id = '', title = '', content = '', badges = [] } = recommendationsContent[name] + {sortBy(recommendations, ({ name }) => + (recommendationsContent[name]?.redisStack ? -1 : 0)) + ?.map(({ name }) => { + const { + id = '', + title = '', + content = '', + badges = [], + redisStack = false + } = recommendationsContent[name] - const buttonContent = ( - - {title} - - {renderBadges(badges)} - - - ) - return ( -
      - handleToggle(isOpen, id)} - data-testid={`${id}-accordion`} - > - - {renderContent(content)} - - -
      - ) - })} + const buttonContent = ( + + + + {redisStack && ( + + )} + + + {title} + + + + {renderBadges(badges)} + + + ) + return ( +
      + handleToggle(isOpen, id)} + data-testid={`${id}-accordion`} + > + + {renderContent(content)} + + +
      + ) + })}
) } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index 46ec75adb3..32de0b16c5 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -112,4 +112,10 @@ .span { display: inline; } + + .redisStack { + width: 20px; + height: 20px; + margin-right: 16px; + } } From 93944573b240597fb0b0218bd5003e36c672c11b Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Wed, 28 Dec 2022 11:52:23 +0300 Subject: [PATCH 067/201] #RI-3964 - fix margin for modules in databases list --- .../components/DatabasesListComponent/DatabasesListWrapper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx index b19b7fe823..afd82672c6 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx @@ -270,7 +270,7 @@ const DatabasesListWrapper = ({
{({ width: columnWidth }) => ( -
+
Date: Wed, 28 Dec 2022 13:06:04 +0400 Subject: [PATCH 068/201] #RI-3942 - update link --- redisinsight/ui/src/constants/dbAnalysisRecommendations.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 9074620fca..1d419d20de 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -492,7 +492,7 @@ "id": "4", "type": "link", "value": { - "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight/", + "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight_recommendations/", "name": "free Redis Stack database" } }, From 467d1ebf933fe629ad96786f1cf271c04ddea8d9 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 28 Dec 2022 11:25:45 +0200 Subject: [PATCH 069/201] #RI-2409 switch db index. base implementation #RI-3873 enhance redis module. base implementation --- redisinsight/api/src/__mocks__/index.ts | 1 + .../api/src/__mocks__/redis-client.ts | 18 + redisinsight/api/src/__mocks__/redis.ts | 11 +- redisinsight/api/src/common/constants/api.ts | 1 + .../client-metadata.decorator.ts | 54 +- .../api/src/common/models/client-metadata.ts | 7 +- .../autodiscovery/autodiscovery.service.ts | 20 +- .../browser-client-metadata.decorator.ts | 5 +- .../browser-tool-cluster.service.ts | 4 +- .../browser-tool/browser-tool.service.ts | 4 +- .../cluster-monitor.controller.ts | 4 +- .../database/database-connection.service.ts | 10 +- .../database/database-info.controller.ts | 25 +- .../modules/database/database-info.service.ts | 33 +- .../modules/database/database.controller.ts | 1 + .../src/modules/database/database.service.ts | 19 +- .../providers/database-overview.provider.ts | 15 +- .../database/providers/database.factory.ts | 33 +- .../src/modules/pub-sub/pub-sub.controller.ts | 4 +- .../redis-sentinel/redis-sentinel.service.ts | 10 +- .../redis/redis-connection.factory.spec.ts | 205 ++++++ .../modules/redis/redis-connection.factory.ts | 330 ++++++++++ .../redis/redis-consumer.abstract.service.ts | 18 +- .../src/modules/redis/redis-tool.factory.ts | 10 +- .../src/modules/redis/redis-tool.service.ts | 4 +- .../api/src/modules/redis/redis.module.ts | 3 + .../src/modules/redis/redis.service.spec.ts | 585 +++++++++++------- .../api/src/modules/redis/redis.service.ts | 387 +++--------- .../modules/slow-log/slow-log.controller.ts | 16 +- .../workbench-client-metadata.decorator.ts | 2 +- 30 files changed, 1251 insertions(+), 588 deletions(-) create mode 100644 redisinsight/api/src/__mocks__/redis-client.ts create mode 100644 redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts create mode 100644 redisinsight/api/src/modules/redis/redis-connection.factory.ts diff --git a/redisinsight/api/src/__mocks__/index.ts b/redisinsight/api/src/__mocks__/index.ts index ef2dacddb2..fb5568e1b1 100644 --- a/redisinsight/api/src/__mocks__/index.ts +++ b/redisinsight/api/src/__mocks__/index.ts @@ -17,3 +17,4 @@ export * from './server'; export * from './redis-enterprise'; export * from './redis-sentinel'; export * from './database-import'; +export * from './redis-client'; diff --git a/redisinsight/api/src/__mocks__/redis-client.ts b/redisinsight/api/src/__mocks__/redis-client.ts new file mode 100644 index 0000000000..28c3d23f9e --- /dev/null +++ b/redisinsight/api/src/__mocks__/redis-client.ts @@ -0,0 +1,18 @@ +import { IRedisClientInstance, RedisService } from 'src/modules/redis/redis.service'; +import { mockCommonClientMetadata } from 'src/__mocks__/common'; +import { mockIORedisClient } from 'src/__mocks__/redis'; +import { ClientMetadata } from 'src/common/models'; + +export const mockRedisClientInstance: IRedisClientInstance = { + id: RedisService.generateId(mockCommonClientMetadata), + clientMetadata: mockCommonClientMetadata, + client: mockIORedisClient, + lastTimeUsed: 1619791508019, +}; + +export const generateMockRedisClientInstance = (clientMetadata: Partial): IRedisClientInstance => ({ + id: RedisService.generateId(clientMetadata as ClientMetadata), + clientMetadata: clientMetadata as ClientMetadata, + client: mockIORedisClient, + lastTimeUsed: Date.now(), +}); diff --git a/redisinsight/api/src/__mocks__/redis.ts b/redisinsight/api/src/__mocks__/redis.ts index 2cede5460b..239fcebcf6 100644 --- a/redisinsight/api/src/__mocks__/redis.ts +++ b/redisinsight/api/src/__mocks__/redis.ts @@ -70,9 +70,12 @@ export const mockRedisService = jest.fn(() => ({ }), setClientInstance: jest.fn(), isClientConnected: jest.fn().mockReturnValue(true), - connectToDatabaseInstance: jest.fn().mockResolvedValue(mockIORedisClient), - createStandaloneClient: jest.fn().mockResolvedValue(mockIORedisClient), - createSentinelClient: jest.fn().mockResolvedValue(mockIORedisSentinel), - createClusterClient: jest.fn().mockResolvedValue(mockIORedisCluster), removeClientInstance: jest.fn(), })); + +export const mockRedisConnectionFactory = jest.fn(() => ({ + createRedisConnection: jest.fn().mockResolvedValue(mockIORedisClient), + createStandaloneConnection: jest.fn().mockResolvedValue(mockIORedisClient), + createSentinelConnection: jest.fn().mockResolvedValue(mockIORedisSentinel), + createClusterConnection: jest.fn().mockResolvedValue(mockIORedisCluster), +})); diff --git a/redisinsight/api/src/common/constants/api.ts b/redisinsight/api/src/common/constants/api.ts index 5d9c060dd8..6e044bfe2c 100644 --- a/redisinsight/api/src/common/constants/api.ts +++ b/redisinsight/api/src/common/constants/api.ts @@ -1,2 +1,3 @@ export const API_PARAM_DATABASE_ID = 'dbInstance'; +export const API_HEADER_DATABASE_INDEX = 'ri-db-index'; export const API_PARAM_CLI_CLIENT_ID = 'uuid'; diff --git a/redisinsight/api/src/common/decorators/client-metadata/client-metadata.decorator.ts b/redisinsight/api/src/common/decorators/client-metadata/client-metadata.decorator.ts index 783d36c4b9..467135c047 100644 --- a/redisinsight/api/src/common/decorators/client-metadata/client-metadata.decorator.ts +++ b/redisinsight/api/src/common/decorators/client-metadata/client-metadata.decorator.ts @@ -3,7 +3,8 @@ import { plainToClass } from 'class-transformer'; import { ClientContext, ClientMetadata } from 'src/common/models'; import { sessionFromRequestFactory } from 'src/common/decorators'; import { Validator } from 'class-validator'; -import { API_PARAM_DATABASE_ID } from 'src/common/constants'; +import { API_HEADER_DATABASE_INDEX, API_PARAM_DATABASE_ID } from 'src/common/constants'; +import { ApiHeader, ApiParam } from '@nestjs/swagger'; const validator = new Validator(); @@ -11,35 +12,31 @@ export interface IClientMetadataParamOptions { databaseIdParam?: string, uniqueIdParam?: string, context?: ClientContext, + ignoreDbIndex?: boolean, } export const clientMetadataParamFactory = ( options: IClientMetadataParamOptions, ctx: ExecutionContext, ): ClientMetadata => { - const opts: IClientMetadataParamOptions = { - context: ClientContext.Common, - databaseIdParam: API_PARAM_DATABASE_ID, - ...options, - }; - const req = ctx.switchToHttp().getRequest(); let databaseId; - if (opts?.databaseIdParam) { - databaseId = req.params?.[opts.databaseIdParam]; + if (options?.databaseIdParam) { + databaseId = req.params?.[options.databaseIdParam]; } let uniqueId; - if (opts?.uniqueIdParam) { - uniqueId = req.params?.[opts.uniqueIdParam]; + if (options?.uniqueIdParam) { + uniqueId = req.params?.[options.uniqueIdParam]; } const clientMetadata = plainToClass(ClientMetadata, { session: sessionFromRequestFactory(undefined, ctx), databaseId, uniqueId, - context: opts?.context || ClientContext.Common, + context: options?.context || ClientContext.Common, + db: options?.ignoreDbIndex ? undefined : req?.headers?.[API_HEADER_DATABASE_INDEX], }); const errors = validator.validateSync(clientMetadata, { @@ -53,4 +50,35 @@ export const clientMetadataParamFactory = ( return clientMetadata; }; -export const ClientMetadataParam = createParamDecorator(clientMetadataParamFactory); +export const ClientMetadataParam = ( + options?: IClientMetadataParamOptions, +) => { + const opts: IClientMetadataParamOptions = { + context: ClientContext.Common, + databaseIdParam: API_PARAM_DATABASE_ID, + ignoreDbIndex: false, + ...options, + }; + + return createParamDecorator(clientMetadataParamFactory, [ + (target: any, key: string) => { + // Here it is. Use the `@ApiQuery` decorator purely as a function to define the meta only once here. + ApiParam({ + name: opts.databaseIdParam, + schema: { type: 'string' }, + required: true, + })(target, key, Object.getOwnPropertyDescriptor(target, key)); + if (!opts.ignoreDbIndex) { + ApiHeader({ + name: API_HEADER_DATABASE_INDEX, + schema: { + default: undefined, + type: 'number', + minimum: 0, + }, + required: false, + })(target, key, Object.getOwnPropertyDescriptor(target, key)); + } + }, + ])(opts); +}; diff --git a/redisinsight/api/src/common/models/client-metadata.ts b/redisinsight/api/src/common/models/client-metadata.ts index db9c561929..98fcc663aa 100644 --- a/redisinsight/api/src/common/models/client-metadata.ts +++ b/redisinsight/api/src/common/models/client-metadata.ts @@ -1,7 +1,7 @@ import { Session } from 'src/common/models/session'; import { Type } from 'class-transformer'; import { - IsEnum, IsNotEmpty, IsOptional, IsString, + IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, } from 'class-validator'; export enum ClientContext { @@ -27,4 +27,9 @@ export class ClientMetadata { @IsOptional() @IsString() uniqueId?: string; + + @IsOptional() + @IsNumber() + @Type(() => Number) + db?: number; } diff --git a/redisinsight/api/src/modules/autodiscovery/autodiscovery.service.ts b/redisinsight/api/src/modules/autodiscovery/autodiscovery.service.ts index 9e1937e71a..fb34f552dd 100644 --- a/redisinsight/api/src/modules/autodiscovery/autodiscovery.service.ts +++ b/redisinsight/api/src/modules/autodiscovery/autodiscovery.service.ts @@ -1,15 +1,12 @@ -import { - Injectable, Logger, OnModuleInit, -} from '@nestjs/common'; -import { RedisService } from 'src/modules/redis/redis.service'; -import { AppTool } from 'src/models'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { getAvailableEndpoints } from 'src/modules/autodiscovery/utils/autodiscovery.util'; import { convertRedisInfoReplyToObject } from 'src/utils'; import config from 'src/utils/config'; import { SettingsService } from 'src/modules/settings/settings.service'; import { Database } from 'src/modules/database/models/database'; import { DatabaseService } from 'src/modules/database/database.service'; -import { ClientContext } from 'src/common/models'; +import { ClientContext, ClientMetadata } from 'src/common/models'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; const SERVER_CONFIG = config.get('server'); @@ -19,7 +16,7 @@ export class AutodiscoveryService implements OnModuleInit { constructor( private settingsService: SettingsService, - private redisService: RedisService, + private redisConnectionFactory: RedisConnectionFactory, private databaseService: DatabaseService, ) {} @@ -73,11 +70,12 @@ export class AutodiscoveryService implements OnModuleInit { */ private async addRedisDatabase(endpoint: { host: string, port: number }) { try { - const client = await this.redisService.createStandaloneClient( + const client = await this.redisConnectionFactory.createStandaloneConnection( + { + context: ClientContext.Common, + } as ClientMetadata, endpoint as Database, - ClientContext.Common, - false, - 'redisinsight-auto-discovery', + { useRetry: false, connectionName: 'redisinsight-auto-discovery' }, ); const info = convertRedisInfoReplyToObject( diff --git a/redisinsight/api/src/modules/browser/decorators/browser-client-metadata.decorator.ts b/redisinsight/api/src/modules/browser/decorators/browser-client-metadata.decorator.ts index 999925d736..4b5abef5fd 100644 --- a/redisinsight/api/src/modules/browser/decorators/browser-client-metadata.decorator.ts +++ b/redisinsight/api/src/modules/browser/decorators/browser-client-metadata.decorator.ts @@ -1,11 +1,10 @@ import { API_PARAM_DATABASE_ID } from 'src/common/constants'; -import { createParamDecorator } from '@nestjs/common'; import { ClientContext } from 'src/common/models'; -import { clientMetadataParamFactory } from 'src/common/decorators'; +import { ClientMetadataParam } from 'src/common/decorators'; export const BrowserClientMetadata = ( databaseIdParam = API_PARAM_DATABASE_ID, -) => createParamDecorator(clientMetadataParamFactory)({ +) => ClientMetadataParam({ context: ClientContext.Browser, databaseIdParam, }); diff --git a/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.ts b/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.ts index d072018265..42d6192a3c 100644 --- a/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.ts +++ b/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.ts @@ -11,6 +11,7 @@ import ERROR_MESSAGES from 'src/constants/error-messages'; import { getRedisPipelineSummary } from 'src/utils/cli-helper'; import { getConnectionName } from 'src/utils/redis-connection-helper'; import { DatabaseService } from 'src/modules/database/database.service'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; export interface IExecCommandFromClusterNode { host: string; @@ -24,9 +25,10 @@ export class BrowserToolClusterService extends RedisConsumerAbstractService { constructor( protected redisService: RedisService, + protected redisConnectionFactory: RedisConnectionFactory, protected databaseService: DatabaseService, ) { - super(ClientContext.Browser, redisService, databaseService); + super(ClientContext.Browser, redisService, redisConnectionFactory, databaseService); } async execCommand( diff --git a/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.ts b/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.ts index 8649dfcdc6..eaf59773c6 100644 --- a/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.ts +++ b/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.ts @@ -10,6 +10,7 @@ import { getRedisPipelineSummary } from 'src/utils/cli-helper'; import { getConnectionName } from 'src/utils/redis-connection-helper'; import { DatabaseService } from 'src/modules/database/database.service'; import { ClientContext, ClientMetadata } from 'src/common/models'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; @Injectable() export class BrowserToolService extends RedisConsumerAbstractService { @@ -17,9 +18,10 @@ export class BrowserToolService extends RedisConsumerAbstractService { constructor( protected redisService: RedisService, + protected redisConnectionFactory: RedisConnectionFactory, protected databaseService: DatabaseService, ) { - super(ClientContext.Browser, redisService, databaseService); + super(ClientContext.Browser, redisService, redisConnectionFactory, databaseService); } async execCommand( diff --git a/redisinsight/api/src/modules/cluster-monitor/cluster-monitor.controller.ts b/redisinsight/api/src/modules/cluster-monitor/cluster-monitor.controller.ts index 22defd4bc7..7260ab5f6e 100644 --- a/redisinsight/api/src/modules/cluster-monitor/cluster-monitor.controller.ts +++ b/redisinsight/api/src/modules/cluster-monitor/cluster-monitor.controller.ts @@ -23,7 +23,9 @@ export class ClusterMonitorController { }) @Get() async getClusterDetails( - @ClientMetadataParam() clientMetadata: ClientMetadata, + @ClientMetadataParam({ + ignoreDbIndex: true, + }) clientMetadata: ClientMetadata, ): Promise { return this.clusterMonitorService.getClusterDetails(clientMetadata); } diff --git a/redisinsight/api/src/modules/database/database-connection.service.ts b/redisinsight/api/src/modules/database/database-connection.service.ts index e5a49a9f87..7feb9e60c9 100644 --- a/redisinsight/api/src/modules/database/database-connection.service.ts +++ b/redisinsight/api/src/modules/database/database-connection.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import * as IORedis from 'ioredis'; -import { generateRedisConnectionName, getRedisConnectionException } from 'src/utils'; +import { getRedisConnectionException } from 'src/utils'; import { DatabaseRepository } from 'src/modules/database/repositories/database.repository'; import { DatabaseAnalytics } from 'src/modules/database/database.analytics'; import { RedisService } from 'src/modules/redis/redis.service'; @@ -9,6 +9,7 @@ import { DatabaseInfoProvider } from 'src/modules/database/providers/database-in import { Database } from 'src/modules/database/models/database'; import { ConnectionType } from 'src/modules/database/entities/database.entity'; import { ClientMetadata } from 'src/common/models'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; @Injectable() export class DatabaseConnectionService { @@ -20,6 +21,7 @@ export class DatabaseConnectionService { private readonly repository: DatabaseRepository, private readonly analytics: DatabaseAnalytics, private readonly redisService: RedisService, + private readonly redisConnectionFactory: RedisConnectionFactory, ) {} /** @@ -91,13 +93,11 @@ export class DatabaseConnectionService { async createClient(clientMetadata: ClientMetadata): Promise { this.logger.log('Creating database client.'); const database = await this.databaseService.get(clientMetadata.databaseId); - const connectionName = generateRedisConnectionName(clientMetadata.context, clientMetadata.databaseId); try { - const client = await this.redisService.connectToDatabaseInstance( + const client = await this.redisConnectionFactory.createRedisConnection( + clientMetadata, database, - clientMetadata.context, - connectionName, ); if (database.connectionType === ConnectionType.NOT_CONNECTED) { diff --git a/redisinsight/api/src/modules/database/database-info.controller.ts b/redisinsight/api/src/modules/database/database-info.controller.ts index 0ae9625d4f..70dba71936 100644 --- a/redisinsight/api/src/modules/database/database-info.controller.ts +++ b/redisinsight/api/src/modules/database/database-info.controller.ts @@ -1,6 +1,6 @@ import { ApiTags } from '@nestjs/swagger'; import { - Controller, Get, UseInterceptors, + Controller, Get, Param, UseInterceptors, } from '@nestjs/common'; import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; import { TimeoutInterceptor } from 'src/common/interceptors/timeout.interceptor'; @@ -33,6 +33,7 @@ export class DatabaseInfoController { async getInfo( @ClientMetadataParam({ databaseIdParam: 'id', + ignoreDbIndex: true, }) clientMetadata: ClientMetadata, ): Promise { return this.databaseInfoService.getInfo(clientMetadata); @@ -54,8 +55,30 @@ export class DatabaseInfoController { async getDatabaseOverview( @ClientMetadataParam({ databaseIdParam: 'id', + ignoreDbIndex: false, // do not ignore db index to calculate current (selected) keys in db }) clientMetadata: ClientMetadata, ): Promise { return this.databaseInfoService.getOverview(clientMetadata); } + + @Get(':id/db/:index') + @UseInterceptors(new TimeoutInterceptor()) + @ApiEndpoint({ + description: 'Try to create connection to specified database index', + statusCode: 200, + responses: [ + { + status: 200, + }, + ], + }) + async getDatabaseIndex( + @Param('index') db: string, + @ClientMetadataParam({ + databaseIdParam: 'id', + ignoreDbIndex: true, + }) clientMetadata: ClientMetadata, + ): Promise { + return this.databaseInfoService.getDatabaseIndex(clientMetadata, db); + } } diff --git a/redisinsight/api/src/modules/database/database-info.service.ts b/redisinsight/api/src/modules/database/database-info.service.ts index 28fc3d1792..7a559af3b4 100644 --- a/redisinsight/api/src/modules/database/database-info.service.ts +++ b/redisinsight/api/src/modules/database/database-info.service.ts @@ -1,4 +1,3 @@ -import { AppTool } from 'src/models'; import { Injectable, Logger } from '@nestjs/common'; import { DatabaseConnectionService } from 'src/modules/database/database-connection.service'; import { DatabaseOverviewProvider } from 'src/modules/database/providers/database-overview.provider'; @@ -37,8 +36,36 @@ export class DatabaseInfoService { public async getOverview(clientMetadata: ClientMetadata): Promise { this.logger.log(`Getting database overview for: ${clientMetadata.databaseId}`); - const client = await this.databaseConnectionService.getOrCreateClient(clientMetadata); + const client = await this.databaseConnectionService.getOrCreateClient({ + ...clientMetadata, + db: undefined, // connect to default db index + }); + + return this.databaseOverviewProvider.getOverview(clientMetadata, client); + } + + /** + * Create connection to specified database index + * + * @param clientMetadata + * @param db + */ + public async getDatabaseIndex(clientMetadata: ClientMetadata, db: string): Promise { + this.logger.log(`Connection to database index: ${db}`); + + let client; - return this.databaseOverviewProvider.getOverview(clientMetadata.databaseId, client); + try { + client = await this.databaseConnectionService.createClient({ + ...clientMetadata, + db: parseInt(db, 10), + }); + client?.disconnect(); + return undefined; + } catch (e) { + this.logger.error(`Unable to connect to logical database: ${db}`, e); + client?.disconnect(); + throw e; + } } } diff --git a/redisinsight/api/src/modules/database/database.controller.ts b/redisinsight/api/src/modules/database/database.controller.ts index 532b512520..089d3a81ac 100644 --- a/redisinsight/api/src/modules/database/database.controller.ts +++ b/redisinsight/api/src/modules/database/database.controller.ts @@ -202,6 +202,7 @@ export class DatabaseController { async connect( @ClientMetadataParam({ databaseIdParam: 'id', + ignoreDbIndex: true, }) clientMetadata: ClientMetadata, ): Promise { await this.connectionService.connect(clientMetadata); diff --git a/redisinsight/api/src/modules/database/database.service.ts b/redisinsight/api/src/modules/database/database.service.ts index 6f5aaacb7b..bf36a555db 100644 --- a/redisinsight/api/src/modules/database/database.service.ts +++ b/redisinsight/api/src/modules/database/database.service.ts @@ -1,7 +1,7 @@ import { Injectable, InternalServerErrorException, Logger, NotFoundException, } from '@nestjs/common'; -import { sum, merge } from 'lodash'; +import { merge, sum } from 'lodash'; import { Database } from 'src/modules/database/models/database'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { DatabaseRepository } from 'src/modules/database/repositories/database.repository'; @@ -17,8 +17,9 @@ import { UpdateDatabaseDto } from 'src/modules/database/dto/update.database.dto' import { AppRedisInstanceEvents } from 'src/constants'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { DeleteDatabasesResponse } from 'src/modules/database/dto/delete.databases.response'; -import { ClientContext } from 'src/common/models'; +import { ClientContext, Session } from 'src/common/models'; import { ModifyDatabaseDto } from 'src/modules/database/dto/modify.database.dto'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; @Injectable() export class DatabaseService { @@ -27,6 +28,7 @@ export class DatabaseService { constructor( private repository: DatabaseRepository, private redisService: RedisService, + private redisConnectionFactory: RedisConnectionFactory, private databaseInfoProvider: DatabaseInfoProvider, private databaseFactory: DatabaseFactory, private analytics: DatabaseAnalytics, @@ -96,7 +98,14 @@ export class DatabaseService { // todo: clarify if we need this and if yes - rethink implementation try { - const client = await this.redisService.connectToDatabaseInstance(database, ClientContext.Common); + const client = await this.redisConnectionFactory.createRedisConnection( + { + session: {} as Session, + databaseId: database.id, + context: ClientContext.Common, + }, + database, + ); const redisInfo = await this.databaseInfoProvider.getRedisGeneralInfo(client); this.analytics.sendInstanceAddedEvent(database, redisInfo); await client.disconnect(); @@ -137,7 +146,7 @@ export class DatabaseService { database = await this.repository.update(id, database); // todo: rethink - this.redisService.removeClientInstance({ databaseId: id }); + this.redisService.removeClientInstances({ databaseId: id }); this.analytics.sendInstanceEditedEvent( oldDatabase, database, @@ -163,7 +172,7 @@ export class DatabaseService { try { await this.repository.delete(id); // todo: rethink - this.redisService.removeClientInstance({ databaseId: id }); + this.redisService.removeClientInstances({ databaseId: id }); this.logger.log('Succeed to delete database instance.'); this.analytics.sendInstanceDeletedEvent(database); diff --git a/redisinsight/api/src/modules/database/providers/database-overview.provider.ts b/redisinsight/api/src/modules/database/providers/database-overview.provider.ts index 514c89e155..f786741740 100644 --- a/redisinsight/api/src/modules/database/providers/database-overview.provider.ts +++ b/redisinsight/api/src/modules/database/providers/database-overview.provider.ts @@ -7,6 +7,7 @@ import { keyBy, sum, sumBy, + isNumber, } from 'lodash'; import { convertBulkStringsToObject, @@ -14,6 +15,7 @@ import { } from 'src/utils'; import { getTotal } from 'src/modules/database/utils/database.total.util'; import { DatabaseOverview } from 'src/modules/database/models/database-overview'; +import { ClientMetadata } from 'src/common/models'; @Injectable() export class DatabaseOverviewProvider { @@ -21,24 +23,25 @@ export class DatabaseOverviewProvider { /** * Calculates redis database metrics based on connection type (eg Cluster or Standalone) - * @param id + * @param clientMetadata * @param client */ async getOverview( - id: string, + clientMetadata: ClientMetadata, client: IORedis.Redis | IORedis.Cluster, ): Promise { let nodesInfo = []; - let currentDbIndex = 0; let totalKeys; let totalKeysPerDb; + const currentDbIndex = isNumber(clientMetadata.db) + ? clientMetadata.db + : get(client, ['options', 'db'], 0); + if (client.isCluster) { - currentDbIndex = get(client, ['options', 'db'], 0); nodesInfo = await this.getNodesInfo(client as IORedis.Cluster); totalKeys = await this.calculateNodesTotalKeys(client as IORedis.Cluster); } else { - currentDbIndex = get(client, ['options', 'db'], 0); nodesInfo = [await this.getNodeInfo(client as IORedis.Redis)]; const [calculatedTotalKeys, calculatedTotalKeysPerDb] = this.calculateTotalKeys(nodesInfo, currentDbIndex); totalKeys = calculatedTotalKeys; @@ -54,7 +57,7 @@ export class DatabaseOverviewProvider { opsPerSecond: this.calculateOpsPerSec(nodesInfo), networkInKbps: this.calculateNetworkIn(nodesInfo), networkOutKbps: this.calculateNetworkOut(nodesInfo), - cpuUsagePercentage: this.calculateCpuUsage(id, nodesInfo), + cpuUsagePercentage: this.calculateCpuUsage(clientMetadata.databaseId, nodesInfo), }; } diff --git a/redisinsight/api/src/modules/database/providers/database.factory.ts b/redisinsight/api/src/modules/database/providers/database.factory.ts index 24723f3d8a..c9316e49ab 100644 --- a/redisinsight/api/src/modules/database/providers/database.factory.ts +++ b/redisinsight/api/src/modules/database/providers/database.factory.ts @@ -3,13 +3,14 @@ import { ConnectionType } from 'src/modules/database/entities/database.entity'; import { catchRedisConnectionError, getHostingProvider } from 'src/utils'; import { Database } from 'src/modules/database/models/database'; import * as IORedis from 'ioredis'; -import { ClientContext } from 'src/common/models'; +import { ClientContext, Session } from 'src/common/models'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { RedisService } from 'src/modules/redis/redis.service'; import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider'; import { RedisErrorCodes } from 'src/constants'; import { CaCertificateService } from 'src/modules/certificate/ca-certificate.service'; import { ClientCertificateService } from 'src/modules/certificate/client-certificate.service'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; @Injectable() export class DatabaseFactory { @@ -17,6 +18,7 @@ export class DatabaseFactory { constructor( private redisService: RedisService, + private redisConnectionFactory: RedisConnectionFactory, private databaseInfoProvider: DatabaseInfoProvider, private caCertificateService: CaCertificateService, private clientCertificateService: ClientCertificateService, @@ -29,10 +31,14 @@ export class DatabaseFactory { async createDatabaseModel(database: Database): Promise { let model = await this.createStandaloneDatabaseModel(database); - const client = await this.redisService.createStandaloneClient( + const client = await this.redisConnectionFactory.createStandaloneConnection( + { + session: {} as Session, + databaseId: database.id, + context: ClientContext.Common, + }, database, - ClientContext.Common, - false, + { useRetry: false }, ); if (await this.databaseInfoProvider.isSentinel(client)) { @@ -95,9 +101,14 @@ export class DatabaseFactory { model.nodes = await this.databaseInfoProvider.determineClusterNodes(client); - const clusterClient = await this.redisService.createClusterClient( + const clusterClient = await this.redisConnectionFactory.createClusterConnection( + { + session: {} as Session, + databaseId: model.id, + context: ClientContext.Common, + }, model, - model.nodes, + { useRetry: false }, ); // todo: rethink @@ -138,10 +149,14 @@ export class DatabaseFactory { ); } - const sentinelClient = await this.redisService.createSentinelClient( + const sentinelClient = await this.redisConnectionFactory.createSentinelConnection( + { + session: {} as Session, + databaseId: model.id, + context: ClientContext.Common, + }, model, - selectedMaster.nodes, - ClientContext.Common, + { useRetry: false }, ); model.connectionType = ConnectionType.SENTINEL; diff --git a/redisinsight/api/src/modules/pub-sub/pub-sub.controller.ts b/redisinsight/api/src/modules/pub-sub/pub-sub.controller.ts index 9e2320c64e..8c72bd8367 100644 --- a/redisinsight/api/src/modules/pub-sub/pub-sub.controller.ts +++ b/redisinsight/api/src/modules/pub-sub/pub-sub.controller.ts @@ -29,7 +29,9 @@ export class PubSubController { ], }) async publish( - @ClientMetadataParam() clientMetadata: ClientMetadata, + @ClientMetadataParam({ + ignoreDbIndex: true, + }) clientMetadata: ClientMetadata, @Body() dto: PublishDto, ): Promise { return this.service.publish(clientMetadata, dto); diff --git a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts index 47736d98a8..73313ae373 100644 --- a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts +++ b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts @@ -5,13 +5,14 @@ import { CreateSentinelDatabaseResponse } from 'src/modules/redis-sentinel/dto/c import { CreateSentinelDatabasesDto } from 'src/modules/redis-sentinel/dto/create.sentinel.databases.dto'; import { RedisService } from 'src/modules/redis/redis.service'; import { Database } from 'src/modules/database/models/database'; -import { ActionStatus, ClientContext } from 'src/common/models'; +import { ActionStatus, ClientContext, Session } from 'src/common/models'; import { DatabaseService } from 'src/modules/database/database.service'; import { getRedisConnectionException } from 'src/utils'; import { SentinelMaster } from 'src/modules/redis-sentinel/models/sentinel-master'; import { RedisSentinelAnalytics } from 'src/modules/redis-sentinel/redis-sentinel.analytics'; import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider'; import { DatabaseFactory } from 'src/modules/database/providers/database.factory'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; @Injectable() export class RedisSentinelService { @@ -19,6 +20,7 @@ export class RedisSentinelService { constructor( private readonly redisService: RedisService, + private readonly redisConnectionFactory: RedisConnectionFactory, private readonly databaseService: DatabaseService, private readonly databaseFactory: DatabaseFactory, private readonly databaseInfoProvider: DatabaseInfoProvider, @@ -115,7 +117,11 @@ export class RedisSentinelService { let result: SentinelMaster[]; try { const database = await this.databaseFactory.createStandaloneDatabaseModel(dto); - const client = await this.redisService.createStandaloneClient(database, ClientContext.Common, false); + const client = await this.redisConnectionFactory.createStandaloneConnection({ + session: {} as Session, + databaseId: database.id, + context: ClientContext.Common, + }, database, { useRetry: false }); result = await this.databaseInfoProvider.determineSentinelMasterGroups(client); this.redisSentinelAnalytics.sendGetSentinelMastersSucceedEvent(result); diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts new file mode 100644 index 0000000000..4f91adc9c6 --- /dev/null +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts @@ -0,0 +1,205 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConnectionOptions } from 'tls'; +import * as Redis from 'ioredis'; +import { + generateMockRedisClientInstance, + mockCaCertificate, + mockClientCertificate, mockClientMetadata, mockClusterDatabaseWithTlsAuth, + mockDatabase, + mockDatabaseEntity, + mockIORedisClient, mockIORedisCluster, mockIORedisSentinel, + mockRedisConnectionFactory, mockSentinelDatabaseWithTlsAuth +} from 'src/__mocks__'; +import { ClientContext, Session } from 'src/common/models'; +import { RedisService } from './redis.service'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; +import { Database } from 'src/modules/database/models/database'; +// +// const mockClientMetadata = { +// session: { +// id: 'sessionId', +// }, +// }; + +const mockRedisClientInstance = { + clientMetadata: {}, + session: {}, + databaseId: mockDatabase.id, + context: ClientContext.Common, + uniqueId: undefined, + client: mockIORedisClient, + lastTimeUsed: Date.now(), +}; + +const mockTlsConfigResult: ConnectionOptions = { + rejectUnauthorized: true, + servername: mockDatabaseEntity.tlsServername, + checkServerIdentity: () => undefined, + ca: [mockCaCertificate.certificate], + key: mockClientCertificate.key, + cert: mockClientCertificate.certificate, +}; + +jest.mock('ioredis', () => ({ + ...jest.requireActual('ioredis') as object, + // default: jest.fn(), +})); + +describe('RedisConnectionFactory', () => { + let service: RedisConnectionFactory; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RedisConnectionFactory, + ], + }).compile(); + + service = await module.get(RedisConnectionFactory); + }); + + describe('createClientAutomatically', () => { + beforeEach(() => { + service.createSentinelConnection = jest.fn().mockRejectedValueOnce(new Error()); + service.createClusterConnection = jest.fn().mockRejectedValueOnce(new Error()); + service.createStandaloneConnection = jest.fn().mockRejectedValueOnce(new Error()); + }); + it('should create standalone client', async () => { + service.createStandaloneConnection = jest.fn().mockResolvedValue(mockIORedisClient); + + const result = await service.createClientAutomatically(mockClientMetadata, mockDatabase); + + expect(result).toEqual(mockIORedisClient); + expect(service.createStandaloneConnection) + .toHaveBeenCalledWith(mockClientMetadata, mockDatabase, { useRetry: true }); + }); + + it('should create cluster client', async () => { + service.createClusterConnection = jest.fn().mockResolvedValue(mockIORedisCluster); + + const result = await service.createClientAutomatically(mockClientMetadata, mockClusterDatabaseWithTlsAuth); + + expect(result).toEqual(mockIORedisCluster); + expect(service.createClusterConnection).toHaveBeenCalledWith( + mockClientMetadata, + mockClusterDatabaseWithTlsAuth, + { useRetry: true }, + ); + expect(service.createStandaloneConnection).not.toHaveBeenCalled(); + }); + + it('should create sentinel client', async () => { + service.createSentinelConnection = jest.fn().mockResolvedValue(mockIORedisSentinel); + + const result = await service.createClientAutomatically(mockClientMetadata, mockSentinelDatabaseWithTlsAuth); + + expect(result).toEqual(mockIORedisSentinel); + expect(service.createSentinelConnection).toHaveBeenCalledWith( + mockClientMetadata, + mockSentinelDatabaseWithTlsAuth, + { useRetry: true }, + ); + expect(service.createClusterConnection).not.toHaveBeenCalled(); + expect(service.createStandaloneConnection).not.toHaveBeenCalled(); + }); + }); + + describe('connectToDatabaseInstance', () => { + it('should create standalone client', async () => { + service.createStandaloneConnection = jest.fn().mockResolvedValue(mockIORedisClient); + + const result = await service.createRedisConnection(mockClientMetadata, mockDatabase); + + expect(result).toEqual(mockIORedisClient); + expect(service.createStandaloneConnection) + .toHaveBeenCalledWith(mockClientMetadata, mockDatabase, { useRetry: true }); + }); + + it('should trigger auto discovery connection type (when no connectionType defined)', async () => { + service.createClientAutomatically = jest.fn().mockResolvedValue(mockIORedisClient); + const mockDatabaseWithoutConnectionType = Object.assign(new Database(), { + ...mockDatabase, + connectionType: null, + }); + + const result = await service.createRedisConnection(mockClientMetadata, mockDatabaseWithoutConnectionType); + + expect(result).toEqual(mockIORedisClient); + expect(service.createClientAutomatically) + .toHaveBeenCalledWith( + mockClientMetadata, + { + ...mockDatabaseWithoutConnectionType, + connectionType: undefined, + }, + undefined, + ); + }); + + it('should create cluster client', async () => { + service.createClusterConnection = jest.fn().mockResolvedValue(mockIORedisCluster); + + const result = await service.createRedisConnection(mockClientMetadata, mockClusterDatabaseWithTlsAuth); + + expect(result).toEqual(mockIORedisCluster); + expect(service.createClusterConnection).toHaveBeenCalledWith( + mockClientMetadata, + mockClusterDatabaseWithTlsAuth, + { useRetry: true }, + ); + }); + + it('should create sentinel client', async () => { + service.createSentinelConnection = jest.fn().mockResolvedValue(mockIORedisSentinel); + + const result = await service.createRedisConnection(mockClientMetadata, mockSentinelDatabaseWithTlsAuth); + + expect(result).toEqual(mockIORedisSentinel); + expect(service.createSentinelConnection).toHaveBeenCalledWith( + mockClientMetadata, + mockSentinelDatabaseWithTlsAuth, + { useRetry: true }, + ); + }); + }); + + // describe('getRedisConnectionConfig', () => { + // it('should return config with tls', async () => { + // service['getTLSConfig'] = jest.fn().mockResolvedValue(mockTlsConfigResult); + // const { + // host, port, password, username, db, + // } = mockClusterDatabaseWithTlsAuth; + // + // const expectedResult = { + // host, port, username, password, db, tls: mockTlsConfigResult, + // }; + // + // const result = await service['getRedisConnectionConfig'](mockClusterDatabaseWithTlsAuth); + // + // expect(JSON.stringify(result)).toEqual(JSON.stringify(expectedResult)); + // }); + // it('should return without tls', async () => { + // const { + // host, port, password, username, db, + // } = mockDatabase; + // + // const expectedResult = { + // host, port, username, password, db, + // }; + // + // const result = await service['getRedisConnectionConfig'](mockDatabase); + // + // expect(result).toEqual(expectedResult); + // }); + // }); + // + // xdescribe('getTLSConfig', () => { + // it('should return tls config', async () => { + // const result = await service['getTLSConfig'](mockClusterDatabaseWithTlsAuth); + // + // expect(JSON.stringify(result)).toEqual( + // JSON.stringify(mockTlsConfigResult), + // ); + // }); + // }); +}); diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.ts new file mode 100644 index 0000000000..d3197ae32d --- /dev/null +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.ts @@ -0,0 +1,330 @@ +import Redis, { Cluster, RedisOptions } from 'ioredis'; +import { Injectable, Logger } from '@nestjs/common'; +import { Database } from 'src/modules/database/models/database'; +import apiConfig from 'src/utils/config'; +import { ConnectionOptions } from 'tls'; +import { isEmpty, isNumber } from 'lodash'; +import { cloneClassInstance, generateRedisConnectionName } from 'src/utils'; +import { ConnectionType } from 'src/modules/database/entities/database.entity'; +import { ClientMetadata } from 'src/common/models'; +import { ClusterOptions } from 'ioredis/built/cluster/ClusterOptions'; + +const REDIS_CLIENTS_CONFIG = apiConfig.get('redis_clients'); + +export interface IRedisConnectionOptions { + useRetry?: boolean, + connectionName?: string, +} + +@Injectable() +export class RedisConnectionFactory { + private logger = new Logger('RedisConnectionFactory'); + + // default retry strategy + private retryStrategy = (times: number): number => { + if (times < REDIS_CLIENTS_CONFIG.retryTimes) { + return Math.min(times * REDIS_CLIENTS_CONFIG.retryDelay, 2000); + } + return undefined; + }; + + /** + * Normalize data to be compatible with used redis connection library + * @param clientMetadata + * @param database + * @param options + * @private + */ + private async getRedisOptions( + clientMetadata: ClientMetadata, + database: Database, + options: IRedisConnectionOptions, + ): Promise { + const { + host, port, password, username, tls, db, + } = database; + const redisOptions: RedisOptions = { + host, + port, + username, + password, + db: isNumber(clientMetadata.db) ? clientMetadata.db : db, + connectionName: options?.connectionName + || generateRedisConnectionName(clientMetadata.context, clientMetadata.databaseId), + showFriendlyErrorStack: true, + maxRetriesPerRequest: REDIS_CLIENTS_CONFIG.maxRetriesPerRequest, + retryStrategy: options?.useRetry ? this.retryStrategy : () => undefined, + }; + + if (tls) { + redisOptions.tls = await this.getTLSConfig(database); + } + + return redisOptions; + } + + /** + * Normalize data to be compatible with redis library cluster connection options + * @param clientMetadata + * @param database + * @param options + * @private + */ + private async getRedisClusterOptions( + clientMetadata: ClientMetadata, + database: Database, + options: IRedisConnectionOptions, + ): Promise { + return { + clusterRetryStrategy: options.useRetry ? this.retryStrategy : () => undefined, + redisOptions: await this.getRedisOptions(clientMetadata, database, options), + }; + } + + /** + * Normalize data to be compatible with redis library sentinel connection options + * @param clientMetadata + * @param database + * @param options + * @private + */ + private async getRedisSentinelOptions( + clientMetadata: ClientMetadata, + database: Database, + options: IRedisConnectionOptions, + ): Promise { + const { sentinelMaster } = database; + + const baseOptions = await this.getRedisOptions(clientMetadata, database, options); + return { + ...baseOptions, + sentinels: database.nodes, + name: sentinelMaster?.name, + sentinelUsername: database.username, + sentinelPassword: database.password, + username: sentinelMaster?.username, + password: sentinelMaster?.password, + sentinelTLS: baseOptions.tls, + enableTLSForSentinelMode: !!baseOptions.tls, // previously was always `true` for tls connections + sentinelRetryStrategy: options?.useRetry ? this.retryStrategy : () => undefined, + }; + } + + /** + * Normalize tls settings to be compatible with user redis connection library + * @param database + * @private + */ + private async getTLSConfig(database: Database): Promise { + let config: ConnectionOptions; + config = { + rejectUnauthorized: database.verifyServerCert, + checkServerIdentity: () => undefined, + servername: database.tlsServername || undefined, + }; + if (database.caCert) { + config = { + ...config, + ca: [database.caCert.certificate], + }; + } + if (database.clientCert) { + config = { + ...config, + cert: database.clientCert.certificate, + key: database.clientCert.key, + }; + } + + return config; + } + + /** + * Try to create standalone redis connection + * @param clientMetadata + * @param database + * @param options + */ + public async createStandaloneConnection( + clientMetadata: ClientMetadata, + database: Database, + options: IRedisConnectionOptions, + ): Promise { + const config = await this.getRedisOptions(clientMetadata, database, options); + + return await new Promise((resolve, reject) => { + try { + const connection = new Redis({ + ...config, + // cover cases when we are connecting to sentinel as to standalone to discover master groups + db: config.db > 0 && !database.sentinelMaster ? config.db : 0, + }); + connection.on('error', (e): void => { + this.logger.error('Failed connection to the redis database.', e); + reject(e); + }); + connection.on('ready', (): void => { + this.logger.log('Successfully connected to the redis database'); + resolve(connection); + }); + connection.on('reconnecting', (): void => { + this.logger.log('Reconnecting to the redis database'); + }); + } catch (e) { + reject(e); + } + }) as Redis; + } + + /** + * Try to create redis cluster connection + * @param clientMetadata + * @param database + * @param options + */ + public async createClusterConnection( + clientMetadata: ClientMetadata, + database: Database, + options: IRedisConnectionOptions, + ): Promise { + const config = await this.getRedisClusterOptions(clientMetadata, database, options); + return new Promise((resolve, reject) => { + try { + const cluster = new Redis.Cluster([{ + host: database.host, + port: database.port, + }].concat(database.nodes), { + ...config, + }); + cluster.on('error', (e): void => { + this.logger.error('Failed connection to the redis oss cluster', e); + reject(!isEmpty(e.lastNodeError) ? e.lastNodeError : e); + }); + cluster.on('ready', (): void => { + this.logger.log('Successfully connected to the redis oss cluster.'); + resolve(cluster); + }); + } catch (e) { + reject(e); + } + }); + } + + /** + * Try to create redis sentinel connection + * @param clientMetadata + * @param database + * @param options + */ + public async createSentinelConnection( + clientMetadata: ClientMetadata, + database: Database, + options: IRedisConnectionOptions, + ): Promise { + const config = await this.getRedisSentinelOptions(clientMetadata, database, options); + + return new Promise((resolve, reject) => { + try { + const client = new Redis(config); + client.on('error', (e): void => { + this.logger.error('Failed connection to the redis oss sentinel', e); + reject(e); + }); + client.on('ready', (): void => { + console.log('\n\n\n\n\n!!!READY!!!!\n\n\n\n', client.options); + this.logger.log('Successfully connected to the redis oss sentinel.'); + resolve(client); + }); + } catch (e) { + reject(e); + } + }); + } + + /** + * Based on data fields (except connectionType) will try to cte connection of proper type + * @param clientMetadata + * @param database + * @param connectionName + */ + public async createClientAutomatically( + clientMetadata: ClientMetadata, + database: Database, + connectionName?, + ) { + // try sentinel connection + if (database?.sentinelMaster) { + try { + return await this.createSentinelConnection(clientMetadata, database, { + useRetry: true, + connectionName, + }); + } catch (e) { + // ignore error + } + } + + // try cluster connection + try { + return await this.createClusterConnection(clientMetadata, database, { + useRetry: true, + connectionName, + }); + } catch (e) { + // ignore error + } + + // Standalone in any other case + return this.createStandaloneConnection(clientMetadata, database, { + useRetry: true, + connectionName, + }); + } + + /** + * Create connection based on connectionType or try to determine connectionType automatically + * @param clientMetadata + * @param databaseDto + * @param connectionName + */ + public async createRedisConnection( + clientMetadata: ClientMetadata, + databaseDto: Database, + connectionName?, + ): Promise { + const database = cloneClassInstance(databaseDto); + Object.keys(database).forEach((key: string) => { + if (database[key] === null) { + delete database[key]; + } + }); + + let client; + + switch (database?.connectionType) { + case ConnectionType.STANDALONE: + client = await this.createStandaloneConnection(clientMetadata, database, { + useRetry: true, + connectionName, + }); + break; + case ConnectionType.CLUSTER: + client = await this.createClusterConnection(clientMetadata, database, { + useRetry: true, + connectionName, + }); + break; + case ConnectionType.SENTINEL: + client = await this.createSentinelConnection(clientMetadata, database, { + useRetry: true, + connectionName, + }); + break; + default: + // AUTO + client = await this.createClientAutomatically(clientMetadata, database, connectionName); + } + + return client; + } +} diff --git a/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.ts b/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.ts index 7ca4a39f6c..9078329106 100644 --- a/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.ts +++ b/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.ts @@ -13,6 +13,7 @@ import { ClientNotFoundErrorException } from 'src/modules/redis/exceptions/clien import { IRedisToolOptions, DEFAULT_REDIS_TOOL_OPTIONS } from 'src/modules/redis/redis-tool-options'; import { DatabaseService } from 'src/modules/database/database.service'; import { ClientContext, ClientMetadata } from 'src/common/models'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; export abstract class RedisConsumerAbstractService implements IRedisConsumer { protected redisService: RedisService; @@ -21,17 +22,21 @@ export abstract class RedisConsumerAbstractService implements IRedisConsumer { protected consumer: ClientContext; + protected readonly redisConnectionFactory: RedisConnectionFactory; + private readonly options: IRedisToolOptions = DEFAULT_REDIS_TOOL_OPTIONS; protected constructor( consumer: ClientContext, redisService: RedisService, + redisConnectionFactory: RedisConnectionFactory, databaseService: DatabaseService, options: IRedisToolOptions = {}, ) { this.consumer = consumer; this.options = { ...this.options, ...options }; this.redisService = redisService; + this.redisConnectionFactory = redisConnectionFactory; this.databaseService = databaseService; } @@ -100,10 +105,7 @@ export abstract class RedisConsumerAbstractService implements IRedisConsumer { context: this.consumer, }); if (!redisClientInstance || !this.redisService.isClientConnected(redisClientInstance.client)) { - this.redisService.removeClientInstance({ - databaseId: redisClientInstance?.databaseId, - context: this.consumer, - }); + this.redisService.removeClientInstance(clientMetadata); if (!this.options.enableAutoConnection) throw new ClientNotFoundErrorException(); return await this.createNewClient(clientMetadata); @@ -133,15 +135,17 @@ export abstract class RedisConsumerAbstractService implements IRedisConsumer { ); try { - const client = await this.redisService.connectToDatabaseInstance( + const client = await this.redisConnectionFactory.createRedisConnection( + { + ...clientMetadata, + context: this.consumer, + }, instanceDto, - this.consumer, connectionName, ); this.redisService.setClientInstance( { ...clientMetadata, - uniqueId, context: clientMetadata.context || this.consumer, }, client, diff --git a/redisinsight/api/src/modules/redis/redis-tool.factory.ts b/redisinsight/api/src/modules/redis/redis-tool.factory.ts index 8a4f1f795e..1c0b683307 100644 --- a/redisinsight/api/src/modules/redis/redis-tool.factory.ts +++ b/redisinsight/api/src/modules/redis/redis-tool.factory.ts @@ -4,15 +4,23 @@ import { RedisService } from 'src/modules/redis/redis.service'; import { RedisToolService } from 'src/modules/redis/redis-tool.service'; import { IRedisToolOptions } from 'src/modules/redis/redis-tool-options'; import { DatabaseService } from 'src/modules/database/database.service'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; @Injectable() export class RedisToolFactory { constructor( protected redisService: RedisService, + protected redisConnectionFactory: RedisConnectionFactory, protected databaseService: DatabaseService, ) {} createRedisTool(clientContext: ClientContext, options: IRedisToolOptions = {}) { - return new RedisToolService(clientContext, this.redisService, this.databaseService, options); + return new RedisToolService( + clientContext, + this.redisService, + this.redisConnectionFactory, + this.databaseService, + options, + ); } } diff --git a/redisinsight/api/src/modules/redis/redis-tool.service.ts b/redisinsight/api/src/modules/redis/redis-tool.service.ts index cd186a0ec5..02d29a927f 100644 --- a/redisinsight/api/src/modules/redis/redis-tool.service.ts +++ b/redisinsight/api/src/modules/redis/redis-tool.service.ts @@ -19,6 +19,7 @@ import { } from 'src/modules/cli/dto/cli.dto'; import { getConnectionName } from 'src/utils/redis-connection-helper'; import { DatabaseService } from 'src/modules/database/database.service'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; import { IRedisToolOptions } from './redis-tool-options'; export interface ICliExecResultFromNode { @@ -36,10 +37,11 @@ export class RedisToolService extends RedisConsumerAbstractService { constructor( private appTool: ClientContext, protected redisService: RedisService, + protected redisConnectionFactory: RedisConnectionFactory, protected databaseService: DatabaseService, options: IRedisToolOptions = {}, ) { - super(appTool, redisService, databaseService, options); + super(appTool, redisService, redisConnectionFactory, databaseService, options); this.logger = new Logger(`${appTool}ToolService`); } diff --git a/redisinsight/api/src/modules/redis/redis.module.ts b/redisinsight/api/src/modules/redis/redis.module.ts index 721e3e4281..6aa81e9bed 100644 --- a/redisinsight/api/src/modules/redis/redis.module.ts +++ b/redisinsight/api/src/modules/redis/redis.module.ts @@ -1,15 +1,18 @@ import { Module } from '@nestjs/common'; import { RedisToolFactory } from 'src/modules/redis/redis-tool.factory'; import { RedisService } from 'src/modules/redis/redis.service'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; @Module({ providers: [ RedisService, RedisToolFactory, + RedisConnectionFactory, ], exports: [ RedisService, RedisToolFactory, + RedisConnectionFactory, ], }) export class RedisModule {} diff --git a/redisinsight/api/src/modules/redis/redis.service.spec.ts b/redisinsight/api/src/modules/redis/redis.service.spec.ts index 80d980134b..f8b7a2d860 100644 --- a/redisinsight/api/src/modules/redis/redis.service.spec.ts +++ b/redisinsight/api/src/modules/redis/redis.service.spec.ts @@ -1,17 +1,27 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { v4 as uuidv4 } from 'uuid'; import { ConnectionOptions } from 'tls'; import { - mockCaCertificate, mockClientCertificate, mockClusterDatabaseWithTlsAuth, mockDatabase, - mockDatabaseEntity, mockIORedisClient, mockIORedisCluster, mockIORedisSentinel, mockSentinelDatabaseWithTlsAuth, + generateMockRedisClientInstance, + mockCaCertificate, + mockClientCertificate, + mockDatabase, + mockDatabaseEntity, + mockIORedisClient, + mockRedisConnectionFactory } from 'src/__mocks__'; -import { AppTool } from 'src/models'; -import { ClientContext, ClientMetadata } from 'src/common/models'; -import { Database } from 'src/modules/database/models/database'; +import { ClientContext, Session } from 'src/common/models'; import { RedisService } from './redis.service'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; + +const mockClientMetadata = { + session: { + id: 'sessionId', + }, +}; const mockRedisClientInstance = { - session: undefined, + clientMetadata: {}, + session: {}, databaseId: mockDatabase.id, context: ClientContext.Common, uniqueId: undefined, @@ -46,6 +56,10 @@ describe('RedisService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ RedisService, + { + provide: RedisConnectionFactory, + useFactory: mockRedisConnectionFactory, + }, ], }).compile(); @@ -53,252 +67,391 @@ describe('RedisService', () => { }); it('should be defined', () => { - expect(service.clients).toEqual([]); + expect(service.clients).toEqual(new Map()); }); - describe('connectToDatabaseInstance', () => { - beforeEach(async () => { - service.clients = []; + // describe('connectToDatabaseInstance', () => { + // beforeEach(async () => { + // service.clients = new Map(); + // }); + // it('should create standalone client', async () => { + // service.createStandaloneClient = jest.fn().mockResolvedValue(mockIORedisClient); + // + // const result = await service.connectToDatabaseInstance(mockDatabase); + // + // expect(result).toEqual(mockIORedisClient); + // expect(service.createStandaloneClient).toHaveBeenCalledWith(mockDatabase, AppTool.Common, true, undefined); + // }); + // it('should create standalone client (by default)', async () => { + // service.createStandaloneClient = jest.fn().mockResolvedValue(mockIORedisClient); + // const mockDatabaseWithoutConnectionType = Object.assign(new Database(), { + // ...mockDatabase, + // connectionType: null, + // }); + // + // const result = await service.connectToDatabaseInstance(mockDatabaseWithoutConnectionType); + // + // expect(result).toEqual(mockIORedisClient); + // expect(service.createStandaloneClient) + // .toHaveBeenCalledWith({ + // ...mockDatabaseWithoutConnectionType, + // connectionType: undefined, + // }, AppTool.Common, true, undefined); + // }); + // it('should create cluster client', async () => { + // service.createClusterClient = jest.fn().mockResolvedValue(mockIORedisCluster); + // + // const result = await service.connectToDatabaseInstance(mockClusterDatabaseWithTlsAuth); + // + // expect(result).toEqual(mockIORedisCluster); + // expect(service.createClusterClient).toHaveBeenCalledWith( + // mockClusterDatabaseWithTlsAuth, + // mockClusterDatabaseWithTlsAuth.nodes, + // true, + // undefined, + // ); + // }); + // it('should create sentinel client', async () => { + // const dto = removeNullsFromDto(mockSentinelDatabaseWithTlsAuth); + // Object.keys(dto).forEach((key: string) => { + // if (dto[key] === null) { + // delete dto[key]; + // } + // }); + // const { nodes } = dto; + // service.createSentinelClient = jest.fn().mockResolvedValue(mockIORedisSentinel); + // + // const result = await service.connectToDatabaseInstance(dto); + // + // expect(result).toEqual(mockIORedisSentinel); + // expect(service.createSentinelClient).toHaveBeenCalledWith( + // mockSentinelDatabaseWithTlsAuth, + // nodes, + // ClientContext.Common, + // true, + // undefined, + // ); + // }); + // }); + + // describe('getClientInstance', () => { + // beforeEach(() => { + // service.clients = [ + // { + // ...mockRedisClientInstance, + // }, + // { + // ...mockRedisClientInstance, context: ClientContext.Browser, + // }, + // { + // ...mockRedisClientInstance, context: ClientContext.CLI, + // }, + // ]; + // }); + // it('should correctly find client instance for App.Common by instance id', () => { + // const newClient = { ...service.clients[0], context: ClientContext.Browser }; + // service.clients.push(newClient); + // const options = { + // session: undefined, + // databaseId: newClient.databaseId, + // context: ClientContext.Common, + // }; + // + // const result = service.getClientInstance(options); + // + // expect(result).toEqual(service.clients[0]); + // }); + // it('should correctly find client instance by instance id and tool', () => { + // const options = { + // session: undefined, + // databaseId: service.clients[0].databaseId, + // context: ClientContext.CLI, + // }; + // + // const result = service.getClientInstance(options); + // + // expect(result).toEqual(service.clients[2]); + // }); + // it('should correctly find client instance by instance id, tool and uuid', () => { + // const newClient = { ...mockRedisClientInstance, uniqueId: uuidv4(), context: ClientContext.CLI }; + // service.clients.push(newClient); + // + // const options = { + // session: undefined, + // databaseId: newClient.databaseId, + // uniqueId: newClient.uniqueId, + // context: newClient.context, + // }; + // + // const result = service.getClientInstance(options); + // + // expect(result).toEqual(newClient); + // }); + // it('should return undefined', () => { + // const options = { + // session: undefined, + // databaseId: 'invalid-instance-id', + // context: ClientContext.Common, + // }; + // + // const result = service.getClientInstance(options); + // + // expect(result).toBeUndefined(); + // }); + // }); + + describe('findClientInstances + removeClientInstances', () => { + const mockClientMetadata1 = { + session: { + userId: 'u1', + sessionId: 's1', + }, + databaseId: mockDatabase.id, + context: ClientContext.Common, + }; + + const mockRedisClientInstance1 = generateMockRedisClientInstance(mockClientMetadata1); + const mockRedisClientInstance2 = generateMockRedisClientInstance({ + ...mockClientMetadata1, + context: ClientContext.Browser, + db: 0, }); - it('should create standalone client', async () => { - service.createStandaloneClient = jest.fn().mockResolvedValue(mockIORedisClient); - - const result = await service.connectToDatabaseInstance(mockDatabase); - - expect(result).toEqual(mockIORedisClient); - expect(service.createStandaloneClient).toHaveBeenCalledWith(mockDatabase, AppTool.Common, true, undefined); - }); - it('should create standalone client (by default)', async () => { - service.createStandaloneClient = jest.fn().mockResolvedValue(mockIORedisClient); - const mockDatabaseWithoutConnectionType = Object.assign(new Database(), { - ...mockDatabase, - connectionType: null, - }); - - const result = await service.connectToDatabaseInstance(mockDatabaseWithoutConnectionType); - - expect(result).toEqual(mockIORedisClient); - expect(service.createStandaloneClient) - .toHaveBeenCalledWith({ - ...mockDatabaseWithoutConnectionType, - connectionType: undefined, - }, AppTool.Common, true, undefined); + const mockRedisClientInstance3 = generateMockRedisClientInstance({ + ...mockClientMetadata1, + session: { userId: 'u2', sessionId: 's2' }, + context: ClientContext.Workbench, + db: 1, }); - it('should create cluster client', async () => { - service.createClusterClient = jest.fn().mockResolvedValue(mockIORedisCluster); - - const result = await service.connectToDatabaseInstance(mockClusterDatabaseWithTlsAuth); - - expect(result).toEqual(mockIORedisCluster); - expect(service.createClusterClient).toHaveBeenCalledWith( - mockClusterDatabaseWithTlsAuth, - mockClusterDatabaseWithTlsAuth.nodes, - true, - undefined, - ); + const mockRedisClientInstance4 = generateMockRedisClientInstance({ + ...mockClientMetadata1, + session: { userId: 'u2', sessionId: 's3' }, + db: 2, }); - it('should create sentinel client', async () => { - const dto = removeNullsFromDto(mockSentinelDatabaseWithTlsAuth); - Object.keys(dto).forEach((key: string) => { - if (dto[key] === null) { - delete dto[key]; - } - }); - const { nodes } = dto; - service.createSentinelClient = jest.fn().mockResolvedValue(mockIORedisSentinel); - - const result = await service.connectToDatabaseInstance(dto); - - expect(result).toEqual(mockIORedisSentinel); - expect(service.createSentinelClient).toHaveBeenCalledWith( - mockSentinelDatabaseWithTlsAuth, - nodes, - ClientContext.Common, - true, - undefined, - ); + const mockRedisClientInstance5 = generateMockRedisClientInstance({ + ...mockClientMetadata1, + databaseId: 'd2', + session: { userId: 'u2', sessionId: 's4' }, }); - }); - describe('getClientInstance', () => { beforeEach(() => { - service.clients = [ - { - ...mockRedisClientInstance, - }, - { - ...mockRedisClientInstance, context: ClientContext.Browser, - }, - { - ...mockRedisClientInstance, context: ClientContext.CLI, - }, - ]; - }); - it('should correctly find client instance for App.Common by instance id', () => { - const newClient = { ...service.clients[0], context: ClientContext.Browser }; - service.clients.push(newClient); - const options = { - session: undefined, - databaseId: newClient.databaseId, - context: ClientContext.Common, - }; - - const result = service.getClientInstance(options); - - expect(result).toEqual(service.clients[0]); - }); - it('should correctly find client instance by instance id and tool', () => { - const options = { - session: undefined, - databaseId: service.clients[0].databaseId, - context: ClientContext.CLI, - }; - - const result = service.getClientInstance(options); - - expect(result).toEqual(service.clients[2]); - }); - it('should correctly find client instance by instance id, tool and uuid', () => { - const newClient = { ...mockRedisClientInstance, uniqueId: uuidv4(), context: ClientContext.CLI }; - service.clients.push(newClient); - - const options = { - session: undefined, - databaseId: newClient.databaseId, - uniqueId: newClient.uniqueId, - context: newClient.context, - }; - - const result = service.getClientInstance(options); - - expect(result).toEqual(newClient); + service.clients = new Map(); + service.clients.set(mockRedisClientInstance1.id, mockRedisClientInstance1); + service.clients.set(mockRedisClientInstance2.id, mockRedisClientInstance2); + service.clients.set(mockRedisClientInstance3.id, mockRedisClientInstance3); + service.clients.set(mockRedisClientInstance4.id, mockRedisClientInstance4); + service.clients.set(mockRedisClientInstance5.id, mockRedisClientInstance5); }); - it('should return undefined', () => { - const options = { - session: undefined, - databaseId: 'invalid-instance-id', - context: ClientContext.Common, + it('should correctly find client instances for particular database', () => { + const query = { + databaseId: mockDatabase.id, }; - const result = service.getClientInstance(options); + const result = service.findClientInstances(query); - expect(result).toBeUndefined(); - }); - }); + expect(result.length).toEqual(4); + result.forEach((clientInstance) => { + expect(clientInstance.clientMetadata.databaseId).toEqual(query.databaseId); + }); - describe('removeClientInstance', () => { - beforeEach(() => { - service.clients = [ - { - ...mockRedisClientInstance, - }, - { - ...mockRedisClientInstance, context: ClientContext.Browser, - }, - ]; + expect(service.removeClientInstances(query)).toEqual(4); + expect(service.clients.size).toEqual(1); }); - it('should remove only client for browser tool', () => { - const options = { - databaseId: mockRedisClientInstance.databaseId, + it('should correctly find client instances for particular database and context', () => { + const query = { + databaseId: mockDatabase.id, context: ClientContext.Browser, }; - const result = service.removeClientInstance(options); - - expect(result).toEqual(1); - expect(service.clients.length).toEqual(1); - }); - it('should remove all clients by instance id', () => { - const options = { - databaseId: mockRedisClientInstance.databaseId, - }; - - const result = service.removeClientInstance(options); + const result = service.findClientInstances(query); - expect(result).toEqual(2); - expect(service.clients.length).toEqual(0); - }); - }); + expect(result.length).toEqual(1); + result.forEach((clientInstance) => { + expect(clientInstance.clientMetadata.databaseId).toEqual(query.databaseId); + expect(clientInstance.clientMetadata.context).toEqual(query.context); + }); - describe('setClientInstance', () => { - beforeEach(() => { - service.clients = [{ ...mockRedisClientInstance }]; + expect(service.removeClientInstances(query)).toEqual(1); + expect(service.clients.size).toEqual(4); }); - it('should add new client', () => { - const initialClientsCount = service.clients.length; - const newClientInstance: ClientMetadata = { - ...mockRedisClientInstance, - databaseId: uuidv4(), + xit('should correctly find client instances for particular database and user', () => { + const query = { + session: { userId: 'u1' } as Session, + databaseId: mockDatabase.id, }; - const result = service.setClientInstance(newClientInstance, mockIORedisClient); - - expect(result).toBe(1); - expect(service.clients.length).toBe(initialClientsCount + 1); - }); - it('should replace exist client', () => { - const initialClientsCount = service.clients.length; + const result = service.findClientInstances(query); - const result = service.setClientInstance(mockRedisClientInstance, mockIORedisClient); + expect(result.length).toEqual(2); + result.forEach((clientInstance) => { + expect(clientInstance.clientMetadata.databaseId).toEqual(query.databaseId); + expect(clientInstance.clientMetadata.session.userId).toEqual(query.session.userId); + }); - expect(result).toBe(0); - expect(service.clients.length).toBe(initialClientsCount); + expect(service.removeClientInstances(query)).toEqual(2); + expect(service.clients.size).toEqual(3); }); - }); - - describe('isClientConnected', () => { - it('should return true', async () => { - const result = service.isClientConnected(mockIORedisClient); + xit('should correctly find client instances for particular user', () => { + const query = { + session: { userId: 'u2' } as Session, + }; - expect(result).toEqual(true); - }); - it('should return false', async () => { - const mockClient = { ...mockIORedisClient }; - mockClient.status = 'end'; + const result = service.findClientInstances(query); - const result = service.isClientConnected(mockClient); + expect(result.length).toEqual(3); + result.forEach((clientInstance) => { + expect(clientInstance.clientMetadata.session.userId).toEqual(query.session.userId); + }); + expect(result[0].clientMetadata.databaseId).toEqual(mockDatabase.id); + expect(result[2].clientMetadata.databaseId).toEqual('d2'); - expect(result).toEqual(false); + expect(service.removeClientInstances(query)).toEqual(3); + expect(service.clients.size).toEqual(2); }); - }); - - describe('getRedisConnectionConfig', () => { - it('should return config with tls', async () => { - service['getTLSConfig'] = jest.fn().mockResolvedValue(mockTlsConfigResult); - const { - host, port, password, username, db, - } = mockClusterDatabaseWithTlsAuth; - - const expectedResult = { - host, port, username, password, db, tls: mockTlsConfigResult, + it('should correctly find client instances for particular database and db index', () => { + const query = { + databaseId: mockDatabase.id, + db: 0, }; - const result = await service['getRedisConnectionConfig'](mockClusterDatabaseWithTlsAuth); + const result = service.findClientInstances(query); - expect(JSON.stringify(result)).toEqual(JSON.stringify(expectedResult)); - }); - it('should return without tls', async () => { - const { - host, port, password, username, db, - } = mockDatabase; + expect(result.length).toEqual(1); + result.forEach((clientInstance) => { + expect(clientInstance.clientMetadata.databaseId).toEqual(query.databaseId); + expect(clientInstance.clientMetadata.context).toEqual(ClientContext.Browser); + expect(clientInstance.clientMetadata.db).toEqual(query.db); + }); - const expectedResult = { - host, port, username, password, db, + expect(service.removeClientInstances(query)).toEqual(1); + expect(service.clients.size).toEqual(4); + }); + it('should not find any instances', () => { + const query = { + databaseId: 'not existing', }; - const result = await service['getRedisConnectionConfig'](mockDatabase); - - expect(result).toEqual(expectedResult); - }); - }); + const result = service.findClientInstances(query); - xdescribe('getTLSConfig', () => { - it('should return tls config', async () => { - const result = await service['getTLSConfig'](mockClusterDatabaseWithTlsAuth); + expect(result).toEqual([]); - expect(JSON.stringify(result)).toEqual( - JSON.stringify(mockTlsConfigResult), - ); + expect(service.removeClientInstances(query)).toEqual(0); + expect(service.clients.size).toEqual(5); }); }); + // + // describe('removeClientInstance', () => { + // beforeEach(() => { + // service.clients = [ + // { + // ...mockRedisClientInstance, + // }, + // { + // ...mockRedisClientInstance, context: ClientContext.Browser, + // }, + // ]; + // }); + // it('should remove only client for browser tool', () => { + // const options = { + // databaseId: mockRedisClientInstance.databaseId, + // context: ClientContext.Browser, + // }; + // + // const result = service.removeClientInstance(options); + // + // expect(result).toEqual(1); + // expect(service.clients.length).toEqual(1); + // }); + // it('should remove all clients by instance id', () => { + // const options = { + // databaseId: mockRedisClientInstance.databaseId, + // }; + // + // const result = service.removeClientInstance(options); + // + // expect(result).toEqual(2); + // expect(service.clients.length).toEqual(0); + // }); + // }); + // + // describe('setClientInstance', () => { + // beforeEach(() => { + // service.clients = [{ ...mockRedisClientInstance }]; + // }); + // it('should add new client', () => { + // const initialClientsCount = service.clients.length; + // const newClientInstance: ClientMetadata = { + // ...mockRedisClientInstance, + // databaseId: uuidv4(), + // }; + // + // const result = service.setClientInstance(newClientInstance, mockIORedisClient); + // + // expect(result).toBe(1); + // expect(service.clients.length).toBe(initialClientsCount + 1); + // }); + // it('should replace exist client', () => { + // const initialClientsCount = service.clients.length; + // + // const result = service.setClientInstance(mockRedisClientInstance, mockIORedisClient); + // + // expect(result).toBe(0); + // expect(service.clients.length).toBe(initialClientsCount); + // }); + // }); + // + // describe('isClientConnected', () => { + // it('should return true', async () => { + // const result = service.isClientConnected(mockIORedisClient); + // + // expect(result).toEqual(true); + // }); + // it('should return false', async () => { + // const mockClient = { ...mockIORedisClient }; + // mockClient.status = 'end'; + // + // const result = service.isClientConnected(mockClient); + // + // expect(result).toEqual(false); + // }); + // }); + // + // describe('getRedisConnectionConfig', () => { + // it('should return config with tls', async () => { + // service['getTLSConfig'] = jest.fn().mockResolvedValue(mockTlsConfigResult); + // const { + // host, port, password, username, db, + // } = mockClusterDatabaseWithTlsAuth; + // + // const expectedResult = { + // host, port, username, password, db, tls: mockTlsConfigResult, + // }; + // + // const result = await service['getRedisConnectionConfig'](mockClusterDatabaseWithTlsAuth); + // + // expect(JSON.stringify(result)).toEqual(JSON.stringify(expectedResult)); + // }); + // it('should return without tls', async () => { + // const { + // host, port, password, username, db, + // } = mockDatabase; + // + // const expectedResult = { + // host, port, username, password, db, + // }; + // + // const result = await service['getRedisConnectionConfig'](mockDatabase); + // + // expect(result).toEqual(expectedResult); + // }); + // }); + // + // xdescribe('getTLSConfig', () => { + // it('should return tls config', async () => { + // const result = await service['getTLSConfig'](mockClusterDatabaseWithTlsAuth); + // + // expect(JSON.stringify(result)).toEqual( + // JSON.stringify(mockTlsConfigResult), + // ); + // }); + // }); }); diff --git a/redisinsight/api/src/modules/redis/redis.service.ts b/redisinsight/api/src/modules/redis/redis.service.ts index de94a88e79..4e5ac2ba52 100644 --- a/redisinsight/api/src/modules/redis/redis.service.ts +++ b/redisinsight/api/src/modules/redis/redis.service.ts @@ -1,226 +1,50 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConnectionOptions } from 'tls'; -import Redis, { RedisOptions, Cluster } from 'ioredis'; +import { Injectable } from '@nestjs/common'; +import Redis, { Cluster } from 'ioredis'; import { - find, findIndex, isEmpty, isNil, omitBy, remove, + isMatch, omit, } from 'lodash'; -import { v4 as uuidv4 } from 'uuid'; import apiConfig from 'src/utils/config'; -import { CONNECTION_NAME_GLOBAL_PREFIX } from 'src/constants'; -import { IRedisClusterNodeAddress } from 'src/models/redis-cluster'; -import { ConnectionType } from 'src/modules/database/entities/database.entity'; -import { Database } from 'src/modules/database/models/database'; -import { cloneClassInstance } from 'src/utils'; -import { ClientContext, ClientMetadata } from 'src/common/models'; +import { ClientMetadata } from 'src/common/models'; const REDIS_CLIENTS_CONFIG = apiConfig.get('redis_clients'); export interface IRedisClientInstance { - databaseId: string; - context: ClientContext; - uniqueId: string; + id: string, + clientMetadata: ClientMetadata, client: any; lastTimeUsed: number; } @Injectable() export class RedisService { - private logger = new Logger('RedisService'); - - private lastClientsSync: number; - - public clients: IRedisClientInstance[] = []; + public clients: Map = new Map(); constructor() { - this.lastClientsSync = Date.now(); - } - - public async createStandaloneClient( - database: Database, - appTool: ClientContext, - useRetry: boolean, - connectionName: string = CONNECTION_NAME_GLOBAL_PREFIX, - ): Promise { - const config = await this.getRedisConnectionConfig(database); - - return await new Promise((resolve, reject) => { - try { - const connection = new Redis({ - ...config, - showFriendlyErrorStack: true, - maxRetriesPerRequest: REDIS_CLIENTS_CONFIG.maxRetriesPerRequest, - connectionName, - retryStrategy: useRetry ? this.retryStrategy : () => undefined, - db: config.db > 0 && !database.sentinelMaster ? config.db : 0, - }); - connection.on('error', (e): void => { - this.logger.error('Failed connection to the redis database.', e); - reject(e); - }); - connection.on('ready', (): void => { - this.logger.log('Successfully connected to the redis database'); - resolve(connection); - }); - connection.on('reconnecting', (): void => { - this.logger.log('Reconnecting to the redis database'); - }); - } catch (e) { - reject(e); - } - }) as Redis; - } - - public async createClusterClient( - database: Database, - nodes: IRedisClusterNodeAddress[] = [], - useRetry: boolean = false, - connectionName: string = CONNECTION_NAME_GLOBAL_PREFIX, - ): Promise { - const config = await this.getRedisConnectionConfig(database); - return new Promise((resolve, reject) => { - try { - const cluster = new Redis.Cluster([{ - host: database.host, - port: database.port, - }].concat(nodes), { - clusterRetryStrategy: useRetry ? this.retryStrategy : () => undefined, - redisOptions: { - ...config, - showFriendlyErrorStack: true, - maxRetriesPerRequest: REDIS_CLIENTS_CONFIG.maxRetriesPerRequest, - connectionName, - }, - }); - cluster.on('error', (e): void => { - this.logger.error('Failed connection to the redis oss cluster', e); - reject(!isEmpty(e.lastNodeError) ? e.lastNodeError : e); - }); - cluster.on('ready', (): void => { - this.logger.log('Successfully connected to the redis oss cluster.'); - resolve(cluster); - }); - } catch (e) { - reject(e); - } - }); + setInterval(this.syncClients.bind(this), 60 * 1000); // sync clients each minute } - public async createSentinelClient( - database: Database, - sentinels: Array<{ host: string; port: number }>, - appTool: ClientContext, - useRetry: boolean = false, - connectionName: string = CONNECTION_NAME_GLOBAL_PREFIX, - ): Promise { - const { - username, password, sentinelMaster, tls, db, - } = database; - const config: RedisOptions = { - sentinels, - name: sentinelMaster.name, - sentinelUsername: username, - sentinelPassword: password, - db, - username: sentinelMaster?.username, - password: sentinelMaster?.password, - }; - - if (tls) { - const tlsConfig = await this.getTLSConfig(database); - config.tls = tlsConfig; - config.sentinelTLS = tlsConfig; - config.enableTLSForSentinelMode = true; - } - return new Promise((resolve, reject) => { - try { - const client = new Redis({ - ...config, - showFriendlyErrorStack: true, - maxRetriesPerRequest: REDIS_CLIENTS_CONFIG.maxRetriesPerRequest, - connectionName, - retryStrategy: useRetry ? this.retryStrategy : () => undefined, - sentinelRetryStrategy: useRetry - ? this.retryStrategy - : () => undefined, - }); - client.on('error', (e): void => { - this.logger.error('Failed connection to the redis oss sentinel', e); - reject(e); - }); - client.on('ready', (): void => { - this.logger.log('Successfully connected to the redis oss sentinel.'); - resolve(client); - }); - } catch (e) { - reject(e); - } - }); - } - - public async connectToDatabaseInstance( - databaseDto: Database, - tool = ClientContext.Common, - connectionName?, - ): Promise { - const database = cloneClassInstance(databaseDto); - Object.keys(database).forEach((key: string) => { - if (database[key] === null) { - delete database[key]; + /** + * Close connections and remove clients which were unused for some time + * @private + */ + private syncClients() { + [...this.clients.keys()].forEach((id) => { + const redisClient = this.clients.get(id); + if (redisClient && (Date.now() - redisClient.lastTimeUsed) >= REDIS_CLIENTS_CONFIG.maxIdleThreshold) { + redisClient.client.disconnect(); + this.clients.delete(id); } }); - - let client; - - const { nodes, connectionType } = database; - switch (connectionType) { - case ConnectionType.STANDALONE: - client = await this.createStandaloneClient(database, tool, true, connectionName); - break; - case ConnectionType.CLUSTER: - client = await this.createClusterClient(database, nodes, true, connectionName); - break; - case ConnectionType.SENTINEL: - client = await this.createSentinelClient(database, nodes, tool, true, connectionName); - break; - default: - // AUTO - client = await this.createClientAutomatically(database, tool, connectionName); - } - - return client; - } - - public async createClientAutomatically(database: Database, tool: ClientContext, connectionName) { - // try sentinel connection - if (database?.sentinelMaster) { - try { - return await this.createSentinelClient(database, database.nodes, tool, true, connectionName); - } catch (e) { - // ignore error - } - } - - // try cluster connection - try { - return await this.createClusterClient(database, database.nodes, true, connectionName); - } catch (e) { - // ignore error - } - - // Standalone in any other case - return this.createStandaloneClient(database, tool, true, connectionName); - } - - public isClientConnected(client: Redis | Cluster): boolean { - try { - return client.status === 'ready'; - } catch (e) { - return false; - } } + /** + * Get client by generated id (only one is possible) + * Will find client by all fields from client metadata + * @param clientMetadata + */ public getClientInstance(clientMetadata: ClientMetadata): IRedisClientInstance { - const found = this.findClientInstance(clientMetadata.databaseId, clientMetadata.context, clientMetadata.uniqueId); + const found = this.clients.get(RedisService.generateId(clientMetadata)); + if (found) { found.lastTimeUsed = Date.now(); } @@ -228,113 +52,94 @@ export class RedisService { return found; } - public removeClientInstance(clientMetadata: Partial): number { - const removed: IRedisClientInstance[] = remove( - this.clients, - clientMetadata, - ); - removed.forEach((clientInstance) => { - clientInstance.client.disconnect(); - }); - return removed.length; - } - public setClientInstance(clientMetadata: ClientMetadata, client): 0 | 1 { - const found = this.findClientInstance( - clientMetadata.databaseId, - clientMetadata.context, - clientMetadata.uniqueId, - ); + const id = RedisService.generateId(clientMetadata); + const found = this.clients.get(id); + + const clientInstance = { + id, + clientMetadata, + client, + lastTimeUsed: Date.now(), + }; if (found) { - const index = findIndex(this.clients, { uniqueId: found.uniqueId }); - this.clients[index].client.disconnect(); - this.clients[index] = { - ...this.clients[index], - lastTimeUsed: Date.now(), - client, - }; - return 0; + found.client.disconnect(); + this.clients.delete(id); + this.clients.set(id, clientInstance); + return 0; // todo: investigate why we need to distinguish between 1 | 0 } - const clientInstance: IRedisClientInstance = { - ...clientMetadata, - uniqueId: clientMetadata.uniqueId || uuidv4(), - lastTimeUsed: Date.now(), - client, - }; - this.clients.push(clientInstance); + this.clients.set(id, clientInstance); + return 1; } - private syncClients() { - const currentTime = Date.now(); - const syncDif = currentTime - this.lastClientsSync; - if (syncDif >= REDIS_CLIENTS_CONFIG.idleSyncInterval) { - this.lastClientsSync = currentTime; - this.clients = this.clients.filter((item) => { - const idle = Date.now() - item.lastTimeUsed; - if (idle >= REDIS_CLIENTS_CONFIG.maxIdleThreshold) { - item.client.disconnect(); - return false; - } - return true; - }); - } - } + public removeClientInstance(clientMetadata: ClientMetadata): number { + const id = RedisService.generateId(clientMetadata); + const found = this.clients.get(id); - private async getRedisConnectionConfig( - database: Database, - ): Promise { - const { - host, port, password, username, tls, db, - } = database; - const config: RedisOptions = { - host, port, username, password, db, - }; - if (tls) { - config.tls = await this.getTLSConfig(database); + if (found) { + found.client.disconnect(); + this.clients.delete(id); + return 1; } - return config; + + return 0; } - private async getTLSConfig(database: Database): Promise { - let config: ConnectionOptions; - config = { - rejectUnauthorized: database.verifyServerCert, - checkServerIdentity: () => undefined, - servername: database.tlsServername || undefined, - }; - if (database.caCert) { - config = { - ...config, - ca: [database.caCert.certificate], - }; - } - if (database.clientCert) { - config = { - ...config, - cert: database.clientCert.certificate, - key: database.clientCert.key, - }; - } + public removeClientInstances(clientMetadata: Partial): number { + const toRemove = this.findClientInstances(clientMetadata); + toRemove.forEach((redisClient) => { + redisClient.client.disconnect(); + this.clients.delete(redisClient.id); + }); + + return toRemove.length; + } - return config; + public findClientInstances(clientMetadata: Partial): IRedisClientInstance[] { + const findOptions = omit(clientMetadata, 'session'); // omit users criteria for searching for now + return [...this.clients.values()] + .filter((redisClient) => isMatch(redisClient.clientMetadata, findOptions)); } - private retryStrategy(times: number): number { - if (times < REDIS_CLIENTS_CONFIG.retryTimes) { - return Math.min(times * REDIS_CLIENTS_CONFIG.retryDelay, 2000); + /** + * Check if client is connected and ready to use + * @param client + */ + public isClientConnected(client: Redis | Cluster): boolean { + try { + return client.status === 'ready'; + } catch (e) { + return false; } - return undefined; } - private findClientInstance( - databaseId: string, - context: ClientContext = ClientContext.Common, - uniqueId: string = undefined, - ): IRedisClientInstance { - const options = omitBy({ databaseId, uniqueId, context }, isNil); - return find(this.clients, options); + /** + * Generate client id string based on client metadata + * @param cm + */ + static generateId(cm: ClientMetadata): string { + const empty = '(nil)'; + const separator = '_'; + + const id = [ + cm.databaseId, + cm.context, + cm.uniqueId || empty, + cm.db || empty, + ].join(separator); + + // const uId = [ + // cm.session?.userId || empty, + // cm.session?.sessionId || empty, + // cm.session?.uniqueId || empty, + // ].join(separator); + + return [ + id, + // uId, user ignored until will be supported everywhere across the app + ].join(separator); } } diff --git a/redisinsight/api/src/modules/slow-log/slow-log.controller.ts b/redisinsight/api/src/modules/slow-log/slow-log.controller.ts index a3e92f0554..1c253118db 100644 --- a/redisinsight/api/src/modules/slow-log/slow-log.controller.ts +++ b/redisinsight/api/src/modules/slow-log/slow-log.controller.ts @@ -31,7 +31,9 @@ export class SlowLogController { }) @Get('') async getSlowLogs( - @ClientMetadataParam() clientMetadata: ClientMetadata, + @ClientMetadataParam({ + ignoreDbIndex: true, + }) clientMetadata: ClientMetadata, @Query() getSlowLogsDto: GetSlowLogsDto, ): Promise { return this.service.getSlowLogs(clientMetadata, getSlowLogsDto); @@ -43,7 +45,9 @@ export class SlowLogController { }) @Delete('') async resetSlowLogs( - @ClientMetadataParam() clientMetadata: ClientMetadata, + @ClientMetadataParam({ + ignoreDbIndex: true, + }) clientMetadata: ClientMetadata, ): Promise { return this.service.reset(clientMetadata); } @@ -60,7 +64,9 @@ export class SlowLogController { }) @Get('config') async getConfig( - @ClientMetadataParam() clientMetadata: ClientMetadata, + @ClientMetadataParam({ + ignoreDbIndex: true, + }) clientMetadata: ClientMetadata, ): Promise { return this.service.getConfig(clientMetadata); } @@ -77,7 +83,9 @@ export class SlowLogController { }) @Patch('config') async updateConfig( - @ClientMetadataParam() clientMetadata: ClientMetadata, + @ClientMetadataParam({ + ignoreDbIndex: true, + }) clientMetadata: ClientMetadata, @Body() dto: UpdateSlowLogConfigDto, ): Promise { return this.service.updateConfig(clientMetadata, dto); diff --git a/redisinsight/api/src/modules/workbench/decorators/workbench-client-metadata.decorator.ts b/redisinsight/api/src/modules/workbench/decorators/workbench-client-metadata.decorator.ts index 3709ad8dd5..e0de3618c5 100644 --- a/redisinsight/api/src/modules/workbench/decorators/workbench-client-metadata.decorator.ts +++ b/redisinsight/api/src/modules/workbench/decorators/workbench-client-metadata.decorator.ts @@ -1,4 +1,4 @@ -import { API_PARAM_CLI_CLIENT_ID, API_PARAM_DATABASE_ID } from 'src/common/constants'; +import { API_PARAM_DATABASE_ID } from 'src/common/constants'; import { createParamDecorator } from '@nestjs/common'; import { ClientContext } from 'src/common/models'; import { clientMetadataParamFactory } from 'src/common/decorators'; From 44738515326594974cb125285eb78ea6270fdceb Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 28 Dec 2022 13:49:36 +0400 Subject: [PATCH 070/201] #RI-3942 - fix IT --- .../api/database-analysis/POST-databases-id-analysis.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index a8d1f413c7..9bdd4cf7a9 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -219,7 +219,7 @@ describe('POST /databases/:instanceId/analysis', () => { responseSchema, before: async () => { const NUMBERS_OF_HASH_FIELDS = 5001; - await rte.data.sendCommand(NUMBERS_OF_HASH_FIELDS, true); + await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBERS_OF_HASH_FIELDS, true); }, checkFn: async ({ body }) => { expect(body.recommendations).to.include.deep.members([ From 646e087767a02181f741147687dcba579fd2fbd4 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 28 Dec 2022 13:46:30 +0200 Subject: [PATCH 071/201] fix db > 0 logic --- redisinsight/api/src/modules/redis/redis-connection.factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.ts index d3197ae32d..71fa368973 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.ts @@ -157,7 +157,7 @@ export class RedisConnectionFactory { const connection = new Redis({ ...config, // cover cases when we are connecting to sentinel as to standalone to discover master groups - db: config.db > 0 && !database.sentinelMaster ? config.db : 0, + db: config.db >= 0 && !database.sentinelMaster ? config.db : 0, }); connection.on('error', (e): void => { this.logger.error('Failed connection to the redis database.', e); From cbf941214e06e068600bd27e221881fe798c13be Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Wed, 28 Dec 2022 15:00:01 +0300 Subject: [PATCH 072/201] #RI-3949 - initial fe implementation for changing db index --- .../ui/src/assets/img/icons/help_illus.svg | 1 + .../instance-header/InstanceHeader.tsx | 142 ++++++++++++++---- .../components/ShortInstanceInfo.tsx | 18 ++- .../components/styles.module.scss | 11 ++ .../instance-header/styles.module.scss | 20 ++- redisinsight/ui/src/constants/storage.ts | 3 +- .../browser-left-panel/BrowserLeftPanel.tsx | 18 ++- .../browser/components/key-list/KeyList.tsx | 10 ++ .../search-key-list/SearchKeyList.tsx | 1 - .../InstanceForm/InstanceForm.tsx | 11 -- redisinsight/ui/src/services/apiService.ts | 30 +++- redisinsight/ui/src/slices/app/context.ts | 18 ++- .../ui/src/slices/instances/instances.ts | 49 +++++- redisinsight/ui/src/slices/interfaces/app.ts | 3 + .../ui/src/slices/workbench/wb-results.ts | 11 +- .../themes/dark_theme/_dark_theme.lazy.scss | 1 + .../themes/dark_theme/_theme_color.scss | 1 + .../themes/light_theme/_light_theme.lazy.scss | 1 + .../themes/light_theme/_theme_color.scss | 1 + redisinsight/ui/src/telemetry/events.ts | 1 + 20 files changed, 299 insertions(+), 52 deletions(-) create mode 100644 redisinsight/ui/src/assets/img/icons/help_illus.svg diff --git a/redisinsight/ui/src/assets/img/icons/help_illus.svg b/redisinsight/ui/src/assets/img/icons/help_illus.svg new file mode 100644 index 0000000000..3928b5fb6c --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/help_illus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx b/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx index 6282dc4645..6c3945a24c 100644 --- a/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx +++ b/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx @@ -1,17 +1,34 @@ import React, { useEffect, useState } from 'react' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import cx from 'classnames' -import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiToolTip } from '@elastic/eui' +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiToolTip +} from '@elastic/eui' import { Pages } from 'uiSrc/constants' import { BuildType } from 'uiSrc/constants/env' import { ConnectionType } from 'uiSrc/slices/interfaces' -import { connectedInstanceOverviewSelector, connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { + checkDatabaseIndexAction, + connectedInstanceOverviewSelector, + connectedInstanceSelector +} from 'uiSrc/slices/instances/instances' import { appInfoSelector } from 'uiSrc/slices/app/info' import ShortInstanceInfo from 'uiSrc/components/instance-header/components/ShortInstanceInfo' import DatabaseOverviewWrapper from 'uiSrc/components/database-overview/DatabaseOverviewWrapper' +import { appContextDbIndex, clearBrowserKeyListData } from 'uiSrc/slices/app/context' +import InlineItemEditor from 'uiSrc/components/inline-item-editor' +import { selectOnFocus, validateNumber } from 'uiSrc/utils' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + import styles from './styles.module.scss' const InstanceHeader = () => { @@ -21,12 +38,19 @@ const InstanceHeader = () => { port = '', username, connectionType = ConnectionType.Standalone, - db = 0 + db = 0, + id, + loading: instanceLoading, } = useSelector(connectedInstanceSelector) const { version } = useSelector(connectedInstanceOverviewSelector) const { server } = useSelector(appInfoSelector) + const { disabled: isDbIndexDisabled } = useSelector(appContextDbIndex) const history = useHistory() const [windowDimensions, setWindowDimensions] = useState(0) + const [dbIndex, setDbIndex] = useState(String(db || 0)) + const [isDbIndexEditing, setIsDbIndexEditing] = useState(false) + + const dispatch = useDispatch() useEffect(() => { updateWindowDimensions() @@ -36,6 +60,8 @@ const InstanceHeader = () => { } }, []) + useEffect(() => { setDbIndex(String(db || 0)) }, [db]) + const updateWindowDimensions = () => { setWindowDimensions(globalThis.innerWidth) } @@ -44,6 +70,29 @@ const InstanceHeader = () => { history.push(Pages.home) } + const onChangeDbIndex = () => { + setIsDbIndexEditing(false) + + if (db === +dbIndex) return + + dispatch(checkDatabaseIndexAction( + id, + +dbIndex, + () => { + dispatch(clearBrowserKeyListData()) + sendEventTelemetry({ + event: TelemetryEvent.BROWSER_DATABASE_INDEX_CHANGED, + eventData: { + databaseId: id, + prevIndex: db, + nextIndex: +dbIndex + } + }) + }, + () => setDbIndex(String(db)) + )) + } + return (
@@ -66,33 +115,74 @@ const InstanceHeader = () => {
- - )} - > +
- {db > 0 ? `${name} [${db}]` : name} + {name} - - + +
+ {isDbIndexEditing ? ( +
+ setIsDbIndexEditing(false)} + viewChildrenMode={false} + controlsClassName={styles.controls} + > + setDbIndex(validateNumber(e.target.value.trim()))} + value={dbIndex} + placeholder="Database Index" + className={styles.input} + fullWidth={false} + compressed + autoComplete="off" + type="text" + data-testid="change-index-input" + /> + +
+ ) : ( + setIsDbIndexEditing(true)} + className={styles.buttonDbIndex} + disabled={isDbIndexDisabled || instanceLoading} + data-testid="change-index-btn" + > + db{db || 0} + + )} +
+
+ + + )} + > + +
- +
diff --git a/redisinsight/ui/src/components/instance-header/components/ShortInstanceInfo.tsx b/redisinsight/ui/src/components/instance-header/components/ShortInstanceInfo.tsx index 027faeeab0..ba3555ad7e 100644 --- a/redisinsight/ui/src/components/instance-header/components/ShortInstanceInfo.tsx +++ b/redisinsight/ui/src/components/instance-header/components/ShortInstanceInfo.tsx @@ -1,6 +1,6 @@ import React from 'react' import { capitalize } from 'lodash' -import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui' +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui' import { CONNECTION_TYPE_DISPLAY, ConnectionType } from 'uiSrc/slices/interfaces' import { Nullable } from 'uiSrc/utils' @@ -8,6 +8,7 @@ import { Nullable } from 'uiSrc/utils' import { ReactComponent as ConnectionIcon } from 'uiSrc/assets/img/icons/connection.svg' import { ReactComponent as UserIcon } from 'uiSrc/assets/img/icons/user.svg' import { ReactComponent as VersionIcon } from 'uiSrc/assets/img/icons/version.svg' +import MessageInfoIcon from 'uiSrc/assets/img/icons/help_illus.svg' import styles from './styles.module.scss' @@ -23,11 +24,11 @@ export interface Props { } } const ShortInstanceInfo = ({ info }: Props) => { - const { name, host, port, connectionType, version, user, dbIndex } = info + const { name, host, port, connectionType, version, user } = info return (
- {dbIndex > 0 ? `${name} [${dbIndex}]` : name } + {name}
@@ -36,6 +37,17 @@ const ShortInstanceInfo = ({ info }: Props) => { {port}
+ + + + + + Logical Databases + + Select logical databases to work with in Browser, Workbench, and Database Analysis. + + + void } +const initialKeyStateData: KeysStoreData = { + total: 0, + scanned: 0, + nextCursor: '0', + keys: [], + shardsMeta: {}, + previousResultCount: 0, + lastRefreshTime: null, +} + const BrowserLeftPanel = (props: Props) => { const { selectKey, @@ -56,7 +66,9 @@ const BrowserLeftPanel = (props: Props) => { const dispatch = useDispatch() const isDataLoaded = searchMode === SearchMode.Pattern ? isDataPatternLoaded : isDataRedisearchLoaded - const keysState = searchMode === SearchMode.Pattern ? patternKeysState : redisearchKeysState + const keysState = !isDataLoaded + ? initialKeyStateData + : (searchMode === SearchMode.Pattern ? patternKeysState : redisearchKeysState) const loading = searchMode === SearchMode.Pattern ? patternLoading : redisearchLoading || redisearchListLoading const isSearched = searchMode === SearchMode.Pattern ? patternIsSearched : redisearchIsSearched const scrollTopPosition = searchMode === SearchMode.Pattern ? scrollPatternTopPosition : scrollRedisearchTopPosition @@ -65,7 +77,7 @@ const BrowserLeftPanel = (props: Props) => { if ((!isDataLoaded || contextInstanceId !== instanceId) && searchMode === SearchMode.Pattern) { loadKeys(viewType) } - }, [searchMode]) + }, [searchMode, isDataLoaded]) const loadKeys = useCallback((keyViewType: KeyViewType = KeyViewType.Browser) => { dispatch(setConnectedInstanceId(instanceId)) diff --git a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx index 254bfe3daf..60a31b81be 100644 --- a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx @@ -166,9 +166,19 @@ const KeyList = forwardRef((props: Props, ref) => { if (isSearched) { return keysState.scanned < total ? ScanNoResultsFoundText : FullScanNoResultsFoundText } + if (isFiltered && keysState.scanned < total) { return ScanNoResultsFoundText } + + if (keysState.scanned === 0 && total) { + return 'loading...' + } + + if (itemsRef.current.length < keysState.keys.length) { + return 'loading...' + } + return NoResultsFoundText } diff --git a/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx b/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx index 03481b78b0..270fbd8259 100644 --- a/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx @@ -7,7 +7,6 @@ import MultiSearch from 'uiSrc/components/multi-search/MultiSearch' import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' import { replaceSpaces } from 'uiSrc/utils' import { fetchKeys, keysSelector, setFilter, setSearchMatch } from 'uiSrc/slices/browser/keys' -import { resetBrowserTree } from 'uiSrc/slices/app/context' import { SearchMode, KeyViewType } from 'uiSrc/slices/interfaces/keys' import { redisearchSelector } from 'uiSrc/slices/browser/redisearch' diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx index 8876c0966f..2a4285c599 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx @@ -1,7 +1,6 @@ import { EuiButton, EuiButtonIcon, - EuiCallOut, EuiCheckbox, EuiCollapsibleNavGroup, EuiFieldNumber, @@ -813,16 +812,6 @@ const AddStandaloneForm = (props: Props) => { - - - - When the database is added, you can select logical databases only in CLI. - To work with other logical databases in Browser and Workbench, - add another database with the same host and port, - but a different database index. - - - { + if (config?.headers) { + const [instanceId] = (/(?<=databases\/)(.*?)(?=\/)/gi).exec(config.url || '') || [] + + if (instanceId) { + const dbIndex = sessionStorageService.get(`${BrowserStorageItem.dbIndex}${instanceId}`) + + if (isNumber(dbIndex)) { + config.headers['ri-db-index'] = dbIndex + } + } + } + + return config +} + +axiosInstance.interceptors.request.use( + requestInterceptor, + (error) => Promise.reject(error) +) + +export default axiosInstance diff --git a/redisinsight/ui/src/slices/app/context.ts b/redisinsight/ui/src/slices/app/context.ts index 2ae1930e32..9b479464bf 100644 --- a/redisinsight/ui/src/slices/app/context.ts +++ b/redisinsight/ui/src/slices/app/context.ts @@ -15,6 +15,9 @@ export const initialState: StateAppContext = { treeViewDelimiter: DEFAULT_DELIMITER, slowLogDurationUnit: DEFAULT_SLOWLOG_DURATION_UNIT }, + dbIndex: { + disabled: false + }, browser: { keyList: { isDataPatternLoaded: false, @@ -107,6 +110,12 @@ const appContextSlice = createSlice({ setBrowserIsNotRendered: (state, { payload }: { payload: boolean }) => { state.browser.keyList.isNotRendered = payload }, + clearBrowserKeyListData: (state) => { + state.browser.keyList = { + ...initialState.browser.keyList, + selectedKey: state.browser.keyList.selectedKey + } + }, setBrowserPanelSizes: (state, { payload }: { payload: any }) => { state.browser.panelSizes = payload }, @@ -186,6 +195,9 @@ const appContextSlice = createSlice({ const { type, sizes } = payload state.browser.keyDetailsSizes[type] = sizes localStorageService?.set(BrowserStorageItem.keyDetailSizes, state.browser.keyDetailsSizes) + }, + setDbIndexState: (state, { payload }: { payload: boolean }) => { + state.dbIndex.disabled = payload } }, }) @@ -218,7 +230,9 @@ export const { setPubSubFieldsContext, setBrowserBulkActionOpen, setLastAnalyticsPage, - updateKeyDetailsSizes + updateKeyDetailsSizes, + clearBrowserKeyListData, + setDbIndexState } = appContextSlice.actions // Selectors @@ -242,6 +256,8 @@ export const appContextPubSub = (state: RootState) => state.app.context.pubsub export const appContextAnalytics = (state: RootState) => state.app.context.analytics +export const appContextDbIndex = (state: RootState) => + state.app.context.dbIndex // The reducer export default appContextSlice.reducer diff --git a/redisinsight/ui/src/slices/instances/instances.ts b/redisinsight/ui/src/slices/instances/instances.ts index 36f0bf7d9e..f414d8d413 100644 --- a/redisinsight/ui/src/slices/instances/instances.ts +++ b/redisinsight/ui/src/slices/instances/instances.ts @@ -3,7 +3,7 @@ import { createSlice } from '@reduxjs/toolkit' import axios, { AxiosError, CancelTokenSource } from 'axios' import ApiErrors from 'uiSrc/constants/apiErrors' -import { apiService, localStorageService } from 'uiSrc/services' +import { apiService, localStorageService, sessionStorageService } from 'uiSrc/services' import { ApiEndpoints, BrowserStorageItem } from 'uiSrc/constants' import { setAppContextInitialState } from 'uiSrc/slices/app/context' import successMessages from 'uiSrc/components/notifications/success-messages' @@ -159,6 +159,7 @@ const instancesSlice = createSlice({ state.connectedInstance = payload state.connectedInstance.loading = false state.connectedInstance.isRediStack = isRediStack || false + state.connectedInstance.db = sessionStorageService.get(`${BrowserStorageItem.dbIndex}${payload.id}`) || payload.db }, // set edited instance @@ -192,6 +193,19 @@ const instancesSlice = createSlice({ resetImportInstances: (state) => { state.importInstances = initialState.importInstances + }, + + checkDatabaseIndex: (state) => { + state.connectedInstance.loading = true + }, + checkDatabaseIndexSuccess: (state, { payload }) => { + state.connectedInstance.db = payload + state.connectedInstance.loading = false + + sessionStorageService.set(`${BrowserStorageItem.dbIndex}${state.connectedInstance.id}`, payload) + }, + checkDatabaseIndexFailure: (state) => { + state.connectedInstance.loading = false } }, }) @@ -223,7 +237,10 @@ export const { importInstancesFromFile, importInstancesFromFileSuccess, importInstancesFromFileFailure, - resetImportInstances + resetImportInstances, + checkDatabaseIndex, + checkDatabaseIndexSuccess, + checkDatabaseIndexFailure, } = instancesSlice.actions // selectors @@ -509,6 +526,34 @@ export function changeInstanceAliasAction( } } +export function checkDatabaseIndexAction( + id: string, + index: number, + onSuccessAction?: () => void, + onFailAction?: () => void +) { + return async (dispatch: AppDispatch) => { + dispatch(checkDatabaseIndex()) + + try { + // TODO - update url + const { status } = await apiService.get( + `${ApiEndpoints.DATABASES}/${id}/db/${index}` + ) + + if (isStatusSuccessful(status)) { + dispatch(checkDatabaseIndexSuccess(index)) + onSuccessAction?.() + } + } catch (_err) { + const error = _err as AxiosError + dispatch(checkDatabaseIndexFailure()) + dispatch(addErrorNotification(error)) + onFailAction?.() + } + } +} + export function resetInstanceUpdateAction() { return async (dispatch: AppDispatch) => { dispatch(resetInstanceUpdate()) diff --git a/redisinsight/ui/src/slices/interfaces/app.ts b/redisinsight/ui/src/slices/interfaces/app.ts index af806062a4..fabdfb69ac 100644 --- a/redisinsight/ui/src/slices/interfaces/app.ts +++ b/redisinsight/ui/src/slices/interfaces/app.ts @@ -42,6 +42,9 @@ export interface StateAppContext { treeViewDelimiter: string slowLogDurationUnit: DurationUnits } + dbIndex: { + disabled: boolean + } browser: { keyList: { isDataPatternLoaded: boolean diff --git a/redisinsight/ui/src/slices/workbench/wb-results.ts b/redisinsight/ui/src/slices/workbench/wb-results.ts index 75674e3c52..937e1f97e6 100644 --- a/redisinsight/ui/src/slices/workbench/wb-results.ts +++ b/redisinsight/ui/src/slices/workbench/wb-results.ts @@ -21,6 +21,7 @@ import { CommandExecution, StateWorkbenchResults, } from '../interfaces' +import { setDbIndexState } from 'uiSrc/slices/app/context' export const initialState: StateWorkbenchResults = { loading: false, @@ -247,6 +248,8 @@ export function sendWBCommandAction({ commandId })) + dispatch(setDbIndexState(true)) + const { data, status } = await apiService.post( getUrl( id, @@ -261,6 +264,7 @@ export function sendWBCommandAction({ if (isStatusSuccessful(status)) { dispatch(sendWBCommandSuccess({ commandId, data: reverse(data), processing: !!multiCommands?.length })) + dispatch(setDbIndexState(!!multiCommands?.length)) onSuccessAction?.(multiCommands) } } catch (_err) { @@ -271,6 +275,7 @@ export function sendWBCommandAction({ commandsId: commands.map((_, i) => commandId + i), error: errorMessage })) + dispatch(setDbIndexState(false)) onFailAction?.() } } @@ -306,6 +311,8 @@ export function sendWBCommandClusterAction({ commandId })) + dispatch(setDbIndexState(true)) + const { data, status } = await apiService.post( getUrl( id, @@ -321,7 +328,8 @@ export function sendWBCommandClusterAction({ ) if (isStatusSuccessful(status)) { - dispatch(sendWBCommandSuccess({ commandId, data: reverse(data) })) + dispatch(sendWBCommandSuccess({ commandId, data: reverse(data), processing: !!multiCommands?.length })) + dispatch(setDbIndexState(!!multiCommands?.length)) onSuccessAction?.(multiCommands) } } catch (_err) { @@ -332,6 +340,7 @@ export function sendWBCommandClusterAction({ commandsId: commands.map((_, i) => commandId + i), error: errorMessage })) + dispatch(setDbIndexState(false)) onFailAction?.() } } diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index 8bf11c6c6c..fd19e263b9 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss @@ -126,6 +126,7 @@ --moduleBackgroundColor: #{$commandGroupBadgeColor}; --callOutBackgroundColor: #{$euiTooltipBackgroundColor}; + --tooltipLightBgColor: #{$tooltipLightBgColor}; --overlayPromoNYColor: #{$overlayPromoNYColor}; diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss index 3805a4afc1..5688fd4353 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -86,6 +86,7 @@ $cliOutputResponseFailColor: #e06c75; $badgeBackgroundColor: #707070; $commandGroupBadgeColor: #3f4b5f; +$tooltipLightBgColor: #455064; $overlayPromoNYColor: #0000001a; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss index 1db959d99f..a80c38cbde 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss @@ -128,6 +128,7 @@ --moduleBackgroundColor: #{$typeHashColor}; --callOutBackgroundColor: #{$callOutBackgroundColor}; + --tooltipLightBgColor: #{$tooltipLightBgColor}; --overlayPromoNYColor: #{$overlayPromoNYColor}; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss index b359cf7102..135cdb0bdd 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -83,6 +83,7 @@ $cliOutputResponseFailColor: #ad0017; $badgeBackgroundColor: #e8efff; $commandGroupBadgeColor: #b8c5db; $callOutBackgroundColor: #e9edfa; +$tooltipLightBgColor: #e9eaef; $overlayPromoNYColor: #ffffff1a; diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index 435ae4db64..345dcb5471 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -72,6 +72,7 @@ export enum TelemetryEvent { BROWSER_KEY_FIELD_VALUE_COLLAPSED = 'BROWSER_KEY_FIELD_VALUE_COLLAPSED', BROWSER_KEY_DETAILS_FORMATTER_CHANGED = 'BROWSER_KEY_DETAILS_FORMATTER_CHANGED', BROWSER_WORKBENCH_LINK_CLICKED = 'BROWSER_WORKBENCH_LINK_CLICKED', + BROWSER_DATABASE_INDEX_CHANGED= 'BROWSER_DATABASE_INDEX_CHANGED', CLI_OPENED = 'CLI_OPENED', CLI_CLOSED = 'CLI_CLOSED', From b58588a0742551cfb1a7013ef49bca1828a3489d Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 28 Dec 2022 15:02:23 +0200 Subject: [PATCH 073/201] fix switch --- .../src/modules/redis/redis-connection.factory.ts | 2 +- redisinsight/api/src/modules/redis/redis.service.ts | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.ts index 71fa368973..d3197ae32d 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.ts @@ -157,7 +157,7 @@ export class RedisConnectionFactory { const connection = new Redis({ ...config, // cover cases when we are connecting to sentinel as to standalone to discover master groups - db: config.db >= 0 && !database.sentinelMaster ? config.db : 0, + db: config.db > 0 && !database.sentinelMaster ? config.db : 0, }); connection.on('error', (e): void => { this.logger.error('Failed connection to the redis database.', e); diff --git a/redisinsight/api/src/modules/redis/redis.service.ts b/redisinsight/api/src/modules/redis/redis.service.ts index 4e5ac2ba52..3a8e3434ee 100644 --- a/redisinsight/api/src/modules/redis/redis.service.ts +++ b/redisinsight/api/src/modules/redis/redis.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import Redis, { Cluster } from 'ioredis'; import { - isMatch, omit, + isMatch, omit, isNumber, pick, } from 'lodash'; import apiConfig from 'src/utils/config'; import { ClientMetadata } from 'src/common/models'; @@ -48,7 +48,11 @@ export class RedisService { if (found) { found.lastTimeUsed = Date.now(); } - this.syncClients(); + + console.log('___ getting client instance: ID', RedisService.generateId(clientMetadata)) + console.log('___ getting client instance: all clients', [...this.clients.values()].map((v) => pick(v, 'id'))) + + // this.syncClients(); return found; } @@ -72,6 +76,9 @@ export class RedisService { this.clients.set(id, clientInstance); + console.log('___ set client instance: ID', RedisService.generateId(clientMetadata)) + console.log('___ set client instance: all clients', [...this.clients.values()].map((v) => pick(v, 'id'))) + return 1; } @@ -128,7 +135,7 @@ export class RedisService { cm.databaseId, cm.context, cm.uniqueId || empty, - cm.db || empty, + isNumber(cm.db) ? cm.db : empty, ].join(separator); // const uId = [ From 36458f400a9d26c02f16293a07d443e675fe59bd Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Wed, 28 Dec 2022 16:17:45 +0300 Subject: [PATCH 074/201] #RI-3949 - fix db index from session storage --- redisinsight/ui/src/slices/instances/instances.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/ui/src/slices/instances/instances.ts b/redisinsight/ui/src/slices/instances/instances.ts index f414d8d413..99b138a44f 100644 --- a/redisinsight/ui/src/slices/instances/instances.ts +++ b/redisinsight/ui/src/slices/instances/instances.ts @@ -159,7 +159,7 @@ const instancesSlice = createSlice({ state.connectedInstance = payload state.connectedInstance.loading = false state.connectedInstance.isRediStack = isRediStack || false - state.connectedInstance.db = sessionStorageService.get(`${BrowserStorageItem.dbIndex}${payload.id}`) || payload.db + state.connectedInstance.db = sessionStorageService.get(`${BrowserStorageItem.dbIndex}${payload.id}`) ?? payload.db }, // set edited instance From 2132c24b6d8aeeeafca48db481caa59c90bc042b Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 28 Dec 2022 15:42:44 +0200 Subject: [PATCH 075/201] #RI-2409 workaround for cli clients --- .../api/src/modules/redis/redis.service.ts | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/redisinsight/api/src/modules/redis/redis.service.ts b/redisinsight/api/src/modules/redis/redis.service.ts index 3a8e3434ee..e139a32958 100644 --- a/redisinsight/api/src/modules/redis/redis.service.ts +++ b/redisinsight/api/src/modules/redis/redis.service.ts @@ -1,10 +1,8 @@ import { Injectable } from '@nestjs/common'; import Redis, { Cluster } from 'ioredis'; -import { - isMatch, omit, isNumber, pick, -} from 'lodash'; +import { isMatch, isNumber, omit } from 'lodash'; import apiConfig from 'src/utils/config'; -import { ClientMetadata } from 'src/common/models'; +import { ClientContext, ClientMetadata } from 'src/common/models'; const REDIS_CLIENTS_CONFIG = apiConfig.get('redis_clients'); @@ -43,26 +41,26 @@ export class RedisService { * @param clientMetadata */ public getClientInstance(clientMetadata: ClientMetadata): IRedisClientInstance { - const found = this.clients.get(RedisService.generateId(clientMetadata)); + const metadata = RedisService.prepareClientMetadata(clientMetadata); + + const found = this.clients.get(RedisService.generateId(metadata)); if (found) { found.lastTimeUsed = Date.now(); } - console.log('___ getting client instance: ID', RedisService.generateId(clientMetadata)) - console.log('___ getting client instance: all clients', [...this.clients.values()].map((v) => pick(v, 'id'))) - - // this.syncClients(); return found; } public setClientInstance(clientMetadata: ClientMetadata, client): 0 | 1 { - const id = RedisService.generateId(clientMetadata); + const metadata = RedisService.prepareClientMetadata(clientMetadata); + + const id = RedisService.generateId(metadata); const found = this.clients.get(id); const clientInstance = { id, - clientMetadata, + clientMetadata: metadata, client, lastTimeUsed: Date.now(), }; @@ -76,14 +74,13 @@ export class RedisService { this.clients.set(id, clientInstance); - console.log('___ set client instance: ID', RedisService.generateId(clientMetadata)) - console.log('___ set client instance: all clients', [...this.clients.values()].map((v) => pick(v, 'id'))) - return 1; } public removeClientInstance(clientMetadata: ClientMetadata): number { - const id = RedisService.generateId(clientMetadata); + const metadata = RedisService.prepareClientMetadata(clientMetadata); + + const id = RedisService.generateId(metadata); const found = this.clients.get(id); if (found) { @@ -123,6 +120,20 @@ export class RedisService { } } + /** + * @param clientMetadata + */ + static prepareClientMetadata(clientMetadata: ClientMetadata): ClientMetadata { + return { + ...clientMetadata, + // Workaround: for cli connections we must ignore db index when storing/getting client + // since inside CLI itself users are able to "select" database manually + // uniqueness will be guaranteed by ClientMetadata.uniqueId and each opened CLI terminal + // will have own and a single client + db: clientMetadata.context === ClientContext.CLI ? null : clientMetadata.db, + }; + } + /** * Generate client id string based on client metadata * @param cm From 5307ba835b0c3c059c12fe94c755583e5f5a3b61 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 29 Dec 2022 11:45:37 +0400 Subject: [PATCH 076/201] #RI-3942 - resolve comments --- .../recommendations-view/Recommendations.tsx | 104 +++++++++--------- 1 file changed, 53 insertions(+), 51 deletions(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index 6ff1b2796b..4d7c9344c4 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -38,6 +38,31 @@ const Recommendations = () => { } }) + const sortedRecommendations = sortBy(recommendations, ({ name }) => + (recommendationsContent[name]?.redisStack ? -1 : 0)) + + const renderButtonContent = (redisStack: boolean, title: string, badges: string[]) => ( + + + + {redisStack && ( + + )} + + + {title} + + + + {renderBadges(badges)} + + + ) + if (loading) { return (
@@ -57,58 +82,35 @@ const Recommendations = () => {
{renderBadgesLegend()}
- {sortBy(recommendations, ({ name }) => - (recommendationsContent[name]?.redisStack ? -1 : 0)) - ?.map(({ name }) => { - const { - id = '', - title = '', - content = '', - badges = [], - redisStack = false - } = recommendationsContent[name] + {sortedRecommendations.map(({ name }) => { + const { + id = '', + title = '', + content = '', + badges = [], + redisStack = false + } = recommendationsContent[name] - const buttonContent = ( - - - - {redisStack && ( - - )} - - - {title} - - - - {renderBadges(badges)} - - - ) - return ( -
- handleToggle(isOpen, id)} - data-testid={`${id}-accordion`} - > - - {renderContent(content)} - - -
- ) - })} + return ( +
+ handleToggle(isOpen, id)} + data-testid={`${id}-accordion`} + > + + {renderContent(content)} + + +
+ ) + })}
) } From 49913f1facbcce20219f05b160aee8a3085557f6 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 29 Dec 2022 10:34:26 +0200 Subject: [PATCH 077/201] #RI-2409 tests --- redisinsight/api/src/__mocks__/redis.ts | 2 + .../autodiscovery.service.spec.ts | 17 +- .../browser-tool-cluster.service.spec.ts | 7 +- .../browser-tool/browser-tool.service.spec.ts | 7 +- .../database-connection.service.spec.ts | 17 +- .../modules/database/database.service.spec.ts | 13 +- .../providers/database.factory.spec.ts | 7 +- .../redis-sentinel.service.spec.ts | 13 +- .../redis/redis-connection.factory.spec.ts | 313 +++++++----- .../modules/redis/redis-connection.factory.ts | 16 +- .../redis-consumer.abstract.service.spec.ts | 39 +- .../src/modules/redis/redis.service.spec.ts | 472 ++++++------------ .../api/src/modules/redis/redis.service.ts | 12 +- 13 files changed, 444 insertions(+), 491 deletions(-) diff --git a/redisinsight/api/src/__mocks__/redis.ts b/redisinsight/api/src/__mocks__/redis.ts index 239fcebcf6..c1cef9dc9a 100644 --- a/redisinsight/api/src/__mocks__/redis.ts +++ b/redisinsight/api/src/__mocks__/redis.ts @@ -71,6 +71,8 @@ export const mockRedisService = jest.fn(() => ({ setClientInstance: jest.fn(), isClientConnected: jest.fn().mockReturnValue(true), removeClientInstance: jest.fn(), + removeClientInstances: jest.fn(), + findClientInstances: jest.fn(), })); export const mockRedisConnectionFactory = jest.fn(() => ({ diff --git a/redisinsight/api/src/modules/autodiscovery/autodiscovery.service.spec.ts b/redisinsight/api/src/modules/autodiscovery/autodiscovery.service.spec.ts index b8e5054323..1ae99f1e6a 100644 --- a/redisinsight/api/src/modules/autodiscovery/autodiscovery.service.spec.ts +++ b/redisinsight/api/src/modules/autodiscovery/autodiscovery.service.spec.ts @@ -4,17 +4,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { mockAutodiscoveryEndpoint, mockDatabaseService, - mockIORedisClient, + mockIORedisClient, mockRedisConnectionFactory, mockRedisServerInfoResponse, - mockRedisService, mockServerConfig, + mockServerConfig, mockSettingsService, MockType, } from 'src/__mocks__'; import { SettingsService } from 'src/modules/settings/settings.service'; import { AutodiscoveryService } from 'src/modules/autodiscovery/autodiscovery.service'; import { DatabaseService } from 'src/modules/database/database.service'; -import { RedisService } from 'src/modules/redis/redis.service'; import { mocked } from 'ts-jest/utils'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; jest.mock( 'src/modules/autodiscovery/utils/autodiscovery.util', @@ -38,7 +38,7 @@ describe('AutodiscoveryService', () => { let service: AutodiscoveryService; let settingsService: MockType; let databaseService: MockType; - let redisService: MockType; + let redisConnectionFactory: MockType; beforeEach(async () => { jest.clearAllMocks(); @@ -54,8 +54,8 @@ describe('AutodiscoveryService', () => { useFactory: mockDatabaseService, }, { - provide: RedisService, - useFactory: mockRedisService, + provide: RedisConnectionFactory, + useFactory: mockRedisConnectionFactory, }, ], }).compile(); @@ -63,9 +63,8 @@ describe('AutodiscoveryService', () => { service = module.get(AutodiscoveryService); settingsService = module.get(SettingsService); databaseService = module.get(DatabaseService); - redisService = await module.get(RedisService); + redisConnectionFactory = await module.get(RedisConnectionFactory); - redisService.createStandaloneClient.mockResolvedValue(mockIORedisClient); mockIORedisClient.info.mockResolvedValue(mockRedisServerInfoResponse); mocked(utils.convertRedisInfoReplyToObject).mockReturnValue({ @@ -187,7 +186,7 @@ describe('AutodiscoveryService', () => { }); it('should not fail in case of an error', async () => { - redisService.createStandaloneClient.mockRejectedValue(new Error()); + redisConnectionFactory.createStandaloneConnection.mockRejectedValue(new Error()); await service['addRedisDatabase'](mockAutodiscoveryEndpoint); diff --git a/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.spec.ts b/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.spec.ts index 68f567b956..918f1f1c7d 100644 --- a/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.spec.ts +++ b/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.spec.ts @@ -15,7 +15,8 @@ import { import { ClusterNodeNotFoundError } from 'src/modules/cli/constants/errors'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { DatabaseService } from 'src/modules/database/database.service'; -import { mockBrowserClientMetadata } from 'src/__mocks__'; +import { mockBrowserClientMetadata, mockRedisConnectionFactory } from 'src/__mocks__'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; const mockClient = new Redis(); const mockCluster = new Redis.Cluster([]); @@ -42,6 +43,10 @@ describe('BrowserToolClusterService', () => { provide: RedisService, useFactory: () => ({}), }, + { + provide: RedisConnectionFactory, + useFactory: mockRedisConnectionFactory, + }, { provide: DatabaseService, useFactory: () => ({}), diff --git a/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.spec.ts b/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.spec.ts index f2de6b44ff..e9cbcd8b72 100644 --- a/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.spec.ts +++ b/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import * as IORedis from 'ioredis'; import * as Redis from 'ioredis-mock'; -import { mockBrowserClientMetadata } from 'src/__mocks__'; +import { mockBrowserClientMetadata, mockRedisConnectionFactory } from 'src/__mocks__'; import { RedisService, } from 'src/modules/redis/redis.service'; @@ -15,6 +15,7 @@ import { InternalServerErrorException } from '@nestjs/common'; import { mockKeyDto } from 'src/modules/browser/__mocks__'; import { RedisString } from 'src/common/constants'; import { DatabaseService } from 'src/modules/database/database.service'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; const mockClient = new Redis(); const mockConnectionErrorMessage = 'Could not connect to localhost, please check the connection details.'; @@ -34,6 +35,10 @@ describe('BrowserToolService', () => { provide: RedisService, useFactory: () => ({}), }, + { + provide: RedisConnectionFactory, + useFactory: mockRedisConnectionFactory, + }, { provide: DatabaseService, useFactory: () => ({}), 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 dbc4801819..c564b79a99 100644 --- a/redisinsight/api/src/modules/database/database-connection.service.spec.ts +++ b/redisinsight/api/src/modules/database/database-connection.service.spec.ts @@ -7,7 +7,7 @@ import { mockDatabaseInfoProvider, mockDatabaseRepository, mockDatabaseService, - mockIORedisClient, + mockIORedisClient, mockRedisConnectionFactory, mockRedisNoAuthError, mockRedisService, MockType, @@ -19,10 +19,12 @@ import { RedisService } from 'src/modules/redis/redis.service'; import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider'; import { DatabaseConnectionService } from 'src/modules/database/database-connection.service'; import ERROR_MESSAGES from 'src/constants/error-messages'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; describe('DatabaseConnectionService', () => { let service: DatabaseConnectionService; let redisService: MockType; + let redisConnectionFactory: MockType; let analytics: MockType; beforeEach(async () => { @@ -39,6 +41,10 @@ describe('DatabaseConnectionService', () => { provide: RedisService, useFactory: mockRedisService, }, + { + provide: RedisConnectionFactory, + useFactory: mockRedisConnectionFactory, + }, { provide: DatabaseInfoProvider, useFactory: mockDatabaseInfoProvider, @@ -56,26 +62,27 @@ describe('DatabaseConnectionService', () => { service = await module.get(DatabaseConnectionService); redisService = await module.get(RedisService); + redisConnectionFactory = await module.get(RedisConnectionFactory); analytics = await module.get(DatabaseAnalytics); }); describe('connect', () => { it('should connect to database', async () => { expect(await service.connect(mockCommonClientMetadata)).toEqual(undefined); - expect(redisService.connectToDatabaseInstance).not.toHaveBeenCalled(); + expect(redisConnectionFactory.createRedisConnection).not.toHaveBeenCalled(); }); }); describe('getOrCreateClient', () => { it('should get existing client', async () => { expect(await service.getOrCreateClient(mockCommonClientMetadata)).toEqual(mockIORedisClient); - expect(redisService.connectToDatabaseInstance).not.toHaveBeenCalled(); + expect(redisConnectionFactory.createRedisConnection).not.toHaveBeenCalled(); }); it('should create new and save it client', async () => { redisService.getClientInstance.mockResolvedValue(null); expect(await service.getOrCreateClient(mockCommonClientMetadata)).toEqual(mockIORedisClient); - expect(redisService.connectToDatabaseInstance).toHaveBeenCalled(); + expect(redisConnectionFactory.createRedisConnection).toHaveBeenCalled(); expect(redisService.setClientInstance).toHaveBeenCalled(); }); }); @@ -85,7 +92,7 @@ describe('DatabaseConnectionService', () => { expect(await service.createClient(mockCommonClientMetadata)).toEqual(mockIORedisClient); }); it('should throw Unauthorized error in case of NOAUTH', async () => { - redisService.connectToDatabaseInstance.mockRejectedValueOnce(mockRedisNoAuthError); + redisConnectionFactory.createRedisConnection.mockRejectedValueOnce(mockRedisNoAuthError); await expect(service.createClient(mockCommonClientMetadata)).rejects.toThrow(UnauthorizedException); expect(analytics.sendConnectionFailedEvent).toHaveBeenCalledWith( mockDatabase, diff --git a/redisinsight/api/src/modules/database/database.service.spec.ts b/redisinsight/api/src/modules/database/database.service.spec.ts index 3d6bb3dff4..aa0e745e7a 100644 --- a/redisinsight/api/src/modules/database/database.service.spec.ts +++ b/redisinsight/api/src/modules/database/database.service.spec.ts @@ -3,7 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { mockDatabase, mockDatabaseAnalytics, mockDatabaseFactory, mockDatabaseInfoProvider, mockDatabaseRepository, - mockRedisService, MockType, mockRedisGeneralInfo, + mockRedisService, MockType, mockRedisGeneralInfo, mockRedisConnectionFactory, } from 'src/__mocks__'; import { DatabaseAnalytics } from 'src/modules/database/database.analytics'; import { DatabaseService } from 'src/modules/database/database.service'; @@ -12,11 +12,12 @@ import { RedisService } from 'src/modules/redis/redis.service'; import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider'; import { DatabaseFactory } from 'src/modules/database/providers/database.factory'; import { UpdateDatabaseDto } from 'src/modules/database/dto/update.database.dto'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; describe('DatabaseService', () => { let service: DatabaseService; let databaseRepository: MockType; - let redisService: MockType; + let redisConnectionFactory: MockType; let analytics: MockType; beforeEach(async () => { @@ -34,6 +35,10 @@ describe('DatabaseService', () => { provide: RedisService, useFactory: mockRedisService, }, + { + provide: RedisConnectionFactory, + useFactory: mockRedisConnectionFactory, + }, { provide: DatabaseInfoProvider, useFactory: mockDatabaseInfoProvider, @@ -51,7 +56,7 @@ describe('DatabaseService', () => { service = await module.get(DatabaseService); databaseRepository = await module.get(DatabaseRepository); - redisService = await module.get(RedisService); + redisConnectionFactory = await module.get(RedisConnectionFactory); analytics = await module.get(DatabaseAnalytics); }); @@ -91,7 +96,7 @@ describe('DatabaseService', () => { expect(analytics.sendInstanceAddFailedEvent).not.toHaveBeenCalled(); }); it('should not fail when collecting data for analytics event', async () => { - redisService.connectToDatabaseInstance.mockRejectedValueOnce(new Error()); + redisConnectionFactory.createRedisConnection.mockRejectedValueOnce(new Error()); expect(await service.create(mockDatabase)).toEqual(mockDatabase); expect(analytics.sendInstanceAddedEvent).not.toHaveBeenCalled(); expect(analytics.sendInstanceAddFailedEvent).not.toHaveBeenCalled(); diff --git a/redisinsight/api/src/modules/database/providers/database.factory.spec.ts b/redisinsight/api/src/modules/database/providers/database.factory.spec.ts index 088f322a14..be0fd6ac4b 100644 --- a/redisinsight/api/src/modules/database/providers/database.factory.spec.ts +++ b/redisinsight/api/src/modules/database/providers/database.factory.spec.ts @@ -4,7 +4,7 @@ import { mockClientCertificateService, mockClusterDatabaseWithTlsAuth, mockDatabase, mockDatabaseInfoProvider, mockDatabaseWithTlsAuth, - mockIORedisClient, mockIORedisCluster, mockIORedisSentinel, + mockIORedisClient, mockIORedisCluster, mockIORedisSentinel, mockRedisConnectionFactory, mockRedisNoPermError, mockRedisService, mockSentinelDatabaseWithTlsAuth, @@ -19,6 +19,7 @@ import { ConnectionType } from 'src/modules/database/entities/database.entity'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { NotFoundException } from '@nestjs/common'; import { RedisErrorCodes } from 'src/constants'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; describe('DatabaseFactory', () => { let service: DatabaseFactory; @@ -34,6 +35,10 @@ describe('DatabaseFactory', () => { provide: RedisService, useFactory: mockRedisService, }, + { + provide: RedisConnectionFactory, + useFactory: mockRedisConnectionFactory, + }, { provide: DatabaseInfoProvider, useFactory: mockDatabaseInfoProvider, diff --git a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.spec.ts b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.spec.ts index 384a8ebcd4..c3f127d923 100644 --- a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.spec.ts +++ b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.spec.ts @@ -6,7 +6,7 @@ import { mockDatabaseFactory, mockDatabaseInfoProvider, mockDatabaseService, - mockIORedisClient, + mockIORedisClient, mockRedisConnectionFactory, mockRedisSentinelAnalytics, mockRedisSentinelMasterResponse, mockRedisService, mockSentinelDatabaseWithTlsAuth, mockSentinelMasterDto, @@ -17,10 +17,12 @@ import { RedisSentinelAnalytics } from 'src/modules/redis-sentinel/redis-sentine import { DatabaseService } from 'src/modules/database/database.service'; import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider'; import { DatabaseFactory } from 'src/modules/database/providers/database.factory'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; describe('RedisSentinelService', () => { let service: RedisSentinelService; let redisService: MockType; + let redisConnectionFactory: MockType; let databaseService: MockType; let databaseInfoProvider: MockType; @@ -36,6 +38,10 @@ describe('RedisSentinelService', () => { provide: RedisService, useFactory: mockRedisService, }, + { + provide: RedisConnectionFactory, + useFactory: mockRedisConnectionFactory, + }, { provide: DatabaseService, useFactory: mockDatabaseService, @@ -53,13 +59,14 @@ describe('RedisSentinelService', () => { service = module.get(RedisSentinelService); redisService = module.get(RedisService); + redisConnectionFactory = module.get(RedisConnectionFactory); databaseService = module.get(DatabaseService); databaseInfoProvider = module.get(DatabaseInfoProvider); }); describe('getSentinelMasters', () => { it('connect and get sentinel masters', async () => { - redisService.createStandaloneClient.mockResolvedValue(mockIORedisClient); + redisConnectionFactory.createStandaloneConnection.mockResolvedValue(mockIORedisClient); mockIORedisClient.call.mockResolvedValue(mockRedisSentinelMasterResponse); databaseInfoProvider.determineSentinelMasterGroups.mockResolvedValue([mockSentinelMasterDto]); @@ -70,7 +77,7 @@ describe('RedisSentinelService', () => { }); it('failed connection to the redis database', async () => { - redisService.createStandaloneClient.mockRejectedValue( + redisConnectionFactory.createStandaloneConnection.mockRejectedValue( new Error(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), ); diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts index 4f91adc9c6..eb1172067f 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts @@ -1,122 +1,236 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ConnectionOptions } from 'tls'; import * as Redis from 'ioredis'; import { - generateMockRedisClientInstance, - mockCaCertificate, - mockClientCertificate, mockClientMetadata, mockClusterDatabaseWithTlsAuth, + mockClientMetadata, mockClusterDatabaseWithTlsAuth, mockDatabase, - mockDatabaseEntity, + mockDatabaseWithTlsAuth, mockIORedisClient, mockIORedisCluster, mockIORedisSentinel, - mockRedisConnectionFactory, mockSentinelDatabaseWithTlsAuth + mockSentinelDatabaseWithTlsAuth, } from 'src/__mocks__'; -import { ClientContext, Session } from 'src/common/models'; -import { RedisService } from './redis.service'; import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; import { Database } from 'src/modules/database/models/database'; -// -// const mockClientMetadata = { -// session: { -// id: 'sessionId', -// }, -// }; - -const mockRedisClientInstance = { - clientMetadata: {}, - session: {}, - databaseId: mockDatabase.id, - context: ClientContext.Common, - uniqueId: undefined, - client: mockIORedisClient, - lastTimeUsed: Date.now(), -}; - -const mockTlsConfigResult: ConnectionOptions = { - rejectUnauthorized: true, - servername: mockDatabaseEntity.tlsServername, - checkServerIdentity: () => undefined, - ca: [mockCaCertificate.certificate], - key: mockClientCertificate.key, - cert: mockClientCertificate.certificate, -}; +import { EventEmitter } from 'events'; +import apiConfig from 'src/utils/config'; + +const REDIS_CLIENTS_CONFIG = apiConfig.get('redis_clients'); jest.mock('ioredis', () => ({ ...jest.requireActual('ioredis') as object, - // default: jest.fn(), + default: jest.requireActual('ioredis-mock/jest') as object, + Cluster: jest.requireActual('ioredis-mock/jest') as object, })); describe('RedisConnectionFactory', () => { let service: RedisConnectionFactory; + let mockClient; + let mockCluster; + let spyRedis; + let spyCluster; + const mockError = new Error('some error'); + const checkError = (cb) => (e) => { + expect(e).toEqual(mockError); + cb(); + }; + const checkClient = (cb, client) => (result) => { + expect(result).toEqual(client); + cb(); + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ RedisConnectionFactory, ], - }).compile(); + }) + .compile(); service = await module.get(RedisConnectionFactory); + + mockClient = new EventEmitter(); + mockCluster = new EventEmitter(); + spyRedis = jest.spyOn(Redis, 'default'); + spyRedis.mockImplementationOnce(() => mockClient); + spyCluster = jest.spyOn(Redis, 'Cluster'); + spyCluster.mockImplementationOnce(() => mockCluster); + }); + + describe('retryStrategy', () => { + it('should return 500ms delay for first retry', () => { + expect(service['retryStrategy'](1)).toEqual(REDIS_CLIENTS_CONFIG.retryDelay); + }); + it('should return 1000ms delay for second retry', () => { + expect(service['retryStrategy'](2)).toEqual(REDIS_CLIENTS_CONFIG.retryDelay * 2); + }); + it('should return undefined when number of retries exceeded', () => { + expect(service['retryStrategy'](REDIS_CLIENTS_CONFIG.maxRetries + 1)).toEqual(undefined); + }); + }); + + describe('createStandaloneConnection', () => { + it('should successfully create standalone client', (done) => { + service.createStandaloneConnection(mockClientMetadata, mockDatabaseWithTlsAuth, { useRetry: true }) + .then(checkClient(done, mockClient)); + + process.nextTick(() => { + mockClient.emit('reconnecting'); + process.nextTick(() => mockClient.emit('ready')); + }); + }); + + it('should successfully create standalone client with reconnect', (done) => { + service.createStandaloneConnection(mockClientMetadata, mockDatabaseWithTlsAuth, { useRetry: true }) + .then(checkClient(done, mockClient)); + process.nextTick(() => mockClient.emit('ready')); + }); + + it('should fail to create standalone connection', (done) => { + service.createStandaloneConnection(mockClientMetadata, mockDatabaseWithTlsAuth, {}) + .catch(checkError(done)); + + process.nextTick(() => mockClient.emit('error', mockError)); + }); + + it('should handle sync error during standalone client creation', (done) => { + spyRedis.mockReset(); + spyRedis.mockImplementationOnce(() => { + throw mockError; + }); + + service.createStandaloneConnection(mockClientMetadata, mockDatabaseWithTlsAuth, {}) + .catch(checkError(done)); + }); + }); + + describe('createClusterConnection', () => { + it('should successfully create cluster client', (done) => { + service.createClusterConnection(mockClientMetadata, mockClusterDatabaseWithTlsAuth, {}) + .then(checkClient(done, mockCluster)); + + process.nextTick(() => mockCluster.emit('ready')); + }); + + it('should fail to create cluster connection', (done) => { + service.createClusterConnection(mockClientMetadata, mockClusterDatabaseWithTlsAuth, {}) + .catch(checkError(done)); + + process.nextTick(() => mockCluster.emit('error', mockError)); + }); + + it('should handle sync error during cluster client creation', (done) => { + spyCluster.mockReset(); + spyCluster.mockImplementationOnce(() => { + throw mockError; + }); + service.createClusterConnection(mockClientMetadata, mockClusterDatabaseWithTlsAuth, {}) + .catch(checkError(done)); + }); + }); + + describe('createSentinelConnection', () => { + it('should successfully create sentinel client', (done) => { + service.createSentinelConnection(mockClientMetadata, mockSentinelDatabaseWithTlsAuth, { useRetry: true }) + .then(checkClient(done, mockClient)); + + process.nextTick(() => mockClient.emit('ready')); + }); + + it('should fail to create sentinel connection', (done) => { + service.createSentinelConnection(mockClientMetadata, mockSentinelDatabaseWithTlsAuth, {}) + .catch(checkError(done)); + + process.nextTick(() => mockClient.emit('error', mockError)); + }); + + it('should handle sync error during sentinel client creation', (done) => { + spyRedis.mockReset(); + spyRedis.mockImplementationOnce(() => { + throw mockError; + }); + + service.createSentinelConnection(mockClientMetadata, mockSentinelDatabaseWithTlsAuth, {}) + .catch(checkError(done)); + }); }); describe('createClientAutomatically', () => { beforeEach(() => { - service.createSentinelConnection = jest.fn().mockRejectedValueOnce(new Error()); - service.createClusterConnection = jest.fn().mockRejectedValueOnce(new Error()); - service.createStandaloneConnection = jest.fn().mockRejectedValueOnce(new Error()); + service.createSentinelConnection = jest.fn() + .mockRejectedValueOnce(new Error()); + service.createClusterConnection = jest.fn() + .mockRejectedValueOnce(new Error()); + service.createStandaloneConnection = jest.fn() + .mockRejectedValueOnce(new Error()); }); it('should create standalone client', async () => { - service.createStandaloneConnection = jest.fn().mockResolvedValue(mockIORedisClient); + service.createStandaloneConnection = jest.fn() + .mockResolvedValue(mockIORedisClient); const result = await service.createClientAutomatically(mockClientMetadata, mockDatabase); - expect(result).toEqual(mockIORedisClient); + expect(result) + .toEqual(mockIORedisClient); expect(service.createStandaloneConnection) .toHaveBeenCalledWith(mockClientMetadata, mockDatabase, { useRetry: true }); }); it('should create cluster client', async () => { - service.createClusterConnection = jest.fn().mockResolvedValue(mockIORedisCluster); + service.createClusterConnection = jest.fn() + .mockResolvedValue(mockIORedisCluster); const result = await service.createClientAutomatically(mockClientMetadata, mockClusterDatabaseWithTlsAuth); - expect(result).toEqual(mockIORedisCluster); - expect(service.createClusterConnection).toHaveBeenCalledWith( - mockClientMetadata, - mockClusterDatabaseWithTlsAuth, - { useRetry: true }, - ); - expect(service.createStandaloneConnection).not.toHaveBeenCalled(); + expect(result) + .toEqual(mockIORedisCluster); + expect(service.createClusterConnection) + .toHaveBeenCalledWith( + mockClientMetadata, + mockClusterDatabaseWithTlsAuth, + { useRetry: true }, + ); + expect(service.createStandaloneConnection) + .not + .toHaveBeenCalled(); }); it('should create sentinel client', async () => { - service.createSentinelConnection = jest.fn().mockResolvedValue(mockIORedisSentinel); + service.createSentinelConnection = jest.fn() + .mockResolvedValue(mockIORedisSentinel); const result = await service.createClientAutomatically(mockClientMetadata, mockSentinelDatabaseWithTlsAuth); - expect(result).toEqual(mockIORedisSentinel); - expect(service.createSentinelConnection).toHaveBeenCalledWith( - mockClientMetadata, - mockSentinelDatabaseWithTlsAuth, - { useRetry: true }, - ); - expect(service.createClusterConnection).not.toHaveBeenCalled(); - expect(service.createStandaloneConnection).not.toHaveBeenCalled(); + expect(result) + .toEqual(mockIORedisSentinel); + expect(service.createSentinelConnection) + .toHaveBeenCalledWith( + mockClientMetadata, + mockSentinelDatabaseWithTlsAuth, + { useRetry: true }, + ); + expect(service.createClusterConnection) + .not + .toHaveBeenCalled(); + expect(service.createStandaloneConnection) + .not + .toHaveBeenCalled(); }); }); - describe('connectToDatabaseInstance', () => { + describe('createRedisConnection', () => { it('should create standalone client', async () => { - service.createStandaloneConnection = jest.fn().mockResolvedValue(mockIORedisClient); + service.createStandaloneConnection = jest.fn() + .mockResolvedValue(mockIORedisClient); const result = await service.createRedisConnection(mockClientMetadata, mockDatabase); - expect(result).toEqual(mockIORedisClient); + expect(result) + .toEqual(mockIORedisClient); expect(service.createStandaloneConnection) .toHaveBeenCalledWith(mockClientMetadata, mockDatabase, { useRetry: true }); }); it('should trigger auto discovery connection type (when no connectionType defined)', async () => { - service.createClientAutomatically = jest.fn().mockResolvedValue(mockIORedisClient); + service.createClientAutomatically = jest.fn() + .mockResolvedValue(mockIORedisClient); const mockDatabaseWithoutConnectionType = Object.assign(new Database(), { ...mockDatabase, connectionType: null, @@ -124,7 +238,8 @@ describe('RedisConnectionFactory', () => { const result = await service.createRedisConnection(mockClientMetadata, mockDatabaseWithoutConnectionType); - expect(result).toEqual(mockIORedisClient); + expect(result) + .toEqual(mockIORedisClient); expect(service.createClientAutomatically) .toHaveBeenCalledWith( mockClientMetadata, @@ -137,69 +252,35 @@ describe('RedisConnectionFactory', () => { }); it('should create cluster client', async () => { - service.createClusterConnection = jest.fn().mockResolvedValue(mockIORedisCluster); + service.createClusterConnection = jest.fn() + .mockResolvedValue(mockIORedisCluster); const result = await service.createRedisConnection(mockClientMetadata, mockClusterDatabaseWithTlsAuth); - expect(result).toEqual(mockIORedisCluster); - expect(service.createClusterConnection).toHaveBeenCalledWith( - mockClientMetadata, - mockClusterDatabaseWithTlsAuth, - { useRetry: true }, - ); + expect(result) + .toEqual(mockIORedisCluster); + expect(service.createClusterConnection) + .toHaveBeenCalledWith( + mockClientMetadata, + mockClusterDatabaseWithTlsAuth, + { useRetry: true }, + ); }); it('should create sentinel client', async () => { - service.createSentinelConnection = jest.fn().mockResolvedValue(mockIORedisSentinel); + service.createSentinelConnection = jest.fn() + .mockResolvedValue(mockIORedisSentinel); const result = await service.createRedisConnection(mockClientMetadata, mockSentinelDatabaseWithTlsAuth); - expect(result).toEqual(mockIORedisSentinel); - expect(service.createSentinelConnection).toHaveBeenCalledWith( - mockClientMetadata, - mockSentinelDatabaseWithTlsAuth, - { useRetry: true }, - ); + expect(result) + .toEqual(mockIORedisSentinel); + expect(service.createSentinelConnection) + .toHaveBeenCalledWith( + mockClientMetadata, + mockSentinelDatabaseWithTlsAuth, + { useRetry: true }, + ); }); }); - - // describe('getRedisConnectionConfig', () => { - // it('should return config with tls', async () => { - // service['getTLSConfig'] = jest.fn().mockResolvedValue(mockTlsConfigResult); - // const { - // host, port, password, username, db, - // } = mockClusterDatabaseWithTlsAuth; - // - // const expectedResult = { - // host, port, username, password, db, tls: mockTlsConfigResult, - // }; - // - // const result = await service['getRedisConnectionConfig'](mockClusterDatabaseWithTlsAuth); - // - // expect(JSON.stringify(result)).toEqual(JSON.stringify(expectedResult)); - // }); - // it('should return without tls', async () => { - // const { - // host, port, password, username, db, - // } = mockDatabase; - // - // const expectedResult = { - // host, port, username, password, db, - // }; - // - // const result = await service['getRedisConnectionConfig'](mockDatabase); - // - // expect(result).toEqual(expectedResult); - // }); - // }); - // - // xdescribe('getTLSConfig', () => { - // it('should return tls config', async () => { - // const result = await service['getTLSConfig'](mockClusterDatabaseWithTlsAuth); - // - // expect(JSON.stringify(result)).toEqual( - // JSON.stringify(mockTlsConfigResult), - // ); - // }); - // }); }); diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.ts index d3197ae32d..69ad051ba6 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.ts @@ -20,7 +20,7 @@ export interface IRedisConnectionOptions { export class RedisConnectionFactory { private logger = new Logger('RedisConnectionFactory'); - // default retry strategy + // common retry strategy private retryStrategy = (times: number): number => { if (times < REDIS_CLIENTS_CONFIG.retryTimes) { return Math.min(times * REDIS_CLIENTS_CONFIG.retryDelay, 2000); @@ -28,6 +28,9 @@ export class RedisConnectionFactory { return undefined; }; + // disable function such as retry or checkIdentity + private dummyFn = () => undefined; + /** * Normalize data to be compatible with used redis connection library * @param clientMetadata @@ -53,7 +56,7 @@ export class RedisConnectionFactory { || generateRedisConnectionName(clientMetadata.context, clientMetadata.databaseId), showFriendlyErrorStack: true, maxRetriesPerRequest: REDIS_CLIENTS_CONFIG.maxRetriesPerRequest, - retryStrategy: options?.useRetry ? this.retryStrategy : () => undefined, + retryStrategy: options?.useRetry ? this.retryStrategy : this.dummyFn, }; if (tls) { @@ -76,7 +79,7 @@ export class RedisConnectionFactory { options: IRedisConnectionOptions, ): Promise { return { - clusterRetryStrategy: options.useRetry ? this.retryStrategy : () => undefined, + clusterRetryStrategy: options.useRetry ? this.retryStrategy : this.dummyFn, redisOptions: await this.getRedisOptions(clientMetadata, database, options), }; } @@ -106,7 +109,7 @@ export class RedisConnectionFactory { password: sentinelMaster?.password, sentinelTLS: baseOptions.tls, enableTLSForSentinelMode: !!baseOptions.tls, // previously was always `true` for tls connections - sentinelRetryStrategy: options?.useRetry ? this.retryStrategy : () => undefined, + sentinelRetryStrategy: options?.useRetry ? this.retryStrategy : this.dummyFn, }; } @@ -119,7 +122,7 @@ export class RedisConnectionFactory { let config: ConnectionOptions; config = { rejectUnauthorized: database.verifyServerCert, - checkServerIdentity: () => undefined, + checkServerIdentity: this.dummyFn, servername: database.tlsServername || undefined, }; if (database.caCert) { @@ -190,7 +193,7 @@ export class RedisConnectionFactory { const config = await this.getRedisClusterOptions(clientMetadata, database, options); return new Promise((resolve, reject) => { try { - const cluster = new Redis.Cluster([{ + const cluster = new Cluster([{ host: database.host, port: database.port, }].concat(database.nodes), { @@ -231,7 +234,6 @@ export class RedisConnectionFactory { reject(e); }); client.on('ready', (): void => { - console.log('\n\n\n\n\n!!!READY!!!!\n\n\n\n', client.options); this.logger.log('Successfully connected to the redis oss sentinel.'); resolve(client); }); diff --git a/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.spec.ts b/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.spec.ts index 5eee95297f..f4f4b95dc3 100644 --- a/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.spec.ts +++ b/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.spec.ts @@ -1,15 +1,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException } from '@nestjs/common'; import * as Redis from 'ioredis-mock'; -import { v4 as uuidv4 } from 'uuid'; -import { mockDatabase, mockDatabaseService } from 'src/__mocks__'; -import { IRedisClientInstance, RedisService } from 'src/modules/redis/redis.service'; +import { + mockDatabase, mockDatabaseService, mockRedisClientInstance, mockRedisConnectionFactory, +} from 'src/__mocks__'; +import { RedisService } from 'src/modules/redis/redis.service'; import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; import { CONNECTION_NAME_GLOBAL_PREFIX } from 'src/constants'; import { RedisConsumerAbstractService } from 'src/modules/redis/redis-consumer.abstract.service'; import { ClientNotFoundErrorException } from 'src/modules/redis/exceptions/client-not-found-error.exception'; import { DatabaseService } from 'src/modules/database/database.service'; import { ClientContext, ClientMetadata } from 'src/common/models'; +import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; const mockClientMetadata: ClientMetadata = { session: undefined, @@ -17,15 +19,9 @@ const mockClientMetadata: ClientMetadata = { context: ClientContext.Browser, }; -export const mockRedisClientInstance: IRedisClientInstance = { - ...mockClientMetadata, - uniqueId: uuidv4(), - client: new Redis(), - lastTimeUsed: 1619791508019, -}; - describe('RedisConsumerAbstractService', () => { let redisService; + let redisConnectionFactory; let instancesBusinessService; let consumerInstance; @@ -44,6 +40,10 @@ describe('RedisConsumerAbstractService', () => { connectToDatabaseInstance: jest.fn(), }), }, + { + provide: RedisConnectionFactory, + useFactory: mockRedisConnectionFactory, + }, { provide: DatabaseService, useFactory: mockDatabaseService, @@ -53,6 +53,7 @@ describe('RedisConsumerAbstractService', () => { redisService = await module.get(RedisService); consumerInstance = await module.get(BrowserToolService); + redisConnectionFactory = await module.get(RedisConnectionFactory); }); describe('getRedisClient', () => { @@ -123,7 +124,13 @@ describe('RedisConsumerAbstractService', () => { // @ts-ignore class Tool extends RedisConsumerAbstractService { constructor() { - super(ClientContext.CLI, redisService, instancesBusinessService, { enableAutoConnection: false }); + super( + ClientContext.CLI, + redisService, + redisConnectionFactory, + instancesBusinessService, + { enableAutoConnection: false }, + ); } } @@ -134,13 +141,11 @@ describe('RedisConsumerAbstractService', () => { describe('createNewClient', () => { it('create new redis client', async () => { - redisService.connectToDatabaseInstance.mockResolvedValue( + redisConnectionFactory.createRedisConnection.mockResolvedValue( mockRedisClientInstance.client, ); - const result = await consumerInstance.createNewClient( - mockRedisClientInstance.databaseId, - ); + const result = await consumerInstance.createNewClient(mockRedisClientInstance.clientMetadata); expect(result).toEqual(mockRedisClientInstance.client); }); @@ -148,10 +153,10 @@ describe('RedisConsumerAbstractService', () => { const error = new BadRequestException( ' Could not connect to localhost, please check the connection details.', ); - redisService.connectToDatabaseInstance.mockRejectedValue(error); + redisConnectionFactory.createRedisConnection.mockRejectedValue(error); await expect( - consumerInstance.createNewClient(mockRedisClientInstance.databaseId), + consumerInstance.createNewClient(mockRedisClientInstance.clientMetadata), ).rejects.toThrow(error); }); }); diff --git a/redisinsight/api/src/modules/redis/redis.service.spec.ts b/redisinsight/api/src/modules/redis/redis.service.spec.ts index f8b7a2d860..eb0b591e32 100644 --- a/redisinsight/api/src/modules/redis/redis.service.spec.ts +++ b/redisinsight/api/src/modules/redis/redis.service.spec.ts @@ -1,56 +1,50 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ConnectionOptions } from 'tls'; import { generateMockRedisClientInstance, - mockCaCertificate, - mockClientCertificate, mockDatabase, - mockDatabaseEntity, - mockIORedisClient, - mockRedisConnectionFactory + mockIORedisClient, mockIORedisSentinel, + mockRedisConnectionFactory, } from 'src/__mocks__'; import { ClientContext, Session } from 'src/common/models'; -import { RedisService } from './redis.service'; import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; +import apiConfig from 'src/utils/config'; +import { RedisService } from './redis.service'; -const mockClientMetadata = { - session: { - id: 'sessionId', - }, -}; - -const mockRedisClientInstance = { - clientMetadata: {}, - session: {}, - databaseId: mockDatabase.id, - context: ClientContext.Common, - uniqueId: undefined, - client: mockIORedisClient, - lastTimeUsed: Date.now(), -}; - -const mockTlsConfigResult: ConnectionOptions = { - rejectUnauthorized: true, - servername: mockDatabaseEntity.tlsServername, - checkServerIdentity: () => undefined, - ca: [mockCaCertificate.certificate], - key: mockClientCertificate.key, - cert: mockClientCertificate.certificate, -}; - -const removeNullsFromDto = (dto): any => { - const result = dto; - Object.keys(dto).forEach((key: string) => { - if (result[key] === null) { - delete result[key]; - } - }); - - return result; -}; +const REDIS_CLIENTS_CONFIG = apiConfig.get('redis_clients'); describe('RedisService', () => { let service: RedisService; + const mockClientMetadata1 = { + session: { + userId: 'u1', + sessionId: 's1', + }, + databaseId: mockDatabase.id, + context: ClientContext.Common, + }; + + const mockRedisClientInstance1 = generateMockRedisClientInstance(mockClientMetadata1); + const mockRedisClientInstance2 = generateMockRedisClientInstance({ + ...mockClientMetadata1, + context: ClientContext.Browser, + db: 0, + }); + const mockRedisClientInstance3 = generateMockRedisClientInstance({ + ...mockClientMetadata1, + session: { userId: 'u2', sessionId: 's2' }, + context: ClientContext.Workbench, + db: 1, + }); + const mockRedisClientInstance4 = generateMockRedisClientInstance({ + ...mockClientMetadata1, + session: { userId: 'u2', sessionId: 's3' }, + db: 2, + }); + const mockRedisClientInstance5 = generateMockRedisClientInstance({ + ...mockClientMetadata1, + databaseId: 'd2', + session: { userId: 'u2', sessionId: 's4' }, + }); beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -64,183 +58,110 @@ describe('RedisService', () => { }).compile(); service = await module.get(RedisService); - }); - it('should be defined', () => { - expect(service.clients).toEqual(new Map()); + service.clients = new Map(); + service.clients.set(mockRedisClientInstance1.id, mockRedisClientInstance1); + service.clients.set(mockRedisClientInstance2.id, mockRedisClientInstance2); + service.clients.set(mockRedisClientInstance3.id, mockRedisClientInstance3); + service.clients.set(mockRedisClientInstance4.id, mockRedisClientInstance4); + service.clients.set(mockRedisClientInstance5.id, mockRedisClientInstance5); }); + describe('isClientConnected', () => { + it('should not remove any client since no idle time passed', async () => { + expect(service.clients.size).toEqual(5); - // describe('connectToDatabaseInstance', () => { - // beforeEach(async () => { - // service.clients = new Map(); - // }); - // it('should create standalone client', async () => { - // service.createStandaloneClient = jest.fn().mockResolvedValue(mockIORedisClient); - // - // const result = await service.connectToDatabaseInstance(mockDatabase); - // - // expect(result).toEqual(mockIORedisClient); - // expect(service.createStandaloneClient).toHaveBeenCalledWith(mockDatabase, AppTool.Common, true, undefined); - // }); - // it('should create standalone client (by default)', async () => { - // service.createStandaloneClient = jest.fn().mockResolvedValue(mockIORedisClient); - // const mockDatabaseWithoutConnectionType = Object.assign(new Database(), { - // ...mockDatabase, - // connectionType: null, - // }); - // - // const result = await service.connectToDatabaseInstance(mockDatabaseWithoutConnectionType); - // - // expect(result).toEqual(mockIORedisClient); - // expect(service.createStandaloneClient) - // .toHaveBeenCalledWith({ - // ...mockDatabaseWithoutConnectionType, - // connectionType: undefined, - // }, AppTool.Common, true, undefined); - // }); - // it('should create cluster client', async () => { - // service.createClusterClient = jest.fn().mockResolvedValue(mockIORedisCluster); - // - // const result = await service.connectToDatabaseInstance(mockClusterDatabaseWithTlsAuth); - // - // expect(result).toEqual(mockIORedisCluster); - // expect(service.createClusterClient).toHaveBeenCalledWith( - // mockClusterDatabaseWithTlsAuth, - // mockClusterDatabaseWithTlsAuth.nodes, - // true, - // undefined, - // ); - // }); - // it('should create sentinel client', async () => { - // const dto = removeNullsFromDto(mockSentinelDatabaseWithTlsAuth); - // Object.keys(dto).forEach((key: string) => { - // if (dto[key] === null) { - // delete dto[key]; - // } - // }); - // const { nodes } = dto; - // service.createSentinelClient = jest.fn().mockResolvedValue(mockIORedisSentinel); - // - // const result = await service.connectToDatabaseInstance(dto); - // - // expect(result).toEqual(mockIORedisSentinel); - // expect(service.createSentinelClient).toHaveBeenCalledWith( - // mockSentinelDatabaseWithTlsAuth, - // nodes, - // ClientContext.Common, - // true, - // undefined, - // ); - // }); - // }); - - // describe('getClientInstance', () => { - // beforeEach(() => { - // service.clients = [ - // { - // ...mockRedisClientInstance, - // }, - // { - // ...mockRedisClientInstance, context: ClientContext.Browser, - // }, - // { - // ...mockRedisClientInstance, context: ClientContext.CLI, - // }, - // ]; - // }); - // it('should correctly find client instance for App.Common by instance id', () => { - // const newClient = { ...service.clients[0], context: ClientContext.Browser }; - // service.clients.push(newClient); - // const options = { - // session: undefined, - // databaseId: newClient.databaseId, - // context: ClientContext.Common, - // }; - // - // const result = service.getClientInstance(options); - // - // expect(result).toEqual(service.clients[0]); - // }); - // it('should correctly find client instance by instance id and tool', () => { - // const options = { - // session: undefined, - // databaseId: service.clients[0].databaseId, - // context: ClientContext.CLI, - // }; - // - // const result = service.getClientInstance(options); - // - // expect(result).toEqual(service.clients[2]); - // }); - // it('should correctly find client instance by instance id, tool and uuid', () => { - // const newClient = { ...mockRedisClientInstance, uniqueId: uuidv4(), context: ClientContext.CLI }; - // service.clients.push(newClient); - // - // const options = { - // session: undefined, - // databaseId: newClient.databaseId, - // uniqueId: newClient.uniqueId, - // context: newClient.context, - // }; - // - // const result = service.getClientInstance(options); - // - // expect(result).toEqual(newClient); - // }); - // it('should return undefined', () => { - // const options = { - // session: undefined, - // databaseId: 'invalid-instance-id', - // context: ClientContext.Common, - // }; - // - // const result = service.getClientInstance(options); - // - // expect(result).toBeUndefined(); - // }); - // }); + service['syncClients'](); - describe('findClientInstances + removeClientInstances', () => { - const mockClientMetadata1 = { - session: { - userId: 'u1', - sessionId: 's1', - }, - databaseId: mockDatabase.id, - context: ClientContext.Common, - }; - - const mockRedisClientInstance1 = generateMockRedisClientInstance(mockClientMetadata1); - const mockRedisClientInstance2 = generateMockRedisClientInstance({ - ...mockClientMetadata1, - context: ClientContext.Browser, - db: 0, + expect(service.clients.size).toEqual(5); }); - const mockRedisClientInstance3 = generateMockRedisClientInstance({ - ...mockClientMetadata1, - session: { userId: 'u2', sessionId: 's2' }, - context: ClientContext.Workbench, - db: 1, + + it('should remove client with exceeded time in idle', async () => { + expect(service.clients.size).toEqual(5); + const toDelete = service.clients.get(mockRedisClientInstance1.id); + toDelete.lastTimeUsed = Date.now() - REDIS_CLIENTS_CONFIG.maxIdleThreshold; + + service['syncClients'](); + + expect(service.clients.size).toEqual(4); + expect(service.clients.get(mockRedisClientInstance1.id)).toEqual(undefined); }); - const mockRedisClientInstance4 = generateMockRedisClientInstance({ - ...mockClientMetadata1, - session: { userId: 'u2', sessionId: 's3' }, - db: 2, + }); + + describe('getClientInstance', () => { + it('should correctly get client instance and update last used time', () => { + const { lastTimeUsed } = mockRedisClientInstance1; + const result = service.getClientInstance(mockRedisClientInstance1.clientMetadata); + + expect(result).toEqual(service.clients.get(mockRedisClientInstance1.id)); + expect(service.clients.get(mockRedisClientInstance1.id).lastTimeUsed).toBeGreaterThan(lastTimeUsed); }); - const mockRedisClientInstance5 = generateMockRedisClientInstance({ - ...mockClientMetadata1, - databaseId: 'd2', - session: { userId: 'u2', sessionId: 's4' }, + it('should not fail when there is no client', () => { + const result = service.getClientInstance({ + session: undefined, + databaseId: 'invalid-instance-id', + context: ClientContext.Common, + }); + + expect(result).toBeUndefined(); }); + }); + describe('setClientInstance', () => { beforeEach(() => { service.clients = new Map(); - service.clients.set(mockRedisClientInstance1.id, mockRedisClientInstance1); - service.clients.set(mockRedisClientInstance2.id, mockRedisClientInstance2); - service.clients.set(mockRedisClientInstance3.id, mockRedisClientInstance3); - service.clients.set(mockRedisClientInstance4.id, mockRedisClientInstance4); - service.clients.set(mockRedisClientInstance5.id, mockRedisClientInstance5); }); + + it('should add new client', () => { + expect(service.clients.size).toEqual(0); + + const result = service.setClientInstance(mockRedisClientInstance1.clientMetadata, mockIORedisClient); + + expect(result).toEqual(1); + expect(service.clients.size).toEqual(1); + }); + + it('should replace existing client and update last used time', async () => { + expect(service.clients.size).toEqual(0); + + expect(service.setClientInstance(mockRedisClientInstance1.clientMetadata, mockIORedisClient)).toEqual(1); + expect(service.clients.size).toEqual(1); + + const [justAddedClient] = [...service.clients.values()]; + + // sleep + await new Promise((res) => setTimeout(res, 100)); + + expect(service.setClientInstance(mockRedisClientInstance1.clientMetadata, mockIORedisSentinel)).toEqual(0); + expect(service.clients.size).toEqual(1); + + const [overwrittenClient] = [...service.clients.values()]; + expect(overwrittenClient.clientMetadata).toEqual(justAddedClient.clientMetadata); + expect(overwrittenClient.id).toEqual(justAddedClient.id); + expect(overwrittenClient.client).not.toEqual(justAddedClient.client); + expect(overwrittenClient.lastTimeUsed).toBeGreaterThan(justAddedClient.lastTimeUsed); + }); + }); + + describe('removeClientInstance', () => { + it('should remove only one', () => { + const result = service.removeClientInstance(mockRedisClientInstance1.clientMetadata); + + expect(result).toEqual(1); + expect(service.clients.size).toEqual(4); + expect(service.clients.get(mockRedisClientInstance1.id)).toEqual(undefined); + }); + it('should not fail in case when no client found', () => { + const result = service.removeClientInstance({ + ...mockRedisClientInstance1.clientMetadata, + db: 100, + }); + + expect(result).toEqual(0); + expect(service.clients.size).toEqual(5); + }); + }); + + describe('findClientInstances + removeClientInstances', () => { it('should correctly find client instances for particular database', () => { const query = { databaseId: mockDatabase.id, @@ -338,120 +259,25 @@ describe('RedisService', () => { expect(service.clients.size).toEqual(5); }); }); - // - // describe('removeClientInstance', () => { - // beforeEach(() => { - // service.clients = [ - // { - // ...mockRedisClientInstance, - // }, - // { - // ...mockRedisClientInstance, context: ClientContext.Browser, - // }, - // ]; - // }); - // it('should remove only client for browser tool', () => { - // const options = { - // databaseId: mockRedisClientInstance.databaseId, - // context: ClientContext.Browser, - // }; - // - // const result = service.removeClientInstance(options); - // - // expect(result).toEqual(1); - // expect(service.clients.length).toEqual(1); - // }); - // it('should remove all clients by instance id', () => { - // const options = { - // databaseId: mockRedisClientInstance.databaseId, - // }; - // - // const result = service.removeClientInstance(options); - // - // expect(result).toEqual(2); - // expect(service.clients.length).toEqual(0); - // }); - // }); - // - // describe('setClientInstance', () => { - // beforeEach(() => { - // service.clients = [{ ...mockRedisClientInstance }]; - // }); - // it('should add new client', () => { - // const initialClientsCount = service.clients.length; - // const newClientInstance: ClientMetadata = { - // ...mockRedisClientInstance, - // databaseId: uuidv4(), - // }; - // - // const result = service.setClientInstance(newClientInstance, mockIORedisClient); - // - // expect(result).toBe(1); - // expect(service.clients.length).toBe(initialClientsCount + 1); - // }); - // it('should replace exist client', () => { - // const initialClientsCount = service.clients.length; - // - // const result = service.setClientInstance(mockRedisClientInstance, mockIORedisClient); - // - // expect(result).toBe(0); - // expect(service.clients.length).toBe(initialClientsCount); - // }); - // }); - // - // describe('isClientConnected', () => { - // it('should return true', async () => { - // const result = service.isClientConnected(mockIORedisClient); - // - // expect(result).toEqual(true); - // }); - // it('should return false', async () => { - // const mockClient = { ...mockIORedisClient }; - // mockClient.status = 'end'; - // - // const result = service.isClientConnected(mockClient); - // - // expect(result).toEqual(false); - // }); - // }); - // - // describe('getRedisConnectionConfig', () => { - // it('should return config with tls', async () => { - // service['getTLSConfig'] = jest.fn().mockResolvedValue(mockTlsConfigResult); - // const { - // host, port, password, username, db, - // } = mockClusterDatabaseWithTlsAuth; - // - // const expectedResult = { - // host, port, username, password, db, tls: mockTlsConfigResult, - // }; - // - // const result = await service['getRedisConnectionConfig'](mockClusterDatabaseWithTlsAuth); - // - // expect(JSON.stringify(result)).toEqual(JSON.stringify(expectedResult)); - // }); - // it('should return without tls', async () => { - // const { - // host, port, password, username, db, - // } = mockDatabase; - // - // const expectedResult = { - // host, port, username, password, db, - // }; - // - // const result = await service['getRedisConnectionConfig'](mockDatabase); - // - // expect(result).toEqual(expectedResult); - // }); - // }); - // - // xdescribe('getTLSConfig', () => { - // it('should return tls config', async () => { - // const result = await service['getTLSConfig'](mockClusterDatabaseWithTlsAuth); - // - // expect(JSON.stringify(result)).toEqual( - // JSON.stringify(mockTlsConfigResult), - // ); - // }); - // }); + + describe('isClientConnected', () => { + it('should return true', async () => { + const result = service.isClientConnected(mockIORedisClient); + + expect(result).toEqual(true); + }); + it('should return false', async () => { + const mockClient = { ...mockIORedisClient }; + mockClient.status = 'end'; + + const result = service.isClientConnected(mockClient); + + expect(result).toEqual(false); + }); + it('should return false in case of an error', async () => { + const result = service.isClientConnected(undefined); + + expect(result).toEqual(false); + }); + }); }); diff --git a/redisinsight/api/src/modules/redis/redis.service.ts b/redisinsight/api/src/modules/redis/redis.service.ts index e139a32958..bd496e7a5b 100644 --- a/redisinsight/api/src/modules/redis/redis.service.ts +++ b/redisinsight/api/src/modules/redis/redis.service.ts @@ -27,10 +27,14 @@ export class RedisService { */ private syncClients() { [...this.clients.keys()].forEach((id) => { - const redisClient = this.clients.get(id); - if (redisClient && (Date.now() - redisClient.lastTimeUsed) >= REDIS_CLIENTS_CONFIG.maxIdleThreshold) { - redisClient.client.disconnect(); - this.clients.delete(id); + try { + const redisClient = this.clients.get(id); + if (redisClient && (Date.now() - redisClient.lastTimeUsed) >= REDIS_CLIENTS_CONFIG.maxIdleThreshold) { + redisClient.client.disconnect(); + this.clients.delete(id); + } + } catch (e) { + // ignore error } }); } From 8aab9bd0ebd663454cac706a19c7cca1becbf970 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 29 Dec 2022 11:56:02 +0200 Subject: [PATCH 078/201] ix tests --- .../database-overview.provider.spec.ts | 21 +++++++++---------- .../providers/redis-observer.provider.spec.ts | 3 --- .../src/modules/redis/redis.service.spec.ts | 5 +++++ .../api/src/modules/redis/redis.service.ts | 10 +++++++-- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/redisinsight/api/src/modules/database/providers/database-overview.provider.spec.ts b/redisinsight/api/src/modules/database/providers/database-overview.provider.spec.ts index a084eeb9d6..279187ce05 100644 --- a/redisinsight/api/src/modules/database/providers/database-overview.provider.spec.ts +++ b/redisinsight/api/src/modules/database/providers/database-overview.provider.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import Redis from 'ioredis'; import { when } from 'jest-when'; import { - mockDatabase, + mockClientMetadata, mockStandaloneRedisInfoReply, } from 'src/__mocks__'; import { DatabaseOverview } from 'src/modules/database/models/database-overview'; @@ -59,7 +59,6 @@ const mockNodeInfo = { const mockGetTotalResponse_1 = 1; -const databaseId = mockDatabase.id; export const mockDatabaseOverview: DatabaseOverview = { version: mockServerInfo.redis_version, usedMemory: 1, @@ -99,7 +98,7 @@ describe('OverviewService', () => { .calledWith() .mockResolvedValue(mockStandaloneRedisInfoReply); - const result = await service.getOverview(databaseId, mockClient); + const result = await service.getOverview(mockClientMetadata, mockClient); expect(result).toEqual({ ...mockDatabaseOverview, @@ -122,7 +121,7 @@ describe('OverviewService', () => { }, }); - expect(await service.getOverview(databaseId, mockClient)).toEqual({ + expect(await service.getOverview(mockClientMetadata, mockClient)).toEqual({ ...mockDatabaseOverview, totalKeys: 0, totalKeysPerDb: undefined, @@ -143,11 +142,11 @@ describe('OverviewService', () => { }, }); - expect(await service.getOverview(databaseId, mockClient)).toEqual({ + expect(await service.getOverview(mockClientMetadata, mockClient)).toEqual({ ...mockDatabaseOverview, }); - expect(await service.getOverview(databaseId, mockClient)).toEqual({ + expect(await service.getOverview(mockClientMetadata, mockClient)).toEqual({ ...mockDatabaseOverview, cpuUsagePercentage: 50, }); @@ -167,11 +166,11 @@ describe('OverviewService', () => { }, }); - expect(await service.getOverview(databaseId, mockClient)).toEqual({ + expect(await service.getOverview(mockClientMetadata, mockClient)).toEqual({ ...mockDatabaseOverview, }); - expect(await service.getOverview(databaseId, mockClient)).toEqual({ + expect(await service.getOverview(mockClientMetadata, mockClient)).toEqual({ ...mockDatabaseOverview, cpuUsagePercentage: 100, }); @@ -190,7 +189,7 @@ describe('OverviewService', () => { }, }); - expect(await service.getOverview(databaseId, mockClient)).toEqual({ + expect(await service.getOverview(mockClientMetadata, mockClient)).toEqual({ ...mockDatabaseOverview, cpuUsagePercentage: undefined, }); @@ -230,7 +229,7 @@ describe('OverviewService', () => { replication: { role: 'slave' }, }); - expect(await service.getOverview(databaseId, mockCluster)).toEqual({ + expect(await service.getOverview(mockClientMetadata, mockCluster)).toEqual({ ...mockDatabaseOverview, connectedClients: 1, totalKeys: 6, @@ -285,7 +284,7 @@ describe('OverviewService', () => { cpu: { ...mockNodeInfo.cpu, used_cpu_sys: '1.5', used_cpu_user: '1.5' }, }); - expect(await service.getOverview(databaseId, mockCluster)).toEqual({ + expect(await service.getOverview(mockClientMetadata, mockCluster)).toEqual({ ...mockDatabaseOverview, connectedClients: 1, totalKeys: 6, diff --git a/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.spec.ts b/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.spec.ts index ddcab5bbbf..94cbf5ce64 100644 --- a/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.spec.ts +++ b/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.spec.ts @@ -2,16 +2,13 @@ import { Test, TestingModule } from '@nestjs/testing'; import { mockDatabaseConnectionService, mockIORedisClient, mockLogFile, mockRedisShardObserver, - MockType, } from 'src/__mocks__'; import { RedisObserverProvider } from 'src/modules/profiler/providers/redis-observer.provider'; -import { mockRedisClientInstance } from 'src/modules/redis/redis-consumer.abstract.service.spec'; import { RedisObserverStatus } from 'src/modules/profiler/constants'; import { DatabaseConnectionService } from 'src/modules/database/database-connection.service'; describe('RedisObserverProvider', () => { let service: RedisObserverProvider; - let databaseConnectionService: MockType; beforeEach(async () => { jest.clearAllMocks(); diff --git a/redisinsight/api/src/modules/redis/redis.service.spec.ts b/redisinsight/api/src/modules/redis/redis.service.spec.ts index eb0b591e32..bd728d96e4 100644 --- a/redisinsight/api/src/modules/redis/redis.service.spec.ts +++ b/redisinsight/api/src/modules/redis/redis.service.spec.ts @@ -66,6 +66,11 @@ describe('RedisService', () => { service.clients.set(mockRedisClientInstance4.id, mockRedisClientInstance4); service.clients.set(mockRedisClientInstance5.id, mockRedisClientInstance5); }); + + afterEach(() => { + service.onModuleDestroy(); + }); + describe('isClientConnected', () => { it('should not remove any client since no idle time passed', async () => { expect(service.clients.size).toEqual(5); diff --git a/redisinsight/api/src/modules/redis/redis.service.ts b/redisinsight/api/src/modules/redis/redis.service.ts index bd496e7a5b..af70810fdf 100644 --- a/redisinsight/api/src/modules/redis/redis.service.ts +++ b/redisinsight/api/src/modules/redis/redis.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, OnModuleDestroy } from '@nestjs/common'; import Redis, { Cluster } from 'ioredis'; import { isMatch, isNumber, omit } from 'lodash'; import apiConfig from 'src/utils/config'; @@ -14,13 +14,19 @@ export interface IRedisClientInstance { } @Injectable() -export class RedisService { +export class RedisService implements OnModuleDestroy { public clients: Map = new Map(); + private readonly syncInterval; + constructor() { setInterval(this.syncClients.bind(this), 60 * 1000); // sync clients each minute } + onModuleDestroy() { + clearInterval(this.syncInterval); + } + /** * Close connections and remove clients which were unused for some time * @private From ecdb85d451f1a301d46395713b61e4682f094b2b Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 29 Dec 2022 12:31:21 +0200 Subject: [PATCH 079/201] remove ioredis-mock usage + forceExit at the end --- redisinsight/api/package.json | 2 +- .../api/src/modules/redis/redis-connection.factory.spec.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 3cfc7ae5f6..5ab3dec344 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -27,7 +27,7 @@ "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", - "test:cov": "cross-env NODE_ENV=test ./node_modules/.bin/jest --coverage -w 1", + "test:cov": "cross-env NODE_ENV=test ./node_modules/.bin/jest --forceExit --coverage -w 1", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand -w 1", "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", diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts index eb1172067f..8fb9b53371 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts @@ -16,11 +16,10 @@ const REDIS_CLIENTS_CONFIG = apiConfig.get('redis_clients'); jest.mock('ioredis', () => ({ ...jest.requireActual('ioredis') as object, - default: jest.requireActual('ioredis-mock/jest') as object, - Cluster: jest.requireActual('ioredis-mock/jest') as object, })); describe('RedisConnectionFactory', () => { + let module: TestingModule; let service: RedisConnectionFactory; let mockClient; let mockCluster; @@ -37,7 +36,7 @@ describe('RedisConnectionFactory', () => { }; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + module = await Test.createTestingModule({ providers: [ RedisConnectionFactory, ], From 36545b78038d392418f590a615ff6c8f5bcb2db8 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 29 Dec 2022 15:14:49 +0400 Subject: [PATCH 080/201] #RI-3969 - fix styles --- .../recommendations-view/Recommendations.tsx | 62 ++++++++++--------- .../recommendations-view/styles.module.scss | 15 +++-- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index c8dec8bf53..66a870a708 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -51,37 +51,39 @@ const Recommendations = () => {
{renderBadgesLegend()}
- {recommendations.map(({ name }) => { - const { id = '', title = '', content = '', badges = [] } = recommendationsContent[name] +
+ {recommendations.map(({ name }) => { + const { id = '', title = '', content = '', badges = [] } = recommendationsContent[name] - const buttonContent = ( - - {title} - - {renderBadges(badges)} - - - ) - return ( -
- handleToggle(isOpen, id)} - data-testid={`${id}-accordion`} - > - - {renderContent(content)} - - -
- ) - })} + const buttonContent = ( + + {title} + + {renderBadges(badges)} + + + ) + return ( +
+ handleToggle(isOpen, id)} + data-testid={`${id}-accordion`} + > + + {renderContent(content)} + + +
+ ) + })} +
) } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index 46ec75adb3..c7b1726a73 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -3,14 +3,10 @@ @import "@elastic/eui/src/global_styling/index"; .wrapper { - @include euiScrollBar; - overflow-y: auto; - overflow-x: hidden; - max-height: 100%; - padding-top: 30px; + height: 100%; .badgesLegend { - margin: 0 22px 14px 0 !important; + margin: 20px 22px 14px 0 !important; .badgeWrapper { margin-right: 0; @@ -34,6 +30,13 @@ align-items: center; margin-right: 24px; } +} + +.recommendationsContainer { + @include euiScrollBar; + overflow-y: auto; + overflow-x: hidden; + max-height: 100%; .accordionButton :global(.euiFlexItem) { margin: 0; From 153f99f7f44eebed1fd646310789f7a7f56d0e2e Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 29 Dec 2022 16:06:48 +0400 Subject: [PATCH 081/201] #RI-3899 - fix styles --- .../components/recommendations-view/styles.module.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index c7b1726a73..3471358ff0 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -6,7 +6,8 @@ height: 100%; .badgesLegend { - margin: 20px 22px 14px 0 !important; + margin: 0 22px 14px 0 !important; + padding-top: 20px; .badgeWrapper { margin-right: 0; @@ -36,7 +37,7 @@ @include euiScrollBar; overflow-y: auto; overflow-x: hidden; - max-height: 100%; + max-height: calc(100% - 51px); .accordionButton :global(.euiFlexItem) { margin: 0; From db050e2b6f9e6198d93b8674ec74e72941570dc3 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Thu, 29 Dec 2022 16:58:41 +0300 Subject: [PATCH 082/201] #RI-3949 - add tests, info request --- .../database-overview/DatabaseOverview.tsx | 2 +- .../instance-header/InstanceHeader.spec.tsx | 60 ++++- .../instance-header/InstanceHeader.tsx | 83 +++--- .../components/ShortInstanceInfo.spec.tsx | 2 +- .../components/ShortInstanceInfo.tsx | 27 +- .../ui/src/pages/instance/InstancePage.tsx | 3 +- .../ui/src/services/tests/apiService.spec.ts | 29 +++ .../ui/src/slices/instances/instances.ts | 48 +++- .../ui/src/slices/interfaces/instances.ts | 2 + .../ui/src/slices/tests/app/context.spec.ts | 25 +- .../slices/tests/instances/instances.spec.ts | 238 +++++++++++++++++- .../slices/tests/workbench/wb-results.spec.ts | 17 +- .../ui/src/slices/workbench/wb-results.ts | 6 +- .../themes/light_theme/_theme_color.scss | 2 +- 14 files changed, 460 insertions(+), 84 deletions(-) create mode 100644 redisinsight/ui/src/services/tests/apiService.spec.ts diff --git a/redisinsight/ui/src/components/database-overview/DatabaseOverview.tsx b/redisinsight/ui/src/components/database-overview/DatabaseOverview.tsx index fa86729934..f1bfee9a26 100644 --- a/redisinsight/ui/src/components/database-overview/DatabaseOverview.tsx +++ b/redisinsight/ui/src/components/database-overview/DatabaseOverview.tsx @@ -35,7 +35,7 @@ interface IState { const DatabaseOverview = (props: Props) => { const { metrics: metricsProps = [], modules: modulesProps = [], windowDimensions, isRediStack } = props const [metrics, setMetrics] = useState>({ visible: [], hidden: [] }) - const [modules, setModules] = useState>({ visible: [], hidden: [] }) + const [modules, setModules] = useState>({ visible: [], hidden: [] }) const { theme } = useContext(ThemeContext) diff --git a/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx b/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx index 752220310b..85b9fb74f5 100644 --- a/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx +++ b/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx @@ -1,7 +1,10 @@ import { cloneDeep } from 'lodash' import React from 'react' import { instance, mock } from 'ts-mockito' -import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' +import { cleanup, mockedStore, render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import { checkDatabaseIndex, connectedInstanceInfoSelector } from 'uiSrc/slices/instances/instances' + +import { BuildType } from 'uiSrc/constants/env' import InstanceHeader, { Props } from './InstanceHeader' const mockedProps = mock() @@ -21,17 +24,58 @@ jest.mock('uiSrc/services', () => ({ }, })) +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), + connectedInstanceInfoSelector: jest.fn().mockReturnValue({ + databases: 16, + }) +})) + describe('InstanceHeader', () => { it('should render', () => { - // connectedInstanceSelector.mockImplementation(() => ({ - // id: '123', - // connectionType: 'CLUSTER', - // })); + expect(render()).toBeTruthy() + }) - // const sendCliClusterActionMock = jest.fn(); + it('should render change index button with databases = 1', () => { + (connectedInstanceInfoSelector as jest.Mock).mockReturnValueOnce({ + databases: 1, + }) - // sendCliClusterCommandAction.mockImplementation(() => sendCliClusterActionMock); + render() - expect(render()).toBeTruthy() + expect(screen.queryByTestId('change-index-btn')).not.toBeInTheDocument() + }) + + it('should render change index button', () => { + render() + + expect(screen.getByTestId('change-index-btn')).toBeInTheDocument() + }) + + it('should render change index input after click on the button', () => { + render() + + fireEvent.click(screen.getByTestId('change-index-btn')) + + expect(screen.getByTestId('change-index-input')).toBeInTheDocument() + }) + + it('should call proper actions after changing database index', () => { + render() + + fireEvent.click(screen.getByTestId('change-index-btn')) + + fireEvent.change( + screen.getByTestId('change-index-input'), + { target: { value: 3 } } + ) + + expect(screen.getByTestId('change-index-input')).toHaveValue('3') + fireEvent.click(screen.getByTestId('apply-btn')) + + const expectedActions = [ + checkDatabaseIndex() + ] + expect(store.getActions()).toEqual([...expectedActions]) }) }) diff --git a/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx b/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx index 6c3945a24c..90a3eb15ef 100644 --- a/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx +++ b/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx @@ -17,8 +17,9 @@ import { BuildType } from 'uiSrc/constants/env' import { ConnectionType } from 'uiSrc/slices/interfaces' import { checkDatabaseIndexAction, + connectedInstanceInfoSelector, connectedInstanceOverviewSelector, - connectedInstanceSelector + connectedInstanceSelector, } from 'uiSrc/slices/instances/instances' import { appInfoSelector } from 'uiSrc/slices/app/info' import ShortInstanceInfo from 'uiSrc/components/instance-header/components/ShortInstanceInfo' @@ -45,6 +46,7 @@ const InstanceHeader = () => { const { version } = useSelector(connectedInstanceOverviewSelector) const { server } = useSelector(appInfoSelector) const { disabled: isDbIndexDisabled } = useSelector(appContextDbIndex) + const { databases = 0 } = useSelector(connectedInstanceInfoSelector) const history = useHistory() const [windowDimensions, setWindowDimensions] = useState(0) const [dbIndex, setDbIndex] = useState(String(db || 0)) @@ -120,45 +122,47 @@ const InstanceHeader = () => { {name} - -
- {isDbIndexEditing ? ( -
- setIsDbIndexEditing(false)} - viewChildrenMode={false} - controlsClassName={styles.controls} + {databases > 1 && ( + +
+ {isDbIndexEditing ? ( +
+ setIsDbIndexEditing(false)} + viewChildrenMode={false} + controlsClassName={styles.controls} + > + setDbIndex(validateNumber(e.target.value.trim()))} + value={dbIndex} + placeholder="Database Index" + className={styles.input} + fullWidth={false} + compressed + autoComplete="off" + type="text" + data-testid="change-index-input" + /> + +
+ ) : ( + setIsDbIndexEditing(true)} + className={styles.buttonDbIndex} + disabled={isDbIndexDisabled || instanceLoading} + data-testid="change-index-btn" > - setDbIndex(validateNumber(e.target.value.trim()))} - value={dbIndex} - placeholder="Database Index" - className={styles.input} - fullWidth={false} - compressed - autoComplete="off" - type="text" - data-testid="change-index-input" - /> - -
- ) : ( - setIsDbIndexEditing(true)} - className={styles.buttonDbIndex} - disabled={isDbIndexDisabled || instanceLoading} - data-testid="change-index-btn" - > - db{db || 0} - - )} -
- + db{db || 0} + + )} +
+
+ )} { info={{ name, host, port, user: username, connectionType, version, dbIndex: db }} + databases={databases} /> )} > diff --git a/redisinsight/ui/src/components/instance-header/components/ShortInstanceInfo.spec.tsx b/redisinsight/ui/src/components/instance-header/components/ShortInstanceInfo.spec.tsx index 638aa8c694..2e7595e045 100644 --- a/redisinsight/ui/src/components/instance-header/components/ShortInstanceInfo.spec.tsx +++ b/redisinsight/ui/src/components/instance-header/components/ShortInstanceInfo.spec.tsx @@ -23,6 +23,6 @@ jest.mock('uiSrc/services', () => ({ describe('ShortInstanceInfo', () => { it('should render', () => { - expect(render()).toBeTruthy() + expect(render()).toBeTruthy() }) }) diff --git a/redisinsight/ui/src/components/instance-header/components/ShortInstanceInfo.tsx b/redisinsight/ui/src/components/instance-header/components/ShortInstanceInfo.tsx index ba3555ad7e..612b3e26d7 100644 --- a/redisinsight/ui/src/components/instance-header/components/ShortInstanceInfo.tsx +++ b/redisinsight/ui/src/components/instance-header/components/ShortInstanceInfo.tsx @@ -22,8 +22,9 @@ export interface Props { dbIndex: number user?: Nullable } + databases: number } -const ShortInstanceInfo = ({ info }: Props) => { +const ShortInstanceInfo = ({ info, databases }: Props) => { const { name, host, port, connectionType, version, user } = info return (
@@ -37,17 +38,19 @@ const ShortInstanceInfo = ({ info }: Props) => { {port}
- - - - - - Logical Databases - - Select logical databases to work with in Browser, Workbench, and Database Analysis. - - - + {databases > 1 && ( + + + + + + Logical Databases + + Select logical databases to work with in Browser, Workbench, and Database Analysis. + + + + )} { !modulesData.length && dispatch(fetchInstancesAction()) })) dispatch(getDatabaseConfigInfoAction(connectionInstanceId)) + dispatch(fetchConnectedInstanceInfoAction(connectionInstanceId)) if (contextInstanceId && contextInstanceId !== connectionInstanceId) { resetContext() diff --git a/redisinsight/ui/src/services/tests/apiService.spec.ts b/redisinsight/ui/src/services/tests/apiService.spec.ts new file mode 100644 index 0000000000..d5327b7133 --- /dev/null +++ b/redisinsight/ui/src/services/tests/apiService.spec.ts @@ -0,0 +1,29 @@ +import { sessionStorageService } from 'uiSrc/services' +import { requestInterceptor } from 'uiSrc/services/apiService' +import { AxiosRequestConfig } from 'axios' + +describe('requestInterceptor', () => { + it('should properly set db-index to headers', () => { + sessionStorageService.get = jest.fn().mockReturnValue(5) + + const config: AxiosRequestConfig = { + headers: {}, + url: 'http://localhost:8080/databases/instanceId/endpoint' + } + + requestInterceptor(config) + expect(config?.headers?.['ri-db-index']).toEqual(5) + }) + + it('should not set db-index to headers with url not related to database', () => { + sessionStorageService.get = jest.fn().mockReturnValue(5) + + const config: AxiosRequestConfig = { + headers: {}, + url: 'http://localhost:8080/settings/instanceId/endpoint' + } + + requestInterceptor(config) + expect(config?.headers?.['ri-db-index']).toEqual(undefined) + }) +}) diff --git a/redisinsight/ui/src/slices/instances/instances.ts b/redisinsight/ui/src/slices/instances/instances.ts index 99b138a44f..7e27895c17 100644 --- a/redisinsight/ui/src/slices/instances/instances.ts +++ b/redisinsight/ui/src/slices/instances/instances.ts @@ -9,6 +9,7 @@ import { setAppContextInitialState } from 'uiSrc/slices/app/context' import successMessages from 'uiSrc/components/notifications/success-messages' import { checkRediStack, getApiErrorMessage, isStatusSuccessful, Nullable } from 'uiSrc/utils' import { Database as DatabaseInstanceResponse } from 'apiSrc/modules/database/models/database' +import { RedisNodeInfoResponse } from 'apiSrc/modules/database/dto/redis-info.dto' import { fetchMastersSentinelAction } from './sentinel' import { AppDispatch, RootState } from '../store' @@ -43,6 +44,10 @@ export const initialState: InitialStateInstances = { instanceOverview: { version: '', }, + instanceInfo: { + version: '', + server: {} + }, importInstances: { loading: false, error: '', @@ -60,7 +65,7 @@ const instancesSlice = createSlice({ state.loading = true state.error = '' }, - loadInstancesSuccess: (state, { payload }: { payload: Instance[] }) => { + loadInstancesSuccess: (state, { payload }: { payload: DatabaseInstanceResponse[] }) => { state.data = checkRediStack(payload) state.loading = false if (state.connectedInstance.id) { @@ -162,8 +167,16 @@ const instancesSlice = createSlice({ state.connectedInstance.db = sessionStorageService.get(`${BrowserStorageItem.dbIndex}${payload.id}`) ?? payload.db }, + setConnectedInfoInstance: (state) => { + state.instanceInfo = initialState.instanceInfo + }, + + setConnectedInfoInstanceSuccess: (state, { payload }: { payload: RedisNodeInfoResponse }) => { + state.instanceInfo = payload + }, + // set edited instance - setEditedInstance: (state, { payload }: { payload:Nullable }) => { + setEditedInstance: (state, { payload }: { payload: Nullable }) => { state.editedInstance.data = payload }, @@ -241,12 +254,16 @@ export const { checkDatabaseIndex, checkDatabaseIndexSuccess, checkDatabaseIndexFailure, + setConnectedInfoInstance, + setConnectedInfoInstanceSuccess, } = instancesSlice.actions // selectors export const instancesSelector = (state: RootState) => state.connections.instances export const connectedInstanceSelector = (state: RootState) => state.connections.instances.connectedInstance +export const connectedInstanceInfoSelector = (state: RootState) => + state.connections.instances.instanceInfo export const editedInstanceSelector = (state: RootState) => state.connections.instances.editedInstance export const connectedInstanceOverviewSelector = (state: RootState) => @@ -266,13 +283,7 @@ export function fetchInstancesAction(onSuccess?: (data?: DatabaseInstanceRespons dispatch(loadInstances()) try { - const { - data, - status, - }: { - data: DatabaseInstanceResponse[]; - status: number; - } = await apiService.get(`${ApiEndpoints.DATABASES}`) + const { data, status } = await apiService.get(`${ApiEndpoints.DATABASES}`) if (isStatusSuccessful(status)) { localStorageService.set(BrowserStorageItem.instancesCount, data?.length) @@ -405,6 +416,24 @@ export function fetchConnectedInstanceAction(id: string, onSuccess?: () => void) } } +// Asynchronous thunk action +export function fetchConnectedInstanceInfoAction(id: string, onSuccess?: () => void, onFail?: () => void) { + return async (dispatch: AppDispatch) => { + dispatch(setConnectedInfoInstance()) + + try { + const { data, status } = await apiService.get(`${ApiEndpoints.DATABASES}/${id}/info`) + + if (isStatusSuccessful(status)) { + dispatch(setConnectedInfoInstanceSuccess(data)) + onSuccess?.() + } + } catch (error) { + onFail?.() + } + } +} + // Asynchronous thunk action export function fetchEditedInstanceAction(id: string, onSuccess?: () => void) { return async (dispatch: AppDispatch) => { @@ -536,7 +565,6 @@ export function checkDatabaseIndexAction( dispatch(checkDatabaseIndex()) try { - // TODO - update url const { status } = await apiService.get( `${ApiEndpoints.DATABASES}/${id}/db/${index}` ) diff --git a/redisinsight/ui/src/slices/interfaces/instances.ts b/redisinsight/ui/src/slices/interfaces/instances.ts index 61234c9b6a..3098b451cc 100644 --- a/redisinsight/ui/src/slices/interfaces/instances.ts +++ b/redisinsight/ui/src/slices/interfaces/instances.ts @@ -13,6 +13,7 @@ import { SearchZSetMembersResponse } from 'apiSrc/modules/browser/dto' import { SentinelMaster } from 'apiSrc/modules/redis-sentinel/models/sentinel-master' import { CreateSentinelDatabaseDto } from 'apiSrc/modules/redis-sentinel/dto/create.sentinel.database.dto' import { CreateSentinelDatabaseResponse } from 'apiSrc/modules/redis-sentinel/dto/create.sentinel.database.response' +import { RedisNodeInfoResponse } from 'apiSrc/modules/database/dto/redis-info.dto' export interface Instance extends DatabaseInstanceResponse { host: string @@ -282,6 +283,7 @@ export interface InitialStateInstances { connectedInstance: Instance editedInstance: InitialStateEditedInstances instanceOverview: DatabaseConfigInfo + instanceInfo: RedisNodeInfoResponse importInstances: { loading: boolean error: string diff --git a/redisinsight/ui/src/slices/tests/app/context.spec.ts b/redisinsight/ui/src/slices/tests/app/context.spec.ts index 56c6059bd8..a536ed8e6a 100644 --- a/redisinsight/ui/src/slices/tests/app/context.spec.ts +++ b/redisinsight/ui/src/slices/tests/app/context.spec.ts @@ -38,7 +38,10 @@ import reducer, { updateKeyDetailsSizes, appContextBrowserKeyDetails, appContextDbConfig, - setSlowLogUnits, setDbConfig, + setSlowLogUnits, + setDbConfig, + setDbIndexState, + appContextDbIndex, } from '../../app/context' jest.mock('uiSrc/services', () => ({ @@ -807,4 +810,24 @@ describe('slices', () => { expect(appContextBrowserKeyDetails(rootState)).toEqual(state) }) }) + + describe('setDbIndexState', () => { + it('should properly set state for db index', () => { + // Arrange + const state = { + ...initialState.dbIndex, + disabled: true + } + + // Act + const nextState = reducer(initialState, setDbIndexState(true)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + app: { context: nextState }, + }) + + expect(appContextDbIndex(rootState)).toEqual(state) + }) + }) }) diff --git a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts index 769c3d1f36..f59bb7aa8d 100644 --- a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts @@ -48,7 +48,14 @@ import reducer, { importInstancesFromFileFailure, resetImportInstances, importInstancesSelector, - uploadInstancesFile + uploadInstancesFile, + checkDatabaseIndexFailure, + checkDatabaseIndexSuccess, + checkDatabaseIndex, + checkDatabaseIndexAction, + setConnectedInfoInstance, + setConnectedInfoInstanceSuccess, + fetchConnectedInstanceInfoAction } from '../../instances/instances' import { addErrorNotification, addMessageNotification, IAddInstanceErrorPayload } from '../../app/notifications' import { ConnectionType, InitialStateInstances, Instance } from '../../interfaces' @@ -493,6 +500,61 @@ describe('instances slice', () => { }) }) + describe('setConnectedInfoInstance', () => { + it('should properly set initial state', () => { + // Arrange + const currentState = { + ...initialState, + instanceInfo: { + version: '6.12.0', + databases: 12, + server: {} + } + } + const state: InitialStateInstances = { + ...initialState, + instanceInfo: initialState.instanceInfo + } + + // Act + const nextState = reducer(currentState, setConnectedInfoInstance()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + connections: { + instances: nextState, + }, + }) + expect(instancesSelector(rootState)).toEqual(state) + }) + }) + + describe('setConnectedInfoInstanceSuccess', () => { + it('should properly set state', () => { + // Arrange + const payload = { + version: '6.12.0', + databases: 12, + server: {} + } + const state: InitialStateInstances = { + ...initialState, + instanceInfo: payload + } + + // Act + const nextState = reducer(initialState, setConnectedInfoInstanceSuccess(payload)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + connections: { + instances: nextState, + }, + }) + expect(instancesSelector(rootState)).toEqual(state) + }) + }) + describe('setEditedInstance', () => { it('should properly set error', () => { // Arrange @@ -626,6 +688,79 @@ describe('instances slice', () => { }) }) + describe('checkDatabaseIndex', () => { + it('should properly set state', () => { + // Arrange + const state = { + ...initialState, + connectedInstance: { + ...initialState.connectedInstance, + loading: true, + } + } + + // Act + const nextState = reducer(initialState, checkDatabaseIndex()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + connections: { + instances: nextState, + }, + }) + expect(instancesSelector(rootState)).toEqual(state) + }) + }) + + describe('checkDatabaseIndexSuccess', () => { + it('should properly set state', () => { + // Arrange + const state = { + ...initialState, + connectedInstance: { + ...initialState.connectedInstance, + loading: false, + db: 5 + } + } + + // Act + const nextState = reducer(initialState, checkDatabaseIndexSuccess(5)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + connections: { + instances: nextState, + }, + }) + expect(instancesSelector(rootState)).toEqual(state) + }) + }) + + describe('checkDatabaseIndexFailure', () => { + it('should properly set state', () => { + // Arrange + const state = { + ...initialState, + connectedInstance: { + ...initialState.connectedInstance, + loading: false, + } + } + + // Act + const nextState = reducer(initialState, checkDatabaseIndexFailure()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + connections: { + instances: nextState, + }, + }) + expect(instancesSelector(rootState)).toEqual(state) + }) + }) + describe('thunks', () => { describe('fetchInstances', () => { it('call both fetchInstances and loadInstancesSuccess when fetch is successed', async () => { @@ -1014,6 +1149,61 @@ describe('instances slice', () => { }) }) + describe('fetchConnectedInstanceInfoAction', () => { + it('succeed to get database instance info', async () => { + // Arrange + const requestId = '123' + const data = { + databases: 12, + server: {}, + modules: [], + version: '6.52' + } + const responsePayload = { status: 200, data } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch( + fetchConnectedInstanceInfoAction(requestId, jest.fn()) + ) + + // Assert + const expectedActions = [ + setConnectedInfoInstance(), + setConnectedInfoInstanceSuccess(data), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to get database config info', async () => { + // Arrange + const requestId = '123' + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.get = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch( + fetchConnectedInstanceInfoAction(requestId, jest.fn()) + ) + + // Assert + const expectedActions = [ + setConnectedInfoInstance(), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + describe('changeInstanceAliasAction', () => { const requestPayload = { id: 'e37cc441-a4f2-402c-8bdb-fc2413cbbaff', name: 'newAlias' } it('succeed to change database alias', async () => { @@ -1113,6 +1303,52 @@ describe('instances slice', () => { }) }) + describe('checkDatabaseIndexAction', () => { + it('should call proper actions on success', async () => { + // Arrange + const id = 'instanceId' + const index = 3 + const responsePayload = { status: 200 } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(checkDatabaseIndexAction(id, index)) + + // Assert + const expectedActions = [ + checkDatabaseIndex(), + checkDatabaseIndexSuccess(index), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should call proper actions on fail', async () => { + // Arrange + const id = 'instanceId' + const errorMessage = 'some error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.get = jest.fn().mockRejectedValueOnce(responsePayload) + + // Act + await store.dispatch(checkDatabaseIndexAction(id, 3)) + + // Assert + const expectedActions = [ + checkDatabaseIndex(), + checkDatabaseIndexFailure(), + addErrorNotification(responsePayload as AxiosError), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + }) + describe('uploadInstancesFile', () => { it('should call proper actions on success', async () => { // Arrange diff --git a/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts b/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts index a1627987bb..1185a6d20e 100644 --- a/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts +++ b/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts @@ -11,6 +11,7 @@ import { apiService } from 'uiSrc/services' import { addErrorNotification } from 'uiSrc/slices/app/notifications' import { ClusterNodeRole, CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' import { EMPTY_COMMAND } from 'uiSrc/constants' +import { setDbIndexState } from 'uiSrc/slices/app/context' import { SendClusterCommandDto } from 'apiSrc/modules/cli/dto/cli.dto' import reducer, { initialState, @@ -324,7 +325,9 @@ describe('workbench results slice', () => { // Assert const expectedActions = [ sendWBCommand({ commands, commandId }), - sendWBCommandSuccess({ data, commandId }) + setDbIndexState(true), + sendWBCommandSuccess({ data, commandId }), + setDbIndexState(false) ] expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) }) @@ -353,7 +356,9 @@ describe('workbench results slice', () => { // Assert const expectedActions = [ sendWBCommand({ commands, commandId }), - sendWBCommandSuccess({ data, commandId }) + setDbIndexState(true), + sendWBCommandSuccess({ data, commandId }), + setDbIndexState(false) ] expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) @@ -379,11 +384,13 @@ describe('workbench results slice', () => { // Assert const expectedActions = [ sendWBCommand({ commands, commandId }), + setDbIndexState(true), addErrorNotification(responsePayload as AxiosError), processWBCommandsFailure({ commandsId: commands.map((_, i) => commandId + i), error: responsePayload.response.data.message }), + setDbIndexState(false), ] expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) }) @@ -424,7 +431,7 @@ describe('workbench results slice', () => { // Assert const expectedActions = [ sendWBCommand({ commands, commandId }), - sendWBCommandSuccess({ data, commandId }) + sendWBCommandSuccess({ data, commandId }), ] expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) }) @@ -452,7 +459,7 @@ describe('workbench results slice', () => { // Assert const expectedActions = [ sendWBCommand({ commands, commandId }), - sendWBCommandSuccess({ data, commandId }) + sendWBCommandSuccess({ data, commandId }), ] expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) }) @@ -476,11 +483,13 @@ describe('workbench results slice', () => { // Assert const expectedActions = [ sendWBCommand({ commands, commandId }), + setDbIndexState(true), addErrorNotification(responsePayload as AxiosError), processWBCommandsFailure({ commandsId: commands.map((_, i) => commandId + i), error: responsePayload.response.data.message }), + setDbIndexState(false), ] expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) }) diff --git a/redisinsight/ui/src/slices/workbench/wb-results.ts b/redisinsight/ui/src/slices/workbench/wb-results.ts index 937e1f97e6..e533a4ff68 100644 --- a/redisinsight/ui/src/slices/workbench/wb-results.ts +++ b/redisinsight/ui/src/slices/workbench/wb-results.ts @@ -14,6 +14,7 @@ import { } from 'uiSrc/utils' import { WORKBENCH_HISTORY_MAX_LENGTH } from 'uiSrc/pages/workbench/constants' import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' +import { setDbIndexState } from 'uiSrc/slices/app/context' import { CreateCommandExecutionsDto } from 'apiSrc/modules/workbench/dto/create-command-executions.dto' import { AppDispatch, RootState } from '../store' @@ -21,7 +22,6 @@ import { CommandExecution, StateWorkbenchResults, } from '../interfaces' -import { setDbIndexState } from 'uiSrc/slices/app/context' export const initialState: StateWorkbenchResults = { loading: false, @@ -311,8 +311,6 @@ export function sendWBCommandClusterAction({ commandId })) - dispatch(setDbIndexState(true)) - const { data, status } = await apiService.post( getUrl( id, @@ -329,7 +327,6 @@ export function sendWBCommandClusterAction({ if (isStatusSuccessful(status)) { dispatch(sendWBCommandSuccess({ commandId, data: reverse(data), processing: !!multiCommands?.length })) - dispatch(setDbIndexState(!!multiCommands?.length)) onSuccessAction?.(multiCommands) } } catch (_err) { @@ -340,7 +337,6 @@ export function sendWBCommandClusterAction({ commandsId: commands.map((_, i) => commandId + i), error: errorMessage })) - dispatch(setDbIndexState(false)) onFailAction?.() } } diff --git a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss index 135cdb0bdd..691209b50d 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -83,7 +83,7 @@ $cliOutputResponseFailColor: #ad0017; $badgeBackgroundColor: #e8efff; $commandGroupBadgeColor: #b8c5db; $callOutBackgroundColor: #e9edfa; -$tooltipLightBgColor: #e9eaef; +$tooltipLightBgColor: #F5F8FF; $overlayPromoNYColor: #ffffff1a; From d48aaaa0d795a0e90524dc3a0643f49d9d5eed16 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Thu, 29 Dec 2022 17:00:37 +0300 Subject: [PATCH 083/201] #RI-3949 - remove unused import --- .../ui/src/components/instance-header/InstanceHeader.spec.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx b/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx index 85b9fb74f5..e4a01d0890 100644 --- a/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx +++ b/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx @@ -4,7 +4,6 @@ import { instance, mock } from 'ts-mockito' import { cleanup, mockedStore, render, screen, fireEvent } from 'uiSrc/utils/test-utils' import { checkDatabaseIndex, connectedInstanceInfoSelector } from 'uiSrc/slices/instances/instances' -import { BuildType } from 'uiSrc/constants/env' import InstanceHeader, { Props } from './InstanceHeader' const mockedProps = mock() From 70408ee7afcc3349790e2712d45652e1e1fd13fd Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Fri, 30 Dec 2022 08:17:33 +0400 Subject: [PATCH 084/201] #RI-3942 - workaround determine only on first shard --- .../modules/database-analysis/database-analysis.service.ts | 5 ++++- .../src/modules/recommendation/recommendation.service.ts | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index 2fa3069476..4c70d05feb 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -3,6 +3,7 @@ import { IFindRedisClientInstanceByOptions } from 'src/modules/redis/redis.servi import { isNull, flatten, uniqBy } from 'lodash'; import { RecommendationService } from 'src/modules/recommendation/recommendation.service'; import { catchAclError } from 'src/utils'; +import { RECOMMENDATION_NAMES } from 'src/constants'; import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/database-analyzer'; import { plainToClass } from 'class-transformer'; import { DatabaseAnalysis, ShortDatabaseAnalysis } from 'src/modules/database-analysis/models'; @@ -58,11 +59,13 @@ export class DatabaseAnalysisService { const recommendations = DatabaseAnalysisService.getRecommendationsSummary( flatten(await Promise.all( - scanResults.map(async (nodeResult) => ( + scanResults.map(async (nodeResult, idx) => ( await this.recommendationService.getRecommendations({ client: nodeResult.client, keys: nodeResult.keys, total: progress.total, + // TODO: create generic solution to exclude recommendations + exclude: idx !== 0 ? [RECOMMENDATION_NAMES.RTS] : [], }) )), )), diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index ea8351557a..a6a25460e0 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Redis } from 'ioredis'; import { RecommendationProvider } from 'src/modules/recommendation/providers/recommendation.provider'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; +import { RECOMMENDATION_NAMES } from 'src/constants'; import { RedisString } from 'src/common/constants'; import { Key } from 'src/modules/database-analysis/models'; @@ -10,6 +11,7 @@ interface RecommendationInput { keys?: Key[], info?: RedisString, total?: number, + exclude?: string[], } @Injectable() @@ -31,6 +33,7 @@ export class RecommendationService { keys, info, total, + exclude, } = dto; return ( @@ -49,7 +52,8 @@ export class RecommendationService { await this.recommendationProvider.determineBigSetsRecommendation(keys), await this.recommendationProvider.determineConnectionClientsRecommendation(client), await this.recommendationProvider.determineSetPasswordRecommendation(client), - await this.recommendationProvider.determineRTSRecommendation(client, keys), + // TODO rework, need better solution to do not start determine recommendation + exclude.includes(RECOMMENDATION_NAMES.RTS) ? null : await this.recommendationProvider.determineRTSRecommendation(client, keys), ])); } } From 24c97b625c512b60264f6654b55457f32dfbb0fa Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Tue, 3 Jan 2023 09:20:52 +0300 Subject: [PATCH 085/201] #RI-3957 - add searchlight as part of modules of redistack --- redisinsight/ui/src/utils/redistack.ts | 17 ++++-- .../ui/src/utils/tests/redistack.spec.ts | 57 ++++++++++++++----- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/redisinsight/ui/src/utils/redistack.ts b/redisinsight/ui/src/utils/redistack.ts index 6d572d10e7..dab6b5e5a4 100644 --- a/redisinsight/ui/src/utils/redistack.ts +++ b/redisinsight/ui/src/utils/redistack.ts @@ -1,16 +1,20 @@ -import { map, isEqual } from 'lodash' +import { isArray, map } from 'lodash' import { Instance, RedisDefaultModules } from 'uiSrc/slices/interfaces' export const REDISTACK_PORT = 6379 -export const REDISTACK_MODULES = [ +export const REDISTACK_MODULES: Array> = [ RedisDefaultModules.ReJSON, + RedisDefaultModules.Bloom, RedisDefaultModules.Graph, + [RedisDefaultModules.Search, RedisDefaultModules.SearchLight], RedisDefaultModules.TimeSeries, - RedisDefaultModules.Search, - RedisDefaultModules.Bloom, -].sort() +] -const checkRediStackModules = (modules: any[]) => isEqual(map(modules, 'name').sort(), REDISTACK_MODULES) +const checkRediStackModules = (modules: any[]) => map(modules, 'name') + .sort() + .every((m, index) => (isArray(REDISTACK_MODULES[index]) + ? (REDISTACK_MODULES[index] as Array).some((rm) => rm === m) + : REDISTACK_MODULES[index] === m)) const checkRediStack = (instances: Instance[]): Instance[] => { let isRediStackCheck = false @@ -25,6 +29,7 @@ const checkRediStack = (instances: Instance[]): Instance[] => { } }) + // if no any database with redistack on port 6379 - mark others as redistack (with modules check) if (!isRediStackCheck) { newInstances = newInstances.map((instance) => ({ ...instance, diff --git a/redisinsight/ui/src/utils/tests/redistack.spec.ts b/redisinsight/ui/src/utils/tests/redistack.spec.ts index 88ceffe2fd..02aa49b0c0 100644 --- a/redisinsight/ui/src/utils/tests/redistack.spec.ts +++ b/redisinsight/ui/src/utils/tests/redistack.spec.ts @@ -1,22 +1,51 @@ -import { checkRediStack, REDISTACK_MODULES, REDISTACK_PORT } from 'uiSrc/utils' +/* eslint-disable max-len */ +import { checkRediStack, REDISTACK_PORT } from 'uiSrc/utils' +import { RedisDefaultModules } from 'uiSrc/slices/interfaces' const unmapWithName = (arr: any[]) => arr.map((item) => ({ name: item })) -const REDISTACK_MODULE_DEFAULT = unmapWithName(REDISTACK_MODULES) +const REDISTACK_MODULE_DEFAULT = unmapWithName([ + RedisDefaultModules.ReJSON, + RedisDefaultModules.Graph, + RedisDefaultModules.TimeSeries, + RedisDefaultModules.Search, + RedisDefaultModules.Bloom, +].sort()) const getOutputCheckRediStackTests: any[] = [ - [[{ port: REDISTACK_PORT, modules: REDISTACK_MODULE_DEFAULT }, - { port: 12000, modules: REDISTACK_MODULE_DEFAULT }], - [{ port: REDISTACK_PORT, modules: REDISTACK_MODULE_DEFAULT, isRediStack: true }, - { port: 12000, modules: REDISTACK_MODULE_DEFAULT, isRediStack: false }]], - [[{ port: REDISTACK_PORT, modules: REDISTACK_MODULE_DEFAULT }], - [{ port: REDISTACK_PORT, modules: REDISTACK_MODULE_DEFAULT, isRediStack: true }]], - [[{ port: REDISTACK_PORT, modules: unmapWithName(['']) }], [{ port: REDISTACK_PORT, modules: unmapWithName(['']), isRediStack: false }]], - [[{ port: REDISTACK_PORT, modules: unmapWithName(['search']) }], [{ port: REDISTACK_PORT, modules: unmapWithName(['search']), isRediStack: false }]], - [[{ port: REDISTACK_PORT, modules: unmapWithName(['bf', 'search', 'timeseries']) }], [{ port: REDISTACK_PORT, modules: unmapWithName(['bf', 'search', 'timeseries']), isRediStack: false }]], - [[{ port: 12000, modules: REDISTACK_MODULE_DEFAULT }], - [{ port: 12000, modules: REDISTACK_MODULE_DEFAULT, isRediStack: true }]], - [[{ port: 12000, modules: unmapWithName(['search']) }], [{ port: 12000, modules: unmapWithName(['search']), isRediStack: false }]], + [ + [{ port: REDISTACK_PORT, modules: REDISTACK_MODULE_DEFAULT }, { port: 12000, modules: REDISTACK_MODULE_DEFAULT }], + [{ port: REDISTACK_PORT, modules: REDISTACK_MODULE_DEFAULT, isRediStack: true }, { port: 12000, modules: REDISTACK_MODULE_DEFAULT, isRediStack: false }] + ], + [ + [{ port: REDISTACK_PORT, modules: REDISTACK_MODULE_DEFAULT }], + [{ port: REDISTACK_PORT, modules: REDISTACK_MODULE_DEFAULT, isRediStack: true }] + ], + [ + [{ port: REDISTACK_PORT, modules: unmapWithName(['']) }], + [{ port: REDISTACK_PORT, modules: unmapWithName(['']), isRediStack: false }] + ], + [ + [{ port: REDISTACK_PORT, modules: unmapWithName(['search']) }], + [{ port: REDISTACK_PORT, modules: unmapWithName(['search']), isRediStack: false }] + ], + [ + [{ port: REDISTACK_PORT, modules: unmapWithName(['bf', 'search', 'timeseries']) }], + [{ port: REDISTACK_PORT, modules: unmapWithName(['bf', 'search', 'timeseries']), isRediStack: false }] + ], + [ + [{ port: 12000, modules: REDISTACK_MODULE_DEFAULT }], + [{ port: 12000, modules: REDISTACK_MODULE_DEFAULT, isRediStack: true }] + ], + [ + [{ port: 12000, modules: unmapWithName(['search']) }], + [{ port: 12000, modules: unmapWithName(['search']), isRediStack: false }] + ], + // check searchlight - should be also marked as RediStack + [ + [{ port: 12000, modules: unmapWithName(['bf', 'timeseries', 'ReJSON', 'searchlight', 'graph']) }], + [{ port: 12000, modules: unmapWithName(['bf', 'timeseries', 'ReJSON', 'searchlight', 'graph']), isRediStack: true }] + ], ] describe('checkRediStack', () => { From 8e58457ae1a0d1728880f6bacc312e19efda77d9 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Tue, 3 Jan 2023 09:36:34 +0300 Subject: [PATCH 086/201] #RI-3983 - fix loading message --- .../ui/src/pages/browser/components/key-list/KeyList.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx index 60a31b81be..730287cbd3 100644 --- a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx @@ -171,10 +171,6 @@ const KeyList = forwardRef((props: Props, ref) => { return ScanNoResultsFoundText } - if (keysState.scanned === 0 && total) { - return 'loading...' - } - if (itemsRef.current.length < keysState.keys.length) { return 'loading...' } From 6aeb63276b6e57b3d5615856c79dfe382a01af50 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 3 Jan 2023 13:34:19 +0200 Subject: [PATCH 087/201] #RI-3980, #RI-3981, #RI-3982 bugfixes --- redisinsight/api/src/common/models/client-metadata.ts | 4 +++- redisinsight/api/src/common/models/database-index.ts | 4 ++++ redisinsight/api/src/common/models/index.ts | 1 + .../common/pipes/database-index.validation.pipe.ts | 7 +++++++ redisinsight/api/src/common/pipes/index.ts | 1 + .../src/modules/bulk-actions/bulk-actions.gateway.ts | 2 +- .../bulk-actions/dto/create-bulk-action.dto.ts | 11 ++++++++++- .../src/modules/database/database-info.controller.ts | 10 ++++++---- .../api/src/modules/database/database-info.service.ts | 4 ++-- 9 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 redisinsight/api/src/common/models/database-index.ts create mode 100644 redisinsight/api/src/common/pipes/database-index.validation.pipe.ts create mode 100644 redisinsight/api/src/common/pipes/index.ts diff --git a/redisinsight/api/src/common/models/client-metadata.ts b/redisinsight/api/src/common/models/client-metadata.ts index 98fcc663aa..999d5ff4d8 100644 --- a/redisinsight/api/src/common/models/client-metadata.ts +++ b/redisinsight/api/src/common/models/client-metadata.ts @@ -1,7 +1,7 @@ import { Session } from 'src/common/models/session'; import { Type } from 'class-transformer'; import { - IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, + IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min, } from 'class-validator'; export enum ClientContext { @@ -31,5 +31,7 @@ export class ClientMetadata { @IsOptional() @IsNumber() @Type(() => Number) + @Min(0) + @Max(2147483647) db?: number; } diff --git a/redisinsight/api/src/common/models/database-index.ts b/redisinsight/api/src/common/models/database-index.ts new file mode 100644 index 0000000000..ff68f09032 --- /dev/null +++ b/redisinsight/api/src/common/models/database-index.ts @@ -0,0 +1,4 @@ +import { PickType } from '@nestjs/swagger'; +import { ClientMetadata } from 'src/common/models/client-metadata'; + +export class DatabaseIndex extends PickType(ClientMetadata, ['db'] as const) {} diff --git a/redisinsight/api/src/common/models/index.ts b/redisinsight/api/src/common/models/index.ts index 20ae751ab7..25a21bb0dd 100644 --- a/redisinsight/api/src/common/models/index.ts +++ b/redisinsight/api/src/common/models/index.ts @@ -2,3 +2,4 @@ export * from './common'; export * from './endpoint'; export * from './session'; export * from './client-metadata'; +export * from './database-index'; diff --git a/redisinsight/api/src/common/pipes/database-index.validation.pipe.ts b/redisinsight/api/src/common/pipes/database-index.validation.pipe.ts new file mode 100644 index 0000000000..088c0b0130 --- /dev/null +++ b/redisinsight/api/src/common/pipes/database-index.validation.pipe.ts @@ -0,0 +1,7 @@ +import { ArgumentMetadata, ValidationPipe } from '@nestjs/common'; + +export class DbIndexValidationPipe extends ValidationPipe { + async transform(db, metadata: ArgumentMetadata) { + return super.transform({ db }, metadata); + } +} diff --git a/redisinsight/api/src/common/pipes/index.ts b/redisinsight/api/src/common/pipes/index.ts new file mode 100644 index 0000000000..b2751618f8 --- /dev/null +++ b/redisinsight/api/src/common/pipes/index.ts @@ -0,0 +1 @@ +export * from './database-index.validation.pipe'; diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-actions.gateway.ts b/redisinsight/api/src/modules/bulk-actions/bulk-actions.gateway.ts index 610b886cdf..f1e8ae414c 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-actions.gateway.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-actions.gateway.ts @@ -46,7 +46,7 @@ export class BulkActionsGateway implements OnGatewayConnection, OnGatewayDisconn @SubscribeMessage(BulkActionsServerEvents.Abort) abort(@Body() dto: BulkActionIdDto) { - this.logger.log('Subscribing to bulk action.'); + this.logger.log('Aborting bulk action.'); return this.service.abort(dto); } diff --git a/redisinsight/api/src/modules/bulk-actions/dto/create-bulk-action.dto.ts b/redisinsight/api/src/modules/bulk-actions/dto/create-bulk-action.dto.ts index 6d16fbd14e..86d74973d5 100644 --- a/redisinsight/api/src/modules/bulk-actions/dto/create-bulk-action.dto.ts +++ b/redisinsight/api/src/modules/bulk-actions/dto/create-bulk-action.dto.ts @@ -1,6 +1,8 @@ import { BulkActionFilter } from 'src/modules/bulk-actions/models/bulk-action-filter'; import { BulkActionType } from 'src/modules/bulk-actions/constants'; -import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { + IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min, +} from 'class-validator'; import { Type } from 'class-transformer'; import { BulkActionIdDto } from 'src/modules/bulk-actions/dto/bulk-action-id.dto'; @@ -16,4 +18,11 @@ export class CreateBulkActionDto extends BulkActionIdDto { @IsNotEmpty() @Type(() => BulkActionFilter) filter: BulkActionFilter; + + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + @Max(2147483647) + db?: number; } diff --git a/redisinsight/api/src/modules/database/database-info.controller.ts b/redisinsight/api/src/modules/database/database-info.controller.ts index 70dba71936..c13c49f7b4 100644 --- a/redisinsight/api/src/modules/database/database-info.controller.ts +++ b/redisinsight/api/src/modules/database/database-info.controller.ts @@ -1,17 +1,19 @@ import { ApiTags } from '@nestjs/swagger'; import { - Controller, Get, Param, UseInterceptors, + Controller, Get, Param, UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; import { TimeoutInterceptor } from 'src/common/interceptors/timeout.interceptor'; import { DatabaseInfoService } from 'src/modules/database/database-info.service'; import { DatabaseOverview } from 'src/modules/database/models/database-overview'; import { RedisDatabaseInfoResponse } from 'src/modules/database/dto/redis-info.dto'; -import { ClientMetadata } from 'src/common/models'; +import { ClientMetadata, DatabaseIndex } from 'src/common/models'; import { ClientMetadataParam } from 'src/common/decorators'; +import { DbIndexValidationPipe } from 'src/common/pipes'; @ApiTags('Database Instances') @Controller('databases') +@UsePipes(new ValidationPipe({ transform: true })) export class DatabaseInfoController { constructor( private databaseInfoService: DatabaseInfoService, @@ -73,12 +75,12 @@ export class DatabaseInfoController { ], }) async getDatabaseIndex( - @Param('index') db: string, + @Param('index', new DbIndexValidationPipe({ transform: true })) databaseIndexDto: DatabaseIndex, @ClientMetadataParam({ databaseIdParam: 'id', ignoreDbIndex: true, }) clientMetadata: ClientMetadata, ): Promise { - return this.databaseInfoService.getDatabaseIndex(clientMetadata, db); + return this.databaseInfoService.getDatabaseIndex(clientMetadata, databaseIndexDto.db); } } diff --git a/redisinsight/api/src/modules/database/database-info.service.ts b/redisinsight/api/src/modules/database/database-info.service.ts index 7a559af3b4..62eec12984 100644 --- a/redisinsight/api/src/modules/database/database-info.service.ts +++ b/redisinsight/api/src/modules/database/database-info.service.ts @@ -50,7 +50,7 @@ export class DatabaseInfoService { * @param clientMetadata * @param db */ - public async getDatabaseIndex(clientMetadata: ClientMetadata, db: string): Promise { + public async getDatabaseIndex(clientMetadata: ClientMetadata, db: number): Promise { this.logger.log(`Connection to database index: ${db}`); let client; @@ -58,7 +58,7 @@ export class DatabaseInfoService { try { client = await this.databaseConnectionService.createClient({ ...clientMetadata, - db: parseInt(db, 10), + db, }); client?.disconnect(); return undefined; From e05284c89926e7512f4b1bfeb71de1cc78fbc921 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 3 Jan 2023 13:56:18 +0200 Subject: [PATCH 088/201] #RI-3980, #RI-3981, #RI-3982 pass db to client creation --- .../src/modules/bulk-actions/providers/bulk-actions.provider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/redisinsight/api/src/modules/bulk-actions/providers/bulk-actions.provider.ts b/redisinsight/api/src/modules/bulk-actions/providers/bulk-actions.provider.ts index a0e2b32cf5..4ee9c03b1b 100644 --- a/redisinsight/api/src/modules/bulk-actions/providers/bulk-actions.provider.ts +++ b/redisinsight/api/src/modules/bulk-actions/providers/bulk-actions.provider.ts @@ -38,6 +38,7 @@ export class BulkActionsProvider { session: undefined, databaseId: dto.databaseId, context: ClientContext.Common, + db: dto.db, }); await bulkAction.prepare(client, BulkActionsProvider.getSimpleRunnerClass(dto)); From 2e294bf80b1debc4a8fe0bf609920b6d927bc302 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Tue, 3 Jan 2023 15:01:13 +0300 Subject: [PATCH 089/201] #RI-3982 - fix bulk delete index --- .../components/bulk-actions-config/BulkActionsConfig.spec.tsx | 1 + .../src/components/bulk-actions-config/BulkActionsConfig.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.spec.tsx b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.spec.tsx index 218da3893e..9810b6b00d 100644 --- a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.spec.tsx +++ b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.spec.tsx @@ -43,6 +43,7 @@ jest.mock('uiSrc/slices/instances/instances', () => ({ const deletingMock = [{ id: '123', databaseId: '1', + db: 1, type: BulkActionsType.Delete, filter: { type: null, diff --git a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx index 8c59533f99..6aa78d5e4e 100644 --- a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx +++ b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx @@ -23,7 +23,7 @@ interface IProps { } const BulkActionsConfig = ({ retryDelay = 5000 } : IProps) => { - const { id: instanceId = '' } = useSelector(connectedInstanceSelector) + const { id: instanceId = '', db } = useSelector(connectedInstanceSelector) const { isActionTriggered, isConnected } = useSelector(bulkActionsSelector) const { filter, search } = useSelector(keysSelector) const socketRef = useRef>(null) @@ -86,6 +86,7 @@ const BulkActionsConfig = ({ retryDelay = 5000 } : IProps) => { { id, databaseId: instanceId, + db: db || 0, type: BulkActionsType.Delete, filter: { type: filter, From a839ca4cda3df1e5530589812e584dc25126677c Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 3 Jan 2023 18:26:12 +0100 Subject: [PATCH 090/201] add tests for change database index --- tests/e2e/helpers/keys.ts | 11 +- tests/e2e/pageObjects/browser-page.ts | 8 + .../e2e/pageObjects/database-overview-page.ts | 31 +++- .../critical-path/browser/context.e2e.ts | 3 +- .../database-overview/database-index.e2e.ts | 140 ++++++++++++++++++ .../database-overview.e2e.ts | 0 .../database/logical-databases.e2e.ts | 6 +- .../memory-efficiency.e2e.ts | 3 +- .../critical-path/tree-view/tree-view.e2e.ts | 3 +- .../tests/regression/browser/context.e2e.ts | 5 +- .../regression/browser/resize-columns.e2e.ts | 10 +- .../database-info.e2e.ts | 0 .../database-overview-keys.e2e.ts | 0 .../database-overview.e2e.ts | 0 .../overview.e2e.ts | 0 15 files changed, 206 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/tests/critical-path/database-overview/database-index.e2e.ts rename tests/e2e/tests/critical-path/{browser => database-overview}/database-overview.e2e.ts (100%) rename tests/e2e/tests/regression/{browser => database-overview}/database-info.e2e.ts (100%) rename tests/e2e/tests/regression/{browser => database-overview}/database-overview-keys.e2e.ts (100%) rename tests/e2e/tests/regression/{browser => database-overview}/database-overview.e2e.ts (100%) rename tests/e2e/tests/regression/{database => database-overview}/overview.e2e.ts (100%) diff --git a/tests/e2e/helpers/keys.ts b/tests/e2e/helpers/keys.ts index f2b1fa8007..003fd0ecbd 100644 --- a/tests/e2e/helpers/keys.ts +++ b/tests/e2e/helpers/keys.ts @@ -234,4 +234,13 @@ export async function verifyKeysNotDisplayedInTheList(keyNames: string[]): Promi for (const keyName of keyNames) { await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyName)).notOk(`The key ${keyName} found`); } -} \ No newline at end of file +} + +/** +* Verify search/filter value +* @param value The value in search/filter input +*/ + +export async function verifySearchFilterValue(value: string): Promise { + await t.expect(browserPage.filterByPatterSearchInput.withAttribute('value', value).exists).ok('Filter per key name is not applied/correct'); +} diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index 3d6099c935..7187ff347a 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -1043,6 +1043,14 @@ export class BrowserPage { Selector(`[data-testid="${base}keys:keys:"]`).visible) .ok("Folder is not selected"); } + + /** + * Verify that database has no keys + */ + async verifyNoKeysInDatabase(): Promise { + await t.expect(this.keyListMessage.exists).ok('Database not empty') + .expect(this.keysSummary.exists).notOk('Total value is displayed for empty database'); + } } /** diff --git a/tests/e2e/pageObjects/database-overview-page.ts b/tests/e2e/pageObjects/database-overview-page.ts index fd4f5b57b4..ff3058e981 100644 --- a/tests/e2e/pageObjects/database-overview-page.ts +++ b/tests/e2e/pageObjects/database-overview-page.ts @@ -1,4 +1,4 @@ -import { Selector } from 'testcafe'; +import { Selector, t } from 'testcafe'; export class DatabaseOverviewPage { //------------------------------------------------------------------------------------------- @@ -7,14 +7,37 @@ export class DatabaseOverviewPage { //*Target any element/component via data-id, if possible! //*The following categories are ordered alphabetically (Alerts, Buttons, Checkboxes, etc.). //------------------------------------------------------------------------------------------- - //TEXT ELEMENTS + // TEXT ELEMENTS overviewTotalKeys = Selector('[data-test-subj=overview-total-keys]'); overviewTotalMemory = Selector('[data-test-subj=overview-total-memory]'); databaseModules = Selector('[data-testid$=module]'); overviewTooltipStatTitle = Selector('[data-testid=overview-db-stat-title]'); - //BUTTONS + // BUTTONS overviewRedisStackLogo = Selector('[data-testid=redis-stack-logo]'); overviewMoreInfo = Selector('[data-testid=overview-more-info-button]'); - //Panel + changeIndexBtn = Selector('[data-testid=change-index-btn]'); + applyButton = Selector('[data-testid=apply-btn]'); + // PANEL overviewTooltip = Selector('[data-testid=overview-more-info-tooltip]'); + // INPUTS + changeIndexInput = Selector('[data-testid=change-index-input]'); + + /** + * Change database index + * @param dbIndex The index of logical database + */ + async changeDbIndex(dbIndex: number): Promise { + await t.click(this.changeIndexBtn) + .typeText(this.changeIndexInput, dbIndex.toString(), { replace: true, paste: true }) + .click(this.applyButton) + .expect(this.changeIndexBtn.textContent).contains(dbIndex.toString()); + } + + /** + * Verify that definite database index selected + * @param dbIndex The index of logical database + */ + async verifyDbIndexSelected(dbIndex: number): Promise { + await t.expect(this.changeIndexBtn.textContent).contains(dbIndex.toString()); + } } diff --git a/tests/e2e/tests/critical-path/browser/context.e2e.ts b/tests/e2e/tests/critical-path/browser/context.e2e.ts index 72d46c4785..b18a28b95c 100644 --- a/tests/e2e/tests/critical-path/browser/context.e2e.ts +++ b/tests/e2e/tests/critical-path/browser/context.e2e.ts @@ -8,6 +8,7 @@ import { commonUrl, ossStandaloneBigConfig, ossStandaloneConfig } from '../../.. import { Common } from '../../../helpers/common'; import { KeyTypesTexts, rte } from '../../../helpers/constants'; import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; +import { verifySearchFilterValue } from '../../../helpers/keys'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); @@ -70,7 +71,7 @@ test('Verify that user can see saved filter per key type applied when he returns // Return back to Browser and check filter applied await t.click(myRedisDatabasePage.browserButton); // Verify that user can see saved input entered into the filter per Key name when he returns back to Browser page - await t.expect(browserPage.filterByPatterSearchInput.withAttribute('value', keyName).exists).ok('Filter per key name is still applied'); + await verifySearchFilterValue(keyName); }); test('Verify that user can see saved executed commands in CLI on Browser page when he returns back to Browser page', async t => { const commands = [ diff --git a/tests/e2e/tests/critical-path/database-overview/database-index.e2e.ts b/tests/e2e/tests/critical-path/database-overview/database-index.e2e.ts new file mode 100644 index 0000000000..e397b639a2 --- /dev/null +++ b/tests/e2e/tests/critical-path/database-overview/database-index.e2e.ts @@ -0,0 +1,140 @@ +import { acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; +import { KeyTypesTexts, rte } from '../../../helpers/constants'; +import { Common } from '../../../helpers/common'; +import { + MyRedisDatabasePage, + BrowserPage, + CliPage, + DatabaseOverviewPage, + WorkbenchPage, + MemoryEfficiencyPage +} from '../../../pageObjects'; +import { + commonUrl, + ossStandaloneConfig +} from '../../../helpers/conf'; +import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; +import { verifyKeysDisplayedInTheList, verifyKeysNotDisplayedInTheList, verifySearchFilterValue } from '../../../helpers/keys'; + +const myRedisDatabasePage = new MyRedisDatabasePage(); +const browserPage = new BrowserPage(); +const cliPage = new CliPage(); +const databaseOverviewPage = new DatabaseOverviewPage(); +const common = new Common(); +const workbenchPage = new WorkbenchPage(); +const memoryEfficiencyPage = new MemoryEfficiencyPage(); + +let keyName = common.generateWord(10); +const indexName = `idx:${keyName}`; +const keyNames = [`${keyName}:1`, `${keyName}:2`]; +const commands = [ + `HSET ${keyNames[0]} "name" "Hall School" "description" " Spanning 10 states" "class" "independent" "type" "traditional" "address_city" "London" "address_street" "Manor Street" "students" 342 "location" "51.445417, -0.258352"`, + `HSET ${keyNames[1]} "name" "Garden School" "description" "Garden School is a new outdoor" "class" "state" "type" "forest; montessori;" "address_city" "London" "address_street" "Gordon Street" "students" 1452 "location" "51.402926, -0.321523"`, + `FT.CREATE ${indexName} ON HASH PREFIX 1 "${keyName}:" SCHEMA name TEXT NOSTEM description TEXT class TAG type TAG SEPARATOR ";" address_city AS city TAG address_street AS address TEXT NOSTEM students NUMERIC SORTABLE location GEO` +]; +const keyNameForSearchInLogicalDb = 'keyForSearch'; +const logicalDbKey = `${keyName}:3`; + +fixture `Allow to change database index` + .meta({ type: 'critical_path', rte: rte.standalone }) + .page(commonUrl) + .beforeEach(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + // Create 3 keys and index + await cliPage.sendCommandsInCli(commands); + }) + .afterEach(async () => { + // Delete keys in logical database + await databaseOverviewPage.changeDbIndex(1); + await cliPage.sendCommandsInCli([`DEL ${keyNameForSearchInLogicalDb}`, `DEL ${logicalDbKey}`]); + // Delete and clear database + await databaseOverviewPage.changeDbIndex(0); + await cliPage.sendCommandsInCli([`DEL ${keyNames.join(' ')}`, `DEL ${keyName}`, `FT.DROPINDEX ${indexName}`]); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + }); +test('Switching between indexed databases', async t => { + const command = `HSET ${logicalDbKey} "name" "Gillford School" "description" "Gillford School is a centre" "class" "private" "type" "democratic; waldorf" "address_city" "Goudhurst" "address_street" "Goudhurst" "students" 721 "location" "51.112685, 0.451076"`; + const rememberedConnectedClients = await browserPage.overviewConnectedClients.textContent; + + // Change index to logical db + await databaseOverviewPage.changeDbIndex(1); + // Verify that the same client connections are used after changing index + const logicalDbConnectedClients = await browserPage.overviewConnectedClients.textContent; + await t.expect(rememberedConnectedClients).eql(logicalDbConnectedClients); + + // Verify that data changed for indexed db on Browser view + await browserPage.verifyNoKeysInDatabase(); + + // Verify that logical db not changed after reloading page + await common.reloadPage(); + await databaseOverviewPage.verifyDbIndexSelected(1); + await browserPage.verifyNoKeysInDatabase(); + + // Add key to logical (index=1) database + await browserPage.addHashKey(keyNameForSearchInLogicalDb); + // Verify that data changed for indexed db on Tree view + await t.click(browserPage.treeViewButton); + await verifyKeysDisplayedInTheList([keyNameForSearchInLogicalDb]); + await verifyKeysNotDisplayedInTheList(keyNames); + + // Filter by Hash keys and search by key name + await browserPage.selectFilterGroupType(KeyTypesTexts.Hash); + await browserPage.searchByKeyName(keyNameForSearchInLogicalDb); + // Return to default database + await databaseOverviewPage.changeDbIndex(0); + + // Verify that search/filter saved after switching index in Browser + await verifySearchFilterValue(keyNameForSearchInLogicalDb); + await verifyKeysNotDisplayedInTheList([keyNameForSearchInLogicalDb]); + await t.click(browserPage.browserViewButton); + // Change index to logical db + await databaseOverviewPage.changeDbIndex(1); + await verifySearchFilterValue(keyNameForSearchInLogicalDb); + await verifyKeysDisplayedInTheList([keyNameForSearchInLogicalDb]); + + // Return to default database and open search capability + await databaseOverviewPage.changeDbIndex(0); + await t.click(browserPage.redisearchModeBtn); + await browserPage.selectIndexByName(indexName); + await verifyKeysDisplayedInTheList(keyNames); + // Change index to logical db + await databaseOverviewPage.changeDbIndex(1); + // Verify that data changed for indexed db on Search capability page + // await t.expect(browserPage.keyListTable.textContent).contains('No results found.', 'Data not changed for indexed db'); + + // Search by value and return to default database + await browserPage.searchByKeyName('Hall School'); + await databaseOverviewPage.changeDbIndex(0); + await verifyKeysDisplayedInTheList([keyNames[0]]); + // Change index to logical db + await databaseOverviewPage.changeDbIndex(1); + // Verify that search/filter saved after switching index in Search capability + // await t.expect(browserPage.keyListTable.textContent).contains('No results found.', 'Data not changed for indexed db'); + await verifySearchFilterValue(keyNameForSearchInLogicalDb); + + // Open Workbench page + await t.click(myRedisDatabasePage.workbenchButton); + await workbenchPage.sendCommandInWorkbench(command); + // Open Browser page + await t.click(myRedisDatabasePage.browserButton); + // check after fix TBD + // await verifyKeysDisplayedInTheList([logicalDbKey]); + await t.click(browserPage.patternModeBtn); + // Verify that data changed for indexed db on Workbench page + await verifyKeysDisplayedInTheList([keyNameForSearchInLogicalDb, logicalDbKey]); + await databaseOverviewPage.changeDbIndex(0); + await verifyKeysNotDisplayedInTheList([logicalDbKey]); + + // Go to Analysis Tools page and create new report + await t.click(myRedisDatabasePage.analysisPageButton); + await t.click(memoryEfficiencyPage.newReportBtn); + + // Verify that data changed for indexed db on Database analysis page + await t.expect(memoryEfficiencyPage.topKeysKeyName.withExactText(keyNames[0]).exists).ok('Keys from current db index not displayed in report'); + await t.expect(memoryEfficiencyPage.topKeysKeyName.withExactText(logicalDbKey).exists).notOk('Keys from other db index displayed in report'); + // Change index to logical db + await databaseOverviewPage.changeDbIndex(1); + await t.click(memoryEfficiencyPage.newReportBtn); + await t.expect(memoryEfficiencyPage.topKeysKeyName.withExactText(logicalDbKey).exists).ok('Keys from current db index not displayed in report'); + await t.expect(memoryEfficiencyPage.topKeysKeyName.withExactText(keyNames[0]).exists).notOk('Keys from other db index displayed in report'); +}); \ No newline at end of file diff --git a/tests/e2e/tests/critical-path/browser/database-overview.e2e.ts b/tests/e2e/tests/critical-path/database-overview/database-overview.e2e.ts similarity index 100% rename from tests/e2e/tests/critical-path/browser/database-overview.e2e.ts rename to tests/e2e/tests/critical-path/database-overview/database-overview.e2e.ts diff --git a/tests/e2e/tests/critical-path/database/logical-databases.e2e.ts b/tests/e2e/tests/critical-path/database/logical-databases.e2e.ts index 2741654895..b3b536cd48 100644 --- a/tests/e2e/tests/critical-path/database/logical-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/logical-databases.e2e.ts @@ -24,9 +24,9 @@ test('Verify that user can add DB with logical index via host and port from Add // Enter logical index await t.click(addRedisDatabasePage.databaseIndexCheckbox); await t.typeText(addRedisDatabasePage.databaseIndexInput, index, { replace: true, paste: true }); - // Verify that user when users select DB index they can see info message how to work with DB index in add DB screen - await t.expect(addRedisDatabasePage.databaseIndexMessage.exists).ok('Index message not displayed') - .expect(addRedisDatabasePage.databaseIndexMessage.innerText).eql(indexDbMessage) + // *** - outdated - Verify that user when users select DB index they can see info message how to work with DB index in add DB screen + // Verify that logical db message not displayed in add database form + await t.expect(addRedisDatabasePage.databaseIndexMessage.exists).notOk('Index message is still displayed') .expect(addRedisDatabasePage.databaseIndexCheckbox.parent().withExactText('Select Logical Database').exists).ok('Checkbox text not displayed'); // Click for saving await t.click(addRedisDatabasePage.addRedisDatabaseButton); diff --git a/tests/e2e/tests/critical-path/memory-efficiency/memory-efficiency.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/memory-efficiency.e2e.ts index 0c8366c96e..19f60ae93d 100644 --- a/tests/e2e/tests/critical-path/memory-efficiency/memory-efficiency.e2e.ts +++ b/tests/e2e/tests/critical-path/memory-efficiency/memory-efficiency.e2e.ts @@ -5,6 +5,7 @@ import { acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; import { Common } from '../../../helpers/common'; +import { verifySearchFilterValue } from '../../../helpers/keys'; const memoryEfficiencyPage = new MemoryEfficiencyPage(); const myRedisDatabasePage = new MyRedisDatabasePage(); @@ -95,7 +96,7 @@ test // Verify filter by data type applied await t.expect(browserPage.filteringLabel.textContent).eql('Stream', 'Key type lable is not displayed in search input'); // Verify keyname in search input prepopulated - await t.expect(browserPage.filterByPatterSearchInput.withAttribute('value', keySpaces[0]).exists).ok('Filter per key name is not applied'); + await verifySearchFilterValue(keySpaces[0]); // Verify key is displayed await t.click(browserPage.browserViewButton); await t.expect(await browserPage.isKeyIsDisplayedInTheList(streamKeyName)).ok('Key is not found'); diff --git a/tests/e2e/tests/critical-path/tree-view/tree-view.e2e.ts b/tests/e2e/tests/critical-path/tree-view/tree-view.e2e.ts index 55caca0e61..6ed6864229 100644 --- a/tests/e2e/tests/critical-path/tree-view/tree-view.e2e.ts +++ b/tests/e2e/tests/critical-path/tree-view/tree-view.e2e.ts @@ -7,6 +7,7 @@ import { import { rte, KeyTypesTexts } from '../../../helpers/constants'; import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; import { Common } from '../../../helpers/common'; +import { verifySearchFilterValue } from '../../../helpers/keys'; const browserPage = new BrowserPage(); const common = new Common(); @@ -62,7 +63,7 @@ test('Verify that when user switched from Tree View to Browser and goes back sta await t.click(browserPage.browserViewButton); await t.click(browserPage.treeViewButton); // Verify that state of filer by key name is saved - await t.expect(browserPage.filterByPatterSearchInput.withAttribute('value', keyName).exists).ok('Filter per key name is not applied'); + await verifySearchFilterValue(keyName); await t.click(browserPage.treeViewButton); // Set filter by key type await browserPage.selectFilterGroupType(KeyTypesTexts.String); diff --git a/tests/e2e/tests/regression/browser/context.e2e.ts b/tests/e2e/tests/regression/browser/context.e2e.ts index 537022163a..26a7a0ceb4 100644 --- a/tests/e2e/tests/regression/browser/context.e2e.ts +++ b/tests/e2e/tests/regression/browser/context.e2e.ts @@ -8,6 +8,7 @@ import { rte } from '../../../helpers/constants'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; import { Common } from '../../../helpers/common'; +import { verifySearchFilterValue } from '../../../helpers/keys'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); @@ -42,7 +43,7 @@ test('Verify that if user has saved context on Browser page and go to Settings p await t.expect(myRedisDatabasePage.workbenchButton.visible).ok('Workbench icon is not displayed'); // Open Browser page and verify context await t.click(myRedisDatabasePage.browserButton); - await t.expect(browserPage.filterByPatterSearchInput.withAttribute('value', keyName).exists).ok('Filter per key name is not applied'); + await verifySearchFilterValue(keyName); await t.expect(browserPage.keyNameFormDetails.withExactText(keyName).exists).ok('The key details is not selected'); await t.expect(cliPage.cliCommandExecuted.withExactText(command).exists).ok(`Executed command '${command}' in CLI is not saved`); await t.click(cliPage.cliCollapseButton); @@ -55,7 +56,7 @@ test('Verify that when user reload the window with saved context(on any page), c await t.click(myRedisDatabasePage.workbenchButton); // Open Browser page and verify context await t.click(myRedisDatabasePage.browserButton); - await t.expect(browserPage.filterByPatterSearchInput.withAttribute('value', keyName).exists).ok('Filter per key name is not applied'); + await verifySearchFilterValue(keyName); await t.expect(browserPage.keyNameFormDetails.withExactText(keyName).exists).ok('The key details is not selected'); // Navigate to Workbench and reload the window await t.click(myRedisDatabasePage.workbenchButton); diff --git a/tests/e2e/tests/regression/browser/resize-columns.e2e.ts b/tests/e2e/tests/regression/browser/resize-columns.e2e.ts index 70302f8a3f..65c71b3bbb 100644 --- a/tests/e2e/tests/regression/browser/resize-columns.e2e.ts +++ b/tests/e2e/tests/regression/browser/resize-columns.e2e.ts @@ -1,7 +1,8 @@ import { acceptLicenseTerms } from '../../../helpers/database'; import { MyRedisDatabasePage, - BrowserPage + BrowserPage, + DatabaseOverviewPage } from '../../../pageObjects'; import { rte } from '../../../helpers/constants'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; @@ -11,6 +12,7 @@ import { Common } from '../../../helpers/common'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); const common = new Common(); +const databaseOverviewPage = new DatabaseOverviewPage(); const keyName = common.generateWord(10); const longFieldName = common.generateSentence(20); @@ -60,6 +62,7 @@ fixture `Resize columns in Key details` }) .afterEach(async() => { // Clear and delete database + await databaseOverviewPage.changeDbIndex(0); await browserPage.deleteKeysByNames(keyNames); await deleteStandaloneDatabasesApi(databasesForAdding); }); @@ -93,4 +96,9 @@ test('Resize of columns in Hash, List, Zset Key details', async t => { await browserPage.openKeyDetails(key.name); await t.expect(field.clientWidth).eql(key.fieldWidthEnd, `Resize context not saved for ${key.type} key when switching between databases`); } + + // Verify that logical db not changed after switching between databases + await databaseOverviewPage.changeDbIndex(1); + await myRedisDatabasePage.clickOnDBByName(databasesForAdding[0].databaseName); + await databaseOverviewPage.verifyDbIndexSelected(1); }); diff --git a/tests/e2e/tests/regression/browser/database-info.e2e.ts b/tests/e2e/tests/regression/database-overview/database-info.e2e.ts similarity index 100% rename from tests/e2e/tests/regression/browser/database-info.e2e.ts rename to tests/e2e/tests/regression/database-overview/database-info.e2e.ts diff --git a/tests/e2e/tests/regression/browser/database-overview-keys.e2e.ts b/tests/e2e/tests/regression/database-overview/database-overview-keys.e2e.ts similarity index 100% rename from tests/e2e/tests/regression/browser/database-overview-keys.e2e.ts rename to tests/e2e/tests/regression/database-overview/database-overview-keys.e2e.ts diff --git a/tests/e2e/tests/regression/browser/database-overview.e2e.ts b/tests/e2e/tests/regression/database-overview/database-overview.e2e.ts similarity index 100% rename from tests/e2e/tests/regression/browser/database-overview.e2e.ts rename to tests/e2e/tests/regression/database-overview/database-overview.e2e.ts diff --git a/tests/e2e/tests/regression/database/overview.e2e.ts b/tests/e2e/tests/regression/database-overview/overview.e2e.ts similarity index 100% rename from tests/e2e/tests/regression/database/overview.e2e.ts rename to tests/e2e/tests/regression/database-overview/overview.e2e.ts From 0a48c522243da7bc3bbb92c04764d18c4065c5ec Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Wed, 4 Jan 2023 11:18:59 +0300 Subject: [PATCH 091/201] #RI-3986 - fix loading data for redisearch --- .../components/browser-left-panel/BrowserLeftPanel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx b/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx index ed413b75cd..c5bff96108 100644 --- a/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx +++ b/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx @@ -66,9 +66,9 @@ const BrowserLeftPanel = (props: Props) => { const dispatch = useDispatch() const isDataLoaded = searchMode === SearchMode.Pattern ? isDataPatternLoaded : isDataRedisearchLoaded - const keysState = !isDataLoaded - ? initialKeyStateData - : (searchMode === SearchMode.Pattern ? patternKeysState : redisearchKeysState) + const keysState = searchMode === SearchMode.Pattern + ? (!isDataLoaded ? initialKeyStateData : patternKeysState) + : redisearchKeysState const loading = searchMode === SearchMode.Pattern ? patternLoading : redisearchLoading || redisearchListLoading const isSearched = searchMode === SearchMode.Pattern ? patternIsSearched : redisearchIsSearched const scrollTopPosition = searchMode === SearchMode.Pattern ? scrollPatternTopPosition : scrollRedisearchTopPosition From 3e40c2fea2cdd93d48fb0ae01e0880b8cc280023 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Wed, 4 Jan 2023 11:20:26 +0300 Subject: [PATCH 092/201] #RI-3986 - change condition --- .../browser/components/browser-left-panel/BrowserLeftPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx b/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx index c5bff96108..0aeb0ad3d1 100644 --- a/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx +++ b/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx @@ -67,7 +67,7 @@ const BrowserLeftPanel = (props: Props) => { const isDataLoaded = searchMode === SearchMode.Pattern ? isDataPatternLoaded : isDataRedisearchLoaded const keysState = searchMode === SearchMode.Pattern - ? (!isDataLoaded ? initialKeyStateData : patternKeysState) + ? (isDataLoaded ? patternKeysState : initialKeyStateData) : redisearchKeysState const loading = searchMode === SearchMode.Pattern ? patternLoading : redisearchLoading || redisearchListLoading const isSearched = searchMode === SearchMode.Pattern ? patternIsSearched : redisearchIsSearched From 71728a292343b88ffea17894808a006deb8a8471 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Wed, 4 Jan 2023 15:11:45 +0300 Subject: [PATCH 093/201] #RI-3986 - fix refresh keys after change index #RI-3985 - remove selected key after changing index --- .../instance-header/InstanceHeader.tsx | 17 +++++++++++++---- .../ui/src/pages/browser/BrowserPage.tsx | 19 ++++++++++++++++++- .../browser-left-panel/BrowserLeftPanel.tsx | 18 +++--------------- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx b/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx index 90a3eb15ef..60355423b3 100644 --- a/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx +++ b/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx @@ -25,14 +25,19 @@ import { appInfoSelector } from 'uiSrc/slices/app/info' import ShortInstanceInfo from 'uiSrc/components/instance-header/components/ShortInstanceInfo' import DatabaseOverviewWrapper from 'uiSrc/components/database-overview/DatabaseOverviewWrapper' -import { appContextDbIndex, clearBrowserKeyListData } from 'uiSrc/slices/app/context' +import { appContextDbIndex, clearBrowserKeyListData, setBrowserSelectedKey } from 'uiSrc/slices/app/context' import InlineItemEditor from 'uiSrc/components/inline-item-editor' import { selectOnFocus, validateNumber } from 'uiSrc/utils' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { resetKeyInfo } from 'uiSrc/slices/browser/keys' import styles from './styles.module.scss' -const InstanceHeader = () => { +export interface Props { + onChangeDbIndex?: (index: number) => void +} + +const InstanceHeader = ({ onChangeDbIndex }: Props) => { const { name = '', host = '', @@ -72,7 +77,7 @@ const InstanceHeader = () => { history.push(Pages.home) } - const onChangeDbIndex = () => { + const handleChangeDbIndex = () => { setIsDbIndexEditing(false) if (db === +dbIndex) return @@ -82,6 +87,10 @@ const InstanceHeader = () => { +dbIndex, () => { dispatch(clearBrowserKeyListData()) + onChangeDbIndex?.(+dbIndex) + dispatch(resetKeyInfo()) + dispatch(setBrowserSelectedKey(null)) + sendEventTelemetry({ event: TelemetryEvent.BROWSER_DATABASE_INDEX_CHANGED, eventData: { @@ -129,7 +138,7 @@ const InstanceHeader = () => {
setIsDbIndexEditing(false)} viewChildrenMode={false} controlsClassName={styles.controls} diff --git a/redisinsight/ui/src/pages/browser/BrowserPage.tsx b/redisinsight/ui/src/pages/browser/BrowserPage.tsx index e888ebe054..fca14a2560 100644 --- a/redisinsight/ui/src/pages/browser/BrowserPage.tsx +++ b/redisinsight/ui/src/pages/browser/BrowserPage.tsx @@ -16,6 +16,7 @@ import { TelemetryPageView, } from 'uiSrc/telemetry' import { + fetchKeys, keysSelector, resetKeyInfo, selectedKeyDataSelector, @@ -35,6 +36,8 @@ import InstanceHeader from 'uiSrc/components/instance-header' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' import BrowserLeftPanel from './components/browser-left-panel' import BrowserRightPanel from './components/browser-right-panel' @@ -56,6 +59,7 @@ const BrowserPage = () => { } = useSelector(appContextBrowser) const { isBrowserFullScreen } = useSelector(keysSelector) const { type } = useSelector(selectedKeyDataSelector) ?? { type: '', length: 0 } + const { viewType, searchMode } = useSelector(keysSelector) const [isPageViewSent, setIsPageViewSent] = useState(false) const [arePanelsCollapsed, setArePanelsCollapsed] = useState(false) @@ -158,6 +162,19 @@ const BrowserPage = () => { setIsCreateIndexPanelOpen(false) }, []) + const onChangeDbIndex = () => { + if (selectedKey) { + dispatch(toggleBrowserFullScreen(true)) + setSelectedKey(null) + } + + dispatch(fetchKeys( + searchMode, + '0', + viewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT + )) + } + const selectKey = ({ rowData }: { rowData: any }) => { if (!isEqualBuffers(rowData.name, selectedKey)) { dispatch(toggleBrowserFullScreen(false)) @@ -173,7 +190,7 @@ const BrowserPage = () => { return (
- +
diff --git a/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx b/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx index 0aeb0ad3d1..3a81cbfc9a 100644 --- a/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx +++ b/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx @@ -13,7 +13,7 @@ import { keysDataSelector, keysSelector, } from 'uiSrc/slices/browser/keys' -import { KeysStoreData, KeyViewType, SearchMode } from 'uiSrc/slices/interfaces/keys' +import { KeyViewType, SearchMode } from 'uiSrc/slices/interfaces/keys' import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' import { setConnectedInstanceId } from 'uiSrc/slices/instances/instances' import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' @@ -32,16 +32,6 @@ export interface Props { handleCreateIndexPanel: (value: boolean) => void } -const initialKeyStateData: KeysStoreData = { - total: 0, - scanned: 0, - nextCursor: '0', - keys: [], - shardsMeta: {}, - previousResultCount: 0, - lastRefreshTime: null, -} - const BrowserLeftPanel = (props: Props) => { const { selectKey, @@ -66,9 +56,7 @@ const BrowserLeftPanel = (props: Props) => { const dispatch = useDispatch() const isDataLoaded = searchMode === SearchMode.Pattern ? isDataPatternLoaded : isDataRedisearchLoaded - const keysState = searchMode === SearchMode.Pattern - ? (isDataLoaded ? patternKeysState : initialKeyStateData) - : redisearchKeysState + const keysState = searchMode === SearchMode.Pattern ? patternKeysState : redisearchKeysState const loading = searchMode === SearchMode.Pattern ? patternLoading : redisearchLoading || redisearchListLoading const isSearched = searchMode === SearchMode.Pattern ? patternIsSearched : redisearchIsSearched const scrollTopPosition = searchMode === SearchMode.Pattern ? scrollPatternTopPosition : scrollRedisearchTopPosition @@ -77,7 +65,7 @@ const BrowserLeftPanel = (props: Props) => { if ((!isDataLoaded || contextInstanceId !== instanceId) && searchMode === SearchMode.Pattern) { loadKeys(viewType) } - }, [searchMode, isDataLoaded]) + }, [searchMode]) const loadKeys = useCallback((keyViewType: KeyViewType = KeyViewType.Browser) => { dispatch(setConnectedInstanceId(instanceId)) From f25a3ec2c48bbc5522b7579d2e1e4d1c4ccbe36e Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 4 Jan 2023 13:48:06 +0100 Subject: [PATCH 094/201] updates for failed tests --- tests/e2e/helpers/api/api-database.ts | 2 +- tests/e2e/helpers/keys.ts | 2 +- .../browser/search-capabilities.e2e.ts | 2 +- .../database-overview/database-index.e2e.ts | 20 +++++++++++-------- .../regression/browser/resize-columns.e2e.ts | 3 +++ 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/tests/e2e/helpers/api/api-database.ts b/tests/e2e/helpers/api/api-database.ts index 5288ef54af..80dc37f748 100644 --- a/tests/e2e/helpers/api/api-database.ts +++ b/tests/e2e/helpers/api/api-database.ts @@ -73,7 +73,7 @@ export async function discoverSentinelDatabaseApi(databaseParameters: SentinelPa }) .set('Accept', 'application/json'); - await t.expect(response.status).eql(201, 'Autodiscovery of Sentinel database request failed'); + await t.expect(response.status).eql(200, 'Autodiscovery of Sentinel database request failed'); } /** diff --git a/tests/e2e/helpers/keys.ts b/tests/e2e/helpers/keys.ts index 003fd0ecbd..65f9d94563 100644 --- a/tests/e2e/helpers/keys.ts +++ b/tests/e2e/helpers/keys.ts @@ -242,5 +242,5 @@ export async function verifyKeysNotDisplayedInTheList(keyNames: string[]): Promi */ export async function verifySearchFilterValue(value: string): Promise { - await t.expect(browserPage.filterByPatterSearchInput.withAttribute('value', value).exists).ok('Filter per key name is not applied/correct'); + await t.expect(browserPage.filterByPatterSearchInput.withAttribute('value', value).exists).ok(`Filter per key name ${value} is not applied/correct`); } diff --git a/tests/e2e/tests/critical-path/browser/search-capabilities.e2e.ts b/tests/e2e/tests/critical-path/browser/search-capabilities.e2e.ts index c250444527..c445b9a4be 100644 --- a/tests/e2e/tests/critical-path/browser/search-capabilities.e2e.ts +++ b/tests/e2e/tests/critical-path/browser/search-capabilities.e2e.ts @@ -43,7 +43,7 @@ async function verifyContext(): Promise { .expect(browserPage.keyNameFormDetails.withExactText(keyName).exists).ok('Key details not opened'); } -fixture`Search capabilities in Browser` +fixture `Search capabilities in Browser` .meta({ type: 'critical_path', rte: rte.standalone }) .page(commonUrl); test diff --git a/tests/e2e/tests/critical-path/database-overview/database-index.e2e.ts b/tests/e2e/tests/critical-path/database-overview/database-index.e2e.ts index e397b639a2..1b99635ad1 100644 --- a/tests/e2e/tests/critical-path/database-overview/database-index.e2e.ts +++ b/tests/e2e/tests/critical-path/database-overview/database-index.e2e.ts @@ -24,7 +24,7 @@ const common = new Common(); const workbenchPage = new WorkbenchPage(); const memoryEfficiencyPage = new MemoryEfficiencyPage(); -let keyName = common.generateWord(10); +const keyName = common.generateWord(10); const indexName = `idx:${keyName}`; const keyNames = [`${keyName}:1`, `${keyName}:2`]; const commands = [ @@ -38,12 +38,12 @@ const logicalDbKey = `${keyName}:3`; fixture `Allow to change database index` .meta({ type: 'critical_path', rte: rte.standalone }) .page(commonUrl) - .beforeEach(async t => { + .beforeEach(async() => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); // Create 3 keys and index await cliPage.sendCommandsInCli(commands); }) - .afterEach(async () => { + .afterEach(async() => { // Delete keys in logical database await databaseOverviewPage.changeDbIndex(1); await cliPage.sendCommandsInCli([`DEL ${keyNameForSearchInLogicalDb}`, `DEL ${logicalDbKey}`]); @@ -82,7 +82,7 @@ test('Switching between indexed databases', async t => { await browserPage.searchByKeyName(keyNameForSearchInLogicalDb); // Return to default database await databaseOverviewPage.changeDbIndex(0); - + // Verify that search/filter saved after switching index in Browser await verifySearchFilterValue(keyNameForSearchInLogicalDb); await verifyKeysNotDisplayedInTheList([keyNameForSearchInLogicalDb]); @@ -110,16 +110,20 @@ test('Switching between indexed databases', async t => { await databaseOverviewPage.changeDbIndex(1); // Verify that search/filter saved after switching index in Search capability // await t.expect(browserPage.keyListTable.textContent).contains('No results found.', 'Data not changed for indexed db'); - await verifySearchFilterValue(keyNameForSearchInLogicalDb); + await verifySearchFilterValue('Hall School'); // Open Workbench page await t.click(myRedisDatabasePage.workbenchButton); await workbenchPage.sendCommandInWorkbench(command); // Open Browser page await t.click(myRedisDatabasePage.browserButton); - // check after fix TBD - // await verifyKeysDisplayedInTheList([logicalDbKey]); + // Clear filter + await t.click(browserPage.clearFilterButton); + // Verify that data changed for indexed db on Workbench page (on Search capability page) + await verifyKeysDisplayedInTheList([logicalDbKey]); await t.click(browserPage.patternModeBtn); + // Clear filter + await t.click(browserPage.clearFilterButton); // Verify that data changed for indexed db on Workbench page await verifyKeysDisplayedInTheList([keyNameForSearchInLogicalDb, logicalDbKey]); await databaseOverviewPage.changeDbIndex(0); @@ -137,4 +141,4 @@ test('Switching between indexed databases', async t => { await t.click(memoryEfficiencyPage.newReportBtn); await t.expect(memoryEfficiencyPage.topKeysKeyName.withExactText(logicalDbKey).exists).ok('Keys from current db index not displayed in report'); await t.expect(memoryEfficiencyPage.topKeysKeyName.withExactText(keyNames[0]).exists).notOk('Keys from other db index displayed in report'); -}); \ No newline at end of file +}); diff --git a/tests/e2e/tests/regression/browser/resize-columns.e2e.ts b/tests/e2e/tests/regression/browser/resize-columns.e2e.ts index 65c71b3bbb..d4dcf69d9a 100644 --- a/tests/e2e/tests/regression/browser/resize-columns.e2e.ts +++ b/tests/e2e/tests/regression/browser/resize-columns.e2e.ts @@ -99,6 +99,9 @@ test('Resize of columns in Hash, List, Zset Key details', async t => { // Verify that logical db not changed after switching between databases await databaseOverviewPage.changeDbIndex(1); + await t.click(myRedisDatabasePage.myRedisDBButton); await myRedisDatabasePage.clickOnDBByName(databasesForAdding[0].databaseName); + await t.click(myRedisDatabasePage.myRedisDBButton); + await myRedisDatabasePage.clickOnDBByName(databasesForAdding[1].databaseName); await databaseOverviewPage.verifyDbIndexSelected(1); }); From 97775613796ed4a72dea22b21d9670186ddb0c71 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 4 Jan 2023 17:27:53 +0400 Subject: [PATCH 095/201] #RI-3941 - add redisstack link --- .../constants/dbAnalysisRecommendations.json | 24 +++++++++++++++++++ .../Recommendations.spec.tsx | 14 +++++++++++ .../recommendations-view/Recommendations.tsx | 19 +++++++++++---- .../recommendations-view/styles.module.scss | 7 ++++-- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 1d419d20de..0ae71775d3 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -282,6 +282,7 @@ "bigSets": { "id": "bigSets", "title": "Switch to Bloom filter, cuckoo filter, or HyperLogLog", + "redisStack": true, "content": [ { "id": "1", @@ -409,6 +410,29 @@ "href": "https://docs.redis.com/latest/ri/memory-optimizations/", "name": "Read more" } + }, + { + "id": "11", + "type": "spacer", + "value": "l" + }, + { + "id": "12", + "type": "span", + "value": "Create a " + }, + { + "id": "13", + "type": "link", + "value": { + "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight_recommendations/", + "name": "free Redis Stack database" + } + }, + { + "id": "14", + "type": "span", + "value": " to use modern data models and processing engines." } ], "badges": ["configuration_changes"] diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index f386fd446e..3081259c54 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -330,4 +330,18 @@ describe('Recommendations', () => { expect(screen.queryByTestId('badges-legend')).toBeInTheDocument() }) + + it('should render redisstack link', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'bigSets' }] + } + })) + + render() + + expect(screen.queryByTestId('redis-stack-link')).toBeInTheDocument() + expect(screen.queryByTestId('redis-stack-link')).toHaveAttribute('href', 'https://redis.io/docs/stack/') + }) }) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index d675772682..c8beccd173 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -9,6 +9,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiLink, } from '@elastic/eui' import { ThemeContext } from 'uiSrc/contexts/themeContext' import { dbAnalysisSelector } from 'uiSrc/slices/analytics/dbAnalysis' @@ -46,11 +47,19 @@ const Recommendations = () => { {redisStack && ( - + + + )} diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index 4766c72e5c..6977235530 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -117,9 +117,12 @@ display: inline; } - .redisStack { + .redisStackLink { + margin-right: 16px; + } + + .redisStackIcon { width: 20px; height: 20px; - margin-right: 16px; } } From d0f2f050f2eabfe573b677df39e3b97169c38be1 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 4 Jan 2023 15:19:26 +0100 Subject: [PATCH 096/201] fix --- tests/e2e/helpers/api/api-database.ts | 2 +- tests/e2e/helpers/conf.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/helpers/api/api-database.ts b/tests/e2e/helpers/api/api-database.ts index 80dc37f748..5288ef54af 100644 --- a/tests/e2e/helpers/api/api-database.ts +++ b/tests/e2e/helpers/api/api-database.ts @@ -73,7 +73,7 @@ export async function discoverSentinelDatabaseApi(databaseParameters: SentinelPa }) .set('Accept', 'application/json'); - await t.expect(response.status).eql(200, 'Autodiscovery of Sentinel database request failed'); + await t.expect(response.status).eql(201, 'Autodiscovery of Sentinel database request failed'); } /** diff --git a/tests/e2e/helpers/conf.ts b/tests/e2e/helpers/conf.ts index 7aabf767d2..f7a1d82689 100644 --- a/tests/e2e/helpers/conf.ts +++ b/tests/e2e/helpers/conf.ts @@ -45,13 +45,13 @@ export const ossSentinelConfig = { alias: 'primary-group-1', db: '0', name: 'primary-group-1', - password: 'defaultpass' + password: 'password' }, { alias: 'primary-group-2', db: '0', name: 'primary-group-2', - password: 'defaultpass' + password: 'password' }], name: ['primary-group-1', 'primary-group-2'] }; From cebc03744fbaf6fbf489198438c02e33c038dfd2 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 4 Jan 2023 18:32:51 +0200 Subject: [PATCH 097/201] #RI-3986 possible fix to deal with concurrent requests (graceful shutdown) --- redisinsight/api/src/modules/redis/redis.service.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/redis/redis.service.ts b/redisinsight/api/src/modules/redis/redis.service.ts index af70810fdf..0d75b9bcf7 100644 --- a/redisinsight/api/src/modules/redis/redis.service.ts +++ b/redisinsight/api/src/modules/redis/redis.service.ts @@ -76,7 +76,14 @@ export class RedisService implements OnModuleDestroy { }; if (found) { - found.client.disconnect(); + // workaround for concurrent requests. + // At first try to gracefully close the connection using quit + try { + found.client.quit(); + } catch (e) { + found.client.disconnect(); + } + this.clients.delete(id); this.clients.set(id, clientInstance); return 0; // todo: investigate why we need to distinguish between 1 | 0 From 5c8a099304520b0eee5631bd72f3ac5c7e24b59c Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 4 Jan 2023 20:18:36 +0200 Subject: [PATCH 098/201] #RI-3986 possible fix to deal with concurrent requests (reuse existing) --- redisinsight/api/src/__mocks__/redis.ts | 4 +++- .../database/database-connection.service.ts | 4 +--- .../redis-consumer.abstract.service.spec.ts | 2 ++ .../redis/redis-consumer.abstract.service.ts | 5 ++-- .../src/modules/redis/redis.service.spec.ts | 23 +++++++++++-------- .../api/src/modules/redis/redis.service.ts | 18 ++++++--------- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/redisinsight/api/src/__mocks__/redis.ts b/redisinsight/api/src/__mocks__/redis.ts index c1cef9dc9a..0a74898f37 100644 --- a/redisinsight/api/src/__mocks__/redis.ts +++ b/redisinsight/api/src/__mocks__/redis.ts @@ -68,7 +68,9 @@ export const mockRedisService = jest.fn(() => ({ getClientInstance: jest.fn().mockResolvedValue({ client: mockIORedisClient, }), - setClientInstance: jest.fn(), + setClientInstance: jest.fn().mockReturnValue({ + client: mockIORedisClient, + }), isClientConnected: jest.fn().mockReturnValue(true), removeClientInstance: jest.fn(), removeClientInstances: jest.fn(), diff --git a/redisinsight/api/src/modules/database/database-connection.service.ts b/redisinsight/api/src/modules/database/database-connection.service.ts index 7feb9e60c9..796e332542 100644 --- a/redisinsight/api/src/modules/database/database-connection.service.ts +++ b/redisinsight/api/src/modules/database/database-connection.service.ts @@ -77,9 +77,7 @@ export class DatabaseConnectionService { client = await this.createClient(clientMetadata); - this.redisService.setClientInstance(clientMetadata, client); - - return client; + return this.redisService.setClientInstance(clientMetadata, client)?.client; } /** diff --git a/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.spec.ts b/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.spec.ts index f4f4b95dc3..a561a03a76 100644 --- a/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.spec.ts +++ b/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.spec.ts @@ -54,6 +54,8 @@ describe('RedisConsumerAbstractService', () => { redisService = await module.get(RedisService); consumerInstance = await module.get(BrowserToolService); redisConnectionFactory = await module.get(RedisConnectionFactory); + + redisService.setClientInstance.mockReturnValue(mockRedisClientInstance); }); describe('getRedisClient', () => { diff --git a/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.ts b/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.ts index 9078329106..5ec78ef705 100644 --- a/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.ts +++ b/redisinsight/api/src/modules/redis/redis-consumer.abstract.service.ts @@ -143,14 +143,13 @@ export abstract class RedisConsumerAbstractService implements IRedisConsumer { instanceDto, connectionName, ); - this.redisService.setClientInstance( + return this.redisService.setClientInstance( { ...clientMetadata, context: clientMetadata.context || this.consumer, }, client, - ); - return client; + )?.client; } catch (error) { throw catchRedisConnectionError(error, instanceDto); } diff --git a/redisinsight/api/src/modules/redis/redis.service.spec.ts b/redisinsight/api/src/modules/redis/redis.service.spec.ts index bd728d96e4..667da35a9c 100644 --- a/redisinsight/api/src/modules/redis/redis.service.spec.ts +++ b/redisinsight/api/src/modules/redis/redis.service.spec.ts @@ -121,29 +121,32 @@ describe('RedisService', () => { const result = service.setClientInstance(mockRedisClientInstance1.clientMetadata, mockIORedisClient); - expect(result).toEqual(1); + expect(result.client).toEqual(mockIORedisClient); expect(service.clients.size).toEqual(1); }); - it('should replace existing client and update last used time', async () => { + it('should use existing client instead of replacing with new one', async () => { expect(service.clients.size).toEqual(0); - expect(service.setClientInstance(mockRedisClientInstance1.clientMetadata, mockIORedisClient)).toEqual(1); + expect(service.setClientInstance(mockRedisClientInstance1.clientMetadata, mockIORedisClient).client) + .toEqual(mockIORedisClient); expect(service.clients.size).toEqual(1); - const [justAddedClient] = [...service.clients.values()]; + let [justAddedClient] = [...service.clients.values()]; + justAddedClient = { ...justAddedClient }; // sleep await new Promise((res) => setTimeout(res, 100)); - expect(service.setClientInstance(mockRedisClientInstance1.clientMetadata, mockIORedisSentinel)).toEqual(0); + expect(service.setClientInstance(mockRedisClientInstance1.clientMetadata, mockIORedisSentinel).client) + .toEqual(mockIORedisClient); expect(service.clients.size).toEqual(1); - const [overwrittenClient] = [...service.clients.values()]; - expect(overwrittenClient.clientMetadata).toEqual(justAddedClient.clientMetadata); - expect(overwrittenClient.id).toEqual(justAddedClient.id); - expect(overwrittenClient.client).not.toEqual(justAddedClient.client); - expect(overwrittenClient.lastTimeUsed).toBeGreaterThan(justAddedClient.lastTimeUsed); + const [newClient] = [...service.clients.values()]; + expect(newClient.clientMetadata).toEqual(justAddedClient.clientMetadata); + expect(newClient.id).toEqual(justAddedClient.id); + expect(newClient.client).toEqual(justAddedClient.client); + expect(newClient.lastTimeUsed).toBeGreaterThan(justAddedClient.lastTimeUsed); }); }); diff --git a/redisinsight/api/src/modules/redis/redis.service.ts b/redisinsight/api/src/modules/redis/redis.service.ts index 0d75b9bcf7..dbd16219ea 100644 --- a/redisinsight/api/src/modules/redis/redis.service.ts +++ b/redisinsight/api/src/modules/redis/redis.service.ts @@ -62,7 +62,7 @@ export class RedisService implements OnModuleDestroy { return found; } - public setClientInstance(clientMetadata: ClientMetadata, client): 0 | 1 { + public setClientInstance(clientMetadata: ClientMetadata, client): IRedisClientInstance { const metadata = RedisService.prepareClientMetadata(clientMetadata); const id = RedisService.generateId(metadata); @@ -76,22 +76,18 @@ export class RedisService implements OnModuleDestroy { }; if (found) { - // workaround for concurrent requests. - // At first try to gracefully close the connection using quit - try { - found.client.quit(); - } catch (e) { - found.client.disconnect(); + if (this.isClientConnected(found.client)) { + found.lastTimeUsed = Date.now(); + client.disconnect(); + return found; } - this.clients.delete(id); - this.clients.set(id, clientInstance); - return 0; // todo: investigate why we need to distinguish between 1 | 0 + found.client.disconnect(); } this.clients.set(id, clientInstance); - return 1; + return clientInstance; } public removeClientInstance(clientMetadata: ClientMetadata): number { From 1a9d9e2c781a492cbec12c340227a9d4c308bd11 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 4 Jan 2023 21:24:05 +0200 Subject: [PATCH 099/201] #RI-3988 fix sentinel connection issue --- .../api/src/modules/redis/redis-connection.factory.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.ts index 69ad051ba6..cd2240927a 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.ts @@ -101,7 +101,9 @@ export class RedisConnectionFactory { const baseOptions = await this.getRedisOptions(clientMetadata, database, options); return { ...baseOptions, - sentinels: database.nodes, + host: undefined, + port: undefined, + sentinels: database.nodes?.length ? database.nodes : [{ host: database.host, port: database.port }], name: sentinelMaster?.name, sentinelUsername: database.username, sentinelPassword: database.password, From fded78dca1431cf6b50b803d633192d7a1d8daab Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Thu, 5 Jan 2023 09:53:05 +0300 Subject: [PATCH 100/201] #RI-2409 - cover disabled state for button by tests --- .../instance-header/InstanceHeader.spec.tsx | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx b/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx index e4a01d0890..f64c9e98dd 100644 --- a/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx +++ b/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx @@ -2,7 +2,12 @@ import { cloneDeep } from 'lodash' import React from 'react' import { instance, mock } from 'ts-mockito' import { cleanup, mockedStore, render, screen, fireEvent } from 'uiSrc/utils/test-utils' -import { checkDatabaseIndex, connectedInstanceInfoSelector } from 'uiSrc/slices/instances/instances' +import { + checkDatabaseIndex, + connectedInstanceInfoSelector, + connectedInstanceSelector +} from 'uiSrc/slices/instances/instances' +import { appContextDbIndex } from 'uiSrc/slices/app/context' import InstanceHeader, { Props } from './InstanceHeader' @@ -27,6 +32,18 @@ jest.mock('uiSrc/slices/instances/instances', () => ({ ...jest.requireActual('uiSrc/slices/instances/instances'), connectedInstanceInfoSelector: jest.fn().mockReturnValue({ databases: 16, + }), + connectedInstanceSelector: jest.fn().mockReturnValue({ + username: 'username', + id: 'instanceId', + loading: false, + }) +})) + +jest.mock('uiSrc/slices/app/context', () => ({ + ...jest.requireActual('uiSrc/slices/app/context'), + appContextDbIndex: jest.fn().mockReturnValue({ + disabled: false, }) })) @@ -77,4 +94,24 @@ describe('InstanceHeader', () => { ] expect(store.getActions()).toEqual([...expectedActions]) }) + + it('should be disabled db index button with loading state', () => { + (connectedInstanceSelector as jest.Mock).mockReturnValueOnce({ + loading: true, + }) + + render() + + expect(screen.getByTestId('change-index-btn')).toBeDisabled() + }) + + it('should be disabled db index button with disabled state', () => { + (appContextDbIndex as jest.Mock).mockReturnValueOnce({ + disabled: true, + }) + + render() + + expect(screen.getByTestId('change-index-btn')).toBeDisabled() + }) }) From a02461a34f05edc4e918b6682f9940c37a13ba52 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 5 Jan 2023 16:11:51 +0400 Subject: [PATCH 101/201] #RI-3941 - resolve comments --- .../constants/dbAnalysisRecommendations.json | 4 ++-- .../recommendations-view/Recommendations.tsx | 21 ++++++++++++++----- .../recommendations-view/styles.module.scss | 19 +++++++++-------- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 0ae71775d3..b55c1169fc 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -425,7 +425,7 @@ "id": "13", "type": "link", "value": { - "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight_recommendations/", + "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight/", "name": "free Redis Stack database" } }, @@ -516,7 +516,7 @@ "id": "4", "type": "link", "value": { - "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight_recommendations/", + "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight/", "name": "free Redis Stack database" } }, diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index c8beccd173..a790d4b30c 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -6,6 +6,7 @@ import { EuiAccordion, EuiPanel, EuiText, + EuiToolTip, EuiFlexGroup, EuiFlexItem, EuiIcon, @@ -39,6 +40,8 @@ const Recommendations = () => { } }) + const onRedisStackClick = (event: React.MouseEvent) => event.stopPropagation() + const sortedRecommendations = sortBy(recommendations, ({ name }) => (recommendationsContent[name]?.redisStack ? -1 : 0)) @@ -53,12 +56,20 @@ const Recommendations = () => { href="https://redis.io/docs/stack/" className={styles.redisStackLink} data-testid="redis-stack-link" + onClick={onRedisStackClick} > - + + + )} diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index 6977235530..e9eb391bbf 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -41,6 +41,16 @@ .accordionButton :global(.euiFlexItem) { margin: 0; + + .redisStackLink { + margin-right: 16px; + animation: none !important; + } + + .redisStackIcon { + width: 20px; + height: 20px; + } } :global(.euiAccordion__buttonReverse .euiAccordion__iconWrapper) { @@ -116,13 +126,4 @@ .span { display: inline; } - - .redisStackLink { - margin-right: 16px; - } - - .redisStackIcon { - width: 20px; - height: 20px; - } } From 9e0f0745663b61552a4dd68b91ccd5da47c23d5b Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 5 Jan 2023 16:35:27 +0400 Subject: [PATCH 102/201] #RI-3941 - resolve comments --- .../recommendations-view/Recommendations.spec.tsx | 4 ++-- .../recommendations-view/Recommendations.tsx | 13 ++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 3081259c54..7bb6bde41a 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -341,7 +341,7 @@ describe('Recommendations', () => { render() - expect(screen.queryByTestId('redis-stack-link')).toBeInTheDocument() - expect(screen.queryByTestId('redis-stack-link')).toHaveAttribute('href', 'https://redis.io/docs/stack/') + expect(screen.queryByTestId('bigSets-redis-stack-link')).toBeInTheDocument() + expect(screen.queryByTestId('bigSets-redis-stack-link')).toHaveAttribute('href', 'https://redis.io/docs/stack/') }) }) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index a790d4b30c..6b0b37d417 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -40,23 +40,22 @@ const Recommendations = () => { } }) - const onRedisStackClick = (event: React.MouseEvent) => event.stopPropagation() + const onRedisStackClick = (event: React.MouseEvent) => event.stopPropagation() const sortedRecommendations = sortBy(recommendations, ({ name }) => (recommendationsContent[name]?.redisStack ? -1 : 0)) - const renderButtonContent = (redisStack: boolean, title: string, badges: string[]) => ( + const renderButtonContent = (redisStack: boolean, title: string, badges: string[], id: string) => ( - + {redisStack && ( { @@ -117,7 +116,7 @@ const Recommendations = () => { Date: Thu, 5 Jan 2023 15:12:17 +0100 Subject: [PATCH 103/201] updates for tests --- tests/e2e/helpers/conf.ts | 4 ++-- .../database-overview/database-index.e2e.ts | 6 ++---- .../tests/regression/cli/cli-re-cluster.e2e.ts | 15 ++++++++++++++- .../database-overview/database-info.e2e.ts | 4 ++++ 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/e2e/helpers/conf.ts b/tests/e2e/helpers/conf.ts index f7a1d82689..7aabf767d2 100644 --- a/tests/e2e/helpers/conf.ts +++ b/tests/e2e/helpers/conf.ts @@ -45,13 +45,13 @@ export const ossSentinelConfig = { alias: 'primary-group-1', db: '0', name: 'primary-group-1', - password: 'password' + password: 'defaultpass' }, { alias: 'primary-group-2', db: '0', name: 'primary-group-2', - password: 'password' + password: 'defaultpass' }], name: ['primary-group-1', 'primary-group-2'] }; diff --git a/tests/e2e/tests/critical-path/database-overview/database-index.e2e.ts b/tests/e2e/tests/critical-path/database-overview/database-index.e2e.ts index 1b99635ad1..cd4187197f 100644 --- a/tests/e2e/tests/critical-path/database-overview/database-index.e2e.ts +++ b/tests/e2e/tests/critical-path/database-overview/database-index.e2e.ts @@ -57,6 +57,7 @@ test('Switching between indexed databases', async t => { const rememberedConnectedClients = await browserPage.overviewConnectedClients.textContent; // Change index to logical db + // Verify that database index switcher displayed for Standalone db await databaseOverviewPage.changeDbIndex(1); // Verify that the same client connections are used after changing index const logicalDbConnectedClients = await browserPage.overviewConnectedClients.textContent; @@ -99,17 +100,14 @@ test('Switching between indexed databases', async t => { await verifyKeysDisplayedInTheList(keyNames); // Change index to logical db await databaseOverviewPage.changeDbIndex(1); - // Verify that data changed for indexed db on Search capability page - // await t.expect(browserPage.keyListTable.textContent).contains('No results found.', 'Data not changed for indexed db'); - // Search by value and return to default database await browserPage.searchByKeyName('Hall School'); await databaseOverviewPage.changeDbIndex(0); + // Verify that data changed for indexed db on Search capability page await verifyKeysDisplayedInTheList([keyNames[0]]); // Change index to logical db await databaseOverviewPage.changeDbIndex(1); // Verify that search/filter saved after switching index in Search capability - // await t.expect(browserPage.keyListTable.textContent).contains('No results found.', 'Data not changed for indexed db'); await verifySearchFilterValue('Hall School'); // Open Workbench page diff --git a/tests/e2e/tests/regression/cli/cli-re-cluster.e2e.ts b/tests/e2e/tests/regression/cli/cli-re-cluster.e2e.ts index 45a64b1262..1fbfa03dd9 100644 --- a/tests/e2e/tests/regression/cli/cli-re-cluster.e2e.ts +++ b/tests/e2e/tests/regression/cli/cli-re-cluster.e2e.ts @@ -7,7 +7,7 @@ import { acceptLicenseTermsAndAddSentinelDatabaseApi, deleteDatabase } from '../../../helpers/database'; -import { BrowserPage, CliPage } from '../../../pageObjects'; +import { BrowserPage, CliPage, DatabaseOverviewPage } from '../../../pageObjects'; import { cloudDatabaseConfig, commonUrl, ossClusterConfig, @@ -20,6 +20,7 @@ import { deleteOSSClusterDatabaseApi, deleteAllSentinelDatabasesApi } from '../. const browserPage = new BrowserPage(); const cliPage = new CliPage(); const common = new Common(); +const databaseOverviewPage = new DatabaseOverviewPage(); let keyName = common.generateWord(10); const verifyCommandsInCli = async(): Promise => { @@ -48,6 +49,9 @@ test await browserPage.deleteKeyByName(keyName); await deleteDatabase(redisEnterpriseClusterConfig.databaseName); })('Verify that user can add data via CLI in RE Cluster DB', async() => { + // Verify that database index switcher not displayed for RE Cluster + await t.expect(databaseOverviewPage.changeIndexBtn.exists).notOk('Change Db index control displayed for RE Cluster DB'); + await verifyCommandsInCli(); }); test @@ -60,6 +64,9 @@ test await browserPage.deleteKeyByName(keyName); await deleteDatabase(cloudDatabaseConfig.databaseName); })('Verify that user can add data via CLI in RE Cloud DB', async() => { + // Verify that database index switcher not displayed for RE Cloud + await t.expect(databaseOverviewPage.changeIndexBtn.exists).notOk('Change Db index control displayed for RE Cloud DB'); + await verifyCommandsInCli(); }); test @@ -72,6 +79,9 @@ test await browserPage.deleteKeyByName(keyName); await deleteOSSClusterDatabaseApi(ossClusterConfig); })('Verify that user can add data via CLI in OSS Cluster DB', async() => { + // Verify that database index switcher not displayed for RE Cloud + await t.expect(databaseOverviewPage.changeIndexBtn.exists).notOk('Change Db index control displayed for OSS Cluster DB'); + await verifyCommandsInCli(); }); test @@ -84,5 +94,8 @@ test await browserPage.deleteKeyByName(keyName); await deleteAllSentinelDatabasesApi(ossSentinelConfig); })('Verify that user can add data via CLI in Sentinel Primary Group', async() => { + // Verify that database index switcher displayed for Sentinel + await t.expect(databaseOverviewPage.changeIndexBtn.exists).notOk('Change Db index control not displayed for Sentinel DB'); + await verifyCommandsInCli(); }); diff --git a/tests/e2e/tests/regression/database-overview/database-info.e2e.ts b/tests/e2e/tests/regression/database-overview/database-info.e2e.ts index fda37c0f27..a3223a50da 100644 --- a/tests/e2e/tests/regression/database-overview/database-info.e2e.ts +++ b/tests/e2e/tests/regression/database-overview/database-info.e2e.ts @@ -24,6 +24,7 @@ fixture `Database info tooltips` }); test('Verify that user can see DB name, endpoint, connection type, Redis version, user name in tooltip when hover over the (i) icon', async t => { const version = /[0-9].[0-9].[0-9]/; + const logicalDbText = 'Select logical databases to work with in Browser, Workbench, and Database Analysis.'; await t.hover(browserPage.databaseInfoIcon); await t.expect(browserPage.databaseInfoToolTip.textContent).contains(ossStandaloneConfig.databaseName, 'User can see database name in tooltip'); @@ -31,6 +32,9 @@ test('Verify that user can see DB name, endpoint, connection type, Redis version await t.expect(browserPage.databaseInfoToolTip.textContent).contains('Standalone', 'User can not see connection type in tooltip'); await t.expect(browserPage.databaseInfoToolTip.textContent).match(version, 'User can not see Redis version in tooltip'); await t.expect(browserPage.databaseInfoToolTip.textContent).contains('Default', 'User can not see user name in tooltip'); + // Verify that user can see the tooltip by hovering on index control switcher + await t.expect(browserPage.databaseInfoToolTip.textContent).contains('Logical Databases', 'Logical Databases text not displayed in tooltip'); + await t.expect(browserPage.databaseInfoToolTip.textContent).contains(logicalDbText, 'Logical Databases text not displayed in tooltip'); // Verify that user can see an (i) icon next to the database name on Browser and Workbench pages await t.expect(browserPage.databaseInfoIcon.visible).ok('User can not see (i) icon on Browser page', { timeout: 10000 }); From f4b5dbdbc7a3fb80095fdc57cfd17e513c344cd7 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 5 Jan 2023 16:33:14 +0100 Subject: [PATCH 104/201] add test for redis stack icon --- tests/e2e/pageObjects/my-redis-databases-page.ts | 1 + .../e2e/tests/regression/database/database-list-search.e2e.ts | 4 ++++ tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts | 2 ++ 3 files changed, 7 insertions(+) diff --git a/tests/e2e/pageObjects/my-redis-databases-page.ts b/tests/e2e/pageObjects/my-redis-databases-page.ts index 23797c4bc8..594d581de3 100644 --- a/tests/e2e/pageObjects/my-redis-databases-page.ts +++ b/tests/e2e/pageObjects/my-redis-databases-page.ts @@ -9,6 +9,7 @@ export class MyRedisDatabasePage { //------------------------------------------------------------------------------------------- // CSS Selectors cssNumberOfDbs = '[data-testid=number-of-dbs]'; + cssRedisStackIcon = '[data-testid=redis-stack-icon]'; //BUTTONS settingsButton = Selector('[data-testid=settings-page-btn]'); workbenchButton = Selector('[data-testid=workbench-page-btn]'); diff --git a/tests/e2e/tests/regression/database/database-list-search.e2e.ts b/tests/e2e/tests/regression/database/database-list-search.e2e.ts index 74b216c1bd..064031627e 100644 --- a/tests/e2e/tests/regression/database/database-list-search.e2e.ts +++ b/tests/e2e/tests/regression/database/database-list-search.e2e.ts @@ -56,6 +56,10 @@ test('Verify DB list search', async t => { const searchTimeout = 60 * 1000; // 60 sec to wait for changing Last Connection time const dbSelector = myRedisDatabasePage.dbNameList.withExactText(databasesForSearch[2].databaseName); const startTime = Date.now(); + const noModulesDbRedisStackIcon = myRedisDatabasePage.dbNameList.withExactText(databasesForSearch[2].databaseName).parent('tr').find(myRedisDatabasePage.cssRedisStackIcon); + + // Verify that db without modules has no redis stack icon + await t.expect(noModulesDbRedisStackIcon.exists).notOk('The database with other alias is found'); // Search for DB by Invalid search await t.typeText(myRedisDatabasePage.searchInput, searchedDBHostInvalid, { replace: true, paste: true }); diff --git a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts index 79e58cda5d..5038380f5f 100644 --- a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts +++ b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts @@ -67,4 +67,6 @@ test await addRECloudDatabase(cloudDatabaseConfig); // Verify new connection badge for RE cloud await myRedisDatabasePage.verifyDatabaseStatusIsVisible(); + // Verify redis stack icon for RE Cloud with all 5 modules + await t.expect(myRedisDatabasePage.redisStackIcon.visible).ok('Redis Stack icon not found for RE Cloud db with all 5 modules'); }); From c1f85bde0db4daa26a0f9a46412cd901892bb7c4 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Fri, 6 Jan 2023 15:56:16 +0300 Subject: [PATCH 105/201] #RI-3975 - base FE implementation for ssh tunnel, refactoring add db form --- .../InstanceForm/InstanceForm.spec.tsx | 4 +- .../InstanceForm/InstanceForm.tsx | 1079 +++-------------- .../AddInstanceForm/InstanceForm/constants.ts | 45 + .../form-components/DatabaseForm.tsx | 193 +++ .../InstanceForm/form-components/DbIndex.tsx | 89 ++ .../InstanceForm/form-components/DbInfo.tsx | 137 +++ .../InstanceForm/form-components/Messages.tsx | 49 + .../form-components/SSHDetails.tsx | 208 ++++ .../form-components/TlsDetails.tsx | 285 +++++ .../InstanceForm/form-components/index.ts | 16 + .../sentinel/DbInfoSentinel.tsx | 61 + .../sentinel/PrimaryGroupSentinel.tsx | 53 + .../sentinel/SentinelHostPort.tsx | 40 + .../sentinel/SentinelMasterDatabase.tsx | 76 ++ .../form-components/sentinel/index.ts | 11 + .../InstanceForm/interfaces.ts | 39 + .../InstanceForm/styles.module.scss | 15 + .../AddInstanceForm/InstanceFormWrapper.tsx | 174 +-- redisinsight/ui/src/pages/home/styles.scss | 4 +- .../ui/src/slices/interfaces/instances.ts | 9 + redisinsight/ui/src/styles/base/_inputs.scss | 4 +- .../ui/src/styles/components/_radio.scss | 8 + 22 files changed, 1617 insertions(+), 982 deletions(-) create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DatabaseForm.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbInfo.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/Messages.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/index.ts create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/DbInfoSentinel.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/PrimaryGroupSentinel.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelHostPort.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelMasterDatabase.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/index.ts create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/interfaces.ts diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx index ee2056f797..9562c40556 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx @@ -2,7 +2,9 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { act, fireEvent, render, screen } from 'uiSrc/utils/test-utils' import { ConnectionType, InstanceType } from 'uiSrc/slices/interfaces' -import InstanceForm, { ADD_NEW_CA_CERT, DbConnectionInfo, Props, } from './InstanceForm' +import InstanceForm, { Props } from './InstanceForm' +import { ADD_NEW_CA_CERT } from './constants' +import { DbConnectionInfo } from './interfaces' const BTN_SUBMIT = 'btn-submit' const NEW_CA_CERT = 'new-ca-cert' diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx index 2a4285c599..6e13ce5202 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx @@ -1,38 +1,19 @@ import { EuiButton, - EuiButtonIcon, - EuiCheckbox, EuiCollapsibleNavGroup, - EuiFieldNumber, - EuiFieldPassword, - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, EuiForm, - EuiFormRow, - EuiIcon, - EuiLink, - EuiListGroup, - EuiListGroupItem, EuiSpacer, - EuiSuperSelect, - EuiSuperSelectOption, - EuiText, - EuiTextArea, - EuiTextColor, EuiToolTip, - htmlIdGenerator, keys, } from '@elastic/eui' -import cx from 'classnames' + import { FormikErrors, useFormik } from 'formik' -import { capitalize, isEmpty, pick } from 'lodash' -import React, { ChangeEvent, useEffect, useRef, useState } from 'react' +import { isEmpty, pick } from 'lodash' +import React, { useEffect, useRef, useState } from 'react' import ReactDOM from 'react-dom' import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router' -import { DatabaseListModules } from 'uiSrc/components' -import { APPLICATION_NAME, PageNames, Pages } from 'uiSrc/constants' +import { PageNames, Pages } from 'uiSrc/constants' import validationErrors from 'uiSrc/constants/validationErrors' import DatabaseAlias from 'uiSrc/pages/home/components/DatabaseAlias' import { useResizableFormField } from 'uiSrc/services' @@ -45,45 +26,36 @@ import { setConnectedInstanceId, } from 'uiSrc/slices/instances/instances' -import { ConnectionType, Instance, InstanceType, } from 'uiSrc/slices/interfaces' +import { ConnectionType, InstanceType, } from 'uiSrc/slices/interfaces' import { getRedisModulesSummary, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { handlePasteHostName, getDiffKeysOfObjectValues, checkRediStackModules, selectOnFocus } from 'uiSrc/utils' +import { getDiffKeysOfObjectValues, checkRediStackModules } from 'uiSrc/utils' + +import { + ADD_NEW_CA_CERT, + NO_CA_CERT, + ADD_NEW, + fieldDisplayNames, + optionsCertsCA, + optionsCertsClient, SshPassType +} from './constants' + +import { DbConnectionInfo, ISubmitButton } from './interfaces' import { - MAX_PORT_NUMBER, - validateCertName, - validateField, - validateNumber, - validatePortNumber, -} from 'uiSrc/utils/validations' + DbIndex, + DbInfo, + MessageSentinel, + MessageStandalone, + TlsDetails, + DatabaseForm +} from './form-components' +import { + DbInfoSentinel, + PrimaryGroupSentinel, + SentinelHostPort, + SentinelMasterDatabase, +} from './form-components/sentinel' +import SSHDetails from './form-components/SSHDetails' import { LoadingDatabaseText, SubmitBtnText, TitleDatabaseText, } from '../InstanceFormWrapper' -import styles from './styles.module.scss' - -export const ADD_NEW_CA_CERT = 'ADD_NEW_CA_CERT' -export const NO_CA_CERT = 'NO_CA_CERT' -export const ADD_NEW = 'ADD_NEW' - -export interface DbConnectionInfo extends Instance { - port: string - tlsClientAuthRequired?: boolean - certificates?: { id: number; name: string }[] - selectedTlsClientCertId?: string | 'ADD_NEW' | undefined - newTlsCertPairName?: string - newTlsClientCert?: string - newTlsClientKey?: string - servername?: string - verifyServerTlsCert?: boolean - caCertificates?: { name: string; id: string }[] - selectedCaCertName: string | typeof ADD_NEW_CA_CERT | typeof NO_CA_CERT - newCaCertName?: string - newCaCert?: string - username?: string - password?: string - showDb?: boolean - sni?: boolean - sentinelMasterUsername?: string - sentinelMasterPassword?: string - sentinelMasterName?: string -} export interface Props { width: number @@ -106,26 +78,6 @@ export interface Props { setErrorMsgRef?: (database: HTMLDivElement | null) => void } -interface ISubmitButton { - onClick: () => void - text?: string - submitIsDisabled?: boolean -} - -const fieldDisplayNames: DbConnectionInfo = { - port: 'Port', - host: 'Host', - name: 'Database alias', - selectedCaCertName: 'CA Certificate', - newCaCertName: 'CA Certificate Name', - newCaCert: 'CA certificate', - newTlsCertPairName: 'Client Certificate Name', - newTlsClientCert: 'Client Certificate', - newTlsClientKey: 'Private Key', - servername: 'Server Name', - sentinelMasterName: 'Primary Group Name' -} - const getInitFieldsDisplayNames = ({ host, port, name, instanceType }: any) => { if (!host || !port) { if (!name && instanceType !== InstanceType.Sentinel) { @@ -165,6 +117,9 @@ const AddStandaloneForm = (props: Props) => { sentinelMasterUsername, servername, provider, + ssh, + sshPassType, + sshOptions }, initialValues: initialValuesProp, width, @@ -205,6 +160,14 @@ const AddStandaloneForm = (props: Props) => { sentinelMasterName: sentinelMaster?.name || '', sentinelMasterUsername, sentinelMasterPassword, + ssh, + sshPassType, + sshHost: sshOptions?.host ?? '', + sshPort: sshOptions?.port ?? 22, + sshUsername: sshOptions?.username ?? '', + sshPassword: sshOptions?.password ?? '', + sshPrivateKey: sshOptions?.privateKey ?? '', + sshPassphrase: sshOptions?.passphrase ?? '' }) const [initialValues, setInitialValues] = useState(prepareInitialValues()) @@ -293,6 +256,18 @@ const AddStandaloneForm = (props: Props) => { errs.sentinelMasterName = fieldDisplayNames.sentinelMasterName } + if (values.ssh) { + if (!values.sshHost) { + errs.sshHost = fieldDisplayNames.sshHost + } + if (!values.sshPort) { + errs.sshPort = fieldDisplayNames.sshPort + } + if (values.sshPassType === SshPassType.PrivateKey && !values.sshPrivateKey) { + errs.sshPrivateKey = fieldDisplayNames.sshPrivateKey + } + } + setErrors(errs) return errs } @@ -336,17 +311,6 @@ const AddStandaloneForm = (props: Props) => { }, []) - const optionsCertsCA: EuiSuperSelectOption[] = [ - { - value: NO_CA_CERT, - inputDisplay: 'No CA Certificate', - }, - { - value: ADD_NEW_CA_CERT, - inputDisplay: 'Add new CA certificate', - }, - ] - caCertificates?.forEach((cert) => { optionsCertsCA.push({ value: cert.id, @@ -354,13 +318,6 @@ const AddStandaloneForm = (props: Props) => { }) }) - const optionsCertsClient: EuiSuperSelectOption[] = [ - { - value: 'ADD_NEW', - inputDisplay: 'Add new certificate', - }, - ] - certificates?.forEach((cert) => { optionsCertsClient.push({ value: `${cert.id}`, @@ -368,10 +325,6 @@ const AddStandaloneForm = (props: Props) => { }) }) - const handleCopy = (text = '') => { - navigator.clipboard.writeText(text) - } - const handleCheckConnectToInstance = () => { const modulesSummary = getRedisModulesSummary(modules) sendEventTelemetry({ @@ -421,15 +374,6 @@ const AddStandaloneForm = (props: Props) => { )) } - const handleChangeDbIndexCheckbox = (e: ChangeEvent): void => { - const isChecked = e.target.checked - if (!isChecked) { - // Reset db field to initial value - formik.setFieldValue('db', null) - } - formik.handleChange(e) - } - const connectToInstance = () => { if (contextInstanceId && contextInstanceId !== id) { dispatch(resetKeys()) @@ -444,706 +388,6 @@ const AddStandaloneForm = (props: Props) => { history.push(Pages.browser(id)) } - const DbInfo = () => ( - - - Connection Type: - - {capitalize(connectionType)} - - - )} - /> - - {nameFromProvider && ( - - Database Name from Provider: - - {nameFromProvider} - - - )} - /> - )} - - - {!!nodes?.length && } - - Host: - - {host} - - - - )} - /> - - - Port: - - {port} - - - )} - /> - - {!!db && ( - - Database Index: - - {db} - - - )} - /> - )} - - {!!modules?.length && ( - <> - - Modules: - - )} - /> - - - - - )} - - ) - - const PrimaryGroupSentinel = () => ( - <> - - - - - - - - - - - - - - - - ) - - const DbInfoSentinel = () => ( - - - Connection Type: - - {capitalize(connectionType)} - - - )} - /> - - {sentinelMaster?.name && ( - - Primary Group Name: - - {sentinelMaster?.name} - - - )} - /> - )} - - {nameFromProvider && ( - - Database Name from Provider: - - {nameFromProvider} - - - )} - /> - )} - - ) - - const AppendHostName = () => ( - -

- Pasting a connection URL auto fills the database details. -

-

- The following connection URLs are supported: -

-
- )} - className="homePage_tooltip" - anchorClassName="inputAppendIcon" - position="right" - content={( -
    -
  • - - redis://[[username]:[password]]@host:port -
  • -
  • - - rediss://[[username]:[password]]@host:port -
  • -
  • - - host:port -
  • -
- )} - > - - - ) - - const AppendEndpoints = () => ( - - {nodes?.map(({ host: ephost, port: epport }) => ( -
  • - - {ephost} - : - {epport} - ; - -
  • - ))} - - )} - > - -
    - ) - - const DatabaseForm = () => ( - <> - {(!isEditMode || isCloneMode) && ( - - - - ) => { - formik.setFieldValue( - e.target.name, - validateField(e.target.value.trim()) - ) - }} - onPaste={(event: React.ClipboardEvent) => handlePasteHostName(onHostNamePaste, event)} - onFocus={selectOnFocus} - append={} - /> - - - - - - ) => { - formik.setFieldValue( - e.target.name, - validatePortNumber(e.target.value.trim()) - ) - }} - onFocus={selectOnFocus} - type="text" - min={0} - max={MAX_PORT_NUMBER} - /> - - - - )} - - {( - (!isEditMode || isCloneMode) - && instanceType !== InstanceType.Sentinel - && connectionType !== ConnectionType.Sentinel - ) && ( - - - - - - - - )} - - - - - - - - - - - - - - - - ) - - const DBIndex = () => ( - <> - - - - - - - - - {formik.values.showDb && ( - - - - ) => { - formik.setFieldValue( - e.target.name, - validateNumber(e.target.value.trim()) - ) - }} - type="text" - min={0} - /> - - - - )} - - ) - - const TlsDetails = () => ( - <> - - - - - - {formik.values.tls && ( - <> - - ) => { - formik.setFieldValue( - 'servername', - formik.values.servername ?? formik.values.host ?? '' - ) - return formik.handleChange(e) - }} - data-testid="sni" - /> - - {formik.values.sni && ( - - - ) => - formik.setFieldValue( - e.target.name, - validateField(e.target.value.trim()) - )} - data-testid="sni-servername" - /> - - - )} - - - - - )} - - {formik.values.tls && ( -
    - - - - { - formik.setFieldValue( - 'selectedCaCertName', - value || NO_CA_CERT - ) - }} - data-testid="select-ca-cert" - /> - - - - {formik.values.tls - && formik.values.selectedCaCertName === ADD_NEW_CA_CERT && ( - - - ) => - formik.setFieldValue( - e.target.name, - validateCertName(e.target.value) - )} - data-testid="qa-ca-cert" - /> - - - )} - - - {formik.values.tls - && formik.values.selectedCaCertName === ADD_NEW_CA_CERT && ( - - - - - - - - )} -
    - )} - {formik.values.tls && ( - - - - - - )} - {formik.values.tls && formik.values.tlsClientAuthRequired && ( -
    - - - - { - formik.setFieldValue('selectedTlsClientCertId', value) - }} - data-testid="select-cert" - /> - - - - {formik.values.tls - && formik.values.tlsClientAuthRequired - && formik.values.selectedTlsClientCertId === 'ADD_NEW' && ( - - - ) => - formik.setFieldValue( - e.target.name, - validateCertName(e.target.value) - )} - data-testid="new-tsl-cert-pair-name" - /> - - - )} - - - {formik.values.tls - && formik.values.tlsClientAuthRequired - && formik.values.selectedTlsClientCertId === 'ADD_NEW' && ( - <> - - - - - - - - - - - - - - - - - )} -
    - )} - - ) - - const SentinelMasterDatabase = () => ( - <> - {(!!db && !isCloneMode) && ( - - Database Index: - - {db} - - - )} - - - - - - - - - - - - - - - ) - const getSubmitButtonContent = (submitIsDisabled?: boolean) => { const maxErrorsCount = 5 const errorsArr = Object.values(errors).map((err) => [ @@ -1218,66 +462,6 @@ const AddStandaloneForm = (props: Props) => { return null } - const SentinelHostPort = () => ( - - Host:Port: -
    - {`${host}:${port}`} - - handleCopy(`${host}:${port}`)} - /> - -
    -
    - ) - - const MessageStandalone = () => ( - - You can manually add your Redis databases. Enter Host and Port of your - Redis database to add it to - {' '} - {APPLICATION_NAME} - .   - - Learn more here. - - - ) - - const MessageSentinel = () => ( - - You can automatically discover and add primary groups from your Redis - Sentinel. Enter Host and Port of your Redis Sentinel to automatically - discover your primary groups and add them to - {' '} - {APPLICATION_NAME} - .   - - Learn more here. - - - ) - return (
    {isEditMode && name && ( @@ -1315,23 +499,83 @@ const AddStandaloneForm = (props: Props) => { data-testid="form" onKeyDown={onKeyDown} > - {DatabaseForm()} - { instanceType !== InstanceType.Sentinel && DBIndex() } - {TlsDetails()} + + {instanceType !== InstanceType.Sentinel && ( + + )} + + {instanceType !== InstanceType.Sentinel && ( + + )} )} {(isEditMode || isCloneMode) && connectionType !== ConnectionType.Sentinel && ( <> - {!isCloneMode && } + {!isCloneMode && ( + + )} - {DatabaseForm()} - {isCloneMode && DBIndex()} - {TlsDetails()} + + {isCloneMode && ( + + )} + + )} @@ -1345,14 +589,24 @@ const AddStandaloneForm = (props: Props) => { > {!isCloneMode && ( <> - + - {SentinelMasterDatabase()} + { initialIsOpen={false} data-testid="sentinel-nav-group" > - {SentinelHostPort()} - {DatabaseForm()} + + { isCollapsible initialIsOpen={false} > - {TlsDetails()} + )} {isCloneMode && ( <> - {PrimaryGroupSentinel()} + - {SentinelMasterDatabase()} + { initialIsOpen={false} data-testid="sentinel-nav-group-clone" > - {DatabaseForm()} + - {DBIndex()} - {TlsDetails()} + + )} @@ -1406,12 +703,4 @@ const AddStandaloneForm = (props: Props) => { ) } -AddStandaloneForm.defaultProps = { - isResizablePanel: false, - submitButtonText: '', - titleText: '', - connectionTypeText: '', - setErrorMsgRef: () => {}, -} - export default AddStandaloneForm diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts new file mode 100644 index 0000000000..a921c83f66 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts @@ -0,0 +1,45 @@ +import { EuiSuperSelectOption } from '@elastic/eui' + +export const ADD_NEW_CA_CERT = 'ADD_NEW_CA_CERT' +export const NO_CA_CERT = 'NO_CA_CERT' +export const ADD_NEW = 'ADD_NEW' + +export const optionsCertsCA: EuiSuperSelectOption[] = [ + { + value: NO_CA_CERT, + inputDisplay: 'No CA Certificate', + }, + { + value: ADD_NEW_CA_CERT, + inputDisplay: 'Add new CA certificate', + }, +] + +export const optionsCertsClient: EuiSuperSelectOption[] = [ + { + value: 'ADD_NEW', + inputDisplay: 'Add new certificate', + }, +] + +export enum SshPassType { + Password = 'password', + PrivateKey = 'privateKey' +} + +export const fieldDisplayNames = { + port: 'Port', + host: 'Host', + name: 'Database alias', + selectedCaCertName: 'CA Certificate', + newCaCertName: 'CA Certificate Name', + newCaCert: 'CA certificate', + newTlsCertPairName: 'Client Certificate Name', + newTlsClientCert: 'Client Certificate', + newTlsClientKey: 'Private Key', + servername: 'Server Name', + sentinelMasterName: 'Primary Group Name', + sshHost: 'SSH Host', + sshPort: 'SSH Port', + sshPrivateKey: 'SSH Private Key' +} diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DatabaseForm.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DatabaseForm.tsx new file mode 100644 index 0000000000..25315f4245 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DatabaseForm.tsx @@ -0,0 +1,193 @@ +import React, { ChangeEvent } from 'react' +import { FormikProps } from 'formik' + +import { + EuiFieldNumber, + EuiFieldPassword, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, EuiIcon, + EuiToolTip +} from '@elastic/eui' +import { handlePasteHostName, MAX_PORT_NUMBER, selectOnFocus, validateField, validatePortNumber } from 'uiSrc/utils' +import { ConnectionType, InstanceType } from 'uiSrc/slices/interfaces' +import { DbConnectionInfo } from '../interfaces' + +export interface Props { + flexGroupClassName?: string + flexItemClassName?: string + formik: FormikProps + isEditMode: boolean + isCloneMode: boolean + onHostNamePaste: (content: string) => boolean + instanceType: InstanceType + connectionType?: ConnectionType +} + +const DatabaseForm = (props: Props) => { + const { + flexGroupClassName = '', + flexItemClassName = '', + formik, + isEditMode, + isCloneMode, + onHostNamePaste, + instanceType, + connectionType + } = props + + const AppendHostName = () => ( + +

    + Pasting a connection URL auto fills the database details. +

    +

    + The following connection URLs are supported: +

    +
    + )} + className="homePage_tooltip" + anchorClassName="inputAppendIcon" + position="right" + content={( +
      +
    • + + redis://[[username]:[password]]@host:port +
    • +
    • + + rediss://[[username]:[password]]@host:port +
    • +
    • + + host:port +
    • +
    + )} + > + + + ) + + return ( + <> + {(!isEditMode || isCloneMode) && ( + + + + ) => { + formik.setFieldValue( + e.target.name, + validateField(e.target.value.trim()) + ) + }} + onPaste={(event: React.ClipboardEvent) => handlePasteHostName(onHostNamePaste, event)} + onFocus={selectOnFocus} + append={} + /> + + + + + + ) => { + formik.setFieldValue( + e.target.name, + validatePortNumber(e.target.value.trim()) + ) + }} + onFocus={selectOnFocus} + type="text" + min={0} + max={MAX_PORT_NUMBER} + /> + + + + )} + + {( + (!isEditMode || isCloneMode) + && instanceType !== InstanceType.Sentinel + && connectionType !== ConnectionType.Sentinel + ) && ( + + + + + + + + )} + + + + + + + + + + + + + + + + ) +} + +export default DatabaseForm diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx new file mode 100644 index 0000000000..176aff21c6 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx @@ -0,0 +1,89 @@ +import React, { ChangeEvent } from 'react' +import { EuiCheckbox, EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, htmlIdGenerator } from '@elastic/eui' +import cx from 'classnames' + +import { validateNumber } from 'uiSrc/utils' +import { FormikProps } from 'formik' + +import { DbConnectionInfo } from '../interfaces' +import styles from '../styles.module.scss' + +export interface Props { + flexGroupClassName?: string + flexItemClassName?: string + formik: FormikProps +} + +const DbIndex = (props: Props) => { + const { flexGroupClassName = '', flexItemClassName = '', formik } = props + + const handleChangeDbIndexCheckbox = (e: ChangeEvent): void => { + const isChecked = e.target.checked + if (!isChecked) { + // Reset db field to initial value + formik.setFieldValue('db', null) + } + formik.handleChange(e) + } + + return ( + <> + + + + + + + + + {formik.values.showDb && ( + + + + ) => { + formik.setFieldValue( + e.target.name, + validateNumber(e.target.value.trim()) + ) + }} + type="text" + min={0} + /> + + + + )} + + ) +} + +export default DbIndex diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbInfo.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbInfo.tsx new file mode 100644 index 0000000000..dad184bf52 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbInfo.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import { EuiIcon, EuiListGroup, EuiListGroupItem, EuiText, EuiTextColor, EuiToolTip } from '@elastic/eui' +import { capitalize } from 'lodash' +import cx from 'classnames' +import { DatabaseListModules } from 'uiSrc/components' +import { ConnectionType } from 'uiSrc/slices/interfaces' +import { Nullable } from 'uiSrc/utils' +import { Endpoint } from 'apiSrc/common/models' +import { AdditionalRedisModule } from 'apiSrc/modules/database/models/additional.redis.module' + +import styles from '../styles.module.scss' + +export interface Props { + connectionType?: ConnectionType + nameFromProvider?: Nullable + nodes: Nullable + host: string + port: string + db: Nullable + modules: AdditionalRedisModule[] +} + +const DbInfo = (props: Props) => { + const { connectionType, nameFromProvider, nodes = null, host, port, db, modules } = props + const AppendEndpoints = () => ( + + {nodes?.map(({ host: eHost, port: ePort }) => ( +
  • + + {eHost} + : + {ePort} + ; + +
  • + ))} + + )} + > + +
    + ) + + return ( + + + Connection Type: + + {capitalize(connectionType)} + + + )} + /> + + {nameFromProvider && ( + + Database Name from Provider: + + {nameFromProvider} + + + )} + /> + )} + + + {!!nodes?.length && } + + Host: + + {host} + + + + )} + /> + + + Port: + + {port} + + + )} + /> + + {!!db && ( + + Database Index: + + {db} + + + )} + /> + )} + + {!!modules?.length && ( + <> + + Modules: + + )} + /> + + + + + )} + + ) +} + +export default DbInfo diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/Messages.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/Messages.tsx new file mode 100644 index 0000000000..93a82e9156 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/Messages.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { EuiLink, EuiText } from '@elastic/eui' +import { APPLICATION_NAME } from 'uiSrc/constants' + +import styles from '../styles.module.scss' + +const MessageStandalone = () => ( + + You can manually add your Redis databases. Enter Host and Port of your + Redis database to add it to + {' '} + {APPLICATION_NAME} + .   + + Learn more here. + + +) + +const MessageSentinel = () => ( + + You can automatically discover and add primary groups from your Redis + Sentinel. Enter Host and Port of your Redis Sentinel to automatically + discover your primary groups and add them to + {' '} + {APPLICATION_NAME} + .   + + Learn more here. + + +) + +export { + MessageStandalone, + MessageSentinel, +} diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx new file mode 100644 index 0000000000..9f319f3b0d --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx @@ -0,0 +1,208 @@ +import React, { ChangeEvent } from 'react' +import { + EuiCheckbox, + EuiFieldNumber, + EuiFieldPassword, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiRadioGroup, + EuiRadioGroupOption, + EuiTextArea, + htmlIdGenerator +} from '@elastic/eui' +import cx from 'classnames' +import { + MAX_PORT_NUMBER, + selectOnFocus, + validateField, + validatePortNumber +} from 'uiSrc/utils' +import { FormikProps } from 'formik' +import { SshPassType } from '../constants' +import { DbConnectionInfo } from '../interfaces' + +import styles from '../styles.module.scss' + +export interface Props { + flexGroupClassName?: string + flexItemClassName?: string + formik: FormikProps +} + +const sshPassTypeOptions: EuiRadioGroupOption[] = [ + { id: SshPassType.Password, label: 'Password', 'data-test-subj': 'radio-btn-password' }, + { id: SshPassType.PrivateKey, label: 'Private Key', 'data-test-subj': 'radio-btn-privateKey' } +] + +const SSHDetails = (props: Props) => { + const { flexGroupClassName = '', flexItemClassName = '', formik } = props + + return ( + <> + + + + + + {formik.values.ssh && ( + <> + + + ) => { + formik.setFieldValue( + e.target.name, + validateField(e.target.value.trim()) + ) + }} + /> + + + + + + ) => { + formik.setFieldValue( + e.target.name, + validatePortNumber(e.target.value.trim()) + ) + }} + onFocus={selectOnFocus} + type="text" + min={0} + max={MAX_PORT_NUMBER} + /> + + + + + + ) => { + formik.setFieldValue( + e.target.name, + validateField(e.target.value.trim()) + ) + }} + /> + + + + + formik.setFieldValue('sshPassType', id)} + data-testid="ssh-pass-type" + /> + + + {formik.values.sshPassType === SshPassType.Password && ( + + + + + + )} + + {formik.values.sshPassType === SshPassType.PrivateKey && ( + <> + + + + + + + + + + + + )} + + )} + + + ) +} + +export default SSHDetails diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx new file mode 100644 index 0000000000..8528534e6d --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx @@ -0,0 +1,285 @@ +import React, { ChangeEvent } from 'react' +import { + EuiCheckbox, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSuperSelect, EuiTextArea, + htmlIdGenerator +} from '@elastic/eui' +import cx from 'classnames' +import { validateCertName, validateField } from 'uiSrc/utils' +import { FormikProps } from 'formik' +import { + ADD_NEW_CA_CERT, + NO_CA_CERT, + optionsCertsCA, + optionsCertsClient +} from '../constants' +import { DbConnectionInfo } from '../interfaces' + +import styles from '../styles.module.scss' + +export interface Props { + flexGroupClassName?: string + flexItemClassName?: string + formik: FormikProps +} +const TlsDetails = (props: Props) => { + const { flexGroupClassName = '', flexItemClassName = '', formik } = props + + return ( + <> + + + + + + {formik.values.tls && ( + <> + + ) => { + formik.setFieldValue( + 'servername', + formik.values.servername ?? formik.values.host ?? '' + ) + return formik.handleChange(e) + }} + data-testid="sni" + /> + + {formik.values.sni && ( + + + ) => + formik.setFieldValue( + e.target.name, + validateField(e.target.value.trim()) + )} + data-testid="sni-servername" + /> + + + )} + + + + + )} + + {formik.values.tls && ( +
    + + + + { + formik.setFieldValue( + 'selectedCaCertName', + value || NO_CA_CERT + ) + }} + data-testid="select-ca-cert" + /> + + + + {formik.values.tls + && formik.values.selectedCaCertName === ADD_NEW_CA_CERT && ( + + + ) => + formik.setFieldValue( + e.target.name, + validateCertName(e.target.value) + )} + data-testid="qa-ca-cert" + /> + + + )} + + + {formik.values.tls + && formik.values.selectedCaCertName === ADD_NEW_CA_CERT && ( + + + + + + + + )} +
    + )} + {formik.values.tls && ( + + + + + + )} + {formik.values.tls && formik.values.tlsClientAuthRequired && ( +
    + + + + { + formik.setFieldValue('selectedTlsClientCertId', value) + }} + data-testid="select-cert" + /> + + + + {formik.values.tls + && formik.values.tlsClientAuthRequired + && formik.values.selectedTlsClientCertId === 'ADD_NEW' && ( + + + ) => + formik.setFieldValue( + e.target.name, + validateCertName(e.target.value) + )} + data-testid="new-tsl-cert-pair-name" + /> + + + )} + + + {formik.values.tls + && formik.values.tlsClientAuthRequired + && formik.values.selectedTlsClientCertId === 'ADD_NEW' && ( + <> + + + + + + + + + + + + + + + + + )} +
    + )} + + ) +} + +export default TlsDetails diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/index.ts b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/index.ts new file mode 100644 index 0000000000..0e715641d7 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/index.ts @@ -0,0 +1,16 @@ +import DbInfo from './DbInfo' +import { MessageStandalone, MessageSentinel } from './Messages' +import DbIndex from './DbIndex' +import TlsDetails from './TlsDetails' +import DatabaseForm from './DatabaseForm' +import SSHDetails from './SSHDetails' + +export { + DbInfo, + MessageStandalone, + MessageSentinel, + DbIndex, + TlsDetails, + DatabaseForm, + SSHDetails +} diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/DbInfoSentinel.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/DbInfoSentinel.tsx new file mode 100644 index 0000000000..0443b99322 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/DbInfoSentinel.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { EuiListGroup, EuiListGroupItem, EuiText, EuiTextColor } from '@elastic/eui' + +import { capitalize } from 'lodash' +import { ConnectionType } from 'uiSrc/slices/interfaces' +import { Nullable } from 'uiSrc/utils' +import { SentinelMaster } from 'apiSrc/modules/redis-sentinel/models/sentinel-master' + +import styles from '../../styles.module.scss' + +export interface Props { + connectionType?: ConnectionType + nameFromProvider?: Nullable + sentinelMaster?: SentinelMaster +} + +const DbInfoSentinel = (props: Props) => { + const { connectionType, nameFromProvider, sentinelMaster } = props + return ( + + + Connection Type: + + {capitalize(connectionType)} + + + )} + /> + + {sentinelMaster?.name && ( + + Primary Group Name: + + {sentinelMaster?.name} + + + )} + /> + )} + + {nameFromProvider && ( + + Database Name from Provider: + + {nameFromProvider} + + + )} + /> + )} + + ) +} + +export default DbInfoSentinel diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/PrimaryGroupSentinel.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/PrimaryGroupSentinel.tsx new file mode 100644 index 0000000000..f913d8ee1a --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/PrimaryGroupSentinel.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui' +import { FormikProps } from 'formik' + +import { DbConnectionInfo } from '../../interfaces' + +export interface Props { + flexGroupClassName?: string + flexItemClassName?: string + formik: FormikProps +} + +const PrimaryGroupSentinel = (props: Props) => { + const { flexGroupClassName = '', flexItemClassName = '', formik } = props + return ( + <> + + + + + + + + + + + + + + + + ) +} + +export default PrimaryGroupSentinel diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelHostPort.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelHostPort.tsx new file mode 100644 index 0000000000..269b724aa8 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelHostPort.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { EuiButtonIcon, EuiText, EuiTextColor, EuiToolTip } from '@elastic/eui' + +import styles from '../../styles.module.scss' + +export interface Props { + host: string + port: string +} + +const SentinelHostPort = (props: Props) => { + const { host, port } = props + + const handleCopy = (text = '') => { + navigator.clipboard.writeText(text) + } + + return ( + + Host:Port: +
    + {`${host}:${port}`} + + handleCopy(`${host}:${port}`)} + /> + +
    +
    + ) +} + +export default SentinelHostPort diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelMasterDatabase.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelMasterDatabase.tsx new file mode 100644 index 0000000000..9f29f47e9b --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/SentinelMasterDatabase.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { + EuiFieldPassword, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiText, + EuiTextColor +} from '@elastic/eui' +import { FormikProps } from 'formik' + +import { Nullable } from 'uiSrc/utils' +import { DbConnectionInfo } from '../../interfaces' +import styles from '../../styles.module.scss' + +export interface Props { + flexGroupClassName?: string + flexItemClassName?: string + formik: FormikProps + isCloneMode: boolean + db: Nullable +} + +const SentinelMasterDatabase = (props: Props) => { + const { db, isCloneMode, flexGroupClassName = '', flexItemClassName = '', formik } = props + return ( + <> + {(!!db && !isCloneMode) && ( + + Database Index: + + {db} + + + )} + + + + + + + + + + + + + + + ) +} + +export default SentinelMasterDatabase diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/index.ts b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/index.ts new file mode 100644 index 0000000000..c8557bafd5 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/sentinel/index.ts @@ -0,0 +1,11 @@ +import DbInfoSentinel from './DbInfoSentinel' +import PrimaryGroupSentinel from './PrimaryGroupSentinel' +import SentinelMasterDatabase from './SentinelMasterDatabase' +import SentinelHostPort from './SentinelHostPort' + +export { + DbInfoSentinel, + PrimaryGroupSentinel, + SentinelMasterDatabase, + SentinelHostPort, +} diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/interfaces.ts b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/interfaces.ts new file mode 100644 index 0000000000..c9874dc76d --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/interfaces.ts @@ -0,0 +1,39 @@ +import { Instance } from 'uiSrc/slices/interfaces' +import { ADD_NEW_CA_CERT, NO_CA_CERT } from './constants' + +export interface DbConnectionInfo extends Instance { + port: string + tlsClientAuthRequired?: boolean + certificates?: { id: number; name: string }[] + selectedTlsClientCertId?: string | 'ADD_NEW' | undefined + newTlsCertPairName?: string + newTlsClientCert?: string + newTlsClientKey?: string + servername?: string + verifyServerTlsCert?: boolean + caCertificates?: { name: string; id: string }[] + selectedCaCertName: string | typeof ADD_NEW_CA_CERT | typeof NO_CA_CERT + newCaCertName?: string + newCaCert?: string + username?: string + password?: string + showDb?: boolean + sni?: boolean + sentinelMasterUsername?: string + sentinelMasterPassword?: string + sentinelMasterName?: string + ssh?: boolean + sshPassType?: string + sshHost?: string + sshPort?: number + sshUsername?: string + sshPassword?: string + sshPrivateKey?: string + sshPassphrase?: string +} + +export interface ISubmitButton { + onClick: () => void + text?: string + submitIsDisabled?: boolean +} diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/styles.module.scss b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/styles.module.scss index 6740d547d7..972322ce1c 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/styles.module.scss @@ -168,3 +168,18 @@ .fullWidth { flex-basis: 100% !important; } + +.sshPassTypeWrapper { + .sshPassType { + display: flex; + flex-direction: column; + :global { + .euiRadioGroup__item { + margin-bottom: 8px !important; + &:last-child { + margin-bottom: 0 !important; + } + } + } + } +} diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx index 2dcaa05f96..cba6b7fd60 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx @@ -1,6 +1,6 @@ /* eslint-disable no-nested-ternary */ import { ConnectionString } from 'connection-string' -import { pick } from 'lodash' +import { isUndefined, pick } from 'lodash' import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router' @@ -22,7 +22,9 @@ import { ConnectionType, Instance, InstanceType, } from 'uiSrc/slices/interfaces import { BrowserStorageItem, DbType, Pages, REDIS_URI_SCHEMES } from 'uiSrc/constants' import { clientCertsSelector, fetchClientCerts, } from 'uiSrc/slices/instances/clientCerts' -import InstanceForm, { ADD_NEW, ADD_NEW_CA_CERT, NO_CA_CERT } from './InstanceForm' +import InstanceForm from './InstanceForm' +import { DbConnectionInfo } from './InstanceForm/interfaces' +import { ADD_NEW, ADD_NEW_CA_CERT, NO_CA_CERT, SshPassType } from './InstanceForm/constants' export interface Props { width: number @@ -61,6 +63,10 @@ const getInitialValues = (editedInstance: Nullable) => ({ username: editedInstance?.username ?? '', password: editedInstance?.password ?? '', tls: !!editedInstance?.tls ?? false, + ssh: !!editedInstance?.ssh ?? false, + sshPassType: editedInstance?.sshOptions + ? (editedInstance.sshOptions.password ? SshPassType.Password : SshPassType.PrivateKey) + : SshPassType.Password }) const InstanceFormWrapper = (props: Props) => { @@ -78,7 +84,7 @@ const InstanceFormWrapper = (props: Props) => { const [initialValues, setInitialValues] = useState(getInitialValues(editedInstance)) const [isCloneMode, setIsCloneMode] = useState(false) - const { host, port, name, username, password, tls } = initialValues + const { host, port, name, username, password, tls, ssh, sshPassType } = initialValues const { loadingChanging: loadingStandalone } = useSelector(instancesSelector) const { loading: loadingSentinel } = useSelector(sentinelSelector) @@ -185,6 +191,8 @@ const InstanceFormWrapper = (props: Props) => { username: details.user || '', password: details.password || '', tls: details.protocol === 'rediss', + ssh: false, + sshPassType: SshPassType.Password }) /* * auto fill was successfull so return true @@ -198,7 +206,72 @@ const InstanceFormWrapper = (props: Props) => { return false } - const editDatabase = (tlsSettings, values) => { + const applyTlSDatabase = (database: any, tlsSettings: any) => { + const { useTls, verifyServerCert, servername, caCert, clientAuth, clientCert } = tlsSettings + if (!useTls) return + + database.tls = useTls + database.tlsServername = servername + database.verifyServerCert = !!verifyServerCert + + if (!isUndefined(caCert?.new)) { + database.caCert = { + name: caCert?.new.name, + certificate: caCert?.new.certificate, + } + } + + if (!isUndefined(caCert?.name)) { + database.caCert = { id: caCert?.name } + } + + if (clientAuth) { + if (!isUndefined(clientCert.new)) { + database.clientCert = { + name: clientCert.new.name, + certificate: clientCert.new.certificate, + key: clientCert.new.key, + } + } + + if (!isUndefined(clientCert.id)) { + database.clientCert = { id: clientCert.id } + } + } + } + + const applySSHDatabase = (database: any, values: DbConnectionInfo) => { + const { + ssh, + sshPassType, + sshHost, + sshPort, + sshPassword, + sshUsername, + sshPassphrase, + sshPrivateKey, + } = values + + if (ssh) { + database.ssh = true + database.sshOptions = { + host: sshHost, + port: sshPort, + username: sshUsername, + } + + if (sshPassType === SshPassType.Password) { + database.sshOptions.password = sshPassword + } + + if (sshPassType === SshPassType.PrivateKey) { + database.sshOptions.passphrase = sshPassphrase + database.sshOptions.privateKey = sshPrivateKey + } + } + } + + const editDatabase = (tlsSettings: any, values: DbConnectionInfo) => { const { name, host, @@ -209,7 +282,7 @@ const InstanceFormWrapper = (props: Props) => { sentinelMasterPassword, } = values - const database = { + const database: any = { id: editedInstance?.id, name, host, @@ -218,44 +291,9 @@ const InstanceFormWrapper = (props: Props) => { password, } - const { - useTls, - servername, - verifyServerCert, - caCert, - clientAuth, - clientCert, - } = tlsSettings - - if (useTls) { - database.tls = useTls - database.tlsServername = servername - database.verifyServerCert = !!verifyServerCert - - if (typeof caCert?.new !== 'undefined') { - database.caCert = { - name: caCert?.new.name, - certificate: caCert?.new.certificate, - } - } - if (typeof caCert?.name !== 'undefined') { - database.caCert = { id: caCert?.name } - } - - if (clientAuth) { - if (typeof clientCert.new !== 'undefined') { - database.clientCert = { - name: clientCert.new.name, - certificate: clientCert.new.certificate, - key: clientCert.new.key, - } - } - - if (typeof clientCert.id !== 'undefined') { - database.clientCert = { id: clientCert.id } - } - } - } + // add tls & ssh for database (modifies database object) + applyTlSDatabase(database, tlsSettings) + applySSHDatabase(database, values) if (connectionType === ConnectionType.Sentinel) { database.sentinelMaster = {} @@ -267,7 +305,7 @@ const InstanceFormWrapper = (props: Props) => { handleEditDatabase(removeEmpty(database)) } - const addDatabase = (tlsSettings, values) => { + const addDatabase = (tlsSettings: any, values: DbConnectionInfo) => { const { name, host, @@ -277,47 +315,13 @@ const InstanceFormWrapper = (props: Props) => { db, sentinelMasterName, sentinelMasterUsername, - sentinelMasterPassword + sentinelMasterPassword, } = values - const database: any = { name, host, port: +port, db: +db, username, password } - - const { - useTls, - servername, - verifyServerCert, - caCert, - clientAuth, - clientCert, - } = tlsSettings - - if (useTls) { - database.tls = useTls - database.tlsServername = servername - database.verifyServerCert = !!verifyServerCert - if (typeof caCert?.new !== 'undefined') { - database.caCert = { - name: caCert?.new.name, - certificate: caCert?.new.certificate, - } - } - if (typeof caCert?.name !== 'undefined') { - database.caCert = { id: caCert?.name } - } - - if (clientAuth) { - if (typeof clientCert.new !== 'undefined') { - database.clientCert = { - name: clientCert.new.name, - certificate: clientCert.new.certificate, - key: clientCert.new.key, - } - } + const database: any = { name, host, port: +port, db: db || 0, username, password } - if (typeof clientCert.id !== 'undefined') { - database.clientCert = { id: clientCert.id } - } - } - } + // add tls & ssh for database (modifies database object) + applyTlSDatabase(database, tlsSettings) + applySSHDatabase(database, values) if (isCloneMode && connectionType === ConnectionType.Sentinel) { database.sentinelMaster = { @@ -339,7 +343,7 @@ const InstanceFormWrapper = (props: Props) => { onDbAdded() } - const handleConnectionFormSubmit = (values) => { + const handleConnectionFormSubmit = (values: DbConnectionInfo) => { const { newCaCert, tls, @@ -426,6 +430,8 @@ const InstanceFormWrapper = (props: Props) => { selectedCaCertName, sentinelMasterUsername, sentinelMasterPassword, + ssh, + sshPassType } const getSubmitButtonText = () => { diff --git a/redisinsight/ui/src/pages/home/styles.scss b/redisinsight/ui/src/pages/home/styles.scss index d9724c8422..1483eef40d 100644 --- a/redisinsight/ui/src/pages/home/styles.scss +++ b/redisinsight/ui/src/pages/home/styles.scss @@ -265,7 +265,9 @@ } .euiFormRow__text { - position: absolute; + position: relative; + top: -10px; + margin-bottom: -10px; } .euiFormHelpText { diff --git a/redisinsight/ui/src/slices/interfaces/instances.ts b/redisinsight/ui/src/slices/interfaces/instances.ts index 3098b451cc..e9ff40ed8d 100644 --- a/redisinsight/ui/src/slices/interfaces/instances.ts +++ b/redisinsight/ui/src/slices/interfaces/instances.ts @@ -29,6 +29,15 @@ export interface Instance extends DatabaseInstanceResponse { name?: string db?: number tls?: boolean + ssh?: boolean + sshOptions?: { + host: string + port: number + username?: string + password?: string + privateKey?: string + passphrase?: string + } tlsClientAuthRequired?: boolean verifyServerCert?: boolean caCert?: CaCertificate diff --git a/redisinsight/ui/src/styles/base/_inputs.scss b/redisinsight/ui/src/styles/base/_inputs.scss index cc4f1d0b12..ab4bd8d69b 100644 --- a/redisinsight/ui/src/styles/base/_inputs.scss +++ b/redisinsight/ui/src/styles/base/_inputs.scss @@ -25,7 +25,9 @@ textarea::placeholder { input[type='password'] ~ .euiFormControlLayoutIcons, input[name='password'] ~ .euiFormControlLayoutIcons, -input[name='sentinelMasterPassword'] ~ .euiFormControlLayoutIcons { +input[name='sentinelMasterPassword'] ~ .euiFormControlLayoutIcons, +input[name='sshPassword'] ~ .euiFormControlLayoutIcons, +input[name='sshPassphrase'] ~ .euiFormControlLayoutIcons { display: none; } diff --git a/redisinsight/ui/src/styles/components/_radio.scss b/redisinsight/ui/src/styles/components/_radio.scss index bfd319c01a..8376fcad93 100644 --- a/redisinsight/ui/src/styles/components/_radio.scss +++ b/redisinsight/ui/src/styles/components/_radio.scss @@ -11,3 +11,11 @@ box-shadow: inset 0 0 0 5px var(--euiColorPrimary); border: none; } + +.main-container { + .euiRadio .euiRadio__input:focus + .euiRadio__circle, + .euiRadio .euiRadio__input:active:not(:disabled) + .euiRadio__circle { + animation: none !important; + } +} + From 758bad798e33ba7316852254be9d9e6184d06f68 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Fri, 6 Jan 2023 17:37:59 +0300 Subject: [PATCH 106/201] #RI-3975 - fix port --- .../components/AddInstanceForm/InstanceForm/InstanceForm.tsx | 3 ++- .../components/AddInstanceForm/InstanceForm/interfaces.ts | 4 ++-- .../home/components/AddInstanceForm/InstanceFormWrapper.tsx | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx index 6e13ce5202..03260427a3 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx @@ -36,7 +36,8 @@ import { ADD_NEW, fieldDisplayNames, optionsCertsCA, - optionsCertsClient, SshPassType + optionsCertsClient, + SshPassType } from './constants' import { DbConnectionInfo, ISubmitButton } from './interfaces' diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/interfaces.ts b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/interfaces.ts index c9874dc76d..365eff0d2a 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/interfaces.ts +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/interfaces.ts @@ -24,8 +24,8 @@ export interface DbConnectionInfo extends Instance { sentinelMasterName?: string ssh?: boolean sshPassType?: string - sshHost?: string - sshPort?: number + sshHost: string + sshPort: string sshUsername?: string sshPassword?: string sshPrivateKey?: string diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx index cba6b7fd60..c9ad4e0ec6 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx @@ -256,7 +256,7 @@ const InstanceFormWrapper = (props: Props) => { database.ssh = true database.sshOptions = { host: sshHost, - port: sshPort, + port: +sshPort, username: sshUsername, } From 2e81f0ec12751e7ddc511bab787318ed268dfbc8 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Fri, 6 Jan 2023 17:56:15 +0300 Subject: [PATCH 107/201] #RI-3975 - fix certificates options duplication --- .../InstanceForm/InstanceForm.tsx | 24 ++++------ .../AddInstanceForm/InstanceForm/constants.ts | 20 --------- .../form-components/TlsDetails.tsx | 44 ++++++++++++++++--- 3 files changed, 47 insertions(+), 41 deletions(-) diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx index 03260427a3..9c338e150d 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx @@ -35,8 +35,6 @@ import { NO_CA_CERT, ADD_NEW, fieldDisplayNames, - optionsCertsCA, - optionsCertsClient, SshPassType } from './constants' @@ -312,20 +310,6 @@ const AddStandaloneForm = (props: Props) => { }, []) - caCertificates?.forEach((cert) => { - optionsCertsCA.push({ - value: cert.id, - inputDisplay: cert.name, - }) - }) - - certificates?.forEach((cert) => { - optionsCertsClient.push({ - value: `${cert.id}`, - inputDisplay: cert.name, - }) - }) - const handleCheckConnectToInstance = () => { const modulesSummary = getRedisModulesSummary(modules) sendEventTelemetry({ @@ -521,6 +505,8 @@ const AddStandaloneForm = (props: Props) => { formik={formik} flexItemClassName={flexItemClassName} flexGroupClassName={flexGroupClassName} + certificates={certificates} + caCertificates={caCertificates} /> {instanceType !== InstanceType.Sentinel && ( { formik={formik} flexItemClassName={flexItemClassName} flexGroupClassName={flexGroupClassName} + certificates={certificates} + caCertificates={caCertificates} /> { formik={formik} flexItemClassName={flexItemClassName} flexGroupClassName={flexGroupClassName} + certificates={certificates} + caCertificates={caCertificates} /> @@ -692,6 +682,8 @@ const AddStandaloneForm = (props: Props) => { formik={formik} flexItemClassName={flexItemClassName} flexGroupClassName={flexGroupClassName} + certificates={certificates} + caCertificates={caCertificates} /> )} diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts index a921c83f66..7a5b7c612e 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts @@ -1,27 +1,7 @@ -import { EuiSuperSelectOption } from '@elastic/eui' - export const ADD_NEW_CA_CERT = 'ADD_NEW_CA_CERT' export const NO_CA_CERT = 'NO_CA_CERT' export const ADD_NEW = 'ADD_NEW' -export const optionsCertsCA: EuiSuperSelectOption[] = [ - { - value: NO_CA_CERT, - inputDisplay: 'No CA Certificate', - }, - { - value: ADD_NEW_CA_CERT, - inputDisplay: 'Add new CA certificate', - }, -] - -export const optionsCertsClient: EuiSuperSelectOption[] = [ - { - value: 'ADD_NEW', - inputDisplay: 'Add new certificate', - }, -] - export enum SshPassType { Password = 'password', PrivateKey = 'privateKey' diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx index 8528534e6d..d30b5d3396 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx @@ -5,7 +5,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiSuperSelect, EuiTextArea, + EuiSuperSelect, + EuiSuperSelectOption, + EuiTextArea, htmlIdGenerator } from '@elastic/eui' import cx from 'classnames' @@ -13,9 +15,7 @@ import { validateCertName, validateField } from 'uiSrc/utils' import { FormikProps } from 'formik' import { ADD_NEW_CA_CERT, - NO_CA_CERT, - optionsCertsCA, - optionsCertsClient + NO_CA_CERT } from '../constants' import { DbConnectionInfo } from '../interfaces' @@ -25,9 +25,43 @@ export interface Props { flexGroupClassName?: string flexItemClassName?: string formik: FormikProps + caCertificates?: { id: string; name: string }[] + certificates?: { id: number; name: string }[] } const TlsDetails = (props: Props) => { - const { flexGroupClassName = '', flexItemClassName = '', formik } = props + const { flexGroupClassName = '', flexItemClassName = '', formik, caCertificates, certificates } = props + + const optionsCertsCA: EuiSuperSelectOption[] = [ + { + value: NO_CA_CERT, + inputDisplay: 'No CA Certificate', + }, + { + value: ADD_NEW_CA_CERT, + inputDisplay: 'Add new CA certificate', + }, + ] + + caCertificates?.forEach((cert) => { + optionsCertsCA.push({ + value: cert.id, + inputDisplay: cert.name, + }) + }) + + const optionsCertsClient: EuiSuperSelectOption[] = [ + { + value: 'ADD_NEW', + inputDisplay: 'Add new certificate', + }, + ] + + certificates?.forEach((cert) => { + optionsCertsClient.push({ + value: `${cert.id}`, + inputDisplay: cert.name, + }) + }) return ( <> From 6153c79e0b2c2b53ad0683379d69e93cd3fdc889 Mon Sep 17 00:00:00 2001 From: Artem Date: Fri, 6 Jan 2023 19:40:22 +0200 Subject: [PATCH 108/201] #RI-3974 ssh tunneling base implementation --- redisinsight/api/config/ormconfig.ts | 2 + redisinsight/api/package.json | 3 + redisinsight/api/src/core.module.ts | 3 + .../database/dto/create.database.dto.ts | 25 +++++- .../database/dto/update.database.dto.ts | 52 ++++++++++++ .../database/entities/database.entity.ts | 22 ++++- .../src/modules/database/models/database.ts | 21 +++++ .../repositories/local.database.repository.ts | 80 +++++++++++++++++-- .../modules/profiler/models/redis.observer.ts | 21 ----- .../providers/redis-observer.provider.ts | 2 +- .../modules/redis/redis-connection.factory.ts | 74 ++++++++++++----- .../ssh/dto/create.basic-ssh-options.dto.ts | 4 + .../ssh/dto/create.cert-ssh-options.dto.ts | 4 + .../ssh/entities/ssh-options.entity.ts | 51 ++++++++++++ .../api/src/modules/ssh/exceptions/index.ts | 4 + .../tunnel-connection-lost.exception.ts | 12 +++ ...unable-to-create-local-server.exception.ts | 12 +++ ...able-to-create-ssh-connection.exception.ts | 12 +++ .../unable-to-create-tunnel.exception.ts | 12 +++ .../api/src/modules/ssh/models/ssh-options.ts | 77 ++++++++++++++++++ .../api/src/modules/ssh/models/ssh-tunnel.ts | 78 ++++++++++++++++++ .../src/modules/ssh/ssh-tunnel.provider.ts | 67 ++++++++++++++++ .../api/src/modules/ssh/ssh.module.ts | 8 ++ .../transformers/ssh-options.transformer.ts | 11 +++ redisinsight/api/yarn.lock | 70 +++++++++++++++- 25 files changed, 673 insertions(+), 54 deletions(-) create mode 100644 redisinsight/api/src/modules/ssh/dto/create.basic-ssh-options.dto.ts create mode 100644 redisinsight/api/src/modules/ssh/dto/create.cert-ssh-options.dto.ts create mode 100644 redisinsight/api/src/modules/ssh/entities/ssh-options.entity.ts create mode 100644 redisinsight/api/src/modules/ssh/exceptions/index.ts create mode 100644 redisinsight/api/src/modules/ssh/exceptions/tunnel-connection-lost.exception.ts create mode 100644 redisinsight/api/src/modules/ssh/exceptions/unable-to-create-local-server.exception.ts create mode 100644 redisinsight/api/src/modules/ssh/exceptions/unable-to-create-ssh-connection.exception.ts create mode 100644 redisinsight/api/src/modules/ssh/exceptions/unable-to-create-tunnel.exception.ts create mode 100644 redisinsight/api/src/modules/ssh/models/ssh-options.ts create mode 100644 redisinsight/api/src/modules/ssh/models/ssh-tunnel.ts create mode 100644 redisinsight/api/src/modules/ssh/ssh-tunnel.provider.ts create mode 100644 redisinsight/api/src/modules/ssh/ssh.module.ts create mode 100644 redisinsight/api/src/modules/ssh/transformers/ssh-options.transformer.ts diff --git a/redisinsight/api/config/ormconfig.ts b/redisinsight/api/config/ormconfig.ts index f71bbdccd4..4e38c3f1fc 100644 --- a/redisinsight/api/config/ormconfig.ts +++ b/redisinsight/api/config/ormconfig.ts @@ -10,6 +10,7 @@ import { SettingsEntity } from 'src/modules/settings/entities/settings.entity'; import { CaCertificateEntity } from 'src/modules/certificate/entities/ca-certificate.entity'; import { ClientCertificateEntity } from 'src/modules/certificate/entities/client-certificate.entity'; import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; +import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; import migrations from '../migration'; import * as config from '../src/utils/config'; @@ -31,6 +32,7 @@ const ormConfig = { PluginStateEntity, NotificationEntity, DatabaseAnalysisEntity, + SshOptionsEntity, ], migrations, }; diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 5ab3dec344..73d6ba7e56 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -53,6 +53,7 @@ "body-parser": "^1.19.0", "class-transformer": "^0.2.3", "class-validator": "^0.12.2", + "detect-port": "^1.5.1", "dotenv": "^16.0.0", "express": "^4.17.1", "fs-extra": "^10.0.0", @@ -67,6 +68,7 @@ "socket.io": "^4.4.0", "source-map-support": "^0.5.19", "sqlite3": "^5.0.11", + "ssh2": "^1.11.0", "swagger-ui-express": "^4.1.4", "typeorm": "^0.3.9", "uuid": "^8.3.2", @@ -84,6 +86,7 @@ "@types/lodash": "^4.14.167", "@types/node": "14.14.10", "@types/socket.io": "^3.0.2", + "@types/ssh2": "^1.11.6", "@types/supertest": "^2.0.8", "@typescript-eslint/eslint-plugin": "^4.8.1", "@typescript-eslint/parser": "^4.8.1", diff --git a/redisinsight/api/src/core.module.ts b/redisinsight/api/src/core.module.ts index c9c91ee1b6..d549235f20 100644 --- a/redisinsight/api/src/core.module.ts +++ b/redisinsight/api/src/core.module.ts @@ -6,6 +6,7 @@ import { CertificateModule } from 'src/modules/certificate/certificate.module'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { RedisModule } from 'src/modules/redis/redis.module'; import { AnalyticsModule } from 'src/modules/analytics/analytics.module'; +import { SshModule } from 'src/modules/ssh/ssh.module'; @Global() @Module({ @@ -17,6 +18,7 @@ import { AnalyticsModule } from 'src/modules/analytics/analytics.module'; CertificateModule.register(), DatabaseModule.register(), RedisModule, + SshModule, ], exports: [ EncryptionModule, @@ -24,6 +26,7 @@ import { AnalyticsModule } from 'src/modules/analytics/analytics.module'; CertificateModule, DatabaseModule, RedisModule, + SshModule, ], }) export class CoreModule {} 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 94dac062a9..f23c4b3a8c 100644 --- a/redisinsight/api/src/modules/database/dto/create.database.dto.ts +++ b/redisinsight/api/src/modules/database/dto/create.database.dto.ts @@ -12,11 +12,18 @@ import { UseCaCertificateDto } from 'src/modules/certificate/dto/use.ca-certific import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-certificate.dto'; import { caCertTransformer } from 'src/modules/certificate/transformers/ca-cert.transformer'; import { clientCertTransformer } from 'src/modules/certificate/transformers/client-cert.transformer'; +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'; -@ApiExtraModels(CreateCaCertificateDto, UseCaCertificateDto, CreateClientCertificateDto, UseClientCertificateDto) +@ApiExtraModels( + CreateCaCertificateDto, UseCaCertificateDto, + CreateClientCertificateDto, UseClientCertificateDto, + CreateBasicSshOptionsDto, CreateCertSshOptionsDto, +) export class CreateDatabaseDto extends PickType(Database, [ 'host', 'port', 'name', 'db', 'username', 'password', 'nameFromProvider', 'provider', - 'tls', 'tlsServername', 'verifyServerCert', 'sentinelMaster', + 'tls', 'tlsServername', 'verifyServerCert', 'sentinelMaster', 'ssh', ] as const) { @ApiPropertyOptional({ description: 'CA Certificate', @@ -45,4 +52,18 @@ export class CreateDatabaseDto extends PickType(Database, [ @Type(clientCertTransformer) @ValidateNested() clientCert?: CreateClientCertificateDto | UseClientCertificateDto; + + @ApiPropertyOptional({ + description: 'SSH Options', + oneOf: [ + { $ref: getSchemaPath(CreateBasicSshOptionsDto) }, + { $ref: getSchemaPath(CreateCertSshOptionsDto) }, + ], + }) + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(sshOptionsTransformer) + @ValidateNested() + sshOptions?: CreateBasicSshOptionsDto | CreateCertSshOptionsDto; } diff --git a/redisinsight/api/src/modules/database/dto/update.database.dto.ts b/redisinsight/api/src/modules/database/dto/update.database.dto.ts index 9946f1a5cf..12a111e8a3 100644 --- a/redisinsight/api/src/modules/database/dto/update.database.dto.ts +++ b/redisinsight/api/src/modules/database/dto/update.database.dto.ts @@ -13,6 +13,9 @@ import { clientCertTransformer } from 'src/modules/certificate/transformers/clie import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-certificate.dto'; import { SentinelMaster } from 'src/modules/redis-sentinel/models/sentinel-master'; import { CreateDatabaseDto } from 'src/modules/database/dto/create.database.dto'; +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'; export class UpdateDatabaseDto extends CreateDatabaseDto { @ValidateIf((object, value) => value !== undefined) @@ -28,6 +31,31 @@ export class UpdateDatabaseDto extends CreateDatabaseDto { @IsInt({ always: true }) port: number; + @ApiPropertyOptional({ + description: + 'Database username, if your database is ACL enabled, otherwise leave this field empty.', + type: String, + }) + @Expose() + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + @Default(null) + username?: string; + + @ApiPropertyOptional({ + description: + 'The password, if any, for your Redis database. ' + + 'If your database doesn’t require a password, leave this field empty.', + type: String, + }) + @Expose() + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + @Default(null) + password?: string; + @ApiPropertyOptional({ description: 'Logical database number.', type: Number, @@ -47,6 +75,15 @@ export class UpdateDatabaseDto extends CreateDatabaseDto { @Default(false) tls?: boolean; + @ApiPropertyOptional({ + description: 'Use SSH to connect.', + type: Boolean, + }) + @IsBoolean() + @IsOptional() + @Default(false) + ssh?: boolean; + @ApiPropertyOptional({ description: 'SNI servername', type: String, @@ -105,4 +142,19 @@ export class UpdateDatabaseDto extends CreateDatabaseDto { @ValidateNested() @Default(null) sentinelMaster?: SentinelMaster; + + @ApiPropertyOptional({ + description: 'SSH Options', + oneOf: [ + { $ref: getSchemaPath(CreateBasicSshOptionsDto) }, + { $ref: getSchemaPath(CreateCertSshOptionsDto) }, + ], + }) + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(sshOptionsTransformer) + @ValidateNested() + @Default(null) + sshOptions?: CreateBasicSshOptionsDto | CreateCertSshOptionsDto; } diff --git a/redisinsight/api/src/modules/database/entities/database.entity.ts b/redisinsight/api/src/modules/database/entities/database.entity.ts index 6c193c605b..1d137039a8 100644 --- a/redisinsight/api/src/modules/database/entities/database.entity.ts +++ b/redisinsight/api/src/modules/database/entities/database.entity.ts @@ -1,11 +1,12 @@ import { - Column, Entity, ManyToOne, PrimaryGeneratedColumn, + Column, Entity, ManyToOne, OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; import { CaCertificateEntity } from 'src/modules/certificate/entities/ca-certificate.entity'; import { ClientCertificateEntity } from 'src/modules/certificate/entities/client-certificate.entity'; import { DataAsJsonString } from 'src/common/decorators'; -import { Expose, Transform } from 'class-transformer'; +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'; export enum HostingProvider { UNKNOWN = 'UNKNOWN', @@ -162,4 +163,21 @@ export class DatabaseEntity { @Expose() @Column({ nullable: true }) new: boolean; + + @Expose() + @Column({ nullable: true }) + ssh: boolean; + + @Expose() + @OneToOne( + () => SshOptionsEntity, + (sshOptions) => sshOptions.database, + { + eager: true, + onDelete: 'CASCADE', + cascade: true, + }, + ) + @Type(() => SshOptionsEntity) + sshOptions: SshOptionsEntity; } diff --git a/redisinsight/api/src/modules/database/models/database.ts b/redisinsight/api/src/modules/database/models/database.ts index 5e6b4c029c..b0054b9d62 100644 --- a/redisinsight/api/src/modules/database/models/database.ts +++ b/redisinsight/api/src/modules/database/models/database.ts @@ -17,6 +17,7 @@ import { import { SentinelMaster } from 'src/modules/redis-sentinel/models/sentinel-master'; 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'; export class Database { @ApiProperty({ @@ -215,4 +216,24 @@ export class Database { @IsOptional() @IsBoolean({ always: true }) new?: boolean; + + @ApiPropertyOptional({ + description: 'Use SSH tunnel to connect.', + type: Boolean, + }) + @Expose() + @IsBoolean() + @IsOptional() + ssh?: boolean; + + @ApiPropertyOptional({ + description: 'SSH options', + type: SshOptions, + }) + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(() => SshOptions) + @ValidateNested() + sshOptions?: SshOptions; } 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 b0f82d6e59..cd5aa3dfba 100644 --- a/redisinsight/api/src/modules/database/repositories/local.database.repository.ts +++ b/redisinsight/api/src/modules/database/repositories/local.database.repository.ts @@ -9,20 +9,29 @@ import { EncryptionService } from 'src/modules/encryption/encryption.service'; import { ModelEncryptor } from 'src/modules/encryption/model.encryptor'; import { CaCertificateRepository } from 'src/modules/certificate/repositories/ca-certificate.repository'; import { ClientCertificateRepository } from 'src/modules/certificate/repositories/client-certificate.repository'; +import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; @Injectable() export class LocalDatabaseRepository extends DatabaseRepository { private readonly modelEncryptor: ModelEncryptor; + private readonly sshModelEncryptor: ModelEncryptor; + constructor( @InjectRepository(DatabaseEntity) private readonly repository: Repository, + @InjectRepository(SshOptionsEntity) + private readonly sshOptionsRepository: Repository, private readonly caCertificateRepository: CaCertificateRepository, private readonly clientCertificateRepository: ClientCertificateRepository, private readonly encryptionService: EncryptionService, ) { super(); this.modelEncryptor = new ModelEncryptor(encryptionService, ['password', 'sentinelMasterPassword']); + this.sshModelEncryptor = new ModelEncryptor(encryptionService, [ + 'username', 'password', + 'privateKey', 'passphrase', + ]); } /** @@ -47,7 +56,7 @@ export class LocalDatabaseRepository extends DatabaseRepository { if (!entity) { return null; } - const model = classToClass(Database, await this.modelEncryptor.decryptEntity(entity, ignoreEncryptionErrors)); + const model = classToClass(Database, await this.decryptEntity(entity, ignoreEncryptionErrors)); if (entity.caCert) { model.caCert = await this.caCertificateRepository.get(entity.caCert.id); @@ -83,9 +92,9 @@ export class LocalDatabaseRepository extends DatabaseRepository { const entity = classToClass(DatabaseEntity, await this.populateCertificates(database)); return classToClass( Database, - await this.modelEncryptor.decryptEntity( + await this.decryptEntity( await this.repository.save( - await this.modelEncryptor.encryptEntity(entity), + await this.encryptEntity(entity), ), ), ); @@ -101,8 +110,35 @@ export class LocalDatabaseRepository extends DatabaseRepository { * @throws TBD */ public async update(id: string, database: Partial): Promise { - const entity = classToClass(DatabaseEntity, await this.populateCertificates(database as Database)); - await this.repository.update(id, await this.modelEncryptor.encryptEntity(entity)); + const oldEntity = await this.decryptEntity((await this.repository.findOneBy({ id })), true); + const newEntity = classToClass(DatabaseEntity, await this.populateCertificates(database as Database)); + + const mergeResult = this.repository.merge(oldEntity, newEntity); + + if (newEntity.caCert === null) { + mergeResult.caCert = null; + } + + if (newEntity.clientCert === null) { + mergeResult.clientCert = null; + } + + if (newEntity.sshOptions === null) { + mergeResult.sshOptions = null; + } + + const encrypted = await this.encryptEntity(mergeResult); + + await this.repository.save(encrypted); + + // workaround for one way cascade deletion + if (newEntity.sshOptions === null) { + await this.sshOptionsRepository.createQueryBuilder() + .delete() + .where('databaseId IS NULL') + .execute(); + } + return this.get(id); } @@ -134,4 +170,38 @@ export class LocalDatabaseRepository extends DatabaseRepository { return model; } + + /** + * Encrypt Database entity and SshOptions entity if present + * @param entity + * @private + */ + private async encryptEntity(entity: DatabaseEntity): Promise { + const encryptedEntity = await this.modelEncryptor.encryptEntity(entity); + + if (encryptedEntity.sshOptions) { + encryptedEntity.sshOptions = await this.sshModelEncryptor.encryptEntity(encryptedEntity.sshOptions); + } + + return encryptedEntity; + } + + /** + * Decrypt Database entity and SshOptions entity if present + * @param entity + * @param ignoreEncryptionErrors + * @private + */ + private async decryptEntity(entity: DatabaseEntity, ignoreEncryptionErrors = false): Promise { + const decryptedEntity = await this.modelEncryptor.decryptEntity(entity, ignoreEncryptionErrors); + + if (decryptedEntity.sshOptions) { + decryptedEntity.sshOptions = await this.sshModelEncryptor.decryptEntity( + decryptedEntity.sshOptions, + ignoreEncryptionErrors, + ); + } + + return decryptedEntity; + } } diff --git a/redisinsight/api/src/modules/profiler/models/redis.observer.ts b/redisinsight/api/src/modules/profiler/models/redis.observer.ts index f4a423c9f9..03fbca14f1 100644 --- a/redisinsight/api/src/modules/profiler/models/redis.observer.ts +++ b/redisinsight/api/src/modules/profiler/models/redis.observer.ts @@ -197,27 +197,6 @@ export class RedisObserver extends EventEmitter2 { * @param redis */ static async createShardObserver(redis: IORedis.Redis): Promise { - await RedisObserver.isMonitorAvailable(redis); return await redis.monitor() as IShardObserver; } - - /** - * HACK: ioredis do not handle error when a user has no permissions to run the 'monitor' command - * Here we try to send "monitor" command directly to throw error (like NOPERM) if any - * @param redis - */ - static async isMonitorAvailable(redis: IORedis.Redis): Promise { - // @ts-ignore - const duplicate = redis.duplicate({ - ...redis.options, - monitor: false, - lazyConnect: false, - connectionName: `redisinsight-monitor-perm-check-${Math.random()}`, - }); - - await duplicate.call('monitor'); - duplicate.disconnect(); - - return true; - } } diff --git a/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.ts b/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.ts index e08928579c..e2993fadb6 100644 --- a/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.ts +++ b/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.ts @@ -105,7 +105,7 @@ export class RedisObserverProvider { */ private getRedisClientFn(clientMetadata: ClientMetadata): () => Promise { return async () => withTimeout( - this.databaseConnectionService.getOrCreateClient(clientMetadata), + this.databaseConnectionService.createClient(clientMetadata), serverConfig.requestTimeout, new ServiceUnavailableException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), ); diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.ts index cd2240927a..ad66b80f39 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.ts @@ -8,6 +8,8 @@ import { cloneClassInstance, generateRedisConnectionName } from 'src/utils'; import { ConnectionType } from 'src/modules/database/entities/database.entity'; 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'; const REDIS_CLIENTS_CONFIG = apiConfig.get('redis_clients'); @@ -20,6 +22,10 @@ export interface IRedisConnectionOptions { export class RedisConnectionFactory { private logger = new Logger('RedisConnectionFactory'); + constructor( + private readonly sshTunnelProvider: SshTunnelProvider, + ) {} + // common retry strategy private retryStrategy = (times: number): number => { if (times < REDIS_CLIENTS_CONFIG.retryTimes) { @@ -155,30 +161,54 @@ export class RedisConnectionFactory { database: Database, options: IRedisConnectionOptions, ): Promise { - const config = await this.getRedisOptions(clientMetadata, database, options); + let tnl; - return await new Promise((resolve, reject) => { - try { - const connection = new Redis({ - ...config, - // cover cases when we are connecting to sentinel as to standalone to discover master groups - db: config.db > 0 && !database.sentinelMaster ? config.db : 0, - }); - connection.on('error', (e): void => { - this.logger.error('Failed connection to the redis database.', e); - reject(e); - }); - connection.on('ready', (): void => { - this.logger.log('Successfully connected to the redis database'); - resolve(connection); - }); - connection.on('reconnecting', (): void => { - this.logger.log('Reconnecting to the redis database'); - }); - } catch (e) { - reject(e); + try { + const config = await this.getRedisOptions(clientMetadata, database, options); + + if (database.ssh) { + tnl = await this.sshTunnelProvider.createTunnel(database); } - }) as Redis; + + return await new Promise((resolve, reject) => { + try { + if (tnl) { + tnl.on('error', (error) => { + reject(error); + }); + + tnl.on('close', () => { + reject(new TunnelConnectionLostException()); + }); + + config.host = tnl.serverAddress.host; + config.port = tnl.serverAddress.port; + } + + const connection = new Redis({ + ...config, + // cover cases when we are connecting to sentinel as to standalone to discover master groups + db: config.db > 0 && !database.sentinelMaster ? config.db : 0, + }); + connection.on('error', (e): void => { + this.logger.error('Failed connection to the redis database.', e); + reject(e); + }); + connection.on('ready', (): void => { + this.logger.log('Successfully connected to the redis database'); + resolve(connection); + }); + connection.on('reconnecting', (): void => { + this.logger.log('Reconnecting to the redis database'); + }); + } catch (e) { + reject(e); + } + }) as Redis; + } catch (e) { + tnl?.close?.(); + throw e; + } } /** diff --git a/redisinsight/api/src/modules/ssh/dto/create.basic-ssh-options.dto.ts b/redisinsight/api/src/modules/ssh/dto/create.basic-ssh-options.dto.ts new file mode 100644 index 0000000000..a5e2decaae --- /dev/null +++ b/redisinsight/api/src/modules/ssh/dto/create.basic-ssh-options.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { SshOptions } from 'src/modules/ssh/models/ssh-options'; + +export class CreateBasicSshOptionsDto extends OmitType(SshOptions, ['privateKey', 'passphrase'] as const) {} diff --git a/redisinsight/api/src/modules/ssh/dto/create.cert-ssh-options.dto.ts b/redisinsight/api/src/modules/ssh/dto/create.cert-ssh-options.dto.ts new file mode 100644 index 0000000000..901392f108 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/dto/create.cert-ssh-options.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { SshOptions } from 'src/modules/ssh/models/ssh-options'; + +export class CreateCertSshOptionsDto extends OmitType(SshOptions, ['password'] as const) {} diff --git a/redisinsight/api/src/modules/ssh/entities/ssh-options.entity.ts b/redisinsight/api/src/modules/ssh/entities/ssh-options.entity.ts new file mode 100644 index 0000000000..d39163caac --- /dev/null +++ b/redisinsight/api/src/modules/ssh/entities/ssh-options.entity.ts @@ -0,0 +1,51 @@ +import { + Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn, +} from 'typeorm'; +import { Expose } from 'class-transformer'; +import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; + +@Entity('ssh_options') +export class SshOptionsEntity { + @Expose() + @PrimaryGeneratedColumn('uuid') + id: string; + + @Expose() + @Column({ nullable: false }) + host: string; + + @Expose() + @Column({ nullable: false }) + port: number; + + @Expose() + @Column({ nullable: true }) + encryption: string; + + @Expose() + @Column({ nullable: true }) + username: string; + + @Expose() + @Column({ nullable: true }) + password: string; + + @Expose() + @Column({ nullable: true }) + privateKey: string; + + @Expose() + @Column({ nullable: true }) + passphrase: string; + + @OneToOne( + () => DatabaseEntity, + (database) => database.sshOptions, + { + nullable: true, + onDelete: 'CASCADE', + }, + ) + @JoinColumn() + database: DatabaseEntity; +} diff --git a/redisinsight/api/src/modules/ssh/exceptions/index.ts b/redisinsight/api/src/modules/ssh/exceptions/index.ts new file mode 100644 index 0000000000..ae6dfb8cc0 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/exceptions/index.ts @@ -0,0 +1,4 @@ +export * from './unable-to-create-ssh-connection.exception'; +export * from './unable-to-create-tunnel.exception'; +export * from './tunnel-connection-lost.exception'; +export * from './unable-to-create-local-server.exception'; diff --git a/redisinsight/api/src/modules/ssh/exceptions/tunnel-connection-lost.exception.ts b/redisinsight/api/src/modules/ssh/exceptions/tunnel-connection-lost.exception.ts new file mode 100644 index 0000000000..cc453a7b8a --- /dev/null +++ b/redisinsight/api/src/modules/ssh/exceptions/tunnel-connection-lost.exception.ts @@ -0,0 +1,12 @@ +import { HttpException } from '@nestjs/common'; + +export class TunnelConnectionLostException extends HttpException { + constructor(message = '') { + const prepend = 'Tunnel connection was lost.'; + super({ + message: `${prepend} ${message}`, + name: 'TunnelConnectionLostException', + statusCode: 500, + }, 500); + } +} diff --git a/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-local-server.exception.ts b/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-local-server.exception.ts new file mode 100644 index 0000000000..1c022d38c3 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-local-server.exception.ts @@ -0,0 +1,12 @@ +import { HttpException } from '@nestjs/common'; + +export class UnableToCreateLocalServerException extends HttpException { + constructor(message = '') { + const prepend = 'Unable to create local server.'; + super({ + message: `${prepend} ${message}`, + name: 'UnableToCreateLocalServerException', + statusCode: 500, + }, 500); + } +} diff --git a/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-ssh-connection.exception.ts b/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-ssh-connection.exception.ts new file mode 100644 index 0000000000..6e2daa68e7 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-ssh-connection.exception.ts @@ -0,0 +1,12 @@ +import { HttpException } from '@nestjs/common'; + +export class UnableToCreateSshConnectionException extends HttpException { + constructor(message = '') { + const prepend = 'Unable to create ssh connection.'; + super({ + message: `${prepend} ${message}`, + name: 'UnableToCreateSshConnectionException', + statusCode: 503, + }, 503); + } +} diff --git a/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-tunnel.exception.ts b/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-tunnel.exception.ts new file mode 100644 index 0000000000..0c7addc967 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-tunnel.exception.ts @@ -0,0 +1,12 @@ +import { HttpException } from '@nestjs/common'; + +export class UnableToCreateTunnelException extends HttpException { + constructor(message = '') { + const prepend = 'Unable to create tunnel.'; + super({ + message: `${prepend} ${message}`, + name: 'UnableToCreateTunnelException', + statusCode: 500, + }, 500); + } +} diff --git a/redisinsight/api/src/modules/ssh/models/ssh-options.ts b/redisinsight/api/src/modules/ssh/models/ssh-options.ts new file mode 100644 index 0000000000..4b75b6331f --- /dev/null +++ b/redisinsight/api/src/modules/ssh/models/ssh-options.ts @@ -0,0 +1,77 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; +import { Default } from 'src/common/decorators'; + +export class SshOptions { + @ApiProperty({ + description: 'The hostname of SSH server', + type: String, + default: 'localhost', + }) + @Expose() + @IsNotEmpty() + @IsString({ always: true }) + @Default(null) + host: string; + + @ApiProperty({ + description: 'The port of SSH server', + type: Number, + default: 22, + }) + @Expose() + @IsNotEmpty() + @IsInt({ always: true }) + @Default(null) + port: number; + + @ApiPropertyOptional({ + description: 'SSH username', + type: String, + }) + @Expose() + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + @Default(null) + username?: string; + + @ApiPropertyOptional({ + description: 'The SSH password', + type: String, + }) + @Expose() + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + @Default(null) + password?: string; + + @ApiPropertyOptional({ + description: 'The SSH private key', + type: String, + }) + @Expose() + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + @Default(null) + privateKey?: string; + + @ApiPropertyOptional({ + description: 'The SSH passphrase', + type: String, + }) + @Expose() + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + @Default(null) + passphrase?: string; +} diff --git a/redisinsight/api/src/modules/ssh/models/ssh-tunnel.ts b/redisinsight/api/src/modules/ssh/models/ssh-tunnel.ts new file mode 100644 index 0000000000..78eab3a21a --- /dev/null +++ b/redisinsight/api/src/modules/ssh/models/ssh-tunnel.ts @@ -0,0 +1,78 @@ +import { AddressInfo, Server } from 'net'; +import { EventEmitter } from 'events'; +import { Client } from 'ssh2'; +import { Endpoint } from 'src/common/models'; +import { UnableToCreateTunnelException } from 'src/modules/ssh/exceptions'; + +export interface ISshTunnelOptions { + targetHost: string, + targetPort: number, +} + +export class SshTunnel extends EventEmitter { + private readonly server: Server; + + private readonly client: Client; + + public readonly serverAddress: Endpoint; + + constructor(server: Server, client: Client, options: ISshTunnelOptions) { + super(); + this.server = server; + this.client = client; + const address = this.server?.address() as AddressInfo; + this.serverAddress = { + host: address?.address, + port: address?.port, + }; + + this.init(options); + } + + public close() { + this.server?.close?.(); + this.client?.end?.(); + this.server?.removeAllListeners?.(); + this.client?.removeAllListeners?.(); + this.emit('close'); + this.removeAllListeners(); + } + + private error(e: Error) { + this.emit('error', e); + } + + private init(options: ISshTunnelOptions) { + this.server.on('close', this.close); + this.client.on('close', this.close); + // close since net server is not being closed automatically when we need this + this.server.on('error', this.close); + this.client.on('error', this.error); + + this.server.on('connection', (connection) => { + this.client.forwardOut( + this.serverAddress?.host, + this.serverAddress?.port, + options.targetHost, + options.targetPort, + (e, stream) => { + if (e) { + return this.emit('error', new UnableToCreateTunnelException(e.message)); + } + + return connection.pipe(stream).pipe(connection); + }, + ); + + connection.on('error', (e) => { + this.client.emit('error', e); + }); + + connection.on('close', () => { + // close server and client connections (entire tunnel) when forward connection was lost + // todo: improve this to keep tunnel connection when there are active forward connections inside + this.close(); + }); + }); + } +} diff --git a/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.ts b/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.ts new file mode 100644 index 0000000000..5ced83a7c6 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.ts @@ -0,0 +1,67 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import * as detectPort from 'detect-port'; +import { Database } from 'src/modules/database/models/database'; +import { Server, createServer } from 'net'; +import { Client } from 'ssh2'; +import { SshTunnel } from 'src/modules/ssh/models/ssh-tunnel'; +import { + UnableToCreateLocalServerException, + UnableToCreateSshConnectionException, + UnableToCreateTunnelException, +} from 'src/modules/ssh/exceptions'; + +@Injectable() +export class SshTunnelProvider { + private async createServer(): Promise { + return new Promise((resolve, reject) => { + try { + const server = createServer(); + + server.on('listening', () => resolve(server)); + server.on('error', (e) => { + reject(new UnableToCreateLocalServerException(e.message)); + }); + + detectPort(50000) + .then((port) => { + server.listen({ + host: '127.0.0.1', + port, + }); + }) + .catch(reject); + } catch (e) { + reject(e); + } + }); + } + + private async createClient(options): Promise { + return new Promise((resolve, reject) => { + const conn = new Client(); + conn.on('ready', () => resolve(conn)); + conn.on('error', (e) => { + reject(new UnableToCreateSshConnectionException(e.message)); + }); + conn.connect(options); + }); + } + + public async createTunnel(database: Database) { + try { + const client = await this.createClient(database?.sshOptions); + const server = await this.createServer(); + + return new SshTunnel(server, client, { + targetHost: database.host, + targetPort: database.port, + }); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + + throw new UnableToCreateTunnelException(e.message); + } + } +} diff --git a/redisinsight/api/src/modules/ssh/ssh.module.ts b/redisinsight/api/src/modules/ssh/ssh.module.ts new file mode 100644 index 0000000000..1b98818cef --- /dev/null +++ b/redisinsight/api/src/modules/ssh/ssh.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SshTunnelProvider } from 'src/modules/ssh/ssh-tunnel.provider'; + +@Module({ + providers: [SshTunnelProvider], + exports: [SshTunnelProvider], +}) +export class SshModule {} diff --git a/redisinsight/api/src/modules/ssh/transformers/ssh-options.transformer.ts b/redisinsight/api/src/modules/ssh/transformers/ssh-options.transformer.ts new file mode 100644 index 0000000000..d089df0ae4 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/transformers/ssh-options.transformer.ts @@ -0,0 +1,11 @@ +import { get } from 'lodash'; +import { TypeHelpOptions } from 'class-transformer'; +import { CreateBasicSshOptionsDto } from 'src/modules/ssh/dto/create.basic-ssh-options.dto'; +import { CreateCertSshOptionsDto } from 'src/modules/ssh/dto/create.cert-ssh-options.dto'; + +export const sshOptionsTransformer = (data: TypeHelpOptions) => { + if (get(data?.object, 'sshOptions.privateKey')) { + return CreateCertSshOptionsDto; + } + return CreateBasicSshOptionsDto; +}; diff --git a/redisinsight/api/yarn.lock b/redisinsight/api/yarn.lock index beaebe7663..c2fe6fe3cc 100644 --- a/redisinsight/api/yarn.lock +++ b/redisinsight/api/yarn.lock @@ -1135,6 +1135,13 @@ dependencies: socket.io "*" +"@types/ssh2@^1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.11.6.tgz#c114d15a3cfd2ba2f7ef219a2020c44f0fb8a01b" + integrity sha512-8Mf6bhzYYBLEB/G6COux7DS/F5bCWwojv/qFo2yH/e4cLzAavJnxvFXrYW59iKfXdhG6OmzJcXDasgOb/s0rxw== + dependencies: + "@types/node" "*" + "@types/stack-utils@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" @@ -1442,6 +1449,11 @@ acorn@^8.5.0, acorn@^8.7.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== +address@^1.0.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/address/-/address-1.2.2.tgz#2b5248dac5485a6390532c6a517fda2e3faac89e" + integrity sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA== + adm-zip@^0.5.9: version "0.5.9" resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.9.tgz#b33691028333821c0cf95c31374c5462f2905a83" @@ -1709,6 +1721,13 @@ arrify@^1.0.0: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= +asn1@^0.2.4: + 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== + dependencies: + safer-buffer "~2.1.0" + assertion-error@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" @@ -1855,6 +1874,13 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" +bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -1995,6 +2021,11 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" +buildcheck@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.3.tgz#70451897a95d80f7807e68fc412eb2e7e35ff4d5" + integrity sha512-pziaA+p/wdVImfcbsZLNF32EiWyujlQLwolMqUQE8xpKNOH7KmZQaY8sXN7DGOEzPAElo9QTaeNRfGnf3iOJbA== + busboy@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" @@ -2545,6 +2576,14 @@ cosmiconfig@^7.0.1: path-type "^4.0.0" yaml "^1.10.0" +cpu-features@~0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.4.tgz#0023475bb4f4c525869c162e4108099e35bf19d8" + integrity sha512-fKiZ/zp1mUwQbnzb9IghXtHtDoTMtNeb8oYGx6kX2SYfhnG0HNdBEBIzB9b5KlXu5DQPhfy3mInbBxFcgwAr3A== + dependencies: + buildcheck "0.0.3" + nan "^2.15.0" + create-require@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" @@ -2798,6 +2837,14 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +detect-port@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/detect-port/-/detect-port-1.5.1.tgz#451ca9b6eaf20451acb0799b8ab40dff7718727b" + integrity sha512-aBzdj76lueB6uUst5iAs7+0H/oOjqI5D16XUWxlWMIMROhcM0rfsNVk93zTngq1dDNpoXRr++Sus7ETAExppAQ== + dependencies: + address "^1.0.1" + debug "4" + diff-sequences@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" @@ -5902,6 +5949,11 @@ mz@^2.4.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nan@^2.15.0, nan@^2.16.0: + version "2.17.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" + integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== + nanoid@3.1.20: version "3.1.20" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788" @@ -7025,7 +7077,7 @@ safe-stable-stringify@^1.1.0: resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-1.1.1.tgz#c8a220ab525cd94e60ebf47ddc404d610dc5d84a" integrity sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw== -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -7522,6 +7574,17 @@ 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== + dependencies: + asn1 "^0.2.4" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.4" + nan "^2.16.0" + ssri@^8.0.0, ssri@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.1.tgz#638e4e439e2ffbd2cd289776d5ca457c4f51a2af" @@ -8118,6 +8181,11 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" +tweetnacl@^0.14.3: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" From b5c9c2e0f63b99daef494e8c267ded30f9ee77d1 Mon Sep 17 00:00:00 2001 From: Artem Date: Fri, 6 Jan 2023 20:45:17 +0200 Subject: [PATCH 109/201] add ssh2 to lazy import --- configs/webpack.config.base.js | 1 + 1 file changed, 1 insertion(+) diff --git a/configs/webpack.config.base.js b/configs/webpack.config.base.js index a58581b672..a2000ade16 100644 --- a/configs/webpack.config.base.js +++ b/configs/webpack.config.base.js @@ -49,6 +49,7 @@ export default { new webpack.IgnorePlugin({ checkResource(resource) { const lazyImports = [ + 'ssh2', '@nestjs/microservices', // '@nestjs/platform-express', // 'pnpapi', From 9118ee3e17b16028d8ccabbb444cd09b40a49b39 Mon Sep 17 00:00:00 2001 From: Artem Date: Fri, 6 Jan 2023 22:21:35 +0200 Subject: [PATCH 110/201] fix deps issue + add migrations --- configs/webpack.config.base.js | 1 - .../migration/1673035852335-ssh-options.ts | 30 +++++++++++ redisinsight/api/migration/index.ts | 2 + redisinsight/package.json | 3 +- redisinsight/yarn.lock | 50 ++++++++++++++++++- 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 redisinsight/api/migration/1673035852335-ssh-options.ts diff --git a/configs/webpack.config.base.js b/configs/webpack.config.base.js index a2000ade16..a58581b672 100644 --- a/configs/webpack.config.base.js +++ b/configs/webpack.config.base.js @@ -49,7 +49,6 @@ export default { new webpack.IgnorePlugin({ checkResource(resource) { const lazyImports = [ - 'ssh2', '@nestjs/microservices', // '@nestjs/platform-express', // 'pnpapi', diff --git a/redisinsight/api/migration/1673035852335-ssh-options.ts b/redisinsight/api/migration/1673035852335-ssh-options.ts new file mode 100644 index 0000000000..0b99e5961a --- /dev/null +++ b/redisinsight/api/migration/1673035852335-ssh-options.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class sshOptions1673035852335 implements MigrationInterface { + name = 'sshOptions1673035852335' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "ssh_options" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "encryption" varchar, "username" varchar, "password" varchar, "privateKey" varchar, "passphrase" varchar, "databaseId" varchar, CONSTRAINT "REL_fe3c3f8b1246e4824a3fb83047" UNIQUE ("databaseId"))`); + 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, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_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") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + await queryRunner.query(`CREATE TABLE "temporary_ssh_options" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "encryption" varchar, "username" varchar, "password" varchar, "privateKey" varchar, "passphrase" varchar, "databaseId" varchar, CONSTRAINT "REL_fe3c3f8b1246e4824a3fb83047" UNIQUE ("databaseId"), CONSTRAINT "FK_fe3c3f8b1246e4824a3fb83047d" FOREIGN KEY ("databaseId") REFERENCES "database_instance" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_ssh_options"("id", "host", "port", "encryption", "username", "password", "privateKey", "passphrase", "databaseId") SELECT "id", "host", "port", "encryption", "username", "password", "privateKey", "passphrase", "databaseId" FROM "ssh_options"`); + await queryRunner.query(`DROP TABLE "ssh_options"`); + await queryRunner.query(`ALTER TABLE "temporary_ssh_options" RENAME TO "ssh_options"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "ssh_options" RENAME TO "temporary_ssh_options"`); + await queryRunner.query(`CREATE TABLE "ssh_options" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "encryption" varchar, "username" varchar, "password" varchar, "privateKey" varchar, "passphrase" varchar, "databaseId" varchar, CONSTRAINT "REL_fe3c3f8b1246e4824a3fb83047" UNIQUE ("databaseId"))`); + await queryRunner.query(`INSERT INTO "ssh_options"("id", "host", "port", "encryption", "username", "password", "privateKey", "passphrase", "databaseId") SELECT "id", "host", "port", "encryption", "username", "password", "privateKey", "passphrase", "databaseId" FROM "temporary_ssh_options"`); + await queryRunner.query(`DROP TABLE "temporary_ssh_options"`); + 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, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_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") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "ssh_options"`); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index a2a4d1b284..c5964662cc 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -22,6 +22,7 @@ import { databaseAnalysisExpirationGroups1664886479051 } from './1664886479051-d import { workbenchExecutionTime1667368983699 } from './1667368983699-workbench-execution-time'; import { database1667477693934 } from './1667477693934-database'; import { databaseNew1670252337342 } from './1670252337342-database-new'; +import { sshOptions1673035852335 } from './1673035852335-ssh-options'; export default [ initialMigration1614164490968, @@ -48,4 +49,5 @@ export default [ workbenchExecutionTime1667368983699, database1667477693934, databaseNew1670252337342, + sshOptions1673035852335, ]; diff --git a/redisinsight/package.json b/redisinsight/package.json index 2cbd657ba9..0b0d0a1b0b 100644 --- a/redisinsight/package.json +++ b/redisinsight/package.json @@ -13,6 +13,7 @@ "scripts": {}, "dependencies": { "keytar": "^7.9.0", - "sqlite3": "^5.0.11" + "sqlite3": "^5.0.11", + "ssh2": "^1.11.0" } } diff --git a/redisinsight/yarn.lock b/redisinsight/yarn.lock index 294fa3fcbd..685ee0c0b6 100644 --- a/redisinsight/yarn.lock +++ b/redisinsight/yarn.lock @@ -98,6 +98,13 @@ are-we-there-yet@^3.0.0: delegates "^1.0.0" readable-stream "^3.6.0" +asn1@^0.2.4: + 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== + dependencies: + safer-buffer "~2.1.0" + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -108,6 +115,13 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + bl@^4.0.3: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" @@ -133,6 +147,11 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +buildcheck@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.3.tgz#70451897a95d80f7807e68fc412eb2e7e35ff4d5" + integrity sha512-pziaA+p/wdVImfcbsZLNF32EiWyujlQLwolMqUQE8xpKNOH7KmZQaY8sXN7DGOEzPAElo9QTaeNRfGnf3iOJbA== + cacache@^15.2.0: version "15.3.0" resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.3.0.tgz#dc85380fb2f556fe3dda4c719bfa0ec875a7f1eb" @@ -187,6 +206,14 @@ console-control-strings@^1.0.0, console-control-strings@^1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= +cpu-features@~0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.4.tgz#0023475bb4f4c525869c162e4108099e35bf19d8" + integrity sha512-fKiZ/zp1mUwQbnzb9IghXtHtDoTMtNeb8oYGx6kX2SYfhnG0HNdBEBIzB9b5KlXu5DQPhfy3mInbBxFcgwAr3A== + dependencies: + buildcheck "0.0.3" + nan "^2.15.0" + debug@4, debug@^4.1.0, debug@^4.3.3: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -576,6 +603,11 @@ ms@^2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +nan@^2.15.0, nan@^2.16.0: + version "2.17.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" + integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== + napi-build-utils@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" @@ -747,7 +779,7 @@ safe-buffer@^5.0.1, safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -"safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -826,6 +858,17 @@ 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== + dependencies: + asn1 "^0.2.4" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.4" + nan "^2.16.0" + ssri@^8.0.0, ssri@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.1.tgz#638e4e439e2ffbd2cd289776d5ca457c4f51a2af" @@ -906,6 +949,11 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" +tweetnacl@^0.14.3: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + unique-filename@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" From 6f68e587e77b1b921f967b2bbce322354a70deee Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 9 Jan 2023 10:04:23 +0400 Subject: [PATCH 111/201] #RI-3955-3972 - add redis version recommendation --- redisinsight/api/package.json | 2 +- .../api/src/constants/recommendations.ts | 2 + .../providers/recommendation.provider.spec.ts | 37 ++++++++ .../providers/recommendation.provider.ts | 68 ++++++++++++++- .../recommendation/recommendation.service.ts | 2 + .../POST-databases-id-analysis.test.ts | 64 +++++++++----- redisinsight/api/test/helpers/constants.ts | 3 + .../constants/dbAnalysisRecommendations.json | 85 +++++++++++++++++++ .../Recommendations.spec.tsx | 30 +++++++ 9 files changed, 271 insertions(+), 22 deletions(-) diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 3cfc7ae5f6..bc280f0444 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -62,6 +62,7 @@ "lodash": "^4.17.20", "nest-router": "^1.0.9", "nest-winston": "^1.4.0", + "node-version-compare": "^1.0.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.5.6", "socket.io": "^4.4.0", @@ -102,7 +103,6 @@ "mocha": "^8.4.0", "mocha-junit-reporter": "^2.0.0", "mocha-multi-reporters": "^1.5.1", - "node-version-compare": "^1.0.3", "nyc": "^15.1.0", "object-diff": "^0.0.4", "rimraf": "^3.0.2", diff --git a/redisinsight/api/src/constants/recommendations.ts b/redisinsight/api/src/constants/recommendations.ts index 640cb330a9..80b50e5b66 100644 --- a/redisinsight/api/src/constants/recommendations.ts +++ b/redisinsight/api/src/constants/recommendations.ts @@ -14,4 +14,6 @@ export const RECOMMENDATION_NAMES = Object.freeze({ ZSET_HASHTABLE_TO_ZIPLIST: 'zSetHashtableToZiplist', SET_PASSWORD: 'setPassword', RTS: 'RTS', + REDIS_VERSION: 'redisVersion', + REDIS_SEARCH: 'redisSearch', }); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts index f36d2a019e..87ebf81567 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -22,6 +22,9 @@ const mockRedisConfigResponse = ['name', '512']; const mockRedisClientsResponse_1: string = '# Clients\r\nconnected_clients:100\r\n'; const mockRedisClientsResponse_2: string = '# Clients\r\nconnected_clients:101\r\n'; +const mockRedisServerResponse_1: string = '# Server\r\nredis_version:6.0.0\r\n'; +const mockRedisServerResponse_2: string = '# Server\r\nredis_version:5.1.1\r\n'; + const mockRedisAclListResponse_1: string[] = [ 'user { expect(RTSRecommendation).toEqual(null); }); }); + + describe('determineRedisVersionRecommendation', () => { + it('should not return redis version recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockResolvedValue(mockRedisServerResponse_1); + + const redisServerRecommendation = await service + .determineRedisVersionRecommendation(nodeClient); + expect(redisServerRecommendation).toEqual(null); + }); + + it('should return redis version recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockResolvedValueOnce(mockRedisServerResponse_2); + + const redisServerRecommendation = await service + .determineRedisVersionRecommendation(nodeClient); + expect(redisServerRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_VERSION }); + }); + + it('should not return redis version recommendation when info command executed with error', + async () => { + resetAllWhenMocks(); + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockRejectedValue('some error'); + + const redisServerRecommendation = await service + .determineRedisVersionRecommendation(nodeClient); + expect(redisServerRecommendation).toEqual(null); + }); + }); }); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 6de9d275ce..d9d92e607c 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { Redis, Cluster, Command } from 'ioredis'; import { get } from 'lodash'; +import * as semverCompare from 'node-version-compare'; import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/utils'; import { RECOMMENDATION_NAMES, IS_TIMESTAMP } from 'src/constants'; import { RedisDataType } from 'src/modules/browser/dto'; @@ -17,6 +18,7 @@ const maxSetLength = 5000; const maxConnectedClients = 100; const bigStringMemory = 5_000_000; const sortedSetCountForCheck = 100; +const minRedisVersion = '6'; @Injectable() export class RecommendationProvider { @@ -309,7 +311,7 @@ export class RecommendationProvider { } /** - * Check set password recommendation + * Check RTS recommendation * @param redisClient * @param keys */ @@ -350,6 +352,70 @@ export class RecommendationProvider { } } + /** + * Check redis search recommendation + * @param redisClient + * @param keys + */ + + async determineRedisSearchRecommendation( + redisClient: Redis | Cluster, + keys: Key[], + ): Promise { + try { + let processedKeysNumber = 0; + let isTimeSeries = false; + let sortedSetNumber = 0; + while ( + processedKeysNumber < keys.length + && !isTimeSeries + && sortedSetNumber <= sortedSetCountForCheck + ) { + if (keys[processedKeysNumber].type !== RedisDataType.ZSet) { + processedKeysNumber += 1; + } else { + const [, membersArray] = await redisClient.sendCommand( + // get first member-score pair + new Command('zscan', [keys[processedKeysNumber].name, '0', 'COUNT', 2], { replyEncoding: 'utf8' }), + ) as string[]; + // check is pair member-score is timestamp + if (IS_TIMESTAMP.test(membersArray[0]) && IS_TIMESTAMP.test(membersArray[1])) { + isTimeSeries = true; + } + processedKeysNumber += 1; + sortedSetNumber += 1; + } + } + + return isTimeSeries ? { name: RECOMMENDATION_NAMES.RTS } : null; + } catch (err) { + this.logger.error('Can not determine RTS recommendation', err); + return null; + } + } + + /** + * Check redis version recommendation + * @param redisClient + */ + + async determineRedisVersionRecommendation( + redisClient: Redis | Cluster, + ): Promise { + try { + const info = convertRedisInfoReplyToObject( + await redisClient.sendCommand( + new Command('info', ['server'], { replyEncoding: 'utf8' }), + ) as string, + ); + const version = get(info, 'server.redis_version'); + return semverCompare(version, minRedisVersion) >= 0 ? null : { name: RECOMMENDATION_NAMES.REDIS_VERSION }; + } catch (err) { + this.logger.error('Can not determine redis version recommendation', err); + return null; + } + } + private async checkAuth(redisClient: Redis | Cluster): Promise { try { await redisClient.sendCommand( diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index a6a25460e0..e850105d92 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -54,6 +54,8 @@ export class RecommendationService { await this.recommendationProvider.determineSetPasswordRecommendation(client), // TODO rework, need better solution to do not start determine recommendation exclude.includes(RECOMMENDATION_NAMES.RTS) ? null : await this.recommendationProvider.determineRTSRecommendation(client, keys), + await this.recommendationProvider.determineRedisSearchRecommendation(client, keys), + await this.recommendationProvider.determineRedisVersionRecommendation(client), ])); } } diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index 9bdd4cf7a9..4ba9854b68 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -191,7 +191,7 @@ describe('POST /databases/:instanceId/analysis', () => { requirements('!rte.pass'); [ { - name: 'Should create new database analysis with useSmallerKeys recommendation', + name: 'Should create new database analysis with setPassword recommendation', data: { delimiter: '-', }, @@ -209,6 +209,29 @@ describe('POST /databases/:instanceId/analysis', () => { ].map(mainCheckFn); }); + + describe('redisVersion recommendation', () => { + requirements('rte.version <= 6'); + [ + { + name: 'Should create new database analysis with redisVersion recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + checkFn: async ({ body }) => { + expect(body.recommendations).to.include.deep.members([ + constants.TEST_REDIS_VERSION_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + ].map(mainCheckFn); + }); + [ { name: 'Should create new database analysis with bigHashes recommendation', @@ -416,25 +439,26 @@ describe('POST /databases/:instanceId/analysis', () => { expect(await repository.count()).to.eq(5); } }, - { - name: 'Should create new database analysis with RTS recommendation', - data: { - delimiter: '-', - }, - statusCode: 201, - responseSchema, - before: async () => { - await rte.data.sendCommand('zadd', [constants.TEST_ZSET_TIMESTAMP_KEY, constants.TEST_ZSET_TIMESTAMP_MEMBER, constants.TEST_ZSET_TIMESTAMP_SCORE]); - }, - checkFn: async ({ body }) => { - expect(body.recommendations).to.include.deep.members([ - constants.TEST_RTS_RECOMMENDATION, - ]); - }, - after: async () => { - expect(await repository.count()).to.eq(5); - } - }, + // update with new requirements + // { + // name: 'Should create new database analysis with RTS recommendation', + // data: { + // delimiter: '-', + // }, + // statusCode: 201, + // responseSchema, + // before: async () => { + // await rte.data.sendCommand('zadd', [constants.TEST_ZSET_TIMESTAMP_KEY, constants.TEST_ZSET_TIMESTAMP_MEMBER, constants.TEST_ZSET_TIMESTAMP_SCORE]); + // }, + // checkFn: async ({ body }) => { + // expect(body.recommendations).to.include.deep.members([ + // constants.TEST_RTS_RECOMMENDATION, + // ]); + // }, + // after: async () => { + // expect(await repository.count()).to.eq(5); + // } + // }, ].map(mainCheckFn); }); }); diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index b250b63745..6e306e2b06 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -506,5 +506,8 @@ export const constants = { name: RECOMMENDATION_NAMES.RTS, }, + TEST_REDIS_VERSION_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.REDIS_VERSION, + }, // etc... } diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index b55c1169fc..963ef1a1a4 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -527,5 +527,90 @@ } ], "badges": ["configuration_changes"] + }, + "redisVersion": { + "id": "redisVersion", + "title":"Update Redis database", + "redisStack": true, + "content": [ + { + "id": "1", + "type": "paragraph", + "value": "Newer versions of Redis (starting from 6.0) have performance and resource utilization improvements, as well as the improved active, expire cycle to evict the keys faster." + }, + { + "id": "2", + "type": "spacer", + "value": "l" + }, + { + "id": "3", + "type": "span", + "value": "Create a " + }, + { + "id": "4", + "type": "link", + "value": { + "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight/", + "name": "free Redis Stack database" + } + }, + { + "id": "5", + "type": "span", + "value": " which extends the core capabilities of Redis OSS and provides a complete developer experience for debugging and more." + } + ], + "badges": ["upgrade"] + }, + "redisSearch": { + "id": "redisSearch", + "title":"Optimize your search and query experience", + "redisStack": true, + "content": [ + { + "id": "1", + "type": "span", + "value": "RediSearch was designed to help address your query needs and support a better development experience when dealing with complex data scenarios. Take a look at the powerful API options here (" + }, + { + "id": "2", + "type": "link", + "value": { + "href": "https://redis.io/commands/?name=Ft", + "name": "Commands" + } + }, + { + "id": "3", + "type": "span", + "value": ") and try it. Supports full-text search, wildcards, fuzzy logic, and more." + }, + { + "id": "4", + "type": "spacer", + "value": "l" + }, + { + "id": "5", + "type": "span", + "value": "Create a " + }, + { + "id": "4", + "type": "link", + "value": { + "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight/", + "name": "free Redis Stack database" + } + }, + { + "id": "5", + "type": "span", + "value": " which extends the core capabilities of Redis OSS and uses modern data models and processing engines." + } + ], + "badges": ["upgrade"] } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 7bb6bde41a..99e978fd51 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -264,6 +264,36 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() }) + it('should render upgrade badge in redisSearch recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'redisSearch' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).not.toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() + }) + + it('should render upgrade badge in redisVersion recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'redisVersion' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).not.toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() + }) + it('should collapse/expand and sent proper telemetry event', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, From bcfcf1a371874810e53471c6723f849c255306fc Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 9 Jan 2023 12:01:23 +0400 Subject: [PATCH 112/201] #RI-3955-3972 - add redis search recommendation --- .../api/src/constants/redis-modules.ts | 4 + .../providers/recommendation.provider.spec.ts | 122 ++++++++++++++++++ .../providers/recommendation.provider.ts | 50 ++++--- .../recommendation/recommendation.service.ts | 2 +- 4 files changed, 150 insertions(+), 28 deletions(-) diff --git a/redisinsight/api/src/constants/redis-modules.ts b/redisinsight/api/src/constants/redis-modules.ts index 85bac9a523..ecf34cf7cf 100644 --- a/redisinsight/api/src/constants/redis-modules.ts +++ b/redisinsight/api/src/constants/redis-modules.ts @@ -8,6 +8,10 @@ export enum AdditionalRedisModuleName { RedisTimeSeries = 'timeseries', } +export const REDIS_SEARCH_MODULES = [ + AdditionalRedisModuleName.RediSearch, +]; + export const SUPPORTED_REDIS_MODULES = Object.freeze({ ai: AdditionalRedisModuleName.RedisAI, graph: AdditionalRedisModuleName.RedisGraph, diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts index 87ebf81567..1c9e91a2e5 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -34,6 +34,30 @@ const mockRedisAclListResponse_2: string[] = [ 'user test_2 on nopass ~* &* +@all', ]; +const mockRedisModulesResponse_1 = [ + { name: 'ai', ver: 10000 }, + { name: 'graph', ver: 10000 }, + { name: 'rg', ver: 10000 }, + { name: 'bf', ver: 10000 }, + { name: 'ReJSON', ver: 10000 }, + { name: 'search', ver: 10000 }, + { name: 'timeseries', ver: 10000 }, + { name: 'customModule', ver: 10000 }, +].map((item) => ([].concat(...Object.entries(item)))); + +const mockRedisModulesResponse_2 = [ + { name: 'ai', ver: 10000 }, + { name: 'graph', ver: 10000 }, + { name: 'rg', ver: 10000 }, + { name: 'bf', ver: 10000 }, + { name: 'ReJSON', ver: 10000 }, + { name: 'timeseries', ver: 10000 }, + { name: 'customModule', ver: 10000 }, +].map((item) => ([].concat(...Object.entries(item)))); + +const mockFTListResponse_1 = []; +const mockFTListResponse_2 = ['idx']; + const mockZScanResponse_1 = [ '0', [123456789, 123456789, 12345678910, 12345678910], @@ -106,6 +130,18 @@ const mockBigListKey = { name: Buffer.from('name'), type: 'list', length: 1001, memory: 10, ttl: -1, }; +const mockJSONKey = { + name: Buffer.from('name'), type: 'ReJSON-RL', length: 1, memory: 10, ttl: -1, +}; + +const mockRediSearchStringKey_1 = { + name: Buffer.from('name'), type: 'string', length: 1, memory: 512 * 1024 + 1, ttl: -1, +}; + +const mockRediSearchStringKey_2 = { + name: Buffer.from('name'), type: 'string', length: 1, memory: 512 * 1024, ttl: -1, +}; + const mockSortedSets = new Array(101).fill( { name: Buffer.from('name'), type: 'zset', length: 10, memory: 10, ttl: -1, @@ -529,6 +565,92 @@ describe('RecommendationProvider', () => { }); }); + describe('determineRediSearchRecommendation', () => { + it('should not return rediSearch recommendation when rediSearch module was download with indexes', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'module' })) + .mockResolvedValue(mockRedisModulesResponse_1); + + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) + .mockResolvedValue(mockFTListResponse_2); + + const redisServerRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); + expect(redisServerRecommendation).toEqual(null); + }); + + it('should return rediSearch recommendation when there is JSON key', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'module' })) + .mockResolvedValue(mockRedisModulesResponse_1); + + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) + .mockResolvedValue(mockFTListResponse_1); + + const redisServerRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); + expect(redisServerRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_SEARCH }); + }); + + it('should return rediSearch recommendation when there is huge string key', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'module' })) + .mockResolvedValue(mockRedisModulesResponse_1); + + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) + .mockResolvedValue(mockFTListResponse_1); + + const redisServerRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockRediSearchStringKey_1]); + expect(redisServerRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_SEARCH }); + }); + + it('should not return rediSearch recommendation when there is small string key', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'module' })) + .mockResolvedValue(mockRedisModulesResponse_1); + + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) + .mockResolvedValue(mockFTListResponse_1); + + const redisServerRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockRediSearchStringKey_2]); + expect(redisServerRecommendation).toEqual(null); + }); + + it('should not return rediSearch recommendation when ft command execute with error', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'module' })) + .mockResolvedValue(mockRedisModulesResponse_1); + + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) + .mockRejectedValue("some error"); + + const redisServerRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); + expect(redisServerRecommendation).toEqual(null); + }); + + it('should not return rediSearch recommendation when module command execute with error', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'module' })) + .mockResolvedValue("some error"); + + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) + .mockResolvedValue(mockFTListResponse_1); + + const redisServerRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); + expect(redisServerRecommendation).toEqual(null); + }); + }); + describe('determineRedisVersionRecommendation', () => { it('should not return redis version recommendation', async () => { when(nodeClient.sendCommand) diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index d9d92e607c..b87db6b27d 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -2,8 +2,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { Redis, Cluster, Command } from 'ioredis'; import { get } from 'lodash'; import * as semverCompare from 'node-version-compare'; -import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/utils'; -import { RECOMMENDATION_NAMES, IS_TIMESTAMP } from 'src/constants'; +import { convertRedisInfoReplyToObject, convertBulkStringsToObject, convertStringsArrayToObject } from 'src/utils'; +import { RECOMMENDATION_NAMES, IS_TIMESTAMP, REDIS_SEARCH_MODULES } from 'src/constants'; import { RedisDataType } from 'src/modules/browser/dto'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { Key } from 'src/modules/database-analysis/models'; @@ -16,6 +16,7 @@ const maxCompressHashLength = 1000; const maxListLength = 1000; const maxSetLength = 5000; const maxConnectedClients = 100; +const maxRediSearchStringMemory = 512 * 1024; const bigStringMemory = 5_000_000; const sortedSetCountForCheck = 100; const minRedisVersion = '6'; @@ -358,38 +359,33 @@ export class RecommendationProvider { * @param keys */ - async determineRedisSearchRecommendation( + async determineRediSearchRecommendation( redisClient: Redis | Cluster, keys: Key[], ): Promise { try { - let processedKeysNumber = 0; - let isTimeSeries = false; - let sortedSetNumber = 0; - while ( - processedKeysNumber < keys.length - && !isTimeSeries - && sortedSetNumber <= sortedSetCountForCheck - ) { - if (keys[processedKeysNumber].type !== RedisDataType.ZSet) { - processedKeysNumber += 1; - } else { - const [, membersArray] = await redisClient.sendCommand( - // get first member-score pair - new Command('zscan', [keys[processedKeysNumber].name, '0', 'COUNT', 2], { replyEncoding: 'utf8' }), - ) as string[]; - // check is pair member-score is timestamp - if (IS_TIMESTAMP.test(membersArray[0]) && IS_TIMESTAMP.test(membersArray[1])) { - isTimeSeries = true; - } - processedKeysNumber += 1; - sortedSetNumber += 1; - } + const reply = await redisClient.sendCommand( + new Command('module', ['list'], { replyEncoding: 'utf8' }), + ) as any[]; + const modules = reply.map((module: any[]) => convertStringsArrayToObject(module)); + const isRediSearchModule = modules.some(({ name }) => REDIS_SEARCH_MODULES + .some((search: string) => name === search)); + + const indexes = await redisClient.sendCommand( + new Command('FT._LIST', [], { replyEncoding: 'utf8' }), + ) as any[]; + if (isRediSearchModule && indexes.length) { + return null; } - return isTimeSeries ? { name: RECOMMENDATION_NAMES.RTS } : null; + const isBigStringOrJSON = keys.some((key) => ( + key.type === RedisDataType.String && key.memory > maxRediSearchStringMemory + ) + || key.type === RedisDataType.JSON); + + return isBigStringOrJSON ? { name: RECOMMENDATION_NAMES.REDIS_SEARCH } : null; } catch (err) { - this.logger.error('Can not determine RTS recommendation', err); + this.logger.error('Can not determine redis search recommendation', err); return null; } } diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index e850105d92..a5bc8d6728 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -54,7 +54,7 @@ export class RecommendationService { await this.recommendationProvider.determineSetPasswordRecommendation(client), // TODO rework, need better solution to do not start determine recommendation exclude.includes(RECOMMENDATION_NAMES.RTS) ? null : await this.recommendationProvider.determineRTSRecommendation(client, keys), - await this.recommendationProvider.determineRedisSearchRecommendation(client, keys), + await this.recommendationProvider.determineRediSearchRecommendation(client, keys), await this.recommendationProvider.determineRedisVersionRecommendation(client), ])); } From c87c862c9206d6183e1e767e8b4cc46f42294667 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 9 Jan 2023 12:15:07 +0400 Subject: [PATCH 113/201] #RI-3972 - add IT test --- .../POST-databases-id-analysis.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index 4ba9854b68..d512fae9ed 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -232,6 +232,30 @@ describe('POST /databases/:instanceId/analysis', () => { ].map(mainCheckFn); }); + describe('rediSearch recommendation', () => { + [ + { + name: 'Should create new database analysis with rediSearch recommendation', + data: { + delimiter: '-', + }, + before: async () => { + await rte.data.sendCommand('SET', [constants.TEST_STRING_KEY_1, Buffer.alloc(513 * 1024, 'a').toString()]); + }, + statusCode: 201, + responseSchema, + checkFn: async ({ body }) => { + expect(body.recommendations).to.include.deep.members([ + constants.TEST_REDIS_VERSION_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + ].map(mainCheckFn); + }); + [ { name: 'Should create new database analysis with bigHashes recommendation', From 35b0bbff9175dba175c114dc3f3d951e81ef6bfb Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 9 Jan 2023 12:21:44 +0400 Subject: [PATCH 114/201] #RI-3972 - add IT reJson test --- .../POST-databases-id-analysis.test.ts | 28 ++++++++++++++++++- redisinsight/api/test/helpers/constants.ts | 4 +++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index d512fae9ed..48404ce13e 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -246,7 +246,33 @@ describe('POST /databases/:instanceId/analysis', () => { responseSchema, checkFn: async ({ body }) => { expect(body.recommendations).to.include.deep.members([ - constants.TEST_REDIS_VERSION_RECOMMENDATION, + constants.TEST_REDISEARCH_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + ].map(mainCheckFn); + }); + + describe('rediSearch recommendation with ReJSON', () => { + requirements('rte.modules.rejson'); + [ + { + name: 'Should create new database analysis with rediSearch recommendation', + data: { + delimiter: '-', + }, + before: async () => { + const NUMBERS_REJSONS = 1; + await rte.data.generateNReJSONs(NUMBERS_REJSONS, true); + }, + statusCode: 201, + responseSchema, + checkFn: async ({ body }) => { + expect(body.recommendations).to.include.deep.members([ + constants.TEST_REDISEARCH_RECOMMENDATION, ]); }, after: async () => { diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index 6e306e2b06..fb1a1ab585 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -509,5 +509,9 @@ export const constants = { TEST_REDIS_VERSION_RECOMMENDATION: { name: RECOMMENDATION_NAMES.REDIS_VERSION, }, + + TEST_REDISEARCH_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.REDIS_SEARCH, + }, // etc... } From aeffc00c326272e06c8a252dd04e1454d510deff Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 9 Jan 2023 12:40:15 +0400 Subject: [PATCH 115/201] #RI-3972 - fix test --- .../providers/recommendation.provider.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index b87db6b27d..5c0569a3b9 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -371,11 +371,13 @@ export class RecommendationProvider { const isRediSearchModule = modules.some(({ name }) => REDIS_SEARCH_MODULES .some((search: string) => name === search)); - const indexes = await redisClient.sendCommand( - new Command('FT._LIST', [], { replyEncoding: 'utf8' }), - ) as any[]; - if (isRediSearchModule && indexes.length) { - return null; + if (isRediSearchModule) { + const indexes = await redisClient.sendCommand( + new Command('FT._LIST', [], { replyEncoding: 'utf8' }), + ) as any[]; + if (indexes.length) { + return null; + } } const isBigStringOrJSON = keys.some((key) => ( From 6b1ba844a42cbc23302a01b30af8c0b6a90167f1 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 9 Jan 2023 13:01:42 +0400 Subject: [PATCH 116/201] #RI-3972 - fix test --- .../providers/recommendation.provider.ts | 52 +++++++++---------- .../POST-databases-id-analysis.test.ts | 1 - 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 5c0569a3b9..4a22923545 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -285,32 +285,6 @@ export class RecommendationProvider { } } - /** - * Check set password recommendation - * @param redisClient - */ - - async determineSetPasswordRecommendation( - redisClient: Redis | Cluster, - ): Promise { - if (await this.checkAuth(redisClient)) { - return { name: RECOMMENDATION_NAMES.SET_PASSWORD }; - } - - try { - const users = await redisClient.sendCommand( - new Command('acl', ['list'], { replyEncoding: 'utf8' }), - ) as string[]; - - const nopassUser = users.some((user) => user.split(' ')[3] === 'nopass'); - - return nopassUser ? { name: RECOMMENDATION_NAMES.SET_PASSWORD } : null; - } catch (err) { - this.logger.error('Can not determine set password recommendation', err); - return null; - } - } - /** * Check RTS recommendation * @param redisClient @@ -414,6 +388,32 @@ export class RecommendationProvider { } } + /** + * Check set password recommendation + * @param redisClient + */ + + async determineSetPasswordRecommendation( + redisClient: Redis | Cluster, + ): Promise { + if (await this.checkAuth(redisClient)) { + return { name: RECOMMENDATION_NAMES.SET_PASSWORD }; + } + + try { + const users = await redisClient.sendCommand( + new Command('acl', ['list'], { replyEncoding: 'utf8' }), + ) as string[]; + + const nopassUser = users.some((user) => user.split(' ')[3] === 'nopass'); + + return nopassUser ? { name: RECOMMENDATION_NAMES.SET_PASSWORD } : null; + } catch (err) { + this.logger.error('Can not determine set password recommendation', err); + return null; + } + } + private async checkAuth(redisClient: Redis | Cluster): Promise { try { await redisClient.sendCommand( diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index 48404ce13e..c33d39c3a1 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -209,7 +209,6 @@ describe('POST /databases/:instanceId/analysis', () => { ].map(mainCheckFn); }); - describe('redisVersion recommendation', () => { requirements('rte.version <= 6'); [ From 38ef44de3ee6d453395422a347265ad818dfbe06 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 9 Jan 2023 13:28:59 +0400 Subject: [PATCH 117/201] #RI-3972 - fix pass IT --- .../api/src/modules/recommendation/recommendation.service.ts | 4 ++-- .../api/database-analysis/POST-databases-id-analysis.test.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index a5bc8d6728..50517b2437 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -51,11 +51,11 @@ export class RecommendationService { await this.recommendationProvider.determineZSetHashtableToZiplistRecommendation(client, keys), await this.recommendationProvider.determineBigSetsRecommendation(keys), await this.recommendationProvider.determineConnectionClientsRecommendation(client), - await this.recommendationProvider.determineSetPasswordRecommendation(client), // TODO rework, need better solution to do not start determine recommendation exclude.includes(RECOMMENDATION_NAMES.RTS) ? null : await this.recommendationProvider.determineRTSRecommendation(client, keys), await this.recommendationProvider.determineRediSearchRecommendation(client, keys), - await this.recommendationProvider.determineRedisVersionRecommendation(client), + await this.recommendationProvider.determineRedisVersionRecommendation(client), + await this.recommendationProvider.determineSetPasswordRecommendation(client), ])); } } diff --git a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts index c33d39c3a1..bc978abe7d 100644 --- a/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts +++ b/redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts @@ -186,7 +186,6 @@ describe('POST /databases/:instanceId/analysis', () => { ].map(mainCheckFn); }); - describe('setPassword recommendation', () => { requirements('!rte.pass'); [ From a9194a4079edbb95751cc929a2268b0eda2e4e5a Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Mon, 9 Jan 2023 17:39:01 +0800 Subject: [PATCH 118/201] #RI-3700 - Apply Workbench modes to non-auto guides --- .../src/components/query-card/QueryCard.tsx | 1 + .../QueryCardHeader/QueryCardHeader.tsx | 48 ++++++++++----- .../QueryCardHeader/styles.module.scss | 24 +++++--- .../ui/src/components/query/Query/Query.tsx | 14 ++++- .../ui/src/components/query/QueryWrapper.tsx | 9 ++- redisinsight/ui/src/constants/workbench.ts | 15 ++++- .../pages/workbench/WorkbenchPage.spec.tsx | 10 ++-- .../components/Code/Code.spec.tsx | 14 ++++- .../EnablementArea/components/Code/Code.tsx | 11 +++- .../components/CodeButton/CodeButton.spec.tsx | 5 +- .../components/CodeButton/CodeButton.tsx | 3 +- .../EnablementArea/utils/parseParams.ts | 10 +++- .../EnablementArea/utils/remarkRedisCode.ts | 7 +-- .../utils/tests/parseParams.spec.ts | 1 + .../utils/tests/remarkRedisCode.spec.ts | 58 +++++++++++++------ .../components/enablement-area/interfaces.ts | 3 +- .../wb-results/WBResults/WBResults.tsx | 6 +- .../components/wb-view/WBViewWrapper.tsx | 24 ++++++-- .../ui/src/slices/interfaces/workbench.ts | 5 ++ redisinsight/ui/src/styles/base/_monaco.scss | 13 +++-- .../themes/dark_theme/_dark_theme.lazy.scss | 1 + .../themes/dark_theme/_theme_color.scss | 1 + .../themes/light_theme/_light_theme.lazy.scss | 1 + .../themes/light_theme/_theme_color.scss | 1 + .../ui/src/utils/monaco/monacoUtils.ts | 17 +++++- .../utils/tests/monaco/monacoUtils.spec.ts | 44 +++++++++++++- redisinsight/ui/src/utils/workbench.ts | 5 +- 27 files changed, 269 insertions(+), 82 deletions(-) diff --git a/redisinsight/ui/src/components/query-card/QueryCard.tsx b/redisinsight/ui/src/components/query-card/QueryCard.tsx index 64e1f8869c..e56a9d86a7 100644 --- a/redisinsight/ui/src/components/query-card/QueryCard.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCard.tsx @@ -167,6 +167,7 @@ const QueryCard = (props: Props) => { selectedValue={selectedViewValue} activeMode={activeMode} mode={mode} + resultsMode={resultsMode} activeResultsMode={activeResultsMode} emptyCommand={emptyCommand} summary={getSummaryText(summary)} diff --git a/redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.tsx b/redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.tsx index b10d6a8aec..4b220b2031 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.tsx @@ -23,6 +23,7 @@ import { truncateText, urlForAsset, truncateMilliseconds, + isRawMode, } from 'uiSrc/utils' import { numberWithSpaces } from 'uiSrc/utils/numbers' import { ThemeContext } from 'uiSrc/contexts/themeContext' @@ -36,6 +37,7 @@ import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' import DefaultPluginIconDark from 'uiSrc/assets/img/workbench/default_view_dark.svg' import DefaultPluginIconLight from 'uiSrc/assets/img/workbench/default_view_light.svg' import { ReactComponent as ExecutionTimeIcon } from 'uiSrc/assets/img/workbench/execution_time.svg' +import { ReactComponent as GroupModeIcon } from 'uiSrc/assets/img/icons/group_mode.svg' import QueryCardTooltip from '../QueryCardTooltip' @@ -49,6 +51,7 @@ export interface Props { summaryText?: string activeMode: RunQueryMode mode?: RunQueryMode + resultsMode?: ResultsMode activeResultsMode?: ResultsMode summary?: string queryType: WBQueryType @@ -89,6 +92,7 @@ const QueryCardHeader = (props: Props) => { summaryText, createdAt, mode, + resultsMode, activeResultsMode, summary, activeMode, @@ -117,7 +121,7 @@ const QueryCardHeader = (props: Props) => { eventData: { databaseId: instanceId, command: getCommandNameFromQuery(query, COMMANDS_SPEC), - rawMode: activeMode === RunQueryMode.Raw, + rawMode: isRawMode(activeMode), group: isGroupMode(activeResultsMode), ...additionalData } @@ -267,18 +271,6 @@ const QueryCardHeader = (props: Props) => { {!!createdAt && ( {getFormatTime()} - {mode === RunQueryMode.Raw && ( - - - -r - - - )} )} @@ -380,6 +372,36 @@ const QueryCardHeader = (props: Props) => { )} + {(isRawMode(mode) || isGroupMode(resultsMode)) && ( + + + {isGroupMode(resultsMode) && ( + + + + )} + {isRawMode(mode) && ( + + -r + + )} + + )} + position="bottom" + data-testid="parameters-tooltip" + > + + + + )}
    ) diff --git a/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss b/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss index 86cfc9a6dd..08d396b14c 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss +++ b/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss @@ -19,7 +19,7 @@ $marginIcon: 12px; } } - :global(.euiFlexGroup--gutterLarge>.euiFlexItem) { + :global(.euiFlexGroup--gutterLarge > .euiFlexItem) { margin: $marginIcon $marginIcon/2 !important; @media (min-width: $breakpoint-m) { @@ -79,16 +79,17 @@ $marginIcon: 12px; } .time { - max-width: 117px; + max-width: 70px; position: relative; height: 18px; } -.mode { - margin-left: 6px; +.mode + .mode { + margin-left: 18px; } -.timeText, .summaryText { +.timeText, +.summaryText { font: normal normal normal 12px/16px Graphik, sans-serif; letter-spacing: -0.12px; color: var(--euiColorMediumShade) !important; @@ -191,12 +192,12 @@ $marginIcon: 12px; margin-right: 6px; path { - fill: var(--iconsDefaultColor) + fill: var(--iconsDefaultColor); } } .dropdownOptionSeparator:after { - content: ''; + content: ""; display: block; height: 0; width: 100%; @@ -271,3 +272,12 @@ $marginIcon: 12px; .container :global(.euiFlexItem).playIcon { margin-right: $marginIcon/3 !important; } + +.tooltipAnchor { + width: 16px; + margin-left: -4px; +} + +:global(.fullscreen) .tooltipAnchor { + margin-left: 0; +} diff --git a/redisinsight/ui/src/components/query/Query/Query.tsx b/redisinsight/ui/src/components/query/Query/Query.tsx index fa30157df1..b876124449 100644 --- a/redisinsight/ui/src/components/query/Query/Query.tsx +++ b/redisinsight/ui/src/components/query/Query/Query.tsx @@ -1,7 +1,7 @@ import React, { useContext, useEffect, useRef, useState } from 'react' import AutoSizer from 'react-virtualized-auto-sizer' import { useDispatch, useSelector } from 'react-redux' -import { compact, findIndex } from 'lodash' +import { compact, findIndex, first } from 'lodash' import cx from 'classnames' import { EuiButtonIcon, EuiButton, EuiIcon, EuiLoadingSpinner, EuiText, EuiToolTip } from '@elastic/eui' import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api' @@ -26,6 +26,7 @@ import { getRedisMonarchTokensProvider, getRedisSignatureHelpProvider, isGroupMode, + isParamsLine, MonacoAction, Nullable, toModelDeltaDecoration @@ -121,11 +122,12 @@ const Query = (props: Props) => { useEffect(() => { if (!monacoObjects.current) return const commands = query.split('\n') + const firstLine = first(commands) ?? '' const { monaco, editor } = monacoObjects.current const notCommandRegEx = /^[\s|//]/ const newDecorations = compact(commands.map((command, index) => { - if (!command || notCommandRegEx.test(command)) return null + if (!command || notCommandRegEx.test(command) || (index === 0 && isParamsLine(command))) return null const lineNumber = index + 1 return toModelDeltaDecoration( @@ -133,6 +135,14 @@ const Query = (props: Props) => { ) })) + // highlight the first line with params + if (isParamsLine(firstLine)) { + newDecorations.push({ + range: new monaco.Range(1, 1, 1, firstLine.indexOf(']') + 2), + options: { inlineClassName: 'monaco-params-line' } + }) + } + decorations = editor.deltaDecorations( decorations, newDecorations diff --git a/redisinsight/ui/src/components/query/QueryWrapper.tsx b/redisinsight/ui/src/components/query/QueryWrapper.tsx index 458e4f8880..80afc31b66 100644 --- a/redisinsight/ui/src/components/query/QueryWrapper.tsx +++ b/redisinsight/ui/src/components/query/QueryWrapper.tsx @@ -7,10 +7,11 @@ import { useParams } from 'react-router-dom' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' -import { getMultiCommands, isGroupMode, removeMonacoComments, splitMonacoValuePerLines } from 'uiSrc/utils' +import { getMultiCommands, isGroupMode, isParamsLine, removeMonacoComments, splitMonacoValuePerLines } from 'uiSrc/utils' import { userSettingsConfigSelector } from 'uiSrc/slices/user/user-settings' import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' import { PIPELINE_COUNT_DEFAULT } from 'uiSrc/constants/api' +import { parseParams } from 'uiSrc/pages/workbench/components/enablement-area/EnablementArea/utils' import Query from './Query' import styles from './Query/styles.module.scss' @@ -79,13 +80,17 @@ const QueryWrapper = (props: Props) => { const multiCommands = getMultiCommands(rest).replaceAll('\n', ';') const command = [commandLine, multiCommands].join('') ? [commandLine, multiCommands].join(';') : null + const params = isParamsLine(commandLine) ? commandLine : '' + const parsedParams = parseParams(params) + return { command: command?.toUpperCase(), databaseId: instanceId, multiple: multiCommands ? 'Multiple' : 'Single', pipeline: batchSize > 1, rawMode: state.activeMode === RunQueryMode.Raw, - group: isGroupMode(state.resultsMode) + group: isGroupMode(state.resultsMode) ? 'group' : 'single', + auto: !!parsedParams?.auto } })() diff --git a/redisinsight/ui/src/constants/workbench.ts b/redisinsight/ui/src/constants/workbench.ts index 273e7c995b..ec20c37bcb 100644 --- a/redisinsight/ui/src/constants/workbench.ts +++ b/redisinsight/ui/src/constants/workbench.ts @@ -1,11 +1,20 @@ -import { ResultsMode, RunQueryMode } from 'uiSrc/slices/interfaces' +import { AutoExecute, ResultsMode, RunQueryMode } from 'uiSrc/slices/interfaces' export const CodeButtonResults = { group: ResultsMode.GroupMode, - single: ResultsMode.Default + single: ResultsMode.Default, + [ResultsMode.GroupMode]: ResultsMode.GroupMode, + [ResultsMode.Default]: ResultsMode.Default, } export const CodeButtonRunQueryMode = { raw: RunQueryMode.Raw, - ascii: RunQueryMode.ASCII + ascii: RunQueryMode.ASCII, + [RunQueryMode.Raw]: RunQueryMode.Raw, + [RunQueryMode.ASCII]: RunQueryMode.ASCII, +} + +export const CodeButtonAutoExecute = { + true: AutoExecute.True, + false: AutoExecute.False, } diff --git a/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx b/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx index 1fa2f57494..74c9e5af3e 100644 --- a/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx +++ b/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx @@ -169,7 +169,8 @@ describe('Telemetry', () => { eventData: { command: 'info;'.toUpperCase(), databaseId: INSTANCE_ID_MOCK, - group: false, + group: 'single', + auto: false, multiple: 'Single', pipeline: true, rawMode: false, @@ -207,7 +208,8 @@ describe('Telemetry', () => { eventData: { command: 'info;'.toUpperCase(), databaseId: INSTANCE_ID_MOCK, - group: false, + group: 'single', + auto: false, multiple: 'Single', pipeline: true, rawMode: true, @@ -279,11 +281,11 @@ describe('Raw mode', () => { render() await act(() => { - fireEvent.mouseOver(screen.getByTestId('raw-mode-anchor')) + fireEvent.mouseOver(screen.getByTestId('parameters-anchor')) }) await waitForEuiToolTipVisible() - expect(screen.getByTestId('raw-mode-tooltip')).toBeInTheDocument() + expect(screen.getByTestId('parameters-tooltip')).toBeInTheDocument() }) it('check button clickable and selected', async () => { diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/Code/Code.spec.tsx b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/Code/Code.spec.tsx index f1fe717190..2dfd3a9fe8 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/Code/Code.spec.tsx +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/Code/Code.spec.tsx @@ -30,7 +30,11 @@ describe('Code', () => { const link = queryByTestId(`preselect-${label}`) fireEvent.click(link as Element) - expect(setScript).toBeCalledWith(MONACO_MANUAL, {}, undefined) + expect(setScript).toBeCalledWith( + MONACO_MANUAL, + { mode: ExecuteButtonMode.Manual, params: undefined }, + undefined, + ) }) it('should correctly set script with auto execute', () => { @@ -39,11 +43,15 @@ describe('Code', () => { render( - {MONACO_MANUAL} + {MONACO_MANUAL} ) fireEvent.click(screen.queryByTestId(`preselect-auto-${label}`) as Element) - expect(setScript).toBeCalledWith(MONACO_MANUAL, { mode: ExecuteButtonMode.Auto }, undefined) + expect(setScript).toBeCalledWith( + MONACO_MANUAL, + { mode: ExecuteButtonMode.Auto, params: { auto: 'true' } }, + undefined, + ) }) }) diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/Code/Code.tsx b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/Code/Code.tsx index edee32b42c..c87e5006d6 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/Code/Code.tsx +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/Code/Code.tsx @@ -5,6 +5,7 @@ import { getFileInfo, parseParams } from 'uiSrc/pages/workbench/components/enabl import { CodeButtonParams, ExecuteButtonMode } from 'uiSrc/pages/workbench/components/enablement-area/interfaces' import EnablementAreaContext from 'uiSrc/pages/workbench/contexts/enablementAreaContext' import { Maybe } from 'uiSrc/utils' +import { CodeButtonAutoExecute } from 'uiSrc/constants' import CodeButton from '../CodeButton' @@ -12,13 +13,17 @@ export interface Props { label: string children: string params?: string - mode?: ExecuteButtonMode } -const Code = ({ children, params, mode, ...rest }: Props) => { +const Code = ({ children, params = '', ...rest }: Props) => { const { search } = useLocation() const { setScript, isCodeBtnDisabled } = useContext(EnablementAreaContext) + const parsedParams = parseParams(params) + const mode = parsedParams?.auto === CodeButtonAutoExecute.true + ? ExecuteButtonMode.Auto + : ExecuteButtonMode.Manual + const loadContent = (execute: { mode?: ExecuteButtonMode, params?: CodeButtonParams }) => { const pagePath = new URLSearchParams(search).get('item') let file: Maybe<{ path: string, name: string }> @@ -38,7 +43,7 @@ const Code = ({ children, params, mode, ...rest }: Props) => { () @@ -43,11 +44,11 @@ describe('CodeButton', () => { label={label} onClick={onClick} mode={ExecuteButtonMode.Auto} - params={{}} + params={{ auto: AutoExecute.True }} /> ) fireEvent.click(screen.getByTestId(`preselect-auto-${label}`)) - expect(onClick).toBeCalledWith({ mode: ExecuteButtonMode.Auto, params: {} }) + expect(onClick).toBeCalledWith({ mode: ExecuteButtonMode.Auto, params: { auto: AutoExecute.True } }) }) }) diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/CodeButton/CodeButton.tsx b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/CodeButton/CodeButton.tsx index c3dd6d81ed..2cb9cdd7f0 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/CodeButton/CodeButton.tsx +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/CodeButton/CodeButton.tsx @@ -3,6 +3,7 @@ import cx from 'classnames' import React from 'react' import { CodeButtonParams, ExecuteButtonMode } from 'uiSrc/pages/workbench/components/enablement-area/interfaces' import { truncateText } from 'uiSrc/utils' +import { CodeButtonAutoExecute } from 'uiSrc/constants' import styles from './styles.module.scss' @@ -16,7 +17,7 @@ export interface Props { mode?: ExecuteButtonMode } const CodeButton = ({ onClick, label, isLoading, className, disabled, params, mode, ...rest }: Props) => { - const isAutoExecute = mode === ExecuteButtonMode.Auto + const isAutoExecute = params?.auto === CodeButtonAutoExecute.true return ( { +export const parseParams = (params?: string): Maybe => { if (params?.match(/(^\[).+(]$)/g)) { - return params + return pickBy(params + ?.replaceAll(' ', '') ?.replace(/^\[|]$/g, '') ?.split(';') .reduce((prev: {}, next: string) => { @@ -11,7 +14,8 @@ export const parseParams = (params?: string): CodeButtonParams | undefined => { ...prev, [key]: value } - }, {}) + }, {}), + identity) } return undefined } diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/remarkRedisCode.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/remarkRedisCode.ts index a3c5f855d3..4eb1a42753 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/remarkRedisCode.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/remarkRedisCode.ts @@ -1,5 +1,4 @@ import { visit } from 'unist-util-visit' -import { ExecuteButtonMode } from 'uiSrc/pages/workbench/components/enablement-area/interfaces' enum ButtonLang { Redis = 'redis', @@ -17,14 +16,12 @@ export const remarkRedisCode = (): (tree: Node) => void => (tree: any) => { // Check that it has a language unsupported by our editor if (lang.startsWith(ButtonLang.Redis)) { - const execute = lang.startsWith(ButtonLang.RedisAuto) - ? ExecuteButtonMode.Auto - : ExecuteButtonMode.Manual const [, params] = lang?.split(PARAMS_SEPARATOR) + const valueWithParams = params ? `${params}\n${value}` : value codeNode.type = 'html' // Replace it with our custom component - codeNode.value = `{${JSON.stringify(value)}}` + codeNode.value = `{${JSON.stringify(valueWithParams)}}` } }) } diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/parseParams.spec.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/parseParams.spec.ts index 7e73a0612e..a6853f8562 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/parseParams.spec.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/parseParams.spec.ts @@ -6,6 +6,7 @@ const parseParamsTests: any[] = [ ['[execute=auto;]', { execute: 'auto' }], ['[execute=auto;mode=group]', { execute: 'auto', mode: 'group' }], ['[execute=auto;mode=group;]', { execute: 'auto', mode: 'group' }], + ['[execute=auto; mode=group; ]', { execute: 'auto', mode: 'group' }], ] describe('parseParams', () => { diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkRedisCode.spec.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkRedisCode.spec.ts index ad2b31ca82..4a5200db4d 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkRedisCode.spec.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkRedisCode.spec.ts @@ -4,8 +4,8 @@ import { remarkRedisCode } from '../remarkRedisCode' jest.mock('unist-util-visit') -const getValue = (meta: string, execute = ExecuteButtonMode.Manual, params?: string, value?: string) => - `{${JSON.stringify(value)}}` +const getValue = (meta: string = ExecuteButtonMode.Manual, params?: string, value?: string) => + `{${JSON.stringify(value)}}` describe('remarkRedisCode', () => { it('should not modify codeNode if lang not redis', () => { @@ -40,26 +40,48 @@ describe('remarkRedisCode', () => { expect(codeNode).toEqual({ ...codeNode, type: 'html', - value: getValue(codeNode.meta, ExecuteButtonMode.Manual, undefined, '1') + value: getValue(codeNode.meta, undefined, '1') }) }) - it('should properly modify codeNode with lang redis-auto', () => { - const codeNode = { - lang: 'redis-auto', - value: '1', - meta: '2' - }; - // mock implementation - (visit as jest.Mock) - .mockImplementation((_tree: any, _name: string, callback: (codeNode: any) => void) => { callback(codeNode) }) + describe('should properly modify codeNode with lang redis', () => { + it('with auto execute param redis:[auto=true;results=group]', () => { + const paramsWithAuto = '[auto=true;results=group]' + const codeNode = { + lang: `redis:${paramsWithAuto}`, + value: '1', + meta: '2' + }; + // mock implementation + (visit as jest.Mock) + .mockImplementation((_tree: any, _name: string, callback: (codeNode: any) => void) => { callback(codeNode) }) - const remark = remarkRedisCode() - remark({} as Node) - expect(codeNode).toEqual({ - ...codeNode, - type: 'html', - value: getValue(codeNode.meta, ExecuteButtonMode.Auto, undefined, '1') + const remark = remarkRedisCode() + remark({} as Node) + expect(codeNode).toEqual({ + ...codeNode, + type: 'html', + value: getValue(codeNode.meta, paramsWithAuto, `${paramsWithAuto}\n1`) + }) + }) + it('without auto execute param redis:[results=group;pipeline=2]', () => { + const paramsWithoutAuto = '[results=group;pipeline=2]' + const codeNode = { + lang: `redis:${paramsWithoutAuto}`, + value: '1', + meta: '2' + }; + // mock implementation + (visit as jest.Mock) + .mockImplementation((_tree: any, _name: string, callback: (codeNode: any) => void) => { callback(codeNode) }) + + const remark = remarkRedisCode() + remark({} as Node) + expect(codeNode).toEqual({ + ...codeNode, + type: 'html', + value: getValue(codeNode.meta, paramsWithoutAuto, `${paramsWithoutAuto}\n1`) + }) }) }) }) diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/interfaces.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/interfaces.ts index 25134e89d7..96d23f55b7 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/interfaces.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/interfaces.ts @@ -1,8 +1,9 @@ -import { CodeButtonResults, CodeButtonRunQueryMode } from 'uiSrc/constants' +import { CodeButtonAutoExecute, CodeButtonResults, CodeButtonRunQueryMode } from 'uiSrc/constants' export interface CodeButtonParams { clearEditor?: boolean pipeline?: string + auto?: keyof typeof CodeButtonAutoExecute results?: keyof typeof CodeButtonResults mode?: keyof typeof CodeButtonRunQueryMode } diff --git a/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.tsx b/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.tsx index cc5089254a..0a191d3ed0 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.tsx @@ -84,7 +84,11 @@ const WBResults = (props: Props) => { activeResultsMode={activeResultsMode} resultsMode={resultsMode} onQueryOpen={() => onQueryOpen(id)} - onQueryReRun={() => onQueryReRun(command, null, { clearEditor: false })} + onQueryReRun={() => onQueryReRun( + command, + null, + { mode, results: resultsMode, clearEditor: false, }, + )} onQueryDelete={() => onQueryDelete(id)} /> ))} diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx index a0ad01cba2..b1cf70e03c 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx @@ -3,7 +3,7 @@ import { useDispatch, useSelector } from 'react-redux' import { decode } from 'html-entities' import { useParams } from 'react-router-dom' import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api' -import { chunk, without } from 'lodash' +import { chunk, first, without } from 'lodash' import { CodeButtonParams } from 'uiSrc/pages/workbench/components/enablement-area/interfaces' import { @@ -14,6 +14,9 @@ import { scrollIntoView, getExecuteParams, isGroupMode, + isParamsLine, + getMonacoLines, + Maybe, } from 'uiSrc/utils' import { localStorageService } from 'uiSrc/services' import { @@ -41,6 +44,8 @@ import { CreateCommandExecutionsDto } from 'apiSrc/modules/workbench/dto/create- import WBView from './WBView' +import { parseParams } from '../enablement-area/EnablementArea/utils' + interface IState extends ExecuteQueryParams { loading: boolean instance: Instance @@ -244,15 +249,26 @@ const WBViewWrapper = () => { } const sourceValueSubmit = ( - value?: string, + value: string = script, commandId?: Nullable, executeParams: CodeButtonParams = { clearEditor: true } ) => { if (state.loading || (!value && !script)) return + let parsedParams: Maybe = {} + const lines = getMonacoLines(value) + + if (isParamsLine(first(lines))) { + const params = lines.shift() + ?.replaceAll?.('\n', '') + ?? '' + parsedParams = parseParams(params) + } + const { clearEditor } = executeParams - handleSubmit(value, commandId, executeParams) - if (cleanupWB && clearEditor) { + handleSubmit(value, commandId, { ...executeParams, ...parsedParams }) + + if (cleanupWB && clearEditor && lines.length) { resetCommand() } } diff --git a/redisinsight/ui/src/slices/interfaces/workbench.ts b/redisinsight/ui/src/slices/interfaces/workbench.ts index 5c83532773..191e0369e3 100644 --- a/redisinsight/ui/src/slices/interfaces/workbench.ts +++ b/redisinsight/ui/src/slices/interfaces/workbench.ts @@ -48,6 +48,11 @@ export enum RunQueryMode { ASCII = 'ASCII', } +export enum AutoExecute { + True = 'true', + False = 'false', +} + export enum ResultsMode { Default = 'DEFAULT', GroupMode = 'GROUP_MODE', diff --git a/redisinsight/ui/src/styles/base/_monaco.scss b/redisinsight/ui/src/styles/base/_monaco.scss index 567e08b9c8..f039c8f160 100644 --- a/redisinsight/ui/src/styles/base/_monaco.scss +++ b/redisinsight/ui/src/styles/base/_monaco.scss @@ -36,22 +36,25 @@ overflow: auto; } +.monaco-params-line { + color: var(--monacoParamsColor) !important; +} + .monaco-glyph-run-command { color: var(--rsSubmitBtn); margin-left: 10px; opacity: 0.5 !important; &::before { - content: ''; + content: ""; width: 16px; height: 16px; - mask-image: url('uiSrc/assets/img/play_icon.svg'); - -webkit-mask-image: url('uiSrc/assets/img/play_icon.svg'); + mask-image: url("uiSrc/assets/img/play_icon.svg"); + -webkit-mask-image: url("uiSrc/assets/img/play_icon.svg"); background-color: var(--rsSubmitBtn); background-size: contain; font-size: 16px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, - Helvetica, Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } } diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index 8bf11c6c6c..1d02ffc560 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss @@ -131,6 +131,7 @@ --monacoBgColor: #{$monacoBgColor}; --highlightDotColor: #{$highlightDotColor}; + --monacoParamsColor: #{$monacoParamsColor}; --successBorderColor: #{$successBorderColor}; --warningBorderColor: #{$warningBorderColor}; diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss index 3805a4afc1..78335f7076 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -91,6 +91,7 @@ $overlayPromoNYColor: #0000001a; $monacoBgColor: #111; $highlightDotColor: #2BBBB2; +$monacoParamsColor: #EC85AA; $successBorderColor: #13A450; $warningBorderColor: #9D6901; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss index 1db959d99f..8befba4917 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss @@ -133,6 +133,7 @@ --monacoBgColor: #{$monacoBgColor}; --highlightDotColor: #{$highlightDotColor}; + --monacoParamsColor: #{$monacoParamsColor}; --successBorderColor: #{$successBorderColor}; --warningBorderColor: #{$warningBorderColor}; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss index b359cf7102..cd755456ab 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -88,6 +88,7 @@ $overlayPromoNYColor: #ffffff1a; $monacoBgColor: #f0f2f7; $highlightDotColor: #2BBBB2; +$monacoParamsColor: #B63563; $successBorderColor: #5BC69B; $warningBorderColor: #FFAF2B; diff --git a/redisinsight/ui/src/utils/monaco/monacoUtils.ts b/redisinsight/ui/src/utils/monaco/monacoUtils.ts index 1ae2ff7319..94b7857111 100644 --- a/redisinsight/ui/src/utils/monaco/monacoUtils.ts +++ b/redisinsight/ui/src/utils/monaco/monacoUtils.ts @@ -1,5 +1,5 @@ import * as monacoEditor from 'monaco-editor' -import { isEmpty, isUndefined, reject } from 'lodash' +import { first, isEmpty, isUndefined, reject } from 'lodash' import { ICommands } from 'uiSrc/constants' import { IMonacoCommand, IMonacoQuery } from './monacoInterfaces' import { Nullable } from '../types' @@ -24,7 +24,7 @@ const removeCommentsFromLine = (text: string = '', prefix: string = ''): string export const splitMonacoValuePerLines = (command = '') => { const linesResult: string[] = [] - const lines = command.split(/\n(?=[^\s])/g) + const lines = getMonacoLines(command) lines.forEach((line) => { const [commandLine, countRepeat] = getCommandRepeat(line || '') @@ -34,6 +34,11 @@ export const splitMonacoValuePerLines = (command = '') => { } linesResult.push(...Array(countRepeat).fill(commandLine)) }) + + // remove execute params + if (isParamsLine(first(linesResult))) { + linesResult.shift() + } return linesResult } @@ -195,3 +200,11 @@ export const createSyntaxWidget = (text: string, shortcutText: string) => { return widget } + +export const isParamsLine = (commandInit: string = '') => { + const command = commandInit.trim() + return command.startsWith('[') && (command.indexOf(']') !== -1) +} + +export const getMonacoLines = (command: string = '') => + command.split(/\n(?=[^\s])/g) diff --git a/redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts b/redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts index 81b4538b1d..e411d5e0d0 100644 --- a/redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts +++ b/redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts @@ -2,7 +2,9 @@ import { multilineCommandToOneLine, removeMonacoComments, splitMonacoValuePerLines, - findArgIndexByCursor + findArgIndexByCursor, + isParamsLine, + getMonacoLines } from 'uiSrc/utils' describe('removeMonacoComments', () => { @@ -96,6 +98,11 @@ describe('splitMonacoValuePerLines', () => { 'get test\n3get test2\nget bar', ['get test', '3get test2', 'get bar'] ], + // Multi commands with parameters and repeating syntax error + [ + '[results=group;mode=raw]\nget test\n3get test2\nget bar', + ['get test', '3get test2', 'get bar'] + ], ] test.each(cases)( 'given %p as argument, returns %p', @@ -147,3 +154,38 @@ describe('findArgIndexByCursor', () => { } ) }) + +describe('isParamsLine', () => { + const cases = [ + ['[1]', true], + ['[1', false], + ['[groups=raw]', true], + ['[groups=raw]', true], + ['1]', false], + ['1[groups=raw]', false], + ['[groups==]aw', true], + ] + test.each(cases)( + 'given %p as argument, returns %p', + (arg: string, expectedResult) => { + const result = isParamsLine(arg) + expect(result).toEqual(expectedResult) + } + ) +}) + +describe('getMonacoLines', () => { + const cases = [ + ['1', ['1']], + ['[1', ['[1']], + ['1\n2', ['1', '2']], + ['[groups=raw] \neget test\nget test2', ['[groups=raw] ', 'eget test', 'get test2']], + ] + test.each(cases)( + 'given %p as argument, returns %p', + (arg: string, expectedResult) => { + const result = getMonacoLines(arg) + expect(result).toEqual(expectedResult) + } + ) +}) diff --git a/redisinsight/ui/src/utils/workbench.ts b/redisinsight/ui/src/utils/workbench.ts index d2896622bb..fb81328ff7 100644 --- a/redisinsight/ui/src/utils/workbench.ts +++ b/redisinsight/ui/src/utils/workbench.ts @@ -2,7 +2,7 @@ import { isInteger } from 'lodash' import { CodeButtonResults, CodeButtonRunQueryMode } from 'uiSrc/constants' import { CodeButtonParams } from 'uiSrc/pages/workbench/components/enablement-area/interfaces' import { WBQueryType } from 'uiSrc/pages/workbench/constants' -import { ExecuteQueryParams, IPluginVisualization, ResultsMode } from 'uiSrc/slices/interfaces' +import { ExecuteQueryParams, IPluginVisualization, ResultsMode, RunQueryMode } from 'uiSrc/slices/interfaces' import { getVisualizationsByCommand } from 'uiSrc/utils/plugins' const getWBQueryType = (query: string = '', views: IPluginVisualization[] = []) => { @@ -30,5 +30,6 @@ const getExecuteParams = (params: CodeButtonParams = {}, state: ExecuteQueryPara } const isGroupMode = (mode?: ResultsMode) => mode === ResultsMode.GroupMode +const isRawMode = (mode?: RunQueryMode) => mode === RunQueryMode.Raw -export { getWBQueryType, getExecuteParams, isGroupMode } +export { getWBQueryType, getExecuteParams, isGroupMode, isRawMode } From d341e9689ff56bda959c7335b8ec03b514dbc967 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 9 Jan 2023 13:58:44 +0400 Subject: [PATCH 119/201] #RI-3972 - update search modules --- redisinsight/api/src/constants/redis-modules.ts | 14 ++++++++++++-- .../providers/recommendation.provider.ts | 4 ++-- .../src/constants/dbAnalysisRecommendations.json | 8 ++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/redisinsight/api/src/constants/redis-modules.ts b/redisinsight/api/src/constants/redis-modules.ts index ecf34cf7cf..0fd0cbaef2 100644 --- a/redisinsight/api/src/constants/redis-modules.ts +++ b/redisinsight/api/src/constants/redis-modules.ts @@ -8,8 +8,18 @@ export enum AdditionalRedisModuleName { RedisTimeSeries = 'timeseries', } -export const REDIS_SEARCH_MODULES = [ - AdditionalRedisModuleName.RediSearch, +enum RediSearchModuleName { + Search = 'search', + SearchLight = 'searchlight', + FT = 'ft', + FTL = 'ftl', +} + +export const REDISEARCH_MODULES = [ + RediSearchModuleName.Search, + RediSearchModuleName.SearchLight, + RediSearchModuleName.FT, + RediSearchModuleName.FTL, ]; export const SUPPORTED_REDIS_MODULES = Object.freeze({ diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 4a22923545..2656b49cc5 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -3,7 +3,7 @@ import { Redis, Cluster, Command } from 'ioredis'; import { get } from 'lodash'; import * as semverCompare from 'node-version-compare'; import { convertRedisInfoReplyToObject, convertBulkStringsToObject, convertStringsArrayToObject } from 'src/utils'; -import { RECOMMENDATION_NAMES, IS_TIMESTAMP, REDIS_SEARCH_MODULES } from 'src/constants'; +import { RECOMMENDATION_NAMES, IS_TIMESTAMP, REDISEARCH_MODULES } from 'src/constants'; import { RedisDataType } from 'src/modules/browser/dto'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { Key } from 'src/modules/database-analysis/models'; @@ -342,7 +342,7 @@ export class RecommendationProvider { new Command('module', ['list'], { replyEncoding: 'utf8' }), ) as any[]; const modules = reply.map((module: any[]) => convertStringsArrayToObject(module)); - const isRediSearchModule = modules.some(({ name }) => REDIS_SEARCH_MODULES + const isRediSearchModule = modules.some(({ name }) => REDISEARCH_MODULES .some((search: string) => name === search)); if (isRediSearchModule) { diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 963ef1a1a4..c4fea11619 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -425,7 +425,7 @@ "id": "13", "type": "link", "value": { - "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight/", + "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight_recommendations/", "name": "free Redis Stack database" } }, @@ -516,7 +516,7 @@ "id": "4", "type": "link", "value": { - "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight/", + "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight_recommendations/", "name": "free Redis Stack database" } }, @@ -552,7 +552,7 @@ "id": "4", "type": "link", "value": { - "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight/", + "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight_recommendations/", "name": "free Redis Stack database" } }, @@ -601,7 +601,7 @@ "id": "4", "type": "link", "value": { - "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight/", + "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight_recommendations/", "name": "free Redis Stack database" } }, From c6994916e37e1c0346b6788d7304269e4e211ecc Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Mon, 9 Jan 2023 11:32:19 +0100 Subject: [PATCH 120/201] fix --- tests/e2e/tests/regression/cli/cli-re-cluster.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/tests/regression/cli/cli-re-cluster.e2e.ts b/tests/e2e/tests/regression/cli/cli-re-cluster.e2e.ts index 1fbfa03dd9..6f470fb1b6 100644 --- a/tests/e2e/tests/regression/cli/cli-re-cluster.e2e.ts +++ b/tests/e2e/tests/regression/cli/cli-re-cluster.e2e.ts @@ -95,7 +95,7 @@ test await deleteAllSentinelDatabasesApi(ossSentinelConfig); })('Verify that user can add data via CLI in Sentinel Primary Group', async() => { // Verify that database index switcher displayed for Sentinel - await t.expect(databaseOverviewPage.changeIndexBtn.exists).notOk('Change Db index control not displayed for Sentinel DB'); + await t.expect(databaseOverviewPage.changeIndexBtn.exists).ok('Change Db index control not displayed for Sentinel DB'); await verifyCommandsInCli(); }); From e094714d7cb04245c1afe16585b0ee0a5a5be91c Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 9 Jan 2023 15:06:01 +0400 Subject: [PATCH 121/201] #RI-3972 - resolve comments --- .../api/src/constants/redis-modules.ts | 14 ---- .../providers/recommendation.provider.spec.ts | 71 ++++--------------- .../providers/recommendation.provider.ts | 13 ++-- 3 files changed, 17 insertions(+), 81 deletions(-) diff --git a/redisinsight/api/src/constants/redis-modules.ts b/redisinsight/api/src/constants/redis-modules.ts index 0fd0cbaef2..85bac9a523 100644 --- a/redisinsight/api/src/constants/redis-modules.ts +++ b/redisinsight/api/src/constants/redis-modules.ts @@ -8,20 +8,6 @@ export enum AdditionalRedisModuleName { RedisTimeSeries = 'timeseries', } -enum RediSearchModuleName { - Search = 'search', - SearchLight = 'searchlight', - FT = 'ft', - FTL = 'ftl', -} - -export const REDISEARCH_MODULES = [ - RediSearchModuleName.Search, - RediSearchModuleName.SearchLight, - RediSearchModuleName.FT, - RediSearchModuleName.FTL, -]; - export const SUPPORTED_REDIS_MODULES = Object.freeze({ ai: AdditionalRedisModuleName.RedisAI, graph: AdditionalRedisModuleName.RedisGraph, diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts index 1c9e91a2e5..760a5a25a1 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -34,27 +34,6 @@ const mockRedisAclListResponse_2: string[] = [ 'user test_2 on nopass ~* &* +@all', ]; -const mockRedisModulesResponse_1 = [ - { name: 'ai', ver: 10000 }, - { name: 'graph', ver: 10000 }, - { name: 'rg', ver: 10000 }, - { name: 'bf', ver: 10000 }, - { name: 'ReJSON', ver: 10000 }, - { name: 'search', ver: 10000 }, - { name: 'timeseries', ver: 10000 }, - { name: 'customModule', ver: 10000 }, -].map((item) => ([].concat(...Object.entries(item)))); - -const mockRedisModulesResponse_2 = [ - { name: 'ai', ver: 10000 }, - { name: 'graph', ver: 10000 }, - { name: 'rg', ver: 10000 }, - { name: 'bf', ver: 10000 }, - { name: 'ReJSON', ver: 10000 }, - { name: 'timeseries', ver: 10000 }, - { name: 'customModule', ver: 10000 }, -].map((item) => ([].concat(...Object.entries(item)))); - const mockFTListResponse_1 = []; const mockFTListResponse_2 = ['idx']; @@ -566,25 +545,7 @@ describe('RecommendationProvider', () => { }); describe('determineRediSearchRecommendation', () => { - it('should not return rediSearch recommendation when rediSearch module was download with indexes', async () => { - when(nodeClient.sendCommand) - .calledWith(jasmine.objectContaining({ name: 'module' })) - .mockResolvedValue(mockRedisModulesResponse_1); - - when(nodeClient.sendCommand) - .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) - .mockResolvedValue(mockFTListResponse_2); - - const redisServerRecommendation = await service - .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); - expect(redisServerRecommendation).toEqual(null); - }); - it('should return rediSearch recommendation when there is JSON key', async () => { - when(nodeClient.sendCommand) - .calledWith(jasmine.objectContaining({ name: 'module' })) - .mockResolvedValue(mockRedisModulesResponse_1); - when(nodeClient.sendCommand) .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) .mockResolvedValue(mockFTListResponse_1); @@ -595,10 +556,6 @@ describe('RecommendationProvider', () => { }); it('should return rediSearch recommendation when there is huge string key', async () => { - when(nodeClient.sendCommand) - .calledWith(jasmine.objectContaining({ name: 'module' })) - .mockResolvedValue(mockRedisModulesResponse_1); - when(nodeClient.sendCommand) .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) .mockResolvedValue(mockFTListResponse_1); @@ -609,10 +566,6 @@ describe('RecommendationProvider', () => { }); it('should not return rediSearch recommendation when there is small string key', async () => { - when(nodeClient.sendCommand) - .calledWith(jasmine.objectContaining({ name: 'module' })) - .mockResolvedValue(mockRedisModulesResponse_1); - when(nodeClient.sendCommand) .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) .mockResolvedValue(mockFTListResponse_1); @@ -622,31 +575,33 @@ describe('RecommendationProvider', () => { expect(redisServerRecommendation).toEqual(null); }); - it('should not return rediSearch recommendation when ft command execute with error', async () => { - when(nodeClient.sendCommand) - .calledWith(jasmine.objectContaining({ name: 'module' })) - .mockResolvedValue(mockRedisModulesResponse_1); - + it('should not return rediSearch recommendation when there are no indexes', async () => { when(nodeClient.sendCommand) .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) - .mockRejectedValue("some error"); + .mockResolvedValue(mockFTListResponse_2); const redisServerRecommendation = await service .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); expect(redisServerRecommendation).toEqual(null); }); - it('should not return rediSearch recommendation when module command execute with error', async () => { + it('should ignore errors when ft command execute with error', async () => { when(nodeClient.sendCommand) - .calledWith(jasmine.objectContaining({ name: 'module' })) - .mockResolvedValue("some error"); + .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) + .mockRejectedValue("some error"); + + const redisServerRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); + expect(redisServerRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_SEARCH }); + }); + it('should ignore errors when ft command execute with error', async () => { when(nodeClient.sendCommand) .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) - .mockResolvedValue(mockFTListResponse_1); + .mockRejectedValue("some error"); const redisServerRecommendation = await service - .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); + .determineRediSearchRecommendation(nodeClient, [mockRediSearchStringKey_2]); expect(redisServerRecommendation).toEqual(null); }); }); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 2656b49cc5..291578e88b 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -3,7 +3,7 @@ import { Redis, Cluster, Command } from 'ioredis'; import { get } from 'lodash'; import * as semverCompare from 'node-version-compare'; import { convertRedisInfoReplyToObject, convertBulkStringsToObject, convertStringsArrayToObject } from 'src/utils'; -import { RECOMMENDATION_NAMES, IS_TIMESTAMP, REDISEARCH_MODULES } from 'src/constants'; +import { RECOMMENDATION_NAMES, IS_TIMESTAMP } from 'src/constants'; import { RedisDataType } from 'src/modules/browser/dto'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { Key } from 'src/modules/database-analysis/models'; @@ -338,20 +338,15 @@ export class RecommendationProvider { keys: Key[], ): Promise { try { - const reply = await redisClient.sendCommand( - new Command('module', ['list'], { replyEncoding: 'utf8' }), - ) as any[]; - const modules = reply.map((module: any[]) => convertStringsArrayToObject(module)); - const isRediSearchModule = modules.some(({ name }) => REDISEARCH_MODULES - .some((search: string) => name === search)); - - if (isRediSearchModule) { + try { const indexes = await redisClient.sendCommand( new Command('FT._LIST', [], { replyEncoding: 'utf8' }), ) as any[]; if (indexes.length) { return null; } + } catch (err) { + // Ignore errors } const isBigStringOrJSON = keys.some((key) => ( From 9fdf09278f881c7591bdefd78787d7dc05478522 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Mon, 9 Jan 2023 16:00:00 +0400 Subject: [PATCH 122/201] #RI-3527 - fix recommendation indexes --- redisinsight/ui/src/constants/dbAnalysisRecommendations.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index c4fea11619..3737dc96ef 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -598,7 +598,7 @@ "value": "Create a " }, { - "id": "4", + "id": "6", "type": "link", "value": { "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight_recommendations/", @@ -606,7 +606,7 @@ } }, { - "id": "5", + "id": "7", "type": "span", "value": " which extends the core capabilities of Redis OSS and uses modern data models and processing engines." } From 44e59ef5bfe3b74f97021e89ef789c55ea879852 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Mon, 9 Jan 2023 15:17:16 +0300 Subject: [PATCH 123/201] #RI-3975 - fix layout, add tests --- .../AddDatabases/AddDatabasesContainer.tsx | 4 +- .../InstanceForm/InstanceForm.spec.tsx | 269 ++++++++++++++++- .../InstanceForm/InstanceForm.tsx | 2 +- .../InstanceForm/form-components/DbIndex.tsx | 5 +- .../form-components/SSHDetails.tsx | 279 ++++++++++-------- .../form-components/TlsDetails.tsx | 4 +- .../InstanceForm/styles.module.scss | 8 +- 7 files changed, 435 insertions(+), 136 deletions(-) diff --git a/redisinsight/ui/src/pages/home/components/AddDatabases/AddDatabasesContainer.tsx b/redisinsight/ui/src/pages/home/components/AddDatabases/AddDatabasesContainer.tsx index b1ad7f4aa3..08121fee24 100644 --- a/redisinsight/ui/src/pages/home/components/AddDatabases/AddDatabasesContainer.tsx +++ b/redisinsight/ui/src/pages/home/components/AddDatabases/AddDatabasesContainer.tsx @@ -133,7 +133,7 @@ const AddDatabasesContainer = React.memo((props: Props) => { const radioBtnLegend = isResizablePanel ? '' : Connect to: - const onChange = (optionId: string) => { + const onChange = (optionId: InstanceType) => { setTypeSelected(optionId) } @@ -157,7 +157,7 @@ const AddDatabasesContainer = React.memo((props: Props) => { options={typesFormStage} idSelected={typeSelected} className="dbTypes" - onChange={(id: string) => onChange(id)} + onChange={(id) => onChange(id as InstanceType)} name="radio group" legend={{ children: radioBtnLegend, diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx index 9562c40556..8819e9ed37 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx @@ -9,6 +9,7 @@ import { DbConnectionInfo } from './interfaces' const BTN_SUBMIT = 'btn-submit' const NEW_CA_CERT = 'new-ca-cert' const QA_CA_CERT = 'qa-ca-cert' +const RADIO_BTN_PRIVATE_KEY = '[data-test-subj="radio-btn-privateKey"] label' const mockedProps = mock() const mockedDbConnectionInfo = mock() @@ -193,7 +194,7 @@ describe('InstanceForm', () => { ) }) - it('should change tls checkbox', async () => { + it('should change db checkbox and value', async () => { const handleSubmit = jest.fn() render(
    @@ -636,4 +637,270 @@ describe('InstanceForm', () => { expect(screen.getByTestId('port')).toHaveValue('26379') }) }) + + it('should change Use SSH checkbox', async () => { + const handleSubmit = jest.fn() + render( +
    + +
    + ) + + fireEvent.click(screen.getByTestId('use-ssh')) + + expect(screen.getByTestId('use-ssh')).toBeChecked() + }) + + it('should change Use SSH checkbox and show proper fields for password radio', async () => { + const handleSubmit = jest.fn() + render( +
    + +
    + ) + + fireEvent.click(screen.getByTestId('use-ssh')) + + expect(screen.getByTestId('sshHost')).toBeInTheDocument() + expect(screen.getByTestId('sshPort')).toBeInTheDocument() + expect(screen.getByTestId('sshPort')).toHaveValue('22') + expect(screen.getByTestId('sshPassword')).toBeInTheDocument() + expect(screen.queryByTestId('sshPrivateKey')).not.toBeInTheDocument() + expect(screen.queryByTestId('sshPassphrase')).not.toBeInTheDocument() + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + expect(submitBtn).toBeDisabled() + }) + + it('should change Use SSH checkbox and show proper fields for passphrase radio', async () => { + const handleSubmit = jest.fn() + const { container } = render( +
    + +
    + ) + + await act(() => { + fireEvent.click(screen.getByTestId('use-ssh')) + fireEvent.click( + container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement + ) + }) + + expect(screen.getByTestId('sshHost')).toBeInTheDocument() + expect(screen.getByTestId('sshPort')).toBeInTheDocument() + expect(screen.getByTestId('sshPort')).toHaveValue('22') + expect(screen.queryByTestId('sshPassword')).not.toBeInTheDocument() + expect(screen.getByTestId('sshPrivateKey')).toBeInTheDocument() + expect(screen.getByTestId('sshPassphrase')).toBeInTheDocument() + + const submitBtn = screen.getByTestId(BTN_SUBMIT) + expect(submitBtn).toBeDisabled() + }) + + it('should be proper validation for ssh via ssh password', async () => { + const handleSubmit = jest.fn() + render( +
    + +
    + ) + + expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + + await act(() => { + fireEvent.click(screen.getByTestId('use-ssh')) + }) + + expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + + await act(() => { + fireEvent.change( + screen.getByTestId('sshHost'), + { target: { value: 'localhost' } } + ) + }) + + expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + }) + + it('should be proper validation for ssh via ssh passphrase', async () => { + const handleSubmit = jest.fn() + const { container } = render( +
    + +
    + ) + + expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + + await act(() => { + fireEvent.click(screen.getByTestId('use-ssh')) + fireEvent.click( + container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement + ) + }) + + expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + + await act(() => { + fireEvent.change( + screen.getByTestId('sshHost'), + { target: { value: 'localhost' } } + ) + }) + + expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + + await act(() => { + fireEvent.change( + screen.getByTestId('sshPrivateKey'), + { target: { value: 'PRIVATEKEY' } } + ) + }) + + expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + }) + + it('should call submit btn with proper fields', async () => { + const handleSubmit = jest.fn() + render( +
    + +
    + ) + + await act(() => { + fireEvent.click(screen.getByTestId('use-ssh')) + }) + + await act(() => { + fireEvent.change( + screen.getByTestId('sshHost'), + { target: { value: 'localhost' } } + ) + + fireEvent.change( + screen.getByTestId('sshPort'), + { target: { value: '1771' } } + ) + + fireEvent.change( + screen.getByTestId('sshPassword'), + { target: { value: '123' } } + ) + }) + + await act(() => { + fireEvent.click(screen.getByTestId(BTN_SUBMIT)) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + sshHost: 'localhost', + sshPort: '1771', + sshPassword: '123', + }) + ) + }) + + it('should call submit btn with proper fields via passphrase', async () => { + const handleSubmit = jest.fn() + const { container } = render( +
    + +
    + ) + + await act(() => { + fireEvent.click(screen.getByTestId('use-ssh')) + fireEvent.click( + container.querySelector(RADIO_BTN_PRIVATE_KEY) as HTMLLabelElement + ) + }) + + await act(() => { + fireEvent.change( + screen.getByTestId('sshHost'), + { target: { value: 'localhost' } } + ) + + fireEvent.change( + screen.getByTestId('sshPort'), + { target: { value: '1771' } } + ) + + fireEvent.change( + screen.getByTestId('sshPrivateKey'), + { target: { value: '123444' } } + ) + + fireEvent.change( + screen.getByTestId('sshPassphrase'), + { target: { value: '123444' } } + ) + }) + + await act(() => { + fireEvent.click(screen.getByTestId(BTN_SUBMIT)) + }) + + expect(handleSubmit).toBeCalledWith( + expect.objectContaining({ + sshHost: 'localhost', + sshPort: '1771', + sshPrivateKey: '123444', + sshPassphrase: '123444', + }) + ) + }) }) diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx index 9c338e150d..e6b36916c5 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx @@ -117,7 +117,7 @@ const AddStandaloneForm = (props: Props) => { servername, provider, ssh, - sshPassType, + sshPassType = SshPassType.Password, sshOptions }, initialValues: initialValuesProp, diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx index 176aff21c6..18c8614988 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx @@ -29,8 +29,11 @@ const DbIndex = (props: Props) => { return ( <> { [styles.tlsSniOpened]: !!formik.values.ssh })} alignItems={!flexGroupClassName ? 'flexEnd' : undefined} + responsive={false} > { label="Use SSH Tunnel" checked={!!formik.values.ssh} onChange={formik.handleChange} - data-testid="ssh" + data-testid="use-ssh" /> + + + {formik.values.ssh && ( + <> + + + + ) => { + formik.setFieldValue( + e.target.name, + validateField(e.target.value.trim()) + ) + }} + /> + + + + + + ) => { + formik.setFieldValue( + e.target.name, + validatePortNumber(e.target.value.trim()) + ) + }} + onFocus={selectOnFocus} + type="text" + min={0} + max={MAX_PORT_NUMBER} + /> + + + + + + + + ) => { + formik.setFieldValue( + e.target.name, + validateField(e.target.value.trim()) + ) + }} + /> + + + + + + + formik.setFieldValue('sshPassType', id)} + data-testid="ssh-pass-type" + /> + + - {formik.values.ssh && ( - <> - - - ) => { - formik.setFieldValue( - e.target.name, - validateField(e.target.value.trim()) - ) - }} + {formik.values.sshPassType === SshPassType.Password && ( + + + + + + )} + {formik.values.sshPassType === SshPassType.PrivateKey && ( + <> + - - ) => { - formik.setFieldValue( - e.target.name, - validatePortNumber(e.target.value.trim()) - ) - }} - onFocus={selectOnFocus} - type="text" - min={0} - max={MAX_PORT_NUMBER} + + - - - - ) => { - formik.setFieldValue( - e.target.name, - validateField(e.target.value.trim()) - ) - }} + + + + + - - - formik.setFieldValue('sshPassType', id)} - data-testid="ssh-pass-type" - /> - - - {formik.values.sshPassType === SshPassType.Password && ( - - - - - - )} - - {formik.values.sshPassType === SshPassType.PrivateKey && ( - <> - - - - - - - - - - - - )} - + + )} - + + )} ) } diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx index d30b5d3396..0eb00f5224 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx @@ -210,7 +210,7 @@ const TlsDetails = (props: Props) => {
    )} {formik.values.tls && ( - + { )} {formik.values.tls && formik.values.tlsClientAuthRequired && ( -
    +
    diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/styles.module.scss b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/styles.module.scss index 972322ce1c..575bc4eebd 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/styles.module.scss @@ -151,14 +151,18 @@ } .tlsContainer { - min-height: 90px; - padding: 12px 0 10px; + margin: 12px 0 10px; flex-wrap: wrap; :global(.euiCheckbox) { padding-bottom: 10px !important; } +} +.tslBoxSection { + @media screen and (min-width: 767px) { + margin-bottom: 20px; + } } .tlsSniOpened { From ce1825697cb8580ebf5377212d5cc35f1507d3ce Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Mon, 9 Jan 2023 15:31:23 +0300 Subject: [PATCH 124/201] #RI-3975 - fix import order --- .../AddInstanceForm/InstanceForm/form-components/DbIndex.tsx | 2 +- .../InstanceForm/form-components/SSHDetails.tsx | 4 +++- .../InstanceForm/form-components/TlsDetails.tsx | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx index 18c8614988..0681dbd941 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DbIndex.tsx @@ -1,9 +1,9 @@ import React, { ChangeEvent } from 'react' import { EuiCheckbox, EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, htmlIdGenerator } from '@elastic/eui' import cx from 'classnames' +import { FormikProps } from 'formik' import { validateNumber } from 'uiSrc/utils' -import { FormikProps } from 'formik' import { DbConnectionInfo } from '../interfaces' import styles from '../styles.module.scss' diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx index 0987b8b747..f79feb8e8f 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx @@ -13,13 +13,15 @@ import { htmlIdGenerator } from '@elastic/eui' import cx from 'classnames' +import { FormikProps } from 'formik' + import { MAX_PORT_NUMBER, selectOnFocus, validateField, validatePortNumber } from 'uiSrc/utils' -import { FormikProps } from 'formik' + import { SshPassType } from '../constants' import { DbConnectionInfo } from '../interfaces' diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx index 0eb00f5224..9f5b6c8884 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/TlsDetails.tsx @@ -11,8 +11,10 @@ import { htmlIdGenerator } from '@elastic/eui' import cx from 'classnames' -import { validateCertName, validateField } from 'uiSrc/utils' import { FormikProps } from 'formik' + +import { validateCertName, validateField } from 'uiSrc/utils' + import { ADD_NEW_CA_CERT, NO_CA_CERT From 0d898ad7313a8c9e2729ab865804c5acef0278dd Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Mon, 9 Jan 2023 17:44:01 +0300 Subject: [PATCH 125/201] #RI-3992 - fix redistack for empty list of modules --- redisinsight/ui/src/utils/redistack.ts | 14 +++++++++----- redisinsight/ui/src/utils/tests/redistack.spec.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/redisinsight/ui/src/utils/redistack.ts b/redisinsight/ui/src/utils/redistack.ts index dab6b5e5a4..6d5be99880 100644 --- a/redisinsight/ui/src/utils/redistack.ts +++ b/redisinsight/ui/src/utils/redistack.ts @@ -10,11 +10,15 @@ export const REDISTACK_MODULES: Array> = [ RedisDefaultModules.TimeSeries, ] -const checkRediStackModules = (modules: any[]) => map(modules, 'name') - .sort() - .every((m, index) => (isArray(REDISTACK_MODULES[index]) - ? (REDISTACK_MODULES[index] as Array).some((rm) => rm === m) - : REDISTACK_MODULES[index] === m)) +const checkRediStackModules = (modules: any[]) => { + if (!modules.length || modules.length !== REDISTACK_MODULES.length) return false + + return map(modules, 'name') + .sort() + .every((m, index) => (isArray(REDISTACK_MODULES[index]) + ? (REDISTACK_MODULES[index] as Array).some((rm) => rm === m) + : REDISTACK_MODULES[index] === m)) +} const checkRediStack = (instances: Instance[]): Instance[] => { let isRediStackCheck = false diff --git a/redisinsight/ui/src/utils/tests/redistack.spec.ts b/redisinsight/ui/src/utils/tests/redistack.spec.ts index 02aa49b0c0..d9006dae7c 100644 --- a/redisinsight/ui/src/utils/tests/redistack.spec.ts +++ b/redisinsight/ui/src/utils/tests/redistack.spec.ts @@ -46,6 +46,18 @@ const getOutputCheckRediStackTests: any[] = [ [{ port: 12000, modules: unmapWithName(['bf', 'timeseries', 'ReJSON', 'searchlight', 'graph']) }], [{ port: 12000, modules: unmapWithName(['bf', 'timeseries', 'ReJSON', 'searchlight', 'graph']), isRediStack: true }] ], + [ + [{ port: 12000, modules: [] }], + [{ port: 12000, modules: [], isRediStack: false }] + ], + [ + [{ port: 12000, modules: unmapWithName(['ReJSON']) }], + [{ port: 12000, modules: unmapWithName(['ReJSON']), isRediStack: false }] + ], + [ + [{ port: 12000, modules: unmapWithName(['bf', 'timeseries', 'ReJSON', 'searchlight', 'graph', 'custom']) }], + [{ port: 12000, modules: unmapWithName(['bf', 'timeseries', 'ReJSON', 'searchlight', 'graph', 'custom']), isRediStack: false }] + ], ] describe('checkRediStack', () => { From 764621f4dce68bf6817a5a7f39ae3ba3a4ac8bec Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Mon, 9 Jan 2023 18:06:30 +0300 Subject: [PATCH 126/201] #RI-3957 - fix tests --- redisinsight/ui/src/utils/redistack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/ui/src/utils/redistack.ts b/redisinsight/ui/src/utils/redistack.ts index 6d5be99880..88f9a73302 100644 --- a/redisinsight/ui/src/utils/redistack.ts +++ b/redisinsight/ui/src/utils/redistack.ts @@ -11,7 +11,7 @@ export const REDISTACK_MODULES: Array> = [ ] const checkRediStackModules = (modules: any[]) => { - if (!modules.length || modules.length !== REDISTACK_MODULES.length) return false + if (!modules?.length || modules.length !== REDISTACK_MODULES.length) return false return map(modules, 'name') .sort() From d9eb141d546af04a94ccac8fee2841f97525d2f2 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Tue, 10 Jan 2023 14:24:47 +0400 Subject: [PATCH 127/201] #RI-3973 - update rts recommendation --- redisinsight/api/package.json | 1 + redisinsight/api/src/constants/regex.ts | 3 +- .../providers/recommendation.provider.spec.ts | 50 ++++++++++++------- .../providers/recommendation.provider.ts | 33 ++++++++++-- redisinsight/api/yarn.lock | 5 ++ 5 files changed, 67 insertions(+), 25 deletions(-) diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 865b996805..65f9e7d0ed 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -53,6 +53,7 @@ "body-parser": "^1.19.0", "class-transformer": "^0.2.3", "class-validator": "^0.12.2", + "date-fns": "^2.29.3", "dotenv": "^16.0.0", "express": "^4.17.1", "fs-extra": "^10.0.0", diff --git a/redisinsight/api/src/constants/regex.ts b/redisinsight/api/src/constants/regex.ts index 8506d70ccd..08207685e3 100644 --- a/redisinsight/api/src/constants/regex.ts +++ b/redisinsight/api/src/constants/regex.ts @@ -1,6 +1,7 @@ export const ARG_IN_QUOTATION_MARKS_REGEX = /"[^"]*|'[^']*'|"+/g; export const IS_INTEGER_NUMBER_REGEX = /^\d+$/; +export const IS_NUMBER_REGEX = /^-?\d*(\.\d+)?$/; export const IS_NON_PRINTABLE_ASCII_CHARACTER = /[^ -~\u0007\b\t\n\r]/; export const IP_ADDRESS_REGEX = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; export const PRIVATE_IP_ADDRESS_REGEX = /(^127\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)/; -export const IS_TIMESTAMP = /^\d{10,}$/; +export const IS_TIMESTAMP = /^(\d{10}|\d{13}|\d{16}|\d{19})$/; diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts index 760a5a25a1..0a347eea03 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -37,13 +37,39 @@ const mockRedisAclListResponse_2: string[] = [ const mockFTListResponse_1 = []; const mockFTListResponse_2 = ['idx']; +const generateRTSRecommendationTests = [ + { input: ['0', ['123', 123]], expected: null }, + { input: ['0', ['1234567891', 3]], expected: { name: RECOMMENDATION_NAMES.RTS } }, + { input: ['0', ['1234567891', 1234567891]], expected: { name: RECOMMENDATION_NAMES.RTS } }, + { input: ['0', ['123', 1234567891]], expected: { name: RECOMMENDATION_NAMES.RTS } }, + { input: ['0', ['123', 12345678911]], expected: null }, + { input: ['0', ['123', 1234567891234]], expected: { name: RECOMMENDATION_NAMES.RTS } }, + { input: ['0', ['123', 12345678912345]], expected: null }, + { input: ['0', ['123', 1234567891234567]], expected: { name: RECOMMENDATION_NAMES.RTS } }, + { input: ['0', ['12345678912345678', 1]], expected: null }, + { input: ['0', ['1234567891234567891', 1]], expected: { name: RECOMMENDATION_NAMES.RTS } }, + { input: ['0', ['1', 1234567891.2]], expected: { name: RECOMMENDATION_NAMES.RTS } }, + { input: ['0', ['1234567891.2', 1]], expected: { name: RECOMMENDATION_NAMES.RTS } }, + { input: ['0', ['1234567891:12', 1]], expected: { name: RECOMMENDATION_NAMES.RTS } }, + { input: ['0', ['1234567891a12', 1]], expected: { name: RECOMMENDATION_NAMES.RTS } }, + { input: ['0', ['1234567891.2.2', 1]], expected: null }, + { input: ['0', ['1234567891asd', 1]], expected: null }, + { input: ['0', ['10-10-2020', 1]], expected: { name: RECOMMENDATION_NAMES.RTS } }, + { input: ['0', ['', 1]], expected: null }, + { input: ['0', ['1', -12]], expected: null }, + { input: ['0', ['1', -1234567891]], expected: null }, + { input: ['0', ['1', -1234567891.123]], expected: null }, + { input: ['0', ['1', -1234567891.123]], expected: null }, + { input: ['0', ['1234567891.-123', 1]], expected: null }, +]; + const mockZScanResponse_1 = [ '0', - [123456789, 123456789, 12345678910, 12345678910], + ['1', 1, '12345678910', 12345678910], ]; const mockZScanResponse_2 = [ '0', - [12345678910, 12345678910, 1, 1], + ['12345678910', 12345678910, 1, 1], ]; const mockKeys = [ @@ -489,28 +515,14 @@ describe('RecommendationProvider', () => { }); describe('determineRTSRecommendation', () => { - it('should not return RTS recommendation', async () => { + test.each(generateRTSRecommendationTests)('%j', async ({ input, expected }) => { when(nodeClient.sendCommand) .calledWith(jasmine.objectContaining({ name: 'zscan' })) - .mockResolvedValue(mockZScanResponse_1); + .mockResolvedValue(input); const RTSRecommendation = await service .determineRTSRecommendation(nodeClient, mockKeys); - expect(RTSRecommendation).toEqual(null); - }); - - it('should return RTS recommendation', async () => { - when(nodeClient.sendCommand) - .calledWith(jasmine.objectContaining({ name: 'zscan' })) - .mockResolvedValueOnce(mockZScanResponse_1); - - when(nodeClient.sendCommand) - .calledWith(jasmine.objectContaining({ name: 'zscan' })) - .mockResolvedValue(mockZScanResponse_2); - - const RTSRecommendation = await service - .determineRTSRecommendation(nodeClient, mockSortedSets); - expect(RTSRecommendation).toEqual({ name: RECOMMENDATION_NAMES.RTS }); + expect(RTSRecommendation).toEqual(expected); }); it('should not return RTS recommendation when only 101 sorted set contain timestamp', async () => { diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 291578e88b..b79f301ef5 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -1,9 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { Redis, Cluster, Command } from 'ioredis'; -import { get } from 'lodash'; +import { get, isNumber } from 'lodash'; +import { isValid } from 'date-fns'; import * as semverCompare from 'node-version-compare'; -import { convertRedisInfoReplyToObject, convertBulkStringsToObject, convertStringsArrayToObject } from 'src/utils'; -import { RECOMMENDATION_NAMES, IS_TIMESTAMP } from 'src/constants'; +import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/utils'; +import { + RECOMMENDATION_NAMES, IS_TIMESTAMP, IS_INTEGER_NUMBER_REGEX, IS_NUMBER_REGEX, +} from 'src/constants'; import { RedisDataType } from 'src/modules/browser/dto'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { Key } from 'src/modules/database-analysis/models'; @@ -311,8 +314,7 @@ export class RecommendationProvider { // get first member-score pair new Command('zscan', [keys[processedKeysNumber].name, '0', 'COUNT', 2], { replyEncoding: 'utf8' }), ) as string[]; - // check is pair member-score is timestamp - if (IS_TIMESTAMP.test(membersArray[0]) && IS_TIMESTAMP.test(membersArray[1])) { + if (this.checkTimestamp(membersArray[0]) || this.checkTimestamp(membersArray[1])) { isTimeSeries = true; } processedKeysNumber += 1; @@ -421,4 +423,25 @@ export class RecommendationProvider { } return false; } + + private checkTimestamp(value: string): boolean { + try { + if (!IS_NUMBER_REGEX.test(value) && isValid(new Date(value))) { + return true; + } + const integerPart = parseInt(value, 10); + if (!IS_TIMESTAMP.test(integerPart.toString())) { + return false; + } + if (isNumber(value) || integerPart.toString().length === value.length) { + return true; + } + // check part after separator + const subPart = value.replace(integerPart.toString(), ''); + return IS_INTEGER_NUMBER_REGEX.test(subPart.substring(1, subPart.length)); + } catch (err) { + // ignore errors + return false; + } + } } diff --git a/redisinsight/api/yarn.lock b/redisinsight/api/yarn.lock index beaebe7663..0c4a40f848 100644 --- a/redisinsight/api/yarn.lock +++ b/redisinsight/api/yarn.lock @@ -2626,6 +2626,11 @@ date-fns@^2.28.0: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.2.tgz#0d4b3d0f3dff0f920820a070920f0d9662c51931" integrity sha512-0VNbwmWJDS/G3ySwFSJA3ayhbURMTJLtwM2DTxf9CWondCnh6DTNlO9JgRSq6ibf4eD0lfMJNBxUdEAHHix+bA== +date-fns@^2.29.3: + version "2.29.3" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" + integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" From 6bdadf38eb5a452902b96fd7e64006b5f07aa04f Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Tue, 10 Jan 2023 14:08:36 +0300 Subject: [PATCH 128/201] #RI-3970 - change password length --- .../InstanceForm/InstanceForm.spec.tsx | 22 +++++++++++++++++++ .../form-components/DatabaseForm.tsx | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx index 8819e9ed37..0e929fb76f 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx @@ -903,4 +903,26 @@ describe('InstanceForm', () => { }) ) }) + + it('should render password input with 10_000 length limit', () => { + render( + + ) + + expect(screen.getByTestId('password')).toHaveAttribute('maxLength', '10000') + }) + + it('should render ssh password input with 10_000 length limit', () => { + render( + + ) + + expect(screen.getByTestId('sshPassword')).toHaveAttribute('maxLength', '10000') + }) }) diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DatabaseForm.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DatabaseForm.tsx index 25315f4245..b6752a68be 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DatabaseForm.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/DatabaseForm.tsx @@ -176,7 +176,7 @@ const DatabaseForm = (props: Props) => { data-testid="password" fullWidth className="passwordField" - maxLength={200} + maxLength={10_000} placeholder="Enter Password" value={formik.values.password ?? ''} onChange={formik.handleChange} From 6e4d2f9d8145bc7a08739f0ddd9997290ad5a0ca Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 10 Jan 2023 15:34:59 +0100 Subject: [PATCH 129/201] add tests for workbench modes from editor --- tests/e2e/pageObjects/workbench-page.ts | 3 + .../workbench-non-auto-guides.e2e.ts | 93 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/e2e/tests/regression/workbench/workbench-non-auto-guides.e2e.ts diff --git a/tests/e2e/pageObjects/workbench-page.ts b/tests/e2e/pageObjects/workbench-page.ts index 0de012b1b5..e7879bad1f 100644 --- a/tests/e2e/pageObjects/workbench-page.ts +++ b/tests/e2e/pageObjects/workbench-page.ts @@ -69,6 +69,9 @@ export class WorkbenchPage { documentHashCreateButton = Selector('[data-testid=preselect-auto-Create]'); //ICONS noCommandHistoryIcon = Selector('[data-testid=wb_no-results__icon]'); + parametersAnchor = Selector('[data-testid=parameters-anchor]'); + groupModeIcon = Selector('[data-testid=group-mode-tooltip]'); + rawModeIcon = Selector('[data-testid=raw-mode-tooltip]'); //LINKS timeSeriesLink = Selector('[data-testid=internal-link-redis_for_time_series]'); redisStackLinks = Selector('[data-testid=accordion-redis_stack] [data-testid^=internal-link]'); diff --git a/tests/e2e/tests/regression/workbench/workbench-non-auto-guides.e2e.ts b/tests/e2e/tests/regression/workbench/workbench-non-auto-guides.e2e.ts new file mode 100644 index 0000000000..8d04833935 --- /dev/null +++ b/tests/e2e/tests/regression/workbench/workbench-non-auto-guides.e2e.ts @@ -0,0 +1,93 @@ +import { acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; +import { WorkbenchPage, MyRedisDatabasePage, BrowserPage } from '../../../pageObjects'; +import { rte } from '../../../helpers/constants'; +import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; +import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; +import { Common } from '../../../helpers/common'; + +const myRedisDatabasePage = new MyRedisDatabasePage(); +const workbenchPage = new WorkbenchPage(); +const common = new Common(); +const browserPage = new BrowserPage(); + +const counter = 7; +const unicodeValue = '山女馬 / 马目 abc 123'; +const keyName = common.generateWord(10); +const keyValue = '\\xe5\\xb1\\xb1\\xe5\\xa5\\xb3\\xe9\\xa6\\xac / \\xe9\\xa9\\xac\\xe7\\x9b\\xae abc 123'; +const commands = [ + `[results=group] \ + ${counter} INFO`, + `[mode=raw] \ + get ${keyName}`, + `[mode=raw;results=group;pipeline=3] \ + ${counter} get ${keyName}`, + `[mode=ascii;results=single] \ + ${counter} get ${keyName}`, + `[mode=ascii;mode=raw;results=single] \ + ${counter} get ${keyName}` +]; +const commandForSend = `set ${keyName} "${keyValue}"`; + +fixture `Workbench modes to non-auto guides` + .meta({ type: 'regression', rte: rte.standalone }) + .page(commonUrl) + .beforeEach(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await t.click(myRedisDatabasePage.workbenchButton); + await workbenchPage.sendCommandInWorkbench(commandForSend); + }) + .afterEach(async t => { + // Clear and delete database + await t.click(myRedisDatabasePage.browserButton); + await browserPage.deleteKeyByName(keyName); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + }); +test('Workbench modes from editor', async t => { + const groupCommandResultName = `${counter} Command(s) - ${counter} success, 0 error(s)`; + const containerOfCommand = await workbenchPage.getCardContainerByCommand(groupCommandResultName); + + // Verify that results parameter applied from the first raw in the Workbench Editor + await workbenchPage.sendCommandInWorkbench(commands[0]); + await t.expect(workbenchPage.queryCardCommand.textContent).eql(groupCommandResultName, 'Group mode not applied'); + await t.hover(workbenchPage.parametersAnchor); + // Verify that group mode icon is displayed + await t.expect(workbenchPage.groupModeIcon.exists).ok('Group mode icon not displayed'); + + // Verify that mode parameter applied from the first raw in the Workbench Editor + await workbenchPage.sendCommandInWorkbench(commands[1]); + await workbenchPage.checkWorkbenchCommandResult(commands[1], `"${unicodeValue}"`); + await t.hover(workbenchPage.parametersAnchor); + // Verify that raw mode icon is displayed + await t.expect(workbenchPage.rawModeIcon.exists).ok('Raw mode icon not displayed'); + + // Verify that multiple parameters applied from the first raw in the Workbench Editor + await workbenchPage.sendCommandInWorkbench(commands[2]); + await t.expect(workbenchPage.queryCardCommand.textContent).eql(groupCommandResultName, 'Group mode not applied'); + await workbenchPage.checkWorkbenchCommandResult(commands[2], `"${unicodeValue}"`); + await t.hover(workbenchPage.parametersAnchor); + // Verify that raw and group mode icons are displayed + await t.expect(workbenchPage.groupModeIcon.exists).ok('Group mode icon not displayed'); + await t.expect(workbenchPage.rawModeIcon.exists).ok('Raw mode icon not displayed'); + + // Add text with parameters in Workbench editor input + await t.typeText(workbenchPage.queryInput, commands[4], { replace: true }); + // Re-run the last command in results + await t.click(containerOfCommand.find(workbenchPage.cssReRunCommandButton)); + // Verify that on re-run any command from history the same parameters specified regardless of Workbench editor input + await t.expect(workbenchPage.queryCardCommand.textContent).eql(commands[2], 'The command is not re-executed'); + + // Turn on raw and group modes + await t.click(workbenchPage.rawModeBtn); + await t.click(workbenchPage.groupMode); + await workbenchPage.sendCommandInWorkbench(commands[3]); + // Verify that Workbench Editor parameters have more priority than manually clicked modes + await t.expect(workbenchPage.queryTextResult.textContent).contains(`"${keyValue}"`, 'The mode is not applied from editor parameters'); + await t.expect(workbenchPage.queryCardCommand.textContent).eql(`get ${keyName}`, 'The result is not applied from editor parameters'); + + // Turn off raw and group modes + await t.click(workbenchPage.rawModeBtn); + await t.click(workbenchPage.groupMode); + // Verify that if user specifies the same parameters he can see the first one is applied + await workbenchPage.sendCommandInWorkbench(commands[4]); + await t.expect(workbenchPage.queryTextResult.textContent).contains(`"${keyValue}"`, 'The first duplicated parameter not applied'); +}); From 110acd704c650ede998171250a57afddc292dda1 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 11 Jan 2023 09:46:19 +0400 Subject: [PATCH 130/201] #RI-3971 - add search indexes recommnedation --- .../api/src/constants/recommendations.ts | 1 + .../database-analysis.service.ts | 56 ++++++++---- .../providers/recommendation.provider.ts | 88 +++++++++++++++++++ .../recommendation/recommendation.service.ts | 5 +- .../constants/dbAnalysisRecommendations.json | 54 ++++++++++++ .../Recommendations.spec.tsx | 15 ++++ 6 files changed, 202 insertions(+), 17 deletions(-) diff --git a/redisinsight/api/src/constants/recommendations.ts b/redisinsight/api/src/constants/recommendations.ts index 80b50e5b66..bb698e06dc 100644 --- a/redisinsight/api/src/constants/recommendations.ts +++ b/redisinsight/api/src/constants/recommendations.ts @@ -16,4 +16,5 @@ export const RECOMMENDATION_NAMES = Object.freeze({ RTS: 'RTS', REDIS_VERSION: 'redisVersion', REDIS_SEARCH: 'redisSearch', + SEARCH_INDEXES: 'searchIndexes', }); diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index a919e7b660..25f5720b20 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -1,5 +1,5 @@ import { HttpException, Injectable, Logger } from '@nestjs/common'; -import { isNull, flatten, uniqBy } from 'lodash'; +import { isNull, flatten, concat } from 'lodash'; import { RecommendationService } from 'src/modules/recommendation/recommendation.service'; import { catchAclError } from 'src/utils'; import { RECOMMENDATION_NAMES } from 'src/constants'; @@ -56,18 +56,41 @@ export class DatabaseAnalysisService { }); const recommendations = DatabaseAnalysisService.getRecommendationsSummary( - flatten(await Promise.all( - scanResults.map(async (nodeResult, idx) => ( - await this.recommendationService.getRecommendations({ - client: nodeResult.client, - keys: nodeResult.keys, - total: progress.total, - // TODO: create generic solution to exclude recommendations - exclude: idx !== 0 ? [RECOMMENDATION_NAMES.RTS] : [], - }) - )), - )), + // flatten(await Promise.all( + // scanResults.map(async (nodeResult, idx) => ( + // await this.recommendationService.getRecommendations({ + // client: nodeResult.client, + // keys: nodeResult.keys, + // total: progress.total, + // globalClient: client, + // // TODO: create generic solution to exclude recommendations + // exclude: idx !== 0 ? [RECOMMENDATION_NAMES.RTS] : [], + // }) + // )), + // )), + await scanResults.reduce(async (previousPromise, nodeResult) => { + const jobsArray = await previousPromise; + let recommendationToExclude = []; + const nodeRecommendations = await this.recommendationService.getRecommendations({ + client: nodeResult.client, + keys: nodeResult.keys, + total: progress.total, + globalClient: client, + exclude: recommendationToExclude, + // TODO: create generic solution to exclude recommendations + // exclude: idx !== 0 ? [RECOMMENDATION_NAMES.RTS] : [], + }); + recommendationToExclude = concat(recommendationToExclude, [RECOMMENDATION_NAMES.RTS]); + const foundedRecommendations = nodeRecommendations.filter((recommendation) => !isNull(recommendation)); + const foundedRecommendationNames = foundedRecommendations.map(({ name }) => name); + recommendationToExclude = concat(recommendationToExclude, foundedRecommendationNames); + jobsArray.push(foundedRecommendations); + console.log(recommendationToExclude); + console.log(foundedRecommendations); + return flatten(jobsArray); + }, Promise.resolve([])), ); + const analysis = plainToClass(DatabaseAnalysis, await this.analyzer.analyze({ databaseId: clientMetadata.databaseId, ...dto, @@ -111,9 +134,10 @@ export class DatabaseAnalysisService { */ static getRecommendationsSummary(recommendations: Recommendation[]): Recommendation[] { - return uniqBy( - recommendations.filter((recommendation) => !isNull(recommendation)), - 'name', - ); + // return uniqBy( + // recommendations, + // 'name', + // ); + return recommendations; } } diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index b79f301ef5..6c6d346146 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -7,6 +7,7 @@ import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/u import { RECOMMENDATION_NAMES, IS_TIMESTAMP, IS_INTEGER_NUMBER_REGEX, IS_NUMBER_REGEX, } from 'src/constants'; +import { checkRedirectionError, parseRedirectionError } from 'src/utils/cli-helper'; import { RedisDataType } from 'src/modules/browser/dto'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { Key } from 'src/modules/database-analysis/models'; @@ -410,6 +411,93 @@ export class RecommendationProvider { return null; } } + /** + * Check search indexes recommendation + * @param redisClient + * @param keys + * @param isCluster + */ + + async determineSearchIndexesRecommendation( + redisClient: Redis, + keys: Key[], + client: any, + ): Promise { + if (client.isCluster) { + let processedKeysNumber = 0; + let isJSONOrHash = false; + let sortedSetNumber = 0; + while ( + processedKeysNumber < keys.length + && !isJSONOrHash + && sortedSetNumber <= sortedSetCountForCheck + ) { + if (keys[processedKeysNumber].type !== RedisDataType.ZSet) { + processedKeysNumber += 1; + } else { + let keyBySortedSetMember; + const sortedSetMember = await redisClient.sendCommand( + new Command('zrange', [keys[processedKeysNumber].name, 0, 0], { replyEncoding: 'utf8' }), + ) as string[]; + + try { + keyBySortedSetMember = await redisClient.sendCommand( + new Command('type', [sortedSetMember[0]], { replyEncoding: 'utf8' }), + ) as string; + } catch (err) { + if (err && checkRedirectionError(err)) { + const { address } = parseRedirectionError(err); + const nodes = client.nodes('master'); + + const node: any = nodes.find(({ options: { host, port } }: Redis) => `${host}:${port}` === address); + // if (!node) { + // node = nodeRole === ClusterNodeRole.All + // ? nodeAddress + // : `${nodeAddress} [${nodeRole.toLowerCase()}]`; + // // throw new ClusterNodeNotFoundError( + // // ERROR_MESSAGES.CLUSTER_NODE_NOT_FOUND(node), + // // ); + // } + + keyBySortedSetMember = await node.sendCommand( + new Command('type', [sortedSetMember[0]], { replyEncoding: 'utf8' }), + ) as string; + } + } + if (keyBySortedSetMember === RedisDataType.JSON || keyBySortedSetMember === RedisDataType.Hash) { + isJSONOrHash = true; + } + processedKeysNumber += 1; + sortedSetNumber += 1; + } + } + + return isJSONOrHash ? { name: RECOMMENDATION_NAMES.SEARCH_INDEXES } : null; + } + try { + const sortedSets = keys + .filter(({ type }) => type === RedisDataType.ZSet) + .slice(0, 100); + const res = await redisClient.pipeline(sortedSets.map(({ name }) => ([ + 'zrange', + name, + 0, + 0, + ]))).exec(); + + const types = await redisClient.pipeline(res.map(([, member]) => ([ + 'type', + member, + ]))).exec(); + + const isHashOrJSONName = types.some(([, type]) => type === RedisDataType.JSON || type === RedisDataType.Hash) + return isHashOrJSONName ? { name: RECOMMENDATION_NAMES.SEARCH_INDEXES } : null; + } catch (err) { + this.logger.error('Can not determine search indexes recommendation', err); + return null; + } + } + private async checkAuth(redisClient: Redis | Cluster): Promise { try { diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index 50517b2437..fed7151ce7 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Redis } from 'ioredis'; +import { Redis, Cluster } from 'ioredis'; import { RecommendationProvider } from 'src/modules/recommendation/providers/recommendation.provider'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { RECOMMENDATION_NAMES } from 'src/constants'; @@ -11,6 +11,7 @@ interface RecommendationInput { keys?: Key[], info?: RedisString, total?: number, + globalClient?: Redis | Cluster, exclude?: string[], } @@ -33,6 +34,7 @@ export class RecommendationService { keys, info, total, + globalClient, exclude, } = dto; @@ -55,6 +57,7 @@ export class RecommendationService { exclude.includes(RECOMMENDATION_NAMES.RTS) ? null : await this.recommendationProvider.determineRTSRecommendation(client, keys), await this.recommendationProvider.determineRediSearchRecommendation(client, keys), await this.recommendationProvider.determineRedisVersionRecommendation(client), + await this.recommendationProvider.determineSearchIndexesRecommendation(client, keys, globalClient), await this.recommendationProvider.determineSetPasswordRecommendation(client), ])); } diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 3737dc96ef..ead17c1329 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -612,5 +612,59 @@ } ], "badges": ["upgrade"] + }, + "searchIndexes": { + "id": "searchIndexes", + "title":"Enhance your search indexes", + "redisStack": true, + "content": [ + { + "id": "1", + "type": "paragraph", + "value": "Creating your own index structure manually? Consider the out-of-box option of FT.CREATE on RediSearch." + }, + { + "id": "2", + "type": "span", + "value": "RediSearch was designed to help meet your query needs and support a better development experience when dealing with complex data scenarios. Take a look at the powerful API options " + }, + { + "id": "3", + "type": "link", + "value": { + "href": "https://redis.io/commands/?name=Ft", + "name": "here" + } + }, + { + "id": "4", + "type": "span", + "value": " and try it." + }, + { + "id": "5", + "type": "spacer", + "value": "l" + }, + { + "id": "6", + "type": "span", + "value": "Create a " + }, + { + "id": "7", + "type": "link", + "value": { + "href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight_recommendations/", + "name": "free Redis Stack database" + } + }, + { + "id": "8", + "type": "span", + "value": " to use modern data models and processing engines." + } + ], + "badges": ["upgrade"] } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 99e978fd51..2c0fb8d906 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -294,6 +294,21 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() }) + it('should render upgrade badge in searchIndexes recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'searchIndexes' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).not.toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() + }) + it('should collapse/expand and sent proper telemetry event', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, From 332f7a3393baad082cf57133415fbbb3e48251ef Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Wed, 11 Jan 2023 15:14:51 +0800 Subject: [PATCH 131/201] #RI-3702 -[FE] Add silent mode to Workbench guides and Tutorials --- .../ui/src/assets/img/icons/group_mode.svg | 4 +- .../ui/src/assets/img/icons/silent_mode.svg | 10 +++ .../src/components/query-card/QueryCard.tsx | 26 +++--- .../QueryCardCliPlugin/QueryCardCliPlugin.tsx | 6 +- .../QueryCardCliResultWrapper.tsx | 4 +- .../QueryCardHeader/QueryCardHeader.tsx | 45 +++++++--- .../QueryCardHeader/styles.module.scss | 6 ++ .../QueryCardTooltip/QueryCardTooltip.tsx | 1 + .../QueryCardTooltip/styles.module.scss | 7 +- .../ui/src/components/query/QueryWrapper.tsx | 72 +-------------- redisinsight/ui/src/constants/workbench.ts | 2 + .../pages/workbench/WorkbenchPage.spec.tsx | 4 +- .../EnablementArea/utils/parseParams.ts | 2 +- .../EnablementArea/utils/remarkRedisCode.ts | 1 - .../utils/tests/parseParams.spec.ts | 1 + .../components/wb-view/WBView/WBView.tsx | 90 +++++++++++++++++-- .../components/wb-view/WBViewWrapper.tsx | 14 +-- .../ui/src/slices/interfaces/workbench.ts | 1 + .../slices/tests/workbench/wb-results.spec.ts | 46 ++++++++++ .../ui/src/slices/workbench/wb-results.ts | 11 ++- .../ui/src/utils/tests/workbench.spec.ts | 19 +++- redisinsight/ui/src/utils/workbench.ts | 32 ++++++- 22 files changed, 274 insertions(+), 130 deletions(-) create mode 100644 redisinsight/ui/src/assets/img/icons/silent_mode.svg diff --git a/redisinsight/ui/src/assets/img/icons/group_mode.svg b/redisinsight/ui/src/assets/img/icons/group_mode.svg index 9b41ab8811..1972194104 100644 --- a/redisinsight/ui/src/assets/img/icons/group_mode.svg +++ b/redisinsight/ui/src/assets/img/icons/group_mode.svg @@ -1,6 +1,6 @@ - - + + diff --git a/redisinsight/ui/src/assets/img/icons/silent_mode.svg b/redisinsight/ui/src/assets/img/icons/silent_mode.svg new file mode 100644 index 0000000000..c450677141 --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/silent_mode.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/redisinsight/ui/src/components/query-card/QueryCard.tsx b/redisinsight/ui/src/components/query-card/QueryCard.tsx index e56a9d86a7..f656480b7b 100644 --- a/redisinsight/ui/src/components/query-card/QueryCard.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCard.tsx @@ -10,7 +10,8 @@ import { getWBQueryType, getVisualizationsByCommand, Maybe, - isGroupMode + isGroupResults, + isSilentModeWithoutError, } from 'uiSrc/utils' import { appPluginsSelector } from 'uiSrc/slices/app/plugins' import { CommandExecutionResult, IPluginVisualization } from 'uiSrc/slices/interfaces' @@ -47,10 +48,14 @@ export interface Props { const getDefaultPlugin = (views: IPluginVisualization[], query: string) => getVisualizationsByCommand(query, views).find((view) => view.default)?.uniqId || '' -export const getSummaryText = (summary?: ResultsSummary) => { +export const getSummaryText = (summary?: ResultsSummary, mode?: ResultsMode) => { if (summary) { const { total, success, fail } = summary - return `${total} Command(s) - ${success} success, ${fail} error(s)` + const summaryText = `${total} Command(s) - ${success} success` + if (!isSilentModeWithoutError(mode, summary?.fail)) { + return `${summaryText}, ${fail} error(s)` + } + return summaryText } return summary } @@ -82,7 +87,7 @@ const QueryCard = (props: Props) => { const [isFullScreen, setIsFullScreen] = useState(false) const [queryType, setQueryType] = useState(getWBQueryType(command, visualizations)) const [viewTypeSelected, setViewTypeSelected] = useState(queryType) - const [summaryText, setSummaryText] = useState('') + const [message, setMessage] = useState('') const [selectedViewValue, setSelectedViewValue] = useState( getDefaultPlugin(visualizations, command || '') || queryType ) @@ -130,7 +135,7 @@ const QueryCard = (props: Props) => { }, [visualizations]) const toggleOpen = () => { - if (isFullScreen) return + if (isFullScreen || isSilentModeWithoutError(resultsMode, summary?.fail)) return dispatch(toggleOpenWBResult(id)) @@ -162,7 +167,7 @@ const QueryCard = (props: Props) => { query={command} loading={loading} createdAt={createdAt} - summaryText={summaryText} + message={message} queryType={queryType} selectedValue={selectedViewValue} activeMode={activeMode} @@ -170,7 +175,8 @@ const QueryCard = (props: Props) => { resultsMode={resultsMode} activeResultsMode={activeResultsMode} emptyCommand={emptyCommand} - summary={getSummaryText(summary)} + summary={summary} + summaryText={getSummaryText(summary, resultsMode)} executionTime={executionTime} toggleOpen={toggleOpen} toggleFullScreen={toggleFullScreen} @@ -180,11 +186,11 @@ const QueryCard = (props: Props) => { /> {isOpen && ( <> - {React.isValidElement(commonError) && !isGroupMode(resultsMode) + {React.isValidElement(commonError) && !isGroupResults(resultsMode) ? : ( <> - {isGroupMode(resultsMode) && ( + {isGroupResults(resultsMode) && ( { id={selectedViewValue} result={result} query={command} - setSummaryText={setSummaryText} + setMessage={setMessage} commandId={id} /> ) : ( diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx b/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx index b323a54d42..d169cdd1eb 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx @@ -25,7 +25,7 @@ export interface Props { result: CommandExecutionResult[] query: any id: string - setSummaryText: (text: string) => void + setMessage: (text: string) => void commandId: string } @@ -43,7 +43,7 @@ enum ActionTypes { const baseUrl = getBaseApiUrl() const QueryCardCliPlugin = (props: Props) => { - const { query, id, result, setSummaryText, commandId } = props + const { query, id, result, setMessage, commandId } = props const { visualizations = [], staticPath } = useSelector(appPluginsSelector) const { modules = [] } = useSelector(connectedInstanceSelector) const serverInfo = useSelector(appServerInfoSelector) @@ -177,7 +177,7 @@ const QueryCardCliPlugin = (props: Props) => { }) pluginApi.onEvent(generatedIframeNameRef.current, PluginEvents.setHeaderText, (text: string) => { - setSummaryText(text) + setMessage(text) }) pluginApi.onEvent(generatedIframeNameRef.current, PluginEvents.executeRedisCommand, sendRedisCommand) diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx b/redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx index 9d2cb08d52..a215240720 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, isGroupMode, Maybe } from 'uiSrc/utils' +import { cliParseTextResponse, formatToText, isGroupResults, Maybe } from 'uiSrc/utils' import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' import QueryCardCliDefaultResult from '../QueryCardCliDefaultResult' @@ -35,7 +35,7 @@ const QueryCardCliResultWrapper = (props: Props) => { The result is too big to be saved. It will be deleted after the application is closed. )} - {isGroupMode(resultsMode) && isArray(result[0]?.response) + {isGroupResults(resultsMode) && isArray(result[0]?.response) ? : ( { toggleFullScreen, query = '', loading, - summaryText, + message, createdAt, mode, resultsMode, - activeResultsMode, summary, + activeResultsMode, + summaryText, activeMode, selectedValue, executionTime, @@ -172,7 +178,7 @@ const QueryCardHeader = (props: Props) => { ) || '' const handleToggleOpen = () => { - if (!isFullScreen) { + if (!isFullScreen && !isSilentModeWithoutError(resultsMode, summary?.fail)) { sendEvent( isOpen ? TelemetryEvent.WORKBENCH_RESULTS_COLLAPSED : TelemetryEvent.WORKBENCH_RESULTS_EXPANDED, query @@ -244,7 +250,14 @@ const QueryCardHeader = (props: Props) => { onClick={handleToggleOpen} tabIndex={0} onKeyDown={() => {}} - className={cx(styles.container, 'query-card-header', { [styles.isOpen]: isOpen })} + className={cx( + styles.container, + 'query-card-header', + { + [styles.isOpen]: isOpen, + [styles.notExpanded]: isSilentModeWithoutError(resultsMode, summary?.fail), + }, + )} data-testid="query-card-open" role="button" > @@ -255,7 +268,7 @@ const QueryCardHeader = (props: Props) => { >
    - + { )} - {!!summaryText && !isOpen && ( + {!!message && !isOpen && ( - {truncateText(summaryText, 13)} + {truncateText(message, 13)} )} @@ -311,7 +324,7 @@ const QueryCardHeader = (props: Props) => { className={cx(styles.buttonIcon, styles.viewTypeIcon)} onClick={onDropDownViewClick} > - {isOpen && options.length > 1 && !summary && ( + {isOpen && options.length > 1 && !summaryText && (
    { )} {!isFullScreen && ( - + {!isSilentModeWithoutError(resultsMode, summary?.fail) + && } )} - {(isRawMode(mode) || isGroupMode(resultsMode)) && ( + {(isRawMode(mode) || isGroupResults(resultsMode)) && ( { )} + {isSilentMode(resultsMode) && ( + + + + )} {isRawMode(mode) && ( -r diff --git a/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss b/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss index 08d396b14c..ab0914e231 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss +++ b/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss @@ -26,6 +26,10 @@ $marginIcon: 12px; margin: $marginIcon !important; } } + + &.notExpanded { + cursor: default; + } } .isOpen { @@ -160,6 +164,7 @@ $marginIcon: 12px; text-overflow: ellipsis; width: 100%; white-space: nowrap; + cursor: pointer; } } @@ -276,6 +281,7 @@ $marginIcon: 12px; .tooltipAnchor { width: 16px; margin-left: -4px; + cursor: pointer; } :global(.fullscreen) .tooltipAnchor { diff --git a/redisinsight/ui/src/components/query-card/QueryCardTooltip/QueryCardTooltip.tsx b/redisinsight/ui/src/components/query-card/QueryCardTooltip/QueryCardTooltip.tsx index fa532584fa..e59c45671d 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardTooltip/QueryCardTooltip.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardTooltip/QueryCardTooltip.tsx @@ -53,6 +53,7 @@ const QueryCardTooltip = (props: Props) => { return ( {contentItems}} position="bottom" > diff --git a/redisinsight/ui/src/components/query-card/QueryCardTooltip/styles.module.scss b/redisinsight/ui/src/components/query-card/QueryCardTooltip/styles.module.scss index 75288ff654..5b68af6b2e 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardTooltip/styles.module.scss +++ b/redisinsight/ui/src/components/query-card/QueryCardTooltip/styles.module.scss @@ -23,6 +23,11 @@ width: 26px; } -.queryLineFolding > span, .queryLineNumber { +.queryLineFolding > span, +.queryLineNumber { opacity: 0.4; } + +.tooltipAnchor { + cursor: pointer; +} diff --git a/redisinsight/ui/src/components/query/QueryWrapper.tsx b/redisinsight/ui/src/components/query/QueryWrapper.tsx index 80afc31b66..cab14f46bf 100644 --- a/redisinsight/ui/src/components/query/QueryWrapper.tsx +++ b/redisinsight/ui/src/components/query/QueryWrapper.tsx @@ -1,17 +1,9 @@ -import { without } from 'lodash' import React from 'react' import { useSelector } from 'react-redux' import { EuiLoadingContent } from '@elastic/eui' -import { decode } from 'html-entities' -import { useParams } from 'react-router-dom' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' -import { getMultiCommands, isGroupMode, isParamsLine, removeMonacoComments, splitMonacoValuePerLines } from 'uiSrc/utils' -import { userSettingsConfigSelector } from 'uiSrc/slices/user/user-settings' import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' -import { PIPELINE_COUNT_DEFAULT } from 'uiSrc/constants/api' -import { parseParams } from 'uiSrc/pages/workbench/components/enablement-area/EnablementArea/utils' import Query from './Query' import styles from './Query/styles.module.scss' @@ -28,16 +20,6 @@ export interface Props { onChangeGroupMode: () => void } -interface IState { - activeMode: RunQueryMode - resultsMode?: ResultsMode -} - -let state: IState = { - activeMode: RunQueryMode.ASCII, - resultsMode: ResultsMode.Default -} - const QueryWrapper = (props: Props) => { const { query = '', @@ -51,61 +33,9 @@ const QueryWrapper = (props: Props) => { onQueryChangeMode, onChangeGroupMode } = props - const { instanceId = '' } = useParams<{ instanceId: string }>() const { loading: isCommandsLoading, - commandsArray: REDIS_COMMANDS_ARRAY, } = useSelector(appRedisCommandsSelector) - const { batchSize = PIPELINE_COUNT_DEFAULT } = useSelector(userSettingsConfigSelector) ?? {} - - state = { - activeMode, - resultsMode - } - - const sendEventSubmitTelemetry = (commandInit = query) => { - const eventData = (() => { - const commands = without( - splitMonacoValuePerLines(commandInit) - .map((command) => removeMonacoComments(decode(command).trim())), - '' - ) - - const [commandLine, ...rest] = commands.map((command = '') => { - const matchedCommand = REDIS_COMMANDS_ARRAY.find((commandName) => - command.toUpperCase().startsWith(commandName)) - return matchedCommand ?? command.split(' ')?.[0] - }) - - const multiCommands = getMultiCommands(rest).replaceAll('\n', ';') - const command = [commandLine, multiCommands].join('') ? [commandLine, multiCommands].join(';') : null - - const params = isParamsLine(commandLine) ? commandLine : '' - const parsedParams = parseParams(params) - - return { - command: command?.toUpperCase(), - databaseId: instanceId, - multiple: multiCommands ? 'Multiple' : 'Single', - pipeline: batchSize > 1, - rawMode: state.activeMode === RunQueryMode.Raw, - group: isGroupMode(state.resultsMode) ? 'group' : 'single', - auto: !!parsedParams?.auto - } - })() - - if (eventData.command) { - sendEventTelemetry({ - event: TelemetryEvent.WORKBENCH_COMMAND_SUBMITTED, - eventData - }) - } - } - - const handleSubmit = (value?: string) => { - sendEventSubmitTelemetry(value) - onSubmit(value) - } const Placeholder = (
    @@ -125,7 +55,7 @@ const QueryWrapper = (props: Props) => { setQueryEl={setQueryEl} setIsCodeBtnDisabled={setIsCodeBtnDisabled} onKeyDown={onKeyDown} - onSubmit={handleSubmit} + onSubmit={onSubmit} onQueryChangeMode={onQueryChangeMode} onChangeGroupMode={onChangeGroupMode} /> diff --git a/redisinsight/ui/src/constants/workbench.ts b/redisinsight/ui/src/constants/workbench.ts index ec20c37bcb..87eb8cca35 100644 --- a/redisinsight/ui/src/constants/workbench.ts +++ b/redisinsight/ui/src/constants/workbench.ts @@ -3,8 +3,10 @@ import { AutoExecute, ResultsMode, RunQueryMode } from 'uiSrc/slices/interfaces' export const CodeButtonResults = { group: ResultsMode.GroupMode, single: ResultsMode.Default, + silent: ResultsMode.Silent, [ResultsMode.GroupMode]: ResultsMode.GroupMode, [ResultsMode.Default]: ResultsMode.Default, + [ResultsMode.Silent]: ResultsMode.Silent, } export const CodeButtonRunQueryMode = { diff --git a/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx b/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx index 74c9e5af3e..2078245655 100644 --- a/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx +++ b/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx @@ -169,7 +169,7 @@ describe('Telemetry', () => { eventData: { command: 'info;'.toUpperCase(), databaseId: INSTANCE_ID_MOCK, - group: 'single', + results: 'single', auto: false, multiple: 'Single', pipeline: true, @@ -208,7 +208,7 @@ describe('Telemetry', () => { eventData: { command: 'info;'.toUpperCase(), databaseId: INSTANCE_ID_MOCK, - group: 'single', + results: 'single', auto: false, multiple: 'Single', pipeline: true, diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/parseParams.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/parseParams.ts index 8dce2857b8..90ce0b39db 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/parseParams.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/parseParams.ts @@ -11,8 +11,8 @@ export const parseParams = (params?: string): Maybe => { .reduce((prev: {}, next: string) => { const [key, value] = next.split('=') return { + [key]: value, ...prev, - [key]: value } }, {}), identity) diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/remarkRedisCode.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/remarkRedisCode.ts index 4eb1a42753..53a58b4e45 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/remarkRedisCode.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/remarkRedisCode.ts @@ -2,7 +2,6 @@ import { visit } from 'unist-util-visit' enum ButtonLang { Redis = 'redis', - RedisAuto = 'redis-auto' } const PARAMS_SEPARATOR = ':' diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/parseParams.spec.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/parseParams.spec.ts index a6853f8562..ff51d2616e 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/parseParams.spec.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/parseParams.spec.ts @@ -7,6 +7,7 @@ const parseParamsTests: any[] = [ ['[execute=auto;mode=group]', { execute: 'auto', mode: 'group' }], ['[execute=auto;mode=group;]', { execute: 'auto', mode: 'group' }], ['[execute=auto; mode=group; ]', { execute: 'auto', mode: 'group' }], + ['[mode=raw;mode=ascii;mode=group;]', { mode: 'raw' }], // first parameters should be applied ] describe('parseParams', () => { diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx b/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx index d0c05a9a6f..e006edb241 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx @@ -1,11 +1,14 @@ import React, { Ref, useCallback, useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' +import { without } from 'lodash' +import { decode } from 'html-entities' +import { useParams } from 'react-router-dom' import { EuiResizableContainer } from '@elastic/eui' import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api' import { CodeButtonParams } from 'uiSrc/pages/workbench/components/enablement-area/interfaces' -import { Nullable } from 'uiSrc/utils' +import { Maybe, Nullable, getMonacoLines, getMultiCommands, getParsedParamsInQuery, removeMonacoComments } from 'uiSrc/utils' import { BrowserStorageItem } from 'uiSrc/constants' import { localStorageService } from 'uiSrc/services' import InstanceHeader from 'uiSrc/components/instance-header' @@ -17,9 +20,12 @@ import { import { CommandExecutionUI } from 'uiSrc/slices/interfaces' import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' -import WBResultsWrapper from '../../wb-results' +import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' +import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' +import { userSettingsConfigSelector } from 'uiSrc/slices/user/user-settings' +import { PIPELINE_COUNT_DEFAULT } from 'uiSrc/constants/api' import EnablementAreaWrapper from '../../enablement-area' - +import WBResultsWrapper from '../../wb-results' import styles from './styles.module.scss' const verticalPanelIds = { @@ -43,6 +49,16 @@ export interface Props { onChangeGroupMode: () => void } +interface IState { + activeMode: RunQueryMode + resultsMode?: ResultsMode +} + +let state: IState = { + activeMode: RunQueryMode.ASCII, + resultsMode: ResultsMode.Default +} + const WBView = (props: Props) => { const { script = '', @@ -59,13 +75,22 @@ const WBView = (props: Props) => { onChangeGroupMode, scrollDivRef, } = props + + state = { + activeMode, + resultsMode + } + + const { instanceId = '' } = useParams<{ instanceId: string }>() + const { panelSizes: { vertical } } = useSelector(appContextWorkbench) + const { commandsArray: REDIS_COMMANDS_ARRAY } = useSelector(appRedisCommandsSelector) + const { batchSize = PIPELINE_COUNT_DEFAULT } = useSelector(userSettingsConfigSelector) ?? {} + const [isMinimized, setIsMinimized] = useState( localStorageService?.get(BrowserStorageItem.isEnablementAreaMinimized) ?? false ) const [isCodeBtnDisabled, setIsCodeBtnDisabled] = useState(false) - const { panelSizes: { vertical } } = useSelector(appContextWorkbench) - const verticalSizesRef = useRef(vertical) const dispatch = useDispatch() @@ -82,6 +107,57 @@ const WBView = (props: Props) => { verticalSizesRef.current = newSizes }, []) + const handleSubmit = (value?: string) => { + sendEventSubmitTelemetry(value) + onSubmit(value) + } + + const sendEventSubmitTelemetry = (commandInit = script) => { + const eventData = (() => { + const parsedParams: Maybe = getParsedParamsInQuery(commandInit) + const lines = getMonacoLines(commandInit) + + const commands = without( + lines + .map((command) => removeMonacoComments(decode(command).trim())), + '' + ) + + const [commandLine, ...rest] = commands.map((command = '') => { + const matchedCommand = REDIS_COMMANDS_ARRAY.find((commandName) => + command.toUpperCase().startsWith(commandName)) + return matchedCommand ?? command.split(' ')?.[0] + }) + + const multiCommands = getMultiCommands(rest).replaceAll('\n', ';') + const command = [commandLine, multiCommands].join('') ? [commandLine, multiCommands].join(';') : null + + return { + command: command?.toUpperCase(), + databaseId: instanceId, + multiple: multiCommands ? 'Multiple' : 'Single', + pipeline: (parsedParams?.pipeline || batchSize) > 1, + auto: !!parsedParams?.auto, + rawMode: (parsedParams?.mode?.toUpperCase() || state.activeMode) === RunQueryMode.Raw, + results: + ResultsMode.GroupMode.startsWith?.( + parsedParams?.results?.toUpperCase() + || state.resultsMode + || 'GROUP' + ) + ? 'group' + : (parsedParams?.results === 'silent' ? 'silent' : 'single'), + } + })() + + if (eventData.command) { + sendEventTelemetry({ + event: TelemetryEvent.WORKBENCH_COMMAND_SUBMITTED, + eventData + }) + } + } + return (
    @@ -91,7 +167,7 @@ const WBView = (props: Props) => { isMinimized={isMinimized} setIsMinimized={setIsMinimized} setScript={setScript} - onSubmit={onSubmit} + onSubmit={handleSubmit} scriptEl={scriptEl} isCodeBtnDisabled={isCodeBtnDisabled} /> @@ -116,7 +192,7 @@ const WBView = (props: Props) => { setQuery={setScript} setQueryEl={setScriptEl} setIsCodeBtnDisabled={setIsCodeBtnDisabled} - onSubmit={onSubmit} + onSubmit={handleSubmit} onQueryChangeMode={onQueryChangeMode} onChangeGroupMode={onChangeGroupMode} /> diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx index b1cf70e03c..ec40ec5d6c 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx @@ -14,9 +14,10 @@ import { scrollIntoView, getExecuteParams, isGroupMode, - isParamsLine, getMonacoLines, Maybe, + isGroupResults, + getParsedParamsInQuery, } from 'uiSrc/utils' import { localStorageService } from 'uiSrc/services' import { @@ -168,7 +169,7 @@ const WBViewWrapper = () => { '' ) - const chunkSize = isGroupMode(resultsMode) ? commandsForExecuting.length : (batchSize > 1 ? batchSize : 1) + const chunkSize = isGroupResults(resultsMode) ? commandsForExecuting.length : (batchSize > 1 ? batchSize : 1) const [commands, ...rest] = chunk(commandsForExecuting, chunkSize) const multiCommands = rest.map((command) => getMultiCommands(command)) @@ -255,15 +256,8 @@ const WBViewWrapper = () => { ) => { if (state.loading || (!value && !script)) return - let parsedParams: Maybe = {} const lines = getMonacoLines(value) - - if (isParamsLine(first(lines))) { - const params = lines.shift() - ?.replaceAll?.('\n', '') - ?? '' - parsedParams = parseParams(params) - } + const parsedParams: Maybe = getParsedParamsInQuery(value) const { clearEditor } = executeParams handleSubmit(value, commandId, { ...executeParams, ...parsedParams }) diff --git a/redisinsight/ui/src/slices/interfaces/workbench.ts b/redisinsight/ui/src/slices/interfaces/workbench.ts index 191e0369e3..9adb48f2b3 100644 --- a/redisinsight/ui/src/slices/interfaces/workbench.ts +++ b/redisinsight/ui/src/slices/interfaces/workbench.ts @@ -54,6 +54,7 @@ export enum AutoExecute { } export enum ResultsMode { + Silent = 'SILENT', Default = 'DEFAULT', GroupMode = 'GROUP_MODE', } diff --git a/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts b/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts index a1627987bb..f0b11db6ab 100644 --- a/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts +++ b/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts @@ -11,6 +11,7 @@ import { apiService } from 'uiSrc/services' import { addErrorNotification } from 'uiSrc/slices/app/notifications' import { ClusterNodeRole, CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' import { EMPTY_COMMAND } from 'uiSrc/constants' +import { ResultsMode } from 'uiSrc/slices/interfaces' import { SendClusterCommandDto } from 'apiSrc/modules/cli/dto/cli.dto' import reducer, { initialState, @@ -175,6 +176,51 @@ describe('workbench results slice', () => { }) expect(workbenchResultsSelector(rootState)).toEqual(state) }) + + it('should properly set the state with fetched data and isOpen = false, for request silent mode and 0 errors', () => { + // Arrange + + const mockedId = '123' + + const mockCommandExecution = { + commandId: '123', + data: [{ + command: 'command', + databaseId: '123', + id: mockedId + 0, + createdAt: new Date(), + resultsMode: ResultsMode.Silent, + isOpen: false, + error: '', + loading: false, + summary: { + fail: 0, + success: 1, + total: 1, + }, + result: [{ + response: 'test', + status: CommandExecutionStatus.Success + }] + }] + } + + const state = { + ...initialState, + items: [...mockCommandExecution.data] + } + + // Act + const nextState = reducer(initialStateWithItems, sendWBCommandSuccess(mockCommandExecution)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + workbench: { + results: nextState, + }, + }) + expect(workbenchResultsSelector(rootState)).toEqual(state) + }) }) describe('processWBCommandFailure', () => { diff --git a/redisinsight/ui/src/slices/workbench/wb-results.ts b/redisinsight/ui/src/slices/workbench/wb-results.ts index 75674e3c52..9007dbdc8f 100644 --- a/redisinsight/ui/src/slices/workbench/wb-results.ts +++ b/redisinsight/ui/src/slices/workbench/wb-results.ts @@ -9,7 +9,8 @@ import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' import { getApiErrorMessage, getUrl, - isGroupMode, + isGroupResults, + isSilentModeWithoutError, isStatusSuccessful, } from 'uiSrc/utils' import { WORKBENCH_HISTORY_MAX_LENGTH } from 'uiSrc/pages/workbench/constants' @@ -127,7 +128,9 @@ const workbenchResultsSlice = createSlice({ let newItem = item data.forEach((command, i) => { if (item.id === (commandId + i)) { - newItem = { ...command, loading: false, isOpen: true, error: '' } + // don't open a card if silent mode and no errors + const isOpen = !isSilentModeWithoutError(command.resultsMode, command?.summary?.fail) + newItem = { ...command, isOpen, loading: false, error: '' } } }) return newItem @@ -243,7 +246,7 @@ export function sendWBCommandAction({ const { id = '' } = state.connections.instances.connectedInstance dispatch(sendWBCommand({ - commands: isGroupMode(resultsMode) ? [`${commands.length} - Command(s)`] : commands, + commands: isGroupResults(resultsMode) ? [`${commands.length} - Command(s)`] : commands, commandId })) @@ -302,7 +305,7 @@ export function sendWBCommandClusterAction({ const { id = '' } = state.connections.instances.connectedInstance dispatch(sendWBCommand({ - commands: isGroupMode(resultsMode) ? [`${commands.length} - Commands`] : commands, + commands: isGroupResults(resultsMode) ? [`${commands.length} - Commands`] : commands, commandId })) diff --git a/redisinsight/ui/src/utils/tests/workbench.spec.ts b/redisinsight/ui/src/utils/tests/workbench.spec.ts index 849b3ff349..eb644542c7 100644 --- a/redisinsight/ui/src/utils/tests/workbench.spec.ts +++ b/redisinsight/ui/src/utils/tests/workbench.spec.ts @@ -1,6 +1,6 @@ import { CodeButtonParams } from 'uiSrc/pages/workbench/components/enablement-area/interfaces' import { ExecuteQueryParams, ResultsMode, RunQueryMode } from 'uiSrc/slices/interfaces' -import { getExecuteParams } from 'uiSrc/utils' +import { getExecuteParams, getParsedParamsInQuery } from 'uiSrc/utils' const paramsState: ExecuteQueryParams = { activeRunQueryMode: RunQueryMode.ASCII, @@ -32,3 +32,20 @@ describe('getExecuteParams', () => { expect(getExecuteParams(btnParams6, paramsState)).toEqual(expect6) }) }) + +describe('getParsedParamsInQuery', () => { + it.each([ + ['123', {}], + ['get test\nget test2', {}], + ['get test\nget test2\nget test3', {}], + ['[]\nget test\nget test2\nget test3', undefined], + ['get test\n[mode=raw]\nget test2\nget test3', {}], + ['[mode=raw]\nget test\nget test2\nget test3', { mode: 'raw' }], + ['[mode=raw;mode=ascii]\nget test\nget test2\nget test3', { mode: 'raw' }], + ['[mode=raw;results=group;pipeline=10]\nget test\nget test2\nget test3', { mode: 'raw', results: 'group', pipeline: '10' }], + ])('for input: %s (input), should be output: %s', + (input, expected) => { + const result = getParsedParamsInQuery(input) + expect(result).toEqual(expected) + }) +}) diff --git a/redisinsight/ui/src/utils/workbench.ts b/redisinsight/ui/src/utils/workbench.ts index fb81328ff7..9f9a71c76d 100644 --- a/redisinsight/ui/src/utils/workbench.ts +++ b/redisinsight/ui/src/utils/workbench.ts @@ -1,9 +1,12 @@ -import { isInteger } from 'lodash' +import { first, isInteger } from 'lodash' import { CodeButtonResults, CodeButtonRunQueryMode } from 'uiSrc/constants' import { CodeButtonParams } from 'uiSrc/pages/workbench/components/enablement-area/interfaces' import { WBQueryType } from 'uiSrc/pages/workbench/constants' import { ExecuteQueryParams, IPluginVisualization, ResultsMode, RunQueryMode } from 'uiSrc/slices/interfaces' import { getVisualizationsByCommand } from 'uiSrc/utils/plugins' +import { parseParams } from 'uiSrc/pages/workbench/components/enablement-area/EnablementArea/utils' +import { getMonacoLines, isParamsLine } from './monaco' +import { Maybe } from './types' const getWBQueryType = (query: string = '', views: IPluginVisualization[] = []) => { const defaultPluginView = getVisualizationsByCommand(query, views) @@ -29,7 +32,32 @@ const getExecuteParams = (params: CodeButtonParams = {}, state: ExecuteQueryPara return { batchSize, resultsMode, activeRunQueryMode } } +export const getParsedParamsInQuery = (query: string) => { + let parsedParams: Maybe = {} + const lines = getMonacoLines(query) + + if (isParamsLine(first(lines))) { + const params = lines.shift() + ?.replaceAll?.('\n', '') + ?? '' + parsedParams = parseParams(params) + } + + return parsedParams +} + const isGroupMode = (mode?: ResultsMode) => mode === ResultsMode.GroupMode const isRawMode = (mode?: RunQueryMode) => mode === RunQueryMode.Raw +const isSilentMode = (mode?: ResultsMode) => mode === ResultsMode.Silent +const isGroupResults = (mode?: ResultsMode) => mode === ResultsMode.GroupMode || mode === ResultsMode.Silent +const isSilentModeWithoutError = (mode?: ResultsMode, fail?: number) => isSilentMode(mode) && fail === 0 -export { getWBQueryType, getExecuteParams, isGroupMode, isRawMode } +export { + getWBQueryType, + getExecuteParams, + isGroupMode, + isRawMode, + isGroupResults, + isSilentMode, + isSilentModeWithoutError, +} From ca9caa364c3826b37eb3f18889890711f0af623f Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Wed, 11 Jan 2023 16:05:15 +0800 Subject: [PATCH 132/201] #RI-3702 -[BE] Add silent mode to Workbench guides and Tutorials --- .../dto/create-command-execution.dto.ts | 1 + .../workbench/workbench.service.spec.ts | 67 +++++++++++++++++++ .../modules/workbench/workbench.service.ts | 8 ++- ...ases-id-plugins-command_executions.test.ts | 2 +- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/redisinsight/api/src/modules/workbench/dto/create-command-execution.dto.ts b/redisinsight/api/src/modules/workbench/dto/create-command-execution.dto.ts index ed844b39d6..88fa2374a7 100644 --- a/redisinsight/api/src/modules/workbench/dto/create-command-execution.dto.ts +++ b/redisinsight/api/src/modules/workbench/dto/create-command-execution.dto.ts @@ -19,6 +19,7 @@ export enum RunQueryMode { export enum ResultsMode { Default = 'DEFAULT', GroupMode = 'GROUP_MODE', + Silent = 'SILENT', } export class CreateCommandExecutionDto { diff --git a/redisinsight/api/src/modules/workbench/workbench.service.spec.ts b/redisinsight/api/src/modules/workbench/workbench.service.spec.ts index 273a989275..fccaa2efca 100644 --- a/redisinsight/api/src/modules/workbench/workbench.service.spec.ts +++ b/redisinsight/api/src/modules/workbench/workbench.service.spec.ts @@ -51,6 +51,18 @@ const mockCreateCommandExecutionDtoWithGroupMode: CreateCommandExecutionsDto = { resultsMode: ResultsMode.GroupMode, }; +const mockCreateCommandExecutionDtoWithSilentMode: CreateCommandExecutionsDto = { + commands: mockCommands, + nodeOptions: { + host: '127.0.0.1', + port: 7002, + enableRedirection: true, + }, + role: ClusterNodeRole.All, + mode: RunQueryMode.ASCII, + resultsMode: ResultsMode.Silent, +}; + const mockCreateCommandExecutionsDto: CreateCommandExecutionsDto = { commands: [ mockCreateCommandExecutionDto.command, @@ -101,6 +113,21 @@ const mockCommandExecutionWithGroupMode = { }], }; +const mockCommandExecutionWithSilentMode = { + mode: 'ASCII', + commands: mockCommands, + resultsMode: 'GROUP_MODE', + databaseId: 'd05043d0 - 0d12- 4ce1-9ca3 - 30c6d7e391ea', + summary: { total: 2, success: 1, fail: 1 }, + command: 'set 1 1\r\nget 1', + result: [{ + status: 'success', + response: [ + { response: 'error', status: 'fail', command: 'get 1' }, + ], + }], +}; + const mockCommandExecutionProvider = () => ({ createMany: jest.fn(), getList: jest.fn(), @@ -215,6 +242,21 @@ describe('WorkbenchService', () => { expect(result).toEqual([mockCommandExecutionWithGroupMode]); }); + it('should successfully execute commands and save in silent mode view', async () => { + when(workbenchCommandsExecutor.sendCommand) + .calledWith(mockWorkbenchClientMetadata, expect.anything()) + .mockResolvedValue([mockSendCommandResultSuccess]); + + commandExecutionProvider.createMany.mockResolvedValueOnce([mockCommandExecutionWithSilentMode]); + + const result = await service.createCommandExecutions( + mockWorkbenchClientMetadata, + mockCreateCommandExecutionDtoWithSilentMode, + ); + + expect(result).toEqual([mockCommandExecutionWithSilentMode]); + }); + it('should successfully execute commands with error and save summary', async () => { when(workbenchCommandsExecutor.sendCommand) .calledWith(mockWorkbenchClientMetadata, { @@ -240,6 +282,31 @@ describe('WorkbenchService', () => { expect(result).toEqual([mockCommandExecutionWithGroupMode]); }); + it('should successfully execute commands with error and save summary in silent mode view', async () => { + when(workbenchCommandsExecutor.sendCommand) + .calledWith(mockWorkbenchClientMetadata, { + ...mockCreateCommandExecutionDtoWithSilentMode, + command: mockCommands[0], + }) + .mockResolvedValue([mockSendCommandResultSuccess]); + + when(workbenchCommandsExecutor.sendCommand) + .calledWith(mockWorkbenchClientMetadata, { + ...mockCreateCommandExecutionDtoWithSilentMode, + command: mockCommands[1], + }) + .mockResolvedValue([mockSendCommandResultFail]); + + commandExecutionProvider.createMany.mockResolvedValueOnce([mockCommandExecutionWithSilentMode]); + + const result = await service.createCommandExecutions( + mockWorkbenchClientMetadata, + mockCreateCommandExecutionDtoWithSilentMode, + ); + + expect(result).toEqual([mockCommandExecutionWithSilentMode]); + }); + it('should throw an error when command execution failed', async () => { workbenchCommandsExecutor.sendCommand.mockRejectedValueOnce(new BadRequestException('error')); diff --git a/redisinsight/api/src/modules/workbench/workbench.service.ts b/redisinsight/api/src/modules/workbench/workbench.service.ts index fb600f64a2..c579cc7852 100644 --- a/redisinsight/api/src/modules/workbench/workbench.service.ts +++ b/redisinsight/api/src/modules/workbench/workbench.service.ts @@ -63,11 +63,13 @@ export class WorkbenchService { * @param clientMetadata * @param dto * @param commands + * @param onlyErrorResponse */ async createCommandsExecution( clientMetadata: ClientMetadata, dto: Partial, commands: string[], + onlyErrorResponse: boolean = false, ): Promise> { const commandExecution: Partial = { ...dto, @@ -114,7 +116,7 @@ export class WorkbenchService { commandExecution.command = commands.join('\r\n'); commandExecution.result = [{ status: CommandExecutionStatus.Success, - response: executionResults, + response: onlyErrorResponse ? failedCommands : executionResults, }]; return commandExecution; @@ -134,9 +136,9 @@ export class WorkbenchService { // temporary workaround. Just create client before any command execution precess await this.databaseConnectionService.getOrCreateClient(clientMetadata); - if (dto.resultsMode === ResultsMode.GroupMode) { + if (dto.resultsMode === ResultsMode.GroupMode || dto.resultsMode === ResultsMode.Silent) { return this.commandExecutionProvider.createMany( - [await this.createCommandsExecution(clientMetadata, dto, dto.commands)], + [await this.createCommandsExecution(clientMetadata, dto, dto.commands, dto.resultsMode === ResultsMode.Silent)], ); } // todo: rework to support pipeline diff --git a/redisinsight/api/test/api/plugins/POST-databases-id-plugins-command_executions.test.ts b/redisinsight/api/test/api/plugins/POST-databases-id-plugins-command_executions.test.ts index 3ff6171775..ca72961e31 100644 --- a/redisinsight/api/test/api/plugins/POST-databases-id-plugins-command_executions.test.ts +++ b/redisinsight/api/test/api/plugins/POST-databases-id-plugins-command_executions.test.ts @@ -21,7 +21,7 @@ const dataSchema = Joi.object({ command: Joi.string().required(), role: Joi.string().valid('ALL', 'MASTER', 'SLAVE').allow(null), mode: Joi.string().valid('RAW', 'ASCII').allow(null), - resultsMode: Joi.string().valid('DEFAULT', 'GROUP_MODE').allow(null), + resultsMode: Joi.string().valid('DEFAULT', 'GROUP_MODE', 'SILENT').allow(null), nodeOptions: Joi.object().keys({ host: Joi.string().required(), // todo: fix BE transform to avoid handle boolean as number From 216c49c5f73a099eab474633a1106bddc8b96458 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Wed, 11 Jan 2023 17:16:24 +0800 Subject: [PATCH 133/201] #RI-3956 - Make edit mode for database alias more visible --- .../database-overview/styles.module.scss | 17 +++++++---- .../DatabaseAlias/DatabaseAlias.tsx | 30 ++++++------------- .../DatabaseAlias/styles.module.scss | 23 ++++++++++++-- 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/redisinsight/ui/src/components/database-overview/styles.module.scss b/redisinsight/ui/src/components/database-overview/styles.module.scss index 2ca38efd4e..f09ca0bd2f 100644 --- a/redisinsight/ui/src/components/database-overview/styles.module.scss +++ b/redisinsight/ui/src/components/database-overview/styles.module.scss @@ -20,7 +20,8 @@ } .overview { - &.noModules, &.RediStack { + &.noModules, + &.RediStack { .overviewItem { &:last-child { border-right: 1px solid var(--separatorColor); @@ -74,9 +75,9 @@ .commandsPerSecTip { margin-bottom: 8px; - &:last-child { - margin-bottom: 0; - } + &:last-child { + margin-bottom: 0; + } .moreInfoOverviewIcon { margin-right: 8px; width: auto !important; @@ -99,6 +100,13 @@ .RediStackLogoWrapper { padding: 0 6px; + cursor: pointer; + transition: transform 250ms ease-in-out; + + &:hover { + transform: translateY(-1px); + } + .redistackLogoIcon { width: 24px; height: 24px; @@ -124,4 +132,3 @@ display: none !important; } } - diff --git a/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx b/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx index e802b9d2ae..2b6ffb77c6 100644 --- a/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx @@ -43,7 +43,6 @@ const DatabaseAlias = (props: Props) => { const { server } = useSelector(appInfoSelector) const [isEditing, setIsEditing] = useState(false) - const [isHovering, setIsHovering] = useState(false) const [value, setValue] = useState(alias) const { theme } = useContext(ThemeContext) @@ -52,14 +51,6 @@ const DatabaseAlias = (props: Props) => { setValue(alias) }, [alias]) - const onMouseEnterAlias = () => { - setIsHovering(true) - } - - const onMouseLeaveAlias = () => { - setIsHovering(false) - } - const setEditMode = () => { setIsEditing(true) } @@ -82,15 +73,13 @@ const DatabaseAlias = (props: Props) => { const handleApplyChanges = () => { setIsEditing(false) - onApplyChanges(value, () => setIsHovering(false), () => setValue(alias)) + onApplyChanges(value, () => {}, () => setValue(alias)) } const handleDeclineChanges = (event?: React.MouseEvent) => { event?.stopPropagation() setValue(alias) setIsEditing(false) - - setIsHovering(false) } return ( @@ -108,7 +97,7 @@ const DatabaseAlias = (props: Props) => { /> )} - + { )} - {!isCloneMode && (isEditing || isHovering || isLoading) ? ( + {!isCloneMode && (isEditing || isLoading) ? ( { ) : ( - + - {isCloneMode && (Clone )} - {alias} + {isCloneMode && (Clone {alias})} + {!isCloneMode && ({alias})} {database ? `[${database}]` : ''} + {!isCloneMode && ()} )} diff --git a/redisinsight/ui/src/pages/home/components/DatabaseAlias/styles.module.scss b/redisinsight/ui/src/pages/home/components/DatabaseAlias/styles.module.scss index ddc5dc4eb7..655c2812aa 100644 --- a/redisinsight/ui/src/pages/home/components/DatabaseAlias/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/DatabaseAlias/styles.module.scss @@ -25,7 +25,7 @@ } .input { - font: normal normal 500 20px/24px 'Graphik' !important; + font: normal normal 500 20px/24px "Graphik" !important; backface-visibility: hidden; } @@ -36,7 +36,8 @@ padding-left: 9px; padding-top: 4px; margin-bottom: 5px; - font: normal normal 500 20px/25px 'Graphik' !important; + align-items: center; + font: normal normal 500 20px/25px "Graphik" !important; } .aliasText { @@ -47,6 +48,24 @@ padding-right: 5px; } +.aliasEditing { + cursor: pointer; + + span { + text-decoration: underline; + } + + &:hover span { + text-decoration: none; + } +} + +.aliasEditIcon { + margin: 0 6px 0 8px; + width: 18px !important; + height: 18px !important; +} + .redistackIcon { width: 24px !important; max-width: 24px !important; From cdd74a99d3fa20e7b4ebe1c819199c9b496e45fb Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Wed, 11 Jan 2023 13:08:05 +0300 Subject: [PATCH 134/201] #RI-3927 - add recommendations tab highlighting --- .../components/analytics-tabs/constants.ts | 21 --------- .../components/analytics-tabs/constants.tsx | 44 ++++++++++++++++++ .../HighlightedFeature.tsx | 5 +- .../navigation-menu/styles.module.scss | 4 +- .../ui/src/constants/featuresHighlighting.tsx | 8 ++-- .../components/data-nav-tabs/constants.tsx | 31 +++++++++++-- .../home/components/HomeHeader/HomeHeader.tsx | 46 ++++++------------- .../ui/src/styles/components/_tabs.scss | 8 +++- 8 files changed, 102 insertions(+), 65 deletions(-) delete mode 100644 redisinsight/ui/src/components/analytics-tabs/constants.ts create mode 100644 redisinsight/ui/src/components/analytics-tabs/constants.tsx diff --git a/redisinsight/ui/src/components/analytics-tabs/constants.ts b/redisinsight/ui/src/components/analytics-tabs/constants.ts deleted file mode 100644 index b813398d40..0000000000 --- a/redisinsight/ui/src/components/analytics-tabs/constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { AnalyticsViewTab } from 'uiSrc/slices/interfaces/analytics' - -interface AnalyticsTabs { - id: AnalyticsViewTab, - label: string, -} - -export const analyticsViewTabs: AnalyticsTabs[] = [ - { - id: AnalyticsViewTab.ClusterDetails, - label: 'Overview', - }, - { - id: AnalyticsViewTab.DatabaseAnalysis, - label: 'Database Analysis', - }, - { - id: AnalyticsViewTab.SlowLog, - label: 'Slow Log', - }, -] diff --git a/redisinsight/ui/src/components/analytics-tabs/constants.tsx b/redisinsight/ui/src/components/analytics-tabs/constants.tsx new file mode 100644 index 0000000000..66922f4acb --- /dev/null +++ b/redisinsight/ui/src/components/analytics-tabs/constants.tsx @@ -0,0 +1,44 @@ +import React, { ReactNode } from 'react' +import { AnalyticsViewTab } from 'uiSrc/slices/interfaces/analytics' +import { useSelector } from 'react-redux' +import { appFeaturesToHighlightSelector } from 'uiSrc/slices/app/features-highlighting' +import HighlightedFeature from 'uiSrc/components/hightlighted-feature/HighlightedFeature' +import { BUILD_FEATURES } from 'uiSrc/constants/featuresHighlighting' + +interface AnalyticsTabs { + id: AnalyticsViewTab + label: string | ReactNode +} + +const DatabaseAnalyticsTab = () => { + const { recommendations: recommendationsHighlighting } = useSelector(appFeaturesToHighlightSelector) ?? {} + + return ( + <> + + Database Analysis + + + ) +} + +export const analyticsViewTabs: AnalyticsTabs[] = [ + { + id: AnalyticsViewTab.ClusterDetails, + label: 'Overview', + }, + { + id: AnalyticsViewTab.DatabaseAnalysis, + label: , + }, + { + id: AnalyticsViewTab.SlowLog, + label: 'Slow Log', + }, +] diff --git a/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx b/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx index f26c1f5532..4e237b37bb 100644 --- a/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx +++ b/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx @@ -1,3 +1,4 @@ +import { isString } from 'lodash' import { EuiToolTip } from '@elastic/eui' import { ToolTipPositions } from '@elastic/eui/src/components/tool_tip/tool_tip' import cx from 'classnames' @@ -8,7 +9,7 @@ import styles from './styles.modules.scss' export interface Props { isHighlight?: boolean - children: React.ReactElement + children: React.ReactElement | string title?: string | React.ReactElement content?: string | React.ReactElement type?: FeaturesHighlightingType @@ -36,7 +37,7 @@ const HighlightedFeature = (props: Props) => { dataTestPostfix = '' } = props - const innerContent = hideFirstChild ? children.props.children : children + const innerContent = hideFirstChild && !isString(children) ? children.props.children : children const DotHighlighting = () => ( <> diff --git a/redisinsight/ui/src/components/navigation-menu/styles.module.scss b/redisinsight/ui/src/components/navigation-menu/styles.module.scss index 98e62a61d5..a0863d287d 100644 --- a/redisinsight/ui/src/components/navigation-menu/styles.module.scss +++ b/redisinsight/ui/src/components/navigation-menu/styles.module.scss @@ -207,8 +207,8 @@ $sideBarWidth: 60px; } .highlightDot { - top: 12px !important; - right: 12px !important; + top: 11px !important; + right: 11px !important; &.activePage { background-color: #465282 !important; diff --git a/redisinsight/ui/src/constants/featuresHighlighting.tsx b/redisinsight/ui/src/constants/featuresHighlighting.tsx index 6dd60756de..14f491dbbb 100644 --- a/redisinsight/ui/src/constants/featuresHighlighting.tsx +++ b/redisinsight/ui/src/constants/featuresHighlighting.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { PageNames } from 'uiSrc/constants/pages' export type FeaturesHighlightingType = 'plain' | 'tooltip' | 'popover' @@ -9,9 +10,10 @@ interface BuildHighlightingFeature { page?: string } export const BUILD_FEATURES: { [key: string]: BuildHighlightingFeature } = { - importDatabases: { + recommendations: { type: 'tooltip', - title: 'Import Database Connections', - content: 'Import your database connections from other Redis UIs' + title: 'Database Recommendations', + content: 'Run database analysis to get recommendations for optimizing your database.', + page: PageNames.analytics } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx index 25b5935871..85bb925c00 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx @@ -1,14 +1,39 @@ import React, { ReactNode } from 'react' +import { useDispatch, useSelector } from 'react-redux' + import { DatabaseAnalysisViewTab } from 'uiSrc/slices/interfaces/analytics' -import AnalysisDataView from '../analysis-data-view' +import { appFeaturesToHighlightSelector, removeFeatureFromHighlighting } from 'uiSrc/slices/app/features-highlighting' +import { BUILD_FEATURES } from 'uiSrc/constants/featuresHighlighting' +import HighlightedFeature from 'uiSrc/components/hightlighted-feature/HighlightedFeature' + import Recommendations from '../recommendations-view' +import AnalysisDataView from '../analysis-data-view' interface DatabaseAnalysisTabs { id: DatabaseAnalysisViewTab, - name: (count?: number) => string, + name: (count?: number) => string | ReactNode, content: ReactNode } +const RecommendationsTab = ({ count }: { count?: number }) => { + const { recommendations: recommendationsHighlighting } = useSelector(appFeaturesToHighlightSelector) ?? {} + + const dispatch = useDispatch() + + return count ? ( + <> + dispatch(removeFeatureFromHighlighting('recommendations'))} + dotClassName="tab-highlighting-dot" + > + <>Recommendations ({count}) + + + ) : (<>Recommendations) +} + export const databaseAnalysisTabs: DatabaseAnalysisTabs[] = [ { id: DatabaseAnalysisViewTab.DataSummary, @@ -17,7 +42,7 @@ export const databaseAnalysisTabs: DatabaseAnalysisTabs[] = [ }, { id: DatabaseAnalysisViewTab.Recommendations, - name: (count?: number) => (count ? `Recommendations (${count})` : 'Recommendations'), + name: (count) => , content: }, ] diff --git a/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx index de6aa94762..959ffc6da9 100644 --- a/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx +++ b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx @@ -9,15 +9,9 @@ import { EuiToolTip, } from '@elastic/eui' import { isEmpty } from 'lodash' -import { useDispatch, useSelector } from 'react-redux' +import { useSelector } from 'react-redux' import cx from 'classnames' import { ImportDatabasesDialog } from 'uiSrc/components' -import HighlightedFeature from 'uiSrc/components/hightlighted-feature/HighlightedFeature' -import { BUILD_FEATURES } from 'uiSrc/constants/featuresHighlighting' -import { - appFeaturesToHighlightSelector, - removeFeatureFromHighlighting -} from 'uiSrc/slices/app/features-highlighting' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import HelpLinksMenu from 'uiSrc/pages/home/components/HelpLinksMenu' import PromoLink from 'uiSrc/components/promo-link/PromoLink' @@ -48,10 +42,6 @@ const HomeHeader = ({ onAddInstance, direction, welcomePage = false }: Props) => const [guides, setGuides] = useState([]) const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) - const { importDatabases: importDatabasesHighlighting } = useSelector(appFeaturesToHighlightSelector) ?? {} - - const dispatch = useDispatch() - useEffect(() => { if (loading || !data || isEmpty(data)) { return @@ -126,30 +116,20 @@ const HomeHeader = ({ onAddInstance, direction, welcomePage = false }: Props) => ) const ImportDatabasesBtn = () => ( - dispatch(removeFeatureFromHighlighting('importDatabases'))} - transformOnHover - hideFirstChild + - - - - - - + + + ) const Guides = () => ( diff --git a/redisinsight/ui/src/styles/components/_tabs.scss b/redisinsight/ui/src/styles/components/_tabs.scss index b03e7f503e..4c22c64a14 100644 --- a/redisinsight/ui/src/styles/components/_tabs.scss +++ b/redisinsight/ui/src/styles/components/_tabs.scss @@ -49,7 +49,7 @@ .tabs-active-borders { .euiTab { border-radius: 0; - padding: 8px 12px !important; + padding: 0 !important; border-bottom: 1px solid var(--separatorColor); color: var(--euiTextSubduedColor) !important; @@ -63,9 +63,15 @@ font-size: 13px !important; line-height: 18px !important; font-weight: 500 !important; + padding: 8px 12px; } } + .tab-highlighting-dot { + top: -6px; + right: -12px; + } + .euiTab + .euiTab { margin-left: 0 !important; From c0c606fbc38ee78b8e91de9c6f7297daf61d864b Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Wed, 11 Jan 2023 16:10:46 +0300 Subject: [PATCH 135/201] #RI-4002 - fix parse params, fix tests --- .../EnablementArea/utils/formatter/MarkdownToJsxString.ts | 6 +++--- .../enablement-area/EnablementArea/utils/index.ts | 3 --- .../enablement-area/EnablementArea/utils/parseParams.ts | 3 ++- .../EnablementArea/utils/tests/parseParams.spec.ts | 2 ++ .../EnablementArea/utils/tests/remarkRedisCode.spec.ts | 5 ++--- .../EnablementArea/utils/{ => transform}/rehypeLinks.ts | 0 .../EnablementArea/utils/{ => transform}/remarkImage.ts | 0 .../EnablementArea/utils/{ => transform}/remarkRedisCode.ts | 0 8 files changed, 9 insertions(+), 10 deletions(-) rename redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/{ => transform}/rehypeLinks.ts (100%) rename redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/{ => transform}/remarkImage.ts (100%) rename redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/{ => transform}/remarkRedisCode.ts (100%) diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/formatter/MarkdownToJsxString.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/formatter/MarkdownToJsxString.ts index 611d6e7760..8dcb73687c 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/formatter/MarkdownToJsxString.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/formatter/MarkdownToJsxString.ts @@ -6,9 +6,9 @@ import rehypeStringify from 'rehype-stringify' import { visit } from 'unist-util-visit' import { IFormatter, IFormatterConfig } from './formatter.interfaces' -import { rehypeLinks } from '../rehypeLinks' -import { remarkRedisCode } from '../remarkRedisCode' -import { remarkImage } from '../remarkImage' +import { rehypeLinks } from '../transform/rehypeLinks' +import { remarkRedisCode } from '../transform/remarkRedisCode' +import { remarkImage } from '../transform/remarkImage' class MarkdownToJsxString implements IFormatter { format(data: any, config?: IFormatterConfig): Promise { diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/index.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/index.ts index cde58b5afd..fe2f8363a8 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/index.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/index.ts @@ -1,5 +1,2 @@ export * from './parseParams' export * from './getFileInfo' -export * from './remarkImage' -export * from './rehypeLinks' -export * from './remarkRedisCode' diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/parseParams.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/parseParams.ts index 90ce0b39db..b67ff565df 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/parseParams.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/parseParams.ts @@ -3,8 +3,9 @@ import { CodeButtonParams } from 'uiSrc/pages/workbench/components/enablement-ar import { identity, pickBy } from 'lodash' export const parseParams = (params?: string): Maybe => { - if (params?.match(/(^\[).+(]$)/g)) { + if (params?.trim().match(/(^\[).+(]$)/g)) { return pickBy(params + ?.trim() ?.replaceAll(' ', '') ?.replace(/^\[|]$/g, '') ?.split(';') diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/parseParams.spec.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/parseParams.spec.ts index ff51d2616e..52cdabb05d 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/parseParams.spec.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/parseParams.spec.ts @@ -8,6 +8,8 @@ const parseParamsTests: any[] = [ ['[execute=auto;mode=group;]', { execute: 'auto', mode: 'group' }], ['[execute=auto; mode=group; ]', { execute: 'auto', mode: 'group' }], ['[mode=raw;mode=ascii;mode=group;]', { mode: 'raw' }], // first parameters should be applied + ['[mode=raw]\n', { mode: 'raw' }], + ['[mode=raw]\r', { mode: 'raw' }], ] describe('parseParams', () => { diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkRedisCode.spec.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkRedisCode.spec.ts index 4a5200db4d..c4123838c0 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkRedisCode.spec.ts +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/tests/remarkRedisCode.spec.ts @@ -1,10 +1,9 @@ -import { ExecuteButtonMode } from 'uiSrc/pages/workbench/components/enablement-area/interfaces' import { visit } from 'unist-util-visit' -import { remarkRedisCode } from '../remarkRedisCode' +import { remarkRedisCode } from '../transform/remarkRedisCode' jest.mock('unist-util-visit') -const getValue = (meta: string = ExecuteButtonMode.Manual, params?: string, value?: string) => +const getValue = (meta: string, params?: string, value?: string) => `{${JSON.stringify(value)}}` describe('remarkRedisCode', () => { diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/rehypeLinks.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/rehypeLinks.ts similarity index 100% rename from redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/rehypeLinks.ts rename to redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/rehypeLinks.ts diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/remarkImage.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/remarkImage.ts similarity index 100% rename from redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/remarkImage.ts rename to redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/remarkImage.ts diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/remarkRedisCode.ts b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/remarkRedisCode.ts similarity index 100% rename from redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/remarkRedisCode.ts rename to redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/utils/transform/remarkRedisCode.ts From 60296a10a59d05f49ea9fe1afab92d2f078620a8 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Wed, 11 Jan 2023 21:12:53 +0800 Subject: [PATCH 136/201] #RI-4004 - Telemetry WORKBENCH_COMMAND_SUBMITTED event not sent when auto=true #RI-4009 - Silent results not collapsed by default when have errors #RI-4010 - WORKBENCH_COMMAND_RUN_AGAIN event should have the same parameters as WORKBENCH_COMMAND_SUBMITTED --- .../QueryCardHeader/QueryCardHeader.tsx | 1 - .../components/wb-view/WBView/WBView.tsx | 37 ++++++++++++++----- .../slices/tests/workbench/wb-results.spec.ts | 2 +- .../ui/src/slices/workbench/wb-results.ts | 10 +++-- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.tsx b/redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.tsx index 32cf2aa5a4..e0ab98707c 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.tsx @@ -168,7 +168,6 @@ const QueryCardHeader = (props: Props) => { } const handleQueryReRun = (event: React.MouseEvent) => { - sendEvent(TelemetryEvent.WORKBENCH_COMMAND_RUN_AGAIN, query) eventStop(event) onQueryReRun() } diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx b/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx index e006edb241..a1dfe9986e 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx @@ -1,14 +1,14 @@ import React, { Ref, useCallback, useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' -import { without } from 'lodash' +import { first, isEmpty, without } from 'lodash' import { decode } from 'html-entities' import { useParams } from 'react-router-dom' import { EuiResizableContainer } from '@elastic/eui' import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api' import { CodeButtonParams } from 'uiSrc/pages/workbench/components/enablement-area/interfaces' -import { Maybe, Nullable, getMonacoLines, getMultiCommands, getParsedParamsInQuery, removeMonacoComments } from 'uiSrc/utils' +import { Maybe, Nullable, getMonacoLines, getMultiCommands, getParsedParamsInQuery, isParamsLine, removeMonacoComments } from 'uiSrc/utils' import { BrowserStorageItem } from 'uiSrc/constants' import { localStorageService } from 'uiSrc/services' import InstanceHeader from 'uiSrc/components/instance-header' @@ -18,7 +18,7 @@ import { appContextWorkbench } from 'uiSrc/slices/app/context' import { CommandExecutionUI } from 'uiSrc/slices/interfaces' -import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' +import { RunQueryMode, ResultsMode, AutoExecute } from 'uiSrc/slices/interfaces/workbench' import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' @@ -108,14 +108,33 @@ const WBView = (props: Props) => { }, []) const handleSubmit = (value?: string) => { - sendEventSubmitTelemetry(value) + sendEventSubmitTelemetry(TelemetryEvent.WORKBENCH_COMMAND_SUBMITTED, value) onSubmit(value) } - const sendEventSubmitTelemetry = (commandInit = script) => { + const handleReRun = (query?: string, commandId?: Nullable, executeParams: CodeButtonParams = {}) => { + sendEventSubmitTelemetry(TelemetryEvent.WORKBENCH_COMMAND_RUN_AGAIN, query, executeParams) + onSubmit(query, commandId, executeParams) + } + + const sendEventSubmitTelemetry = ( + event: TelemetryEvent, + commandInit = script, + executeParams?: CodeButtonParams, + ) => { const eventData = (() => { - const parsedParams: Maybe = getParsedParamsInQuery(commandInit) const lines = getMonacoLines(commandInit) + const firstLine = first(lines) ?? '' + + const parsedParams: Maybe = isEmpty(executeParams) + ? getParsedParamsInQuery(commandInit) + : executeParams + + const auto = TelemetryEvent.WORKBENCH_COMMAND_RUN_AGAIN !== event + ? parsedParams?.auto === AutoExecute.True + : undefined + + if (isParamsLine(firstLine)) lines.shift() const commands = without( lines @@ -134,10 +153,10 @@ const WBView = (props: Props) => { return { command: command?.toUpperCase(), + auto, databaseId: instanceId, multiple: multiCommands ? 'Multiple' : 'Single', pipeline: (parsedParams?.pipeline || batchSize) > 1, - auto: !!parsedParams?.auto, rawMode: (parsedParams?.mode?.toUpperCase() || state.activeMode) === RunQueryMode.Raw, results: ResultsMode.GroupMode.startsWith?.( @@ -152,7 +171,7 @@ const WBView = (props: Props) => { if (eventData.command) { sendEventTelemetry({ - event: TelemetryEvent.WORKBENCH_COMMAND_SUBMITTED, + event, eventData }) } @@ -218,7 +237,7 @@ const WBView = (props: Props) => { activeMode={activeMode} activeResultsMode={resultsMode} scrollDivRef={scrollDivRef} - onQueryReRun={onSubmit} + onQueryReRun={handleReRun} onQueryOpen={onQueryOpen} onQueryDelete={onQueryDelete} /> diff --git a/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts b/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts index f0b11db6ab..42ebeefe45 100644 --- a/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts +++ b/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts @@ -177,7 +177,7 @@ describe('workbench results slice', () => { expect(workbenchResultsSelector(rootState)).toEqual(state) }) - it('should properly set the state with fetched data and isOpen = false, for request silent mode and 0 errors', () => { + it('should properly set the state with fetched data and isOpen = false, for request silent mode', () => { // Arrange const mockedId = '123' diff --git a/redisinsight/ui/src/slices/workbench/wb-results.ts b/redisinsight/ui/src/slices/workbench/wb-results.ts index 9007dbdc8f..06fb394d1d 100644 --- a/redisinsight/ui/src/slices/workbench/wb-results.ts +++ b/redisinsight/ui/src/slices/workbench/wb-results.ts @@ -10,7 +10,7 @@ import { getApiErrorMessage, getUrl, isGroupResults, - isSilentModeWithoutError, + isSilentMode, isStatusSuccessful, } from 'uiSrc/utils' import { WORKBENCH_HISTORY_MAX_LENGTH } from 'uiSrc/pages/workbench/constants' @@ -129,8 +129,12 @@ const workbenchResultsSlice = createSlice({ data.forEach((command, i) => { if (item.id === (commandId + i)) { // don't open a card if silent mode and no errors - const isOpen = !isSilentModeWithoutError(command.resultsMode, command?.summary?.fail) - newItem = { ...command, isOpen, loading: false, error: '' } + newItem = { + ...command, + loading: false, + error: '', + isOpen: !isSilentMode(command.resultsMode), + } } }) return newItem From 49ca6c230ae5f8e6bc52f5572fbe0622c18cf11a Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 11 Jan 2023 14:57:03 +0100 Subject: [PATCH 137/201] added test for silent mode --- tests/e2e/pageObjects/workbench-page.ts | 1 + .../workbench-non-auto-guides.e2e.ts | 40 ++++++++++++++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/tests/e2e/pageObjects/workbench-page.ts b/tests/e2e/pageObjects/workbench-page.ts index e7879bad1f..fc228fc6fb 100644 --- a/tests/e2e/pageObjects/workbench-page.ts +++ b/tests/e2e/pageObjects/workbench-page.ts @@ -72,6 +72,7 @@ export class WorkbenchPage { parametersAnchor = Selector('[data-testid=parameters-anchor]'); groupModeIcon = Selector('[data-testid=group-mode-tooltip]'); rawModeIcon = Selector('[data-testid=raw-mode-tooltip]'); + silentModeIcon = Selector('[data-testid=silent-mode-tooltip]'); //LINKS timeSeriesLink = Selector('[data-testid=internal-link-redis_for_time_series]'); redisStackLinks = Selector('[data-testid=accordion-redis_stack] [data-testid^=internal-link]'); diff --git a/tests/e2e/tests/regression/workbench/workbench-non-auto-guides.e2e.ts b/tests/e2e/tests/regression/workbench/workbench-non-auto-guides.e2e.ts index 8d04833935..bd2fcf43dc 100644 --- a/tests/e2e/tests/regression/workbench/workbench-non-auto-guides.e2e.ts +++ b/tests/e2e/tests/regression/workbench/workbench-non-auto-guides.e2e.ts @@ -24,7 +24,12 @@ const commands = [ `[mode=ascii;results=single] \ ${counter} get ${keyName}`, `[mode=ascii;mode=raw;results=single] \ - ${counter} get ${keyName}` + ${counter} get ${keyName}`, + `[mode=raw;results=silent;pipeline=3] \ + ${counter} INFO`, + `[mode=ascii;results=silent;pipeline=1] \ + ${counter} INFO + invalidCommand` ]; const commandForSend = `set ${keyName} "${keyValue}"`; @@ -37,12 +42,15 @@ fixture `Workbench modes to non-auto guides` await workbenchPage.sendCommandInWorkbench(commandForSend); }) .afterEach(async t => { - // Clear and delete database - await t.click(myRedisDatabasePage.browserButton); - await browserPage.deleteKeyByName(keyName); + // Delete database await deleteStandaloneDatabaseApi(ossStandaloneConfig); }); -test('Workbench modes from editor', async t => { +test.after(async t => { + // Clear and delete database + await t.click(myRedisDatabasePage.browserButton); + await browserPage.deleteKeyByName(keyName); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); +})('Workbench modes from editor', async t => { const groupCommandResultName = `${counter} Command(s) - ${counter} success, 0 error(s)`; const containerOfCommand = await workbenchPage.getCardContainerByCommand(groupCommandResultName); @@ -91,3 +99,25 @@ test('Workbench modes from editor', async t => { await workbenchPage.sendCommandInWorkbench(commands[4]); await t.expect(workbenchPage.queryTextResult.textContent).contains(`"${keyValue}"`, 'The first duplicated parameter not applied'); }); +test('Workbench Silent mode', async t => { + const silentCommandSuccessResultName = `${counter} Command(s) - ${counter} success`; + const silentCommandErrorsResultName = `${counter + 1} Command(s) - ${counter} success, 1 error(s)`; + + await workbenchPage.sendCommandInWorkbench(commands[5]); + // Verify that user can see the success command output with header: {number} Command(s) - {number} success + await t.expect(workbenchPage.queryCardCommand.textContent).eql(silentCommandSuccessResultName, 'Silent mode not applied'); + // Verify that user can see the command output is grouped into one window when run any guide or tutorial with the [results=silent] + await t.expect(workbenchPage.queryTextResult.exists).notOk('The result is displayed in silent mode'); + + await t.hover(workbenchPage.parametersAnchor); + // Verify that silent mode icon displayed + await t.expect(workbenchPage.silentModeIcon.exists).ok('Silent mode icon not displayed'); + + await workbenchPage.sendCommandInWorkbench(commands[6]); + // Verify that user can expand the results to see the list of commands with errors and the list of errors per a command + await t.click(workbenchPage.queryCardContainer.nth(0)); + await t.expect(workbenchPage.queryCardCommand.nth(0).textContent).contains('invalidCommand', 'Silent mode result does not contain error'); + await t.expect(workbenchPage.queryCardCommand.nth(0).textContent).notContains('INFO', 'Silent mode result contains not only errors'); + // Verify that user can see the errors command output with header: {number} Command(s) - {number} success, {number} error(s) + await t.expect(workbenchPage.queryCardCommand.textContent).eql(silentCommandErrorsResultName, 'Silent mode with errors header text is invalid'); +}); From 2f93141c25e56856c11d86a29b01448bd35c06e9 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Wed, 11 Jan 2023 22:48:03 +0800 Subject: [PATCH 138/201] fix ui test --- redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx b/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx index 2078245655..9ca8b36fff 100644 --- a/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx +++ b/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx @@ -231,9 +231,12 @@ describe('Telemetry', () => { expect(sendEventTelemetry).toBeCalledWith({ event: TelemetryEvent.WORKBENCH_COMMAND_RUN_AGAIN, eventData: { - command: 'info', + auto: undefined, + command: 'INFO;', databaseId: INSTANCE_ID_MOCK, - group: false, + multiple: 'Single', + results: 'single', + pipeline: true, rawMode: true, } }) From 43d541bcb46cd8cac3e4a6358c419296da73f876 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 11 Jan 2023 18:18:55 +0100 Subject: [PATCH 139/201] updates for tests --- tests/e2e/pageObjects/workbench-page.ts | 13 ++ .../regression/workbench/raw-mode.e2e.ts | 8 +- .../workbench-non-auto-guides.e2e.ts | 150 +++++++++--------- 3 files changed, 95 insertions(+), 76 deletions(-) diff --git a/tests/e2e/pageObjects/workbench-page.ts b/tests/e2e/pageObjects/workbench-page.ts index fc228fc6fb..dab9fc0eaf 100644 --- a/tests/e2e/pageObjects/workbench-page.ts +++ b/tests/e2e/pageObjects/workbench-page.ts @@ -182,6 +182,19 @@ export class WorkbenchPage { .click(this.submitCommandButton); } + /** + * Send multiple commands in Workbench + * @param commands The commands + */ + async sendMultipleCommandsInWorkbench(commands: string[]): Promise { + for (const command of commands) { + await t + .typeText(this.queryInput, command, { replace: false, speed: 1, paste: true }) + .pressKey('enter'); + } + await t.click(this.submitCommandButton); + } + /** * Send commands array in Workbench page * @param commands The array of commands to send diff --git a/tests/e2e/tests/regression/workbench/raw-mode.e2e.ts b/tests/e2e/tests/regression/workbench/raw-mode.e2e.ts index f1861b2383..0c41648308 100644 --- a/tests/e2e/tests/regression/workbench/raw-mode.e2e.ts +++ b/tests/e2e/tests/regression/workbench/raw-mode.e2e.ts @@ -14,7 +14,6 @@ const keyName = common.generateWord(10); const indexName = common.generateWord(5); const keyValue = '\\xe5\\xb1\\xb1\\xe5\\xa5\\xb3\\xe9\\xa6\\xac / \\xe9\\xa9\\xac\\xe7\\x9b\\xae abc 123'; const unicodeValue = '山女馬 / 马目 abc 123'; -const rawModeIcon = '-r'; const commandsForSend = [ `set ${keyName} "${keyValue}"`, `get ${keyName}` @@ -44,16 +43,15 @@ test('Use raw mode for Workbech result', async t => { // Display result in Ascii when raw mode is off await t.expect(workbenchPage.queryTextResult.textContent).contains(`"${keyValue}"`, 'The result is not correct'); // Verify that user can't see Raw marker in Workbench command history - await t.expect(workbenchPage.queryCardContainer.nth(0).find(workbenchPage.cssCommandExecutionDateTime).textContent) - .notContains(rawModeIcon, 'Raw mode icon is displayed in command history'); + await t.expect(workbenchPage.parametersAnchor.exists).notOk('Raw mode icon displayed'); //Send command in raw mode await t.click(workbenchPage.rawModeBtn); await workbenchPage.sendCommandInWorkbench(commandsForSend[1]); // Verify that user can see command result execution in raw mode await workbenchPage.checkWorkbenchCommandResult(commandsForSend[1], `"${unicodeValue}"`); // Verify that user can see R marker in command history - await t.expect(workbenchPage.queryCardContainer.nth(0).find(workbenchPage.cssCommandExecutionDateTime).textContent) - .contains(rawModeIcon, 'No raw mode icon in command history'); + await t.hover(workbenchPage.parametersAnchor); + await t.expect(workbenchPage.rawModeIcon.exists).ok('Raw mode icon not displayed'); }); test .before(async t => { diff --git a/tests/e2e/tests/regression/workbench/workbench-non-auto-guides.e2e.ts b/tests/e2e/tests/regression/workbench/workbench-non-auto-guides.e2e.ts index bd2fcf43dc..9c84a22013 100644 --- a/tests/e2e/tests/regression/workbench/workbench-non-auto-guides.e2e.ts +++ b/tests/e2e/tests/regression/workbench/workbench-non-auto-guides.e2e.ts @@ -14,24 +14,21 @@ const counter = 7; const unicodeValue = '山女馬 / 马目 abc 123'; const keyName = common.generateWord(10); const keyValue = '\\xe5\\xb1\\xb1\\xe5\\xa5\\xb3\\xe9\\xa6\\xac / \\xe9\\xa9\\xac\\xe7\\x9b\\xae abc 123'; +const parameters = [ + '[results=group]', + '[mode=raw]', + '[mode=raw;results=group;pipeline=3]', + '[mode=ascii;results=single]', + '[mode=ascii;mode=raw;results=single]', + '[mode=raw;results=silent;pipeline=3]', + '[mode=ascii;results=silent;pipeline=1]' +]; const commands = [ - `[results=group] \ - ${counter} INFO`, - `[mode=raw] \ - get ${keyName}`, - `[mode=raw;results=group;pipeline=3] \ - ${counter} get ${keyName}`, - `[mode=ascii;results=single] \ - ${counter} get ${keyName}`, - `[mode=ascii;mode=raw;results=single] \ - ${counter} get ${keyName}`, - `[mode=raw;results=silent;pipeline=3] \ - ${counter} INFO`, - `[mode=ascii;results=silent;pipeline=1] \ - ${counter} INFO - invalidCommand` + `${counter} INFO`, + `${counter} get ${keyName}`, + `get ${keyName}`, + 'invalidCommand' ]; -const commandForSend = `set ${keyName} "${keyValue}"`; fixture `Workbench modes to non-auto guides` .meta({ type: 'regression', rte: rte.standalone }) @@ -39,85 +36,96 @@ fixture `Workbench modes to non-auto guides` .beforeEach(async t => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); await t.click(myRedisDatabasePage.workbenchButton); - await workbenchPage.sendCommandInWorkbench(commandForSend); }) - .afterEach(async t => { + .afterEach(async() => { // Delete database await deleteStandaloneDatabaseApi(ossStandaloneConfig); }); -test.after(async t => { - // Clear and delete database - await t.click(myRedisDatabasePage.browserButton); - await browserPage.deleteKeyByName(keyName); - await deleteStandaloneDatabaseApi(ossStandaloneConfig); -})('Workbench modes from editor', async t => { - const groupCommandResultName = `${counter} Command(s) - ${counter} success, 0 error(s)`; - const containerOfCommand = await workbenchPage.getCardContainerByCommand(groupCommandResultName); +test + .before(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await t.click(myRedisDatabasePage.workbenchButton); + await workbenchPage.sendCommandInWorkbench(`set ${keyName} "${keyValue}"`); + }) + .after(async t => { + // Clear and delete database + await t.click(myRedisDatabasePage.browserButton); + await browserPage.deleteKeyByName(keyName); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('Workbench modes from editor', async t => { + const groupCommandResultName = `${counter} Command(s) - ${counter} success, 0 error(s)`; + const containerOfCommand = await workbenchPage.getCardContainerByCommand(groupCommandResultName); - // Verify that results parameter applied from the first raw in the Workbench Editor - await workbenchPage.sendCommandInWorkbench(commands[0]); - await t.expect(workbenchPage.queryCardCommand.textContent).eql(groupCommandResultName, 'Group mode not applied'); - await t.hover(workbenchPage.parametersAnchor); - // Verify that group mode icon is displayed - await t.expect(workbenchPage.groupModeIcon.exists).ok('Group mode icon not displayed'); + // Verify that results parameter applied from the first raw in the Workbench Editor + await workbenchPage.sendMultipleCommandsInWorkbench([parameters[0], commands[0]]); + await t.expect(workbenchPage.queryCardCommand.textContent).eql(groupCommandResultName, 'Group mode not applied'); + await t.hover(workbenchPage.parametersAnchor); + // Verify that group mode icon is displayed + await t.expect(workbenchPage.groupModeIcon.exists).ok('Group mode icon not displayed'); - // Verify that mode parameter applied from the first raw in the Workbench Editor - await workbenchPage.sendCommandInWorkbench(commands[1]); - await workbenchPage.checkWorkbenchCommandResult(commands[1], `"${unicodeValue}"`); - await t.hover(workbenchPage.parametersAnchor); - // Verify that raw mode icon is displayed - await t.expect(workbenchPage.rawModeIcon.exists).ok('Raw mode icon not displayed'); + // Verify that mode parameter applied from the first raw in the Workbench Editor + await workbenchPage.sendMultipleCommandsInWorkbench([parameters[1], commands[2]]); + await workbenchPage.checkWorkbenchCommandResult(commands[2], `"${unicodeValue}"`); + await t.hover(workbenchPage.parametersAnchor); + // Verify that raw mode icon is displayed + await t.expect(workbenchPage.rawModeIcon.exists).ok('Raw mode icon not displayed'); - // Verify that multiple parameters applied from the first raw in the Workbench Editor - await workbenchPage.sendCommandInWorkbench(commands[2]); - await t.expect(workbenchPage.queryCardCommand.textContent).eql(groupCommandResultName, 'Group mode not applied'); - await workbenchPage.checkWorkbenchCommandResult(commands[2], `"${unicodeValue}"`); - await t.hover(workbenchPage.parametersAnchor); - // Verify that raw and group mode icons are displayed - await t.expect(workbenchPage.groupModeIcon.exists).ok('Group mode icon not displayed'); - await t.expect(workbenchPage.rawModeIcon.exists).ok('Raw mode icon not displayed'); + // Verify that multiple parameters applied from the first raw in the Workbench Editor + await workbenchPage.sendMultipleCommandsInWorkbench([parameters[2], commands[1]]); + await t.expect(workbenchPage.queryCardCommand.textContent).eql(groupCommandResultName, 'Group mode not applied'); + const actualCommandResult = await workbenchPage.queryCardContainer.nth(0).find(workbenchPage.cssQueryTextResult).textContent; + await t.expect(actualCommandResult).contains(`"${unicodeValue}"`, 'Actual command result is not equal to executed'); - // Add text with parameters in Workbench editor input - await t.typeText(workbenchPage.queryInput, commands[4], { replace: true }); - // Re-run the last command in results - await t.click(containerOfCommand.find(workbenchPage.cssReRunCommandButton)); - // Verify that on re-run any command from history the same parameters specified regardless of Workbench editor input - await t.expect(workbenchPage.queryCardCommand.textContent).eql(commands[2], 'The command is not re-executed'); + await t.hover(workbenchPage.parametersAnchor); + // Verify that raw and group mode icons are displayed + await t.expect(workbenchPage.groupModeIcon.exists).ok('Group mode icon not displayed'); + await t.expect(workbenchPage.rawModeIcon.exists).ok('Raw mode icon not displayed'); - // Turn on raw and group modes - await t.click(workbenchPage.rawModeBtn); - await t.click(workbenchPage.groupMode); - await workbenchPage.sendCommandInWorkbench(commands[3]); - // Verify that Workbench Editor parameters have more priority than manually clicked modes - await t.expect(workbenchPage.queryTextResult.textContent).contains(`"${keyValue}"`, 'The mode is not applied from editor parameters'); - await t.expect(workbenchPage.queryCardCommand.textContent).eql(`get ${keyName}`, 'The result is not applied from editor parameters'); + // Add text with parameters in Workbench editor input + await t.typeText(workbenchPage.queryInput, parameters[4], { replace: true }); + // Re-run the last command in results + await t.click(containerOfCommand.find(workbenchPage.cssReRunCommandButton)); + // Verify that on re-run any command from history the same parameters specified regardless of Workbench editor input + await t.expect(actualCommandResult).contains(`"${unicodeValue}"`, 'The command is not re-executed'); - // Turn off raw and group modes - await t.click(workbenchPage.rawModeBtn); - await t.click(workbenchPage.groupMode); - // Verify that if user specifies the same parameters he can see the first one is applied - await workbenchPage.sendCommandInWorkbench(commands[4]); - await t.expect(workbenchPage.queryTextResult.textContent).contains(`"${keyValue}"`, 'The first duplicated parameter not applied'); -}); + // Clear value in input + await t.click(workbenchPage.submitCommandButton); + // Turn on raw and group modes + await t.click(workbenchPage.rawModeBtn); + await t.click(workbenchPage.groupMode); + await workbenchPage.sendMultipleCommandsInWorkbench([parameters[3], commands[1]]); + // Verify that Workbench Editor parameters have more priority than manually clicked modes + await t.expect(workbenchPage.queryTextResult.textContent).contains(`"${keyValue}"`, 'The mode is not applied from editor parameters'); + await t.expect(workbenchPage.queryCardCommand.textContent).eql(`get ${keyName}`, 'The result is not applied from editor parameters'); + + // Turn off raw and group modes + await t.click(workbenchPage.rawModeBtn); + await t.click(workbenchPage.groupMode); + // Verify that if user specifies the same parameters he can see the first one is applied + await workbenchPage.sendMultipleCommandsInWorkbench([parameters[4], commands[1]]); + await t.expect(workbenchPage.queryTextResult.textContent).contains(`"${keyValue}"`, 'The first duplicated parameter not applied'); + }); test('Workbench Silent mode', async t => { const silentCommandSuccessResultName = `${counter} Command(s) - ${counter} success`; const silentCommandErrorsResultName = `${counter + 1} Command(s) - ${counter} success, 1 error(s)`; + const errorResult = `"ERR unknown command \`${commands[3]}\`, with args beginning with: "`; - await workbenchPage.sendCommandInWorkbench(commands[5]); + await workbenchPage.sendMultipleCommandsInWorkbench([parameters[5], commands[0]]); // Verify that user can see the success command output with header: {number} Command(s) - {number} success await t.expect(workbenchPage.queryCardCommand.textContent).eql(silentCommandSuccessResultName, 'Silent mode not applied'); // Verify that user can see the command output is grouped into one window when run any guide or tutorial with the [results=silent] - await t.expect(workbenchPage.queryTextResult.exists).notOk('The result is displayed in silent mode'); + await t.expect(workbenchPage.queryCardContainer.nth(0).find(workbenchPage.cssQueryTextResult).exists).notOk('The result is displayed in silent mode'); await t.hover(workbenchPage.parametersAnchor); // Verify that silent mode icon displayed await t.expect(workbenchPage.silentModeIcon.exists).ok('Silent mode icon not displayed'); - await workbenchPage.sendCommandInWorkbench(commands[6]); + await workbenchPage.sendMultipleCommandsInWorkbench([parameters[6], commands[3], commands[0]]); // Verify that user can expand the results to see the list of commands with errors and the list of errors per a command await t.click(workbenchPage.queryCardContainer.nth(0)); - await t.expect(workbenchPage.queryCardCommand.nth(0).textContent).contains('invalidCommand', 'Silent mode result does not contain error'); - await t.expect(workbenchPage.queryCardCommand.nth(0).textContent).notContains('INFO', 'Silent mode result contains not only errors'); + await t.expect(workbenchPage.queryTextResult.nth(0).textContent).contains(commands[3], 'Silent mode result does not contain error'); + await t.expect(workbenchPage.commandExecutionResultFailed.textContent).contains(errorResult, 'Error message not displayed'); + await t.expect(workbenchPage.queryTextResult.nth(0).textContent).notContains('INFO', 'Silent mode result contains not only errors'); // Verify that user can see the errors command output with header: {number} Command(s) - {number} success, {number} error(s) await t.expect(workbenchPage.queryCardCommand.textContent).eql(silentCommandErrorsResultName, 'Silent mode with errors header text is invalid'); }); From 9588cc101c840cc60236e3d57179941a21c28924 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 12 Jan 2023 09:32:30 +0400 Subject: [PATCH 140/201] #RI-3572-dangerous commands recommendation --- .../api/src/constants/recommendations.ts | 1 + .../database-analysis.service.ts | 69 ++++-------- .../models/recommendation.ts | 9 +- .../providers/recommendation.provider.ts | 50 +++++++-- .../recommendation/recommendation.service.ts | 104 ++++++++++++++---- .../constants/dbAnalysisRecommendations.json | 51 +++++++-- .../Recommendations.spec.tsx | 14 +++ .../recommendations-view/Recommendations.tsx | 4 +- .../components/recommendations-view/utils.tsx | 43 ++++++-- 9 files changed, 245 insertions(+), 100 deletions(-) diff --git a/redisinsight/api/src/constants/recommendations.ts b/redisinsight/api/src/constants/recommendations.ts index bb698e06dc..a53422829c 100644 --- a/redisinsight/api/src/constants/recommendations.ts +++ b/redisinsight/api/src/constants/recommendations.ts @@ -17,4 +17,5 @@ export const RECOMMENDATION_NAMES = Object.freeze({ REDIS_VERSION: 'redisVersion', REDIS_SEARCH: 'redisSearch', SEARCH_INDEXES: 'searchIndexes', + DANGEROUS_COMMANDS: 'dangerousCommands', }); diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index 25f5720b20..cf36a38a4c 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -2,14 +2,12 @@ import { HttpException, Injectable, Logger } from '@nestjs/common'; import { isNull, flatten, concat } from 'lodash'; import { RecommendationService } from 'src/modules/recommendation/recommendation.service'; import { catchAclError } from 'src/utils'; -import { RECOMMENDATION_NAMES } from 'src/constants'; import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/database-analyzer'; import { plainToClass } from 'class-transformer'; import { DatabaseAnalysis, ShortDatabaseAnalysis } from 'src/modules/database-analysis/models'; import { DatabaseAnalysisProvider } from 'src/modules/database-analysis/providers/database-analysis.provider'; import { CreateDatabaseAnalysisDto } from 'src/modules/database-analysis/dto'; import { KeysScanner } from 'src/modules/database-analysis/scanner/keys-scanner'; -import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { DatabaseConnectionService } from 'src/modules/database/database-connection.service'; import { ClientMetadata } from 'src/common/models'; @@ -55,41 +53,25 @@ export class DatabaseAnalysisService { progress.total += nodeResult.progress.total; }); - const recommendations = DatabaseAnalysisService.getRecommendationsSummary( - // flatten(await Promise.all( - // scanResults.map(async (nodeResult, idx) => ( - // await this.recommendationService.getRecommendations({ - // client: nodeResult.client, - // keys: nodeResult.keys, - // total: progress.total, - // globalClient: client, - // // TODO: create generic solution to exclude recommendations - // exclude: idx !== 0 ? [RECOMMENDATION_NAMES.RTS] : [], - // }) - // )), - // )), - await scanResults.reduce(async (previousPromise, nodeResult) => { - const jobsArray = await previousPromise; - let recommendationToExclude = []; - const nodeRecommendations = await this.recommendationService.getRecommendations({ - client: nodeResult.client, - keys: nodeResult.keys, - total: progress.total, - globalClient: client, - exclude: recommendationToExclude, - // TODO: create generic solution to exclude recommendations - // exclude: idx !== 0 ? [RECOMMENDATION_NAMES.RTS] : [], - }); - recommendationToExclude = concat(recommendationToExclude, [RECOMMENDATION_NAMES.RTS]); - const foundedRecommendations = nodeRecommendations.filter((recommendation) => !isNull(recommendation)); - const foundedRecommendationNames = foundedRecommendations.map(({ name }) => name); - recommendationToExclude = concat(recommendationToExclude, foundedRecommendationNames); - jobsArray.push(foundedRecommendations); - console.log(recommendationToExclude); - console.log(foundedRecommendations); - return flatten(jobsArray); - }, Promise.resolve([])), - ); + let recommendationToExclude = []; + + const recommendations = await scanResults.reduce(async (previousPromise, nodeResult) => { + const jobsArray = await previousPromise; + const nodeRecommendations = await this.recommendationService.getRecommendations({ + client: nodeResult.client, + keys: nodeResult.keys, + total: progress.total, + globalClient: client, + exclude: recommendationToExclude, + }); + // recommendationToExclude = concat(recommendationToExclude, [RECOMMENDATION_NAMES.RTS]); + const foundedRecommendations = nodeRecommendations.filter((recommendation) => !isNull(recommendation)); + const foundedRecommendationNames = foundedRecommendations.map(({ name }) => name); + recommendationToExclude = concat(recommendationToExclude, foundedRecommendationNames); + recommendationToExclude.push(...foundedRecommendationNames); + jobsArray.push(foundedRecommendations); + return flatten(jobsArray); + }, Promise.resolve([])); const analysis = plainToClass(DatabaseAnalysis, await this.analyzer.analyze({ databaseId: clientMetadata.databaseId, @@ -127,17 +109,4 @@ export class DatabaseAnalysisService { async list(databaseId: string): Promise { return this.databaseAnalysisProvider.list(databaseId); } - - /** - * Get recommendations summary - * @param recommendations - */ - - static getRecommendationsSummary(recommendations: Recommendation[]): Recommendation[] { - // return uniqBy( - // recommendations, - // 'name', - // ); - return recommendations; - } } diff --git a/redisinsight/api/src/modules/database-analysis/models/recommendation.ts b/redisinsight/api/src/modules/database-analysis/models/recommendation.ts index e3910bbfe1..9cd1b8ab3f 100644 --- a/redisinsight/api/src/modules/database-analysis/models/recommendation.ts +++ b/redisinsight/api/src/modules/database-analysis/models/recommendation.ts @@ -1,5 +1,5 @@ import { Expose } from 'class-transformer'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class Recommendation { @ApiProperty({ @@ -9,4 +9,11 @@ export class Recommendation { }) @Expose() name: string; + + @ApiPropertyOptional({ + description: 'Additional recommendation params', + example: 'luaScript', + }) + @Expose() + params?: any; } diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 6c6d346146..2164d2854d 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -7,6 +7,8 @@ import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/u import { RECOMMENDATION_NAMES, IS_TIMESTAMP, IS_INTEGER_NUMBER_REGEX, IS_NUMBER_REGEX, } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { ClusterNodeNotFoundError } from 'src/modules/cli/constants/errors'; import { checkRedirectionError, parseRedirectionError } from 'src/utils/cli-helper'; import { RedisDataType } from 'src/modules/browser/dto'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; @@ -25,6 +27,8 @@ const bigStringMemory = 5_000_000; const sortedSetCountForCheck = 100; const minRedisVersion = '6'; +const redisInsightCommands = ['info', 'monitor', 'slowlog', 'acl', 'config', 'module']; + @Injectable() export class RecommendationProvider { private logger = new Logger('RecommendationProvider'); @@ -411,13 +415,14 @@ export class RecommendationProvider { return null; } } + /** * Check search indexes recommendation * @param redisClient * @param keys - * @param isCluster + * @param client */ - + // eslint-disable-next-line async determineSearchIndexesRecommendation( redisClient: Redis, keys: Key[], @@ -450,14 +455,11 @@ export class RecommendationProvider { const nodes = client.nodes('master'); const node: any = nodes.find(({ options: { host, port } }: Redis) => `${host}:${port}` === address); - // if (!node) { - // node = nodeRole === ClusterNodeRole.All - // ? nodeAddress - // : `${nodeAddress} [${nodeRole.toLowerCase()}]`; - // // throw new ClusterNodeNotFoundError( - // // ERROR_MESSAGES.CLUSTER_NODE_NOT_FOUND(node), - // // ); - // } + if (!node) { + throw new ClusterNodeNotFoundError( + ERROR_MESSAGES.CLUSTER_NODE_NOT_FOUND(node), + ); + } keyBySortedSetMember = await node.sendCommand( new Command('type', [sortedSetMember[0]], { replyEncoding: 'utf8' }), @@ -490,7 +492,7 @@ export class RecommendationProvider { member, ]))).exec(); - const isHashOrJSONName = types.some(([, type]) => type === RedisDataType.JSON || type === RedisDataType.Hash) + const isHashOrJSONName = types.some(([, type]) => type === RedisDataType.JSON || type === RedisDataType.Hash); return isHashOrJSONName ? { name: RECOMMENDATION_NAMES.SEARCH_INDEXES } : null; } catch (err) { this.logger.error('Can not determine search indexes recommendation', err); @@ -498,6 +500,32 @@ export class RecommendationProvider { } } + /* + * Check dangerous commands recommendation + * @param redisClient + */ + + async determineDangerousCommandsRecommendation( + redisClient: Redis | Cluster, + ): Promise { + try { + const dangerousCommands = await redisClient.sendCommand( + new Command('command', ['LIST', 'FILTERBY', 'aclcat', 'dangerous'], { replyEncoding: 'utf8' }), + ) as string[]; + + const filteredDangerousCommands = dangerousCommands.filter((command) => { + const commandName = command.split('|')[0]; + return !redisInsightCommands.includes(commandName); + }); + const commands = filteredDangerousCommands.join('\r\n').toUpperCase(); + return filteredDangerousCommands + ? { name: RECOMMENDATION_NAMES.DANGEROUS_COMMANDS, params: { commands } } + : null; + } catch (err) { + this.logger.error('Can not determine dangerous commands recommendation', err); + return null; + } + } private async checkAuth(redisClient: Redis | Cluster): Promise { try { diff --git a/redisinsight/api/src/modules/recommendation/recommendation.service.ts b/redisinsight/api/src/modules/recommendation/recommendation.service.ts index fed7151ce7..7562b5bb38 100644 --- a/redisinsight/api/src/modules/recommendation/recommendation.service.ts +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Redis, Cluster } from 'ioredis'; +import { difference } from 'lodash'; import { RecommendationProvider } from 'src/modules/recommendation/providers/recommendation.provider'; import { Recommendation } from 'src/modules/database-analysis/models/recommendation'; import { RECOMMENDATION_NAMES } from 'src/constants'; @@ -38,27 +39,88 @@ export class RecommendationService { exclude, } = dto; + const recommendations = new Map([ + [ + RECOMMENDATION_NAMES.LUA_SCRIPT, + async () => await this.recommendationProvider.determineLuaScriptRecommendation(client), + ], + [ + RECOMMENDATION_NAMES.BIG_HASHES, + async () => await this.recommendationProvider.determineBigHashesRecommendation(keys), + ], + [ + RECOMMENDATION_NAMES.USE_SMALLER_KEYS, + async () => await this.recommendationProvider.determineBigTotalRecommendation(total), + ], + [ + RECOMMENDATION_NAMES.AVOID_LOGICAL_DATABASES, + async () => await this.recommendationProvider.determineLogicalDatabasesRecommendation(client), + ], + [ + RECOMMENDATION_NAMES.COMBINE_SMALL_STRINGS_TO_HASHES, + async () => await this.recommendationProvider.determineCombineSmallStringsToHashesRecommendation(keys), + ], + [ + RECOMMENDATION_NAMES.INCREASE_SET_MAX_INTSET_ENTRIES, + async () => await this.recommendationProvider.determineIncreaseSetMaxIntsetEntriesRecommendation(client, keys), + ], + [ + RECOMMENDATION_NAMES.HASH_HASHTABLE_TO_ZIPLIST, + async () => await this.recommendationProvider.determineHashHashtableToZiplistRecommendation(client, keys), + ], + [ + RECOMMENDATION_NAMES.COMPRESS_HASH_FIELD_NAMES, + async () => await this.recommendationProvider.determineCompressHashFieldNamesRecommendation(keys), + ], + [ + RECOMMENDATION_NAMES.COMPRESSION_FOR_LIST, + async () => await this.recommendationProvider.determineCompressionForListRecommendation(keys), + ], + [ + RECOMMENDATION_NAMES.BIG_STRINGS, + async () => await this.recommendationProvider.determineBigStringsRecommendation(keys), + ], + [ + RECOMMENDATION_NAMES.ZSET_HASHTABLE_TO_ZIPLIST, + async () => await this.recommendationProvider.determineZSetHashtableToZiplistRecommendation(client, keys), + ], + [ + RECOMMENDATION_NAMES.BIG_SETS, + async () => await this.recommendationProvider.determineBigSetsRecommendation(keys), + ], + [ + RECOMMENDATION_NAMES.BIG_AMOUNT_OF_CONNECTED_CLIENTS, + async () => await this.recommendationProvider.determineConnectionClientsRecommendation(client), + ], + [ + RECOMMENDATION_NAMES.RTS, + async () => await this.recommendationProvider.determineRTSRecommendation(client, keys), + ], + [ + RECOMMENDATION_NAMES.REDIS_SEARCH, + async () => await this.recommendationProvider.determineRediSearchRecommendation(client, keys), + ], + [ + RECOMMENDATION_NAMES.REDIS_VERSION, + async () => await this.recommendationProvider.determineRedisVersionRecommendation(client), + ], + [ + RECOMMENDATION_NAMES.SEARCH_INDEXES, + async () => await this.recommendationProvider.determineSearchIndexesRecommendation(client, keys, globalClient), + ], + [ + RECOMMENDATION_NAMES.DANGEROUS_COMMANDS, + async () => await this.recommendationProvider.determineDangerousCommandsRecommendation(client), + ], + [ + RECOMMENDATION_NAMES.SET_PASSWORD, + async () => await this.recommendationProvider.determineSetPasswordRecommendation(client), + ], + ]); + + const recommendationsToDetermine = difference(Object.values(RECOMMENDATION_NAMES), exclude); + return ( - Promise.all([ - await this.recommendationProvider.determineLuaScriptRecommendation(client), - await this.recommendationProvider.determineBigHashesRecommendation(keys), - await this.recommendationProvider.determineBigTotalRecommendation(total), - await this.recommendationProvider.determineLogicalDatabasesRecommendation(client), - await this.recommendationProvider.determineCombineSmallStringsToHashesRecommendation(keys), - await this.recommendationProvider.determineIncreaseSetMaxIntsetEntriesRecommendation(client, keys), - await this.recommendationProvider.determineHashHashtableToZiplistRecommendation(client, keys), - await this.recommendationProvider.determineCompressHashFieldNamesRecommendation(keys), - await this.recommendationProvider.determineCompressionForListRecommendation(keys), - await this.recommendationProvider.determineBigStringsRecommendation(keys), - await this.recommendationProvider.determineZSetHashtableToZiplistRecommendation(client, keys), - await this.recommendationProvider.determineBigSetsRecommendation(keys), - await this.recommendationProvider.determineConnectionClientsRecommendation(client), - // TODO rework, need better solution to do not start determine recommendation - exclude.includes(RECOMMENDATION_NAMES.RTS) ? null : await this.recommendationProvider.determineRTSRecommendation(client, keys), - await this.recommendationProvider.determineRediSearchRecommendation(client, keys), - await this.recommendationProvider.determineRedisVersionRecommendation(client), - await this.recommendationProvider.determineSearchIndexesRecommendation(client, keys, globalClient), - await this.recommendationProvider.determineSetPasswordRecommendation(client), - ])); + Promise.all(recommendationsToDetermine.map((recommendation) => recommendations.get(recommendation)()))); } } diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index ead17c1329..17e7dc11d4 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -439,7 +439,7 @@ }, "bigAmountOfConnectedClients": { "id": "bigAmountOfConnectedClients", - "title":"Don't open a new connection for every request / every command", + "title": "Don't open a new connection for every request / every command", "content": [ { "id": "1", @@ -461,7 +461,7 @@ }, "setPassword": { "id": "setPassword", - "title":"Set the password", + "title": "Set the password", "content": [ { "id": "1", @@ -502,11 +502,6 @@ "type": "paragraph", "value": "If you are using sorted sets to work with time series data, consider using RedisTimeSeries to optimize the memory usage while having extraordinary query performance and small overhead during ingestion." }, - { - "id": "2", - "type": "spacer", - "value": "l" - }, { "id": "3", "type": "span", @@ -666,5 +661,47 @@ } ], "badges": ["upgrade"] + }, + "dangerousCommands": { + "id": "dangerousCommands", + "title": "Rename or disable dangerous commands", + "content": [ + { + "id": "2", + "type": "pre", + "parameter": ["commands"], + "value": "${0} " + }, + { + "id": "2", + "type": "span", + "parameter": ["commands"], + "value": " are currently not renamed or disabled for your Instance." + }, + { + "id": "2", + "type": "paragraph", + "value": "These commands are powerful and dangerous if not managed properly." + }, + { + "id": "3", + "type": "spacer", + "value": "l" + }, + { + "id": "4", + "type": "span", + "value": "Rename or disable them, especially for the production environment. " + }, + { + "id": "5", + "type": "link", + "value": { + "href": "https://redis.io/download/", + "name": "Read more" + } + } + ], + "badges": ["code_changes"] } } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx index 2c0fb8d906..e1b73d4188 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -309,6 +309,20 @@ describe('Recommendations', () => { expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() }) + it('should render configuration_changes badge in dangerousCommands recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'dangerousCommands', params: { commands: 'some commands' } }] + } + })) + + render() + expect(screen.queryByTestId('code_changes')).toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).not.toBeInTheDocument() + }) + it('should collapse/expand and sent proper telemetry event', () => { (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ ...mockdbAnalysisSelector, diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index 6b0b37d417..68470e835e 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -102,7 +102,7 @@ const Recommendations = () => { {renderBadgesLegend()}
    - {sortedRecommendations.map(({ name }) => { + {sortedRecommendations.map(({ name, params }) => { const { id = '', title = '', @@ -125,7 +125,7 @@ const Recommendations = () => { data-testid={`${id}-accordion`} > - {renderContent(content)} + {renderContent(content, params)}
    diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx index 67e47706bb..168b26210d 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx @@ -1,5 +1,7 @@ import React from 'react' +import { isString, isArray } from 'lodash' import { + EuiText, EuiTextColor, EuiToolTip, EuiFlexGroup, @@ -7,6 +9,7 @@ import { EuiLink, EuiSpacer, } from '@elastic/eui' +import { SpacerSize } from '@elastic/eui/src/components/spacer/spacer' import cx from 'classnames' import { ReactComponent as CodeIcon } from 'uiSrc/assets/img/code-changes.svg' import { ReactComponent as ConfigurationIcon } from 'uiSrc/assets/img/configuration-changes.svg' @@ -17,7 +20,8 @@ import styles from './styles.module.scss' interface IContentElement { id: string type: string - value: any[] + value: any[] | any + parameter: string[] } const badgesContent = [ @@ -58,21 +62,44 @@ export const renderBadgesLegend = () => ( ) -const renderContentElement = ({ id, type, value }: IContentElement) => { +const replaceVariables = (value: any[] | any, parameter: string[], params: any) => ( + parameter && isString(value) ? value.replace(/\$\{\d}/i, (matched) => { + const parameterIndex: string = matched.substring( + matched.indexOf('{') + 1, + matched.lastIndexOf('}') + ) + return params[parameter[+parameterIndex]] + }) : value +) + +const renderContentElement = ({ id, type, value: jsonValue, parameter }: IContentElement, params: any) => { + const value = replaceVariables(jsonValue, parameter, params) switch (type) { case 'paragraph': - return {value} + return ( + + {value} + + ) + case 'pre': + return ( + +
    +            {value}
    +          
    +
    + ) case 'span': return {value} case 'link': return {value.name} case 'spacer': - return + return case 'list': return (
      - {value.map((listElement: IContentElement[]) => ( -
    • {renderContent(listElement)}
    • + {isArray(jsonValue) && jsonValue.map((listElement: IContentElement[]) => ( +
    • {renderContent(listElement, params)}
    • ))}
    ) @@ -81,5 +108,5 @@ const renderContentElement = ({ id, type, value }: IContentElement) => { } } -export const renderContent = (elements: IContentElement[]) => ( - elements?.map((item) => renderContentElement(item))) +export const renderContent = (elements: IContentElement[], params: any) => ( + elements?.map((item) => renderContentElement(item, params))) From 84ba9b9641351d2bd170ec2dc26deea101f8cf28 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 12 Jan 2023 09:42:06 +0400 Subject: [PATCH 141/201] #RI-3971 - add one node recommendations --- redisinsight/api/src/constants/recommendations.ts | 8 ++++++++ .../database-analysis/database-analysis.service.ts | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/src/constants/recommendations.ts b/redisinsight/api/src/constants/recommendations.ts index a53422829c..2fef5729c3 100644 --- a/redisinsight/api/src/constants/recommendations.ts +++ b/redisinsight/api/src/constants/recommendations.ts @@ -19,3 +19,11 @@ export const RECOMMENDATION_NAMES = Object.freeze({ SEARCH_INDEXES: 'searchIndexes', DANGEROUS_COMMANDS: 'dangerousCommands', }); + +export const ONE_NODE_RECOMMENDATIONS = [ + RECOMMENDATION_NAMES.LUA_SCRIPT, + RECOMMENDATION_NAMES.DANGEROUS_COMMANDS, + RECOMMENDATION_NAMES.AVOID_LOGICAL_DATABASES, + RECOMMENDATION_NAMES.RTS, + RECOMMENDATION_NAMES.REDIS_VERSION, +]; diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index cf36a38a4c..7b2918d26f 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -2,6 +2,7 @@ import { HttpException, Injectable, Logger } from '@nestjs/common'; import { isNull, flatten, concat } from 'lodash'; import { RecommendationService } from 'src/modules/recommendation/recommendation.service'; import { catchAclError } from 'src/utils'; +import { ONE_NODE_RECOMMENDATIONS } from 'src/constants'; import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/database-analyzer'; import { plainToClass } from 'class-transformer'; import { DatabaseAnalysis, ShortDatabaseAnalysis } from 'src/modules/database-analysis/models'; @@ -55,7 +56,7 @@ export class DatabaseAnalysisService { let recommendationToExclude = []; - const recommendations = await scanResults.reduce(async (previousPromise, nodeResult) => { + const recommendations = await scanResults.reduce(async (previousPromise, nodeResult, idx) => { const jobsArray = await previousPromise; const nodeRecommendations = await this.recommendationService.getRecommendations({ client: nodeResult.client, @@ -64,7 +65,9 @@ export class DatabaseAnalysisService { globalClient: client, exclude: recommendationToExclude, }); - // recommendationToExclude = concat(recommendationToExclude, [RECOMMENDATION_NAMES.RTS]); + if (idx === 0) { + recommendationToExclude = concat(recommendationToExclude, ONE_NODE_RECOMMENDATIONS); + } const foundedRecommendations = nodeRecommendations.filter((recommendation) => !isNull(recommendation)); const foundedRecommendationNames = foundedRecommendations.map(({ name }) => name); recommendationToExclude = concat(recommendationToExclude, foundedRecommendationNames); From f732f165c23e030dfa2680578e0bf5b5b71105d3 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 12 Jan 2023 10:49:05 +0400 Subject: [PATCH 142/201] #RI-3572 - add check renamed command function --- .../providers/recommendation.provider.spec.ts | 50 ++++++++++++------- .../providers/recommendation.provider.ts | 34 ++++++++++--- 2 files changed, 59 insertions(+), 25 deletions(-) diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts index 0a347eea03..bd9dca469d 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -562,9 +562,9 @@ describe('RecommendationProvider', () => { .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) .mockResolvedValue(mockFTListResponse_1); - const redisServerRecommendation = await service + const redisSearchRecommendation = await service .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); - expect(redisServerRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_SEARCH }); + expect(redisSearchRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_SEARCH }); }); it('should return rediSearch recommendation when there is huge string key', async () => { @@ -572,9 +572,9 @@ describe('RecommendationProvider', () => { .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) .mockResolvedValue(mockFTListResponse_1); - const redisServerRecommendation = await service + const redisSearchRecommendation = await service .determineRediSearchRecommendation(nodeClient, [mockRediSearchStringKey_1]); - expect(redisServerRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_SEARCH }); + expect(redisSearchRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_SEARCH }); }); it('should not return rediSearch recommendation when there is small string key', async () => { @@ -582,9 +582,9 @@ describe('RecommendationProvider', () => { .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) .mockResolvedValue(mockFTListResponse_1); - const redisServerRecommendation = await service + const redisSearchRecommendation = await service .determineRediSearchRecommendation(nodeClient, [mockRediSearchStringKey_2]); - expect(redisServerRecommendation).toEqual(null); + expect(redisSearchRecommendation).toEqual(null); }); it('should not return rediSearch recommendation when there are no indexes', async () => { @@ -592,9 +592,9 @@ describe('RecommendationProvider', () => { .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) .mockResolvedValue(mockFTListResponse_2); - const redisServerRecommendation = await service + const redisSearchRecommendation = await service .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); - expect(redisServerRecommendation).toEqual(null); + expect(redisSearchRecommendation).toEqual(null); }); it('should ignore errors when ft command execute with error', async () => { @@ -602,9 +602,9 @@ describe('RecommendationProvider', () => { .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) .mockRejectedValue("some error"); - const redisServerRecommendation = await service + const redisSearchRecommendation = await service .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); - expect(redisServerRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_SEARCH }); + expect(redisSearchRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_SEARCH }); }); it('should ignore errors when ft command execute with error', async () => { @@ -612,9 +612,9 @@ describe('RecommendationProvider', () => { .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) .mockRejectedValue("some error"); - const redisServerRecommendation = await service + const redisSearchRecommendation = await service .determineRediSearchRecommendation(nodeClient, [mockRediSearchStringKey_2]); - expect(redisServerRecommendation).toEqual(null); + expect(redisSearchRecommendation).toEqual(null); }); }); @@ -624,9 +624,9 @@ describe('RecommendationProvider', () => { .calledWith(jasmine.objectContaining({ name: 'info' })) .mockResolvedValue(mockRedisServerResponse_1); - const redisServerRecommendation = await service + const redisVersionRecommendation = await service .determineRedisVersionRecommendation(nodeClient); - expect(redisServerRecommendation).toEqual(null); + expect(redisVersionRecommendation).toEqual(null); }); it('should return redis version recommendation', async () => { @@ -634,9 +634,9 @@ describe('RecommendationProvider', () => { .calledWith(jasmine.objectContaining({ name: 'info' })) .mockResolvedValueOnce(mockRedisServerResponse_2); - const redisServerRecommendation = await service + const redisVersionRecommendation = await service .determineRedisVersionRecommendation(nodeClient); - expect(redisServerRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_VERSION }); + expect(redisVersionRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_VERSION }); }); it('should not return redis version recommendation when info command executed with error', @@ -646,9 +646,23 @@ describe('RecommendationProvider', () => { .calledWith(jasmine.objectContaining({ name: 'info' })) .mockRejectedValue('some error'); - const redisServerRecommendation = await service + const redisVersionRecommendation = await service .determineRedisVersionRecommendation(nodeClient); - expect(redisServerRecommendation).toEqual(null); + expect(redisVersionRecommendation).toEqual(null); + }); + }); + + describe('determineDangerousCommandsRecommendation', () => { + it('should not return dangerous commands recommendation when "command" command executed with error', + async () => { + resetAllWhenMocks(); + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'command' })) + .mockRejectedValue('some error'); + + const dangerousCommandsRecommendation = await service + .determineDangerousCommandsRecommendation(nodeClient); + expect(dangerousCommandsRecommendation).toEqual(null); }); }); }); diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index 2164d2854d..c938546357 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { Redis, Cluster, Command } from 'ioredis'; -import { get, isNumber } from 'lodash'; +import { get, isNull, isNumber } from 'lodash'; import { isValid } from 'date-fns'; import * as semverCompare from 'node-version-compare'; import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/utils'; @@ -440,13 +440,13 @@ export class RecommendationProvider { if (keys[processedKeysNumber].type !== RedisDataType.ZSet) { processedKeysNumber += 1; } else { - let keyBySortedSetMember; + let keyType: string; const sortedSetMember = await redisClient.sendCommand( new Command('zrange', [keys[processedKeysNumber].name, 0, 0], { replyEncoding: 'utf8' }), ) as string[]; try { - keyBySortedSetMember = await redisClient.sendCommand( + keyType = await redisClient.sendCommand( new Command('type', [sortedSetMember[0]], { replyEncoding: 'utf8' }), ) as string; } catch (err) { @@ -461,12 +461,12 @@ export class RecommendationProvider { ); } - keyBySortedSetMember = await node.sendCommand( + keyType = await node.sendCommand( new Command('type', [sortedSetMember[0]], { replyEncoding: 'utf8' }), ) as string; } } - if (keyBySortedSetMember === RedisDataType.JSON || keyBySortedSetMember === RedisDataType.Hash) { + if (keyType === RedisDataType.JSON || keyType === RedisDataType.Hash) { isJSONOrHash = true; } processedKeysNumber += 1; @@ -517,8 +517,14 @@ export class RecommendationProvider { const commandName = command.split('|')[0]; return !redisInsightCommands.includes(commandName); }); - const commands = filteredDangerousCommands.join('\r\n').toUpperCase(); - return filteredDangerousCommands + + const activeDangerousCommands = await Promise.all( + filteredDangerousCommands.map(async (command) => await this.checkCommandInfo(redisClient, command)), + ); + const commands = activeDangerousCommands + .filter((command) => !isNull(command)) + .join('\r\n').toUpperCase(); + return activeDangerousCommands.length ? { name: RECOMMENDATION_NAMES.DANGEROUS_COMMANDS, params: { commands } } : null; } catch (err) { @@ -540,6 +546,20 @@ export class RecommendationProvider { return false; } + private async checkCommandInfo(redisClient: Redis | Cluster, command: string): Promise { + try { + const result = await redisClient.sendCommand( + new Command('command', ['info', command]), + ); + if (isNull(result[0])) { + return null; + } + } catch (err) { + return null; + } + return command; + } + private checkTimestamp(value: string): boolean { try { if (!IS_NUMBER_REGEX.test(value) && isValid(new Date(value))) { From afa08a63d5b53217d4419518ebad548ad3fa6763 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Thu, 12 Jan 2023 14:52:40 +0800 Subject: [PATCH 143/201] * #RI-4011 - WORKBENCH_COMMAND_SUBMITTED event has incorrect "command" parameter value if command contains number of runs * #RI-4013 - Parameters are not applied if there is any text on the same row * #RI-4010 - WORKBENCH_COMMAND_RUN_AGAIN event should have the same parameters as WORKBENCH_COMMAND_SUBMITTED --- .../pages/workbench/WorkbenchPage.spec.tsx | 2 +- .../components/wb-view/WBView/WBView.tsx | 24 +++++++++---------- .../ui/src/utils/monaco/monacoUtils.ts | 15 ++++++++---- .../ui/src/utils/tests/workbench.spec.ts | 1 + redisinsight/ui/src/utils/workbench.ts | 6 +++-- 5 files changed, 28 insertions(+), 20 deletions(-) diff --git a/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx b/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx index 9ca8b36fff..3f5a51b29f 100644 --- a/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx +++ b/redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx @@ -232,11 +232,11 @@ describe('Telemetry', () => { event: TelemetryEvent.WORKBENCH_COMMAND_RUN_AGAIN, eventData: { auto: undefined, + pipeline: undefined, command: 'INFO;', databaseId: INSTANCE_ID_MOCK, multiple: 'Single', results: 'single', - pipeline: true, rawMode: true, } }) diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx b/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx index a1dfe9986e..09fec43381 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx @@ -8,7 +8,7 @@ import { EuiResizableContainer } from '@elastic/eui' import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api' import { CodeButtonParams } from 'uiSrc/pages/workbench/components/enablement-area/interfaces' -import { Maybe, Nullable, getMonacoLines, getMultiCommands, getParsedParamsInQuery, isParamsLine, removeMonacoComments } from 'uiSrc/utils' +import { Maybe, Nullable, getMultiCommands, getParsedParamsInQuery, removeMonacoComments, splitMonacoValuePerLines } from 'uiSrc/utils' import { BrowserStorageItem } from 'uiSrc/constants' import { localStorageService } from 'uiSrc/services' import InstanceHeader from 'uiSrc/components/instance-header' @@ -123,21 +123,11 @@ const WBView = (props: Props) => { executeParams?: CodeButtonParams, ) => { const eventData = (() => { - const lines = getMonacoLines(commandInit) - const firstLine = first(lines) ?? '' - const parsedParams: Maybe = isEmpty(executeParams) ? getParsedParamsInQuery(commandInit) : executeParams - - const auto = TelemetryEvent.WORKBENCH_COMMAND_RUN_AGAIN !== event - ? parsedParams?.auto === AutoExecute.True - : undefined - - if (isParamsLine(firstLine)) lines.shift() - const commands = without( - lines + splitMonacoValuePerLines(commandInit) .map((command) => removeMonacoComments(decode(command).trim())), '' ) @@ -151,12 +141,20 @@ const WBView = (props: Props) => { const multiCommands = getMultiCommands(rest).replaceAll('\n', ';') const command = [commandLine, multiCommands].join('') ? [commandLine, multiCommands].join(';') : null + const auto = TelemetryEvent.WORKBENCH_COMMAND_RUN_AGAIN !== event + ? parsedParams?.auto === AutoExecute.True + : undefined + + const pipeline = TelemetryEvent.WORKBENCH_COMMAND_RUN_AGAIN !== event + ? (parsedParams?.pipeline || batchSize) > 1 + : undefined + return { command: command?.toUpperCase(), auto, + pipeline, databaseId: instanceId, multiple: multiCommands ? 'Multiple' : 'Single', - pipeline: (parsedParams?.pipeline || batchSize) > 1, rawMode: (parsedParams?.mode?.toUpperCase() || state.activeMode) === RunQueryMode.Raw, results: ResultsMode.GroupMode.startsWith?.( diff --git a/redisinsight/ui/src/utils/monaco/monacoUtils.ts b/redisinsight/ui/src/utils/monaco/monacoUtils.ts index 94b7857111..a51e7c756b 100644 --- a/redisinsight/ui/src/utils/monaco/monacoUtils.ts +++ b/redisinsight/ui/src/utils/monaco/monacoUtils.ts @@ -25,6 +25,11 @@ const removeCommentsFromLine = (text: string = '', prefix: string = ''): string export const splitMonacoValuePerLines = (command = '') => { const linesResult: string[] = [] const lines = getMonacoLines(command) + // remove execute params + if (isParamsLine(first(lines))) { + lines.splice(0, 1, removeParams(first(lines))) + } + lines.forEach((line) => { const [commandLine, countRepeat] = getCommandRepeat(line || '') @@ -35,10 +40,6 @@ export const splitMonacoValuePerLines = (command = '') => { linesResult.push(...Array(countRepeat).fill(commandLine)) }) - // remove execute params - if (isParamsLine(first(linesResult))) { - linesResult.shift() - } return linesResult } @@ -206,5 +207,11 @@ export const isParamsLine = (commandInit: string = '') => { return command.startsWith('[') && (command.indexOf(']') !== -1) } +const removeParams = (commandInit: string = '') => { + const command = commandInit.trim() + const paramsLastIndex = command.indexOf(']') + return command.substring(paramsLastIndex + 1).trim() +} + export const getMonacoLines = (command: string = '') => command.split(/\n(?=[^\s])/g) diff --git a/redisinsight/ui/src/utils/tests/workbench.spec.ts b/redisinsight/ui/src/utils/tests/workbench.spec.ts index eb644542c7..50fb4fb880 100644 --- a/redisinsight/ui/src/utils/tests/workbench.spec.ts +++ b/redisinsight/ui/src/utils/tests/workbench.spec.ts @@ -42,6 +42,7 @@ describe('getParsedParamsInQuery', () => { ['get test\n[mode=raw]\nget test2\nget test3', {}], ['[mode=raw]\nget test\nget test2\nget test3', { mode: 'raw' }], ['[mode=raw;mode=ascii]\nget test\nget test2\nget test3', { mode: 'raw' }], + ['[mode=raw;results=ascii]info\nget test\nget test2\nget test3', { mode: 'raw', results: 'ascii' }], ['[mode=raw;results=group;pipeline=10]\nget test\nget test2\nget test3', { mode: 'raw', results: 'group', pipeline: '10' }], ])('for input: %s (input), should be output: %s', (input, expected) => { diff --git a/redisinsight/ui/src/utils/workbench.ts b/redisinsight/ui/src/utils/workbench.ts index 9f9a71c76d..6e92f5867e 100644 --- a/redisinsight/ui/src/utils/workbench.ts +++ b/redisinsight/ui/src/utils/workbench.ts @@ -37,9 +37,11 @@ export const getParsedParamsInQuery = (query: string) => { const lines = getMonacoLines(query) if (isParamsLine(first(lines))) { - const params = lines.shift() - ?.replaceAll?.('\n', '') + const paramsLine = lines.shift() || '' + const params = paramsLine + ?.substring?.(paramsLine.indexOf(']') + 1, 0) ?? '' + parsedParams = parseParams(params) } From 7434698a11325a79ec05ae5ca07c2904d70344ec Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 12 Jan 2023 10:54:34 +0400 Subject: [PATCH 144/201] #RI-3971 - update json file --- .../src/constants/dbAnalysisRecommendations.json | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 17e7dc11d4..0a6b074738 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -502,6 +502,11 @@ "type": "paragraph", "value": "If you are using sorted sets to work with time series data, consider using RedisTimeSeries to optimize the memory usage while having extraordinary query performance and small overhead during ingestion." }, + { + "id": "2", + "type": "spacer", + "value": "l" + }, { "id": "3", "type": "span", @@ -667,7 +672,7 @@ "title": "Rename or disable dangerous commands", "content": [ { - "id": "2", + "id": "1", "type": "pre", "parameter": ["commands"], "value": "${0} " @@ -679,22 +684,22 @@ "value": " are currently not renamed or disabled for your Instance." }, { - "id": "2", + "id": "3", "type": "paragraph", "value": "These commands are powerful and dangerous if not managed properly." }, { - "id": "3", + "id": "4", "type": "spacer", "value": "l" }, { - "id": "4", + "id": "5", "type": "span", "value": "Rename or disable them, especially for the production environment. " }, { - "id": "5", + "id": "6", "type": "link", "value": { "href": "https://redis.io/download/", From 5bf736631b0dbe3f46b726a91574ba752f779c79 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 12 Jan 2023 11:00:59 +0400 Subject: [PATCH 145/201] #RI-3971 - remove unused code --- redisinsight/ui/src/constants/dbAnalysisRecommendations.json | 1 - .../databaseAnalysis/components/recommendations-view/utils.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 0a6b074738..9a5790335d 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -680,7 +680,6 @@ { "id": "2", "type": "span", - "parameter": ["commands"], "value": " are currently not renamed or disabled for your Instance." }, { diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx index 168b26210d..fad8f5aca2 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx @@ -1,7 +1,6 @@ import React from 'react' import { isString, isArray } from 'lodash' import { - EuiText, EuiTextColor, EuiToolTip, EuiFlexGroup, From ab0ec793bab24c34b721734ffeee8184272ef0f5 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Thu, 12 Jan 2023 15:12:57 +0800 Subject: [PATCH 146/201] fix tests --- redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts b/redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts index e411d5e0d0..85aa5de7fe 100644 --- a/redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts +++ b/redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts @@ -100,8 +100,8 @@ describe('splitMonacoValuePerLines', () => { ], // Multi commands with parameters and repeating syntax error [ - '[results=group;mode=raw]\nget test\n3get test2\nget bar', - ['get test', '3get test2', 'get bar'] + '[results=group;mode=raw]info\nget test\n3get test2\nget bar', + ['info', 'get test', '3get test2', 'get bar'] ], ] test.each(cases)( From 653fd018d5b85fcdf2493e07d3096d037fa56b70 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Thu, 12 Jan 2023 12:37:30 +0400 Subject: [PATCH 147/201] #RI-3572 - resolve comments --- .../providers/recommendation.provider.ts | 85 +++++++++---------- .../constants/dbAnalysisRecommendations.json | 24 ++---- 2 files changed, 49 insertions(+), 60 deletions(-) diff --git a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts index c938546357..9ca90028e3 100644 --- a/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -428,55 +428,54 @@ export class RecommendationProvider { keys: Key[], client: any, ): Promise { - if (client.isCluster) { - let processedKeysNumber = 0; - let isJSONOrHash = false; - let sortedSetNumber = 0; - while ( - processedKeysNumber < keys.length - && !isJSONOrHash - && sortedSetNumber <= sortedSetCountForCheck - ) { - if (keys[processedKeysNumber].type !== RedisDataType.ZSet) { - processedKeysNumber += 1; - } else { - let keyType: string; - const sortedSetMember = await redisClient.sendCommand( - new Command('zrange', [keys[processedKeysNumber].name, 0, 0], { replyEncoding: 'utf8' }), - ) as string[]; - - try { - keyType = await redisClient.sendCommand( - new Command('type', [sortedSetMember[0]], { replyEncoding: 'utf8' }), - ) as string; - } catch (err) { - if (err && checkRedirectionError(err)) { - const { address } = parseRedirectionError(err); - const nodes = client.nodes('master'); - - const node: any = nodes.find(({ options: { host, port } }: Redis) => `${host}:${port}` === address); - if (!node) { - throw new ClusterNodeNotFoundError( - ERROR_MESSAGES.CLUSTER_NODE_NOT_FOUND(node), - ); - } - - keyType = await node.sendCommand( + try { + if (client.isCluster) { + let processedKeysNumber = 0; + let isJSONOrHash = false; + let sortedSetNumber = 0; + while ( + processedKeysNumber < keys.length + && !isJSONOrHash + && sortedSetNumber <= sortedSetCountForCheck + ) { + if (keys[processedKeysNumber].type !== RedisDataType.ZSet) { + processedKeysNumber += 1; + } else { + let keyType: string; + const sortedSetMember = await redisClient.sendCommand( + new Command('zrange', [keys[processedKeysNumber].name, 0, 0], { replyEncoding: 'utf8' }), + ) as string[]; + try { + keyType = await redisClient.sendCommand( new Command('type', [sortedSetMember[0]], { replyEncoding: 'utf8' }), ) as string; + } catch (err) { + if (err && checkRedirectionError(err)) { + const { address } = parseRedirectionError(err); + const nodes = client.nodes('master'); + + const node: any = nodes.find(({ options: { host, port } }: Redis) => `${host}:${port}` === address); + if (!node) { + throw new ClusterNodeNotFoundError( + ERROR_MESSAGES.CLUSTER_NODE_NOT_FOUND(node), + ); + } + + keyType = await node.sendCommand( + new Command('type', [sortedSetMember[0]], { replyEncoding: 'utf8' }), + ) as string; + } } + if (keyType === RedisDataType.JSON || keyType === RedisDataType.Hash) { + isJSONOrHash = true; + } + processedKeysNumber += 1; + sortedSetNumber += 1; } - if (keyType === RedisDataType.JSON || keyType === RedisDataType.Hash) { - isJSONOrHash = true; - } - processedKeysNumber += 1; - sortedSetNumber += 1; } - } - return isJSONOrHash ? { name: RECOMMENDATION_NAMES.SEARCH_INDEXES } : null; - } - try { + return isJSONOrHash ? { name: RECOMMENDATION_NAMES.SEARCH_INDEXES } : null; + } const sortedSets = keys .filter(({ type }) => type === RedisDataType.ZSet) .slice(0, 100); diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index 9a5790335d..e8eae1a338 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -673,32 +673,22 @@ "content": [ { "id": "1", - "type": "pre", - "parameter": ["commands"], - "value": "${0} " + "type": "paragraph", + "value": "The following commands are currently not renamed or disabled for your Instance. These commands are powerful and dangerous if not managed properly. Rename or disable them, especially for the production environment" }, { "id": "2", - "type": "span", - "value": " are currently not renamed or disabled for your Instance." + "type": "pre", + "parameter": ["commands"], + "value": "${0} " }, { "id": "3", - "type": "paragraph", - "value": "These commands are powerful and dangerous if not managed properly." - }, - { - "id": "4", "type": "spacer", - "value": "l" + "value": "s" }, { - "id": "5", - "type": "span", - "value": "Rename or disable them, especially for the production environment. " - }, - { - "id": "6", + "id": "4", "type": "link", "value": { "href": "https://redis.io/download/", From ba61fab2e1b4e3dfecb0fbdd4eeb9cd07849e755 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Thu, 12 Jan 2023 17:01:29 +0800 Subject: [PATCH 148/201] #RI-4012 - Edit alias input appears in a second after opening Edit panel --- redisinsight/ui/src/pages/home/HomePage.tsx | 13 +++--- .../ui/src/slices/instances/instances.ts | 22 ++++++++-- .../slices/tests/instances/instances.spec.ts | 41 ++++++++++++++++--- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/redisinsight/ui/src/pages/home/HomePage.tsx b/redisinsight/ui/src/pages/home/HomePage.tsx index cfabb8c2e6..6f52dcf45f 100644 --- a/redisinsight/ui/src/pages/home/HomePage.tsx +++ b/redisinsight/ui/src/pages/home/HomePage.tsx @@ -122,7 +122,7 @@ const HomePage = () => { if (editedInstance) { const found = instances.find((item: Instance) => item.id === editedInstance.id) if (found) { - dispatch(fetchEditedInstanceAction(found.id)) + dispatch(fetchEditedInstanceAction(found)) } } }, [instances]) @@ -160,12 +160,13 @@ const HomePage = () => { setEditDialogIsOpen(false) } - const handleEditInstance = ({ id }: Instance) => { - dispatch(fetchEditedInstanceAction(id)) - setEditDialogIsOpen(true) - setAddDialogIsOpen(false) + const handleEditInstance = (editedInstance: Instance) => { + if (editedInstance) { + dispatch(fetchEditedInstanceAction(editedInstance)) + setEditDialogIsOpen(true) + setAddDialogIsOpen(false) + } } - const handleDeleteInstances = (instances: Instance[]) => { if (instances.find((instance) => instance.id === editedInstance?.id)) { dispatch(setEditedInstance(null)) diff --git a/redisinsight/ui/src/slices/instances/instances.ts b/redisinsight/ui/src/slices/instances/instances.ts index 7e27895c17..a7ff10ef9e 100644 --- a/redisinsight/ui/src/slices/instances/instances.ts +++ b/redisinsight/ui/src/slices/instances/instances.ts @@ -1,4 +1,4 @@ -import { first, map } from 'lodash' +import { first, isNull, map } from 'lodash' import { createSlice } from '@reduxjs/toolkit' import axios, { AxiosError, CancelTokenSource } from 'axios' @@ -180,6 +180,17 @@ const instancesSlice = createSlice({ state.editedInstance.data = payload }, + updateEditedInstance: (state, { payload }: { payload: Nullable }) => { + if (isNull(state.editedInstance.data)) { + state.editedInstance.data = payload + } else { + state.editedInstance.data = { + ...state.editedInstance.data, + ...payload, + } + } + }, + setConnectedInstanceFailure: (state) => { state.connectedInstance.loading = false }, @@ -247,6 +258,7 @@ export const { changeInstanceAliasFailure, resetInstanceUpdate, setEditedInstance, + updateEditedInstance, importInstancesFromFile, importInstancesFromFileSuccess, importInstancesFromFileFailure, @@ -435,20 +447,22 @@ export function fetchConnectedInstanceInfoAction(id: string, onSuccess?: () => v } // Asynchronous thunk action -export function fetchEditedInstanceAction(id: string, onSuccess?: () => void) { +export function fetchEditedInstanceAction(instance: Instance, onSuccess?: () => void) { return async (dispatch: AppDispatch) => { dispatch(setDefaultInstance()) + dispatch(setEditedInstance(instance)) try { - const { data, status } = await apiService.get(`${ApiEndpoints.DATABASES}/${id}`) + const { data, status } = await apiService.get(`${ApiEndpoints.DATABASES}/${instance.id}`) if (isStatusSuccessful(status)) { - dispatch(setEditedInstance(data)) + dispatch(updateEditedInstance(data)) dispatch(setDefaultInstanceSuccess()) } onSuccess?.() } catch (error) { const errorMessage = getApiErrorMessage(error) + dispatch(setEditedInstance(null)) dispatch(setConnectedInstanceFailure()) dispatch(setDefaultInstanceFailure(errorMessage)) dispatch(addErrorNotification(error)) diff --git a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts index f59bb7aa8d..dec5c230cc 100644 --- a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts @@ -55,7 +55,8 @@ import reducer, { checkDatabaseIndexAction, setConnectedInfoInstance, setConnectedInfoInstanceSuccess, - fetchConnectedInstanceInfoAction + fetchConnectedInstanceInfoAction, + updateEditedInstance, } from '../../instances/instances' import { addErrorNotification, addMessageNotification, IAddInstanceErrorPayload } from '../../app/notifications' import { ConnectionType, InitialStateInstances, Instance } from '../../interfaces' @@ -580,6 +581,31 @@ describe('instances slice', () => { }) }) + describe('updateEditedInstance', () => { + it('should properly set error', () => { + // Arrange + const data = instances[1] + const state = { + ...initialState, + editedInstance: { + ...initialState.editedInstance, + data, + } + } + + // Act + const nextState = reducer(initialState, updateEditedInstance(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + connections: { + instances: nextState, + }, + }) + expect(instancesSelector(rootState)).toEqual(state) + }) + }) + describe('importInstancesFromFile', () => { it('should properly set state', () => { // Arrange @@ -1258,19 +1284,20 @@ describe('instances slice', () => { describe('fetchEditedInstanceAction', () => { it('call both setEditedInstance and setDefaultInstanceSuccess when fetch is successed', async () => { // Arrange - const id = 'instanceId' + const editedInstance = { id: 'instanceId', host: '1', port: 1, modules: [] } const data = instances[1] const responsePayload = { data, status: 200 } apiService.get = jest.fn().mockResolvedValue(responsePayload) // Act - await store.dispatch(fetchEditedInstanceAction(id)) + await store.dispatch(fetchEditedInstanceAction(editedInstance)) // Assert const expectedActions = [ setDefaultInstance(), - setEditedInstance(responsePayload.data), + setEditedInstance(editedInstance), + updateEditedInstance(responsePayload.data), setDefaultInstanceSuccess(), ] expect(store.getActions()).toEqual(expectedActions) @@ -1278,7 +1305,7 @@ describe('instances slice', () => { it('call both setDefaultInstance and setDefaultInstanceFailure when fetch is fail', async () => { // Arrange - const id = 'instanceId' + const editedInstance = { id: 'instanceId', host: '1', port: 1, modules: [] } const errorMessage = 'Could not connect to aoeu:123, please check the connection details.' const responsePayload = { response: { @@ -1290,11 +1317,13 @@ describe('instances slice', () => { apiService.get = jest.fn().mockRejectedValueOnce(responsePayload) // Act - await store.dispatch(fetchEditedInstanceAction(id)) + await store.dispatch(fetchEditedInstanceAction(editedInstance)) // Assert const expectedActions = [ setDefaultInstance(), + setEditedInstance(editedInstance), + setEditedInstance(null), setConnectedInstanceFailure(), setDefaultInstanceFailure(responsePayload.response.data.message), addErrorNotification(responsePayload as AxiosError), From 38b2313875d700c6cee6c118a0cd143cade174ca Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Thu, 12 Jan 2023 17:04:44 +0800 Subject: [PATCH 149/201] #RI-4012 - fix pr comment --- redisinsight/ui/src/slices/tests/instances/instances.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts index dec5c230cc..5f85d35c71 100644 --- a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts @@ -557,7 +557,7 @@ describe('instances slice', () => { }) describe('setEditedInstance', () => { - it('should properly set error', () => { + it('should properly set state', () => { // Arrange const data = instances[1] const state = { @@ -582,7 +582,7 @@ describe('instances slice', () => { }) describe('updateEditedInstance', () => { - it('should properly set error', () => { + it('should properly set state', () => { // Arrange const data = instances[1] const state = { From dde336047d77abe607645ff4c028ecab91722e7f Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Thu, 12 Jan 2023 12:08:16 +0300 Subject: [PATCH 150/201] #RI-4014 - fix re-rendering, fix highlighting if no count, fix click space for tab highlighting --- .../components/analytics-tabs/constants.tsx | 10 ++++-- .../DatabaseOverviewWrapper.tsx | 2 +- .../components/data-nav-tabs/constants.tsx | 32 +++++++++++-------- .../src/slices/app/features-highlighting.ts | 3 -- .../ui/src/styles/components/_tabs.scss | 10 ++++++ redisinsight/ui/src/utils/highlighting.ts | 3 ++ .../ui/src/utils/tests/highlighting.spec.ts | 10 +++++- 7 files changed, 48 insertions(+), 22 deletions(-) diff --git a/redisinsight/ui/src/components/analytics-tabs/constants.tsx b/redisinsight/ui/src/components/analytics-tabs/constants.tsx index 66922f4acb..6131253503 100644 --- a/redisinsight/ui/src/components/analytics-tabs/constants.tsx +++ b/redisinsight/ui/src/components/analytics-tabs/constants.tsx @@ -1,9 +1,11 @@ import React, { ReactNode } from 'react' -import { AnalyticsViewTab } from 'uiSrc/slices/interfaces/analytics' import { useSelector } from 'react-redux' -import { appFeaturesToHighlightSelector } from 'uiSrc/slices/app/features-highlighting' + +import { AnalyticsViewTab } from 'uiSrc/slices/interfaces/analytics' +import { appFeatureHighlightingSelector } from 'uiSrc/slices/app/features-highlighting' import HighlightedFeature from 'uiSrc/components/hightlighted-feature/HighlightedFeature' import { BUILD_FEATURES } from 'uiSrc/constants/featuresHighlighting' +import { getHighlightingFeatures } from 'uiSrc/utils/highlighting' interface AnalyticsTabs { id: AnalyticsViewTab @@ -11,7 +13,8 @@ interface AnalyticsTabs { } const DatabaseAnalyticsTab = () => { - const { recommendations: recommendationsHighlighting } = useSelector(appFeaturesToHighlightSelector) ?? {} + const { features } = useSelector(appFeatureHighlightingSelector) + const { recommendations: recommendationsHighlighting } = getHighlightingFeatures(features) return ( <> @@ -21,6 +24,7 @@ const DatabaseAnalyticsTab = () => { type={BUILD_FEATURES.recommendations?.type} isHighlight={BUILD_FEATURES.recommendations && recommendationsHighlighting} dotClassName="tab-highlighting-dot" + wrapperClassName="inner-highlighting-wrapper" > Database Analysis diff --git a/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx b/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx index a8998451cc..6033b5c41f 100644 --- a/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx +++ b/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx @@ -10,7 +10,7 @@ import { ThemeContext } from 'uiSrc/contexts/themeContext' import { getOverviewMetrics } from './components/OverviewMetrics' -const TIMEOUT_TO_GET_INFO = process.env.NODE_ENV !== 'development' ? 5000 : 10000000 +const TIMEOUT_TO_GET_INFO = process.env.NODE_ENV !== 'development' ? 5000 : 60_000 interface IProps { windowDimensions: number } diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx index 85bb925c00..82ea803159 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx @@ -2,10 +2,14 @@ import React, { ReactNode } from 'react' import { useDispatch, useSelector } from 'react-redux' import { DatabaseAnalysisViewTab } from 'uiSrc/slices/interfaces/analytics' -import { appFeaturesToHighlightSelector, removeFeatureFromHighlighting } from 'uiSrc/slices/app/features-highlighting' +import { + appFeatureHighlightingSelector, + removeFeatureFromHighlighting +} from 'uiSrc/slices/app/features-highlighting' import { BUILD_FEATURES } from 'uiSrc/constants/featuresHighlighting' import HighlightedFeature from 'uiSrc/components/hightlighted-feature/HighlightedFeature' +import { getHighlightingFeatures } from 'uiSrc/utils/highlighting' import Recommendations from '../recommendations-view' import AnalysisDataView from '../analysis-data-view' @@ -16,22 +20,22 @@ interface DatabaseAnalysisTabs { } const RecommendationsTab = ({ count }: { count?: number }) => { - const { recommendations: recommendationsHighlighting } = useSelector(appFeaturesToHighlightSelector) ?? {} + const { features } = useSelector(appFeatureHighlightingSelector) + const { recommendations: recommendationsHighlighting } = getHighlightingFeatures(features) const dispatch = useDispatch() - return count ? ( - <> - dispatch(removeFeatureFromHighlighting('recommendations'))} - dotClassName="tab-highlighting-dot" - > - <>Recommendations ({count}) - - - ) : (<>Recommendations) + return ( + dispatch(removeFeatureFromHighlighting('recommendations'))} + dotClassName="tab-highlighting-dot" + wrapperClassName="inner-highlighting-wrapper" + > + {count ? <>Recommendations ({count}) : <>Recommendations} + + ) } export const databaseAnalysisTabs: DatabaseAnalysisTabs[] = [ diff --git a/redisinsight/ui/src/slices/app/features-highlighting.ts b/redisinsight/ui/src/slices/app/features-highlighting.ts index 9d2d2d37cb..93de48534e 100644 --- a/redisinsight/ui/src/slices/app/features-highlighting.ts +++ b/redisinsight/ui/src/slices/app/features-highlighting.ts @@ -44,9 +44,6 @@ export const { } = appFeaturesHighlightingSlice.actions export const appFeatureHighlightingSelector = (state: RootState) => state.app.featuresHighlighting -export const appFeaturesToHighlightSelector = (state: RootState): { [key: string]: boolean } => - state.app.featuresHighlighting.features - .reduce((prev, next) => ({ ...prev, [next]: true }), {}) export const appFeaturePagesHighlightingSelector = (state: RootState) => state.app.featuresHighlighting.pages export default appFeaturesHighlightingSlice.reducer diff --git a/redisinsight/ui/src/styles/components/_tabs.scss b/redisinsight/ui/src/styles/components/_tabs.scss index 4c22c64a14..166250e16e 100644 --- a/redisinsight/ui/src/styles/components/_tabs.scss +++ b/redisinsight/ui/src/styles/components/_tabs.scss @@ -67,6 +67,16 @@ } } + .inner-highlighting-wrapper { + margin: -8px -12px; + padding: 8px 12px; + + .tab-highlighting-dot { + top: 2px; + right: 2px; + } + } + .tab-highlighting-dot { top: -6px; right: -12px; diff --git a/redisinsight/ui/src/utils/highlighting.ts b/redisinsight/ui/src/utils/highlighting.ts index 95479d32cf..6609f59db4 100644 --- a/redisinsight/ui/src/utils/highlighting.ts +++ b/redisinsight/ui/src/utils/highlighting.ts @@ -17,3 +17,6 @@ export const getPagesForFeatures = (features: string[] = []) => { return result } + +export const getHighlightingFeatures = (features: string[]): { [key: string]: boolean } => features + .reduce((prev, next) => ({ ...prev, [next]: true }), {}) diff --git a/redisinsight/ui/src/utils/tests/highlighting.spec.ts b/redisinsight/ui/src/utils/tests/highlighting.spec.ts index df999bd68a..082dc7a582 100644 --- a/redisinsight/ui/src/utils/tests/highlighting.spec.ts +++ b/redisinsight/ui/src/utils/tests/highlighting.spec.ts @@ -1,4 +1,4 @@ -import { getPagesForFeatures } from 'uiSrc/utils/highlighting' +import { getHighlightingFeatures, getPagesForFeatures } from 'uiSrc/utils/highlighting' import { MOCKED_HIGHLIGHTING_FEATURES } from 'uiSrc/utils/test-utils' describe('getPagesForFeatures', () => { @@ -10,3 +10,11 @@ describe('getPagesForFeatures', () => { expect(getPagesForFeatures(MOCKED_HIGHLIGHTING_FEATURES)).toEqual({ browser: MOCKED_HIGHLIGHTING_FEATURES }) }) }) + +describe('getPagesForFeatures', () => { + it('should return proper pages for features', () => { + expect(getHighlightingFeatures([])).toEqual({}) + expect(getHighlightingFeatures(['feature1'])).toEqual({ feature1: true }) + expect(getHighlightingFeatures(['f1', 'f2'])).toEqual({ f1: true, f2: true }) + }) +}) From 1fda041fcde48a14d996343efc29e63ec5b7be8e Mon Sep 17 00:00:00 2001 From: Zalenski Egor <63463140+zalenskiSofteq@users.noreply.github.com> Date: Thu, 12 Jan 2023 21:12:46 +0800 Subject: [PATCH 151/201] #RI-4010 - WORKBENCH_COMMAND_RUN_AGAIN event should have the same parameters as WORKBENCH_COMMAND_SUBMITTED --- .../ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx b/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx index 09fec43381..c2691a1502 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx @@ -163,7 +163,7 @@ const WBView = (props: Props) => { || 'GROUP' ) ? 'group' - : (parsedParams?.results === 'silent' ? 'silent' : 'single'), + : (parsedParams?.results?.toLowerCase() === 'silent' ? 'silent' : 'single'), } })() From b1a108b87eb78a43b2b55fc9d6b59da15d13fc06 Mon Sep 17 00:00:00 2001 From: Artem Date: Fri, 13 Jan 2023 08:47:23 +0200 Subject: [PATCH 152/201] #RI-3974 UTests --- redisinsight/api/src/__mocks__/common.ts | 1 + redisinsight/api/src/__mocks__/databases.ts | 30 +++++ redisinsight/api/src/__mocks__/index.ts | 1 + redisinsight/api/src/__mocks__/ssh.ts | 49 +++++++ .../local.database.repository.spec.ts | 122 +++++++++++++++++- .../stack.database.repository.spec.ts | 20 +-- .../profiler/models/redis.observer.spec.ts | 4 +- .../redis/redis-connection.factory.spec.ts | 7 +- .../api/src/modules/ssh/models/ssh-options.ts | 3 + .../modules/ssh/ssh-tunnel.provider.spec.ts | 112 ++++++++++++++++ .../ssh-options.transformer.spec.ts | 33 +++++ 11 files changed, 364 insertions(+), 18 deletions(-) create mode 100644 redisinsight/api/src/__mocks__/ssh.ts create mode 100644 redisinsight/api/src/modules/ssh/ssh-tunnel.provider.spec.ts create mode 100644 redisinsight/api/src/modules/ssh/transformers/ssh-options.transformer.spec.ts diff --git a/redisinsight/api/src/__mocks__/common.ts b/redisinsight/api/src/__mocks__/common.ts index c4844f6c3b..f1769eb49c 100644 --- a/redisinsight/api/src/__mocks__/common.ts +++ b/redisinsight/api/src/__mocks__/common.ts @@ -54,6 +54,7 @@ export const mockRepository = jest.fn(() => ({ findOneBy: jest.fn(), find: jest.fn(), findByIds: jest.fn(), + merge: jest.fn(), create: jest.fn(), save: jest.fn(), insert: jest.fn(), diff --git a/redisinsight/api/src/__mocks__/databases.ts b/redisinsight/api/src/__mocks__/databases.ts index b86f8eb7b7..e90f4f2127 100644 --- a/redisinsight/api/src/__mocks__/databases.ts +++ b/redisinsight/api/src/__mocks__/databases.ts @@ -9,6 +9,12 @@ import { pick } from 'lodash'; import { RedisDatabaseInfoResponse } from 'src/modules/database/dto/redis-info.dto'; import { DatabaseOverview } from 'src/modules/database/models/database-overview'; import { ClientContext, ClientMetadata } from 'src/common/models'; +import { + mockSshOptionsBasic, + mockSshOptionsBasicEntity, + mockSshOptionsPrivateKey, + mockSshOptionsPrivateKeyEntity, +} from 'src/__mocks__/ssh'; export const mockDatabaseId = 'a77b23c1-7816-4ea4-b61f-d37795a0f805-db-id'; @@ -34,6 +40,29 @@ export const mockDatabaseEntity = Object.assign(new DatabaseEntity(), { encryption: null, }); +export const mockDatabaseWithSshBasic = Object.assign(new Database(), { + ...mockDatabase, + ssh: true, + sshOptions: mockSshOptionsBasic, +}); + +export const mockDatabaseWithSshBasicEntity = Object.assign(new DatabaseEntity(), { + ...mockDatabaseWithSshBasic, + encryption: null, + sshOptions: mockSshOptionsBasicEntity, +}); + +export const mockDatabaseWithSshPrivateKey = Object.assign(new Database(), { + ...mockDatabase, + ssh: true, + sshOptions: mockSshOptionsPrivateKey, +}); + +export const mockDatabaseWithSshPrivateKeyEntity = Object.assign(new DatabaseEntity(), { + ...mockDatabaseWithSshPrivateKey, + sshOptions: mockSshOptionsPrivateKeyEntity, +}); + export const mockDatabaseWithAuth = Object.assign(new Database(), { ...mockDatabase, username: 'some username', @@ -181,6 +210,7 @@ export const mockDatabaseService = jest.fn(() => ({ export const mockDatabaseConnectionService = jest.fn(() => ({ getOrCreateClient: jest.fn().mockResolvedValue(mockIORedisClient), + createClient: jest.fn().mockResolvedValue(mockIORedisClient), })); export const mockDatabaseInfoProvider = jest.fn(() => ({ diff --git a/redisinsight/api/src/__mocks__/index.ts b/redisinsight/api/src/__mocks__/index.ts index fb5568e1b1..ab17f43dc4 100644 --- a/redisinsight/api/src/__mocks__/index.ts +++ b/redisinsight/api/src/__mocks__/index.ts @@ -18,3 +18,4 @@ export * from './redis-enterprise'; export * from './redis-sentinel'; export * from './database-import'; export * from './redis-client'; +export * from './ssh'; diff --git a/redisinsight/api/src/__mocks__/ssh.ts b/redisinsight/api/src/__mocks__/ssh.ts new file mode 100644 index 0000000000..621821cbc3 --- /dev/null +++ b/redisinsight/api/src/__mocks__/ssh.ts @@ -0,0 +1,49 @@ +import { EncryptionStrategy } from 'src/modules/encryption/models'; +import { SshOptions } from 'src/modules/ssh/models/ssh-options'; +import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; + +export const mockSshOptionsId = 'a77b23c1-7816-4ea4-b61f-d37795a0f805-ssh-id'; + +export const mockSshOptionsUsernamePlain = 'ssh-username'; +export const mockSshOptionsUsernameEncrypted = 'ssh.username.ENCRYPTED'; +export const mockSshOptionsPasswordPlain = 'ssh-password'; +export const mockSshOptionsPasswordEncrypted = 'ssh.password.ENCRYPTED'; +export const mockSshOptionsPrivateKeyPlain = '-----BEGIN OPENSSH PRIVATE KEY-----\nssh-private-key'; +export const mockSshOptionsPrivateKeyEncrypted = 'ssh.privateKey.ENCRYPTED'; +export const mockSshOptionsPassphrasePlain = 'ssh-passphrase'; +export const mockSshOptionsPassphraseEncrypted = 'ssh.passphrase.ENCRYPTED'; + +export const mockSshOptionsBasic = Object.assign(new SshOptions(), { + id: mockSshOptionsId, + host: 'ssh.host.test', + port: 22, + username: mockSshOptionsUsernamePlain, + password: mockSshOptionsPasswordPlain, + privateKey: null, + passphrase: null, +}); + +export const mockSshOptionsBasicEntity = Object.assign(new SshOptionsEntity(), { + ...mockSshOptionsBasic, + username: mockSshOptionsUsernameEncrypted, + password: mockSshOptionsPasswordEncrypted, + encryption: EncryptionStrategy.KEYTAR, +}); + +export const mockSshOptionsPrivateKey = Object.assign(new SshOptions(), { + ...mockSshOptionsBasic, + password: null, + privateKey: mockSshOptionsPrivateKeyPlain, + passphrase: mockSshOptionsPassphrasePlain, +}); + +export const mockSshOptionsPrivateKeyEntity = Object.assign(new SshOptionsEntity(), { + ...mockSshOptionsBasicEntity, + password: null, + privateKey: mockSshOptionsPrivateKeyEncrypted, + passphrase: mockSshOptionsPassphraseEncrypted, +}); + +export const mockSshTunnelProvider = jest.fn(() => { + +}); 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 2552ad2c05..55af7f2cc2 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 @@ -5,7 +5,9 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { mockCaCertificateRepository, - mockClientCertificateRepository, mockClusterDatabaseWithTlsAuth, mockClusterDatabaseWithTlsAuthEntity, + mockClientCertificateRepository, + mockClusterDatabaseWithTlsAuth, + mockClusterDatabaseWithTlsAuthEntity, mockDatabase, mockDatabaseEntity, mockDatabaseId, @@ -13,11 +15,27 @@ import { mockDatabasePasswordPlain, mockDatabaseSentinelMasterPasswordEncrypted, mockDatabaseSentinelMasterPasswordPlain, - mockDatabaseWithTls, mockDatabaseWithTlsAuth, + mockDatabaseWithSshBasic, + mockDatabaseWithSshBasicEntity, + mockDatabaseWithSshPrivateKey, + mockDatabaseWithSshPrivateKeyEntity, + mockDatabaseWithTls, + mockDatabaseWithTlsAuth, mockDatabaseWithTlsAuthEntity, mockDatabaseWithTlsEntity, mockEncryptionService, - mockRepository, mockSentinelDatabaseWithTlsAuth, mockSentinelDatabaseWithTlsAuthEntity, + mockRepository, + mockSentinelDatabaseWithTlsAuth, + mockSentinelDatabaseWithTlsAuthEntity, + mockSshOptionsBasicEntity, + mockSshOptionsPassphraseEncrypted, + mockSshOptionsPassphrasePlain, + mockSshOptionsPasswordEncrypted, + mockSshOptionsPasswordPlain, + mockSshOptionsPrivateKeyEncrypted, mockSshOptionsPrivateKeyEntity, + mockSshOptionsPrivateKeyPlain, + mockSshOptionsUsernameEncrypted, + mockSshOptionsUsernamePlain, MockType, } from 'src/__mocks__'; import { EncryptionService } from 'src/modules/encryption/encryption.service'; @@ -26,6 +44,7 @@ import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; import { CaCertificateRepository } from 'src/modules/certificate/repositories/ca-certificate.repository'; import { ClientCertificateRepository } from 'src/modules/certificate/repositories/client-certificate.repository'; import { cloneClassInstance } from 'src/utils'; +import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; const listFields = [ 'id', 'name', 'host', 'port', 'db', @@ -36,6 +55,7 @@ describe('LocalDatabaseRepository', () => { let service: LocalDatabaseRepository; let encryptionService: MockType; let repository: MockType>; + let sshOptionsRepository: MockType>; let caCertRepository: MockType; let clientCertRepository: MockType; @@ -49,6 +69,10 @@ describe('LocalDatabaseRepository', () => { provide: getRepositoryToken(DatabaseEntity), useFactory: mockRepository, }, + { + provide: getRepositoryToken(SshOptionsEntity), + useFactory: mockRepository, + }, { provide: EncryptionService, useFactory: mockEncryptionService, @@ -65,6 +89,7 @@ describe('LocalDatabaseRepository', () => { }).compile(); repository = await module.get(getRepositoryToken(DatabaseEntity)); + sshOptionsRepository = await module.get(getRepositoryToken(SshOptionsEntity)); caCertRepository = await module.get(CaCertificateRepository); clientCertRepository = await module.get(ClientCertificateRepository); encryptionService = await module.get(EncryptionService); @@ -83,7 +108,15 @@ describe('LocalDatabaseRepository', () => { .calledWith(mockDatabasePasswordEncrypted, jasmine.anything()) .mockResolvedValue(mockDatabasePasswordPlain) .calledWith(mockDatabaseSentinelMasterPasswordEncrypted, jasmine.anything()) - .mockResolvedValue(mockDatabaseSentinelMasterPasswordPlain); + .mockResolvedValue(mockDatabaseSentinelMasterPasswordPlain) + .calledWith(mockSshOptionsUsernameEncrypted, jasmine.anything()) + .mockResolvedValue(mockSshOptionsUsernamePlain) + .calledWith(mockSshOptionsPasswordEncrypted, jasmine.anything()) + .mockResolvedValue(mockSshOptionsPasswordPlain) + .calledWith(mockSshOptionsPrivateKeyEncrypted, jasmine.anything()) + .mockResolvedValue(mockSshOptionsPrivateKeyPlain) + .calledWith(mockSshOptionsPassphraseEncrypted, jasmine.anything()) + .mockResolvedValue(mockSshOptionsPassphrasePlain); when(encryptionService.encrypt) .calledWith(mockDatabasePasswordPlain) .mockResolvedValue({ @@ -94,6 +127,26 @@ describe('LocalDatabaseRepository', () => { .mockResolvedValue({ data: mockDatabaseSentinelMasterPasswordEncrypted, encryption: mockDatabaseWithTlsAuthEntity.encryption, + }) + .calledWith(mockSshOptionsUsernamePlain) + .mockResolvedValue({ + data: mockSshOptionsUsernameEncrypted, + encryption: mockSshOptionsBasicEntity.encryption, + }) + .calledWith(mockSshOptionsPasswordPlain) + .mockResolvedValue({ + data: mockSshOptionsPasswordEncrypted, + encryption: mockSshOptionsBasicEntity.encryption, + }) + .calledWith(mockSshOptionsPrivateKeyPlain) + .mockResolvedValue({ + data: mockSshOptionsPrivateKeyEncrypted, + encryption: mockSshOptionsPrivateKeyEntity.encryption, + }) + .calledWith(mockSshOptionsPassphrasePlain) + .mockResolvedValue({ + data: mockSshOptionsPassphraseEncrypted, + encryption: mockSshOptionsPrivateKeyEntity.encryption, }); }); @@ -117,6 +170,24 @@ describe('LocalDatabaseRepository', () => { expect(clientCertRepository.get).not.toHaveBeenCalled(); }); + it('should return standalone database model with ssh enabled (basic)', async () => { + repository.findOneBy.mockResolvedValue(mockDatabaseWithSshBasicEntity); + const result = await service.get(mockDatabaseWithSshBasic.id); + + expect(result).toEqual(mockDatabaseWithSshBasic); + expect(caCertRepository.get).not.toHaveBeenCalled(); + expect(clientCertRepository.get).not.toHaveBeenCalled(); + }); + + it('should return standalone database model with ssh enabled (privateKey + passphrase)', async () => { + repository.findOneBy.mockResolvedValue(mockDatabaseWithSshPrivateKeyEntity); + const result = await service.get(mockDatabaseWithSshPrivateKey.id); + + expect(result).toEqual(mockDatabaseWithSshPrivateKey); + expect(caCertRepository.get).not.toHaveBeenCalled(); + expect(clientCertRepository.get).not.toHaveBeenCalled(); + }); + it('should return standalone model with ca tls', async () => { repository.findOneBy.mockResolvedValue(mockDatabaseWithTlsEntity); @@ -201,14 +272,51 @@ describe('LocalDatabaseRepository', () => { describe('update', () => { it('should update standalone database', async () => { - const result = await service.update(mockDatabaseId, mockDatabase); + repository.merge.mockReturnValue(mockDatabaseEntity); - expect(result).toEqual(mockDatabase); + const result = await service.update(mockDatabaseId, { + ...mockDatabase, + caCert: null, + clientCert: null, + sshOptions: null, + }); + + expect(result).toEqual({ + ...mockDatabase, + caCert: null, + clientCert: null, + sshOptions: null, + }); + expect(caCertRepository.create).not.toHaveBeenCalled(); + expect(clientCertRepository.create).not.toHaveBeenCalled(); + expect(sshOptionsRepository.createQueryBuilder).toHaveBeenCalled(); + }); + + it('should update standalone database with ssh enabled (basic)', async () => { + repository.findOneBy.mockResolvedValue(mockDatabaseWithSshBasicEntity); + repository.merge.mockReturnValue(mockDatabaseWithSshBasic); + + const result = await service.update(mockDatabaseId, mockDatabaseWithSshBasic); + + expect(result).toEqual(mockDatabaseWithSshBasic); + expect(caCertRepository.create).not.toHaveBeenCalled(); + expect(clientCertRepository.create).not.toHaveBeenCalled(); + }); + + it('should update standalone database with ssh enabled (privateKey)', async () => { + repository.findOneBy.mockResolvedValue(mockDatabaseWithSshPrivateKeyEntity); + repository.merge.mockReturnValue(mockDatabaseWithSshPrivateKey); + + const result = await service.update(mockDatabaseId, mockDatabaseWithSshPrivateKey); + + expect(result).toEqual(mockDatabaseWithSshPrivateKey); expect(caCertRepository.create).not.toHaveBeenCalled(); expect(clientCertRepository.create).not.toHaveBeenCalled(); }); it('should update standalone database (with existing certificates)', async () => { + repository.merge.mockReturnValue(mockDatabaseWithTlsAuth); + repository.findOneBy.mockResolvedValueOnce(mockDatabaseWithTlsAuthEntity); repository.findOneBy.mockResolvedValueOnce(mockDatabaseWithTlsAuthEntity); const result = await service.update(mockDatabaseId, mockDatabaseWithTlsAuth); @@ -219,6 +327,8 @@ describe('LocalDatabaseRepository', () => { }); it('should update standalone database (and certificates)', async () => { + repository.merge.mockReturnValue(mockDatabaseWithTlsAuth); + repository.findOneBy.mockResolvedValueOnce(mockDatabaseWithTlsAuthEntity); repository.findOneBy.mockResolvedValueOnce(mockDatabaseWithTlsAuthEntity); const result = await service.update( diff --git a/redisinsight/api/src/modules/database/repositories/stack.database.repository.spec.ts b/redisinsight/api/src/modules/database/repositories/stack.database.repository.spec.ts index 66ffaffce3..809cbbc12e 100644 --- a/redisinsight/api/src/modules/database/repositories/stack.database.repository.spec.ts +++ b/redisinsight/api/src/modules/database/repositories/stack.database.repository.spec.ts @@ -1,11 +1,11 @@ import { when } from 'jest-when'; -import { pick, omit } from 'lodash'; +import { pick } from 'lodash'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { mockCaCertificateRepository, - mockClientCertificateRepository, mockClusterDatabaseWithTlsAuth, mockClusterDatabaseWithTlsAuthEntity, + mockClientCertificateRepository, mockDatabase, mockDatabaseEntity, mockDatabaseId, @@ -13,22 +13,20 @@ import { mockDatabasePasswordPlain, mockDatabaseSentinelMasterPasswordEncrypted, mockDatabaseSentinelMasterPasswordPlain, - mockDatabaseWithTls, mockDatabaseWithTlsAuth, mockDatabaseWithTlsAuthEntity, - mockDatabaseWithTlsEntity, mockEncryptionService, - mockRepository, mockSentinelDatabaseWithTlsAuth, mockSentinelDatabaseWithTlsAuthEntity, + mockRepository, MockType, } from 'src/__mocks__'; import { EncryptionService } from 'src/modules/encryption/encryption.service'; -import { LocalDatabaseRepository } from 'src/modules/database/repositories/local.database.repository'; import { ConnectionType, DatabaseEntity } from 'src/modules/database/entities/database.entity'; import { CaCertificateRepository } from 'src/modules/certificate/repositories/ca-certificate.repository'; import { ClientCertificateRepository } from 'src/modules/certificate/repositories/client-certificate.repository'; -import { cloneClassInstance } from 'src/utils'; import { StackDatabasesRepository } from 'src/modules/database/repositories/stack.databases.repository'; import config from 'src/utils/config'; import { NotImplementedException } from '@nestjs/common'; +import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; + const REDIS_STACK_CONFIG = config.get('redisStack'); const listFields = [ @@ -53,6 +51,10 @@ describe('StackDatabasesRepository', () => { provide: getRepositoryToken(DatabaseEntity), useFactory: mockRepository, }, + { + provide: getRepositoryToken(SshOptionsEntity), + useFactory: mockRepository, + }, { provide: EncryptionService, useFactory: mockEncryptionService, @@ -135,7 +137,6 @@ describe('StackDatabasesRepository', () => { describe('exists', () => { it('should return true when receive database entity', async () => { - expect(await service.exists()).toEqual(true); expect(repository.createQueryBuilder().where).toHaveBeenCalledWith({ id: REDIS_STACK_CONFIG.id }); }); @@ -173,10 +174,11 @@ describe('StackDatabasesRepository', () => { describe('update', () => { it('should update standalone database', async () => { + repository.merge.mockReturnValue(mockDatabase); + const result = await service.update(mockDatabaseId, mockDatabase); expect(result).toEqual(mockDatabase); - expect(repository.update).toHaveBeenCalledWith(REDIS_STACK_CONFIG.id, jasmine.anything()); }); }); }); diff --git a/redisinsight/api/src/modules/profiler/models/redis.observer.spec.ts b/redisinsight/api/src/modules/profiler/models/redis.observer.spec.ts index b4c91c3d6c..108615eb67 100644 --- a/redisinsight/api/src/modules/profiler/models/redis.observer.spec.ts +++ b/redisinsight/api/src/modules/profiler/models/redis.observer.spec.ts @@ -197,7 +197,7 @@ describe('RedisObserver', () => { }); it('connect fail due to NOPERM', (done) => { - nodeClient.call.mockRejectedValueOnce(NO_PERM_ERROR); + nodeClient.monitor.mockRejectedValueOnce(NO_PERM_ERROR); redisObserver.init(getRedisClientFn); redisObserver.on('connect_error', (e) => { expect(redisObserver['shardsObservers']).toEqual([]); @@ -208,7 +208,7 @@ describe('RedisObserver', () => { }); it('connect fail due an error', (done) => { - nodeClient.call.mockRejectedValueOnce(new Error('some error')); + nodeClient.monitor.mockRejectedValueOnce(new Error('some error')); redisObserver.init(getRedisClientFn); redisObserver.on('connect_error', (e) => { expect(e).toBeInstanceOf(ServiceUnavailableException); diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts index 8fb9b53371..18cc849767 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.spec.ts @@ -5,12 +5,13 @@ import { mockDatabase, mockDatabaseWithTlsAuth, mockIORedisClient, mockIORedisCluster, mockIORedisSentinel, - mockSentinelDatabaseWithTlsAuth, + mockSentinelDatabaseWithTlsAuth, mockSshTunnelProvider, } from 'src/__mocks__'; import { RedisConnectionFactory } from 'src/modules/redis/redis-connection.factory'; import { Database } from 'src/modules/database/models/database'; import { EventEmitter } from 'events'; import apiConfig from 'src/utils/config'; +import { SshTunnelProvider } from 'src/modules/ssh/ssh-tunnel.provider'; const REDIS_CLIENTS_CONFIG = apiConfig.get('redis_clients'); @@ -39,6 +40,10 @@ describe('RedisConnectionFactory', () => { module = await Test.createTestingModule({ providers: [ RedisConnectionFactory, + { + provide: SshTunnelProvider, + useFactory: mockSshTunnelProvider, + }, ], }) .compile(); diff --git a/redisinsight/api/src/modules/ssh/models/ssh-options.ts b/redisinsight/api/src/modules/ssh/models/ssh-options.ts index 4b75b6331f..5c3c0e5798 100644 --- a/redisinsight/api/src/modules/ssh/models/ssh-options.ts +++ b/redisinsight/api/src/modules/ssh/models/ssh-options.ts @@ -9,6 +9,9 @@ import { import { Default } from 'src/common/decorators'; export class SshOptions { + @Expose() + id: string; + @ApiProperty({ description: 'The hostname of SSH server', type: String, diff --git a/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.spec.ts b/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.spec.ts new file mode 100644 index 0000000000..b889c6485b --- /dev/null +++ b/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.spec.ts @@ -0,0 +1,112 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockDatabaseWithSshBasic, +} from 'src/__mocks__'; +import { SshTunnelProvider } from 'src/modules/ssh/ssh-tunnel.provider'; +import { EventEmitter } from 'events'; +import { + UnableToCreateLocalServerException, + UnableToCreateSshConnectionException, + UnableToCreateTunnelException, +} from 'src/modules/ssh/exceptions'; +import * as net from 'net'; +import * as ssh2 from 'ssh2'; +import * as detectPort from 'detect-port'; +import Mock = jest.Mock; + +jest.mock('ssh2', () => ({ + ...jest.requireActual('ssh2') as object, +})); + +jest.mock('net', () => ({ + ...jest.requireActual('net') as object, +})); + +jest.mock('detect-port'); + +describe('SshTunnelProvider', () => { + let service: SshTunnelProvider; + let mockClient; + let mockServer; + let createServerSpy; + let sshClientSpy; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SshTunnelProvider, + ], + }).compile(); + + service = await module.get(SshTunnelProvider); + + mockClient = new EventEmitter(); + mockClient.connect = jest.fn(); + mockServer = new EventEmitter(); + mockServer.listen = jest.fn(() => mockServer.emit('listening')); + mockServer.address = jest.fn().mockReturnValue({ address: '127.0.0.1', port: 50000 }); + createServerSpy = jest.spyOn(net, 'createServer'); + createServerSpy.mockImplementationOnce(() => mockServer); + sshClientSpy = jest.spyOn(ssh2, 'Client'); + sshClientSpy.mockImplementationOnce(() => mockClient); + (detectPort as Mock).mockImplementationOnce(() => Promise.resolve(50000)); + }); + + describe('createTunnel', () => { + it('should create tunnel', (done) => { + service.createTunnel(mockDatabaseWithSshBasic) + .then((tnl) => { + expect(mockServer.listen).toHaveBeenCalledWith({ + host: '127.0.0.1', + port: 50000, + }); + + expect(tnl['client']).toEqual(mockClient); + expect(tnl['server']).toEqual(mockServer); + done(); + }) + .catch(done); + + process.nextTick(() => mockClient.emit('ready')); + process.nextTick(() => mockServer.emit('listening')); + }); + it('should fail due to server init error', (done) => { + (mockServer.listen as Mock).mockImplementationOnce(() => mockServer.emit('error', new Error('bb'))); + + service.createTunnel(mockDatabaseWithSshBasic) + .catch((e) => { + expect(e).toBeInstanceOf(UnableToCreateLocalServerException); + done(); + }); + + process.nextTick(() => mockClient.emit('ready')); + }); + it('should fail due to createServer failed', (done) => { + const mockError = new Error('some not processed error'); + createServerSpy.mockReset().mockImplementationOnce(() => { + throw mockError; + }); + + service.createTunnel(mockDatabaseWithSshBasic) + .catch((e) => { + expect(e).toBeInstanceOf(UnableToCreateTunnelException); + done(); + }); + + process.nextTick(() => mockClient.emit('ready')); + }); + it('should fail due to ssh client creation failed', (done) => { + const mockError = new Error('some not processed error'); + + service.createTunnel(mockDatabaseWithSshBasic) + .catch((e) => { + expect(e).toBeInstanceOf(UnableToCreateSshConnectionException); + done(); + }); + + process.nextTick(() => mockClient.emit('error', mockError)); + process.nextTick(() => mockServer.emit('listening')); + }); + }); +}); diff --git a/redisinsight/api/src/modules/ssh/transformers/ssh-options.transformer.spec.ts b/redisinsight/api/src/modules/ssh/transformers/ssh-options.transformer.spec.ts new file mode 100644 index 0000000000..18db629ab0 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/transformers/ssh-options.transformer.spec.ts @@ -0,0 +1,33 @@ +import { TypeHelpOptions } from 'class-transformer'; +import { CreateCertSshOptionsDto } from 'src/modules/ssh/dto/create.cert-ssh-options.dto'; +import { CreateBasicSshOptionsDto } from 'src/modules/ssh/dto/create.basic-ssh-options.dto'; +import { sshOptionsTransformer } from './ssh-options.transformer'; + +describe('caCertTransformer', () => { + [ + { + input: { object: { sshOptions: {} } } as unknown as TypeHelpOptions, + output: CreateBasicSshOptionsDto, + }, + { + input: { object: { sshOptions: { privateKey: 'asd' } } } as unknown as TypeHelpOptions, + output: CreateCertSshOptionsDto, + }, + { + input: { object: { sshOptions: { privateKey: null } } } as unknown as TypeHelpOptions, + output: CreateBasicSshOptionsDto, + }, + { + input: { object: null } as unknown as TypeHelpOptions, + output: CreateBasicSshOptionsDto, + }, + { + input: null, + output: CreateBasicSshOptionsDto, + }, + ].forEach((tc) => { + it(`Should return ${tc.output} when input is: ${tc.input}`, () => { + expect(sshOptionsTransformer(tc.input)).toEqual(tc.output); + }); + }); +}); From 19b8aaa055123ca0367cc107e577c117b9b9977e Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Fri, 13 Jan 2023 11:49:39 +0300 Subject: [PATCH 153/201] #RI-4022 - fix ssh username validation, fix maxlength --- .../components/AddInstanceForm/InstanceForm/InstanceForm.tsx | 3 +++ .../home/components/AddInstanceForm/InstanceForm/constants.ts | 3 ++- .../InstanceForm/form-components/SSHDetails.tsx | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx index e6b36916c5..e277117764 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx @@ -262,6 +262,9 @@ const AddStandaloneForm = (props: Props) => { if (!values.sshPort) { errs.sshPort = fieldDisplayNames.sshPort } + if (!values.sshUsername) { + errs.sshUsername = fieldDisplayNames.sshUsername + } if (values.sshPassType === SshPassType.PrivateKey && !values.sshPrivateKey) { errs.sshPrivateKey = fieldDisplayNames.sshPrivateKey } diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts index 7a5b7c612e..fc3c35dcf6 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/constants.ts @@ -21,5 +21,6 @@ export const fieldDisplayNames = { sentinelMasterName: 'Primary Group Name', sshHost: 'SSH Host', sshPort: 'SSH Port', - sshPrivateKey: 'SSH Private Key' + sshPrivateKey: 'SSH Private Key', + sshUsername: 'SSH Username', } diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx index f79feb8e8f..315223898f 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/form-components/SSHDetails.tsx @@ -121,7 +121,7 @@ const SSHDetails = (props: Props) => { className={flexGroupClassName} > - + { value={formik.values.sshPrivateKey ?? ''} onChange={formik.handleChange} fullWidth + maxLength={50_000} placeholder="Enter SSH Private Key" data-testid="sshPrivateKey" /> From 4096a79622090ad14aef609c18cb4d6a3ae872f6 Mon Sep 17 00:00:00 2001 From: Artem Date: Fri, 13 Jan 2023 11:07:09 +0200 Subject: [PATCH 154/201] #RI-3974 Fix existing ITests --- .../api/test/api/database/POST-databases.test.ts | 9 +++++++++ .../api/test/api/database/PUT-databases-id.test.ts | 2 +- redisinsight/api/test/api/database/constants.ts | 9 +++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/redisinsight/api/test/api/database/POST-databases.test.ts b/redisinsight/api/test/api/database/POST-databases.test.ts index c43600a3dc..589ef74f32 100644 --- a/redisinsight/api/test/api/database/POST-databases.test.ts +++ b/redisinsight/api/test/api/database/POST-databases.test.ts @@ -30,6 +30,15 @@ const dataSchema = Joi.object({ username: Joi.string(), password: Joi.string(), }).allow(null), + ssh: Joi.boolean().allow(null), + sshOptions: Joi.object({ + host: Joi.string().required(), + port: Joi.number().required(), + username: Joi.string().required(), + password: Joi.string().allow(null), + privateKey: Joi.string().allow(null), + passphrase: Joi.string().allow(null), + }).allow(null), }).messages({ 'any.required': '{#label} should not be empty', }).strict(true); 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 d53231eafd..adeb29d507 100644 --- a/redisinsight/api/test/api/database/PUT-databases-id.test.ts +++ b/redisinsight/api/test/api/database/PUT-databases-id.test.ts @@ -163,7 +163,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']), + ..._.omit(oldDatabase, ['modules', 'provider', 'lastConnection', 'new', 'ssh']), 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 ec4831d3f7..99889cd4e2 100644 --- a/redisinsight/api/test/api/database/constants.ts +++ b/redisinsight/api/test/api/database/constants.ts @@ -33,4 +33,13 @@ export const databaseSchema = Joi.object().keys({ version: Joi.number().integer(), semanticVersion: Joi.string(), }).allow(null), + ssh: Joi.boolean().allow(null), + sshOptions: Joi.object({ + host: Joi.string().required(), + port: Joi.number().required(), + username: Joi.string().required(), + password: Joi.string().allow(null), + privateKey: Joi.string().allow(null), + passphrase: Joi.string().allow(null), + }).allow(null), }); From 52f350bbaf2183bdb94bb2525a7ea95420472f8d Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Fri, 13 Jan 2023 12:12:19 +0300 Subject: [PATCH 155/201] #RI-4022 - fix tests --- .../InstanceForm/InstanceForm.spec.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx index 0e929fb76f..d9c38b394d 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx @@ -749,6 +749,15 @@ describe('InstanceForm', () => { ) }) + expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + + await act(() => { + fireEvent.change( + screen.getByTestId('sshUsername'), + { target: { value: 'username' } } + ) + }) + expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() }) @@ -783,6 +792,10 @@ describe('InstanceForm', () => { screen.getByTestId('sshHost'), { target: { value: 'localhost' } } ) + fireEvent.change( + screen.getByTestId('sshUsername'), + { target: { value: 'username' } } + ) }) expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() @@ -827,6 +840,11 @@ describe('InstanceForm', () => { { target: { value: '1771' } } ) + fireEvent.change( + screen.getByTestId('sshUsername'), + { target: { value: 'username' } } + ) + fireEvent.change( screen.getByTestId('sshPassword'), { target: { value: '123' } } @@ -841,6 +859,7 @@ describe('InstanceForm', () => { expect.objectContaining({ sshHost: 'localhost', sshPort: '1771', + sshUsername: 'username', sshPassword: '123', }) ) @@ -879,6 +898,11 @@ describe('InstanceForm', () => { { target: { value: '1771' } } ) + fireEvent.change( + screen.getByTestId('sshUsername'), + { target: { value: 'username' } } + ) + fireEvent.change( screen.getByTestId('sshPrivateKey'), { target: { value: '123444' } } @@ -898,6 +922,7 @@ describe('InstanceForm', () => { expect.objectContaining({ sshHost: 'localhost', sshPort: '1771', + sshUsername: 'username', sshPrivateKey: '123444', sshPassphrase: '123444', }) From 7b23aacbc2f25184576e5daa635041a8f83ed781 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Mon, 16 Jan 2023 14:07:33 +0300 Subject: [PATCH 156/201] #RI-3784 - add monaco editor for adding json key, update eslint --- configs/webpack.config.renderer.dev.babel.js | 2 +- configs/webpack.config.renderer.prod.babel.js | 2 +- configs/webpack.config.web.common.babel.js | 2 +- package.json | 3 +- redisinsight/__mocks__/monacoMock.js | 14 ++- redisinsight/ui/.eslintrc.js | 5 + .../src/components/monaco-json/MonacoJson.tsx | 96 +++++++++++++++++++ .../ui/src/components/monaco-json/index.ts | 3 + .../monaco-json/styles.modules.scss | 24 +++++ .../components/add-key/AddKey.spec.tsx | 60 ++++++++++-- .../browser/components/add-key/AddKey.tsx | 13 ++- .../AddKeyCommonFields/AddKeyCommonFields.tsx | 1 + .../add-key/AddKeyReJSON/AddKeyReJSON.tsx | 16 +--- redisinsight/ui/src/utils/modules.ts | 3 + .../ui/src/utils/tests/modules.spec.ts | 19 +++- yarn.lock | 25 ++--- 16 files changed, 240 insertions(+), 48 deletions(-) create mode 100644 redisinsight/ui/src/components/monaco-json/MonacoJson.tsx create mode 100644 redisinsight/ui/src/components/monaco-json/index.ts create mode 100644 redisinsight/ui/src/components/monaco-json/styles.modules.scss diff --git a/configs/webpack.config.renderer.dev.babel.js b/configs/webpack.config.renderer.dev.babel.js index d93533a44c..81464cc524 100644 --- a/configs/webpack.config.renderer.dev.babel.js +++ b/configs/webpack.config.renderer.dev.babel.js @@ -223,7 +223,7 @@ export default merge(baseConfig, { new ReactRefreshWebpackPlugin(), - new MonacoWebpackPlugin({ languages: [], features: ['!rename'] }), + new MonacoWebpackPlugin({ languages: ['json'], features: ['!rename'] }), ], node: { diff --git a/configs/webpack.config.renderer.prod.babel.js b/configs/webpack.config.renderer.prod.babel.js index f6275f00cb..bee5be9e0f 100644 --- a/configs/webpack.config.renderer.prod.babel.js +++ b/configs/webpack.config.renderer.prod.babel.js @@ -187,7 +187,7 @@ export default merge(baseConfig, { }, plugins: [ - new MonacoWebpackPlugin({ languages: [], features: ['!rename'] }), + new MonacoWebpackPlugin({ languages: ['json'], features: ['!rename'] }), new webpack.EnvironmentPlugin({ NODE_ENV: 'production', diff --git a/configs/webpack.config.web.common.babel.js b/configs/webpack.config.web.common.babel.js index f24b18b74b..973b9ea30c 100644 --- a/configs/webpack.config.web.common.babel.js +++ b/configs/webpack.config.web.common.babel.js @@ -71,7 +71,7 @@ export default { plugins: [ new HtmlWebpackPlugin({ template: 'index.html.ejs' }), - new MonacoWebpackPlugin({ languages: [], features: ['!rename'] }), + new MonacoWebpackPlugin({ languages: ['json'], features: ['!rename'] }), new webpack.IgnorePlugin({ checkResource(resource) { diff --git a/package.json b/package.json index e7cc1441df..5817976db2 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,6 @@ "@types/node": "14.14.10", "@types/react": "^18.0.20", "@types/react-dom": "^18.0.5", - "@types/react-monaco-editor": "^0.16.0", "@types/react-redux": "^7.1.12", "@types/react-router-dom": "^5.1.6", "@types/react-virtualized": "^9.21.10", @@ -243,7 +242,7 @@ "react-hotkeys-hook": "^3.3.1", "react-json-pretty": "^2.2.0", "react-jsx-parser": "^1.28.4", - "react-monaco-editor": "^0.44.0", + "react-monaco-editor": "^0.45.0", "react-redux": "^7.2.2", "react-rnd": "^10.3.5", "react-router-dom": "^5.2.0", diff --git a/redisinsight/__mocks__/monacoMock.js b/redisinsight/__mocks__/monacoMock.js index a3922e5b95..e04f14c09b 100644 --- a/redisinsight/__mocks__/monacoMock.js +++ b/redisinsight/__mocks__/monacoMock.js @@ -15,7 +15,8 @@ export default function MonacoEditor(props) { createContextKey: jest.fn(), focus: jest.fn(), onDidChangeCursorPosition: jest.fn(), - executeEdits: jest.fn() + executeEdits: jest.fn(), + updateOptions: jest.fn() }, // monaco { @@ -31,12 +32,21 @@ export default function MonacoEditor(props) { }), setLanguageConfiguration: jest.fn(), setMonarchTokensProvider: jest.fn(), + json: { + jsonDefaults:{ + setDiagnosticsOptions: jest.fn() + } + } }, KeyMod: {}, KeyCode: {} }) }, []) - return ; + return