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 26caa5f803..9e66d5658c 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'; import { databaseNew1670252337342 } from './1670252337342-database-new'; import { sshOptions1673035852335 } from './1673035852335-ssh-options'; import { workbenchAndAnalysisDbIndex1673934231410 } from './1673934231410-workbench_and_analysis_db'; @@ -49,6 +50,7 @@ export default [ databaseAnalysisExpirationGroups1664886479051, workbenchExecutionTime1667368983699, database1667477693934, + databaseAnalysisRecommendations1668420950002, databaseNew1670252337342, sshOptions1673035852335, workbenchAndAnalysisDbIndex1673934231410, diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 73d6ba7e56..1489cfe9d8 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", "detect-port": "^1.5.1", "dotenv": "^16.0.0", "express": "^4.17.1", @@ -63,6 +64,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", @@ -105,7 +107,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/__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/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..2fef5729c3 --- /dev/null +++ b/redisinsight/api/src/constants/recommendations.ts @@ -0,0 +1,29 @@ +export const RECOMMENDATION_NAMES = Object.freeze({ + LUA_SCRIPT: 'luaScript', + BIG_HASHES: 'bigHashes', + BIG_STRINGS: 'bigStrings', + BIG_SETS: 'bigSets', + BIG_AMOUNT_OF_CONNECTED_CLIENTS: 'bigAmountOfConnectedClients', + USE_SMALLER_KEYS: 'useSmallerKeys', + AVOID_LOGICAL_DATABASES: 'avoidLogicalDatabases', + COMBINE_SMALL_STRINGS_TO_HASHES: 'combineSmallStringsToHashes', + INCREASE_SET_MAX_INTSET_ENTRIES: 'increaseSetMaxIntsetEntries', + HASH_HASHTABLE_TO_ZIPLIST: 'hashHashtableToZiplist', + COMPRESS_HASH_FIELD_NAMES: 'compressHashFieldNames', + COMPRESSION_FOR_LIST: 'compressionForList', + ZSET_HASHTABLE_TO_ZIPLIST: 'zSetHashtableToZiplist', + SET_PASSWORD: 'setPassword', + RTS: 'RTS', + REDIS_VERSION: 'redisVersion', + REDIS_SEARCH: 'redisSearch', + 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/constants/regex.ts b/redisinsight/api/src/constants/regex.ts index ef68cca6cc..08207685e3 100644 --- a/redisinsight/api/src/constants/regex.ts +++ b/redisinsight/api/src/constants/regex.ts @@ -1,5 +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}|\d{13}|\d{16}|\d{19})$/; diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.controller.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.controller.ts index fa9e8d675d..fdc79fe1ce 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.controller.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.controller.ts @@ -1,6 +1,6 @@ import { Body, - Controller, Get, Param, Post, UseInterceptors, UsePipes, ValidationPipe, + Controller, Get, Param, Post, Patch, UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; import { ApiTags } from '@nestjs/swagger'; @@ -8,7 +8,7 @@ import { DatabaseAnalysisService } from 'src/modules/database-analysis/database- import { DatabaseAnalysis, ShortDatabaseAnalysis } from 'src/modules/database-analysis/models'; import { BrowserSerializeInterceptor } from 'src/common/interceptors'; import { ApiQueryRedisStringEncoding, ClientMetadataParam } from 'src/common/decorators'; -import { CreateDatabaseAnalysisDto } from 'src/modules/database-analysis/dto'; +import { CreateDatabaseAnalysisDto, RecommendationVoteDto } from 'src/modules/database-analysis/dto'; import { ClientMetadata } from 'src/common/models'; @UseInterceptors(BrowserSerializeInterceptor) @@ -72,4 +72,30 @@ export class DatabaseAnalysisController { ): Promise { return this.service.list(databaseId); } + + @Patch(':id') + @ApiEndpoint({ + description: 'Update database instance by id', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Updated database instance\' response', + type: DatabaseAnalysis, + }, + ], + }) + @UsePipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }), + ) + async modify( + @Param('id') id: string, + @Body() dto: RecommendationVoteDto, + ): Promise { + return await this.service.vote(id, dto); + } } 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..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,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 { RecommendationModule } from 'src/modules/recommendation/recommendation.module'; @Module({ + 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 9d828341f4..932d18a177 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -1,10 +1,13 @@ 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'; import { DatabaseAnalysisProvider } from 'src/modules/database-analysis/providers/database-analysis.provider'; -import { CreateDatabaseAnalysisDto } from 'src/modules/database-analysis/dto'; +import { CreateDatabaseAnalysisDto, RecommendationVoteDto } from 'src/modules/database-analysis/dto'; import { KeysScanner } from 'src/modules/database-analysis/scanner/keys-scanner'; import { DatabaseConnectionService } from 'src/modules/database/database-connection.service'; import { ClientMetadata } from 'src/common/models'; @@ -15,6 +18,7 @@ export class DatabaseAnalysisService { constructor( private readonly databaseConnectionService: DatabaseConnectionService, + private readonly recommendationService: RecommendationService, private readonly analyzer: DatabaseAnalyzer, private readonly databaseAnalysisProvider: DatabaseAnalysisProvider, private readonly scanner: KeysScanner, @@ -50,11 +54,34 @@ export class DatabaseAnalysisService { progress.total += nodeResult.progress.total; }); + let recommendationToExclude = []; + + const recommendations = await scanResults.reduce(async (previousPromise, nodeResult, idx) => { + const jobsArray = await previousPromise; + const nodeRecommendations = await this.recommendationService.getRecommendations({ + client: nodeResult.client, + keys: nodeResult.keys, + total: progress.total, + globalClient: client, + exclude: recommendationToExclude, + }); + 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); + recommendationToExclude.push(...foundedRecommendationNames); + jobsArray.push(foundedRecommendations); + return flatten(jobsArray); + }, Promise.resolve([])); + const analysis = plainToClass(DatabaseAnalysis, await this.analyzer.analyze({ databaseId: clientMetadata.databaseId, db: client?.options?.db || 0, ...dto, progress, + recommendations, }, [].concat(...scanResults.map((nodeResult) => nodeResult.keys)))); client.disconnect(); @@ -86,4 +113,13 @@ export class DatabaseAnalysisService { async list(databaseId: string): Promise { return this.databaseAnalysisProvider.list(databaseId); } + + /** + * Set user vote for recommendation + * @param id + * @param recommendation + */ + async vote(id: string, recommendation: RecommendationVoteDto): Promise { + return this.databaseAnalysisProvider.recommendationVote(id, recommendation); + } } diff --git a/redisinsight/api/src/modules/database-analysis/dto/index.ts b/redisinsight/api/src/modules/database-analysis/dto/index.ts index b7e8392483..70aa99912a 100644 --- a/redisinsight/api/src/modules/database-analysis/dto/index.ts +++ b/redisinsight/api/src/modules/database-analysis/dto/index.ts @@ -1 +1,2 @@ export * from './create-database-analysis.dto'; +export * from './recommendation-vote.dto'; diff --git a/redisinsight/api/src/modules/database-analysis/dto/recommendation-vote.dto.ts b/redisinsight/api/src/modules/database-analysis/dto/recommendation-vote.dto.ts new file mode 100644 index 0000000000..11f2bce39e --- /dev/null +++ b/redisinsight/api/src/modules/database-analysis/dto/recommendation-vote.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class RecommendationVoteDto { + @ApiProperty({ + description: 'Recommendation name', + type: String, + }) + @IsString() + name: string; + + @ApiProperty({ + description: 'User vote', + type: String, + }) + @IsString() + vote: string; +} 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 13794a372d..ad1cacc744 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,6 +119,11 @@ export class DatabaseAnalysisEntity { @Column({ nullable: true }) encryption: string; + @Column({ nullable: true, type: 'blob' }) + @DataAsJsonString() + @Expose() + recommendations: string; + @Column({ nullable: true }) @Expose() @IsInt() 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 4c63c66435..31bba1581d 100644 --- a/redisinsight/api/src/modules/database-analysis/models/database-analysis.ts +++ b/redisinsight/api/src/modules/database-analysis/models/database-analysis.ts @@ -7,6 +7,7 @@ import { ApiProperty, ApiPropertyOptional } 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({ @@ -116,6 +117,14 @@ export class DatabaseAnalysis { @Type(() => SumGroup) expirationGroups: SumGroup[]; + @ApiProperty({ + description: 'Recommendations', + isArray: true, + type: () => Recommendation, + }) + @Expose() + @Type(() => Recommendation) + recommendations: Recommendation[]; @ApiPropertyOptional({ description: 'Logical database number.', type: Number, 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..e98007998f --- /dev/null +++ b/redisinsight/api/src/modules/database-analysis/models/recommendation.ts @@ -0,0 +1,26 @@ +import { Expose } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class Recommendation { + @ApiProperty({ + description: 'Recommendation name', + type: String, + example: 'luaScript', + }) + @Expose() + name: string; + + @ApiPropertyOptional({ + description: 'Additional recommendation params', + example: 'luaScript', + }) + @Expose() + params?: any; + + @ApiPropertyOptional({ + description: 'User vote', + example: 'useful', + }) + @Expose() + vote?: string; +} 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..4cfe3bd067 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 @@ -13,7 +13,7 @@ import { Repository } from 'typeorm'; import { getRepositoryToken } from '@nestjs/typeorm'; import { DatabaseAnalysisProvider } from 'src/modules/database-analysis/providers/database-analysis.provider'; import { DatabaseAnalysis } from 'src/modules/database-analysis/models'; -import { CreateDatabaseAnalysisDto } from 'src/modules/database-analysis/dto'; +import { CreateDatabaseAnalysisDto, RecommendationVoteDto } from 'src/modules/database-analysis/dto'; import { RedisDataType } from 'src/modules/browser/dto'; import { plainToClass } from 'class-transformer'; import { ScanFilter } from 'src/modules/database-analysis/models/scan-filter'; @@ -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,8 +147,19 @@ const mockDatabaseAnalysis = { total: 0, }, ], + recommendations: [{ name: 'luaScript' }], } as DatabaseAnalysis; +const mockDatabaseAnalysisWithVote = { + ...mockDatabaseAnalysis, + recommendations: [{ name: 'luaScript', vote: 'useful' }], +} as DatabaseAnalysis; + +const mockRecommendationVoteDto: RecommendationVoteDto = { + name: 'luaScript', + vote: 'useful', +}; + describe('DatabaseAnalysisProvider', () => { let service: DatabaseAnalysisProvider; let repository: MockType>; @@ -175,7 +187,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])) @@ -252,4 +264,27 @@ describe('DatabaseAnalysisProvider', () => { ); }); }); + + describe('recommendationVote', () => { + it('should return updated database analysis', async () => { + repository.findOneBy.mockReturnValueOnce(mockDatabaseAnalysisEntity); + repository.update.mockReturnValueOnce(true); + await encryptionService.encrypt.mockReturnValue(mockEncryptResult); + + expect(await service.recommendationVote(mockDatabaseAnalysis.id, mockRecommendationVoteDto)) + .toEqual(mockDatabaseAnalysisWithVote); + }); + + it('should throw an error', async () => { + repository.findOneBy.mockReturnValueOnce(null); + + try { + await service.recommendationVote(mockDatabaseAnalysis.id, mockRecommendationVoteDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.DATABASE_ANALYSIS_NOT_FOUND); + } + }); + }); }); 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 89ef9fd082..de816747f6 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 @@ -6,6 +6,7 @@ import { Repository } from 'typeorm'; import { EncryptionService } from 'src/modules/encryption/encryption.service'; import { plainToClass } from 'class-transformer'; import { DatabaseAnalysis, ShortDatabaseAnalysis } from 'src/modules/database-analysis/models'; +import { RecommendationVoteDto } from 'src/modules/database-analysis/dto'; import { classToClass } from 'src/utils'; import config from 'src/utils/config'; import ERROR_MESSAGES from 'src/constants/error-messages'; @@ -26,6 +27,7 @@ export class DatabaseAnalysisProvider { 'filter', 'progress', 'expirationGroups', + 'recommendations', ]; constructor( @@ -40,7 +42,9 @@ 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 { @@ -67,6 +71,31 @@ export class DatabaseAnalysisProvider { return classToClass(DatabaseAnalysis, await this.decryptEntity(entity, true)); } + /** + * Fetches entity, decrypt, update and return updated DatabaseAnalysis model + * @param id + * @param dto + */ + async recommendationVote(id: string, dto: RecommendationVoteDto): Promise { + this.logger.log('Updating database analysis with recommendation vote'); + const { name, vote } = dto; + const oldDatabaseAnalysis = await this.repository.findOneBy({ id }); + + if (!oldDatabaseAnalysis) { + this.logger.error(`Database analysis with id:${id} was not Found`); + throw new NotFoundException(ERROR_MESSAGES.DATABASE_ANALYSIS_NOT_FOUND); + } + + const entity = classToClass(DatabaseAnalysis, await this.decryptEntity(oldDatabaseAnalysis, true)); + + entity.recommendations = entity.recommendations.map((recommendation) => ( + recommendation.name === name ? { ...recommendation, vote } : recommendation)); + + await this.repository.update(id, await this.encryptEntity(plainToClass(DatabaseAnalysisEntity, entity))); + + return entity; + } + /** * Return list of database analysis with several fields only * @param databaseId 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 7ef2bb2a70..9e5054e22d 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 @@ -43,6 +43,7 @@ const mockScanResult = { scanned: 15, total: 1, }, + client: Object.assign(nodeClient), }; describe('KeysScanner', () => { 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 830176e248..eca598acad 100644 --- a/redisinsight/api/src/modules/database-analysis/scanner/keys-scanner.ts +++ b/redisinsight/api/src/modules/database-analysis/scanner/keys-scanner.ts @@ -72,6 +72,7 @@ export class KeysScanner { scanned: opts.filter.count, processed: nodeKeys.length, }, + client, }; } } 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..bd9dca469d --- /dev/null +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts @@ -0,0 +1,668 @@ +import IORedis from 'ioredis'; +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'; + +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 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 { + 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: RECOMMENDATION_NAMES.LUA_SCRIPT }); + }); + + 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: RECOMMENDATION_NAMES.BIG_HASHES }); + }); + }); + + 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: RECOMMENDATION_NAMES.USE_SMALLER_KEYS }); + }); + }); + + 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: RECOMMENDATION_NAMES.COMBINE_SMALL_STRINGS_TO_HASHES }); + }); + }); + + 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: RECOMMENDATION_NAMES.INCREASE_SET_MAX_INTSET_ENTRIES }); + }); + + 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('determineHashHashtableToZiplistRecommendation', () => { + it('should not return hashHashtableToZiplist recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'config' })) + .mockResolvedValue(mockRedisConfigResponse); + + const convertHashtableToZiplistRecommendation = await service + .determineHashHashtableToZiplistRecommendation(nodeClient, mockKeys); + expect(convertHashtableToZiplistRecommendation).toEqual(null); + }); + + it('should return hashHashtableToZiplist recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'config' })) + .mockResolvedValue(mockRedisConfigResponse); + + const convertHashtableToZiplistRecommendation = await service + .determineHashHashtableToZiplistRecommendation(nodeClient, [...mockKeys, mockBigHashKey_3]); + expect(convertHashtableToZiplistRecommendation) + .toEqual({ name: RECOMMENDATION_NAMES.HASH_HASHTABLE_TO_ZIPLIST }); + }); + + 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 + .determineHashHashtableToZiplistRecommendation(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: 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 }); + }); + }); + + 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 }); + }); + }); + + 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 }); + }); + }); + + 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_AMOUNT_OF_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); + }); + }); + + 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 }); + }); + }); + + describe('determineRTSRecommendation', () => { + test.each(generateRTSRecommendationTests)('%j', async ({ input, expected }) => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'zscan' })) + .mockResolvedValue(input); + + const RTSRecommendation = await service + .determineRTSRecommendation(nodeClient, mockKeys); + expect(RTSRecommendation).toEqual(expected); + }); + + 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); + }); + }); + + describe('determineRediSearchRecommendation', () => { + it('should return rediSearch recommendation when there is JSON key', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) + .mockResolvedValue(mockFTListResponse_1); + + const redisSearchRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); + expect(redisSearchRecommendation).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: 'FT._LIST' })) + .mockResolvedValue(mockFTListResponse_1); + + const redisSearchRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockRediSearchStringKey_1]); + expect(redisSearchRecommendation).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: 'FT._LIST' })) + .mockResolvedValue(mockFTListResponse_1); + + const redisSearchRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockRediSearchStringKey_2]); + expect(redisSearchRecommendation).toEqual(null); + }); + + it('should not return rediSearch recommendation when there are no indexes', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) + .mockResolvedValue(mockFTListResponse_2); + + const redisSearchRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); + expect(redisSearchRecommendation).toEqual(null); + }); + + it('should ignore errors when ft command execute with error', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT._LIST' })) + .mockRejectedValue("some error"); + + const redisSearchRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockJSONKey]); + expect(redisSearchRecommendation).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' })) + .mockRejectedValue("some error"); + + const redisSearchRecommendation = await service + .determineRediSearchRecommendation(nodeClient, [mockRediSearchStringKey_2]); + expect(redisSearchRecommendation).toEqual(null); + }); + }); + + describe('determineRedisVersionRecommendation', () => { + it('should not return redis version recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockResolvedValue(mockRedisServerResponse_1); + + const redisVersionRecommendation = await service + .determineRedisVersionRecommendation(nodeClient); + expect(redisVersionRecommendation).toEqual(null); + }); + + it('should return redis version recommendation', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'info' })) + .mockResolvedValueOnce(mockRedisServerResponse_2); + + const redisVersionRecommendation = await service + .determineRedisVersionRecommendation(nodeClient); + expect(redisVersionRecommendation).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 redisVersionRecommendation = await service + .determineRedisVersionRecommendation(nodeClient); + 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 new file mode 100644 index 0000000000..115eb70897 --- /dev/null +++ b/redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts @@ -0,0 +1,582 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Redis, Cluster, Command } from 'ioredis'; +import { get, isNull, isNumber } from 'lodash'; +import { isValid } from 'date-fns'; +import * as semverCompare from 'node-version-compare'; +import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/utils'; +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'; +import { Key } from 'src/modules/database-analysis/models'; + +const minNumberOfCachedScripts = 10; +const maxHashLength = 5000; +const maxStringMemory = 200; +const maxDatabaseTotal = 1_000_000; +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'; + +const redisInsightCommands = ['info', 'monitor', 'slowlog', 'acl', 'config', 'module']; + +@Injectable() +export class RecommendationProvider { + private logger = new Logger('RecommendationProvider'); + + /** + * Check lua script recommendation + * @param redisClient + */ + async determineLuaScriptRecommendation( + redisClient: Redis, + ): Promise { + try { + 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(nodesNumbersOfCachedScripts, 10) > minNumberOfCachedScripts + ? { name: RECOMMENDATION_NAMES.LUA_SCRIPT } + : null; + } catch (err) { + this.logger.error('Can not determine Lua script recommendation', err); + return null; + } + } + + /** + * Check big hashes recommendation + * @param keys + */ + async determineBigHashesRecommendation( + keys: Key[], + ): Promise { + 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 use smaller keys recommendation + * @param total + */ + async determineBigTotalRecommendation( + total: number, + ): Promise { + return total > maxDatabaseTotal ? { name: RECOMMENDATION_NAMES.USE_SMALLER_KEYS } : null; + } + + /** + * Check logical databases recommendation + * @param redisClient + */ + async determineLogicalDatabasesRecommendation( + redisClient: Redis | Cluster, + ): Promise { + if (redisClient.isCluster) { + return 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: RECOMMENDATION_NAMES.AVOID_LOGICAL_DATABASES } : null; + } catch (err) { + this.logger.error('Can not determine Logical database recommendation', err); + return null; + } + } + + /** + * Check combine small strings to hashes recommendation + * @param keys + */ + async determineCombineSmallStringsToHashesRecommendation( + keys: Key[], + ): Promise { + 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; + } + } + + /** + * Check increase set max intset entries recommendation + * @param keys + * @param redisClient + */ + async determineIncreaseSetMaxIntsetEntriesRecommendation( + redisClient: Redis | Cluster, + keys: Key[], + ): Promise { + try { + const [, setMaxIntsetEntries] = await redisClient.sendCommand( + new Command('config', ['get', 'set-max-intset-entries'], { + replyEncoding: 'utf8', + }), + ) as string[]; + + if (!setMaxIntsetEntries) { + return null; + } + const setMaxIntsetEntriesNumber = parseInt(setMaxIntsetEntries, 10); + const bigSet = keys.some((key) => key.type === RedisDataType.Set && key.length > setMaxIntsetEntriesNumber); + 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; + } + } + /** + * Check convert hashtable to ziplist recommendation + * @param keys + * @param redisClient + */ + + async determineHashHashtableToZiplistRecommendation( + 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: RECOMMENDATION_NAMES.HASH_HASHTABLE_TO_ZIPLIST } : null; + } catch (err) { + this.logger.error('Can not determine Convert hashtable to ziplist recommendation', err); + return null; + } + } + + /** + * Check compress hash field names recommendation + * @param keys + */ + async determineCompressHashFieldNamesRecommendation( + keys: Key[], + ): Promise { + 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 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; + } + } + + /** + * 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; + } + } + + /** + * 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_AMOUNT_OF_CONNECTED_CLIENTS } : null; + } catch (err) { + this.logger.error('Can not determine Connection clients recommendation', err); + return null; + } + } + + /** + * Check RTS 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[]; + if (this.checkTimestamp(membersArray[0]) || this.checkTimestamp(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 search recommendation + * @param redisClient + * @param keys + */ + + async determineRediSearchRecommendation( + redisClient: Redis | Cluster, + keys: Key[], + ): Promise { + try { + 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) => ( + 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 redis search 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; + } + } + + /** + * 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 search indexes recommendation + * @param redisClient + * @param keys + * @param client + */ + // eslint-disable-next-line + async determineSearchIndexesRecommendation( + redisClient: Redis, + keys: Key[], + client: any, + ): Promise { + 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; + } + } + + return isJSONOrHash ? { name: RECOMMENDATION_NAMES.SEARCH_INDEXES } : null; + } + 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; + } + } + + /* + * Check dangerous commands recommendation + * @param redisClient + */ + + async determineDangerousCommandsRecommendation( + redisClient: Redis | Cluster, + ): Promise { + try { + const dangerousCommands = await redisClient.sendCommand( + new Command('ACL', ['CAT', 'dangerous'], { replyEncoding: 'utf8' }), + ) as string[]; + + const filteredDangerousCommands = dangerousCommands.filter((command) => { + const commandName = command.split('|')[0]; + return !redisInsightCommands.includes(commandName); + }); + + 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) { + this.logger.error('Can not determine dangerous commands recommendation', err); + return null; + } + } + + private 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; + } + + 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))) { + 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/src/modules/recommendation/recommendation.module.ts b/redisinsight/api/src/modules/recommendation/recommendation.module.ts new file mode 100644 index 0000000000..9eb6c38863 --- /dev/null +++ b/redisinsight/api/src/modules/recommendation/recommendation.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { RecommendationService } from './recommendation.service'; +import { RecommendationProvider } from './providers/recommendation.provider'; + +@Module({ + 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..7562b5bb38 --- /dev/null +++ b/redisinsight/api/src/modules/recommendation/recommendation.service.ts @@ -0,0 +1,126 @@ +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'; +import { RedisString } from 'src/common/constants'; +import { Key } from 'src/modules/database-analysis/models'; + +interface RecommendationInput { + client?: Redis, + keys?: Key[], + info?: RedisString, + total?: number, + globalClient?: Redis | Cluster, + exclude?: string[], +} + +@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, + total, + globalClient, + 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(recommendationsToDetermine.map((recommendation) => recommendations.get(recommendation)()))); + } +} 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 7f4c5c7602..125d8d42d2 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, @@ -39,6 +39,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_LUA_DATABASE_ANALYSIS_RECOMMENDATION], }); } }, diff --git a/redisinsight/api/test/api/database-analysis/PATCH-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/PATCH-databases-id-analysis.test.ts new file mode 100644 index 0000000000..2da67f5ff3 --- /dev/null +++ b/redisinsight/api/test/api/database-analysis/PATCH-databases-id-analysis.test.ts @@ -0,0 +1,69 @@ +import { + expect, + describe, + deps, + before, + getMainCheckFn, + Joi, + generateInvalidDataTestCases, + validateInvalidDataTestCase, +} from '../deps'; +import { analysisSchema } from './constants'; +const { localDb, request, server, constants, rte } = deps; + +const endpoint = ( + instanceId = constants.TEST_INSTANCE_ID, + id = constants.TEST_DATABASE_ANALYSIS_ID_1, +) => + request(server).patch(`/${constants.API.DATABASES}/${instanceId}/analysis/${id}`); + + // input data schema +const dataSchema = Joi.object({ + name: Joi.string(), + vote: Joi.string(), +}).strict(); + +const validInputData = { + name: constants.getRandomString(), + vote: constants.getRandomString(), +}; + +const responseSchema = analysisSchema; +const mainCheckFn = getMainCheckFn(endpoint); +let repository; + +describe('PATCH /databases/:instanceId/analysis/:id', () => { + before(async () => await localDb.generateNDatabaseAnalysis({ + databaseId: constants.TEST_INSTANCE_ID, + id: constants.TEST_DATABASE_ANALYSIS_ID_1, + createdAt: constants.TEST_DATABASE_ANALYSIS_CREATED_AT_1, + }, 1, true), + ); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('recommendations', () => { + describe('recommendation vote', () => { + [ + { + name: 'Should add vote for RTS recommendation', + data: { + name: 'luaScript', + vote: 'useful', + }, + statusCode: 200, + responseSchema, + checkFn: async ({ body }) => { + expect(body.recommendations).to.include.deep.members([ + constants.TEST_LUA_SCRIPT_VOTE_RECOMMENDATION + ]); + }, + }, + ].map(mainCheckFn); + }); + }); +}); 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 4edf9b4f0a..e73e92b48e 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); }); [ @@ -136,7 +136,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); @@ -151,4 +151,365 @@ describe('POST /databases/:instanceId/analysis', () => { } }, ].map(mainCheckFn); + + describe('recommendations', () => { + requirements('!rte.bigData'); + + 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.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 setPassword recommendation', + data: { + delimiter: '-', + }, + statusCode: 201, + responseSchema, + checkFn: async ({ body }) => { + expect(body.recommendations).to.include.deep.members([ + constants.TEST_SET_PASSWORD_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + ].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); + }); + + 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_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 () => { + expect(await repository.count()).to.eq(5); + } + }, + ].map(mainCheckFn); + }); + + [ + { + 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.include.deep.members([ + constants.TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION, + constants.TEST_HASH_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.include.deep.members([ + 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.include.deep.members([ + constants.TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION, + ]); + }, + after: async () => { + expect(await repository.count()).to.eq(5); + } + }, + { + name: 'Should create new database analysis with hashHashtableToZiplist 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.include.deep.members([ + constants.TEST_HASH_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.recommendations).to.include.deep.members([ + constants.TEST_HASH_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.recommendations).to.include.deep.members([ + constants.TEST_COMPRESSION_FOR_LIST_RECOMMENDATION, + ]); + }, + after: async () => { + 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.include.deep.members([ + constants.TEST_BIG_STRINGS_RECOMMENDATION, + ]); + }, + after: async () => { + 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.include.deep.members([ + 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.generateHugeNumberOfMembersForSetKey(NUMBERS_OF_SET_MEMBERS, true); + }, + checkFn: async ({ body }) => { + 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, + ]); + }, + 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.include.deep.members([ + constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION, + ]); + }, + after: async () => { + await rte.data.sendCommand('script', ['flush']); + 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/api/database-analysis/constants.ts b/redisinsight/api/test/api/database-analysis/constants.ts index a529cef10a..5e39642c7f 100644 --- a/redisinsight/api/test/api/database-analysis/constants.ts +++ b/redisinsight/api/test/api/database-analysis/constants.ts @@ -1,5 +1,11 @@ import { Joi } from '../../helpers/test'; +export const typedRecommendationSchema = Joi.object({ + name: Joi.string().required(), + vote: Joi.string(), + params: Joi.any(), +}); + export const typedTotalSchema = Joi.object({ total: Joi.number().integer().required(), types: Joi.array().items(Joi.object({ @@ -56,4 +62,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 c0eac54f1e..348e4fe46d 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid'; import * as path from 'path'; import { randomBytes } from 'crypto'; import { getASCIISafeStringFromBuffer, getBufferFromSafeASCIIString } from "src/utils/cli-helper"; +import { RECOMMENDATION_NAMES } from 'src/constants'; const API = { DATABASES: 'databases', @@ -201,6 +202,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) }, @@ -468,6 +472,61 @@ export const constants = { threshold: 4 * 60 * 60 * 1000, }, + TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.LUA_SCRIPT, + }, + TEST_BIG_HASHES_DATABASE_ANALYSIS_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.BIG_HASHES, + }, + TEST_SMALLER_KEYS_DATABASE_ANALYSIS_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.USE_SMALLER_KEYS, + }, + TEST_INCREASE_SET_MAX_INTSET_ENTRIES_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.INCREASE_SET_MAX_INTSET_ENTRIES, + }, + TEST_COMBINE_SMALL_STRING_TO_HASHES_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.COMBINE_SMALL_STRINGS_TO_HASHES, + }, + 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, + }, + TEST_COMPRESSION_FOR_LIST_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.COMPRESSION_FOR_LIST, + }, + TEST_BIG_STRINGS_RECOMMENDATION: { + 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, + }, + + TEST_SET_PASSWORD_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.SET_PASSWORD, + }, + + TEST_RTS_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.RTS, + }, + + TEST_REDIS_VERSION_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.REDIS_VERSION, + }, + + TEST_REDISEARCH_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.REDIS_SEARCH, + }, + + TEST_LUA_SCRIPT_VOTE_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.LUA_SCRIPT, + vote: 'useful', + }, // etc... } diff --git a/redisinsight/api/test/helpers/data/redis.ts b/redisinsight/api/test/helpers/data/redis.ts index d0a9c315fa..829993e3ce 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) { @@ -267,6 +286,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) { @@ -386,6 +423,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(); @@ -456,6 +512,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); + }; + const setRedisearchConfig = async ( rule: string, value: string, @@ -475,6 +544,8 @@ export const initDataHelper = (rte) => { generateKeys, generateHugeNumberOfFieldsForHashKey, generateHugeNumberOfTinyStringKeys, + generateHugeElementsForListKey, + generateHugeMembersForSortedListKey, generateHugeStream, generateNKeys, generateRedisearchIndexes, @@ -485,6 +556,8 @@ export const initDataHelper = (rte) => { generateStreamsWithoutStrictMode, generateNStreams, generateNGraphs, + generateNCachedScripts, + generateHugeNumberOfMembersForSetKey, getClientNodes, setRedisearchConfig, } diff --git a/redisinsight/api/test/helpers/local-db.ts b/redisinsight/api/test/helpers/local-db.ts index 2961fe3bcd..c7f37bfb6a 100644 --- a/redisinsight/api/test/helpers/local-db.ts +++ b/redisinsight/api/test/helpers/local-db.ts @@ -178,6 +178,7 @@ export const generateNDatabaseAnalysis = async ( expirationGroups: encryptData(JSON.stringify([ constants.TEST_DATABASE_ANALYSIS_EXPIRATION_GROUP_1, ])), + recommendations: encryptData(JSON.stringify([constants.TEST_LUA_DATABASE_ANALYSIS_RECOMMENDATION])), createdAt: new Date(), encryption: constants.TEST_ENCRYPTION_STRATEGY, ...partial, diff --git a/redisinsight/api/yarn.lock b/redisinsight/api/yarn.lock index c2fe6fe3cc..6e2a41e4d9 100644 --- a/redisinsight/api/yarn.lock +++ b/redisinsight/api/yarn.lock @@ -2665,6 +2665,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" 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/icons/dislike.svg b/redisinsight/ui/src/assets/img/icons/dislike.svg new file mode 100644 index 0000000000..9bc0db8362 --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/dislike.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/assets/img/icons/double_like.svg b/redisinsight/ui/src/assets/img/icons/double_like.svg new file mode 100644 index 0000000000..336288eb9e --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/double_like.svg @@ -0,0 +1,4 @@ + + + + diff --git a/redisinsight/ui/src/assets/img/icons/like.svg b/redisinsight/ui/src/assets/img/icons/like.svg new file mode 100644 index 0000000000..4eea43fc0d --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/like.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/assets/img/icons/recommendations_dark.svg b/redisinsight/ui/src/assets/img/icons/recommendations_dark.svg new file mode 100644 index 0000000000..99219b30d5 --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/recommendations_dark.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/icons/recommendations_light.svg b/redisinsight/ui/src/assets/img/icons/recommendations_light.svg new file mode 100644 index 0000000000..23264787c4 --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/recommendations_light.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + 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/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..6131253503 --- /dev/null +++ b/redisinsight/ui/src/components/analytics-tabs/constants.tsx @@ -0,0 +1,48 @@ +import React, { ReactNode } from 'react' +import { useSelector } from 'react-redux' + +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 + label: string | ReactNode +} + +const DatabaseAnalyticsTab = () => { + const { features } = useSelector(appFeatureHighlightingSelector) + const { recommendations: recommendationsHighlighting } = getHighlightingFeatures(features) + + 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/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/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/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json new file mode 100644 index 0000000000..4097052fc1 --- /dev/null +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -0,0 +1,722 @@ +{ + "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": ["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"] + }, + "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"] + }, + "hashHashtableToZiplist": { + "id": "hashHashtableToZiplist", + "title": "Increase hash-max-ziplist-entries", + "content": [ + { + "id": "1", + "type": "span", + "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", + "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"] + }, + "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"] + }, + "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"] + }, + "zSetHashtableToZiplist": { + "id": "zSetHashtableToZiplist", + "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": "4", + "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", + "redisStack": true, + "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": "2", + "type": "span", + "value": "Check if an observation already appeared in the stream" + } + ], + [ + { + "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" + } + ] + ] + }, + { + "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": "7", + "type": "span", + "value": "Bloom filter and cuckoo filter require " + }, + { + "id": "8", + "type": "link", + "value": { + "href": "https://redis.com/modules/redis-bloom/", + "name": "RedisBloom" + } + }, + { + "id": "9", + "type": "span", + "value": ". " + }, + { + "id": "10", + "type": "link", + "value": { + "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"] + }, + "bigAmountOfConnectedClients": { + "id": "bigAmountOfConnectedClients", + "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"] + }, + "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"] + }, + "RTS": { + "id": "RTS", + "title":"Optimize the use of time series", + "redisStack": true, + "content": [ + { + "id": "1", + "type": "span", + "value": "If you are using sorted sets to work with time series data, consider using " + }, + { + "id": "2", + "type": "link", + "value": { + "href": "https://redis.io/docs/stack/timeseries/", + "name": "RedisTimeSeries" + } + }, + { + "id": "3", + "type": "span", + "value": " to optimize the memory usage while having extraordinary query performance and small overhead during ingestion." + }, + { + "id": "4", + "type": "spacer", + "value": "l" + }, + { + "id": "5", + "type": "span", + "value": "Create a " + }, + { + "id": "6", + "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": "7", + "type": "span", + "value": " to use modern data models and processing engines." + } + ], + "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_recommendations/", + "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 query and search experience", + "redisStack": true, + "content": [ + { + "id": "1", + "type": "link", + "value": { + "href": "https://redis.io/docs/stack/search/", + "name": "RediSearch" + } + }, + { + "id": "2", + "type": "span", + "value": " 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 " + }, + { + "id": "3", + "type": "link", + "value": { + "href": "https://redis.io/commands/?name=Ft", + "name": "powerful API options" + } + }, + { + "id": "4", + "type": "span", + "value": " and try them. Supports full-text search, wildcards, fuzzy logic, and more." + }, + { + "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": " which extends the core capabilities of Redis OSS and uses modern data models and processing engines." + } + ], + "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"] + }, + "dangerousCommands": { + "id": "dangerousCommands", + "title": "Rename or disable dangerous commands", + "content": [ + { + "id": "1", + "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": "pre", + "parameter": ["commands"], + "value": "${0} " + }, + { + "id": "3", + "type": "spacer", + "value": "s" + }, + { + "id": "4", + "type": "link", + "value": { + "href": "https://redis.io/download/", + "name": "Read more" + } + } + ], + "badges": ["code_changes"] + } +} 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/constants/links.ts b/redisinsight/ui/src/constants/links.ts index 506be9f976..1e22edb2b2 100644 --- a/redisinsight/ui/src/constants/links.ts +++ b/redisinsight/ui/src/constants/links.ts @@ -3,4 +3,5 @@ export const EXTERNAL_LINKS = { githubIssues: 'https://github.com/RedisInsight/RedisInsight/issues', releaseNotes: 'https://github.com/RedisInsight/RedisInsight/releases', userSurvey: 'https://www.surveymonkey.com/r/redisinsight', + recommendationFeedback: 'https://github.com/RedisInsight/RedisInsight/issues/new/choose', } diff --git a/redisinsight/ui/src/constants/recommendations.ts b/redisinsight/ui/src/constants/recommendations.ts new file mode 100644 index 0000000000..2ed717788b --- /dev/null +++ b/redisinsight/ui/src/constants/recommendations.ts @@ -0,0 +1,5 @@ +export enum Vote { + DoubleLike = 'very useful', + Like = 'useful', + Dislike = 'not useful' +} 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 61cb5a42ed..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 AnalysisDataView from './components/analysis-data-view' +import DatabaseAnalysisTabs from './components/data-nav-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.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 1c6e84cf7f..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 @@ -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,13 @@ import { SummaryPerData, ExpirationGroupsView } from 'uiSrc/pages/databaseAnalysis/components' -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 +42,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) && ( - - )}
({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +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') + }) + }) + + 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 new file mode 100644 index 0000000000..6e41ba5f57 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/DatabaseAnalysisTabs.tsx @@ -0,0 +1,92 @@ +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' +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 { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { ShortDatabaseAnalysis, DatabaseAnalysis } from 'apiSrc/modules/database-analysis/models' + +import { databaseAnalysisTabs } from './constants' +import styles from './styles.module.scss' + +export interface Props { + loading: boolean + reports: ShortDatabaseAnalysis[] + data: Nullable +} + +const DatabaseAnalysisTabs = (props: Props) => { + const { loading, reports, data } = 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)) + } + + const renderTabs = () => ( + databaseAnalysisTabs.map(({ id, name }) => ( + onSelectedTabChanged(id)} + isSelected={id === viewTab} + data-testid={`${id}-tab`} + > + {name(data?.recommendations?.length)} + + ))) + + 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/data-nav-tabs/constants.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx new file mode 100644 index 0000000000..82ea803159 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/constants.tsx @@ -0,0 +1,52 @@ +import React, { ReactNode } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { DatabaseAnalysisViewTab } from 'uiSrc/slices/interfaces/analytics' +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' + +interface DatabaseAnalysisTabs { + id: DatabaseAnalysisViewTab, + name: (count?: number) => string | ReactNode, + content: ReactNode +} + +const RecommendationsTab = ({ count }: { count?: number }) => { + const { features } = useSelector(appFeatureHighlightingSelector) + const { recommendations: recommendationsHighlighting } = getHighlightingFeatures(features) + + const dispatch = useDispatch() + + return ( + dispatch(removeFeatureFromHighlighting('recommendations'))} + dotClassName="tab-highlighting-dot" + wrapperClassName="inner-highlighting-wrapper" + > + {count ? <>Recommendations ({count}) : <>Recommendations} + + ) +} + +export const databaseAnalysisTabs: DatabaseAnalysisTabs[] = [ + { + id: DatabaseAnalysisViewTab.DataSummary, + name: () => 'Data Summary', + content: + }, + { + id: DatabaseAnalysisViewTab.Recommendations, + name: (count) => , + content: + }, +] diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/index.ts b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/index.ts new file mode 100644 index 0000000000..dbfeb488e6 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/index.ts @@ -0,0 +1,3 @@ +import DatabaseAnalysisTabs from './DatabaseAnalysisTabs' + +export default DatabaseAnalysisTabs diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-tabs/styles.module.scss new file mode 100644 index 0000000000..7e288fa094 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/data-nav-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); +} diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/empty-analysis-message/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/empty-analysis-message/styles.module.scss index 4b8e8f238c..4574d35ee9 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/empty-analysis-message/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/empty-analysis-message/styles.module.scss @@ -7,9 +7,8 @@ display: flex; justify-content: center; overflow: auto; - width: 100%; - height: calc(100% - 96px); + height: 100%; } .content { diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/index.ts b/redisinsight/ui/src/pages/databaseAnalysis/components/index.ts index 368b008a47..1221f0ccf1 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/index.ts +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/index.ts @@ -2,6 +2,7 @@ import AnalysisDataView from './analysis-data-view' import ExpirationGroupsView from './analysis-ttl-view' import EmptyAnalysisMessage from './empty-analysis-message' import Header from './header' +import RecommendationVoting from './recommendation-voting' import SummaryPerData from './summary-per-data' import TableLoader from './table-loader' import TopKeys from './top-keys' @@ -12,6 +13,7 @@ export { ExpirationGroupsView, EmptyAnalysisMessage, Header, + RecommendationVoting, SummaryPerData, TableLoader, TopKeys, diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/RecommendationVoting.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/RecommendationVoting.spec.tsx new file mode 100644 index 0000000000..9c479d6175 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/RecommendationVoting.spec.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { instance, mock } from 'ts-mockito' +import { setRecommendationVote } from 'uiSrc/slices/analytics/dbAnalysis' + +import { + cleanup, + mockedStore, + fireEvent, + render, + screen, + waitForEuiPopoverVisible, +} from 'uiSrc/utils/test-utils' + +import RecommendationVoting, { Props } from './RecommendationVoting' + +const mockedProps = mock() + +let store: typeof mockedStore + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})) + +describe('RecommendationVoting', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call "setRecommendationVote" action be called after click "very-useful-vote-btn"', () => { + render() + fireEvent.click(screen.getByTestId('very-useful-vote-btn')) + + const expectedActions = [setRecommendationVote()] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should call "setRecommendationVote" action be called after click "useful-vote-btn"', () => { + render() + fireEvent.click(screen.getByTestId('useful-vote-btn')) + + const expectedActions = [setRecommendationVote()] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should call "setRecommendationVote" action be called after click "not-useful-vote-btn"', () => { + render() + fireEvent.click(screen.getByTestId('not-useful-vote-btn')) + + const expectedActions = [setRecommendationVote()] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should render popover after click "not-useful-vote-btn"', async () => { + render() + + expect(document.querySelector('[data-test-subj="github-repo-link"]')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('not-useful-vote-btn')) + await waitForEuiPopoverVisible() + + expect(document.querySelector('[data-test-subj="github-repo-link"]')).toHaveAttribute('href', 'https://github.com/RedisInsight/RedisInsight/issues/new/choose') + }) + + it('should render component where all buttons are disabled"', async () => { + render() + + expect(screen.getByTestId('very-useful-vote-btn')).toBeDisabled() + expect(screen.getByTestId('useful-vote-btn')).toBeDisabled() + expect(screen.getByTestId('not-useful-vote-btn')).toBeDisabled() + }) +}) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/RecommendationVoting.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/RecommendationVoting.tsx new file mode 100644 index 0000000000..4b46364b70 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/RecommendationVoting.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import cx from 'classnames' +import { + EuiButton, + EuiButtonIcon, + EuiPopover, + EuiText, + EuiToolTip, + EuiFlexGroup, + EuiIcon, + EuiLink, +} from '@elastic/eui' +import { userSettingsConfigSelector } from 'uiSrc/slices/user/user-settings' +import { putRecommendationVote } from 'uiSrc/slices/analytics/dbAnalysis' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { EXTERNAL_LINKS } from 'uiSrc/constants/links' +import { Vote } from 'uiSrc/constants/recommendations' + +import { ReactComponent as LikeIcon } from 'uiSrc/assets/img/icons/like.svg' +import { ReactComponent as DoubleLikeIcon } from 'uiSrc/assets/img/icons/double_like.svg' +import { ReactComponent as DislikeIcon } from 'uiSrc/assets/img/icons/dislike.svg' +import GithubSVG from 'uiSrc/assets/img/sidebar/github.svg' +import styles from './styles.module.scss' + +export interface Props { vote?: Vote, name: string } + +const RecommendationVoting = ({ vote, name }: Props) => { + const config = useSelector(userSettingsConfigSelector) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const dispatch = useDispatch() + + const onSuccessVoted = (instanceId: string, name: string, vote: Vote) => { + sendEventTelemetry({ + event: TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_VOTED, + eventData: { + databaseId: instanceId, + name, + vote, + } + }) + } + + const handleClick = (name: string, vote: Vote) => { + if (vote === Vote.Dislike) { + setIsPopoverOpen(true) + } + dispatch(putRecommendationVote(name, vote, onSuccessVoted)) + } + + const getTooltipContent = (content: string) => (config?.agreements?.analytics + ? content + : 'Enable Analytics on the Settings page to vote for a recommendation') + + return ( + + Rate Recommendation +
+ + handleClick(name, Vote.DoubleLike)} + /> + + + handleClick(name, Vote.Like)} + /> + + + setIsPopoverOpen(false)} + anchorClassName={styles.popoverAnchor} + panelClassName={cx('euiToolTip', 'popoverLikeTooltip', styles.popover)} + button={( + handleClick(name, Vote.Dislike)} + /> + )} + > +
+ Thank you for your feedback, Tell us how we can improve + + + + To Github + + + setIsPopoverOpen(false)} + /> +
+
+
+
+
+ ) +} + +export default RecommendationVoting diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/index.ts b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/index.ts new file mode 100644 index 0000000000..7b77eec7fb --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/index.ts @@ -0,0 +1,3 @@ +import RecommendationVoting from './RecommendationVoting' + +export default RecommendationVoting diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/styles.module.scss new file mode 100644 index 0000000000..b0fc89120f --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/styles.module.scss @@ -0,0 +1,64 @@ +.votingContainer { + padding-top: 15px; + margin-top: 15px !important; + border-top: 1px solid var(--separatorColor); + height: 49px; + + .vote { + margin-left: 10px; + } + + .vote :global(.euiIcon) { + width: 34px; + height: 34px; + fill: none; + + path { + stroke: var(--buttonSecondaryTextColor); + } + } + + .vote .voteBtn { + width: 34px; + height: 34px; + border-radius: 50%; + + &:hover, + &:focus, + &.selected { + transform: none; + background-color: var(--separatorColor); + } + } +} + +:global(.euiPanel).popover { + max-width: none !important; + box-shadow: none !important; + padding: 10px 15px !important; + color: var(--buttonSecondaryTextColor) !important; + + .feedbackBtn { + padding: 4px 8px 4px 4px; + margin: 0 10px; + height: 22px !important; + + :global(.euiButtonContent.euiButton__content) { + padding: 0; + } + + .link { + display: flex; + align-items: center; + color: var(--euiColorPrimaryText) !important; + text-decoration: none !important; + font: normal normal normal 12px/14px Graphik, sans-serif; + } + + .link .githubIcon { + width: 12px; + height: 12px; + margin-right: 2px; + } + } +} 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..e1b73d4188 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx @@ -0,0 +1,406 @@ +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({ + 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() + }) + + 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 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 render code changes badge 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 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 configuration_changes badge in increaseSetMaxIntsetEntries recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'increaseSetMaxIntsetEntries' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).not.toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + expect(screen.queryByTestId('configuration_changes')).toBeInTheDocument() + }) + + it('should render code changes badge in hashHashtableToZiplist recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'hashHashtableToZiplist' }] + } + })) + + 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 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 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 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 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 render code_changes badge in bigAmountOfConnectedClients recommendation', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'bigAmountOfConnectedClients' }] + } + })) + + render() + + expect(screen.queryByTestId('code_changes')).toBeInTheDocument() + expect(screen.queryByTestId('upgrade')).not.toBeInTheDocument() + 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 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 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 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, + data: { + recommendations: [{ name: 'luaScript' }] + } + })) + + const sendEventTelemetryMock = jest.fn() + + sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) + + 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() + 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() + }) + + 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() + }) + + it('should render redisstack link', () => { + (dbAnalysisSelector as jest.Mock).mockImplementation(() => ({ + ...mockdbAnalysisSelector, + data: { + recommendations: [{ name: 'bigSets' }] + } + })) + + render() + + 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 new file mode 100644 index 0000000000..b056b6d446 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -0,0 +1,150 @@ +import React, { useContext } from 'react' +import { useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import { isNull, sortBy } from 'lodash' +import { + EuiAccordion, + EuiPanel, + EuiText, + EuiToolTip, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, +} from '@elastic/eui' +import { ThemeContext } from 'uiSrc/contexts/themeContext' +import { RecommendationVoting } from 'uiSrc/pages/databaseAnalysis/components' +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 NoRecommendationsDark from 'uiSrc/assets/img/icons/recommendations_dark.svg' +import NoRecommendationsLight from 'uiSrc/assets/img/icons/recommendations_light.svg' +import { renderContent, renderBadges, renderBadgesLegend } from './utils' +import styles from './styles.module.scss' + +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({ + event: isOpen + ? TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_EXPANDED + : TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_COLLAPSED, + eventData: { + databaseId: instanceId, + recommendation: id, + } + }) + + 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[], id: string) => ( + + + + {redisStack && ( + + + + + + )} + + + {title} + + + + {renderBadges(badges)} + + + ) + + if (loading) { + return ( +
+ ) + } + + if (isNull(recommendations) || !recommendations.length) { + return ( +
+ + AMAZING JOB! + No Recommendations at the moment, +
+ keep up the good work! +
+ ) + } + + return ( +
+
+ {renderBadgesLegend()} +
+
+ {sortedRecommendations.map(({ name, params, vote }) => { + const { + id = '', + title = '', + content = '', + badges = [], + redisStack = false + } = recommendationsContent[name] + + return ( +
+ handleToggle(isOpen, id)} + data-testid={`${id}-accordion`} + > + + {renderContent(content, params)} + + + +
+ ) + })} +
+
+ ) +} + +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..88a4cf30c8 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -0,0 +1,140 @@ +@import "@elastic/eui/src/global_styling/mixins/helpers"; +@import "@elastic/eui/src/components/table/mixins"; +@import "@elastic/eui/src/global_styling/index"; + +.wrapper { + height: 100%; + + .badgesLegend { + margin: 0 22px 14px 0 !important; + padding-top: 20px; + + .badgeWrapper { + margin-right: 0; + } + + .badge { + margin: 0 0 0 24px; + } + + .badgeIcon { + margin-right: 14px; + } + } + + .badgeIcon { + fill: var(--badgeIconColor); + } + + .badgeWrapper { + display: flex; + align-items: center; + margin-right: 24px; + } +} + +.recommendationsContainer { + @include euiScrollBar; + overflow-y: auto; + overflow-x: hidden; + max-height: calc(100% - 51px); + + .accordionButton :global(.euiFlexItem) { + margin: 0; + + .redisStackLink { + margin-right: 16px; + animation: none !important; + } + + .redisStackIcon { + width: 20px; + height: 20px; + } + } + + :global(.euiAccordion__buttonReverse .euiAccordion__iconWrapper) { + margin-left: 0; + } + + :global(.euiFlexGroup--gutterLarge) { + margin: 0; + } +} + +.container { + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + height: 100%; + padding-bottom: 162px; + + .noRecommendationsIcon { + width: 154px; + height: 127px; + } +} + +.bigText { + font: normal normal 600 18px/22px Graphik, sans-serif !important; + margin: 16px 0 12px; +} + +.loadingWrapper { + width: 100%; + height: 129px; + margin-top: 30px; + background-color: var(--euiColorLightestShade); + border-radius: 4px; +} + +.recommendation { + border-radius: 8px; + border: 1px solid var(--recommendationBorderColor); + background-color: var(--euiColorLightestShade); + margin-bottom: 6px; + padding: 30px 18px 11px; + + ul { + list-style: initial; + padding-left: 21px; + + li::marker { + color: var(--euiTextSubduedColor); + } + } + + .accordionContent { + padding: 18px 0 0 !important; + } + + :global(.euiAccordion__triggerWrapper) { + background-color: transparent; + } + + :global(.euiPanel.euiPanel--subdued) { + background-color: transparent; + } + + :global(.euiAccordion.euiAccordion-isOpen .euiAccordion__triggerWrapper) { + border-bottom: none; + } + + :global(.euiIEFlexWrapFix) { + display: block; + width: 100%; + } + + .accordionBtn { + font: normal normal 500 16px/19px Graphik, sans-serif; + } + + .text { + font: normal normal normal 14px/24px Graphik, sans-serif; + } + + .span { + display: inline; + } +} 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..fad8f5aca2 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/utils.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { isString, isArray } from 'lodash' +import { + EuiTextColor, + EuiToolTip, + EuiFlexGroup, + EuiFlexItem, + 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' +import { ReactComponent as UpgradeIcon } from 'uiSrc/assets/img/upgrade.svg' + +import styles from './styles.module.scss' + +interface IContentElement { + id: string + type: string + value: any[] | any + parameter: string[] +} + +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, name, icon }) => (badges.indexOf(id) > -1 && ( + +
+ + {icon} + +
+
+ )))} +
+) + +export const renderBadgesLegend = () => ( + + {badgesContent.map(({ id, icon, name }) => ( + +
+ {icon} + {name} +
+
+ ))} +
+) + +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} + + ) + case 'pre': + return ( + +
+            {value}
+          
+
+ ) + case 'span': + return {value} + case 'link': + return {value.name} + case 'spacer': + return + case 'list': + return ( +
    + {isArray(jsonValue) && jsonValue.map((listElement: IContentElement[]) => ( +
  • {renderContent(listElement, params)}
  • + ))} +
+ ) + default: + return value + } +} + +export const renderContent = (elements: IContentElement[], params: any) => ( + elements?.map((item) => renderContentElement(item, params))) 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 ca7520b5b2..0d7021811a 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() 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/slices/analytics/dbAnalysis.ts b/redisinsight/ui/src/slices/analytics/dbAnalysis.ts index 773983dd78..b569213bc9 100644 --- a/redisinsight/ui/src/slices/analytics/dbAnalysis.ts +++ b/redisinsight/ui/src/slices/analytics/dbAnalysis.ts @@ -1,9 +1,10 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AxiosError } from 'axios' import { ApiEndpoints } from 'uiSrc/constants' +import { Vote } from 'uiSrc/constants/recommendations' import { apiService, } from 'uiSrc/services' import { addErrorNotification } from 'uiSrc/slices/app/notifications' -import { StateDatabaseAnalysis } from 'uiSrc/slices/interfaces/analytics' +import { StateDatabaseAnalysis, DatabaseAnalysisViewTab } from 'uiSrc/slices/interfaces/analytics' import { getApiErrorMessage, getUrl, isStatusSuccessful } from 'uiSrc/utils' import { DatabaseAnalysis, ShortDatabaseAnalysis } from 'apiSrc/modules/database-analysis/models' @@ -13,6 +14,7 @@ export const initialState: StateDatabaseAnalysis = { loading: false, error: '', data: null, + selectedViewTab: DatabaseAnalysisViewTab.DataSummary, history: { loading: false, error: '', @@ -38,6 +40,15 @@ const databaseAnalysisSlice = createSlice({ state.loading = false state.error = payload }, + setRecommendationVote: () => { + // we don't have any loading here + }, + setRecommendationVoteSuccess: (state, { payload }: PayloadAction) => { + state.data = payload + }, + setRecommendationVoteError: (state, { payload }) => { + state.error = payload + }, loadDBAnalysisReports: (state) => { state.history.loading = true }, @@ -55,11 +66,15 @@ const databaseAnalysisSlice = createSlice({ setShowNoExpiryGroup: (state, { payload }: PayloadAction) => { 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 +86,10 @@ export const { loadDBAnalysisReportsError, setSelectedAnalysisId, setShowNoExpiryGroup, + setDatabaseAnalysisViewTab, + setRecommendationVote, + setRecommendationVoteSuccess, + setRecommendationVoteError, } = databaseAnalysisSlice.actions // The reducer @@ -110,6 +129,43 @@ export function fetchDBAnalysisAction( } } +// Asynchronous thunk action +export function putRecommendationVote( + recommendationName: string, + vote: Vote, + onSuccessAction?: (instanceId: string, name: string, vote: Vote) => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + try { + dispatch(setRecommendationVote()) + const state = stateInit() + const instanceId = state.connections.instances.connectedInstance?.id + + const { data, status } = await apiService.patch( + getUrl( + instanceId, + ApiEndpoints.DATABASE_ANALYSIS, + state.analytics.databaseAnalysis.history.selectedAnalysis ?? '', + ), + { name: recommendationName, vote }, + ) + + if (isStatusSuccessful(status)) { + dispatch(setRecommendationVoteSuccess(data)) + + onSuccessAction?.(instanceId, recommendationName, vote) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(setRecommendationVoteError(errorMessage)) + onFailAction?.() + } + } +} + export function fetchDBAnalysisReportsHistory( instanceId: string, onSuccessAction?: (data: ShortDatabaseAnalysis[]) => void, 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/slices/interfaces/analytics.ts b/redisinsight/ui/src/slices/interfaces/analytics.ts index 5018ad11b9..b75f12b73d 100644 --- a/redisinsight/ui/src/slices/interfaces/analytics.ts +++ b/redisinsight/ui/src/slices/interfaces/analytics.ts @@ -21,6 +21,7 @@ export interface StateDatabaseAnalysis { loading: boolean error: string data: Nullable + selectedViewTab: DatabaseAnalysisViewTab history: { loading: boolean error: string @@ -39,3 +40,8 @@ export enum AnalyticsViewTab { DatabaseAnalysis = 'DatabaseAnalysis', SlowLog = 'SlowLog', } + +export enum DatabaseAnalysisViewTab { + DataSummary = 'DataSummary', + Recommendations = 'Recommendations', +} diff --git a/redisinsight/ui/src/slices/tests/analytics/dbAnalysis.spec.ts b/redisinsight/ui/src/slices/tests/analytics/dbAnalysis.spec.ts index 122f66975c..0ea90411cb 100644 --- a/redisinsight/ui/src/slices/tests/analytics/dbAnalysis.spec.ts +++ b/redisinsight/ui/src/slices/tests/analytics/dbAnalysis.spec.ts @@ -18,7 +18,12 @@ import reducer, { dbAnalysisReportsSelector, dbAnalysisSelector, setShowNoExpiryGroup, + setRecommendationVote, + setRecommendationVoteSuccess, + setRecommendationVoteError, + putRecommendationVote, } from 'uiSrc/slices/analytics/dbAnalysis' +import { Vote } from 'uiSrc/constants/recommendations' import { cleanup, initialStateDefault, mockedStore } from 'uiSrc/utils/test-utils' let store: typeof mockedStore @@ -159,6 +164,26 @@ describe('db analysis slice', () => { expect(dbAnalysisSelector(rootState)).toEqual(state) }) }) + describe('setRecommendationVoteError', () => { + it('should properly set error', () => { + // Arrange + const error = 'Some error' + const state = { + ...initialState, + error, + loading: false, + } + + // Act + const nextState = reducer(initialState, setRecommendationVoteError(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + analytics: { databaseAnalysis: nextState }, + }) + expect(dbAnalysisSelector(rootState)).toEqual(state) + }) + }) describe('getDBAnalysis', () => { it('should properly set loading: true', () => { // Arrange @@ -218,6 +243,26 @@ describe('db analysis slice', () => { expect(dbAnalysisSelector(rootState)).toEqual(state) }) }) + describe('setRecommendationVoteSuccess', () => { + it('should properly set data', () => { + const payload = mockAnalysis + // Arrange + const state = { + ...initialState, + loading: false, + data: mockAnalysis + } + + // Act + const nextState = reducer(initialState, setRecommendationVoteSuccess(payload)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + analytics: { databaseAnalysis: nextState }, + }) + expect(dbAnalysisSelector(rootState)).toEqual(state) + }) + }) describe('loadDBAnalysisReportsSuccess', () => { it('should properly set data to history', () => { const payload = [mockHistoryReport] @@ -408,6 +453,53 @@ describe('db analysis slice', () => { loadDBAnalysisReportsError(errorMessage) ] + expect(store.getActions()).toEqual(expectedActions) + }) + }) + describe('putRecommendationVote', () => { + it('succeed to put recommendation vote', async () => { + const data = mockAnalysis + const responsePayload = { data, status: 200 } + + apiService.patch = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch( + putRecommendationVote('name', Vote.Like) + ) + + // Assert + const expectedActions = [ + setRecommendationVote(), + setRecommendationVoteSuccess(data), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to put recommendation vote', async () => { + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.patch = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch( + putRecommendationVote('name', Vote.Like) + ) + + // Assert + const expectedActions = [ + setRecommendationVote(), + addErrorNotification(responsePayload as AxiosError), + setRecommendationVoteError(errorMessage) + ] + expect(store.getActions()).toEqual(expectedActions) }) }) diff --git a/redisinsight/ui/src/styles/components/_tabs.scss b/redisinsight/ui/src/styles/components/_tabs.scss index b03e7f503e..166250e16e 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,25 @@ font-size: 13px !important; line-height: 18px !important; font-weight: 500 !important; + padding: 8px 12px; } } + .inner-highlighting-wrapper { + margin: -8px -12px; + padding: 8px 12px; + + .tab-highlighting-dot { + top: 2px; + right: 2px; + } + } + + .tab-highlighting-dot { + top: -6px; + right: -12px; + } + .euiTab + .euiTab { margin-left: 0 !important; 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 8d9e591ad0..2819808f43 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 @@ -182,4 +182,8 @@ // Pub/Sub --pubSubClientsBadge: #{$pubSubClientsBadge}; + + // 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 1f05ce4f70..e42473ee00 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -142,3 +142,7 @@ $wbActiveIconColor: #8ba2ff; // PubSub $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 e93b8c2fb6..ccc938f991 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 @@ -184,4 +184,8 @@ // Pub/Sub --pubSubClientsBadge: #{$pubSubClientsBadge}; + + // 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 7f7533452d..20c09ec571 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -139,3 +139,7 @@ $wbActiveIconColor: #3163D8; // Pub/Sub $pubSubClientsBadge: #b5cea8; + +// Database analysis +$badgeIconColor : #415681; +$recommendationBorderColor: #3953c3; diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index 345dcb5471..35acaeffdb 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -72,7 +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', + BROWSER_DATABASE_INDEX_CHANGED = 'BROWSER_DATABASE_INDEX_CHANGED', CLI_OPENED = 'CLI_OPENED', CLI_CLOSED = 'CLI_CLOSED', @@ -190,6 +190,11 @@ 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', + DATABASE_ANALYSIS_RECOMMENDATIONS_VOTED = 'DATABASE_ANALYSIS_RECOMMENDATIONS_VOTED', USER_SURVEY_LINK_CLICKED = 'USER_SURVEY_LINK_CLICKED', 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 }) + }) +}) diff --git a/tests/e2e/common-actions/cli-actions.ts b/tests/e2e/common-actions/cli-actions.ts index 6bbf73bc7f..e882782028 100644 --- a/tests/e2e/common-actions/cli-actions.ts +++ b/tests/e2e/common-actions/cli-actions.ts @@ -1,7 +1,9 @@ import { t } from 'testcafe'; +import { Common } from '../helpers/common'; import { CliPage } from '../pageObjects'; const cliPage = new CliPage(); +const common = new Common(); export class CliActions { /** @@ -9,7 +11,7 @@ export class CliActions { * @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; @@ -29,4 +31,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/common-actions/memory-efficiency-actions.ts b/tests/e2e/common-actions/memory-efficiency-actions.ts new file mode 100644 index 0000000000..edb67920ee --- /dev/null +++ b/tests/e2e/common-actions/memory-efficiency-actions.ts @@ -0,0 +1,36 @@ +import {t} from 'testcafe'; +import { MemoryEfficiencyPage } from '../pageObjects'; + +const memoryEfficiencyPage = new MemoryEfficiencyPage(); +export class MemoryEfficiencyActions { + /* + vote for very useful and verify others are disabled + */ + async voteForVeryUsefulAndVerifyDisabled(): Promise { + await t.click(memoryEfficiencyPage.veryUsefulVoteBtn); + await this.verifyVoteDisabled(); + } + /* + vote for useful and verify others are disabled + */ + async voteForUsefulAndVerifyDisabled(): Promise { + await t.click(memoryEfficiencyPage.usefulVoteBtn); + await this.verifyVoteDisabled(); + } + /* + vote for not useful and verify others are disabled + */ + async voteForNotUsefulAndVerifyDisabled(): Promise { + await t.click(memoryEfficiencyPage.notUsefulVoteBtn); + await this.verifyVoteDisabled(); + } + async verifyVoteDisabled(): Promise{ + // Verify that user can rate recommendations with one of 3 existing types at the same time + await t.expect(memoryEfficiencyPage.veryUsefulVoteBtn + .hasAttribute('disabled')).ok('very useful vote button is not disabled'); + await t.expect(memoryEfficiencyPage.usefulVoteBtn + .hasAttribute('disabled')).ok('useful vote button is not disabled'); + await t.expect(memoryEfficiencyPage.notUsefulVoteBtn + .hasAttribute('disabled')).ok('not useful vote button is not disabled'); + } +} diff --git a/tests/e2e/helpers/keys.ts b/tests/e2e/helpers/keys.ts index 65f9d94563..cbce7b2211 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 845f0388b5..6a70527dbf 100644 --- a/tests/e2e/pageObjects/memory-efficiency-page.ts +++ b/tests/e2e/pageObjects/memory-efficiency-page.ts @@ -1,4 +1,4 @@ -import { Selector } from 'testcafe'; +import {Selector} from 'testcafe'; export class MemoryEfficiencyPage { //------------------------------------------------------------------------------------------- @@ -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-]'); @@ -15,6 +18,9 @@ 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]'); + useSmallKeysButton = Selector('[data-test-subj=useSmallerKeys-button]'); // ICONS reportTooltipIcon = Selector('[data-testid=db-new-reports-icon]'); // TEXT ELEMENTS @@ -26,6 +32,9 @@ 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]'); + codeChangesLabel = Selector('[data-testid=code_changes]'); + configurationChangesLabel = Selector('[data-testid=configuration_changes]'); topKeysKeySizeCell = Selector('[data-testid^=nsp-usedMemory-value]'); topKeysLengthCell = Selector('[data-testid^=length-value]'); // TABLE @@ -44,4 +53,19 @@ 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'); + 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]'); + convertHashToZipAccordion = Selector('[data-testid=convertHashtableToZiplist-accordion]'); + compressHashAccordion = Selector('[data-testid=compressHashFieldNames-accordion]'); + veryUsefulVoteBtn = Selector('[data-testid=very-useful-vote-btn]').nth(0); + usefulVoteBtn = Selector('[data-testid=useful-vote-btn]').nth(0); + notUsefulVoteBtn = Selector('[data-testid=not-useful-vote-btn]').nth(0); + recommendationsFeedbackBtn = Selector('[data-testid=recommendation-feedback-btn]'); } 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..4b010ed580 --- /dev/null +++ b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts @@ -0,0 +1,147 @@ +import { MyRedisDatabasePage, MemoryEfficiencyPage, BrowserPage, CliPage, AddRedisDatabasePage } from '../../../pageObjects'; +import { rte } from '../../../helpers/constants'; +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'; +import { MemoryEfficiencyActions } from '../../../common-actions/memory-efficiency-actions'; +import { Common } from '../../../helpers/common'; + +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 addRedisDatabasePage = new AddRedisDatabasePage(); +const memoryEfficiencyActions = new MemoryEfficiencyActions(); + +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'; + +fixture `Memory Efficiency Recommendations` + .meta({ type: 'critical_path', rte: rte.standalone }) + .page(commonUrl) + .beforeEach(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + // Go to Analysis Tools page + await t.click(myRedisDatabasePage.analysisPageButton); + }) + .afterEach(async t => { + // Clear and delete database + await t.click(myRedisDatabasePage.browserButton); + await browserPage.deleteKeyByName(keyName); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + }); +test + .before(async t => { + 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); + })('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 expandedTextContaiterSize = await memoryEfficiencyPage.luaScriptTextContainer.offsetHeight; + await t.click(memoryEfficiencyPage.luaScriptButton); + 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(expandedTextContaiterSize, 'Lua script 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('No recommendations message', async t => { + keyName = `recomKey-${common.generateWord(10)}`; + const noRecommendationsMessage = 'No Recommendations at the moment.'; + 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'); +}); +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'); + }) + .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 recommendation', async t => { + // 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 + .before(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + // Go to Analysis Tools page and create new report and open recommendations + await t.click(myRedisDatabasePage.analysisPageButton); + await t.click(memoryEfficiencyPage.newReportBtn); + await t.click(memoryEfficiencyPage.recommendationsTab); + }).after(async() => { + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('Verify that user can upvote recommendations', async t => { + await memoryEfficiencyActions.voteForVeryUsefulAndVerifyDisabled(); + // Verify that user can see previous votes when reload the page + await common.reloadPage(); + await t.click(memoryEfficiencyPage.recommendationsTab); + await memoryEfficiencyActions.verifyVoteDisabled(); + + await t.click(memoryEfficiencyPage.newReportBtn); + await memoryEfficiencyActions.voteForUsefulAndVerifyDisabled(); + + await t.click(memoryEfficiencyPage.newReportBtn); + await memoryEfficiencyActions.voteForNotUsefulAndVerifyDisabled(); + // Verify that user can see the popup with link when he votes for “Not useful” + await t.expect(memoryEfficiencyPage.recommendationsFeedbackBtn.visible).ok('popup did not appear after voting for not useful'); + });